[
  {
    "path": ".editorconfig",
    "content": "[*.sh]\nindent_style = space\nindent_size = 4\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: mfontanini\n"
  },
  {
    "path": ".github/workflows/docs.yaml",
    "content": "name: Deploy docs\n\non:\n  push:\n    branches:\n      - master\n\npermissions:\n  contents: write\n\njobs:\n  build-and-deploy:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n\n      - name: Install cargo-binstall\n        uses: cargo-bins/cargo-binstall@v1.10.22\n\n      - name: Install mdbook\n        run: |\n          cargo binstall -y mdbook@0.4.44 mdbook-alerts@0.7.0\n\n      - name: Build the book\n        run: |\n          cd docs\n          mdbook build\n\n      - name: Deploy build to gh-pages branch\n        uses: crazy-max/ghaction-github-pages@v4\n        with:\n          target_branch: gh-pages\n          build_dir: docs/book \n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/merge.yaml",
    "content": "on:\n  pull_request:\n  push:\n    branches:\n      - master\n\nname: Merge checks\n\njobs:\n  check:\n    name: Checks\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout sources\n        uses: actions/checkout@v4\n\n      - name: Install rust toolchain\n        uses: dtolnay/rust-toolchain@1.90.0\n        with:\n          components: clippy\n\n      - name: Run cargo check\n        run: cargo check\n\n      - name: Run cargo test\n        run: cargo test\n\n      - name: Run cargo clippy\n        run: cargo clippy -- -D warnings\n\n      - name: Install nightly toolchain\n        uses: dtolnay/rust-toolchain@nightly\n        with:\n          components: rustfmt\n\n      - name: Run cargo fmt\n        run: cargo +nightly fmt --all -- --check\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v5\n\n      - name: Install weasyprint\n        run: |\n          uv venv\n          source ./.venv/bin/activate\n          uv pip install weasyprint\n\n      - name: Export demo presentation as PDF and HTML\n        run: |\n          cat >/tmp/config.yaml <<EOL\n          export:\n            dimensions:\n              rows: 35\n              columns: 135\n          EOL\n          source ./.venv/bin/activate\n          cargo run -- --export-pdf -c /tmp/config.yaml examples/demo.md\n          cargo run -- --export-html -c /tmp/config.yaml examples/demo.md\n\n  nix-flake:\n    name: Validate nix flake\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Install nix\n        uses: DeterminateSystems/nix-installer-action@v17\n      - name: Build\n        run: nix build .#dev.presenterm\n\n  bat-assets:\n    name: Validate bat assets\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout sources\n        uses: actions/checkout@v2\n      - name: Validate assets\n        run: ./bat/verify.sh\n\n  json-schemas:\n    name: Validate JSON schemas\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout sources\n        uses: actions/checkout@v2\n      - name: Validate config file schema\n        run: |\n          if ! ./scripts/validate-config-file-schema.sh; then\n            echo -e \"\\033[31;1mRun ./scripts/generate-config-file-schema.sh to regenerate JSON schema\"\n            exit 1\n          fi\n"
  },
  {
    "path": ".github/workflows/nightly.yaml",
    "content": "name: Nightly build\n\non:\n  schedule:\n    - cron: \"0 0 * * *\"\n\nenv:\n  RELEASE_VERSION: nightly\n\njobs:\n  vars:\n    name: Set release variables\n    runs-on: ubuntu-latest\n    outputs:\n      timestamp: ${{ steps.set.outputs.timestamp }}\n      git_hash: ${{ steps.set.outputs.git_hash }}\n      latest_nightly_hash: ${{ steps.set.outputs.latest_nightly_hash }}\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v4\n\n      - name: Set variables\n        id: set\n        shell: bash\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          set -euo pipefail\n          timestamp=$(date -u)\n          git_hash=$(git rev-parse HEAD)\n          latest_nightly_hash=$(gh release view nightly | sed -n 's/.*based on ref \\[\\(.*\\)\\].*/\\1/p')\n          echo \"timestamp=$timestamp\" >> \"$GITHUB_OUTPUT\"\n          echo \"git_hash=$git_hash\" >> \"$GITHUB_OUTPUT\"\n          echo \"latest_nightly_hash=$latest_nightly_hash\" >> \"$GITHUB_OUTPUT\"\n\n  publish-github:\n    name: Publish on GitHub\n    runs-on: ${{ matrix.config.OS }}\n    needs: vars\n    # Don't run this if the nightly hash already points to the current hash\n    if: needs.vars.outputs.git_hash != needs.vars.outputs.latest_nightly_hash\n    strategy:\n      fail-fast: false\n      matrix:\n        config:\n          - { OS: ubuntu-latest, TARGET: \"x86_64-unknown-linux-gnu\" }\n          - { OS: ubuntu-latest, TARGET: \"x86_64-unknown-linux-musl\" }\n          - { OS: ubuntu-latest, TARGET: \"i686-unknown-linux-gnu\" }\n          - { OS: ubuntu-latest, TARGET: \"i686-unknown-linux-musl\" }\n          - { OS: ubuntu-latest, TARGET: \"armv5te-unknown-linux-gnueabi\" }\n          - { OS: ubuntu-latest, TARGET: \"armv7-unknown-linux-gnueabihf\" }\n          - { OS: ubuntu-latest, TARGET: \"aarch64-unknown-linux-gnu\" }\n          - { OS: ubuntu-latest, TARGET: \"aarch64-unknown-linux-musl\" }\n          - { OS: macos-latest, TARGET: \"x86_64-apple-darwin\" }\n          - { OS: macos-latest, TARGET: \"aarch64-apple-darwin\" }\n          - { OS: windows-latest, TARGET: \"x86_64-pc-windows-msvc\" }\n          - { OS: windows-latest, TARGET: \"i686-pc-windows-msvc\" }\n\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v4\n\n      - name: Build binary\n        uses: houseabsolute/actions-rust-cross@a448c4b13769d56b63b035024fef8577e1d81915\n        with:\n          command: build\n          toolchain: 1.90.0\n          target: ${{ matrix.config.TARGET }}\n          args: \"--locked --release\"\n\n      - name: Prepare release assets\n        shell: bash\n        run: |\n          mkdir release/\n          cp {LICENSE,README.md} release/\n          cp target/${{ matrix.config.TARGET }}/release/presenterm release/\n          mv release/ presenterm-${{ env.RELEASE_VERSION }}/\n      - name: Create release artifacts\n        shell: bash\n        run: |\n          if [ \"${{ matrix.config.OS }}\" = \"windows-latest\" ]; then\n            7z a -tzip \"presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip\" \\\n              presenterm-${{ env.RELEASE_VERSION }}\n            sha512sum \"presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip\" \\\n              > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512\n          else\n            tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \\\n              presenterm-${{ env.RELEASE_VERSION }}/\n            shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \\\n              > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512\n          fi\n      - name: Upload the release\n        uses: svenstaro/upload-release-action@e2a63377780a8bacc68dcac9b0979ee20ad5a791\n        with:\n          repo_token: ${{ secrets.GITHUB_TOKEN }}\n          tag: nightly\n          file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.*\n          file_glob: true\n          overwrite: true\n          prerelease: true\n          release_name: Nightly\n          body: |\n            This is a nightly build based on ref [${{ needs.vars.outputs.git_hash }}](https://github.com/mfontanini/presenterm/commit/${{ needs.vars.outputs.git_hash }})\n            Generated on `${{ needs.vars.outputs.timestamp }}`\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*.*.*\"\n\njobs:\n  changelog:\n    name: Parse changelog\n    runs-on: ubuntu-latest\n    outputs:\n      notes: ${{ steps.parse.outputs.notes }}\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v3\n\n      - name: Parse release notes\n        id: parse\n        shell: bash\n        run: |\n          release_version=v${GITHUB_REF:11}\n          r=$(./scripts/parse-changelog.sh \"${release_version}\")\n          r=\"${r//'%'/'%25'}\"\n          r=\"${r//$'\\n'/'%0A'}\"\n          r=\"${r//$'\\r'/'%0D'}\"\n          echo \"notes=$r\" >> \"$GITHUB_OUTPUT\"\n\n  publish-github:\n    name: Publish on GitHub\n    runs-on: ${{ matrix.config.OS }}\n    needs: changelog\n    strategy:\n      fail-fast: false\n      matrix:\n        config:\n          - { OS: ubuntu-latest, TARGET: \"x86_64-unknown-linux-gnu\" }\n          - { OS: ubuntu-latest, TARGET: \"x86_64-unknown-linux-musl\" }\n          - { OS: ubuntu-latest, TARGET: \"i686-unknown-linux-gnu\" }\n          - { OS: ubuntu-latest, TARGET: \"i686-unknown-linux-musl\" }\n          - { OS: ubuntu-latest, TARGET: \"armv5te-unknown-linux-gnueabi\" }\n          - { OS: ubuntu-latest, TARGET: \"armv7-unknown-linux-gnueabihf\" }\n          - { OS: ubuntu-latest, TARGET: \"aarch64-unknown-linux-gnu\" }\n          - { OS: ubuntu-latest, TARGET: \"aarch64-unknown-linux-musl\" }\n          - { OS: macos-latest, TARGET: \"x86_64-apple-darwin\" }\n          - { OS: macos-latest, TARGET: \"aarch64-apple-darwin\" }\n          - { OS: windows-latest, TARGET: \"x86_64-pc-windows-msvc\" }\n          - { OS: windows-latest, TARGET: \"i686-pc-windows-msvc\" }\n\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v4\n\n      - name: Set the release version\n        shell: bash\n        run: echo \"RELEASE_VERSION=${GITHUB_REF:11}\" >> $GITHUB_ENV\n\n      - name: Build binary\n        uses: houseabsolute/actions-rust-cross@a448c4b13769d56b63b035024fef8577e1d81915\n        with:\n          command: build\n          toolchain: 1.90.0\n          target: ${{ matrix.config.TARGET }}\n          args: \"--locked --release\"\n\n      - name: Prepare release assets\n        shell: bash\n        run: |\n          mkdir release/\n          cp {LICENSE,README.md} release/\n          cp target/${{ matrix.config.TARGET }}/release/presenterm release/\n          mv release/ presenterm-${{ env.RELEASE_VERSION }}/\n\n      - name: Create release artifacts\n        shell: bash\n        run: |\n          if [ \"${{ matrix.config.OS }}\" = \"windows-latest\" ]; then\n            7z a -tzip \"presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip\" \\\n              presenterm-${{ env.RELEASE_VERSION }}\n            sha512sum \"presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip\" \\\n              > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512\n          else\n            tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \\\n              presenterm-${{ env.RELEASE_VERSION }}/\n            shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \\\n              > presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512\n          fi\n\n      - name: Upload the release\n        uses: svenstaro/upload-release-action@e2a63377780a8bacc68dcac9b0979ee20ad5a791\n        with:\n          repo_token: ${{ secrets.GITHUB_TOKEN }}\n          file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.*\n          file_glob: true\n          overwrite: true\n          release_name: v${{ env.RELEASE_VERSION }}\n          tag: ${{ github.ref }}\n          body: |\n            ${{ needs.changelog.outputs.notes }}\n\n  publish-crates-io:\n    name: Publish on crates.io\n    needs: publish-github\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v4\n\n      - name: Install rust toolchain\n        uses: dtolnay/rust-toolchain@1.90.0\n\n      - name: Publish\n        run: cargo publish --locked --token ${{ secrets.CARGO_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# v0.16.1 - 2026-02-19\n\n## New features\n\n* Allow `italic` to be used as well as `italics` in theme ([#847](https://github.com/mfontanini/presenterm/issues/847)).\n\n## Fixes\n\n* Render modals at the center of the screen ([#848](https://github.com/mfontanini/presenterm/issues/848)).\n\n## Docs\n\n* Add styling docs to `slide_title` ([#845](https://github.com/mfontanini/presenterm/issues/845)) - thanks @0atman.\n* Describe slide titles and headings better ([#846](https://github.com/mfontanini/presenterm/issues/846)).\n\n# v0.16.0 - 2026-02-15\n\n## Breaking changes\n\n* 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)).\n\n## New features\n\n* 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)).\n* 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.\n* 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)).\n* Allow passing in a [mermaid config file](https://mfontanini.github.io/presenterm/features/commands.html#mermaid) ([#833](https://github.com/mfontanini/presenterm/issues/833)).\n* 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)).\n* 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)).\n* 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)).\n* Update bat themes/syntaxes to latest to support a few new themes ([#811](https://github.com/mfontanini/presenterm/issues/811)).\n* Add tokyonight moon/day/night themes ([#751](https://github.com/mfontanini/presenterm/issues/751)) - thanks @cloudlena.\n* Allow configuring a global alignment in theme ([#801](https://github.com/mfontanini/presenterm/issues/801)).\n* Add dynamic theme option (light/dark) based on terminal color ([#778](https://github.com/mfontanini/presenterm/issues/778)) - thanks @JOTSR.\n* Add common es executors and support jsx and ts(x) snippets ([#783](https://github.com/mfontanini/presenterm/issues/783)) - thanks @JOTSR.\n* Allow configuring code block line numbers at theme level ([#771](https://github.com/mfontanini/presenterm/issues/771)).\n* Allow setting prefix on slide titles ([#739](https://github.com/mfontanini/presenterm/issues/739)).\n* 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)).\n* Allow styling bold/italics ([#737](https://github.com/mfontanini/presenterm/issues/737)).\n* Respect `pause` in speaker notes ([#735](https://github.com/mfontanini/presenterm/issues/735)).\n* Add `--list-comment-commands` cli option ([#723](https://github.com/mfontanini/presenterm/issues/723)) - thanks @rochacbruno.\n* Allow setting headings to be bold/italics/underlined ([#721](https://github.com/mfontanini/presenterm/issues/721)).\n* Add `typescript-react/tsx` highlighting ([#777](https://github.com/mfontanini/presenterm/issues/777)) - thanks @JOTSR.\n* Add dart code highlighting #779 ([#780](https://github.com/mfontanini/presenterm/issues/780)) - thanks @alycda.\n* Add ms windows executors and highlight (`cmd`, `wsl`, `bat`, `pwsh`) ([#799](https://github.com/mfontanini/presenterm/issues/799)) - thanks @JOTSR.\n* Add Elixir to executors ([#709](https://github.com/mfontanini/presenterm/issues/709)) - thanks @kevinschweikert.\n* Support gdscript syntax highlighting ([#820](https://github.com/mfontanini/presenterm/issues/820)) - thanks @TitanNano.\n\n## Fixes\n\n* Use right size for footer images ([#840](https://github.com/mfontanini/presenterm/issues/840)).\n* Don't crash when exporting `+image` snippets ([#827](https://github.com/mfontanini/presenterm/issues/827)).\n* Clippy useless conversion ([#805](https://github.com/mfontanini/presenterm/issues/805)) - thanks @JOTSR.\n* Preserve footnote definition location ([#803](https://github.com/mfontanini/presenterm/issues/803)).\n* Respect global alignment for lists ([#802](https://github.com/mfontanini/presenterm/issues/802)).\n* Don't crash sending event if presentation is in error state ([#800](https://github.com/mfontanini/presenterm/issues/800)).\n* Highlight php code even if it doesn't start with \"<?php\" ([#796](https://github.com/mfontanini/presenterm/issues/796)).\n* Handle dark/light colors properly when converting from 8bt ([#793](https://github.com/mfontanini/presenterm/issues/793)).\n* Use legible color in tokyonight-day's block quote/alert style ([#792](https://github.com/mfontanini/presenterm/issues/792)).\n* Allow column layouts in included files ([#776](https://github.com/mfontanini/presenterm/issues/776)).\n* Use `show_pauses` in sample config ([#745](https://github.com/mfontanini/presenterm/issues/745)).\n* Don't consider prefix part of the title ([#740](https://github.com/mfontanini/presenterm/issues/740)).\n* Keep state between pauses on pause-new-slide ([#731](https://github.com/mfontanini/presenterm/issues/731)).\n* Don't add extra heading lines depending on font size ([#719](https://github.com/mfontanini/presenterm/issues/719)).\n* Consider color range 0x08.. - 0x0f.. in bat themes ([#706](https://github.com/mfontanini/presenterm/issues/706)).\n\n## Chore\n\n* Increase async render polling speed ([#806](https://github.com/mfontanini/presenterm/issues/806)).\n* Add script to generate config file json schema ([#791](https://github.com/mfontanini/presenterm/issues/791)).\n* Restructure snippet execution attributes ([#787](https://github.com/mfontanini/presenterm/issues/787)).\n* Bump dependencies ([#772](https://github.com/mfontanini/presenterm/issues/772)).\n* Unify text styling based on theme ([#734](https://github.com/mfontanini/presenterm/issues/734)).\n\n## Docs\n\n* Fix typo in config theme section ([#804](https://github.com/mfontanini/presenterm/issues/804)) - thanks @JOTSR.\n* Fix typo in code execution docs ([#782](https://github.com/mfontanini/presenterm/issues/782)) - thanks @gcavelier.\n* Sync supported terminals ([#722](https://github.com/mfontanini/presenterm/issues/722)) - thanks @gcavelier.\n* Document overflow validation ([#712](https://github.com/mfontanini/presenterm/issues/712)).\n* Add new sample presentation about ratatui on embedded ([#817](https://github.com/mfontanini/presenterm/issues/817)) - thanks @Vaishnav-Sabari-Girish.\n* Add new sample presentation about Hayasen library ([#813](https://github.com/mfontanini/presenterm/issues/813)) - thanks @Vaishnav-Sabari-Girish.\n\n## ❤️ Sponsors\n\nThanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release:\n\n* [@0atman](https://github.com/0atman)\n* [@orhun](https://github.com/orhun)\n* [@gwpl](https://github.com/gwpl)\n* [@ADS-Fund](https://github.com/ADS-Fund)\n* [@jonas-grobe](https://github.com/jonas-grobe)\n* [@sidju](https://github.com/sidju)\n* [@alycda](https://github.com/alycda)\n\n# v0.15.1 - 2025-08-01\n\n## Fixes\n\n* Disable OSC 11 when running in tmux ([#696](https://github.com/mfontanini/presenterm/issues/696)).\n* Follow custom theme symlinks ([#692](https://github.com/mfontanini/presenterm/issues/692)).\n\n# v0.15.0 - 2025-07-13\n\n## Breaking changes\n\n* The behavior for \"jump next fast\" and \"jump previous fast\" keybindings (defaults to `n` and `p`) now jumps straight from one slide to the next/previous one ignoring pauses. Before this used to \"reveal\" all pauses when jumping forward before going to the next slide. This behavior was weird and unintuitive so now fast jumps go straight into the next/previous slides. The action of \"showing all pauses on the current slide\" can now be done by pressing `s` ([#678](https://github.com/mfontanini/presenterm/issues/678)).\n\n## New features\n\n* Allow specifying where a [snippet's execution output will go](https://mfontanini.github.io/presenterm/features/code/execution.html#output-placing) ([#658](https://github.com/mfontanini/presenterm/issues/658)).\n* Add `include` comment command to [import markdown files](https://mfontanini.github.io/presenterm/features/commands.html#including-external-markdown-files) ([#651](https://github.com/mfontanini/presenterm/issues/651)) ([#683](https://github.com/mfontanini/presenterm/issues/683)).\n* Allow [validating snippets without explicitly executing them](https://mfontanini.github.io/presenterm/features/code/execution.html#validating-snippets) by using `--validate-snippets` switch ([#645](https://github.com/mfontanini/presenterm/issues/645)) ([#637](https://github.com/mfontanini/presenterm/issues/637)).\n* Support iterm2 image protocol when running in tmux ([#661](https://github.com/mfontanini/presenterm/issues/661)).\n* Add support for [d2 diagrams](https://mfontanini.github.io/presenterm/features/code/d2.html) ([#657](https://github.com/mfontanini/presenterm/issues/657)).\n* Errors encountered when parsing markdown now always display the file, line, and column where the error was found, as well as the markdown line that caused the error ([#674](https://github.com/mfontanini/presenterm/issues/674)) ([#653](https://github.com/mfontanini/presenterm/issues/653)) ([#684](https://github.com/mfontanini/presenterm/issues/684)) ([#685](https://github.com/mfontanini/presenterm/issues/685)).\n* Superscript via `^this^` and `<sup>this</sup>` 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)).\n* 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)).\n* Allow using env var `PRESENTERM_CONFIG_FILE` to point to the config file ([#663](https://github.com/mfontanini/presenterm/issues/663)) - thanks @Silver-Golden.\n* 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)).\n* Add support for markdown footnotes ([#616](https://github.com/mfontanini/presenterm/issues/616)).\n* Runtime errors are now centered rather than being left aligned with some fixed margin ([#638](https://github.com/mfontanini/presenterm/issues/638)).\n* 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)).\n* Allow 3 digit hex colors ([#609](https://github.com/mfontanini/presenterm/issues/609)) - thanks @peterc-s.\n* 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)).\n* Added `uv` as an alternative executor for python code ([#662](https://github.com/mfontanini/presenterm/issues/662)) - thanks @JanNeuendorf.\n* Allow multiline slide titles ([#679](https://github.com/mfontanini/presenterm/issues/679)).\n* Add support for multiline slide titles ([#682](https://github.com/mfontanini/presenterm/issues/682)) - thanks @barr-israel.\n* Add support for multiline subtitle ([#680](https://github.com/mfontanini/presenterm/issues/680)) - thanks @barr-israel.\n* Add support for syntax highlighting and execution for F# ([#650](https://github.com/mfontanini/presenterm/issues/650)) - thanks @mnebes.\n* Use text style/colors in rust-script errors ([#644](https://github.com/mfontanini/presenterm/issues/644)).\n* Added `rust-script-pedantic` alternative executor for rust ([#640](https://github.com/mfontanini/presenterm/issues/640)).\n\n## Fixes\n\n* Consider rect start row when capping max terminal rows ([#656](https://github.com/mfontanini/presenterm/issues/656)).\n* Skip speaker notes slide on `skip_slide` ([#625](https://github.com/mfontanini/presenterm/issues/625)).\n* Don't loop on 0 bytes read when querying capabilities ([#620](https://github.com/mfontanini/presenterm/issues/620)).\n* Make code snippet language specifiers case insensitive ([#613](https://github.com/mfontanini/presenterm/issues/613)) - thanks @peterc-s.\n* Bump dependencies ([#681](https://github.com/mfontanini/presenterm/issues/681)) - thanks @barr-israel.\n\n## Chore\n\n* 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)).\n* Bump rust version to 1.82 ([#611](https://github.com/mfontanini/presenterm/issues/611)).\n* Perform better validation around matching HTML tags ([#668](https://github.com/mfontanini/presenterm/issues/668)).\n* 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)).\n* Display an error when using http(s) urls in image tags ([#666](https://github.com/mfontanini/presenterm/issues/666)).\n* Update Catppuccin themes to use palettes ([#672](https://github.com/mfontanini/presenterm/issues/672)) - thanks @jmcharter.\n\n## Docs\n\n* Add custom introduction slides example ([#633](https://github.com/mfontanini/presenterm/issues/633)).\n* Add mention of `winget` ([#621](https://github.com/mfontanini/presenterm/issues/621)) - thanks @DeveloperPaul123.\n* Fix incorrect note callout ([#610](https://github.com/mfontanini/presenterm/issues/610)) - thanks @Sacquer.\n* Add a note to export pdf using `uv` ([#646](https://github.com/mfontanini/presenterm/issues/646)) - thanks @PitiBouchon.\n* Clarify why no remote urls work with images ([#664](https://github.com/mfontanini/presenterm/issues/664)) - thanks @ryuheechul.\n\n## ❤️ Sponsors\n\nThanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release:\n\n* [@0atman](https://github.com/0atman)\n* [@orhun](https://github.com/orhun)\n* [@gwpl](https://github.com/gwpl)\n\n# v0.14.0 - 2025-05-17\n\n## New features\n\n* 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.\n* 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)).\n* Add highlighting and execution support for Jsonnet ([#585](https://github.com/mfontanini/presenterm/issues/585)) - thanks @imobachgs.\n* 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)).\n\n## Fixes\n\n* Skip slides with pauses correctly ([#598](https://github.com/mfontanini/presenterm/issues/598)).\n* 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)).\n* Execute snippets only once during export ([#583](https://github.com/mfontanini/presenterm/issues/583)).\n* Don't add an extra pause after lists if there's nothing left ([#580](https://github.com/mfontanini/presenterm/issues/580)).\n* Allow interleaved spans and variables in footer ([#577](https://github.com/mfontanini/presenterm/issues/577)).\n* Truly center `+exec_replace` snippet output ([#572](https://github.com/mfontanini/presenterm/issues/572)).\n\n## Docs\n\n* Added link to public presentation using presenterm ([#589](https://github.com/mfontanini/presenterm/issues/589)) - thanks @pwnwriter.\n* Rename parameter name to the correct one in docs ([#570](https://github.com/mfontanini/presenterm/issues/570)) - thanks @DzuWe.\n* Fix typo in highlighting.md ([#586](https://github.com/mfontanini/presenterm/issues/586)) - thanks @0atman.\n\n## Chore\n\n* Bump dependencies ([#596](https://github.com/mfontanini/presenterm/issues/596)).\n\n## ❤️ Sponsors\n\nThanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release:\n\n* [@0atman](https://github.com/0atman)\n* [@orhun](https://github.com/orhun)\n\n# v0.13.0 - 2025-04-25\n\n## Breaking changes\n\n* 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)).\n\n## New features\n\n* Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitions.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)):\n  * Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)).\n  * Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)).\n  * Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)).\n* 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.\n* 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)).\n* 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)).\n* 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)).\n* 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)).\n* Add julia language highlighting and execution support ([#561](https://github.com/mfontanini/presenterm/issues/561)).\n\n## Fixes\n\n* Center overflow lines when using centered text ([#546](https://github.com/mfontanini/presenterm/issues/546)).\n* Don't add extra space before heading if prefix in theme is empty ([#542](https://github.com/mfontanini/presenterm/issues/542)).\n* Use no typst background in terminal-* built in themes ([#535](https://github.com/mfontanini/presenterm/issues/535)).\n* Use `std::env::temp_dir` in the `external_snippet` test ([#533](https://github.com/mfontanini/presenterm/issues/533)) - thanks @Medovi.\n* Respect `extends` in a theme set via `path` in front matter ([#532](https://github.com/mfontanini/presenterm/issues/532)).\n\n## Misc\n\n* 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)).\n* Get rid of `textproperties` ([#529](https://github.com/mfontanini/presenterm/issues/529)).\n* Add links to presentations using presenterm ([#544](https://github.com/mfontanini/presenterm/issues/544)) - thanks @orhun.\n\n## Performance improvements\n\n* A few performance improvements had to be done for slide transitions to work seemlessly:\n  * Pre-scale ASCII images when transitions are enabled ([#550](https://github.com/mfontanini/presenterm/issues/550)).\n  * Pre-scale generated images ([#553](https://github.com/mfontanini/presenterm/issues/553)).\n  * Cache resized ASCII images ([#547](https://github.com/mfontanini/presenterm/issues/547)).\n\n## ❤️ Sponsors\n\nThanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release:\n\n* [@0atman](https://github.com/0atman)\n* [@orhun](https://github.com/orhun)\n* [@fipoac](https://github.com/fipoac)\n\n# v0.12.0 - 2025-03-24\n\n## Breaking changes\n\n* 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)).\n\n## New features\n\n* [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)).\n* 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)).\n* Allow specifying path for temporary files generated during presentation export ([#518](https://github.com/mfontanini/presenterm/issues/518)).\n* Respect font sizes in generated PDF ([#510](https://github.com/mfontanini/presenterm/issues/510)).\n* 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)).\n* 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)).\n* Add `--current-theme` CLI parameter to display the theme being used ([#489](https://github.com/mfontanini/presenterm/issues/489)).\n* Add gruvbox dark theme ([#483](https://github.com/mfontanini/presenterm/issues/483)) - thanks @ret2src.\n\n## Fixes\n\n* 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)).\n* Center lists correctly ([#512](https://github.com/mfontanini/presenterm/issues/512)) ([#520](https://github.com/mfontanini/presenterm/issues/520)).\n* Respect end slide shorthand in speaker notes mode ([#494](https://github.com/mfontanini/presenterm/issues/494)).\n* Use more visible colors in snippet execution output in terminal-light/dark themes ([#485](https://github.com/mfontanini/presenterm/issues/485)).\n* Show error if sixel mode is selected but disabled ([#525](https://github.com/mfontanini/presenterm/issues/525)).\n\n## CI\n\n* Add nightly build job ([#496](https://github.com/mfontanini/presenterm/issues/496)).\n\n## Docs\n\n* Fix typo in README.md ([#490](https://github.com/mfontanini/presenterm/issues/490)) - thanks @eltociear.\n* Correctly include layout pic ([#495](https://github.com/mfontanini/presenterm/issues/495)) - thanks @Tuxified.\n\n## Misc\n\n* Cleanup text attributes ([#519](https://github.com/mfontanini/presenterm/issues/519)).\n* Refactor snippet processing ([#484](https://github.com/mfontanini/presenterm/issues/484)).\n\n## Sponsors\n\nIt is now possible to sponsor this project via [github sponsors](https://github.com/sponsors/mfontanini).\n\nThanks to [@0atman](https://github.com/0atman) for being the first project sponsor!\n\n# v0.11.0 - 2025-03-08\n\n## Breaking changes\n\n* 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)).\n\n## New features\n\n* [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)).\n* [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)).\n* [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)).\n* [Footers can now contain inline markdown](https://mfontanini.github.io/presenterm/features/themes/definition.html#template-footers), which allows using bold, italics, `<span>` tags for colors, etc ([#466](https://github.com/mfontanini/presenterm/issues/466)).\n* [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)).\n* [Introduce palette.classes in themes](https://mfontanini.github.io/presenterm/features/themes/definition.html#color-palette) to allow specifying combinations of foreground/background colors that can be referenced via the `class` attribute in `<span>` tags ([#468](https://github.com/mfontanini/presenterm/issues/468)).\n* 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)).\n* Add support for wikilinks ([#448](https://github.com/mfontanini/presenterm/issues/448)).\n\n## Fixes\n\n* Don't get stuck if tmux doesn't passthrough ([#456](https://github.com/mfontanini/presenterm/issues/456)).\n* Don't squash image if terminal's font aspect ratio is not 2:1 ([#446](https://github.com/mfontanini/presenterm/issues/446)).\n* Fail if `--config-file` points to non existent file ([#474](https://github.com/mfontanini/presenterm/issues/474)).\n* Use right script name for kotlin files when executing ([#462](https://github.com/mfontanini/presenterm/issues/462)).\n* Respect lists that start at non 1 indexes ([#459](https://github.com/mfontanini/presenterm/issues/459)).\n* Jump to right slide on code attribute change ([#478](https://github.com/mfontanini/presenterm/issues/478)).\n\n## Improvements\n\n* Remove `result` return type from builder fns that don't need it ([#465](https://github.com/mfontanini/presenterm/issues/465)).\n* Refactor theme code ([#463](https://github.com/mfontanini/presenterm/issues/463)).\n* Restructure `terminal` code and add test for margins/layouts ([#443](https://github.com/mfontanini/presenterm/issues/443)).\n* Use `fastrand` instead of `rand` ([#441](https://github.com/mfontanini/presenterm/issues/441)).\n* Avoid cloning strings when styling them ([#440](https://github.com/mfontanini/presenterm/issues/440)).\n\n# v0.10.1 - 2025-02-14\n\n## Fixes\n\n* Don't error out if `options` in front matter doesn't include `auto_render_languages` ([#454](https://github.com/mfontanini/presenterm/pull/454)).\n* Bump sixel-rs to 0.4.1 to fix build in aarch64 and riscv64 ([#452](https://github.com/mfontanini/presenterm/pull/452)) - thanks @Xeonacid.\n\n# v0.10.0 - 2025-02-02\n\n## New features\n\n* 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.\n* Add support for colored text via inline `span` HTML tags  ([#390](https://github.com/mfontanini/presenterm/issues/390)).\n* 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)).\n* Add support for github/gitlab style markdown alerts ([#423](https://github.com/mfontanini/presenterm/issues/423)) ([#430](https://github.com/mfontanini/presenterm/issues/430)).\n* Allow using `+image` on code blocks to consume their output as an image ([#429](https://github.com/mfontanini/presenterm/issues/429)).\n* Allow multiline comment commands ([#424](https://github.com/mfontanini/presenterm/issues/424)).\n* Allow auto rendering mermaid/typst/latex code blocks ([#418](https://github.com/mfontanini/presenterm/issues/418)).\n* Allow capping max columns on presentation ([#417](https://github.com/mfontanini/presenterm/issues/417)).\n* Automatically detect kitty support, including when running inside tmux ([#406](https://github.com/mfontanini/presenterm/issues/406)).\n* Use kitty image protocol in ghostty ([#405](https://github.com/mfontanini/presenterm/issues/405)).\n* Force color output in rust, c, and c++ compiler executions ([#401](https://github.com/mfontanini/presenterm/issues/401)).\n* Add graphql code highlighting ([#385](https://github.com/mfontanini/presenterm/issues/385)) - thanks @GV14982.\n* Add tcl code highlighting ([#387](https://github.com/mfontanini/presenterm/issues/387)) - thanks @jtplaarj.\n* Add Haskell executor ([#414](https://github.com/mfontanini/presenterm/issues/414)) - thanks @feature-not-a-bug.\n* Add C# to code executors ([#399](https://github.com/mfontanini/presenterm/issues/399)) - thanks @giggio.\n* Add R to executors ([#393](https://github.com/mfontanini/presenterm/issues/393)) - thanks @jonocarroll.\n\n## Fixes\n\n* Check for `term_program` before `term` to determine emulator ([#420](https://github.com/mfontanini/presenterm/issues/420)).\n* Allow jumping back to column in column layout ([#396](https://github.com/mfontanini/presenterm/issues/396)).\n* Ignore comments that start with `vim:` prefix ([#395](https://github.com/mfontanini/presenterm/issues/395)).\n* Respect `+no_background` on a `+exec_replace` block ([#383](https://github.com/mfontanini/presenterm/issues/383)).\n\n## Docs\n\n* Document tmux active session bug ([#402](https://github.com/mfontanini/presenterm/issues/402)).\n* Add notes on running `bat` directly ([#397](https://github.com/mfontanini/presenterm/issues/397)).\n\n# v0.9.0 - 2024-10-06\n\n## Breaking changes\n\n* Default themes now no longer use a progress bar based footer. Instead they use indicator of the current page number \nand the total number of pages. If you'd like to preserve the old behavior, you can override the theme by using \n`footer.style = progress_bar` in [your \ntheme](https://mfontanini.github.io/presenterm/guides/themes.html#setting-themes).\n* Links that include a title (e.g. `[my title](http://example.com)`) now have their title rendered as well. Removing a \nlink's title will make it look the same as they used to.\n\n## New features\n\n* Use \"template\" footer in built-in themes ([#358](https://github.com/mfontanini/presenterm/issues/358)).\n* Allow including external code snippets ([#328](https://github.com/mfontanini/presenterm/issues/328)) \n  ([#372](https://github.com/mfontanini/presenterm/issues/372)).\n* Add `+no_background` property to remove background from code blocks \n  ([#363](https://github.com/mfontanini/presenterm/issues/363)) \n  ([#368](https://github.com/mfontanini/presenterm/issues/368)).\n* Show colored output from snippet execution output ([#316](https://github.com/mfontanini/presenterm/issues/316)).\n* Style markdown inside block quotes ([#350](https://github.com/mfontanini/presenterm/issues/350)) \n  ([#351](https://github.com/mfontanini/presenterm/issues/351)).\n* Allow using all intro slide variables in footer template \n  ([#338](https://github.com/mfontanini/presenterm/issues/338)).\n* Include hidden line prefix in executors file ([#337](https://github.com/mfontanini/presenterm/issues/337)).\n* Show link labels and titles ([#334](https://github.com/mfontanini/presenterm/issues/334)).\n* Add `+exec_replace` which executes snippets and replaces them with their output \n  ([#330](https://github.com/mfontanini/presenterm/issues/330)) \n  ([#371](https://github.com/mfontanini/presenterm/issues/371)).\n* Always show snippet execution bar ([#329](https://github.com/mfontanini/presenterm/issues/329)).\n* Handle suspend signal (SIGTSTP) ([#318](https://github.com/mfontanini/presenterm/issues/318)).\n* Allow closing with `q` ([#321](https://github.com/mfontanini/presenterm/issues/321)).\n* Add event, location, and date labels in intro slide ([#317](https://github.com/mfontanini/presenterm/issues/317)).\n* Use transparent background in mermaid charts ([#314](https://github.com/mfontanini/presenterm/issues/314)).\n* Add `+acquire_terminal` to acquire the terminal when running snippets \n  ([#366](https://github.com/mfontanini/presenterm/issues/366))\n  ([#376](https://github.com/mfontanini/presenterm/pull/376)).\n* Add PHP executor ([#332](https://github.com/mfontanini/presenterm/issues/332)).\n* Add Racket syntax highlighting ([#367](https://github.com/mfontanini/presenterm/issues/367)).\n* Add TOML highlighting ([#361](https://github.com/mfontanini/presenterm/issues/361)).\n\n## Fixes\n\n* Wrap code snippets if they don't fit in terminal ([#320](https://github.com/mfontanini/presenterm/issues/320)).\n* Allow list-themes/acknowledgements to run without path ([#359](https://github.com/mfontanini/presenterm/issues/359)).\n* Translate tabs in code snippets to 4 spaces ([#356](https://github.com/mfontanini/presenterm/issues/356)).\n* Add padding to right of code block wrapped lines ([#354](https://github.com/mfontanini/presenterm/issues/354)).\n* Don't wrap code snippet separator line ([#353](https://github.com/mfontanini/presenterm/issues/353)).\n* Show block quote prefix when wrapping ([#352](https://github.com/mfontanini/presenterm/issues/352)).\n* Don't crash on code block with only hidden-line-prefixed lines \n  ([#347](https://github.com/mfontanini/presenterm/issues/347)).\n* Canonicalize resources path ([#333](https://github.com/mfontanini/presenterm/issues/333)).\n* Execute script relative to current working directory ([#323](https://github.com/mfontanini/presenterm/issues/323)).\n* Support rendering mermaid charts on windows ([#319](https://github.com/mfontanini/presenterm/issues/319)).\n\n## Improvements\n\n* Add example on how column layouts and pauses interact ([#348](https://github.com/mfontanini/presenterm/issues/348)).\n* Rename `jump_to_vertical_center` -> `jump_to_middle` in docs \n  ([#342](https://github.com/mfontanini/presenterm/issues/342)).\n* Document `all` snippet highlighting keyword ([#335](https://github.com/mfontanini/presenterm/issues/335)).\n\n# v0.8.0 - 2024-07-29\n\n## Breaking changes\n\n* Force users to explicitly enable snippet execution ([#276](https://github.com/mfontanini/presenterm/issues/276)) ([#281](https://github.com/mfontanini/presenterm/issues/281)).\n\n## New features\n\n* 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)).\n* Allow executing compiled snippets in windows ([#303](https://github.com/mfontanini/presenterm/issues/303)).\n* 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.\n* Support [mermaid](https://mermaid.js.org/) snippet rendering to image via `+render` attribute ([#268](https://github.com/mfontanini/presenterm/issues/268)).\n* Allow scaling images dynamically based on terminal size ([#288](https://github.com/mfontanini/presenterm/issues/288)) ([#291](https://github.com/mfontanini/presenterm/issues/291)).\n* Allow scaling images generated via `+render` code blocks (mermaid, typst, latex) ([#290](https://github.com/mfontanini/presenterm/issues/290)).\n* Show `stderr` output from code execution ([#252](https://github.com/mfontanini/presenterm/issues/252)) - thanks @dmackdev.\n* Wait for code execution process to exit completely ([#250](https://github.com/mfontanini/presenterm/issues/250)) - thanks @dmackdev.\n* 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)).\n* Dim non highlighted code snippet lines ([#287](https://github.com/mfontanini/presenterm/issues/287)).\n* Shrink snippet execution to match code block width ([#286](https://github.com/mfontanini/presenterm/issues/286)).\n* Include code snippet execution output in generated PDF ([#295](https://github.com/mfontanini/presenterm/issues/295)).\n* Cache `+render` block images ([#270](https://github.com/mfontanini/presenterm/issues/270)).\n* Add kotlin script executor ([#257](https://github.com/mfontanini/presenterm/issues/257)) - thanks @dmackdev.\n* Add nushell code execution ([#274](https://github.com/mfontanini/presenterm/issues/274)) ([#275](https://github.com/mfontanini/presenterm/issues/275)) - thanks @PitiBouchon.\n* Add rust-script as a new code executor ([#269](https://github.com/mfontanini/presenterm/issues/269)) - @ZhangHanDong. \n* Allow custom themes to extend others ([#265](https://github.com/mfontanini/presenterm/issues/265)).\n* Allow jumping fast between slides ([#244](https://github.com/mfontanini/presenterm/issues/244)).\n* Allow explicitly disabling footer in certain slides ([#239](https://github.com/mfontanini/presenterm/issues/239)).\n* Allow using image paths in typst ([#235](https://github.com/mfontanini/presenterm/issues/235)).\n* 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.\n* Allow having multiple authors ([#227](https://github.com/mfontanini/presenterm/issues/227)).\n\n## Fixes\n\n* Avoid re-rendering code output and auto rendered blocks ([#280](https://github.com/mfontanini/presenterm/issues/280)).\n* Use unicode width to calculate execution output's line len ([#261](https://github.com/mfontanini/presenterm/issues/261)).\n* Display background color behind '\\t' in code exec output ([#245](https://github.com/mfontanini/presenterm/issues/245)).\n* Close child process stdin by default ([#297](https://github.com/mfontanini/presenterm/issues/297)).\n\n## Improvements\n\n* Update install instructions for Arch Linux ([#248](https://github.com/mfontanini/presenterm/issues/248)) - thanks @orhun.\n* Fix all clippy warnings ([#231](https://github.com/mfontanini/presenterm/issues/231)) - thanks @mikavilpas.\n* Include strict `_front_matter_parsing` in default config ([#229](https://github.com/mfontanini/presenterm/issues/229)) - thanks @mikavilpas.\n* `CHANGELOG.md` contains clickable links to issues ([#230](https://github.com/mfontanini/presenterm/issues/230)) - thanks @mikavilpas.\n* Add Support for Ruby Code Highlighting ([#226](https://github.com/mfontanini/presenterm/issues/226)) - thanks @pranavrao145.\n* Use \".presenterm\" as prefix for tmp files ([#306](https://github.com/mfontanini/presenterm/issues/306)).\n* Add more descriptive error message when loading image fails ([#298](https://github.com/mfontanini/presenterm/issues/298)).\n* Align all error messages to left ([#301](https://github.com/mfontanini/presenterm/issues/301)).\n\n# v0.7.0 - 2024-03-02\n\n## New features\n\n* Add color to prefix in block quote ([#218](https://github.com/mfontanini/presenterm/issues/218)).\n* Allow having code blocks without background ([#215](https://github.com/mfontanini/presenterm/issues/215) [#216](https://github.com/mfontanini/presenterm/issues/216)).\n* Allow validating whether presentation overflows terminal ([#209](https://github.com/mfontanini/presenterm/issues/209) [#211](https://github.com/mfontanini/presenterm/issues/211)).\n* Add parameter to list themes ([#207](https://github.com/mfontanini/presenterm/issues/207)).\n* 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.\n* Detect konsole terminal emulator ([#204](https://github.com/mfontanini/presenterm/issues/204)).\n* Allow customizing slide title style ([#201](https://github.com/mfontanini/presenterm/issues/201)).\n\n## Fixes\n\n* Don't crash in present mode ([#210](https://github.com/mfontanini/presenterm/issues/210)).\n* Set colors properly before displaying an error ([#212](https://github.com/mfontanini/presenterm/issues/212)).\n\n## Improvements\n\n* Suggest a tool is missing when spawning returns ENOTFOUND ([#221](https://github.com/mfontanini/presenterm/issues/221)).\n* Sort input file list ([#202](https://github.com/mfontanini/presenterm/issues/202)) - thanks @bmwiedemann.\n* Add more example presentations ([#217](https://github.com/mfontanini/presenterm/issues/217)).\n* Add Scoop to package managers ([#200](https://github.com/mfontanini/presenterm/issues/200)) - thanks @nagromc.\n* Remove support for uncommon image formats ([#208](https://github.com/mfontanini/presenterm/issues/208)).\n\n# v0.6.1 - 2024-02-11\n\n## Fixes\n\n* Don't escape symbols in block quotes ([#195](https://github.com/mfontanini/presenterm/issues/195)).\n* Respect `XDG_CONFIG_HOME` when loading configuration files and custom themes ([#193](https://github.com/mfontanini/presenterm/issues/193)).\n\n# v0.6.0 - 2024-02-09\n\n## Breaking changes\n\n* The default configuration file and custom themes paths have been changed in Windows and macOS to be compliant to where \n  those platforms store these types of files. See the [configuration \n  guide](https://mfontanini.github.io/presenterm/guides/configuration.html) to learn more.\n\n## New features\n\n* Add `f` keys, tab, and backspace as possible bindings ([#188](https://github.com/mfontanini/presenterm/issues/188)).\n* Add support for multiline block quotes ([#184](https://github.com/mfontanini/presenterm/issues/184)).\n* Use theme color as background on ascii-blocks mode images ([#182](https://github.com/mfontanini/presenterm/issues/182)).\n* Blend ascii-blocks image semi-transparent borders ([#185](https://github.com/mfontanini/presenterm/issues/185)).\n* Respect Windows/macOS config paths for configuration ([#181](https://github.com/mfontanini/presenterm/issues/181)).\n* Allow making front matter strict parsing optional ([#190](https://github.com/mfontanini/presenterm/issues/190)).\n\n## Fixes\n\n* Don't add an extra line after an end slide shorthand ([#187](https://github.com/mfontanini/presenterm/issues/187)).\n* Don't clear input state on key release event ([#183](https://github.com/mfontanini/presenterm/issues/183)).\n\n# v0.5.0 - 2024-01-26\n\n## New features\n\n* Support images on Windows ([#120](https://github.com/mfontanini/presenterm/issues/120)).\n* Support animated gifs on kitty terminal ([#157](https://github.com/mfontanini/presenterm/issues/157) [#161](https://github.com/mfontanini/presenterm/issues/161)).\n* Support images on tmux running in kitty terminal ([#166](https://github.com/mfontanini/presenterm/issues/166)).\n* Improve sixel support ([#169](https://github.com/mfontanini/presenterm/issues/169) [#172](https://github.com/mfontanini/presenterm/issues/172)).\n* Use synchronized updates to remove flickering when switching slides ([#156](https://github.com/mfontanini/presenterm/issues/156)).\n* Add newlines command ([#167](https://github.com/mfontanini/presenterm/issues/167)).\n* Detect image protocol instead of relying on viuer ([#160](https://github.com/mfontanini/presenterm/issues/160)).\n* Turn documentation into mdbook ([#141](https://github.com/mfontanini/presenterm/issues/141) [#147](https://github.com/mfontanini/presenterm/issues/147)) - thanks @pwnwriter.\n* Allow using thematic breaks to end slides ([#138](https://github.com/mfontanini/presenterm/issues/138)).\n* 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)).\n* 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)).\n* Allow defining custom keybindings in config file ([#132](https://github.com/mfontanini/presenterm/issues/132) [#155](https://github.com/mfontanini/presenterm/issues/155)).\n* Add key bindings modal ([#152](https://github.com/mfontanini/presenterm/issues/152)).\n* Prioritize CLI args `--theme` over anything else ([#116](https://github.com/mfontanini/presenterm/issues/116)).\n* 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)).\n* Allow passing in config file path via CLI arg ([#174](https://github.com/mfontanini/presenterm/issues/174)).\n\n## Fixes\n\n* Shrink columns layout dimensions correctly when shrinking left ([#113](https://github.com/mfontanini/presenterm/issues/113)).\n* Explicitly set execution output foreground color in built-in themes ([#122](https://github.com/mfontanini/presenterm/issues/122)).\n* Detect sixel early and fallback to ascii blocks properly ([#135](https://github.com/mfontanini/presenterm/issues/135)).\n* Exit with a clap error on missing path ([#150](https://github.com/mfontanini/presenterm/issues/150)).\n* Don't blow up if presentation file temporarily disappears ([#154](https://github.com/mfontanini/presenterm/issues/154)).\n* Parse front matter properly in presence of \\r\\n ([#162](https://github.com/mfontanini/presenterm/issues/162)).\n* Don't preload graphics mode when generating pdf metadata ([#168](https://github.com/mfontanini/presenterm/issues/168)).\n* Ignore key release events ([#119](https://github.com/mfontanini/presenterm/issues/119)).\n\n## Improvements\n\n* Validate that config file contains the right attributes ([#107](https://github.com/mfontanini/presenterm/issues/107)).\n* Display first presentation load error as any other ([#118](https://github.com/mfontanini/presenterm/issues/118)).\n* Add hashes for windows artifacts ([#126](https://github.com/mfontanini/presenterm/issues/126)).\n* Remove arch packaging files ([#111](https://github.com/mfontanini/presenterm/issues/111)).\n* Lower CPU and memory usage when displaying images ([#157](https://github.com/mfontanini/presenterm/issues/157)).\n\n# v0.4.1 - 2023-12-22\n\n## New features\n\n* 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)).\n\n## Fixes\n\n* Explicitly disable kitty/iterm protocols when printing images in export PDF mode as this was causing PDF generation in \n  macOS to fail ([#101](https://github.com/mfontanini/presenterm/issues/101)).\n\n# v0.4.0 - 2023-12-16\n\n## New features\n\n* Add support for all of [bat](https://github.com/sharkdp/bat)'s code highlighting themes ([#67](https://github.com/mfontanini/presenterm/issues/67)).\n* 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)).\n* Allow placing themes in `$HOME/.config/presenterm/themes` to make them available automatically as if they were \n  built-in themes ([#73](https://github.com/mfontanini/presenterm/issues/73)).\n* Allow configuring the default theme in `$HOME/.config/presenterm/config.yaml` ([#74](https://github.com/mfontanini/presenterm/issues/74)).\n* 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)).\n* Add syntax highlighting support for _nix_ and _diff_ ([#78](https://github.com/mfontanini/presenterm/issues/78) [#82](https://github.com/mfontanini/presenterm/issues/82)).\n* Add comment command to jump into the middle of a slide ([#86](https://github.com/mfontanini/presenterm/issues/86)).\n* Add configuration option to have implicit slide ends ([#87](https://github.com/mfontanini/presenterm/issues/87) [#89](https://github.com/mfontanini/presenterm/issues/89)).\n* Add configuration option to have custom comment-command prefix ([#91](https://github.com/mfontanini/presenterm/issues/91)).\n\n# v0.3.0 - 2023-11-24\n\n## New features\n\n* 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)).\n* Add shell script executable code blocks ([#17](https://github.com/mfontanini/presenterm/issues/17)).\n* Allow exporting presentation to PDF ([#43](https://github.com/mfontanini/presenterm/issues/43) [#60](https://github.com/mfontanini/presenterm/issues/60)).\n* 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)).\n* Allow display code block line numbers ([#46](https://github.com/mfontanini/presenterm/issues/46)).\n* Allow code block selective line highlighting ([#48](https://github.com/mfontanini/presenterm/issues/48)).\n* Allow code block dynamic line highlighting ([#49](https://github.com/mfontanini/presenterm/issues/49)).\n* Support animated gifs when using the iterm2 image protocol ([#56](https://github.com/mfontanini/presenterm/issues/56)).\n* Nix flake packaging ([#11](https://github.com/mfontanini/presenterm/issues/11) [#27](https://github.com/mfontanini/presenterm/issues/27)).\n* Arch repo packaging ([#10](https://github.com/mfontanini/presenterm/issues/10)).\n* Ignore vim-like code folding tags in comments.\n* Add keybinding to refresh assets in presentation ([#38](https://github.com/mfontanini/presenterm/issues/38)).\n* Template style footer is now one row above bottom ([#39](https://github.com/mfontanini/presenterm/issues/39)).\n* Add `light` theme.\n\n## Fixes\n\n* Don't crash on Windows when terminal window size can't be found ([#14](https://github.com/mfontanini/presenterm/issues/14)).\n* Don't reset numbers on ordered lists when using pauses in between ([#19](https://github.com/mfontanini/presenterm/issues/19)).\n* 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)).\n* Don't reset the default footer when overriding theme in presentation without setting footer ([#52](https://github.com/mfontanini/presenterm/issues/52)).\n* 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)).\n\n# v0.2.1 - 2023-10-18\n\n## New features\n\n* Binary artifacts are now automatically generated when a new release is done ([#5](https://github.com/mfontanini/presenterm/issues/5)) - thanks @pwnwriter.\n\n# v0.2.0 - 2023-10-17\n\n## New features\n\n* [Column layouts](https://github.com/mfontanini/presenterm/blob/26e2eb28884675aac452f4c6e03f98413654240c/docs/layouts.md) that let you structure slides into columns.\n* Support for `percent` margin rather than only a fixed number of columns.\n* Spacebar now moves the presentation into the next slide.\n* Add support for `center` footer when using the `template` mode.\n* **Breaking**: themes now only use colors in hex format.\n\n## Fixes\n\n* Allow using `sh` as language for code block ([#3](https://github.com/mfontanini/presenterm/issues/3)).\n* Minimum size for code blocks is now prioritized over minimum margin.\n* Overflowing lines in lists will now correctly be padded to align all text under the same starting column.\n* Running `cargo run` will now rebuild the tool if any of the built-in themes changed.\n* `alignment` was removed from certain elements (like `list`) as it didn't really make sense.\n* `default.alignment` is now no longer supported and by default we use left alignment. Use `default.margin` to specify the margins to use.\n\n# v0.1.0 - 2023-10-08\n\n## Features\n* Define your presentation in a single markdown file.\n* Image rendering support for iterm2, terminals that support the kitty graphics protocol, or sixel.\n* Customize your presentation's look by defining themes, including colors, margins, layout (left/center aligned \n  content), footer for every slide, etc.\n* Code highlighting for a wide list of programming languages.\n* Support for an introduction slide that displays the presentation title and your name.\n* Support for slide titles.\n* Create pauses in between each slide so that it progressively renders for a more interactive presentation.\n* Text formatting support for **bold**, _italics_, ~strikethrough~, and `inline code`.\n* Automatically reload your presentation every time it changes for a fast development loop.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"presenterm\"\nauthors = [\"Matias Fontanini\"]\ndescription = \"A terminal slideshow presentation tool\"\nrepository = \"https://github.com/mfontanini/presenterm\"\nlicense = \"BSD-2-Clause\"\nversion = \"0.16.1\"\nedition = \"2021\"\n\n[dependencies]\nanyhow = \"1\"\nbase64 = \"0.22\"\nbincode = \"1.3\"\nclap = { version = \"4.4\", features = [\"derive\", \"string\", \"env\"] }\ncomrak = { version = \"0.48.0\", default-features = false }\ncrossterm = { version = \"0.29\", default-features = false, features = [\"events\", \"windows\"] }\ndirectories = \"6.0\"\nhex = \"0.4\"\nfastrand = \"2.3\"\nflate2 = \"1.0\"\nimage = { version = \"0.25\", features = [\"gif\", \"jpeg\", \"png\"], default-features = false }\nicy_sixel = \"0.5\"\nmerge-struct = \"0.1.0\"\nitertools = \"0.14\"\nonce_cell = \"1.19\"\nportable-pty = \"0.9\"\nschemars = { version = \"0.8\", optional = true }\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_yaml = \"0.9\"\nserde_json = \"1.0\"\nsyntect = { version = \"5.2\", features = [\"parsing\", \"default-themes\", \"regex-onig\", \"plist-load\"], default-features = false }\nsocket2 = \"0.6\"\nstrum = { version = \"0.27\", features = [\"derive\"] }\ntempfile = { version = \"3.10\", default-features =  false }\ntl = \"0.7\"\nthiserror = \"2\"\nunicode-width = \"0.2\"\nos_pipe = \"1.1.5\"\nlibc = \"0.2\"\nvte = \"0.15\"\ntermbg = \"0.6.2\"\nvt100 = \"0.16\"\n\n[dev-dependencies]\nrstest = { version = \"0.26\", default-features = false }\n\n[features]\ndefault = []\njson-schema = [\"dep:schemars\"]\n\n[profile.dev]\nopt-level = 0\ndebug = true\npanic = \"abort\"\n\n[profile.test]\nopt-level = 0\ndebug = true\n\n[profile.release]\nopt-level = 3\ndebug = false\npanic = \"unwind\"\nlto = true\ncodegen-units = 1\n\n[profile.bench]\nopt-level = 3\ndebug = false\n"
  },
  {
    "path": "LICENSE",
    "content": "BSD 2-Clause License\n\nCopyright (c) 2023, Matias Fontanini\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n   list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n   this list of conditions and the following disclaimer in the documentation\n   and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "README.md",
    "content": "presenterm\n===\n\n[![crates-badge]][crates-package] [![brew-badge]][brew-package] [![nix-badge]][nix-package] \n[![arch-badge]][arch-package] [![scoop-badge]][scoop-package] [![winget-badge]][winget-package]\n\n[brew-badge]: https://img.shields.io/homebrew/v/presenterm\n[brew-package]: https://formulae.brew.sh/formula/presenterm\n[nix-badge]: https://img.shields.io/badge/Packaged_for-Nix-5277C3.svg?logo=nixos&labelColor=73C3D5\n[nix-package]: https://search.nixos.org/packages?size=1&show=presenterm\n[crates-badge]: https://img.shields.io/crates/v/presenterm\n[crates-package]: https://crates.io/crates/presenterm\n[arch-badge]: https://img.shields.io/archlinux/v/extra/x86_64/presenterm\n[arch-package]: https://archlinux.org/packages/extra/x86_64/presenterm/\n[scoop-badge]: https://img.shields.io/scoop/v/presenterm\n[scoop-package]: https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a\n[winget-badge]: https://img.shields.io/winget/v/mfontanini.presenterm\n[winget-package]: https://winstall.app/apps/mfontanini.presenterm\n\n_presenterm_ lets you create presentations in markdown format and run them from your terminal, with support for image \nand animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of \nother features. This is how the [demo presentation](/examples/demo.md) looks like when running in the [kitty \nterminal](https://sw.kovidgoyal.net/kitty/):\n\n![](/docs/src/assets/demo.gif)\n\nCheck the rest of the example presentations in the [examples directory](/examples).\n\n# Documentation\n\nVisit the [documentation][docs-introduction] to get started.\n\n# Features\n\n* Presentations consist of one [or more][docs-include] markdown files.\n* [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, _wezterm_, _ghostty_ and _foot_.\n* [Customizable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every \n  slide, etc. Several [built-in themes][docs-builtin-themes] can give your presentation the look you want without \n  having to define your own.\n* Code highlighting for a [wide list of programming languages][docs-code-highlight].\n* [Font sizes][docs-font-sizes] for terminals that support them.\n* [Selective/dynamic][docs-selective-highlight] code highlighting that only highlights portions of code at a time.\n* [Column layouts][docs-layout].\n* [mermaid graph rendering][docs-mermaid].\n* [d2 graph rendering][docs-d2].\n* [_LaTeX_ and _typst_ formula rendering][docs-latex].\n* [Introduction slide][docs-intro-slide] that displays the presentation title and your name.\n* [Slide titles][docs-slide-titles].\n* [Snippet execution][docs-code-execute] for various programming languages, including execution inside pseudo terminals.\n* [Export presentations to PDF and HTML][docs-exports].\n* [Slide transitions][docs-slide-transitions].\n* [Pause][docs-pauses] portions of your slides.\n* [Custom key bindings][docs-key-bindings].\n* [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop.\n* [Define speaker notes][docs-speaker-notes] to aid you during presentations.\n\nSee the [introduction page][docs-introduction] to learn more.\n\n# presenterm in action\n\nHere are some talks and demos that feature _presenterm_:\n\n- [Bringing Terminal Aesthetics to the Web With Rust][bringing-terminal-aesthetics] by [Orhun Parmaksız][orhun-github]\n- [7 Rust Terminal Tools That You Should Use][rust-terminal-tools] by [Orhun Parmaksız][orhun-github]\n- [Renaissance of Terminal User Interfaces with Rust][renaissance-tui] by [Orhun Parmaksız][orhun-github]\n- [Using Nix on Apple Silicon and declarative development environments][NiXOS-and-Dev] by [pwnwriter][pwnwriter-github]\n- [Hayasen: A Robust Embedded Rust Library which supports multiple sensors][hayasen] by [Vaishnav-Sabari-Girish][vaishnav]\n- [Using ratatui in Embedded sytems : Meet Mousefood][mousefood] by [Vaishnav-Sabari-Girish][vaishnav]\n\nGave a talk using _presenterm_? We would love to feature it here! Open a PR or issue to get it added.\n\n<!-- links -->\n\n[docs-introduction]: https://mfontanini.github.io/presenterm/\n[docs-basics]: https://mfontanini.github.io/presenterm/features/introduction.html\n[docs-intro-slide]: https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide\n[docs-slide-titles]: https://mfontanini.github.io/presenterm/features/introduction.html#slide-titles\n[docs-font-sizes]: https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes\n[docs-pauses]: https://mfontanini.github.io/presenterm/features/commands.html#pauses\n[docs-images]: https://mfontanini.github.io/presenterm/features/images.html\n[docs-include]: https://mfontanini.github.io/presenterm/features/commands.html#including-external-markdown-files\n[docs-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html\n[docs-builtin-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html#built-in-themes\n[docs-code-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html\n[docs-code-execute]: https://mfontanini.github.io/presenterm/features/code/execution.html\n[docs-selective-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html#selective-highlighting\n[docs-slide-transitions]: https://mfontanini.github.io/presenterm/features/slide-transitions.html\n[docs-layout]: https://mfontanini.github.io/presenterm/features/layout.html\n[docs-mermaid]: https://mfontanini.github.io/presenterm/features/code/mermaid.html\n[docs-d2]: https://mfontanini.github.io/presenterm/features/code/d2.html\n[docs-latex]: https://mfontanini.github.io/presenterm/features/code/latex.html\n[docs-exports]: https://mfontanini.github.io/presenterm/features/exports.html\n[docs-key-bindings]: https://mfontanini.github.io/presenterm/configuration/settings.html#key-bindings\n[docs-hot-reload]: https://mfontanini.github.io/presenterm/features/introduction.html#hot-reload\n[docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html\n[bat]: https://github.com/sharkdp/bat\n[syntect]: https://github.com/trishume/syntect\n[bringing-terminal-aesthetics]: https://www.youtube.com/watch?v=iepbyYrF_YQ\n[rust-terminal-tools]: https://www.youtube.com/watch?v=ATiKwUiqnAU\n[renaissance-tui]: https://www.youtube.com/watch?v=hWG51Mc1DlM\n[orhun-github]: https://github.com/orhun\n[NiXOS-and-Dev]: https://github.com/pwnwriter/PTN11\n[pwnwriter-github]: https://github.com/pwnwriter\n[hayasen]: https://github.com/Vaishnav-Sabari-Girish/rust_bangalore_oct_2025\n[vaishnav]: https://github.com/Vaishnav-Sabari-Girish\n[mousefood]: https://github.com/Vaishnav-Sabari-Girish/rust_bangalore_december_2025/\n"
  },
  {
    "path": "bat/bat.git-hash",
    "content": "0e469634a3e8987fd7085520e1f90cd83d8fe51b\n"
  },
  {
    "path": "bat/syntaxes.git-hash",
    "content": "3d87b25b190e0990e0e75a2ab8f994d6c277d263\n"
  },
  {
    "path": "bat/update.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nif [ $# -ne 1 ]; then\n    echo \"Usage: $0 <bat-git-hash>\"\n    exit 1\nfi\n\nscript_path=$(realpath \"$0\")\nscript_dir=$(dirname \"$script_path\")\ngit_hash=$1\nclone_path=$(mktemp -d)\n\necho \"Cloning repo @ ${git_hash} into '$clone_path'\"\ngit clone https://github.com/sharkdp/bat.git \"$clone_path\"\ncd \"$clone_path\"\ngit reset --hard \"$git_hash\"\n\ncp assets/syntaxes.bin \"$script_dir\"\ncp assets/themes.bin \"$script_dir\"\n\nacknowledgements_file=\"$script_dir/acknowledgements.txt\"\ncp LICENSE-MIT \"$acknowledgements_file\"\nzlib-flate -uncompress <assets/acknowledgements.bin >>\"$acknowledgements_file\"\necho \"$git_hash\" >\"$script_dir/bat.git-hash\"\n\necho \"syntaxes/themes updated\"\n"
  },
  {
    "path": "bat/verify.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nscript_path=$(realpath \"$0\")\nscript_dir=$(dirname \"$script_path\")\nclone_path=$(mktemp -d)\n\ngit_hash=$(cat \"$script_dir/bat.git-hash\")\necho \"Cloning repo @ ${git_hash} into '$clone_path'\"\ngit clone https://github.com/sharkdp/bat.git \"$clone_path\"\ncd \"$clone_path\"\ngit reset --hard \"$git_hash\"\n\nfor file in syntaxes.bin themes.bin; do\n    our_hash=$(sha256sum \"$script_dir/$file\" | cut -d \" \" -f1)\n    their_hash=$(sha256sum \"$clone_path/assets/$file\" | cut -d \" \" -f 1)\n    if [ \"$our_hash\" != \"$their_hash\" ]; then\n        echo \"Unexpected hash for ${file}: should be ${their_hash}, is ${our_hash}\"\n        exit 1\n    fi\ndone\n\necho \"All hashes match\"\n"
  },
  {
    "path": "build.rs",
    "content": "use std::{\n    env,\n    fs::{self, File},\n    io::{self, BufWriter, Write},\n};\n\n// Take all files under `themes` and turn them into a file that contains a hashmap with their\n// contents by name. This is pulled in theme.rs to construct themes.\nfn build_themes(out_dir: &str) -> io::Result<()> {\n    let output_path = format!(\"{out_dir}/themes.rs\");\n    let mut output_file = BufWriter::new(File::create(output_path)?);\n    output_file.write_all(b\"use std::collections::BTreeMap as Map;\\n\")?;\n    output_file.write_all(b\"use once_cell::sync::Lazy;\\n\")?;\n    output_file.write_all(b\"static THEMES: Lazy<Map<&'static str, &'static [u8]>> = Lazy::new(|| Map::from([\\n\")?;\n\n    let mut paths = fs::read_dir(\"themes\")?.collect::<io::Result<Vec<_>>>()?;\n    paths.sort_by_key(|e| e.path());\n    for theme_file in paths {\n        let metadata = theme_file.metadata()?;\n        if !metadata.is_file() {\n            panic!(\"found non file in themes directory\");\n        }\n        let path = theme_file.path();\n        let contents = fs::read(&path)?;\n        let file_name = path.file_name().unwrap().to_string_lossy();\n        let theme_name = file_name.split_once('.').unwrap().0;\n        // TODO this wastes a bit of space\n        output_file.write_all(format!(\"(\\\"{theme_name}\\\", {contents:?}.as_slice()),\\n\").as_bytes())?;\n    }\n    output_file.write_all(b\"]));\\n\")?;\n\n    // Rebuild if anything changes.\n    println!(\"cargo:rerun-if-changed=themes\");\n    Ok(())\n}\n\nfn main() -> io::Result<()> {\n    let out_dir = env::var(\"OUT_DIR\").unwrap();\n    build_themes(&out_dir)?;\n    Ok(())\n}\n"
  },
  {
    "path": "config-file-schema.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"Config\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"bindings\": {\n      \"$ref\": \"#/definitions/KeyBindingsConfig\"\n    },\n    \"d2\": {\n      \"$ref\": \"#/definitions/D2Config\"\n    },\n    \"defaults\": {\n      \"description\": \"The default configuration for the presentation.\",\n      \"allOf\": [\n        {\n          \"$ref\": \"#/definitions/DefaultsConfig\"\n        }\n      ]\n    },\n    \"export\": {\n      \"$ref\": \"#/definitions/ExportConfig\"\n    },\n    \"mermaid\": {\n      \"$ref\": \"#/definitions/MermaidConfig\"\n    },\n    \"options\": {\n      \"$ref\": \"#/definitions/OptionsConfig\"\n    },\n    \"snippet\": {\n      \"$ref\": \"#/definitions/SnippetConfig\"\n    },\n    \"speaker_notes\": {\n      \"$ref\": \"#/definitions/SpeakerNotesConfig\"\n    },\n    \"transition\": {\n      \"anyOf\": [\n        {\n          \"$ref\": \"#/definitions/SlideTransitionConfig\"\n        },\n        {\n          \"type\": \"null\"\n        }\n      ]\n    },\n    \"typst\": {\n      \"$ref\": \"#/definitions/TypstConfig\"\n    }\n  },\n  \"additionalProperties\": false,\n  \"definitions\": {\n    \"D2Config\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"scale\": {\n          \"description\": \"The scaling parameter to be used in the d2 CLI.\",\n          \"default\": null,\n          \"type\": [\n            \"number\",\n            \"null\"\n          ],\n          \"format\": \"float\"\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"DefaultsConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"image_protocol\": {\n          \"description\": \"The image protocol to use.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/ImageProtocol\"\n            }\n          ]\n        },\n        \"incremental_lists\": {\n          \"description\": \"The configuration for lists when incremental lists are enabled.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/IncrementalElementConfig\"\n            }\n          ]\n        },\n        \"incremental_tables\": {\n          \"description\": \"The configuration for tables when incremental tables are enabled.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/IncrementalElementConfig\"\n            }\n          ]\n        },\n        \"max_columns\": {\n          \"description\": \"A max width in columns that the presentation must always be capped to.\",\n          \"default\": 65535,\n          \"type\": \"integer\",\n          \"format\": \"uint16\",\n          \"minimum\": 0.0\n        },\n        \"max_columns_alignment\": {\n          \"description\": \"The alignment the presentation should have if `max_columns` is set and the terminal is larger than that.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/MaxColumnsAlignment\"\n            }\n          ]\n        },\n        \"max_rows\": {\n          \"description\": \"A max height in rows that the presentation must always be capped to.\",\n          \"default\": 65535,\n          \"type\": \"integer\",\n          \"format\": \"uint16\",\n          \"minimum\": 0.0\n        },\n        \"max_rows_alignment\": {\n          \"description\": \"The alignment the presentation should have if `max_rows` is set and the terminal is larger than that.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/MaxRowsAlignment\"\n            }\n          ]\n        },\n        \"terminal_font_size\": {\n          \"description\": \"Override the terminal font size when in windows or when using sixel.\",\n          \"default\": 16,\n          \"type\": \"integer\",\n          \"format\": \"uint8\",\n          \"minimum\": 1.0\n        },\n        \"theme\": {\n          \"description\": \"The theme to use by default in every presentation unless overridden.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/ThemeConfig\"\n            }\n          ]\n        },\n        \"validate_overflows\": {\n          \"description\": \"Validate that the presentation does not overflow the terminal screen.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/ValidateOverflows\"\n            }\n          ]\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"ExportConfig\": {\n      \"description\": \"The export configuration.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"dimensions\": {\n          \"description\": \"The dimensions to use for presentation exports.\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/ExportDimensionsConfig\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"pauses\": {\n          \"description\": \"Whether pauses should create new slides.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/PauseExportPolicy\"\n            }\n          ]\n        },\n        \"pdf\": {\n          \"description\": \"The PDF specific export configs.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/PdfExportConfig\"\n            }\n          ]\n        },\n        \"snippets\": {\n          \"description\": \"The policy for executable snippets when exporting.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/SnippetsExportPolicy\"\n            }\n          ]\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"ExportDimensionsConfig\": {\n      \"description\": \"The dimensions to use for presentation exports.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"columns\",\n        \"rows\"\n      ],\n      \"properties\": {\n        \"columns\": {\n          \"description\": \"The number of columns.\",\n          \"type\": \"integer\",\n          \"format\": \"uint16\",\n          \"minimum\": 0.0\n        },\n        \"rows\": {\n          \"description\": \"The number of rows.\",\n          \"type\": \"integer\",\n          \"format\": \"uint16\",\n          \"minimum\": 0.0\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"ExportFontsConfig\": {\n      \"description\": \"The fonts used for exports.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"normal\"\n      ],\n      \"properties\": {\n        \"bold\": {\n          \"description\": \"The path to the font file to be used for the \\\"bold\\\" variable of this font.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"bold_italic\": {\n          \"description\": \"The path to the font file to be used for the \\\"bold+italic\\\" variable of this font.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"italic\": {\n          \"description\": \"The path to the font file to be used for the \\\"italic\\\" variable of this font.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"normal\": {\n          \"description\": \"The path to the font file to be used for the \\\"normal\\\" variable of this font.\",\n          \"type\": \"string\"\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"ImageProtocol\": {\n      \"oneOf\": [\n        {\n          \"description\": \"Automatically detect the best image protocol to use.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"auto\"\n          ]\n        },\n        {\n          \"description\": \"Use the iTerm2 image protocol.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iterm2\"\n          ]\n        },\n        {\n          \"description\": \"Use the iTerm2 image protocol in multipart mode.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iterm2-multipart\"\n          ]\n        },\n        {\n          \"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.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"kitty-local\"\n          ]\n        },\n        {\n          \"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.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"kitty-remote\"\n          ]\n        },\n        {\n          \"description\": \"Use the sixel protocol.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"sixel\"\n          ]\n        },\n        {\n          \"description\": \"The default image protocol to use when no other is specified.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"ascii-blocks\"\n          ]\n        }\n      ]\n    },\n    \"IncrementalElementConfig\": {\n      \"description\": \"The configuration for incrementally shown elements.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"pause_after\": {\n          \"description\": \"Whether to pause after.\",\n          \"default\": null,\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        },\n        \"pause_before\": {\n          \"description\": \"Whether to pause before.\",\n          \"default\": null,\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"KeyBinding\": {\n      \"type\": \"string\"\n    },\n    \"KeyBindingsConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"close_modal\": {\n          \"description\": \"The key binding to close the currently open modal.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"execute_code\": {\n          \"description\": \"The key binding to execute a piece of shell code.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"exit\": {\n          \"description\": \"The key binding to close the application.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"first_slide\": {\n          \"description\": \"The key binding to jump to the first slide.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"go_to_slide\": {\n          \"description\": \"The key binding to jump to a specific slide.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"last_slide\": {\n          \"description\": \"The key binding to jump to the last slide.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"next\": {\n          \"description\": \"The keys that cause the presentation to move forwards.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"next_fast\": {\n          \"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.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"previous\": {\n          \"description\": \"The keys that cause the presentation to move backwards.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"previous_fast\": {\n          \"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.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"reload\": {\n          \"description\": \"The key binding to reload the presentation.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"skip_pauses\": {\n          \"description\": \"The key binding to show the entire slide, after skipping any pauses in it.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"suspend\": {\n          \"description\": \"The key binding to suspend the application.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"toggle_bindings\": {\n          \"description\": \"The key binding to toggle the key bindings modal.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"toggle_layout_grid\": {\n          \"description\": \"The key binding to toggle the layout grid.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        },\n        \"toggle_slide_index\": {\n          \"description\": \"The key binding to toggle the slide index modal.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/KeyBinding\"\n          }\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"LanguageSnippetExecutionConfig\": {\n      \"description\": \"The snippet execution configuration for a specific programming language.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"commands\",\n        \"filename\"\n      ],\n      \"properties\": {\n        \"alternative\": {\n          \"description\": \"Alternative executors for this language.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/SnippetExecutorConfig\"\n          }\n        },\n        \"commands\": {\n          \"description\": \"The commands to be ran when executing snippets for this programming language.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          }\n        },\n        \"environment\": {\n          \"description\": \"The environment variables to set before invoking every command.\",\n          \"default\": {},\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          }\n        },\n        \"filename\": {\n          \"description\": \"The filename to use for the snippet input file.\",\n          \"type\": \"string\"\n        },\n        \"hidden_line_prefix\": {\n          \"description\": \"The prefix to use to hide lines visually but still execute them.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        }\n      }\n    },\n    \"MaxColumnsAlignment\": {\n      \"description\": \"The alignment to use when `defaults.max_columns` is set.\",\n      \"oneOf\": [\n        {\n          \"description\": \"Align the presentation to the left.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"left\"\n          ]\n        },\n        {\n          \"description\": \"Align the presentation on the center.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"center\"\n          ]\n        },\n        {\n          \"description\": \"Align the presentation to the right.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"right\"\n          ]\n        }\n      ]\n    },\n    \"MaxRowsAlignment\": {\n      \"description\": \"The alignment to use when `defaults.max_rows` is set.\",\n      \"oneOf\": [\n        {\n          \"description\": \"Align the presentation to the top.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"top\"\n          ]\n        },\n        {\n          \"description\": \"Align the presentation on the center.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"center\"\n          ]\n        },\n        {\n          \"description\": \"Align the presentation to the bottom.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"bottom\"\n          ]\n        }\n      ]\n    },\n    \"MermaidConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"config_path\": {\n          \"description\": \"A path to a mermaid JSON configuration file to be used by the `mmdc` tool.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"puppeteer_config_path\": {\n          \"description\": \"A path to a puppeteer JSON configuration file to be used by the `mmdc` tool.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"scale\": {\n          \"description\": \"The scaling parameter to be used in the mermaid CLI.\",\n          \"default\": 2,\n          \"type\": \"integer\",\n          \"format\": \"uint32\",\n          \"minimum\": 0.0\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"OptionsConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"auto_render_languages\": {\n          \"description\": \"Assume snippets for these languages contain `+render` and render them automatically.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/SnippetLanguage\"\n          }\n        },\n        \"command_prefix\": {\n          \"description\": \"The prefix to use for commands.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"end_slide_shorthand\": {\n          \"description\": \"Whether to treat a thematic break as a slide end.\",\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        },\n        \"h1_slide_titles\": {\n          \"description\": \"Whether the first `h1` header on a slide should be considered a slide title.\",\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        },\n        \"image_attributes_prefix\": {\n          \"description\": \"The prefix to use for image attributes.\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"implicit_slide_ends\": {\n          \"description\": \"Whether slides are automatically terminated when a slide title is found.\",\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        },\n        \"incremental_lists\": {\n          \"description\": \"Show all lists incrementally, by implicitly adding pauses in between elements.\",\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        },\n        \"incremental_tables\": {\n          \"description\": \"Show all tables incrementally, by implicitly adding pauses in between rows.\",\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        },\n        \"list_item_newlines\": {\n          \"description\": \"The number of newlines in between list items.\",\n          \"type\": [\n            \"integer\",\n            \"null\"\n          ],\n          \"format\": \"uint8\",\n          \"minimum\": 1.0\n        },\n        \"strict_front_matter_parsing\": {\n          \"description\": \"Whether to be strict about parsing the presentation's front matter.\",\n          \"type\": [\n            \"boolean\",\n            \"null\"\n          ]\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"PauseExportPolicy\": {\n      \"description\": \"The policy for pauses when exporting.\",\n      \"oneOf\": [\n        {\n          \"description\": \"Whether to ignore pauses.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"ignore\"\n          ]\n        },\n        {\n          \"description\": \"Create a new slide when a pause is found.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"new_slide\"\n          ]\n        }\n      ]\n    },\n    \"PdfExportConfig\": {\n      \"description\": \"The PDF export specific configs.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"fonts\": {\n          \"description\": \"The path to the font file to be used.\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/ExportFontsConfig\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"SlideTransitionConfig\": {\n      \"type\": \"object\",\n      \"required\": [\n        \"animation\"\n      ],\n      \"properties\": {\n        \"animation\": {\n          \"description\": \"The slide transition style.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/SlideTransitionStyleConfig\"\n            }\n          ]\n        },\n        \"duration_millis\": {\n          \"description\": \"The amount of time to take to perform the transition.\",\n          \"default\": 1000,\n          \"type\": \"integer\",\n          \"format\": \"uint16\",\n          \"minimum\": 0.0\n        },\n        \"frames\": {\n          \"description\": \"The number of frames in a transition.\",\n          \"default\": 30,\n          \"type\": \"integer\",\n          \"format\": \"uint\",\n          \"minimum\": 0.0\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"SlideTransitionStyleConfig\": {\n      \"oneOf\": [\n        {\n          \"description\": \"Slide horizontally.\",\n          \"type\": \"object\",\n          \"required\": [\n            \"style\"\n          ],\n          \"properties\": {\n            \"style\": {\n              \"type\": \"string\",\n              \"enum\": [\n                \"slide_horizontal\"\n              ]\n            }\n          },\n          \"additionalProperties\": false\n        },\n        {\n          \"description\": \"Fade the new slide into the previous one.\",\n          \"type\": \"object\",\n          \"required\": [\n            \"style\"\n          ],\n          \"properties\": {\n            \"style\": {\n              \"type\": \"string\",\n              \"enum\": [\n                \"fade\"\n              ]\n            }\n          },\n          \"additionalProperties\": false\n        },\n        {\n          \"description\": \"Collapse the current slide into the center of the screen.\",\n          \"type\": \"object\",\n          \"required\": [\n            \"style\"\n          ],\n          \"properties\": {\n            \"style\": {\n              \"type\": \"string\",\n              \"enum\": [\n                \"collapse_horizontal\"\n              ]\n            }\n          },\n          \"additionalProperties\": false\n        }\n      ]\n    },\n    \"SnippetConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"exec\": {\n          \"description\": \"The properties for snippet execution.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/SnippetExecConfig\"\n            }\n          ]\n        },\n        \"exec_replace\": {\n          \"description\": \"The properties for snippet execution.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/SnippetExecReplaceConfig\"\n            }\n          ]\n        },\n        \"render\": {\n          \"description\": \"The properties for snippet auto rendering.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/SnippetRenderConfig\"\n            }\n          ]\n        },\n        \"validate\": {\n          \"description\": \"Whether to validate snippets.\",\n          \"default\": false,\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"SnippetExecConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"custom\": {\n          \"description\": \"Custom snippet executors.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/LanguageSnippetExecutionConfig\"\n          }\n        },\n        \"enable\": {\n          \"description\": \"Whether to enable snippet execution.\",\n          \"default\": false,\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"SnippetExecReplaceConfig\": {\n      \"type\": \"object\",\n      \"required\": [\n        \"enable\"\n      ],\n      \"properties\": {\n        \"enable\": {\n          \"description\": \"Whether to enable snippet replace-executions, which automatically run code snippets without the user's intervention.\",\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"SnippetExecutorConfig\": {\n      \"description\": \"A snippet executor configuration.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"commands\",\n        \"filename\"\n      ],\n      \"properties\": {\n        \"commands\": {\n          \"description\": \"The commands to be ran when executing snippets for this programming language.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          }\n        },\n        \"environment\": {\n          \"description\": \"The environment variables to set before invoking every command.\",\n          \"default\": {},\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          }\n        },\n        \"filename\": {\n          \"description\": \"The filename to use for the snippet input file.\",\n          \"type\": \"string\"\n        }\n      }\n    },\n    \"SnippetLanguage\": {\n      \"description\": \"The language of a code snippet.\",\n      \"oneOf\": [\n        {\n          \"type\": \"string\",\n          \"enum\": [\n            \"Ada\",\n            \"Asp\",\n            \"Awk\",\n            \"Bash\",\n            \"BatchFile\",\n            \"C\",\n            \"CMake\",\n            \"Crontab\",\n            \"CSharp\",\n            \"Clojure\",\n            \"Cpp\",\n            \"Css\",\n            \"Dart\",\n            \"D2\",\n            \"DLang\",\n            \"Diff\",\n            \"Docker\",\n            \"Dotenv\",\n            \"Elixir\",\n            \"Elm\",\n            \"Erlang\",\n            \"File\",\n            \"Fish\",\n            \"FSharp\",\n            \"GdScript\",\n            \"Go\",\n            \"GraphQL\",\n            \"Haskell\",\n            \"Html\",\n            \"Java\",\n            \"JavaScript\",\n            \"Json\",\n            \"Jsonnet\",\n            \"Julia\",\n            \"Kotlin\",\n            \"Latex\",\n            \"Lua\",\n            \"Makefile\",\n            \"Mermaid\",\n            \"Markdown\",\n            \"Nix\",\n            \"Nushell\",\n            \"OCaml\",\n            \"Perl\",\n            \"Php\",\n            \"PowerShell\",\n            \"Protobuf\",\n            \"Puppet\",\n            \"Python\",\n            \"R\",\n            \"Racket\",\n            \"Ruby\",\n            \"Rust\",\n            \"RustScript\",\n            \"Scala\",\n            \"Shell\",\n            \"Sql\",\n            \"Swift\",\n            \"Svelte\",\n            \"Tcl\",\n            \"Terraform\",\n            \"Toml\",\n            \"TypeScript\",\n            \"TypeScriptReact\",\n            \"Typst\",\n            \"Xml\",\n            \"Yaml\",\n            \"Verilog\",\n            \"Vue\",\n            \"Wsl\",\n            \"Zig\",\n            \"Zsh\"\n          ]\n        },\n        {\n          \"type\": \"object\",\n          \"required\": [\n            \"Unknown\"\n          ],\n          \"properties\": {\n            \"Unknown\": {\n              \"type\": \"string\"\n            }\n          },\n          \"additionalProperties\": false\n        }\n      ]\n    },\n    \"SnippetRenderConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"threads\": {\n          \"description\": \"The number of threads to use when rendering.\",\n          \"default\": 2,\n          \"type\": \"integer\",\n          \"format\": \"uint\",\n          \"minimum\": 0.0\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"SnippetsExportPolicy\": {\n      \"description\": \"The policy for executable snippets when exporting.\",\n      \"oneOf\": [\n        {\n          \"description\": \"Render all executable snippets in parallel.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"parallel\"\n          ]\n        },\n        {\n          \"description\": \"Render all executable snippets sequentially.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"sequential\"\n          ]\n        }\n      ]\n    },\n    \"SpeakerNotesConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"always_publish\": {\n          \"description\": \"Whether to always publish speaker notes.\",\n          \"default\": false,\n          \"type\": \"boolean\"\n        },\n        \"listen_address\": {\n          \"description\": \"The address in which to listen for speaker note events.\",\n          \"default\": \"127.255.255.255:59418\",\n          \"type\": \"string\"\n        },\n        \"publish_address\": {\n          \"description\": \"The address in which to publish speaker notes events.\",\n          \"default\": \"127.255.255.255:59418\",\n          \"type\": \"string\"\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"ThemeConfig\": {\n      \"anyOf\": [\n        {\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"Theme of the presentation.\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"Automatic dark/light theme switch based on the terminal background luminance.\",\n          \"type\": \"object\",\n          \"required\": [\n            \"dark\",\n            \"light\"\n          ],\n          \"properties\": {\n            \"dark\": {\n              \"description\": \"Dark theme of the presentation.\",\n              \"type\": \"string\"\n            },\n            \"light\": {\n              \"description\": \"Light theme of the presentation.\",\n              \"type\": \"string\"\n            },\n            \"timeout\": {\n              \"description\": \"Light/Dark detection timeout in ms.\",\n              \"type\": [\n                \"integer\",\n                \"null\"\n              ],\n              \"format\": \"uint64\",\n              \"minimum\": 1.0\n            }\n          },\n          \"additionalProperties\": false\n        }\n      ]\n    },\n    \"TypstConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"ppi\": {\n          \"description\": \"The pixels per inch when rendering latex/typst formulas.\",\n          \"default\": 300,\n          \"type\": \"integer\",\n          \"format\": \"uint32\",\n          \"minimum\": 0.0\n        }\n      },\n      \"additionalProperties\": false\n    },\n    \"ValidateOverflows\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"never\",\n        \"always\",\n        \"when_presenting\",\n        \"when_developing\"\n      ]\n    }\n  }\n}"
  },
  {
    "path": "config.sample.yaml",
    "content": "---\n# yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json\ndefaults:\n  # override the terminal font size when in windows or when using sixel.\n  terminal_font_size: 16\n\n  # the theme to use by default in every presentation unless overridden.\n  theme: dark\n\n  # the image protocol to use.\n  image_protocol: kitty-local\n\ntypst:\n  # the pixels per inch when rendering latex/typst formulas.\n  ppi: 300\n\nmermaid:\n  # the scale parameter passed to the mermaid CLI (mmdc).\n  scale: 2\n\noptions:\n  # whether slides are automatically terminated when a slide title is found.\n  implicit_slide_ends: false\n\n  # the prefix to use for commands.\n  command_prefix: \"\"\n\n  # show all lists incrementally, by implicitly adding pauses in between elements.\n  incremental_lists: false\n\n  # this option tells presenterm you don't care about extra parameters in\n  # presentation's front matter. This can be useful if you're trying to load a\n  # presentation made for another tool\n  strict_front_matter_parsing: true\n\n  # whether to treat a thematic break as a slide end.\n  end_slide_shorthand: false\n\nsnippet:\n  exec:\n    # enable code snippet execution. Use at your own risk!\n    enable: true\n\n  exec_replace:\n    # enable code snippet automatic execution + replacing the snippet with its output. Use at your own risk!\n    enable: true\n\n  render:\n    # the number of threads to use when rendering `+render` code snippets.\n    threads: 2\n\nspeaker_notes:\n  # The endpoint to listen for speaker note events.\n  listen_address: \"127.0.0.1:59418\"\n\n  # The endpoint to publish speaker note events.\n  publish_address: \"127.0.0.1:59418\"\n\n  # Whether to always publish speaker notes even when `--publish-speaker-notes` is not set.\n  always_publish: false\n\nbindings:\n  # the keys that cause the presentation to move forwards.\n  next: [\"l\", \"j\", \"<right>\", \"<page_down>\", \"<down>\", \" \"]\n\n  # the keys that cause the presentation to move forwards fast.\n  next_fast: [\"n\"]\n\n  # the keys that cause the presentation to move backwards.\n  previous: [\"h\", \"k\", \"<left>\", \"<page_up>\", \"<up>\"]\n\n  # the keys that cause the presentation to move backwards fast\n  previous_fast: [\"p\"]\n\n  # the key binding to jump to the first slide.\n  first_slide: [\"gg\"]\n\n  # the key binding to jump to the last slide.\n  last_slide: [\"G\"]\n\n  # the key binding to jump to a specific slide.\n  go_to_slide: [\"<number>G\"]\n\n  # the key binding to execute a piece of shell code.\n  execute_code: [\"<c-e>\"]\n\n  # the key binding to reload the presentation.\n  reload: [\"<c-r>\"]\n\n  # the key binding to toggle the slide index modal.\n  toggle_slide_index: [\"<c-p>\"]\n\n  # the key binding to toggle the key bindings modal.\n  toggle_bindings: [\"?\"]\n\n  # the key binding to close the currently open modal.\n  close_modal: [\"<esc>\"]\n\n  # the key binding to close the application.\n  exit: [\"<c-c>\", \"q\"]\n\n  # the key binding to suspend the application.\n  suspend: [\"<c-z>\"]\n\n  # the key binding to skip all pauses in the current slide.\n  skip_pauses: [\"s\"]\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "book/\n"
  },
  {
    "path": "docs/book.toml",
    "content": "[book]\nauthors = [\"mfontanini\"]\nlanguage = \"en\"\nmultilingual = false\nsrc = \"src\"\ntitle = \"presenterm documentation\"\n\n[preprocessor]\n\n[preprocessor.alerts]\n\n[output]\n\n[output.html]\ngit-repository-url = \"https://github.com/mfontanini/presenterm\"\ndefault-theme = \"navy\"\n\n[output.html.redirect]\n# Redirects for broken links after 02/02/2025 restructuring.\n\"/guides/basics.html\" = \"../features/introduction.html\"\n\"/guides/installation.html\" = \"../install.html\"\n\"/guides/code-highlight.html\" = \"../features/code/highlighting.html\"\n\"/guides/mermaid.html\" = \"../features/code/mermaid.html\"\n\n# Redirects for HTML export changes on 05/17/2025.\n\"/features/pdf-export.html\" = \"exports.html\"\n\n"
  },
  {
    "path": "docs/src/SUMMARY.md",
    "content": "# Summary\n\n[Introduction](./introduction.md)\n\n# Docs\n\n- [Install](./install.md)\n- [Features](./features/introduction.md)\n    - [Images](./features/images.md).\n    - [Commands](./features/commands.md).\n    - [Layout](./features/layout.md).\n    - [Code](./features/code/highlighting.md)\n        - [Execution](./features/code/execution.md)\n        - [Mermaid diagrams](./features/code/mermaid.md)\n        - [LaTeX and typst](./features/code/latex.md)\n        - [D2](./features/code/d2.md)\n    - [Themes](./features/themes/introduction.md)\n        - [Definition](./features/themes/definition.md)\n    - [Exports](./features/exports.md)\n    - [Slide transitions](./features/slide-transitions.md)\n    - [Speaker notes](./features/speaker-notes.md)\n- [Configuration](./configuration/introduction.md)\n    - [Options](./configuration/options.md)\n    - [Settings](./configuration/settings.md)\n\n# Internals\n\n- [Parse](./internals/parse.md)\n\n---\n\n[Acknowledgements](./acknowledgements.md)\n"
  },
  {
    "path": "docs/src/acknowledgements.md",
    "content": "## Acknowledgements\n\nThis tool is heavily inspired by:\n\n* [slides][slides_url]\n* [lookatme][lookatme_url]\n* [sli.dev][slide_dev_url]\n\nSupport for code highlighting on many languages is thanks to [bat][bat_url], which contains a \ncustom set of syntaxes that extend [syntect][syntect_url]'s default set of supported languages. \nRun `presenterm --acknowledgements` to get a full list of all the licenses for the binary files being pulled in.\n\n## Contributors\n\nThanks to everyone who's contributed to _presenterm_ in one way or another! This is a list of the users who have \ncontributed code to make _presenterm_ better in some way:\n\n<a href=\"https://github.com/mfontanini/presenterm/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=mfontanini/presenterm\" />\n</a>\n\n<!-- Links -->\n[slides_url]: https://github.com/maaslalani/slides/\n[lookatme_url]: https://github.com/d0c-s4vage/lookatme\n[slide_dev_url]: https://sli.dev/\n[bat_url]: https://github.com/sharkdp/bat\n[syntect_url]: https://github.com/trishume/syntect\n"
  },
  {
    "path": "docs/src/configuration/introduction.md",
    "content": "# Configuration\n\n_presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your \ncustom themes, in the following directories:\n\n* `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise:\n* `~/.config/presenterm/` in Linux.\n* `~/Library/Application Support/presenterm/` in macOS.\n* `~/AppData/Roaming/presenterm/config/` in Windows.\n\nThe configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on \nLinux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file \nwhen running _presenterm_ via the `--config-file` parameter or the `PRESENTERM_CONFIG_FILE` environment variable.\n\nA [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in \nthe repository that you can use as a base.\n\n# Configuration schema\n\nA JSON schema that defines the configuration file's schema is available to be used with YAML language servers such as\n[yaml-language-server](https://github.com/redhat-developer/yaml-language-server).\n\nInclude the following line at the beginning of your configuration file to have your editor pull in autocompletion \nsuggestions and docs automatically:\n\n```yaml\n# yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json\n```\n"
  },
  {
    "path": "docs/src/configuration/options.md",
    "content": "# Options\n\nOptions are special configuration parameters that can be set either in the configuration file under the `options` key, \nor in a presentation's front matter under the same key. This last one allows you to customize a single presentation so \nthat it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation \nwith other people.\n\nThe supported configuration options are currently the following:\n\n## implicit_slide_ends\n\nThis option removes the need to use `<!-- end_slide -->` in between slides and instead assumes that if you use a slide \ntitle, then you're implying that the previous slide ended. For example, the following presentation:\n\n```markdown\n---\noptions:\n  implicit_slide_ends: true\n---\n\nTasty vegetables\n================\n\n* Potato\n\nAwful vegetables\n================\n\n* Lettuce\n```\n\nIs equivalent to this \"vanilla\" one that doesn't use implicit slide ends.\n\n```markdown\nTasty vegetables\n================\n\n* Potato\n\n<!-- end_slide -->\n\nAwful vegetables\n================\n\n* Lettuce\n```\n\n## end_slide_shorthand\n\nThis option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still \nuse `<!-- end_slide -->` but any thematic break will also be considered a slide terminator.\n\n```\n---\noptions:\n  end_slide_shorthand: true\n---\n\nthis is a slide\n\n---------------------\n\nthis is another slide\n```\n\n## h1_slide_titles\n\nThis options allows setting whether the first `h1` heading in a slide will automatically become the slide title:\n\n```\n---\noptions:\n    h1_slide_titles: true\n---\n\n# title\n\n# not the first, so no title\n```\n\n## command_prefix\n\nBecause _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a \ncommand and what isn't. The current heuristic is:\n\n* 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 \n  HTML comment like `<!-- remember to say \"potato\" here -->`, this will raise an error.\n* If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means \n  you can't have a multi-line comment that contains a command like `pause` inside.\n\nDepending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments \nthat are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in \nall commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be \nconsidered a command.\n\nFor example:\n\n```\n---\noptions:\n  command_prefix: \"cmd:\"\n---\n\n<!-- remember to say \"potato here\" -->\n\nTasty vegetables\n================\n\n* Potato\n\n<!-- cmd:pause -->\n\n**That's it!**\n```\n\nIn the example above, the first comment is ignored because it doesn't start with \"cmd:\" and the second one is processed \nbecause it does.\n\n## incremental_lists\n\nIf you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists` \noption:\n\n```\n---\noptions:\n  incremental_lists: true\n---\n\n* pauses\n* in\n* between\n```\n\nKeep in mind if you only want specific bullet points to show up with pauses in between, you can use the \n[`incremental_lists` comment command](../features/commands.md#incremental-lists).\n\n## strict_front_matter_parsing\n\nThis option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful \nif you're trying to load a presentation made for another tool. The following presentation would only be successfully \nloaded if you set `strict_front_matter_parsing` to `false` in your configuration file:\n\n```markdown\n---\npotato: 42\n---\n\n# Hi\n```\n\n## image_attributes_prefix\n\nThe [image size](../features/images.md#image-size) prefix (by default `image:`) can be configured to be anything you \nwould want in case you don't like the default one. For example, if you'd like to set the image size by simply doing \n`![width:50%](path.png)` you would need to set:\n\n```yaml\n---\noptions:\n  image_attributes_prefix: \"\"\n---\n\n![width:50%](path.png)\n```\n\n## auto_render_languages\n\nThis option allows indicating a list of languages for which the `+render` attribute can be omitted in their code \nsnippets and will be implicitly considered to be set. This can be used for languages like `mermaid` so that graphs are \nalways automatically rendered without the need to specify `+render` everywhere.\n\n```yaml\n---\noptions:\n  auto_render_languages:\n    - mermaid\n---\n```\n\n## list_item_newlines\n\nThe option allows configuring the number of newlines in between list items, the default being `1`. This cam also be set \nvia the `list_item_newlines` comment command.\n\n```yaml\n---\noptions:\n    list_item_newlines: 2\n---\n```\n"
  },
  {
    "path": "docs/src/configuration/settings.md",
    "content": "# Settings\n\nAs opposed to options, the rest of these settings **can only be configured via the configuration file**.\n\n## Default theme\n\nThe default theme can be configured only via the config file. When this is set, every presentation that doesn't set a \ntheme explicitly will use this one:\n\n```yaml\ndefaults:\n  theme: light\n```\n\nYou can also set a dark and light theme independently, _presenterm_ will detect terminal theme on launch by fetching\nforeground and background color and pick the right theme:\n\n```yaml\ndefaults:\n  theme:\n    light: light\n    dark: dark\n```\n\n## Terminal font size\n\nThis is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are \nin Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the \nwindow is and resize images properly. Some terminals on other platforms may also have this issue, but that should not be \nas common.\n\nIf you are on Windows or you notice images show up larger/smaller than they should, you can adjust this setting in your \nconfig file:\n\n```yaml\ndefaults:\n  terminal_font_size: 16\n```\n\n## Preferred image protocol\n\nBy default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In case \ndetection for some reason fails in your setup or you'd like to force a different protocol to be used, you can explicitly \nset this via the `--image-protocol` parameter or the configuration key `defaults.image_protocol`:\n\n```yaml\ndefaults:\n  image_protocol: kitty-local\n```\n\nPossible values are:\n* `auto`: try to detect it automatically (default).\n* `kitty-local`: use the kitty protocol in \"local\" mode, meaning both _presenterm_ and the terminal run in the same host \n  and can share the filesystem to communicate.\n* `kitty-remote`: use the kitty protocol in \"remote\" mode, meaning _presenterm_ and the terminal run in different hosts \n  and therefore can only communicate via terminal escape codes.\n* `iterm2`: use the iterm2 protocol.\n* `sixel`: use the sixel protocol.\n\n## Maximum presentation width\n\nThe `max_columns` property can be set to specify the maximum number of columns that the presentation will stretch to. If \nyour terminal is larger than that, the presentation will stick to that size and will be centered, preventing it from \nlooking too stretched.\n\n```yaml\ndefaults:\n  max_columns: 100\n```\n\nIf you would like your presentation to be left or right aligned instead of centered when the terminal is too wide, you \ncan use the `max_columns_alignment` key:\n\n```yaml\ndefaults:\n  max_columns: 100\n  # Valid values: left, center, right\n  max_columns_alignment: left\n```\n\n## Maximum presentation height\n\nThe `max_rows` and `max_rows_alignment` properties are analogous to `max_columns*` to allow capping the maximum number \nof rows:\n\n```yaml\ndefaults:\n  max_rows: 100\n  # Valid values: top, center, bottom\n  max_rows_alignment: left\n```\n\n## Incremental lists behavior\n\nBy default, [incremental lists](../features/commands.md) will pause before and after a list. If you would like to change \nthis behavior, use the `defaults.incremental_lists` key:\n\n```yaml\ndefaults:\n  incremental_lists:\n    # The defaults, change to false if desired.\n    pause_before: true\n    pause_after: true\n```\n\n## Validate terminal overflows\n\nThe `validate_overflows` property allows configuring whether _presenterm_ should make sure your presentation fits in the \ncurrent terminal screen. This allows knowing whether any lines are too long to fit in the screen without having to \nscroll through every slide and manually check for that.\n\nWhen the presentation is first loaded, after the presentation file is modified (if in development mode), and when you \nresize your terminal, _presenterm_ will make sure every slide in it fits. If any of them don't, an error will be \ndisplayed and you will need to resize your terminal until the error goes away or you exit the program.\n\nThis parameter supports multiple options:\n\n* `never`: the default, where overflows aren't validated at all.\n* `always`: overflow validation will always happen when running _presenterm_.\n* `when_presenting`: only perform validation when in present mode. That is, when you're running `presenterm -p`.\n* `when_developing`: only perform validation when running in development mode. That is, any time you're not using \n`presenterm -p`.\n\n```yaml\ndefaults:\n  validate_overflows: always\n```\n\n# Slide transitions\n\nSlide transitions allow animating your presentation every time you move from a slide to the next/previous one. The \nconfiguration for slide transitions is the following:\n\n```yaml\ntransition:\n  # how long the transition should last.\n  duration_millis: 750\n\n  # how many frames should be rendered during the transition\n  frames: 45\n\n  # the animation to use\n  animation:\n    style: <style_name>\n```\n\nSee the [slide transitions page](../features/slide-transitions.md) for more information on which animation styles are \nsupported.\n\n# Key bindings\n\nKey bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following \nis the default configuration:\n\n```yaml\nbindings:\n  # the keys that cause the presentation to move forwards.\n  next: [\"l\", \"j\", \"<right>\", \"<page_down>\", \"<down>\", \" \"]\n\n  # the keys that cause the presentation to move backwards.\n  previous: [\"h\", \"k\", \"<left>\", \"<page_up>\", \"<up>\"]\n\n  # the keys that cause the presentation to move \"fast\" to the next slide. this will ignore:\n  #\n  # * Pauses.\n  # * Dynamic code highlights.\n  # * Slide transitions, if enabled.\n  next_fast: [\"n\"]\n\n  # same as `next_fast` but jumps fast to the previous slide.\n  previous_fast: [\"p\"]\n\n  # the key binding to jump to the first slide.\n  first_slide: [\"gg\"]\n\n  # the key binding to jump to the last slide.\n  last_slide: [\"G\"]\n\n  # the key binding to jump to a specific slide.\n  go_to_slide: [\"<number>G\"]\n\n  # the key binding to execute a piece of shell code.\n  execute_code: [\"<c-e>\"]\n\n  # the key binding to reload the presentation.\n  reload: [\"<c-r>\"]\n\n  # the key binding to toggle the slide index modal.\n  toggle_slide_index: [\"<c-p>\"] \n\n  # the key binding to toggle the key bindings modal.\n  toggle_bindings: [\"?\"] \n\n  # the key binding to close the currently open modal.\n  close_modal: [\"<esc>\"]\n\n  # the key binding to close the application.\n  exit: [\"<c-c>\", \"q\"]\n\n  # the key binding to suspend the application.\n  suspend: [\"<c-z>\"]\n```\n\nYou can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the \ndefault won't apply anymore and only what you've defined will be used.\n\n# Snippet configurations\n\nThe configurations that affect code snippets in presentations.\n\n## Snippet execution\n\n[Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons. \nBesides passing in the `-x` command line parameter every time you run _presenterm_, you can also configure this globally \nfor all presentations by setting:\n\n```yaml\nsnippet:\n  exec:\n    enable: true\n```\n\n**Use this at your own risk**, especially if you're running someone else's presentations!\n\n## Snippet execution + replace\n\n[Snippet execution + replace](../features/code/execution.md#executing-and-replacing) is disabled by default for security \nreasons. Similar to `+exec`, this can be enabled by passing in the `-X` command line parameter or configuring it \nglobally by setting:\n\n```yaml\nsnippet:\n  exec_replace:\n    enable: true\n```\n\n**Use this at your own risk**. This will cause _presenterm_ to execute code without user intervention so don't blindly \nenable this and open a presentation unless you trust its origin!\n\n## Custom snippet executors\n\nIf _presenterm_ doesn't support executing code snippets for your language of choice, please [create an \nissue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by \nsetting:\n\n```yaml\nsnippet:\n  exec:\n    custom:\n      # The keys should be the language identifier you'd use in a code block.\n      c++:\n        # The name of the file that will be created with your snippet's contents.\n        filename: \"snippet.cpp\"\n\n        # A list of environment variables that should be set before building/running your code.\n        environment:\n          MY_FAVORITE_ENVIRONMENT_VAR: foo\n\n        # A prefix that indicates a line that starts with it should not be visible but should be executed if the\n        # snippet is marked with `+exec`.\n        hidden_line_prefix: \"/// \"\n\n        # A list of commands that will be ran one by one in the same directory as the snippet is in.\n        commands:\n          # Compile if first\n          - [\"g++\", \"-std=c++20\", \"snippet.cpp\", \"-o\", \"snippet\"]\n          # Now run it \n          - [\"./snippet\"]\n```\n\nThe output of all commands will be included in the code snippet execution output so if a command (like the `g++` \ninvocation) was to emit any output, make sure to use whatever flags are needed to mute its output.\n\nAlso note that you can override built-in executors in case you want to run them differently (e.g. use `c++23` in the \nexample above).\n\nSee more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file \nwhich defines all of the built-in executors. \n\n## Snippet rendering threads\n\nBecause some `+render` code blocks can take some time to be rendered into an image, especially if you're using \n[mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which \ndefaults to 2, can be configured by setting:\n\n```yaml\nsnippet:\n  render:\n    threads: 2\n```\n\n## Mermaid\n\nThe following configuration parameters can be set to alter the behavior when displaying \n[mermaid](https://mermaid.js.org/) diagrams.\n\n### Config file\n\nA custom [mermaid config file](https://mermaid.ai/open-source/config/schema-docs/config.html) can be configured via the \n`mermaid.config_file` config parameter. This should point to a configuration file where you can set any configs you \nconsider appropriate, such as the font family to use:\n\n```yaml\nmermaid:\n  config_file: /home/foo/my_config_file.yml\n```\n\n### Puppeteer config file\n\nA custom puppeteer config file can be configured via the `mermaid.puppeteer_config_file` config parameter. This should \npoint to a configuration file that will be given to puppeteer by the `mmdc` tool:\n\n```yaml\nmermaid:\n  puppeteer_config_file: /home/foo/puppeteer.json\n```\n\n### Scaling\n\nmermaid graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like to change this use:\n\n\n```yaml\nmermaid:\n  scale: 2\n```\n\n## D2 scaling\n\n[d2](https://d2lang.com/) graphs will use the default scaling when invoking the d2 CLI. If you'd like to change this \nuse:\n\n\n```yaml\nd2:\n  scale: 2\n```\n\n## Enabling speaker note publishing\n\nIf you don't want to run _presenterm_ with `--publish-speaker-notes` every time you want to publish speaker notes, you \ncan set the `speaker_notes.always_publish` attribute to `true`.\n\n```yaml\nspeaker_notes:\n  always_publish: true\n```\n\n# Presentation exports\n\nThe configurations that affect PDF and HTML exports.\n\n## Export size\n\nBy default, the size of each page in the generated PDF and HTML files will depend on the size of your terminal. \n\nIf you would like to instead configure the dimensions by hand, set the `export.dimensions` key:\n\n```yaml\nexport:\n  dimensions:\n    columns: 80\n    rows: 30\n```\n\n## Pause behavior\n\nBy default pauses will be ignored in generated PDF files. If instead you'd like every pause to generate a new page in \nthe export, set the `export.pauses` attribute:\n\n```yaml\nexport:\n  pauses: new_slide\n```\n\n## Sequential snippet execution\n\nWhen generating exports, snippets are executed in parallel to make the process faster. If your snippets require being \nexecuted sequentially, you can use the `export.snippets` parameter:\n\n```yaml\nexport:\n  snippets: sequential\n```\n\n## PDF font \n\nThe PDF export can be configured to use a specific font installed in your system. Use the following keys to do so:\n\n```yaml\nexport:\n  pdf:\n    fonts:\n      normal: /usr/share/fonts/truetype/tlwg/TlwgMono.ttf\n      italic: /usr/share/fonts/truetype/tlwg/TlwgMono-Oblique.ttf\n      bold: /usr/share/fonts/truetype/tlwg/TlwgMono-Bold.ttf\n      bold_italic: /usr/share/fonts/truetype/tlwg/TlwgMono-BoldOblique.ttf\n```\n\n"
  },
  {
    "path": "docs/src/features/code/d2.md",
    "content": "# D2\n\n[D2](https://d2lang.com/) snippets can be converted into images automatically for any snippets tagged with the `d2` \nlanguage and using a `+render` attribute:\n\n~~~markdown\n```d2 +render\nmy_table: {\n  shape: sql_table\n  id: int {constraint: primary_key}\n  last_updated: timestamp with time zone\n}\n```\n~~~\n\n\n**This requires having [d2](https://github.com/terrastruct/d2) installed**.\n\nSimilar to [mermaid diagrams support](mermaid.md), d2 diagrams:\n\n* Will take some time because of how slow the d2 tool is.\n* Can be scaled by using a `+width:<number>%` attribute in the snippet or by setting the `d2.scale` property in the \nconfig file, which is passed along to the `--scale` parameter to the d2 CLI.\n\n## Theme\n\nThe theme of the rendered d2 diagrams can be changed through the `d2.theme` [theme](../themes/introduction.md) \nparameter. See the available themes in the [d2 docs](https://d2lang.com/tour/themes/).\n"
  },
  {
    "path": "docs/src/features/code/execution.md",
    "content": "# Snippet execution\n\n## Executing code blocks\n\nAnnotating a code block with a `+exec` attribute will make it executable. Pressing `control+e` when viewing a slide that \ncontains an executable block, the code in the snippet will be executed and the output of the execution will be displayed \non a box below it. The code execution is stateful so if you switch to another slide and then go back, you will still see \nthe output.\n\n~~~markdown\n```bash +exec\necho hello world\n```\n~~~\n\nCode execution **must be explicitly enabled** by using either:\n\n* The `-x` command line parameter when running _presenterm_.\n* Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config \nfile](../../configuration/settings.md#snippet-execution).\n\nRefer to [the table in the highlighting page](highlighting.md#code-highlighting) for the list of languages for which \ncode execution is supported.\n\n---\n\n[![asciicast](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr.svg)](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr)\n\n> [!warning]\n> Run code in presentations at your own risk! Especially if you're running someone else's presentation. Don't blindly \n> enable snippet execution!\n\n### Output placing\n\nBy default a snippet's output will always show up right below the snippet. However, if you wanted to show the output in \na different place in a slide (e.g. another column) or even in another slide you can do this by:\n\n1. Defining a snippet's identifier:\n\n~~~markdown\n```bash +exec +id:foo\necho hellow world\n```\n~~~\n\n2. Referencing that identifier where you want the output to appear by using the `snippet_output` comment command:\n\n~~~markdown\n<!-- snippet_output: foo -->\n~~~\n\nA single snippet can be referenced multiple times in multiple slides, as long as the slide you're referencing it in \ncomes after the snippet. The snippet will only be executed once, and every `snippet_output` command will display that \nsingle execution's output.\n\n### Validating snippets\n\nWhile you're developing your presentation you probably want to make sure the executable snippets you write in it are \ncorrect and don't contain any syntax errors. While you can do this by constantly pressing `<c-e>` every time you change \na snippet, this is automatically done by _presenterm_ if you pass in the `--validate-snippets` flag.\n\nWhen you pass in this flag, _presenterm_ will:\n\n* Automatically run all `+exec`, `+exec_replace`, and `+validate` snippets as soon as your presentation starts. Note \nthat the `+validate` flag is a special one that doesn't make a snippet executable but still validates it by running it \nduring development.\n* Report an error if any of the snippets returns an exit code other than 0.\n* Re-run all snippets `+exec` and `+exec_replace` snippets every time the presentation is reloaded.\n\nIn case you expect a snippet to return an exit code other than 0, you can use the `+expect:failure` flag. This will \ncause _presenterm_ to display an error if the snippet does not fail.\n\nFor example, the following defines a snippet that's not executable but that will be validated if `--validate-snippets` \nis passed in and will display an error if the snippet does not fail.\n\n```rust +validate +expect:failure\nfn main() {\n    let q = 42;\n    let w = q + \"foo\"; // oops\n}\n```\n\n## Running code in pseudo terminal (PTY)\n\nBy using the `+pty` attribute, you can run a code snippet inside a pseudo terminal. This allows running tools like `top` \nand `htop` which move the cursor around, clear the screen, etc, and have them behave how you'd expect.\n\n[![asciicast](https://asciinema.org/a/PWdPtehxI6xwM7Yt.svg)](https://asciinema.org/a/PWdPtehxI6xwM7Yt)\n\nNote that this can be used with snippets in any language, not necessarily only shell scripts. \n\n> [!note]\n> Support for sending keyboard input into running scripts is planned but not currently supported.\n\n### Specifying PTY size\n\nThe 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 \n`+pty:<columns>:<rows>` syntax, e.g. `+pty:80:30`.\n\n### Standby mode\n\nA `+pty:standby` block indicates the area of the slide the PTY will be rendered on should always be visible, even before \nthe code is executed. Note that if you're also configuring the PTY size, the syntax is `+pty:standby:<columns>:<rows>` \n\n[![asciicast](https://asciinema.org/a/IrclITxMSkBZPPH3.svg)](https://asciinema.org/a/IrclITxMSkBZPPH3)\n\n## Executing and replacing\n\nSimilar to `+exec`, `+exec_replace` causes a snippet to be executable but:\n\n* Execution happens automatically without user intervention.\n* The snippet will be automatically replaced with its execution output.\n\nThis can be useful to run programs that generate some form of ASCII art that you'd like to generate dynamically.\n\n[![asciicast](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD.svg)](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD)\n\nBecause of the risk involved in `+exec_replace`, where code gets automatically executed when running a presentation, \nthis requires users to explicitly opt in to it. This can be done by either passing in the `-X` command line parameter\nor setting the `snippet.exec_replace.enable` flag in your configuration file to `true`.\n\n## Automatic execution\n\nThe `+auto_exec` property behaves like a `+exec` code block but doesn't require pressing `<c-e>` to execute it.\n\n## Alternative executors\n\nSome languages support alternative executors. For example, `rust` code can be ran via \n[`rust-script`](https://rust-script.org/), which allows you to use external crates. These executors can be used by \nspecifying `:<executor-name>` after `+exec` or `+exec_replace`.\n\nFor example, the following `rust` snippet will be executed using `rust-script`:\n\n~~~markdown\n```rust +exec:rust-script\n# //! ```cargo\n# //! [dependencies]\n# //! time = \"0.1.25\"\n# //! ```\n# // The lines above will be hidden\nfn main() {\n    println!(\"the time is {}\", time::now().rfc822z());\n}\n```\n~~~\n\nThe supported alternative executors are:\n\n* `rust-script` for `rust` snippets.\n* `pytest` and `uv` for `python` snippets.\n\n## Code to image conversions\n\nThe `+image` attribute behaves like `+exec_replace` but also assumes the output of the executed snippet will be an \nimage, and it will render it as such. For this to work, the code **must only emit an image in jpg/png formats** and \nnothing else.\n\nFor example, this would render the demo presentation's image:\n\n~~~markdown\n```bash +image\ncat examples/doge.png\n```\n~~~\n\nThis attribute carries the same risks as `+exec_replace` and therefore needs to be enabled via the same flags.\n\n## Executing snippets that need a TTY\n\nIf you're trying to execute a program like `top` that needs to run on a TTY as it renders text, clears the screen, etc, \nyou can use the `+acquire_terminal` modifier on a code already marked as executable with `+exec`. Executing snippets \ntagged with these two attributes will cause _presenterm_ to suspend execution, the snippet will be invoked giving it the \nraw terminal to do whatever it needs, and upon its completion _presenterm_ will resume its execution.\n\n[![asciicast](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT.svg)](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT)\n\n## Styled execution output\n\nSnippets that generate output which contains escape codes that change the colors or styling of the text will be parsed \nand displayed respecting those styles. Do note that you may need to force certain tools to use colored output as they \nwill likely not use it by default.\n\nFor example, to get colored output when invoking `ls` you can use:\n\n~~~markdown\n```bash +exec\nls /tmp --color=always\n```\n~~~\n\nThe parameter or way to enable this will depend on the tool being invoked.\n\n## Hiding code lines\n\nWhen you mark a code snippet as executable via the `+exec` flag, you may not be interested in showing _all the lines_ to \nyour audience, as some of them may not be necessary to convey your point. For example, you may want to hide imports, \nnon-essential functions, initialization of certain variables, etc. For this purpose, _presenterm_ supports a prefix \nunder certain programming languages that let you indicate a line should be executed when running the code but should not \nbe displayed in the presentation.\n\nFor example, in the following code snippet only the print statement will be displayed but the entire snippet will be \nran:\n\n~~~markdown\n```rust\n# fn main() {\nprintln!(\"Hello world!\");\n# }\n```\n~~~\n\nRather than blindly relying on a prefix that may have a meaning in a language, prefixes are chosen on a per language \nbasis. The languages that are supported and their prefix is:\n\n* rust: `# `.\n* python/bash/fish/shell/zsh/kotlin/java/javascript/typescript/c/c++/go: `/// `.\n\nThis means that any line in a rust code snippet that starts with `# ` will be hidden, whereas all lines in, say, a \ngolang code snippet that starts with a `/// ` will be hidden.\n\n## Pre-rendering \n\nSome languages support pre-rendering. This means the code block is transformed into something else when the presentation \nis loaded. The languages that currently support this are _mermaid_, _LaTeX_, and _typst_ where the contents of the code \nblock is transformed into an image, allowing you to define formulas as text in your presentation. This can be done by \nusing the `+render` attribute on a code block.\n\nSee the [LaTeX and typst](latex.md), [mermaid](mermaid.md), and [d2](d2.md) docs for more information.\n\n"
  },
  {
    "path": "docs/src/features/code/highlighting.md",
    "content": "# Code highlighting\n\nCode highlighting is supported for the following languages:\n\n| Language   | Execution support |\n|------------|-------------------|\n| ada        |                   |\n| asp        |                   |\n| awk        |                   |\n| bash       |         ✓         |\n| batchfile  |                   |\n| C          |         ✓         |\n| cmake      |                   |\n| crontab    |                   |\n| C#         |         ✓         |\n| clojure    |                   |\n| C++        |         ✓         |\n| CSS        |                   |\n| D          |                   |\n| diff       |                   |\n| docker     |                   |\n| dotenv     |                   |\n| elixir     |                   |\n| elm        |                   |\n| erlang     |                   |\n| fish       |         ✓         |\n| F#         |         ✓         |\n| go         |         ✓         |\n| haskell    |         ✓         |\n| HTML       |                   |\n| java       |         ✓         |\n| javascript |         ✓         |\n| json       |                   |\n| julia      |         ✓         |\n| kotlin     |         ✓         |\n| latex      |                   |\n| lua        |         ✓         |\n| makefile   |                   |\n| markdown   |                   |\n| nix        |                   |\n| ocaml      |                   |\n| perl       |         ✓         |\n| php        |         ✓         |\n| protobuf   |                   |\n| puppet     |                   |\n| python     |         ✓         |\n| R          |         ✓         |\n| ruby       |         ✓         |\n| rust       |         ✓         |\n| scala      |                   |\n| shell      |         ✓         |\n| sql        |                   |\n| swift      |                   |\n| svelte     |                   |\n| tcl        |                   |\n| toml       |                   |\n| terraform  |                   |\n| typescript |                   |\n| xml        |                   |\n| yaml       |                   |\n| vue        |                   |\n| zig        |                   |\n| zsh        |         ✓         |\n\nOther languages that are supported are:\n\n* nushell, for which highlighting isn't supported but execution is.\n\nIf there's a language that is not in this list and you would like it to be supported, please [create an \nissue](https://github.com/mfontanini/presenterm/issues/new). If you'd also like code execution support, provide details \non how to compile (if necessary) and run snippets for that language. You can also configure how to run code snippet for \na language locally in your [config file](../../configuration/settings.md#custom-snippet-executors).\n\n## Enabling line numbers\n\nIf you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying\nthe language in a code block:\n\n~~~markdown\n```rust +line_numbers\n   fn hello_world() {\n       println!(\"Hello world\");\n   }\n```\n~~~\n\n## Selective highlighting\n\nBy default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be\nhighlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.\n\n~~~markdown\n```rust {1,3,5-7}\n   fn potato() -> u32 {         // 1: highlighted\n                                // 2: not highlighted\n       println!(\"Hello world\"); // 3: highlighted\n       let mut q = 42;          // 4: not highlighted\n       q = q * 1337;            // 5: highlighted\n       q                        // 6: highlighted\n   }                            // 7: highlighted\n```\n~~~\n\n## Dynamic highlighting\n\nSimilar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a\ncode block are highlighted every time you move to the next/previous slide.\n\nThis is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.\nYou can also use `all` to highlight all lines for a particular frame.\n\n~~~markdown\n```rust {1,3|5-7}\n   fn potato() -> u32 {\n\n       println!(\"Hello world\");\n       let mut q = 42;\n       q = q * 1337;\n       q\n   }\n```\n~~~\n\nIn this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines\n1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic\npresentations where you can display sections of the code to explain something specific about each of them.\n\nSee this real example of how this looks like.\n\n[![asciicast](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI.svg)](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI)\n\n## Including external code snippets\n\nThe `file` snippet type can be used to specify an external code snippet that will be included and highlighted as usual. \n\n~~~markdown\n```file +exec +line_numbers\npath: snippet.rs\nlanguage: rust\n```\n~~~\n\nIf you'd like to include only a subset of the file, you can use the optional fields `start_line` and `end_line`:\n\n~~~markdown\n```file +exec +line_numbers\npath: snippet.rs\nlanguage: rust\n# Only show lines 5-10\nstart_line: 5\nend_line: 10\n```\n~~~\n\n## Showing a snippet without a background\n\nUsing the `+no_background` flag will cause the snippet to have no background. This is useful when combining it with the \n`+exec_replace` flag described further down.\n\n## Adding highlighting syntaxes for new languages\n\n_presenterm_ uses the syntaxes supported by [bat](https://github.com/sharkdp/bat) to highlight code snippets, so any \nlanguages supported by _bat_ natively can be added to _presenterm_ easily. Please create a ticket or use \n[this](https://github.com/mfontanini/presenterm/pull/385) as a reference to submit a pull request to make a syntax \nofficially supported by _presenterm_ as well.\n\nIf a language isn't natively supported by _bat_ but you'd like to use it, you can follow\n[this guide in the bat docs](https://github.com/sharkdp/bat#adding-new-syntaxes--language-definitions) and\ninvoke _bat_ directly in a presentation:\n\n~~~markdown\n```bash +exec_replace\nbat --color always script.py\n```\n~~~\n\n> [!note]\n> Check the [code execution docs](execution.md#executing-and-replacing) for more details on how to allow the tool to run \n> `exec_replace` blocks.\n"
  },
  {
    "path": "docs/src/features/code/latex.md",
    "content": "# LaTeX and typst\n\n`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](highlighting.md)) to have \nthem rendered into images when the presentation is loaded. This allows you to define formulas in text rather than having \nto define them somewhere else, transform them into an image, and them embed it.\n\nFor example, the following presentation:\n\n~~~\n# Formulas\n\n```latex +render\n\\[ \\sum_{n=1}^{\\infty} 2^{-n} = 1 \\]\n```\n~~~\n\nWould be rendered like this:\n\n![](../../assets/formula.png)\n\n## Dependencies\n\n### typst\n\nThe engine used to render both of these languages is [typst](https://github.com/typst/typst). _typst_ is easy to \ninstall, lightweight, and boilerplate free as compared to _LaTeX_.\n\n### pandoc\n\nFor _LaTeX_ code rendering both _typst_ and [pandoc](https://github.com/jgm/pandoc) are required. How this works is the \n_LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_. This lets us:\n\n* Have the same look/feel on generated formulas for both languages.\n* Avoid having to write lots of boilerplate _LaTeX_ to make rendering for that language work.\n* Have the same logic to render formulas for both languages, except with a small preparation step for _LaTeX_.\n\n## Controlling PPI\n\n_presenterm_ lets you define how many Pixels Per Inch (PPI) you want in the generated images. This is important because \nas opposed to images that you manually include in your presentation, where you control the exact dimensions, the images \ngenerated on the fly will have a fixed size. Configuring the PPI used during the conversion can let you adjust this: the \nhigher the PPI, the larger the generated images will be.\n\nBecause as opposed to most configurations this is a very environment-specific config, the PPI parameter is not part of \nthe theme definition but is instead has to be set in _presenterm_'s [config file](../../configuration/introduction.md):\n\n```yaml\ntypst:\n  ppi: 400\n```\n\nThe default is 300 so adjust it and see what works for you.\n\n## Image paths\n\nIf you're including an image inside a _typst_ snippet, you must:\n\n* Use absolute paths, e.g. `#image(\"/image1.png\")`.\n* Place the image in the same or a sub path of the path where the presentation is. That is, if your presentation file is \nat `/tmp/foo/presentation.md`, you can place images in `/tmp/foo`, and `/tmp/foo/bar` but not under `/tmp/bar`. This is \nbecause of the absolute path rule above: the path will be considered to be relative to the presentation file's \ndirectory.\n\n## Controlling the image size\n\nYou can also set the generated image's size on a per code snippet basis by using the `+width` modifier which specifies \nthe width of the image as a percentage of the terminal size.\n\n~~~markdown\n```typst +render +width:50%\n$f(x) = x + 1$\n```\n~~~\n\n## Customizations\n\nThe colors and margin of the generated images can be defined in your theme:\n\n```yaml\ntypst:\n  colors:\n    background: ff0000\n    foreground: 00ff00\n\n  # In points\n  horizontal_margin: 2\n  vertical_margin: 2\n```\n"
  },
  {
    "path": "docs/src/features/code/mermaid.md",
    "content": "## Mermaid\n\n[mermaid](https://mermaid.js.org/) snippets can be converted into images automatically in any code snippet tagged with \nthe `mermaid` language and a `+render` tag:\n\n~~~markdown\n```mermaid +render\nsequenceDiagram\n    Mark --> Bob: Hello!\n    Bob --> Mark: Oh, hi mark!\n```\n~~~\n\n**This requires having [mermaid-cli](https://github.com/mermaid-js/mermaid-cli) installed**.\n\nNote that because the mermaid CLI will spin up a browser under the hood, this may not work in all environments and can \nalso be a bit slow (e.g. ~2 seconds to generate every image). Mermaid graphs are rendered asynchronously by a number of \nthreads that can be configured in the [configuration file](../../configuration/settings.md#snippet-rendering-threads). \nThis configuration value currently defaults to 2.\n\nThe size of the rendered image can be configured by changing:\n* The `mermaid.scale` [configuration parameter](../../configuration/settings.md#mermaid-scaling).\n* Using the `+width:<number>%` attribute in the code snippet.\n\nFor example, this diagram will take up 50% of the width of the window and will preserve its aspect ratio:\n\n~~~markdown\n```mermaid +render +width:50%\nsequenceDiagram\n    Mark --> Bob: Hello!\n    Bob --> Mark: Oh, hi mark!\n```\n~~~\n\nIt is recommended to change the `mermaid.scale` parameter until images look big enough and then adjust on an image by \nimage case if necessary using the `+width` attribute. Otherwise, using a small scale and then scaling via `+width` may \ncause the image to become blurry.\n\n## Theme\n\nThe theme of the rendered mermaid diagrams can be changed through the following [theme](../themes/introduction.md) \nparameters:\n\n* `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`).\n* `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use.\n\n## Always render diagrams\n\nIf you don't want to use `+render` every time, you can configure which languages get this automatically via the [config \nfile](../../configuration/settings.md#auto_render_languages).\n"
  },
  {
    "path": "docs/src/features/commands.md",
    "content": "# Comment commands\n\n_presenterm_ uses \"comment commands\" in the form of HTML comments to let the user specify certain behaviors that can't \nbe specified by vanilla markdown.\n\n## Pauses\n\nPauses allow the sections of the content in your slide to only show up when you advance in your presentation. That is, \nonly after you press, say, the right arrow will a section of the slide show up. This can be done by the `pause` comment \ncommand:\n\n```html\n<!-- pause -->\n```\n\n## Font size\n\nThe font size can be changed by using the `font_size` command:\n\n```html\n<!-- font_size: 2 -->\n```\n\nThis causes the remainder of the slide to use the font size specified. The font size can range from 1 to 7, 1 being the \ndefault.\n\n> [!note]\n> This is currently only supported in the [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal and only as of version \n> 0.40.0. See the notes on font sizes on the [introduction page](introduction.md#font-sizes) for more information on \n> this.\n\n## Jumping to the vertical center\n\nThe command `jump_to_middle` lets you jump to the middle of the page vertically. This is useful in combination\nwith slide titles to create separator slides:\n\n```markdown\nblablabla\n\n<!-- end_slide -->\n\n<!-- jump_to_middle -->\n\nFarming potatoes\n===\n\n<!-- end_slide -->\n```\n\nThis will create a slide with the text \"Farming potatoes\" in the center, rendered using the slide title style.\n\n## Explicit new lines\n\nThe `newline`/`new_line` and `newlines`/`new_lines` commands allow you to explicitly create new lines. Because markdown \nignores multiple line breaks in a row, this is useful to create some spacing where necessary:\n\n```markdown\nhi\n\n<!-- new_lines: 10 -->\n\nmom\n\n<!-- new_line -->\n\nbye\n```\n\n## Incremental lists\n\nUsing `<!-- pause -->` in between each bullet point a list is a bit tedious so instead you can use the \n`incremental_lists` command to tell _presenterm_ that **until the end of the current slide** you want each individual \nbullet point to appear only after you move to the next slide:\n\n```markdown\n<!-- incremental_lists: true -->\n\n* this\n* appears\n* one after\n* the other\n\n<!-- incremental_lists: false -->\n\n* this appears\n* all at once\n```\n\n## Number of lines in between list items\n\nThe `list_item_newlines` option lets you configure the number of new lines in between list items in the remainder of a \nslide. 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 \nslide. This can also be configured for all lists via the [`options.list_item_newlines` \noption](../configuration/options.md#list_item_newlines).\n\n```markdown\n<!-- list_item_newlines: 2 -->\n\n* this\n* is\n* more\n* spaced\n```\n\n## Including external markdown files\n\nBy using the `include` command you can include the contents of an external markdown file as if it was part of the \noriginal presentation file:\n\n```markdown\n<!-- include: foo.md -->\n```\n\nAny files referenced by an included file will have their paths relative to that path. e.g. if you include `foo/bar.md` \nand that file contains an image `tar.png`, that image will be looked up in `foo/tar.png`.\n\n## No footer\n\nIf you don't want the footer to show up in some particular slide for some reason, you can use the `no_footer` command:\n\n```html\n<!-- no_footer -->\n```\n\n## Skip slide\n\nIf you don't want a specific slide to be included in the presentation use the `skip_slide` command:\n\n```html\n<!-- skip_slide -->\n```\n\n## Text alignment\n\nThe text alignment for the remainder of the slide can be configured via the `alignment` command, which can use values: \n`left`, `center`, and `right`:\n\n```markdown\n<!-- alignment: left -->\n\nleft alignment, the default\n\n<!-- alignment: center -->\n\ncentered\n\n<!-- alignment: right -->\n\nright aligned\n```\n\n\n## User comments\n\nUser comments such as personal notes, TODOs, and other documentation that will\nbe ignored during presentation rendering can be added using these formats:\n\n```markdown\n<!-- // This is a user comment -->\n<!-- comment: This is also a user comment which won't be rendered -->\n```\n\nThese are useful for:\n\n- Personal notes and reminders.\n- TODO items and planning notes.\n- Source references and attribution.\n\n## Listing available comment commands\n\nThe `--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.\n\n### Purpose\n\nThis feature is designed to:\n- Provide a machine-readable list of all comment commands\n- Enable editor integrations for autocompletion and snippets\n- Allow validation of comment commands in external tools\n- Serve as a quick reference without consulting documentation\n\n### Usage\n\n```bash\n# List all available comment commands\npresenterm --list-comment-commands\n\n# Use with fzf for interactive selection\npresenterm --list-comment-commands | fzf\n\n# Pipe to grep to filter specific commands\npresenterm --list-comment-commands | grep alignment\n```\n\n### Output format\n\nEach command is output on a separate line with appropriate default values where applicable:\n\n```\n<!-- pause -->\n<!-- end_slide -->\n<!-- new_line -->\n<!-- new_lines: 2 -->\n<!-- jump_to_middle -->\n<!-- column_layout: [1, 2] -->\n<!-- column: 0 -->\n<!-- reset_layout -->\n<!-- incremental_lists: true -->\n<!-- incremental_lists: false -->\n<!-- no_footer -->\n<!-- font_size: 2 -->\n<!-- alignment: left -->\n<!-- alignment: center -->\n<!-- alignment: right -->\n<!-- skip_slide -->\n<!-- list_item_newlines: 2 -->\n<!-- include: file.md -->\n<!-- speaker_note: Your note here -->\n<!-- snippet_output: identifier -->\n```\n\n### Editor integration example: Vim\n\nFor Vim users with fzf.vim installed, you can add this to your `.vimrc` to enable quick insertion of comment commands:\n\n```vim\n\" Presenterm comment command helper\nif executable('presenterm') && executable('fzf')\n  inoremap <expr> <c-k> fzf#vim#complete(fzf#wrap({\n        \\ 'source':  'presenterm --list-comment-commands',\n        \\ 'options': '--header \"Comment Command Selection\" --no-hscroll',\n        \\ 'reducer': { lines -> lines[0] } }))\nendif\n```\n\nWith 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.\n"
  },
  {
    "path": "docs/src/features/exports.md",
    "content": "# Exporting presentations\n\nPresentations can be exported to PDF and HTML, to allow easily sharing the slide deck at the end of a presentation.\n\n## PDF\n\nPresentations can be converted into PDF by using [weasyprint](https://pypi.org/project/weasyprint/). Follow their \n[installation instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) since it may require you \nto install extra dependencies for the tool to work.\n\n> [!note]\n> If you were using _presenterm-export_ before it was deprecated, that tool already required _weasyprint_ so it is \n> already installed in whatever virtual env you were using and there's nothing to be done.\n\n\nAfter you've installed _weasyprint_, run _presenterm_ with the `--export-pdf` parameter to generate the output PDF:\n\n```bash\npresenterm --export-pdf examples/demo.md\n```\n\nThe output PDF will be placed in `examples/demo.pdf`. Alternatively you can use the `--output` flag to specify where you \nwant the output file to be written to.\n\n> [!note]\n> If you're using a separate virtual env to install _weasyprint_ just make sure you activate it before running \n> _presenterm_ with the `--export-pdf` parameter.\n\n> [!note]\n> If you have [uv](https://github.com/astral-sh/uv) installed you can simply run: \n> ```bash\n> uv run --with weasyprint presenterm --export-pdf examples/demo.md\n> ```\n\n## HTML\n\nSimilarly, using the `--export-html` parameter allows generating a single self contained HTML file that contains all \nimages and styles embedded in it. As opposed to PDF exports, this requires no extra dependencies:\n\n```bash\npresenterm --export-html examples/demo.md\n```\n\nThe output file will be placed in `examples/demo.html` but this behavior can be configured via the `--output` flag just \nlike for PDF exports.\n\n# Configurable behavior\n\nSee the [settings page](../configuration/settings.md#presentation-exports) to see all the configurable behavior around \npresentation exports.\n\n"
  },
  {
    "path": "docs/src/features/images.md",
    "content": "# Images\n\nImages are supported and will render in your terminal as long as it supports either the [iterm2 image \nprotocol](https://iterm2.com/documentation-images.html), the [kitty graphics \nprotocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), or [sixel](https://saitoha.github.io/libsixel/). Some of \nthe terminals where at least one of these is supported are:\n\n* [kitty](https://sw.kovidgoyal.net/kitty/)\n* [iterm2](https://iterm2.com/)\n* [WezTerm](https://wezfurlong.org/wezterm/index.html)\n* [ghostty](https://ghostty.org/)\n* [foot](https://codeberg.org/dnkl/foot)\n\n---\n\nThings you should know when using image tags in your presentation's markdown are:\n* Image paths are relative to your presentation path. That is a tag like `![](food/potato.png)` will be looked up at \n  `$PRESENTATION_DIRECTORY/food/potato.png`.\n* Images will be rendered by default in their original size. That is, if your terminal is 300x200px and your image is \n200x100px, it will take up 66% of your horizontal space and 50% of your vertical space.\n* The exception to the point above is if the image does not fit in your terminal, it will be resized accordingly while \n  preserving the aspect ratio.\n* If your terminal does not support any of the graphics protocol above, images will be rendered using ascii blocks. It \n  ain't great but it's something!\n* Remote images are not supported [by design](https://github.com/mfontanini/presenterm/issues/213#issuecomment-1950342423).\n\n## tmux\n\nIf you're using tmux, you will need to enable the [allow-passthrough \noption](https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it) for images to \nwork correctly.\n\n## Image size\n\nThe size of each image can be set by using the `image:width` or `image:w` attributes in the image tag. For example, the \nfollowing will cause the image to take up 50% of the terminal width:\n\n```markdown\n![image:width:50%](image.png)\n```\n\nThe image will always be scaled to preserve its aspect ratio and it will not be allowed to overflow vertically nor \nhorizontally.\n\n## Protocol detection\n\nBy default the image protocol to be used will be automatically detected. In cases where this detection fails, you can \nset it manually via the `--image-protocol` parameter or by setting it in the [config \nfile](../configuration/settings.md#preferred-image-protocol).\n"
  },
  {
    "path": "docs/src/features/introduction.md",
    "content": "# Introduction\n\nThis guide teaches you how to use _presenterm_. At this point you should have already installed _presenterm_, otherwise \nvisit the [installation](../install.md) guide to get started.\n\n## Quick start\n\nDownload the demo presentation and run it using:\n\n```bash\ngit clone https://github.com/mfontanini/presenterm.git\ncd presenterm\npresenterm examples/demo.md\n```\n\n# Presentations\n\nA presentation in _presenterm_ is a single markdown file. Every slide in the presentation file is delimited by a line \nthat contains a single HTML comment:\n\n```html\n<!-- end_slide -->\n```\n\nPresentations can contain most commonly used markdown elements such as ordered and unordered lists, headings, formatted \ntext (**bold**, _italics_, ~strikethrough~, `inline code`, etc), code blocks, block quotes, tables, etc.\n\n## Introduction slide\n\nBy setting a front matter at the beginning of your presentation you can configure the title, sub title, author and other \nmetadata about your presentation. Doing so will cause _presenterm_ to create an introduction slide:\n\n```yaml\n---\ntitle: \"My _first_ **presentation**\"\nsub_title: (in presenterm!)\nauthor: Myself\n---\n```\n\nAll of these attributes are optional and should be avoided if an introduction slide is not needed. Note that the `title` \nkey can contain arbitrary markdown so you can use bold, italics, `<span>` tags, etc.\n\n### Multiple authors\n\nIf you're creating a presentation in which there's multiple authors, you can use the `authors` key instead of `author`\nand list them all this way:\n\n```yaml\n---\ntitle: Our first presentation\nauthors:\n  - Me\n  - You\n---\n```\n\n## Slide titles\n\nAny [setext header](https://spec.commonmark.org/0.30/#setext-headings) will be considered to be a slide title and will \nbe rendered in a more slide-title-looking way. By default this means it will be centered, some vertical padding will be \nadded and the text color will be different.\n\n~~~markdown\nHello\n===\n~~~\n\n> [!note]\n> See the [themes](themes/introduction.md) section on how to customize the looks of slide titles and any other element \n> in a presentation.\n\n## Ending slides\n\nWhile other applications use a thematic break (`---`) to mark the end of a slide, _presenterm_ uses a special \n`end_slide` HTML comment:\n\n```html\n<!-- end_slide -->\n```\n\nThis makes the end of a slide more explicit and easy to spot while you're editing your presentation. See the \n[configuration](../configuration/options.md#implicit_slide_ends) if you want to customize this behavior.\n\nIf you really would prefer to use thematic breaks (`---`) to delimit slides, you can do that by enabling the \n[`end_slide_shorthand`](../configuration/options.md#end_slide_shorthand) options.\n\n## Colored text\n\n`span` HTML tags can be used to provide foreground and/or background colors to text. There's currently two ways to \nspecify colors:\n\n* Via the `style` attribute, in which only the CSS attributes `color` and `background-color` can be used to set the \nforeground and background colors respectively. Colors used in both CSS attributes can refer to \n[theme palette colors](themes/definition.md#color-palette) by using the `palette:<name>` or `p:<name` syntaxes.\n* Via the `class` attribute, which must point to a class defined in the [theme \npalette](themes/definition.md#color-palette). Classes allow configuring foreground/background color combinations to be \nused across your presentation.\n\nFor example, the following will use `ff0000` as the foreground color and whatever the active theme's palette defines as \n`foo`:\n\n```markdown\n<span style=\"color: #ff0000; background-color: palette:foo\">colored text!</span>\n```\n\nAlternatively, can you can define a class that contains a foreground/background color combination in your theme's \npalette and use it:\n\n```markdown\n<span class=\"my_class\">colored text!</span>\n```\n\n> [!note]\n> Keep in mind **only `span` tags are supported**.\n\n## Font sizes\n\nThe [_kitty_](https://sw.kovidgoyal.net/kitty/) terminal added in version 0.40.0 support for a new protocol that allows \nTUIs to specify the font size to be used when printing text. _presenterm_ is one of the first applications supports this \nprotocol in various places:\n\n* Themes can specify it in the presentation title in the introduction slide, in slide titles, and in headers by using \nthe `font_size` property. All built in themes currently set font size to 2 (1 is the default) for these elements.\n* Explicitly by using the `font_size` comment command:\n\n```markdown\n# Normal text\n\n<!-- font_size: 2 -->\n\n# Larger text\n```\n\nTerminal support for this feature is verified when _presenterm_ starts and any attempt to change the font size, be it \nvia the theme or via the comment command, will be ignored if it's not supported.\n\n# Key bindings\n\nNavigation within a presentation should be intuitive: jumping to the next/previous slide can be done by using the arrow \nkeys, _hjkl_, and page up/down keys.\n\nBesides this:\n\n* Jumping to the first slide: `gg`.\n* Jumping to the last slide: `G`.\n* Jumping to a specific slide: `<slide-number>G`.\n* Exit the presentation: `<ctrl>c`.\n\nYou can check all the configured keybindings by pressing `?` while running _presenterm_.\n\n## Configuring key bindings\n\nIf you don't like the default key bindings, you can override them in the [configuration \nfile](../configuration/settings.md#key-bindings).\n\n# Modals\n\n_presenterm_ currently has 2 modals that can provide some information while running the application. Modals can be \ntoggled using some key combination and can be hidden using the escape key by default, but these can be configured via \nthe [configuration file key bindings](../configuration/settings.md#key-bindings).\n\n## Slide index modal\n\nThis modal can be toggled by default using `control+p` and lets you see an index that contains a row for every slide in \nthe presentation, including its title and slide index. This allows you to find a slide you're trying to jump to \nquicklier rather than scanning through each of them.\n\n[![asciicast](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi.svg)](https://asciinema.org/a/1VgRxVIEyLrMmq6OZ3oKx4PGi)\n\n## Key bindings modal\n\nThe key bindings modal displays the key bindings for each of the supported actions and can be opened by pressing `?`.\n\n## Toggle visual grid\n\nPress uppercase `T` by default to toggle the layout grid. This is useful when using a column layout and trying to \nunderstand how wide each column is. See [this PR](https://github.com/mfontanini/presenterm/pull/718) for more details.\n\n# Hot reload\n\nUnless you run in presentation mode by passing in the `--present` parameter, _presenterm_ will automatically reload your \npresentation file every time you save it. _presenterm_ will also automatically detect which specific slide was modified \nand jump to it so you don't have to be jumping back and forth between the source markdown and the presentation to see \nhow the changes look like.\n\n[![asciicast](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3.svg)](https://asciinema.org/a/bu9ITs8KhaQK5OdDWnPwUYKu3)\n"
  },
  {
    "path": "docs/src/features/layout.md",
    "content": "# Layouts\n\n_presenterm_ currently supports a column layout that lets you split parts of your slides into column. This allows you to \nput text on one side, and code/images on the other, or really organize markdown into columns in any way you want.\n\nThis is done by using commands, just like `pause` and `end_slide`, in the form of HTML comments. This section describes \nhow to use those.\n\n## Wait, why not HTML?\n\nWhile markdown _can_ contain HTML tags (beyond comments!) and we _could_ represent this using divs with alignment, I \ndon't really want to:\n\n1. Deal with HTML and all the implications this would have. e.g. nesting many divs together and all the chaos that would \n   bring to the rendering code.\n2. Require people to write HTML when we have such a narrow use-case for it here: we only want column layouts.\n\nBecause of this, _presenterm_ doesn't let you use HTML and instead has a custom way of specifying column layouts.\n\n## Column layout\n\nThe way to specify column layouts is by first creating a layout, and then telling _presenterm_ you want to enter each of \nthe column in it as you write your presentation.\n\n### Defining layouts\n\nDefining a layout is done via the `column_layout` command, in the form of an HTML comment:\n\n```html\n<!-- column_layout: [3, 2] -->\n```\n\nThis defines a layout with 2 columns where:\n* The total number of \"size units\" is `3 + 2 = 5`. You can think of this as the terminal screen being split into 5 \n  pieces vertically.\n* The first column takes 3 out of those 5 pieces/units, or in other words 60% of the terminal screen.\n* The second column takes 2 out of those 5 pieces/units, or in other words 40% of the terminal screen.\n\nYou can use any number of columns and with as many units you want on each of them. This lets you decide how to structure\nthe presentation in a fairly straightforward way.\n\n### Using columns\n\nOnce a layout is defined, you just need to specify that you want to enter a column before writing any text to it by \nusing the `column` command:\n\n```html\n<!-- column: 0 -->\n```\n\nNow all the markdown you write will be placed on the first column until you either:\n\n* Reset the layout by using the `reset_layout` command.\n* The slide ends.\n* You jump into another column by using the `column` command again.\n\n## Example\n\nThe following example puts all of this together by defining 2 columns, one with some code and bullet points, another one \nwith an image, and some extra text at the bottom that's not tied to any columns.\n\n~~~markdown\nLayout example\n==============\n\n<!-- column_layout: [2, 1] -->\n\n<!-- column: 0 -->\n\nThis is some code I like:\n\n```rust\nfn potato() -> u32 {\n    42\n}\n```\n\nThings I like about it:\n1. Potato\n2. Rust\n3. The image on the right\n\n<!-- column: 1 -->\n\n![](examples/doge.png)\n\n_Picture by Alexis Bailey / CC BY-NC 4.0_\n\n<!-- reset_layout -->\n\nBecause we just reset the layout, this text is now below both of the columns.\n~~~\n\nThis would render the following way:\n\n![](../assets/layouts.png)\n\n## Other uses\n\nBesides organizing your slides into columns, you can use column layouts to center a piece of your slide. For example, if \nyou want a certain portion of your slide to be centered, you could define a column layout like `[1, 3, 1]` and then only \nwrite content into the middle column. This would make your content take up the center 60% of the screen.\n\n"
  },
  {
    "path": "docs/src/features/slide-transitions.md",
    "content": "# Slide transitions\n\nSlide transitions allow animating your presentation every time you move from a slide to the next/previous one. See the \n[configuration page](../configuration/settings.md#slide-transitions) to learn how to configure transitions.\n\nThe following animations are supported:\n\n## `fade`\n\nFade the current slide into the next one.\n\n[![asciicast](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw.svg)](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw)\n\n## `slide_horizontal`\n\nSlide horizontally to the next/previous slide.\n\n[![asciicast](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ.svg)](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ)\n\n## `collapse_horizontal`\n\nCollapse the current slide into the center of the screen horizontally.\n\n[![asciicast](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW.svg)](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW)\n"
  },
  {
    "path": "docs/src/features/speaker-notes.md",
    "content": "## Speaker notes\n\nStarting on version 0.10.0, _presenterm_ allows presentations to define speaker notes. The way this works is:\n\n* You start an instance of _presenterm_ using the `--publish-speaker-notes` parameter. This will be the main instance in \nwhich you will present like you usually do.\n* Another instance should be started using the `--listen-speaker-notes` parameter. This instance will only display \nspeaker notes in the presentation and will automatically change slides whenever the main instance does so.\n\nFor example:\n\n```bash\n# Start the main instance\npresenterm demo.md --publish-speaker-notes\n\n# In another shell: start the speaker notes instance\npresenterm demo.md --listen-speaker-notes\n```\n\n[![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J)\n\nSee the [speaker notes example](https://github.com/mfontanini/presenterm/blob/master/examples/speaker-notes.md) for more \ninformation.\n\n### Defining speaker notes\n\nIn order to define speaker notes you can use the `speaker_notes` comment command:\n\n```markdown\nNormal text\n\n<!-- speaker_note: this is a speaker note -->\n\nMore text\n```\n\nWhen running this two instance setup, the main one will show \"normal text\" and \"more text\", whereas the second one will \nonly show \"this is a speaker note\" on that slide.\n\n### Multiline speaker notes\n\nYou can use multiline speaker notes by using the appropriate YAML syntax:\n\n```yaml\n<!-- \nspeaker_note: |\n  something\n  something else\n-->\n```\n\n### Multiple instances\n\nOn Linux and Windows, you can run multiple instances in publish mode and multiple instances in listen mode at the same \ntime. Each instance will only listen to events for the presentation it was started on.\n\nOn Mac this is not supported and only a single listener can be used at a time.\n\n### Enabling publishing by default\n\nYou can use the `speaker_notes.always_publish` key in your config file to always publish speaker notes. This means you \nwill only ever need to use `--listen-speaker-notes` and you will never need to use `--publish-speaker-notes`:\n\n```yaml\nspeaker_notes:\n  always_publish: true\n```\n\n### Internals\n\nThis uses UDP sockets on localhost to communicate between instances. The main instance sends events every time a slide \nis shown and the listener instances listen to them and displays the speaker notes for that specific slide.\n"
  },
  {
    "path": "docs/src/features/themes/definition.md",
    "content": "# Theme definition\n\nThis section goes through the structure of the theme files. Have a look at some of the [existing \nthemes](https://github.com/mfontanini/presenterm/tree/master/themes) to have an idea of how to structure themes. \n\n## Root elements\n\nThe root attributes on the theme yaml files specify either:\n\n* A specific type of element in the input markdown or rendered presentation. That is, the slide title, headings, footer, \n  etc.\n* A default to be applied as a fallback if no specific style is specified for a particular element.\n\n## Alignment\n\n_presenterm_ uses the notion of alignment, just like you would have in a GUI editor, to align text to the left, center, \nor right. You probably want most elements to be aligned left, _some_ to be aligned on the center, and probably none to \nthe right (but hey, you're free to do so!).\n\nThe following elements support alignment:\n* Code blocks.\n* Slide titles.\n* The title, subtitle, and author elements in the intro slide.\n* Tables.\n\n### Left/right alignment\n\nLeft and right alignments take a margin property which specifies the number of columns to keep between the text and the \nleft/right terminal screen borders. \n\nThe margin can be specified in two ways:\n\n#### Fixed\n\nA specific number of characters regardless of the terminal size.\n\n```yaml\nalignment: left\nmargin:\n  fixed: 5\n```\n\n#### Percent\n\nA percentage over the total number of columns in the terminal.\n\n```yaml\nalignment: left\nmargin:\n  percent: 8\n```\n\nPercent alignment tends to look a bit nicer as it won't change the presentation's look as much when the terminal size \nchanges.\n\n### Center alignment\n\nCenter alignment has 2 properties:\n* `minimum_size` which specifies the minimum size you want that element to have. This is normally useful for code blocks \n  as they have a predefined background which you likely want to extend slightly beyond the end of the code on the right.\n* `minimum_margin` which specifies the minimum margin you want, using the same structure as `margin` for left/right \n  alignment. This doesn't play very well with `minimum_size` but in isolation it specifies the minimum number of columns \n  you want to the left and right of your text.\n\n## Colors\n\nEvery element can have its own background/foreground color using hex notation:\n\n```yaml\ndefault:\n  colors:\n    foreground: ff0000\n    background: 00ff00\n```\n\n## Default style\n\nThe default style specifies:\n\n* The margin to be applied to all slides.\n* The colors to be used for all text.\n\n```yaml\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: \"e6e6e6\"\n    background: \"040312\"\n```\n\n## Intro slide\n\nThe introductory slide will be rendered if you specify a title, subtitle, or author in the presentation's front matter. \nThis lets you have a less markdown-looking introductory slide that stands out so that it doesn't end up looking too \nmonotonous:\n\n```yaml\n---\ntitle: Presenting from my terminal\nsub_title: Like it's 1990\nauthor: John Doe\n---\n```\n\nThe theme can specify:\n* For the title and subtitle, the alignment and colors.\n* For the author, the alignment, colors, and positioning (`page_bottom` and `below_title`). The first one will push it \n  to the bottom of the screen while the second one will put it right below the title (or subtitle if there is one)\n\nFor example:\n\n```yaml\nintro_slide:\n  title:\n    alignment: left\n    margin:\n      percent: 8\n  author:\n    colors:\n      foreground: black\n    positioning: below_title\n```\n\n## Footer\n\nThe footer currently comes in 3 flavors:\n\n### Template footers\n\nA template footer lets you put text on the left, center and/or right of the screen. The template strings\ncan reference `{current_slide}` and `{total_slides}` which will be replaced with the current and total number of slides.\n\nBesides those special variables, any of the attributes defined in the front matter can also be used:\n\n* `title`.\n* `sub_title`.\n* `event`.\n* `location`.\n* `date`.\n* `author`.\n\nStrings used in template footers can contain arbitrary markdown, including `span` tags that let you use colored text. A \n`height` attribute allows specifying how tall, in terminal rows, the footer is. The text in the footer will always be \nplaced at the center of the footer area. The default footer height is 2.\n\n```yaml\nfooter:\n  style: template\n  left: \"My **name** is {author}\"\n  center: \"_@myhandle_\"\n  right: \"{current_slide} / {total_slides}\"\n  height: 3\n```\n\nDo note that:\n\n* Only existing attributes in the front matter can be referenced. That is, if you use `{date}` but the `date` isn't set, \nan error will be shown.\n* Similarly, referencing unsupported variables (e.g. `{potato}`) will cause an error to be displayed. If you'd like the \n`{}` characters to be used in contexts where you don't want to reference a variable, you will need to escape them by \nusing another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farms`.\n\n#### Footer images\n\nBesides text, images can also be used in the left/center/right positions. This can be done by specifying an `image` key \nunder each of those attributes:\n\n```yaml\nfooter:\n  style: template\n  left:\n    image: potato.png\n  center:\n    image: banana.png\n  right:\n    image: apple.png\n  # The height of the footer to adjust image sizes\n  height: 5\n```\n\nImages will be looked up:\n\n* First, relative to the presentation file just like any other image.\n* If the image is not found, it will be looked up relative to the themes directory. e.g. `~/.config/presenterm/themes`. \nThis allows you to define a custom theme in your themes directory that points to a local image within that same \nlocation.\n\nImages will preserve their aspect ratio and expand vertically to take up as many terminal rows as `footer.height` \nspecifies. This parameter should be adjusted accordingly if taller-than-wider images are used in a footer.\n\nSee the [footer example](https://github.com/mfontanini/presenterm/blob/master/examples/footer.md) as a showcase of how a \nfooter can contain images and colored text.\n\n![](../../assets/example-footer.png)\n\n### Progress bar footers\n\nA progress bar that will advance as you move in your presentation. This will by default use a block-looking character to \ndraw the progress bar but you can customize it:\n\n```yaml\nfooter:\n  style: progress_bar\n\n  # Optional!\n  character: 🚀\n```\n\n### None\n\nNo footer at all!\n\n```yaml\nfooter:\n  style: empty\n```\n\n\n## Slide title\n\nSlide titles, as specified by using a setext header, can be styled the following way:\n\n```yaml\nslide_title:\n  # The prefix to use for the slide title.\n  prefix: \"██\"\n\n  # The font size to use.\n  font_size: 2\n\n  # The vertical padding added before the title.\n  padding_top: 1\n\n  # The vertical padding added after the title.\n  padding_bottom: 1\n\n  # Whether to use a horizontal separator line after the title.\n  separator: true\n\n  # Whether to style for the title using bold text.\n  bold: true\n\n  # Whether to style for the title using underlined text.\n  underlined: true\n\n  # Whether to style for the title using italics text.\n  italics: true\n\n  # The colors to use.\n  colors:\n    foreground: beeeff\n    background: feeedd\n```\n\n## Headings\n\nEvery header type (h1 through h6) can have its own style. Each of them can be styled using the following attributes:\n\n```yaml\nheadings:\n  # H1 style.\n  h1:\n    # The prefix to use for the heading\n    prefix: \"██\"\n\n    # The colors to use.\n    colors:\n      foreground: beeeff\n      background: feeedd\n    # Whether to style for the title using bold text.\n    bold: true\n\n    # Whether to style for the title using underlined text.\n    underlined: true\n\n    # Whether to style for the title using italics text.\n    italics: true\n  \n  # H2 style, same as the keys for H1.\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: feeedd\n```\n\n## Code blocks\n\nThe syntax highlighting for code blocks is done via the [syntect](https://github.com/trishume/syntect) crate. The list \nof all the supported themes is the following:\n\n* base16-ocean.dark\n* base16-eighties.dark\n* base16-mocha.dark\n* base16-ocean.light\n* Catppuccin\n* Coldark\n* DarkNeon\n* InspiredGitHub\n* Nord-sublime\n* Solarized\n* Solarized (dark)\n* Solarized (light)\n* TwoDark\n* dracula-sublime\n* github-sublime-theme\n* gruvbox\n* onehalf\n* sublime-monokai-extended\n* sublime-snazzy\n* visual-studio-dark-plus\n* zenburn\n\nMost of these are taken from the [bat tool](https://github.com/sharkdp/bat), thanks to the people behind `bat` for \nimplementing them!\n\nCode blocks can also have a few additional properties:\n\n```yaml\ncode:\n  # The code theme.\n  theme_name: base16-eighties.dark\n\n  # The padding to be applied, in cells, around a code snippet.\n  padding:\n    horizontal: 2\n    vertical: 1\n\n  # Whether the theme's background color should be used around the code block.\n  background: false\n\n  # Whether to set line numbers in all snippets by default.\n  line_numbers: false\n```\n\n#### Custom highlighting themes\n\nBesides the built-in highlighting themes, you can drop any `.tmTheme` theme in the `themes/highlighting` directory under \nyour [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes/highlighting` in \nLinux) and they will be loaded automatically when _presenterm_ starts.\n\n## Block quotes\n\nFor block quotes you can specify a string to use as a prefix in every line of quoted text:\n\n```yaml\nblock_quote:\n  prefix: \"▍ \"\n```\n\n## Mermaid\n\nThe [mermaid](https://mermaid.js.org/) graphs can be customized using the following parameters:\n\n* `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`).\n* `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use.\n\n```yaml\nmermaid:\n  background: transparent\n  theme: dark\n```\n\n## Alerts\n\nGitHub style markdown alerts can be styled by setting the `alert` key:\n\n```yaml\nalert:\n  # the base colors used in all text in an alert\n  base_colors:\n    foreground: red\n    background: black\n\n  # the prefix used in every line in the alert\n  prefix: \"▍ \"\n\n  # the style for each alert type\n  styles:\n    note:\n      color: blue\n      title: Note\n      icon: I\n    tip:\n      color: green\n      title: Tip\n      icon: T\n    important:\n      color: cyan\n      title: Important\n      icon: I\n    warning:\n      color: orange\n      title: Warning\n      icon: W\n    caution:\n      color: red\n      title: Caution\n      icon: C\n```\n\n## Extending themes\n\nCustom themes can extend other custom or built in themes. This means it will inherit all the properties of the theme \nbeing extended by default.\n\nFor example:\n\n```yaml\nextends: dark\ndefault:\n  colors:\n    background: \"000000\"\n```\n\nThis theme extends the built in _dark_ theme and overrides the background color. This is useful if you find yourself \n_almost_ liking a built in theme but there's only some properties you don't like.\n\n## Color palette\n\nEvery theme can define a color palette, which includes a list of pre-defined colors and a list of background/foreground \npairs called \"classes\". Colors and classes can be used when styling text via `<span>` HTML tags, whereas colors can also \nbe used inside themes to avoid duplicating the same colors all over the theme definition.\n\nA palette can de defined as follows:\n\n```yaml\npalette:\n  colors:\n    red: \"f78ca2\"\n    purple: \"986ee2\"\n  classes:\n    foo:\n      foreground: \"ff0000\"\n      background: \"00ff00\"\n```\n\nAny palette color can be referenced using either `palette:<name>` or `p:<name>`. This means now any part of the theme \ncan use `p:red` and `p:purple` where a color is required.\n\nSimilarly, these colors can be used in `span` tags like:\n\n```html\n<span style=\"color: palette:red\">this is red</span>\n\n<span class=\"foo\">this is foo-colored</span>\n```\n\nThese colors can used anywhere in your presentation as well as in other places such as in\n[template footers](#template-footers) and [introduction slides](../introduction.md#introduction-slide).\n\n## Bold/italics styling\n\nBold and italics text is not given any colors by default. The `bold` and `italics` top level keys can be used to define \na set of colors to use for them:\n\n```yaml\nbold:\n  colors:\n    foreground: red\nitalics:\n  colors:\n    background: blue\n```\n"
  },
  {
    "path": "docs/src/features/themes/introduction.md",
    "content": "# Themes\n\n_presenterm_ tries to be as configurable as possible, allowing users to create presentations that look exactly how they \nwant them to look like. The tool ships with a set of [built-in \nthemes](https://github.com/mfontanini/presenterm/tree/master/themes) but users can be created by users in their local \nsetup and imported in their presentations.\n\n## Setting themes\n\nThere's various ways of setting the theme you want in your presentation:\n\n### CLI\n\nPassing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes.\n\n### Within the presentation\n\nThe presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors:\n\n#### By name\n\nUsing a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme` \noption specifies:\n\n```yaml\n---\ntheme:\n  name: dark\n---\n```\n\nBuilt-in light/dark theme detection allows you to define a different theme for each variant:\n\n\n```yaml\n---\ntheme:\n  # The theme used if your terminal is using light colors.\n  light: light\n\n  # The theme used if your terminal is using dark colors.\n  dark: dark\n---\n```\n\n#### By path\n\nYou can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation:\n\n```yaml\n---\ntheme:\n  path: /home/me/Documents/epic-theme.yaml\n---\n```\n\n#### Overrides\n\nYou can partially/completely override the theme in use from within the presentation:\n\n```yaml\n---\ntheme:\n  override:\n    default:\n      colors:\n        foreground: \"beeeff\"\n---\n```\n\nThis lets you:\n\n1. Create a unique style for your presentation without having to go through the process of taking an existing theme, \n   copying somewhere, and changing it when you only expect to use it for that one presentation.\n2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file.\n\n# Built-in themes\n\nA few built-in themes are bundled with the application binary, meaning you don't need to have any external files \navailable to use them. These are packed as part of the [build \nprocess](https://github.com/mfontanini/presenterm/blob/master/build.rs) as a binary blob and are decoded on demand only\nwhen used.\n\nCurrently, the following themes are supported:\n\n* A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette:\n  * `catppuccin-latte`\n  * `catppuccin-frappe`\n  * `catppuccin-macchiato`\n  * `catppuccin-mocha`\n* `dark`: A dark theme.\n* `gruvbox-dark`: A theme inspired by the colors used in [gruvbox](https://github.com/morhetz/gruvbox).\n* `light`: A light theme.\n* `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This \nmeans if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.\n* `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme.\n* A set of themes based on the [toyonight](https://github.com/folke/tokyonight.nvim) color palette:\n    * `tokyonight-moon`\n    * `tokyonight-day`\n    * `tokyonight-night`\n    * `tokyonight-storm`\n\n## Trying out built-in themes\n\nAll built-in themes can be tested by using the `--list-themes` parameter:\n\n```bash\npresenterm --list-themes\n```\n\nThis will run a presentation where the same content is rendered using a different theme in each slide:\n\n[![asciicast](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle.svg)](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle)\n\n# Loading custom themes\n\nOn startup, _presenterm_ will look into the `themes` directory under the [configuration \ndirectory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml` \nfile 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 \n`--theme` parameter, use it in the `theme.name` property in a presentation's front matter, etc.\n"
  },
  {
    "path": "docs/src/install.md",
    "content": "# Installing _presenterm_\n\n_presenterm_ works on Linux, macOS, and Windows and can be installed in different ways:\n\n#### Binary\n\nThe recommended way to install _presenterm_ is to download the latest pre-built version for your system from the \n[releases page](https://github.com/mfontanini/presenterm/releases).\n\n#### cargo-binstall\n\nIf you're a [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) user:\n\n```bash\ncargo binstall presenterm\n```\n\n#### From source\n\nAlternatively, build from source by downloading [rust](https://www.rust-lang.org/) and running:\n\n```bash\ncargo install --locked presenterm\n```\n\n## Latest unreleased version\n\nThe latest unreleased version can be installed either in binary form or by building it from source.\n\n#### Binary\n\nThe nightly pre-build binary can be downloaded from \n[github](https://github.com/mfontanini/presenterm/releases/tag/nightly). Keep in mind this is built once a day at \nmidnight UTC so if you need code that has been recently merged you may have to wait a few hours.\n\n#### From source\n\n```bash\ncargo install --locked --git https://github.com/mfontanini/presenterm\n```\n\n# Community maintained packages\n\nThe community maintains packages for various operating systems and linux distributions and can be installed in the \nfollowing ways:\n\n## macOS\n\nInstall the latest version in macOS via [brew](https://formulae.brew.sh/formula/presenterm) by running:\n\n```bash\nbrew install presenterm\n```\n\nThe latest unreleased version can be built via brew by running:\n\n```bash\nbrew install --head presenterm\n```\n\n## Nix\n\nTo install _presenterm_ using the Nix package manager run:\n\n```bash\nnix-env -iA nixos.presenterm    # for nixos\nnix-env -iA nixpkgs.presenterm  # for non-nixos\n```\n\n#### NixOS\n\nAdd the following to your `configuration.nix` if you are on NixOS\n\n```nix\nenvironment.systemPackages = [\n  pkgs.presenterm\n];\n```\n\n#### Flakes\n\nAlternatively if you're a Nix user using flakes you can run:\n\n```shell\nnix run nixpkgs#presenterm            # to run from nixpkgs\nnix run github:mfontanini/presenterm  # to run from github repo\n```\n\nFor more information see \n[nixpkgs](https://search.nixos.org/packages?channel=unstable&show=presenterm&from=0&size=50&sort=relevance&type=packages&query=presenterm).\n\n## Arch Linux\n\n_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:\n\n```bash\npacman -S presenterm\n```\n\n#### Binary\n\nAlternatively, you can use any AUR helper to install the upstream binaries:\n\n```bash\nparu/yay -S presenterm-bin\n```\n\n#### From source\n\n```bash\nparu/yay -S presenterm-git\n```\n\n## Windows\n\n#### Scoop\n\nInstall the [latest version](https://scoop.sh/#/apps?q=presenterm&id=a462289f824b50f180afbaa6d8c7c1e6e0952e3a) via scoop \nby running:\n\n```powershell\nscoop install main/presenterm\n```\n\n#### Winget\n\nAlternatively, you can install via [WinGet](https://github.com/microsoft/winget-cli) by running:\n\n```powershell\nwinget install --id=mfontanini.presenterm  -e\n```\n"
  },
  {
    "path": "docs/src/internals/parse.md",
    "content": "# Parsing and rendering\n\nThis document goes through the internals of how we take a markdown file and finish rendering it into the terminal \nscreen.\n\n## Parsing\n\nMarkdown file parsing is done via the [comrak](https://github.com/kivikakk/comrak) crate. This crate parses the markdown \nfile and gives you back an AST that contains the contents and structure of the input file.\n\nASTs are a logical way of representing the markdown file but this structure makes it a bit hard to process. Given our \nultimate goal is to render this input, we want it to be represented in a way that facilitates that. Because of this we \nfirst do a pass on this AST and construct a list of `MarkdownElement`s. This enum represents each of the markdown \nelements in a flattened, non-recursive, way:\n\n* Inline text is flattened so that instead of having a recursive structure you have chunks of text, each with their own \n  style. So for example the text \"**hello _my name is ~bob~_**\" which would look like a 3 level tree (I think?) in the \n  AST, gets transformed to something like `[Bold(hello), ItalicsBold(my name is), ItalicsBoldStrikethrough(bob)]` (names \n  are completely not what they are in the code, this is just to illustrate flattening). This makes it much easier to \n  render text because we don't need to walk the tree and keep the state between levels.\n* Lists are flattened into a single `MarkdownElement::List` element that contains a list of items that contain their \n  text, prefix (\"\\*\" for bullet lists), and nesting depth. This also simplifies processing as list elements can also \n  contain formatted text so we would otherwise have the same problem as above.\n\nThis first step then produces a list of elements that can easily be processed.\n\n## Building the presentation\n\nThe 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 \npresentation for various reasons:\n\n* The presentation needs to be styled, which means we need to apply a theme on top of it to transform it. Putting this \n  responsibility in the render code creates too much coupling: now the render needs to understand markdown _and_ how \n  themes work.\n* The render code tends to be a bit annoying: we need to jump around in the screen, print text, change colors, etc. If \n  we add the responsibility of transforming the markdown into visible text to the render code itself, we end up having a \n  mess of UI code mixed with the markdown element processing.\n* 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 \n  to be in charge of understanding and executing those transformations.\n\nBecause of this, we introduce a step in between parsing and rendering where we build a presentation. A presentation is \nmade up of a list of slides and each slide is made up of render operations. Render operations are the primitives that \nthe render code understands to print text on the screen. These can be the following, among others:\n\n* Render text.\n* Clear the screen.\n* Set the default colors to be used.\n* Render a line break.\n* Jump to the middle of the screen.\n\nThis allows us to have a simple model where the logic that takes markdown elements and a theme and chooses _how_ it will \nbe rendered is in one place, and the logic that takes those instructions and executes them is elsewhere. So for example, \nthis step will take a bullet point and concatenate is suffix (\"\\*\" for bullet points for example), turn that into a \nsingle string and generate a \"render text\" operation.\n\nThis has the nice added bonus that the rendering code doesn't have to be fiddling around with string concatenation or \nother operations that could take up CPU cycles: it just takes these render operations and executes them. Not that \nperformance matters here but it's nice to get better performance for free.\n\n## Render a slide\n\nThe rendering code is straightforward and simply takes the current slide, iterates all of its rendering operations, and \nexecutes those one by one. This is done via the [crossterm](https://github.com/crossterm-rs/crossterm) crate. \n\nThe only really complicated part is fitting text into the screen. Because we apply our own margins, we perform word \nsplitting and wrapping around manually, so there's some logic that takes the text to be printed and the width of the \nterminal and splits it accordingly.\n\nNote that this piece of code is the only one aware of the current screen size. This lets us forget in previous steps \nabout how large the screen is and simply delegate that responsibility to this piece.\n\n## Entire flow\n\n![](../assets/parse-flow.png)\n"
  },
  {
    "path": "docs/src/introduction.md",
    "content": "# presenterm\n\n[presenterm][github] lets you create presentations in markdown format and run them from your terminal, with support for \nimage and animated gif support, highly customizable themes, code highlighting, exporting presentations into PDF format, \nand plenty of other features.\n\n## Demo\n\nThis is how the [demo presentation][demo-source] looks like:\n\n![demo]\n\n**A few other example presentations can be found [here][examples]**.\n\n<!-- Links -->\n[github]: https://github.com/mfontanini/presenterm/\n[demo]: ./assets/demo.gif\n[demo-source]: https://github.com/mfontanini/presenterm/blob/master/examples/demo.md\n[examples]: https://github.com/mfontanini/presenterm/tree/master/examples\n"
  },
  {
    "path": "examples/README.md",
    "content": "Examples\n===\n\nThis section contains a few example presentations that display different features and styles you can use in your own. In \norder to run the presentations locally, first [install \npresenterm](https://mfontanini.github.io/presenterm/guides/installation.html), clone this repo, and finally run:\n\n```shell\npresenterm examples/<name-of-the-presentation>.md\n```\n\n# Demo\n\n[Source](/examples/demo.md)\n\nThis is the main demo presentation, which showcases most features and uses the default dark theme.\n\nThis is how it looks like when rendered:\n\n![](/docs/src/assets/demo.gif)\n\n# Code\n\n[Source](/examples/code.md)\n\nThis example contains some piece of code and showcases some different styling properties to make it look a bit different \nthan how it looks like by default by using:\n\n* Use left alignment for code blocks.\n* No background for code blocks.\n\n[![asciicast](https://asciinema.org/a/irNPKwEkPZzFbQP6jIKfVL30b.svg)](https://asciinema.org/a/irNPKwEkPZzFbQP6jIKfVL30b)\n\n# Footer\n\n[Source](/examples/footer.md)\n\nThis example uses a template-style footer, which lets you place some text on the left, center, and right of every slide. \nA few template variables, such as `current_slide` and `total_slides` can be used to reference properties of the \npresentation.\n\n![](../docs/src/assets/example-footer.png)\n\n# Columns\n\n[Source](/examples/columns.md)\n\nThis example shows how column layouts and pauses interact with each other. Note that the image shows up as pixels \nbecause asciinema doesn't support these and it will otherwise look like a normal image if your terminal supports images.\n\n[![asciicast](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp.svg)](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp)\n\n# Speaker notes\n\n[Source](/examples/speaker-notes.md)\n\nThis example shows how to use speaker notes.\n\n[![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J)\n\n# Custom introduction slides\n\n[Source](/examples/custom-intro-slides.md)\n\nThis example various custom introduction slides that contain images placed in different layouts. Note that the images \nlooks pixelated because of asciinema but they will otherwise look normal in your terminal.\n\n[![asciicast](https://asciinema.org/a/sBeAMJbpBxqKA2gF2RI3MmLT7.svg)](https://asciinema.org/a/sBeAMJbpBxqKA2gF2RI3MmLT7)\n"
  },
  {
    "path": "examples/code.md",
    "content": "---\ntheme:\n  override:\n    code:\n      alignment: left\n      background: false\n---\n\nCode styling\n===\n\nThis presentation shows how to:\n\n* Left-align code blocks.\n* Have code blocks without background.\n* Execute code snippets.\n\n```rust\npub struct Greeter {\n    prefix: &'static str,\n}\n\nimpl Greeter {\n    /// Greet someone.\n    pub fn greet(&self, name: &str) -> String {\n        let prefix = self.prefix;\n        format!(\"{prefix} {name}!\")\n    }\n}\n\nfn main() {\n    let greeter = Greeter { prefix: \"Oh, hi\" };\n    let greeting = greeter.greet(\"Mark\");\n    println!(\"{greeting}\");\n}\n```\n\n<!-- end_slide -->\n\nColumn layouts\n===\n\nThe same code as the one before but split into two columns to split the API definition with its usage:\n\n<!-- column_layout: [1, 1] -->\n\n<!-- column: 0 -->\n\n# The `Greeter` type\n\n```rust\npub struct Greeter {\n    prefix: &'static str,\n}\n\nimpl Greeter {\n    /// Greet someone.\n    pub fn greet(&self, name: &str) -> String {\n        let prefix = self.prefix;\n        format!(\"{prefix} {name}!\")\n    }\n}\n```\n\n<!-- column: 1 -->\n\n# Using the `Greeter`\n\n```rust\nfn main() {\n    let greeter = Greeter {\n      prefix: \"Oh, hi\"\n    };\n    let greeting = greeter.greet(\"Mark\");\n    println!(\"{greeting}\");\n}\n```\n\n<!-- end_slide -->\n\nSnippet execution\n===\n\nRun code snippets from the presentation and display their output dynamically.\n\n```python +exec\n/// import time\nfor i in range(0, 5):\n    print(f\"count is {i}\")\n    time.sleep(0.5)\n```\n\n<!-- end_slide -->\n\nSnippet execution - `stderr`\n===\n\nOutput from `stderr` will also be shown as output.\n\n```bash +exec\necho \"This is a successful command\"\nsleep 0.5\necho \"This message redirects to stderr\" >&2\nsleep 0.5\necho \"This is a successful command again\"\nsleep 0.5\nman # Missing argument\n```\n"
  },
  {
    "path": "examples/columns.md",
    "content": "# Columns and pauses\n\nColumns and pauses can interact with each other in useful ways:\n\n<!-- pause -->\n\n<!-- column_layout: [1, 1] -->\n\n<!-- column: 1 -->\n\n![](../examples/doge.png)\n\nAfter this pause, the text on the left will show up\n\n<!-- pause -->\n\n<!-- column: 0 -->\n\nThis is useful for various things:\n\n<!-- incremental_lists: true -->\n* Lorem.\n* Ipsum.\n* Etcetera.\n\n"
  },
  {
    "path": "examples/custom-intro-slides.md",
    "content": "<!-- newlines: 6 -->\n\n![](doge.png)\n\n\nCustom introduction slides\n====\n\n<!-- alignment: center -->\n\n<span style=\"color: blue\">John Doe</span>\n\n<!-- end_slide -->\n\n\n\n\n\n<!-- newlines: 12 -->\n\n<!-- column_layout: [1, 3]-->\n\n<!-- column: 0 -->\n\n![](doge.png)\n\n<!-- column: 1 -->\n\n<!-- newlines: 3 -->\n\nCustom introduction slides\n====\n\n<!-- alignment: center -->\n\n<span style=\"color: blue\">John Doe</span>\n\n<!-- end_slide -->\n\n\n\n\n\n<!-- newlines: 12 -->\n\n<!-- column_layout: [3, 1]-->\n\n<!-- column: 0 -->\n\n<!-- newlines: 3 -->\n\nCustom introduction slides\n====\n\n<!-- alignment: center -->\n\n<span style=\"color: blue\">John Doe</span>\n\n<!-- column: 1 -->\n\n![](doge.png)\n\n\n"
  },
  {
    "path": "examples/demo.md",
    "content": "---\ntitle: Introducing _presenterm_\nauthor: Matias\n---\n\nCustomizability\n---\n_presenterm_ allows configuring almost anything about your presentation:\n\n* The colors used.\n* Layouts.\n* Footers, including images in the footer.\n\n<!-- pause -->\n\nThis is an example on how to configure a footer:\n\n```yaml\nfooter:\n  style: template\n  left:\n    image: doge.png\n  center: '<span class=\"noice\">Colored</span> _footer_'\n  right: \"{current_slide} / {total_slides}\"\n  height: 5\n\npalette:\n  classes:\n    noice:\n      foreground: red\n```\n\n<!-- end_slide -->\n\nHeaders\n---\n\nMarkdown headers can be used to set slide titles like:\n\n```markdown\nHeaders\n-------\n```\n\n# Headers\n\nEach header type can be styled differently.\n\n## Subheaders\n\n### And more\n\n<!-- end_slide -->\n\nCode highlighting\n---\n\nHighlight code in 50+ programming languages:\n\n```rust\n// Rust\nfn greet() -> &'static str {\n    \"hi mom\"\n}\n```\n\n```python\n# Python\ndef greet() -> str:\n    return \"hi mom\"\n```\n\n<!-- pause -->\n\n-------\n\nCode snippets can have different styles including no background:\n\n```cpp +no_background +line_numbers\n// C++\nstring greet() {\n    return \"hi mom\";\n}\n```\n\n<!-- end_slide -->\n\nDynamic code highlighting\n---\n\nDynamically highlight different subsets of lines:\n\n```rust {1-4|6-10|all} +line_numbers\n#[derive(Clone, Debug)]\nstruct Person {\n    name: String,\n}\n\nimpl Person {\n    fn say_hello(&self) {\n        println!(\"hello, I'm {}\", self.name)\n    }\n}\n```\n\n<!-- end_slide -->\n\nSnippet execution\n---\n\nCode snippets can be executed on demand:\n\n* For 20+ languages, including compiled ones.\n* Display their output in real time.\n* Comment out unimportant lines to hide them.\n\n```rust +exec\n# use std::thread::sleep;\n# use std::time::Duration;\nfn main() {\n    let names = [\"Alice\", \"Bob\", \"Eve\", \"Mallory\", \"Trent\"];\n    for name in names {\n        println!(\"Hi {name}!\");\n        sleep(Duration::from_millis(500));\n    }\n}\n```\n\n<!-- end_slide -->\n\nImages\n---\n\nImages and animated gifs are supported in terminals such as:\n\n* kitty\n* iterm2\n* wezterm\n* ghostty\n* foot\n* Any sixel enabled terminal\n\n<!-- column_layout: [1, 3, 1] -->\n\n<!-- column: 1 -->\n\n![](doge.png)\n\n_Picture by Alexis Bailey / CC BY-NC 4.0_\n\n<!-- end_slide -->\n\nColumn layouts\n---\n\n<!-- column_layout: [7, 3] -->\n\n<!-- column: 0 -->\n\nUse column layouts to structure your presentation:\n\n* Define the number of columns.\n* Adjust column widths as needed.\n* Write content into every column.\n\n```rust\nfn potato() -> u32 {\n    42\n}\n```\n\n<!-- column: 1 -->\n\n![](doge.png)\n\n<!-- reset_layout -->\n\n---\n\nLayouts can be reset at any time.\n\n```python\nprint(\"Hello world!\")\n```\n\n<!-- end_slide -->\n\nText formatting\n---\n\nText formatting works including:\n\n* **Bold text**.\n* _Italics_.\n* **_Bold and italic_**.\n* ~Strikethrough~.\n* `Inline code`.\n* Links [](https://example.com/)\n* <span style=\"color: red\">Colored</span> text.\n* <span style=\"color: blue; background-color: black\">Background color</span> can be changed too.\n\n<!-- end_slide -->\n\nMore markdown\n---\n\nOther markdown elements supported are:\n\n# Block quotes\n\n> Lorem ipsum dolor sit amet. Eos laudantium animi ut ipsam beataeet\n> et exercitationem deleniti et quia maiores a cumque enim et\n> aspernatur nesciunt sed adipisci quis.\n\n# Alerts\n\n> [!caution]\n> Github style alerts\n\n# Tables\n\n| Name   | Taste  |\n| ------ | ------ |\n| Potato | Great  |\n| Carrot | Yuck   |\n\n<!-- end_slide -->\n\n<!-- jump_to_middle -->\n\nThe end\n---\n"
  },
  {
    "path": "examples/footer.md",
    "content": "---\ntheme:\n  override:\n    footer:\n      style: template\n      left:\n        image: doge.png\n      center: '**Introduction** to <span class=\"noice\">footer</span> _styling_'\n      right: \"{current_slide} / {total_slides}\"\n      height: 5\n    palette:\n      classes:\n        noice:\n          foreground: red\n---\n\nFirst slide\n===\n\nThe important bit in this presentation is the **footer at the bottom**.\n\n<!-- end_slide -->\n\nSecond slide\n===\n\n_nothing to see here_\n"
  },
  {
    "path": "examples/speaker-notes.md",
    "content": "Speaker Notes\n===\n\n`presenterm` supports speaker notes.\n\nYou can use the following HTML comment throughout your presentation markdown file:\n\n```markdown\n<!-- speaker_note: Your speaker note goes here. -->\n```\n\n<!-- speaker_note: This is a speaker note from slide 1. -->\n\nAnd you can run a separate instance of `presenterm` to view them.\n\n<!-- speaker_note: You can use multiple speaker notes within each slide and interleave them with other markdown. -->\n\n<!-- end_slide -->\n\nUsage\n===\nRun the following two commands in separate terminals.\n\n<!-- speaker_note: This is a speaker note from slide 2. -->\n\nThe `--publish-speaker-notes` argument will render your actual presentation as normal, without speaker notes:\n\n```\npresenterm --publish-speaker-notes examples/speaker-notes.md\n```\n\nThe `--listen-speaker-notes` argument will render only the speaker notes for the current slide being shown in the actual \npresentation:\n\n```\npresenterm --listen-speaker-notes examples/speaker-notes.md\n```\n\n<!-- speaker_note: Demonstrate changing slides in the actual presentation. -->\n\nAs you change slides in your actual presentation, the speaker notes presentation slide will automatically navigate to the correct slide.\n\n<!-- speaker_note: Isn't that cool? -->\n"
  },
  {
    "path": "executors.yaml",
    "content": "---\nbash:\n  filename: script.sh\n  commands:\n    - [\"bash\", \"$pwd/script.sh\"]\n  hidden_line_prefix: \"/// \"\nc++:\n  filename: snippet.cpp\n  commands:\n    - [\n        \"g++\",\n        \"-std=c++20\",\n        \"-fdiagnostics-color=always\",\n        \"$pwd/snippet.cpp\",\n        \"-o\",\n        \"$pwd/snippet\",\n      ]\n    - [\"$pwd/snippet\"]\n  hidden_line_prefix: \"/// \"\nc:\n  filename: snippet.c\n  commands:\n    - [\n        \"gcc\",\n        \"$pwd/snippet.c\",\n        \"-fdiagnostics-color=always\",\n        \"-o\",\n        \"$pwd/snippet\",\n      ]\n    - [\"$pwd/snippet\"]\n  hidden_line_prefix: \"/// \"\nelixir:\n  filename: snippet.exs\n  commands:\n    - [\"elixir\", \"$pwd/snippet.exs\"]\n  hidden_line_prefix: \"## \"\nfish:\n  filename: script.fish\n  commands:\n    - [\"fish\", \"$pwd/script.fish\"]\n  hidden_line_prefix: \"/// \"\ngo:\n  filename: snippet.go\n  environment:\n    GO11MODULE: off\n  commands:\n    - [\"go\", \"run\", \"$pwd/snippet.go\"]\n  hidden_line_prefix: \"/// \"\nhaskell:\n  filename: snippet.hs\n  commands:\n    - [\"runhaskell\", \"-w\", \"$pwd/snippet.hs\"]\njava:\n  filename: Snippet.java\n  commands:\n    - [\"java\", \"$pwd/Snippet.java\"]\n  hidden_line_prefix: \"/// \"\njs:\n  filename: snippet.js\n  environment:\n    FORCE_COLOR: \"true\"\n  commands:\n    - [\"node\", \"$pwd/snippet.js\"]\n  hidden_line_prefix: \"/// \"\n  alternative:\n    deno:\n      filename: \"snippet.js\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"deno\", \"run\", \"-A\", \"$pwd/snippet.js\"]\n    bun:\n      filename: \"snippet.js\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"bun\", \"run\", \"$pwd/snippet.js\"]\njsx:\n  filename: snippet.jsx\n  environment:\n    FORCE_COLOR: \"true\"\n  commands:\n    - [\"node\", \"$pwd/snippet.jsx\"]\n  hidden_line_prefix: \"/// \"\n  alternative:\n    deno:\n      filename: \"snippet.jsx\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"deno\", \"run\", \"-A\", \"$pwd/snippet.jsx\"]\n    bun:\n      filename: \"snippet.jsx\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"bun\", \"run\", \"$pwd/snippet.jsx\"]\nts:\n  filename: snippet.ts\n  environment:\n    FORCE_COLOR: \"true\"\n  commands:\n    - [\"node\", \"$pwd/snippet.ts\"]\n  hidden_line_prefix: \"/// \"\n  alternative:\n    deno:\n      filename: \"snippet.ts\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"deno\", \"run\", \"-A\", \"$pwd/snippet.ts\"]\n    bun:\n      filename: \"snippet.ts\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"bun\", \"run\", \"$pwd/snippet.ts\"]\ntsx:\n  filename: snippet.tsx\n  environment:\n    FORCE_COLOR: \"true\"\n  commands:\n    - [\"node\", \"$pwd/snippet.tsx\"]\n  hidden_line_prefix: \"/// \"\n  alternative:\n    deno:\n      filename: \"snippet.tsx\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"deno\", \"run\", \"-A\", \"$pwd/snippet.tsx\"]\n    bun:\n      filename: \"snippet.tsx\"\n      environment:\n        FORCE_COLOR: \"true\"\n      commands:\n        - [\"bun\", \"run\", \"$pwd/snippet.tsx\"]\njulia:\n  filename: snippet.jl\n  commands:\n    - [\"julia\", \"$pwd/snippet.jl\"]\n  hidden_line_prefix: \"/// \"\njsonnet:\n  filename: snippet.jsonnet\n  commands:\n    - [\"jsonnet\", \"$pwd/snippet.jsonnet\"]\n  hidden_line_prefix: \"## \"\nkotlin:\n  filename: snippet.kts\n  commands:\n    - [\"kotlinc\", \"-script\", \"$pwd/snippet.kts\"]\n  hidden_line_prefix: \"/// \"\nlua:\n  filename: snippet.lua\n  commands:\n    - [\"lua\", \"$pwd/snippet.lua\"]\nnushell:\n  filename: snippet.nu\n  commands:\n    - [\"nu\", \"$pwd/snippet.nu\"]\nperl:\n  filename: snippet.pl\n  commands:\n    - [\"perl\", \"$pwd/snippet.pl\"]\nphp:\n  filename: snippet.php\n  commands:\n    - [\"php\", \"-f\", \"$pwd/snippet.php\"]\n  hidden_line_prefix: \"/// \"\npython:\n  filename: snippet.py\n  commands:\n    - [\"python\", \"-u\", \"$pwd/snippet.py\"]\n  hidden_line_prefix: \"/// \"\n  alternative:\n    uv:\n      filename: \"snippet.py\"\n      commands:\n        - [\"uv\", \"run\", \"--script\", \"-q\", \"$pwd/snippet.py\"]\nr:\n  filename: snippet.R\n  commands:\n    - [\"Rscript\", \"$pwd/snippet.R\"]\nruby:\n  filename: snippet.rb\n  commands:\n    - [\"ruby\", \"$pwd/snippet.rb\"]\nrust-script:\n  filename: snippet.rs\n  environment:\n    CARGO_TERM_COLOR: \"always\"\n  commands:\n    - [\"rust-script\", \"--debug\", \"$pwd/snippet.rs\"]\n  hidden_line_prefix: \"# \"\nrust:\n  filename: snippet.rs\n  commands:\n    - [\n        \"rustc\",\n        \"--crate-name\",\n        \"presenterm_snippet\",\n        \"$pwd/snippet.rs\",\n        \"-o\",\n        \"$pwd/snippet\",\n        \"--color\",\n        \"always\",\n      ]\n    - [\"$pwd/snippet\"]\n  hidden_line_prefix: \"# \"\n  alternative:\n    rust-script:\n      filename: snippet.rs\n      environment:\n        CARGO_TERM_COLOR: \"always\"\n      commands:\n        - [\"rust-script\", \"--debug\", \"$pwd/snippet.rs\"]\n    rust-script-pedantic:\n      filename: snippet.rs\n      environment:\n        RUSTFLAGS: \"--deny warnings\"\n        CARGO_TERM_COLOR: \"always\"\n      commands:\n        - [\"rust-script\", \"--debug\", \"$pwd/snippet.rs\"]\nsh:\n  filename: script.sh\n  commands:\n    - [\"sh\", \"$pwd/script.sh\"]\n  hidden_line_prefix: \"/// \"\nzsh:\n  filename: script.sh\n  commands:\n    - [\"zsh\", \"$pwd/script.sh\"]\n  hidden_line_prefix: \"/// \"\npwsh:\n  filename: script.ps1\n  commands:\n    - [\"pwsh\", \"$pwd/script.ps1\"]\n  hidden_line_prefix: \"/// \"\ncmd:\n  filename: script.cmd\n  commands:\n    - [\"cmd\", \"/q\", \"/c\", \"$pwd/script.cmd\"]\n  hidden_line_prefix: \"/// \"\nwsl:\n  filename: script.sh\n  commands:\n    - [\"wsl\", \"$(wslpath -u '$pwd')/script.sh\"]\n  hidden_line_prefix: \"/// \"\ncsharp:\n  filename: snippet.cs\n  commands:\n    - [\"dotnet-script\", \"$pwd/snippet.cs\"]\n  hidden_line_prefix: \"/// \"\nfsharp:\n  filename: snippet.fsx\n  commands:\n    - [\"dotnet\", \"fsi\", \"$pwd/snippet.fsx\"]\n  hidden_line_prefix: \"/// \"\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"A terminal slideshow tool\";\n\n  inputs = {\n    flakebox = {\n      url = \"github:rustshop/flakebox?rev=62af969ab344229d2a0d585a482293b3f186b221\";\n    };\n\n    flake-utils.url = \"github:numtide/flake-utils\";\n  };\n\n  outputs = { self, flake-utils, flakebox }:\n    flake-utils.lib.eachDefaultSystem (system:\n      let\n        projectName = \"presenterm\";\n        pkgs = flakebox.inputs.nixpkgs.legacyPackages.${system};\n\n        flakeboxLib = flakebox.lib.mkLib pkgs {\n          config = {\n            github.ci.buildOutputs = [ \".#ci.${projectName}\" ];\n          };\n        };\n\n        buildPaths = [\n          \"build.rs\"\n          \"Cargo.toml\"\n          \"Cargo.lock\"\n          \".cargo\"\n          \"src\"\n          \"themes\"\n          \"bat\"\n          \"executors.yaml\"\n        ];\n\n        buildSrc = flakeboxLib.filterSubPaths {\n          root = builtins.path {\n            name = projectName;\n            path = ./.;\n          };\n          paths = buildPaths;\n        };\n\n        multiBuild =\n          (flakeboxLib.craneMultiBuild { }) (craneLib':\n            let\n              craneLib = (craneLib'.overrideArgs {\n                pname = projectName;\n                src = buildSrc;\n                nativeBuildInputs = [ ];\n              });\n            in\n            {\n              ${projectName} = craneLib.buildPackage { };\n            });\n      in\n      {\n        packages.default = multiBuild.${projectName};\n\n        legacyPackages = multiBuild;\n\n        devShells = flakeboxLib.mkShells { };\n      }\n    );\n}\n"
  },
  {
    "path": "rustfmt.toml",
    "content": "version = \"Two\"\nunstable_features = true\nuse_small_heuristics = \"Max\"\nmax_width = 120\nimports_granularity = \"Crate\"\nnormalize_comments = true\n"
  },
  {
    "path": "scripts/generate-config-file-schema.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nscript_dir=$(dirname \"$0\")\nroot_dir=\"${script_dir}/../\"\n\ncurrent_schema=$(mktemp)\ndocker run \\\n  --rm \\\n  -v \"${root_dir}:/tmp/workspace\" \\\n  -w \"/tmp/workspace\" \\\n  rust:1.90 \\\n  cargo run --features json-schema -q -- --generate-config-file-schema >\"${current_schema}\"\n\ncp \"$current_schema\" \"${root_dir}/config-file-schema.json\"\nrm \"$current_schema\"\n"
  },
  {
    "path": "scripts/parse-changelog.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nscript_dir=$(dirname \"$0\")\nroot_dir=\"${script_dir}/../\"\n\nif [ $# -ne 1 ]; then\n    echo \"Usage: $0 <version>\"\n    exit 1\nfi\n\nversion=$1\nchangelog=\"${root_dir}/CHANGELOG.md\"\n\nif ! grep \"^# ${version}\" \"$changelog\" >/dev/null; then\n    echo \"Version ${version} not found in changelog\"\n    exit 1\nfi\n\nreleases=$(grep -e \"^# \" -n \"$changelog\")\nversion_line=$(echo \"$releases\" | grep \"$version\" | cut -d : -f 1)\nnext_line=$(echo \"$releases\" | grep \"$version\" -A 1 -m 1 | tail -n 1 | cut -d : -f 1)\nlet next_line=(\"$next_line\" - 1)\n\nsed -n \"${version_line},${next_line}p\" \"$changelog\" | tail -n +3\n"
  },
  {
    "path": "scripts/test-pdf-generation.sh",
    "content": "#!/bin/bash\n\nset -e\n\nscript_dir=$(dirname \"$0\")\nroot_dir=$(realpath \"${script_dir}/../\")\n\necho \"Creating python env\"\nenv_dir=$(mktemp -d)\ntrap 'rm -rf \"${env_dir}\"' EXIT\npython -mvenv \"${env_dir}/pyenv\"\nsource \"${env_dir}/pyenv/bin/activate\"\n\necho \"Installing presenterm-export==0.2.0\"\npip install presenterm-export\n\necho \"Running presenterm...\"\nrm -f \"${root_dir}/examples/demo.pdf\"\ncargo run -q -- --export-pdf \"${root_dir}/examples/demo.md\"\n\nif test -f \"${root_dir}/examples/demo.pdf\"; then\n    echo \"PDF file created successfully\"\n    rm -f \"${root_dir}/examples/demo.pdf\"\nelse\n    echo \"PDF file does not exist\"\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/validate-config-file-schema.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nscript_dir=$(dirname \"$0\")\nroot_dir=\"${script_dir}/../\"\n\ncurrent_schema=$(mktemp)\ncargo run --features json-schema -q -- --generate-config-file-schema >\"$current_schema\"\n\ndiff=$(diff --color=always -u \"${root_dir}/config-file-schema.json\" \"$current_schema\")\nif [ $? -ne 0 ]; then\n  echo \"Config file JSON schema differs:\"\n  echo \"$diff\"\n  exit 1\nelse\n  echo \"Config file JSON schema is up to date\"\nfi\n"
  },
  {
    "path": "src/code/execute.rs",
    "content": "//! Code execution.\n\nuse super::snippet::SnippetExecutorSpec;\nuse crate::{\n    code::snippet::{Snippet, SnippetExecution, SnippetLanguage, SnippetRepr},\n    config::{LanguageSnippetExecutionConfig, SnippetExecutorConfig},\n};\nuse once_cell::sync::Lazy;\nuse os_pipe::PipeReader;\nuse std::{\n    collections::{BTreeMap, HashMap},\n    fmt::{self, Debug},\n    fs::File,\n    io::{self, BufRead, BufReader, Read, Write},\n    path::{Path, PathBuf},\n    process::{self, Child, Stdio},\n    sync::{Arc, Mutex},\n    thread,\n};\nuse tempfile::TempDir;\n\nstatic EXECUTORS: Lazy<BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>> =\n    Lazy::new(|| serde_yaml::from_slice(include_bytes!(\"../../executors.yaml\")).expect(\"executors.yaml is broken\"));\n\n/// Strip verbatim UNC prefix when drive letters\n#[cfg(windows)]\nfn strip_drive_unc_prefix(path: &Path) -> String {\n    // Convert to string (lossy if needed)\n    let path_str = path.to_string_lossy();\n\n    // If it starts with \\\\?\\ and the next part looks like a drive letter, strip it\n    if let Some(rest) = path_str.strip_prefix(r\"\\\\?\\\") {\n        if rest.len() >= 2 && rest.as_bytes()[1] == b':' {\n            // Example: \\\\?\\C:\\foo -> C:\\foo\n            return rest.to_string();\n        }\n    }\n\n    // Otherwise, return the original string unchanged\n    path_str.into_owned()\n}\n\n/// Allows executing code.\npub struct SnippetExecutor {\n    executors: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,\n    cwd: PathBuf,\n}\n\nimpl SnippetExecutor {\n    pub fn new(\n        custom_executors: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,\n        cwd: PathBuf,\n    ) -> Result<Self, InvalidSnippetConfig> {\n        let mut executors = EXECUTORS.clone();\n        executors.extend(custom_executors);\n        for (language, config) in &executors {\n            Self::validate_executor_config(language, &config.executor)?;\n            for alternative in config.alternative.values() {\n                Self::validate_executor_config(language, alternative)?;\n            }\n        }\n        Ok(Self { executors, cwd })\n    }\n\n    pub(crate) fn language_executor(\n        &self,\n        language: &SnippetLanguage,\n        spec: &SnippetExecutorSpec,\n    ) -> Result<LanguageSnippetExecutor, UnsupportedExecution> {\n        let language_config = self\n            .executors\n            .get(language)\n            .ok_or_else(|| UnsupportedExecution(language.clone(), \"no executors found\".into()))?;\n        let config = match spec {\n            SnippetExecutorSpec::Default => language_config.executor.clone(),\n            SnippetExecutorSpec::Alternative(name) => {\n                language_config.alternative.get(name).cloned().ok_or_else(|| {\n                    UnsupportedExecution(language.clone(), format!(\"alternative executor '{name}' is not defined\"))\n                })?\n            }\n        };\n        Ok(LanguageSnippetExecutor {\n            hidden_line_prefix: language_config.hidden_line_prefix.clone(),\n            config,\n            cwd: self.cwd.clone(),\n        })\n    }\n\n    pub(crate) fn hidden_line_prefix(&self, language: &SnippetLanguage) -> Option<&str> {\n        self.executors.get(language).and_then(|lang| lang.hidden_line_prefix.as_deref())\n    }\n\n    fn validate_executor_config(\n        language: &SnippetLanguage,\n        executor: &SnippetExecutorConfig,\n    ) -> Result<(), InvalidSnippetConfig> {\n        if executor.filename.is_empty() {\n            return Err(InvalidSnippetConfig(language.clone(), \"filename is empty\"));\n        }\n        if executor.commands.is_empty() {\n            return Err(InvalidSnippetConfig(language.clone(), \"no commands given\"));\n        }\n        for command in &executor.commands {\n            if command.is_empty() {\n                return Err(InvalidSnippetConfig(language.clone(), \"empty command given\"));\n            }\n        }\n        Ok(())\n    }\n}\n\nimpl Default for SnippetExecutor {\n    fn default() -> Self {\n        Self::new(Default::default(), PathBuf::from(\"./\")).expect(\"initialization failed\")\n    }\n}\n\nimpl Debug for SnippetExecutor {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"SnippetExecutor {{ .. }}\")\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct LanguageSnippetExecutor {\n    hidden_line_prefix: Option<String>,\n    config: SnippetExecutorConfig,\n    cwd: PathBuf,\n}\n\nimpl LanguageSnippetExecutor {\n    /// Execute a piece of code asynchronously.\n    pub(crate) fn execute_async(&self, snippet: &Snippet) -> Result<ExecutionHandle, CodeExecuteError> {\n        let script_dir = self.write_snippet(snippet)?;\n        let state: Arc<Mutex<ExecutionState>> = Default::default();\n        let output_type = match &snippet.attributes.execution {\n            SnippetExecution::Exec(args) if matches!(args.repr, SnippetRepr::Image) => OutputType::Binary,\n            _ => OutputType::Lines,\n        };\n        let reader_handle = CommandsRunner::spawn(\n            state.clone(),\n            script_dir,\n            self.config.commands.clone(),\n            self.config.environment.clone(),\n            self.cwd.clone(),\n            output_type,\n        );\n        let handle = ExecutionHandle { state, reader_handle };\n        Ok(handle)\n    }\n\n    /// Executes a piece of code synchronously.\n    pub(crate) fn execute_sync(&self, snippet: &Snippet) -> Result<(), CodeExecuteError> {\n        let script_dir = self.write_snippet(snippet)?;\n        let script_dir_path = script_dir.path().to_string_lossy();\n        for commands in self.config.commands.clone() {\n            self.execute_command(commands, &script_dir_path)?;\n        }\n        Ok(())\n    }\n\n    /// Creates the necessary context to run this snippet in a PTY.\n    pub(crate) fn pty_execution_context(&self, snippet: &Snippet) -> Result<PtySnippetContext, CodeExecuteError> {\n        let script_dir = self.write_snippet(snippet)?;\n        let script_dir_path = script_dir.path().to_string_lossy();\n\n        // Run the first N-1 commands normally and assume the last one is the one that actually\n        // invokes the thing (e.g. rust snippet compilation happens here, snippet execution in PTY)\n        for commands in self.config.commands.iter().take(self.config.commands.len() - 1).cloned() {\n            self.execute_command(commands, &script_dir_path)?;\n        }\n        let mut commands = self.config.commands.last().cloned().unwrap();\n        for command in &mut commands {\n            *command = command.replace(\"$pwd\", &script_dir_path);\n        }\n        let (command, args) = commands.split_first().expect(\"no commands\");\n        let mut command = portable_pty::CommandBuilder::new(command);\n        command.args(args);\n        command.cwd(&self.cwd);\n        for (key, value) in &self.config.environment {\n            command.env(key, value);\n        }\n        Ok(PtySnippetContext { command, _temp: script_dir })\n    }\n\n    fn execute_command(&self, mut commands: Vec<String>, script_dir_path: &str) -> Result<(), CodeExecuteError> {\n        for command in &mut commands {\n            *command = command.replace(\"$pwd\", script_dir_path);\n        }\n        let (command, args) = commands.split_first().expect(\"no commands\");\n        let child = process::Command::new(command)\n            .args(args)\n            .envs(&self.config.environment)\n            .current_dir(&self.cwd)\n            .stderr(Stdio::piped())\n            .spawn()\n            .map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?;\n\n        let output = child.wait_with_output().map_err(CodeExecuteError::Waiting)?;\n        if output.status.success() {\n            Ok(())\n        } else {\n            let error = String::from_utf8_lossy(&output.stderr).to_string();\n            Err(CodeExecuteError::Running(error))\n        }\n    }\n\n    fn write_snippet(&self, snippet: &Snippet) -> Result<TempDir, CodeExecuteError> {\n        let hide_prefix = self.hidden_line_prefix.as_deref();\n        let code = snippet.executable_contents(hide_prefix);\n        let script_dir =\n            tempfile::Builder::default().prefix(\".presenterm\").tempdir().map_err(CodeExecuteError::TempDir)?;\n        let snippet_path = script_dir.path().join(&self.config.filename);\n        let mut snippet_file = File::create(snippet_path).map_err(CodeExecuteError::TempDir)?;\n        snippet_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempDir)?;\n        Ok(script_dir)\n    }\n}\n\npub(crate) struct PtySnippetContext {\n    pub(crate) command: portable_pty::CommandBuilder,\n    _temp: TempDir,\n}\n\n/// An invalid executor was found.\n#[derive(thiserror::Error, Debug)]\n#[error(\"invalid snippet execution for '{0:?}': {1}\")]\npub struct InvalidSnippetConfig(SnippetLanguage, &'static str);\n\n/// Execution for a language is unsupported.\n#[derive(thiserror::Error, Debug)]\n#[error(\"cannot execute code for '{0:?}': {1}\")]\npub struct UnsupportedExecution(SnippetLanguage, String);\n\n/// An error during the execution of some code.\n#[derive(thiserror::Error, Debug)]\npub(crate) enum CodeExecuteError {\n    #[error(\"error creating temporary directory: {0}\")]\n    TempDir(io::Error),\n\n    #[error(\"error spawning process '{0}': {1}\")]\n    SpawnProcess(String, io::Error),\n\n    #[error(\"error creating pipe: {0}\")]\n    Pipe(io::Error),\n\n    #[error(\"error waiting for process to run: {0}\")]\n    Waiting(io::Error),\n\n    #[error(\"error running process: {0}\")]\n    Running(String),\n}\n\n/// A handle for the execution of a piece of code.\n#[derive(Debug)]\npub(crate) struct ExecutionHandle {\n    pub(crate) state: Arc<Mutex<ExecutionState>>,\n    #[allow(dead_code)]\n    reader_handle: thread::JoinHandle<()>,\n}\n\n/// Consumes the output of a process and stores it in a shared state.\nstruct CommandsRunner {\n    state: Arc<Mutex<ExecutionState>>,\n    script_directory: TempDir,\n}\n\nimpl CommandsRunner {\n    fn spawn(\n        state: Arc<Mutex<ExecutionState>>,\n        script_directory: TempDir,\n        commands: Vec<Vec<String>>,\n        env: HashMap<String, String>,\n        cwd: PathBuf,\n        output_type: OutputType,\n    ) -> thread::JoinHandle<()> {\n        let reader = Self { state, script_directory };\n        thread::spawn(move || reader.run(commands, env, cwd, output_type))\n    }\n\n    fn run(self, commands: Vec<Vec<String>>, env: HashMap<String, String>, cwd: PathBuf, output_type: OutputType) {\n        let mut last_result = true;\n        for command in commands {\n            last_result = self.run_command(command, &env, &cwd, output_type);\n            if !last_result {\n                break;\n            }\n        }\n        let status = match last_result {\n            true => ProcessStatus::Success,\n            false => ProcessStatus::Failure,\n        };\n        self.state.lock().unwrap().status = status;\n    }\n\n    fn run_command(\n        &self,\n        command: Vec<String>,\n        env: &HashMap<String, String>,\n        cwd: &Path,\n        output_type: OutputType,\n    ) -> bool {\n        let (mut child, reader) = match self.launch_process(command, env, cwd) {\n            Ok(inner) => inner,\n            Err(e) => {\n                let mut state = self.state.lock().unwrap();\n                state.status = ProcessStatus::Failure;\n                state.output.extend(e.to_string().into_bytes());\n                return false;\n            }\n        };\n        let _ = Self::process_output(self.state.clone(), reader, output_type);\n\n        match child.wait() {\n            Ok(code) => code.success(),\n            _ => false,\n        }\n    }\n\n    fn launch_process(\n        &self,\n        mut commands: Vec<String>,\n        env: &HashMap<String, String>,\n        cwd: &Path,\n    ) -> Result<(Child, PipeReader), CodeExecuteError> {\n        let (reader, writer) = os_pipe::pipe().map_err(CodeExecuteError::Pipe)?;\n        let writer_clone = writer.try_clone().map_err(CodeExecuteError::Pipe)?;\n        let script_dir = self.script_directory.path().to_string_lossy();\n\n        #[cfg(windows)]\n        let cwd = strip_drive_unc_prefix(cwd);\n\n        for command in &mut commands {\n            *command = command.replace(\"$pwd\", &script_dir);\n        }\n        let (command, args) = commands.split_first().expect(\"no commands\");\n        let child = process::Command::new(command)\n            .args(args)\n            .envs(env)\n            .current_dir(cwd)\n            .stdin(Stdio::null())\n            .stdout(writer)\n            .stderr(writer_clone)\n            .spawn()\n            .map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?;\n        Ok((child, reader))\n    }\n\n    fn process_output(\n        state: Arc<Mutex<ExecutionState>>,\n        mut reader: os_pipe::PipeReader,\n        output_type: OutputType,\n    ) -> io::Result<()> {\n        match output_type {\n            OutputType::Lines => {\n                let reader = BufReader::new(reader);\n                for line in reader.lines() {\n                    let mut state = state.lock().unwrap();\n                    state.output.extend(line?.into_bytes());\n                    state.output.push(b'\\n');\n                }\n                Ok(())\n            }\n            OutputType::Binary => {\n                let mut buffer = Vec::new();\n                reader.read_to_end(&mut buffer)?;\n                state.lock().unwrap().output.extend(buffer);\n                Ok(())\n            }\n        }\n    }\n}\n\n#[derive(Clone, Copy)]\nenum OutputType {\n    Lines,\n    Binary,\n}\n\n/// The state of the execution of a process.\n#[derive(Clone, Default, Debug)]\npub(crate) struct ExecutionState {\n    pub(crate) output: Vec<u8>,\n    pub(crate) status: ProcessStatus,\n}\n\n/// The status of a process.\n#[derive(Clone, Copy, Debug, Default)]\npub(crate) enum ProcessStatus {\n    #[default]\n    Running,\n    Success,\n    Failure,\n}\n\nimpl ProcessStatus {\n    /// Check whether the underlying process is finished.\n    pub(crate) fn is_finished(&self) -> bool {\n        matches!(self, ProcessStatus::Success | ProcessStatus::Failure)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::code::snippet::{SnippetAttributes, SnippetExecution};\n\n    #[test]\n    fn shell_code_execution() {\n        let contents = r\"\necho 'hello world'\necho 'bye'\"\n            .into();\n        let snippet = Snippet {\n            contents,\n            language: SnippetLanguage::Shell,\n            attributes: SnippetAttributes {\n                execution: SnippetExecution::Exec(Default::default()),\n                ..Default::default()\n            },\n        };\n        let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();\n        let handle = executor.execute_async(&snippet).expect(\"execution failed\");\n        let state = loop {\n            let state = handle.state.lock().unwrap();\n            if state.status.is_finished() {\n                break state;\n            }\n        };\n\n        let expected = b\"hello world\\nbye\\n\";\n        assert_eq!(state.output, expected);\n    }\n\n    #[test]\n    fn shell_code_execution_captures_stderr() {\n        let contents = r\"\necho 'This message redirects to stderr' >&2\necho 'hello world'\n\"\n        .into();\n        let snippet = Snippet {\n            contents,\n            language: SnippetLanguage::Shell,\n            attributes: SnippetAttributes {\n                execution: SnippetExecution::Exec(Default::default()),\n                ..Default::default()\n            },\n        };\n        let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();\n        let handle = executor.execute_async(&snippet).expect(\"execution failed\");\n        let state = loop {\n            let state = handle.state.lock().unwrap();\n            if state.status.is_finished() {\n                break state;\n            }\n        };\n\n        let expected = b\"This message redirects to stderr\\nhello world\\n\";\n        assert_eq!(state.output, expected);\n    }\n\n    #[test]\n    fn shell_code_execution_executes_hidden_lines() {\n        let contents = r\"\n/// echo 'this line was hidden'\n/// echo 'this line was hidden and contains another prefix /// '\necho 'hello world'\n\"\n        .into();\n        let snippet = Snippet {\n            contents,\n            language: SnippetLanguage::Shell,\n            attributes: SnippetAttributes {\n                execution: SnippetExecution::Exec(Default::default()),\n                ..Default::default()\n            },\n        };\n        let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();\n        let handle = executor.execute_async(&snippet).expect(\"execution failed\");\n        let state = loop {\n            let state = handle.state.lock().unwrap();\n            if state.status.is_finished() {\n                break state;\n            }\n        };\n\n        let expected = b\"this line was hidden\\nthis line was hidden and contains another prefix /// \\nhello world\\n\";\n        assert_eq!(state.output, expected);\n    }\n\n    #[test]\n    fn built_in_executors() {\n        SnippetExecutor::new(Default::default(), PathBuf::from(\"./\")).expect(\"invalid default executors\");\n    }\n}\n"
  },
  {
    "path": "src/code/highlighting.rs",
    "content": "use crate::{\n    code::snippet::SnippetLanguage,\n    markdown::{\n        elements::{Line, Text},\n        text_style::{Color, TextStyle},\n    },\n    theme::CodeBlockStyle,\n};\nuse flate2::read::ZlibDecoder;\nuse once_cell::sync::Lazy;\nuse serde::Deserialize;\nuse std::{cell::RefCell, collections::BTreeMap, fs, path::Path, rc::Rc};\nuse syntect::{\n    LoadingError,\n    easy::HighlightLines,\n    highlighting::{Style, Theme, ThemeSet},\n    parsing::SyntaxSet,\n};\n\nstatic SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(|| {\n    let contents = include_bytes!(\"../../bat/syntaxes.bin\");\n    bincode::deserialize(contents).expect(\"syntaxes are broken\")\n});\n\nstatic BAT_THEMES: Lazy<LazyThemeSet> = Lazy::new(|| {\n    let contents = include_bytes!(\"../../bat/themes.bin\");\n    let theme_set: LazyThemeSet = bincode::deserialize(contents).expect(\"syntaxes are broken\");\n    theme_set\n});\n\n// This structure mimic's `bat`'s serialized theme set's.\n#[derive(Debug, Deserialize)]\nstruct LazyThemeSet {\n    serialized_themes: BTreeMap<String, Vec<u8>>,\n}\n\npub struct HighlightThemeSet {\n    themes: RefCell<BTreeMap<String, Rc<Theme>>>,\n}\n\nimpl HighlightThemeSet {\n    /// Construct a new highlighter using the given [syntect] theme name.\n    pub fn load_by_name(&self, name: &str) -> Option<SnippetHighlighter> {\n        let mut themes = self.themes.borrow_mut();\n        // Check if we already loaded this one.\n        if let Some(theme) = themes.get(name).cloned() {\n            Some(SnippetHighlighter { theme })\n        }\n        // Otherwise try to deserialize it from bat's themes\n        else if let Some(theme) = self.deserialize_bat_theme(name) {\n            themes.insert(name.into(), theme.clone());\n            Some(SnippetHighlighter { theme })\n        } else {\n            None\n        }\n    }\n\n    /// Register all highlighting themes in the given directory.\n    pub fn register_from_directory<P: AsRef<Path>>(&mut self, path: P) -> Result<(), LoadingError> {\n        let Ok(metadata) = fs::metadata(&path) else {\n            return Ok(());\n        };\n        if !metadata.is_dir() {\n            return Ok(());\n        }\n        let themes = ThemeSet::load_from_folder(path)?;\n        let themes = themes.themes.into_iter().map(|(name, theme)| (name, Rc::new(theme)));\n        self.themes.borrow_mut().extend(themes);\n        Ok(())\n    }\n\n    fn deserialize_bat_theme(&self, name: &str) -> Option<Rc<Theme>> {\n        let serialized = BAT_THEMES.serialized_themes.get(name)?;\n        let decoded: Theme = bincode::deserialize_from(ZlibDecoder::new(serialized.as_slice())).ok()?;\n        let decoded = Rc::new(decoded);\n        Some(decoded)\n    }\n}\n\nimpl Default for HighlightThemeSet {\n    fn default() -> Self {\n        let themes = ThemeSet::load_defaults();\n        let themes = themes.themes.into_iter().map(|(name, theme)| (name, Rc::new(theme))).collect();\n        Self { themes: RefCell::new(themes) }\n    }\n}\n\n/// A snippet highlighter.\n#[derive(Clone)]\npub(crate) struct SnippetHighlighter {\n    theme: Rc<Theme>,\n}\n\nimpl SnippetHighlighter {\n    /// Create a highlighter for a specific language.\n    pub(crate) fn language_highlighter(&self, language: &SnippetLanguage) -> LanguageHighlighter<'_> {\n        let extension = Self::language_extension(language);\n        let syntax = SYNTAX_SET.find_syntax_by_extension(extension).unwrap();\n        let highlighter = HighlightLines::new(syntax, &self.theme);\n        LanguageHighlighter::new(language.clone(), highlighter)\n    }\n\n    fn language_extension(language: &SnippetLanguage) -> &'static str {\n        use SnippetLanguage::*;\n        match language {\n            Ada => \"adb\",\n            Asp => \"asa\",\n            Awk => \"awk\",\n            Bash => \"sh\",\n            BatchFile => \"cmd\",\n            C => \"c\",\n            CMake => \"cmake\",\n            CSharp => \"cs\",\n            Clojure => \"clj\",\n            Cpp => \"cpp\",\n            Crontab => \"crontab\",\n            Css => \"css\",\n            Dart => \"dart\",\n            D2 => \"txt\",\n            DLang => \"d\",\n            Diff => \"diff\",\n            Docker => \"Dockerfile\",\n            Dotenv => \"env\",\n            Elixir => \"ex\",\n            Elm => \"elm\",\n            Erlang => \"erl\",\n            File => \"txt\",\n            Fish => \"fish\",\n            FSharp => \"fsx\",\n            GdScript => \"gd\",\n            Go => \"go\",\n            GraphQL => \"graphql\",\n            Haskell => \"hs\",\n            Html => \"html\",\n            Java => \"java\",\n            JavaScript => \"js\",\n            Json => \"json\",\n            Jsonnet => \"jsonnet\",\n            Julia => \"jl\",\n            Kotlin => \"kt\",\n            Latex => \"tex\",\n            Lua => \"lua\",\n            Makefile => \"make\",\n            Markdown => \"md\",\n            Mermaid => \"txt\",\n            Nix => \"nix\",\n            Nushell => \"txt\",\n            OCaml => \"ml\",\n            Perl => \"pl\",\n            Php => \"php\",\n            PowerShell => \"ps1\",\n            Protobuf => \"proto\",\n            Puppet => \"pp\",\n            Python => \"py\",\n            R => \"r\",\n            Racket => \"rkt\",\n            Ruby => \"rb\",\n            Rust => \"rs\",\n            RustScript => \"rs\",\n            Scala => \"scala\",\n            Shell => \"sh\",\n            Sql => \"sql\",\n            Swift => \"swift\",\n            Svelte => \"svelte\",\n            Tcl => \"tcl\",\n            Terraform => \"tf\",\n            Toml => \"toml\",\n            TypeScript => \"ts\",\n            TypeScriptReact => \"tsx\",\n            Typst => \"txt\",\n            // default to plain text so we get the same look&feel\n            Unknown(_) => \"txt\",\n            Verilog => \"v\",\n            Vue => \"vue\",\n            Wsl => \"sh\",\n            Xml => \"xml\",\n            Yaml => \"yaml\",\n            Zsh => \"sh\",\n            Zig => \"zig\",\n        }\n    }\n}\n\nimpl Default for SnippetHighlighter {\n    fn default() -> Self {\n        let themes = HighlightThemeSet::default();\n        themes.load_by_name(\"base16-eighties.dark\").expect(\"default theme not found\")\n    }\n}\n\npub(crate) struct LanguageHighlighter<'a> {\n    language: SnippetLanguage,\n    highlighter: HighlightLines<'a>,\n    parse_started: bool,\n}\n\nimpl<'a> LanguageHighlighter<'a> {\n    fn new(language: SnippetLanguage, highlighter: HighlightLines<'a>) -> Self {\n        Self { language, highlighter, parse_started: false }\n    }\n\n    pub(crate) fn style_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line {\n        if !self.parse_started {\n            let line = line.trim();\n            if !line.is_empty() {\n                self.parse_started = true;\n                // Parse a fake \"<?php\" line if PHP code doesn't start with one so highlighting\n                // looks good.\n                if matches!(self.language, SnippetLanguage::Php) && !line.starts_with(\"<?php\") {\n                    self.highlighter.highlight_line(\"<?php\\n\", &SYNTAX_SET).unwrap();\n                }\n            }\n        }\n        let texts: Vec<_> = self\n            .highlighter\n            .highlight_line(line, &SYNTAX_SET)\n            .unwrap()\n            .into_iter()\n            .map(|(style, tokens)| StyledTokens::new(style, tokens, block_style).apply_style())\n            .collect();\n        Line(texts)\n    }\n}\n\npub(crate) struct StyledTokens<'a> {\n    pub(crate) style: TextStyle,\n    pub(crate) tokens: &'a str,\n}\n\nimpl<'a> StyledTokens<'a> {\n    pub(crate) fn new(style: Style, tokens: &'a str, block_style: &CodeBlockStyle) -> Self {\n        let has_background = block_style.background;\n        let background = has_background.then_some(parse_color(style.background)).flatten();\n        let foreground = parse_color(style.foreground);\n        let mut style = TextStyle::default();\n        style.colors.background = background;\n        style.colors.foreground = foreground;\n        Self { style, tokens }\n    }\n\n    pub(crate) fn apply_style(&self) -> Text {\n        let text: String = self.tokens.split('\\n').collect();\n        Text::new(text, self.style)\n    }\n}\n\n// This code has been adapted from bat's: https://github.com/sharkdp/bat\nfn parse_color(color: syntect::highlighting::Color) -> Option<Color> {\n    if color.a == 0 {\n        Some(match color.r {\n            0x00 => Color::Black,\n            0x01 => Color::DarkRed,\n            0x02 => Color::DarkGreen,\n            0x03 => Color::DarkYellow,\n            0x04 => Color::DarkBlue,\n            0x05 => Color::DarkMagenta,\n            0x06 => Color::DarkCyan,\n            0x07 => Color::Grey,\n            0x08 => Color::DarkGrey,\n            0x09 => Color::Red,\n            0x0a => Color::Green,\n            0x0b => Color::Yellow,\n            0x0c => Color::Blue,\n            0x0d => Color::Magenta,\n            0x0e => Color::Cyan,\n            0x0f => Color::White,\n            n => Color::from_ansi(n)?,\n        })\n    } else if color.a == 1 {\n        None\n    } else {\n        Some(Color::new(color.r, color.g, color.b))\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use strum::IntoEnumIterator;\n    use tempfile::tempdir;\n\n    #[test]\n    fn language_extensions_exist() {\n        for language in SnippetLanguage::iter() {\n            let extension = SnippetHighlighter::language_extension(&language);\n            let syntax = SYNTAX_SET.find_syntax_by_extension(extension);\n            assert!(syntax.is_some(), \"extension {extension} for {language:?} not found\");\n        }\n    }\n\n    #[test]\n    fn default_highlighter() {\n        SnippetHighlighter::default();\n    }\n\n    #[test]\n    fn load_custom() {\n        let directory = tempdir().expect(\"creating tempdir\");\n        // A minimalistic .tmTheme theme.\n        let theme = r#\"\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple Computer//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>potato</key>\n    <string>Example Color Scheme</string>\n    <key>settings</key>\n    <array>\n        <dict>\n            <key>settings</key>\n            <dict></dict>\n        </dict>\n    </array>\n</dict>\"#;\n        fs::write(directory.path().join(\"potato.tmTheme\"), theme).expect(\"writing theme\");\n\n        let mut themes = HighlightThemeSet::default();\n        themes.register_from_directory(directory.path()).expect(\"loading themes\");\n        assert!(themes.load_by_name(\"potato\").is_some());\n    }\n\n    #[test]\n    fn register_from_missing_directory() {\n        let mut themes = HighlightThemeSet::default();\n        let result = themes.register_from_directory(\"/tmp/presenterm/8ee2027983915ec78acc45027d874316\");\n        result.expect(\"loading failed\");\n    }\n\n    #[test]\n    fn default_themes() {\n        let themes = HighlightThemeSet::default();\n        // This is a bat theme\n        assert!(themes.load_by_name(\"GitHub\").is_some());\n        // This is a default syntect theme\n        assert!(themes.load_by_name(\"InspiredGitHub\").is_some());\n    }\n}\n"
  },
  {
    "path": "src/code/mod.rs",
    "content": "pub(crate) mod execute;\npub(crate) mod highlighting;\npub(crate) mod padding;\npub(crate) mod snippet;\n"
  },
  {
    "path": "src/code/padding.rs",
    "content": "use std::iter;\n\npub(crate) struct NumberPadder {\n    width: usize,\n}\n\nimpl NumberPadder {\n    pub(crate) fn new(upper_bound: usize) -> Self {\n        let width = upper_bound.checked_ilog10().map(|log| log as usize + 1).unwrap_or_default();\n        Self { width }\n    }\n\n    pub(crate) fn pad_right(&self, number: usize) -> String {\n        let line_number_width = number.ilog10() as usize + 1;\n        let number_padding = self.width - line_number_width;\n\n        let mut output = String::with_capacity(self.width);\n        output.extend(iter::repeat_n(' ', number_padding));\n        output.push_str(&number.to_string());\n        output\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use rstest::rstest;\n\n    #[rstest]\n    #[case(&[1, 2], &[\"1\", \"2\"])]\n    #[case(&[1, 9], &[\"1\", \"9\"])]\n    #[case(&[1, 10], &[\" 1\", \"10\"])]\n    #[case(&[1, 10, 100], &[\"  1\", \" 10\", \"100\"])]\n    fn right_padding(#[case] numbers: &[usize], #[case] expected: &[&str]) {\n        let max = numbers.iter().max().expect(\"no numbers\");\n        let padder = NumberPadder::new(*max);\n        let rendered: Vec<_> = numbers.iter().map(|n| padder.pad_right(*n)).collect();\n        assert_eq!(rendered, expected);\n    }\n\n    #[test]\n    fn zero_count() {\n        NumberPadder::new(0);\n    }\n}\n"
  },
  {
    "path": "src/code/snippet.rs",
    "content": "use super::{\n    highlighting::{LanguageHighlighter, StyledTokens},\n    padding::NumberPadder,\n};\nuse crate::{\n    markdown::{\n        elements::{Percent, PercentParseError},\n        text::{WeightedLine, WeightedText},\n        text_style::{Color, TextStyle},\n    },\n    presentation::ChunkMutator,\n    render::{\n        operation::{AsRenderOperations, BlockLine, RenderOperation},\n        properties::WindowSize,\n    },\n    theme::{Alignment, CodeBlockStyle},\n};\nuse serde::Deserialize;\nuse std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, path::PathBuf, rc::Rc, str::FromStr};\nuse strum::{EnumDiscriminants, EnumIter};\nuse unicode_width::UnicodeWidthStr;\n\npub(crate) struct SnippetSplitter<'a> {\n    style: &'a CodeBlockStyle,\n    hidden_line_prefix: Option<&'a str>,\n}\n\nimpl<'a> SnippetSplitter<'a> {\n    pub(crate) fn new(style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>) -> Self {\n        Self { style, hidden_line_prefix }\n    }\n\n    pub(crate) fn split(&self, code: &Snippet) -> Vec<SnippetLine> {\n        let mut lines = Vec::new();\n        let horizontal_padding = self.style.padding.horizontal;\n        let vertical_padding = self.style.padding.vertical;\n        if vertical_padding > 0 {\n            lines.push(SnippetLine::empty());\n        }\n        self.push_lines(code, horizontal_padding, &mut lines);\n        if vertical_padding > 0 {\n            lines.push(SnippetLine::empty());\n        }\n        lines\n    }\n\n    fn push_lines(&self, code: &Snippet, horizontal_padding: u8, lines: &mut Vec<SnippetLine>) {\n        if code.contents.is_empty() {\n            return;\n        }\n\n        let padding = \" \".repeat(horizontal_padding as usize);\n        let padder = NumberPadder::new(code.visible_lines(self.hidden_line_prefix).count());\n        for (index, line) in code.visible_lines(self.hidden_line_prefix).enumerate() {\n            let mut line = line.replace('\\t', \"    \");\n            let mut prefix = padding.clone();\n            if code.attributes.line_numbers {\n                let line_number = index + 1;\n                prefix.push_str(&padder.pad_right(line_number));\n                prefix.push(' ');\n            }\n            line.push('\\n');\n            let line_number = Some(index as u16 + 1);\n            lines.push(SnippetLine { prefix, code: line, right_padding_length: padding.len() as u16, line_number });\n        }\n    }\n}\n\npub(crate) struct SnippetLine {\n    pub(crate) prefix: String,\n    pub(crate) code: String,\n    pub(crate) right_padding_length: u16,\n    pub(crate) line_number: Option<u16>,\n}\n\nimpl SnippetLine {\n    pub(crate) fn empty() -> Self {\n        Self { prefix: String::new(), code: \"\\n\".into(), right_padding_length: 0, line_number: None }\n    }\n\n    pub(crate) fn width(&self) -> usize {\n        self.prefix.width() + self.code.width() + self.right_padding_length as usize\n    }\n\n    pub(crate) fn highlight(\n        &self,\n        code_highlighter: &mut LanguageHighlighter,\n        block_style: &CodeBlockStyle,\n        font_size: u8,\n    ) -> WeightedLine {\n        let mut line = code_highlighter.style_line(&self.code, block_style);\n        line.apply_style(&TextStyle::default().size(font_size));\n        line.into()\n    }\n\n    pub(crate) fn dim(&self, dim_style: &TextStyle) -> WeightedLine {\n        let output = vec![StyledTokens { style: *dim_style, tokens: &self.code }.apply_style()];\n        output.into()\n    }\n\n    pub(crate) fn dim_prefix(&self, dim_style: &TextStyle) -> WeightedText {\n        let text = StyledTokens { style: *dim_style, tokens: &self.prefix }.apply_style();\n        text.into()\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct HighlightContext {\n    pub(crate) groups: Vec<HighlightGroup>,\n    pub(crate) current: usize,\n    pub(crate) block_length: u16,\n    pub(crate) alignment: Alignment,\n}\n\n#[derive(Debug)]\npub(crate) struct HighlightedLine {\n    pub(crate) prefix: WeightedText,\n    pub(crate) right_padding_length: u16,\n    pub(crate) highlighted: WeightedLine,\n    pub(crate) not_highlighted: WeightedLine,\n    pub(crate) line_number: Option<u16>,\n    pub(crate) context: Rc<RefCell<HighlightContext>>,\n    pub(crate) block_color: Option<Color>,\n}\n\nimpl AsRenderOperations for HighlightedLine {\n    fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {\n        let context = self.context.borrow();\n        let group = &context.groups[context.current];\n        let needs_highlight = self.line_number.map(|number| group.contains(number)).unwrap_or_default();\n        // TODO: Cow<str>?\n        let text = match needs_highlight {\n            true => self.highlighted.clone(),\n            false => self.not_highlighted.clone(),\n        };\n        vec![\n            RenderOperation::RenderBlockLine(BlockLine {\n                prefix: self.prefix.clone(),\n                right_padding_length: self.right_padding_length,\n                repeat_prefix_on_wrap: false,\n                text,\n                block_length: context.block_length,\n                alignment: context.alignment,\n                block_color: self.block_color,\n            }),\n            RenderOperation::RenderLineBreak,\n        ]\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct HighlightMutator {\n    context: Rc<RefCell<HighlightContext>>,\n}\n\nimpl HighlightMutator {\n    pub(crate) fn new(context: Rc<RefCell<HighlightContext>>) -> Self {\n        Self { context }\n    }\n}\n\nimpl ChunkMutator for HighlightMutator {\n    fn mutate_next(&self) -> bool {\n        let mut context = self.context.borrow_mut();\n        if context.current == context.groups.len() - 1 {\n            false\n        } else {\n            context.current += 1;\n            true\n        }\n    }\n\n    fn mutate_previous(&self) -> bool {\n        let mut context = self.context.borrow_mut();\n        if context.current == 0 {\n            false\n        } else {\n            context.current -= 1;\n            true\n        }\n    }\n\n    fn reset_mutations(&self) {\n        self.context.borrow_mut().current = 0;\n    }\n\n    fn apply_all_mutations(&self) {\n        let mut context = self.context.borrow_mut();\n        context.current = context.groups.len() - 1;\n    }\n\n    fn mutations(&self) -> (usize, usize) {\n        let context = self.context.borrow();\n        (context.current, context.groups.len())\n    }\n}\n\npub(crate) type ParseResult<T> = Result<T, SnippetBlockParseError>;\n\npub(crate) struct SnippetParser;\n\nimpl SnippetParser {\n    pub(crate) fn parse(info: String, code: String) -> ParseResult<Snippet> {\n        let (language, attributes) = Self::parse_block_info(&info)?;\n        let code = Snippet { contents: code, language, attributes };\n        Ok(code)\n    }\n\n    fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> {\n        let (language, input) = Self::parse_language(input);\n        let attributes = Self::parse_attributes(input)?;\n        if attributes.width.is_some() && !matches!(attributes.execution, SnippetExecution::Render) {\n            return Err(SnippetBlockParseError::NotRenderSnippet(\"width\"));\n        }\n        Ok((language, attributes))\n    }\n\n    fn parse_language(input: &str) -> (SnippetLanguage, &str) {\n        let token = Self::next_identifier(input);\n        // this always returns `Ok` given we fall back to `Unknown` if we don't know the language.\n        let language = token.parse().expect(\"language parsing\");\n        let rest = &input[token.len()..];\n        (language, rest)\n    }\n\n    fn parse_attributes(mut input: &str) -> ParseResult<SnippetAttributes> {\n        let mut attributes = SnippetAttributes::default();\n        let mut processed_attributes = Vec::new();\n        while let (Some(attribute), rest) = Self::parse_attribute(input)? {\n            let discriminant = SnippetAttributeDiscriminants::from(&attribute);\n            if processed_attributes.contains(&discriminant) {\n                return Err(SnippetBlockParseError::DuplicateAttribute(\"duplicate attribute\"));\n            }\n            use SnippetAttribute::*;\n            match attribute {\n                LineNumbers => attributes.line_numbers = true,\n                Exec(spec) => {\n                    attributes.execution = attributes\n                        .execution\n                        .try_merge(SnippetExecution::Exec(SnippetExecArgs { spec, ..Default::default() }))?;\n                }\n                AutoExec(spec) => {\n                    attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {\n                        spec,\n                        auto: true,\n                        ..Default::default()\n                    }))?;\n                }\n                ExecPty(spec, args) => {\n                    attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {\n                        spec,\n                        pty: Some(args),\n                        ..Default::default()\n                    }))?;\n                }\n                ExecReplace(spec) => {\n                    attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {\n                        spec,\n                        repr: SnippetRepr::ExecReplace,\n                        ..Default::default()\n                    }))?;\n                }\n                Id(id) => {\n                    attributes.id = Some(id);\n                }\n                Validate(spec) => {\n                    if attributes.validate.is_some() {\n                        return Err(SnippetBlockParseError::DuplicateAttribute(\"+validate\"));\n                    }\n                    attributes.validate = Some(spec);\n                }\n                Image => {\n                    attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {\n                        repr: SnippetRepr::Image,\n                        ..Default::default()\n                    }))?;\n                }\n                Render => {\n                    attributes.execution = attributes.execution.try_merge(SnippetExecution::Render)?;\n                }\n                AcquireTerminal(spec) => {\n                    attributes.execution = attributes.execution.try_merge(SnippetExecution::Exec(SnippetExecArgs {\n                        spec,\n                        repr: SnippetRepr::AcquireTerminal,\n                        ..Default::default()\n                    }))?;\n                }\n                NoBackground => attributes.no_background = true,\n                HighlightedLines(lines) => attributes.highlight_groups = lines,\n                Width(width) => attributes.width = Some(width),\n                ExpectedExecutionResult(result) => attributes.expected_execution_result = result,\n            };\n            processed_attributes.push(discriminant);\n            input = rest;\n        }\n        if attributes.highlight_groups.is_empty() {\n            attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All]));\n        }\n        Ok(attributes)\n    }\n\n    fn parse_attribute(input: &str) -> ParseResult<(Option<SnippetAttribute>, &str)> {\n        let input = Self::skip_whitespace(input);\n        let (attribute, input) = match input.chars().next() {\n            Some('+') => {\n                let token = Self::next_identifier(&input[1..]);\n                let attribute = match token {\n                    \"line_numbers\" => SnippetAttribute::LineNumbers,\n                    \"exec\" => SnippetAttribute::Exec(SnippetExecutorSpec::default()),\n                    \"auto_exec\" => SnippetAttribute::AutoExec(SnippetExecutorSpec::default()),\n                    \"exec_replace\" => SnippetAttribute::ExecReplace(SnippetExecutorSpec::default()),\n                    \"validate\" => SnippetAttribute::Validate(SnippetExecutorSpec::default()),\n                    \"image\" => SnippetAttribute::Image,\n                    \"render\" => SnippetAttribute::Render,\n                    \"no_background\" => SnippetAttribute::NoBackground,\n                    \"acquire_terminal\" => SnippetAttribute::AcquireTerminal(SnippetExecutorSpec::default()),\n                    \"pty\" => SnippetAttribute::ExecPty(SnippetExecutorSpec::default(), Default::default()),\n                    other => {\n                        let (attribute, parameter) = other\n                            .split_once(':')\n                            .ok_or_else(|| SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into()))?;\n                        match attribute {\n                            \"exec\" => SnippetAttribute::Exec(SnippetExecutorSpec::Alternative(parameter.to_string())),\n                            \"auto_exec\" => {\n                                SnippetAttribute::AutoExec(SnippetExecutorSpec::Alternative(parameter.to_string()))\n                            }\n                            \"exec_replace\" => {\n                                SnippetAttribute::ExecReplace(SnippetExecutorSpec::Alternative(parameter.to_string()))\n                            }\n                            \"id\" => SnippetAttribute::Id(parameter.to_string()),\n                            \"validate\" => {\n                                SnippetAttribute::Validate(SnippetExecutorSpec::Alternative(parameter.to_string()))\n                            }\n                            \"acquire_terminal\" => SnippetAttribute::AcquireTerminal(SnippetExecutorSpec::Alternative(\n                                parameter.to_string(),\n                            )),\n                            \"width\" => {\n                                let width = parameter.parse().map_err(SnippetBlockParseError::InvalidWidth)?;\n                                SnippetAttribute::Width(width)\n                            }\n                            \"expect\" => match parameter {\n                                \"success\" => {\n                                    SnippetAttribute::ExpectedExecutionResult(ExpectedSnippetExecutionResult::Success)\n                                }\n                                \"failure\" | \"fail\" => {\n                                    SnippetAttribute::ExpectedExecutionResult(ExpectedSnippetExecutionResult::Failure)\n                                }\n                                _ => {\n                                    return Err(SnippetBlockParseError::InvalidToken(\n                                        Self::next_identifier(input).into(),\n                                    ));\n                                }\n                            },\n                            \"pty\" => SnippetAttribute::ExecPty(SnippetExecutorSpec::default(), parameter.parse()?),\n                            _ => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())),\n                        }\n                    }\n                };\n                (Some(attribute), &input[token.len() + 1..])\n            }\n            Some('{') => {\n                let (lines, input) = Self::parse_highlight_groups(&input[1..])?;\n                (Some(SnippetAttribute::HighlightedLines(lines)), input)\n            }\n            Some(_) => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())),\n            None => (None, input),\n        };\n        Ok((attribute, input))\n    }\n\n    fn parse_highlight_groups(input: &str) -> ParseResult<(Vec<HighlightGroup>, &str)> {\n        use SnippetBlockParseError::InvalidHighlightedLines;\n        let Some((head, tail)) = input.split_once('}') else {\n            return Err(InvalidHighlightedLines(\"no enclosing '}'\".into()));\n        };\n        let head = head.trim();\n        if head.is_empty() {\n            return Ok((Vec::new(), tail));\n        }\n\n        let mut highlight_groups = Vec::new();\n        for group in head.split('|') {\n            let group = Self::parse_highlight_group(group)?;\n            highlight_groups.push(group);\n        }\n        Ok((highlight_groups, tail))\n    }\n\n    fn parse_highlight_group(input: &str) -> ParseResult<HighlightGroup> {\n        let mut highlights = Vec::new();\n        for piece in input.split(',') {\n            let piece = piece.trim();\n            if piece == \"all\" {\n                highlights.push(Highlight::All);\n                continue;\n            }\n            match piece.split_once('-') {\n                Some((left, right)) => {\n                    let left = Self::parse_number(left)?;\n                    let right = Self::parse_number(right)?;\n                    let right = right.checked_add(1).ok_or_else(|| {\n                        SnippetBlockParseError::InvalidHighlightedLines(format!(\"{right} is too large\"))\n                    })?;\n                    highlights.push(Highlight::Range(left..right));\n                }\n                None => {\n                    let number = Self::parse_number(piece)?;\n                    highlights.push(Highlight::Single(number));\n                }\n            }\n        }\n        Ok(HighlightGroup::new(highlights))\n    }\n\n    fn parse_number(input: &str) -> ParseResult<u16> {\n        input\n            .trim()\n            .parse()\n            .map_err(|_| SnippetBlockParseError::InvalidHighlightedLines(format!(\"not a number: '{input}'\")))\n    }\n\n    fn skip_whitespace(input: &str) -> &str {\n        input.trim_start_matches(' ')\n    }\n\n    fn next_identifier(input: &str) -> &str {\n        match input.split_once(' ') {\n            Some((token, _)) => token,\n            None => input,\n        }\n    }\n}\n\n#[derive(thiserror::Error, Debug)]\npub enum SnippetBlockParseError {\n    #[error(\"invalid code attribute: {0}\")]\n    InvalidToken(String),\n\n    #[error(\"invalid highlighted lines: {0}\")]\n    InvalidHighlightedLines(String),\n\n    #[error(\"invalid width: {0}\")]\n    InvalidWidth(PercentParseError),\n\n    #[error(\"invalid pty args, expected '[standby:]<columns>:<rows>'\")]\n    InvalidPtyArgs,\n\n    #[error(\"duplicate attribute: {0}\")]\n    DuplicateAttribute(&'static str),\n\n    #[error(\"+exec_replace +image and +render can't be used together \")]\n    MultipleRepresentation,\n\n    #[error(\"attribute {0} can only be set in +render blocks\")]\n    NotRenderSnippet(&'static str),\n}\n\n#[derive(EnumDiscriminants)]\nenum SnippetAttribute {\n    LineNumbers,\n    Exec(SnippetExecutorSpec),\n    AutoExec(SnippetExecutorSpec),\n    ExecReplace(SnippetExecutorSpec),\n    ExecPty(SnippetExecutorSpec, PtyArgs),\n    Validate(SnippetExecutorSpec),\n    Image,\n    Render,\n    HighlightedLines(Vec<HighlightGroup>),\n    Width(Percent),\n    NoBackground,\n    AcquireTerminal(SnippetExecutorSpec),\n    ExpectedExecutionResult(ExpectedSnippetExecutionResult),\n    Id(String),\n}\n\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) enum SnippetExecutorSpec {\n    #[default]\n    Default,\n    Alternative(String),\n}\n\n#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub(crate) enum ExpectedSnippetExecutionResult {\n    #[default]\n    Success,\n    Failure,\n}\n\n/// A code snippet.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct Snippet {\n    /// The snippet itself.\n    pub(crate) contents: String,\n\n    /// The programming language this snippet is written in.\n    pub(crate) language: SnippetLanguage,\n\n    /// The attributes used for snippet.\n    pub(crate) attributes: SnippetAttributes,\n}\n\nimpl Snippet {\n    pub(crate) fn visible_lines<'a, 'b>(\n        &'a self,\n        hidden_line_prefix: Option<&'b str>,\n    ) -> impl Iterator<Item = &'a str> + 'b\n    where\n        'a: 'b,\n    {\n        self.contents.lines().filter(move |line| !hidden_line_prefix.is_some_and(|prefix| line.starts_with(prefix)))\n    }\n\n    pub(crate) fn executable_contents(&self, hidden_line_prefix: Option<&str>) -> String {\n        if let Some(prefix) = hidden_line_prefix {\n            self.contents.lines().fold(String::new(), |mut output, line| {\n                let line = line.strip_prefix(prefix).unwrap_or(line);\n                let _ = writeln!(output, \"{line}\");\n                output\n            })\n        } else {\n            self.contents.to_owned()\n        }\n    }\n}\n\n/// The language of a code snippet.\n#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\npub enum SnippetLanguage {\n    Ada,\n    Asp,\n    Awk,\n    Bash,\n    BatchFile,\n    C,\n    CMake,\n    Crontab,\n    CSharp,\n    Clojure,\n    Cpp,\n    Css,\n    Dart,\n    D2,\n    DLang,\n    Diff,\n    Docker,\n    Dotenv,\n    Elixir,\n    Elm,\n    Erlang,\n    File,\n    Fish,\n    FSharp,\n    GdScript,\n    Go,\n    GraphQL,\n    Haskell,\n    Html,\n    Java,\n    JavaScript,\n    Json,\n    Jsonnet,\n    Julia,\n    Kotlin,\n    Latex,\n    Lua,\n    Makefile,\n    Mermaid,\n    Markdown,\n    Nix,\n    Nushell,\n    OCaml,\n    Perl,\n    Php,\n    PowerShell,\n    Protobuf,\n    Puppet,\n    Python,\n    R,\n    Racket,\n    Ruby,\n    Rust,\n    RustScript,\n    Scala,\n    Shell,\n    Sql,\n    Swift,\n    Svelte,\n    Tcl,\n    Terraform,\n    Toml,\n    TypeScript,\n    TypeScriptReact,\n    Typst,\n    Unknown(String),\n    Xml,\n    Yaml,\n    Verilog,\n    Vue,\n    Wsl,\n    Zig,\n    Zsh,\n}\n\ncrate::utils::impl_deserialize_from_str!(SnippetLanguage);\n\nimpl FromStr for SnippetLanguage {\n    type Err = Infallible;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        use SnippetLanguage::*;\n        let language = match s.to_lowercase().as_str() {\n            \"ada\" => Ada,\n            \"asp\" => Asp,\n            \"awk\" => Awk,\n            \"bash\" => Bash,\n            \"bat\" | \"cmd\" => BatchFile,\n            \"c\" => C,\n            \"cmake\" => CMake,\n            \"crontab\" => Crontab,\n            \"csharp\" => CSharp,\n            \"clojure\" => Clojure,\n            \"cpp\" | \"c++\" => Cpp,\n            \"css\" => Css,\n            \"dart\" => Dart,\n            \"d2\" => D2,\n            \"d\" => DLang,\n            \"diff\" => Diff,\n            \"docker\" => Docker,\n            \"dotenv\" => Dotenv,\n            \"elixir\" => Elixir,\n            \"elm\" => Elm,\n            \"erlang\" => Erlang,\n            \"file\" => File,\n            \"fish\" => Fish,\n            \"fsharp\" => FSharp,\n            \"go\" => Go,\n            \"graphql\" => GraphQL,\n            \"gdscript\" => GdScript,\n            \"haskell\" => Haskell,\n            \"html\" => Html,\n            \"java\" => Java,\n            \"javascript\" | \"js\" => JavaScript,\n            \"json\" => Json,\n            \"jsonnet\" => Jsonnet,\n            \"julia\" => Julia,\n            \"kotlin\" => Kotlin,\n            \"latex\" => Latex,\n            \"lua\" => Lua,\n            \"make\" => Makefile,\n            \"markdown\" => Markdown,\n            \"mermaid\" => Mermaid,\n            \"nix\" => Nix,\n            \"nushell\" | \"nu\" => Nushell,\n            \"ocaml\" => OCaml,\n            \"perl\" => Perl,\n            \"php\" => Php,\n            \"powershell\" | \"pwsh\" => PowerShell,\n            \"protobuf\" => Protobuf,\n            \"puppet\" => Puppet,\n            \"python\" => Python,\n            \"r\" => R,\n            \"racket\" => Racket,\n            \"ruby\" => Ruby,\n            \"rust\" => Rust,\n            \"rust-script\" => RustScript,\n            \"scala\" => Scala,\n            \"shell\" | \"sh\" => Shell,\n            \"sql\" => Sql,\n            \"svelte\" => Svelte,\n            \"swift\" => Swift,\n            \"tcl\" => Tcl,\n            \"terraform\" => Terraform,\n            \"toml\" => Toml,\n            \"typescript\" | \"ts\" => TypeScript,\n            \"tsx\" => TypeScriptReact,\n            \"typst\" => Typst,\n            \"xml\" => Xml,\n            \"yaml\" => Yaml,\n            \"verilog\" => Verilog,\n            \"vue\" => Vue,\n            \"wsl\" => Wsl,\n            \"zig\" => Zig,\n            \"zsh\" => Zsh,\n            other => Unknown(other.to_string()),\n        };\n        Ok(language)\n    }\n}\n\n/// Attributes for code snippets.\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) struct SnippetAttributes {\n    /// The way the snippet should be executed, if any.\n    pub(crate) execution: SnippetExecution,\n\n    /// Whether the snippet should show line numbers.\n    pub(crate) line_numbers: bool,\n\n    /// The groups of lines to highlight.\n    pub(crate) highlight_groups: Vec<HighlightGroup>,\n\n    /// The width of the generated image.\n    ///\n    /// Only valid for +render snippets.\n    pub(crate) width: Option<Percent>,\n\n    /// Whether to add no background to a snippet.\n    pub(crate) no_background: bool,\n\n    /// The spec to use to validate this snippet.\n    pub(crate) validate: Option<SnippetExecutorSpec>,\n\n    /// The expected execution result for a snippet.\n    pub(crate) expected_execution_result: ExpectedSnippetExecutionResult,\n\n    /// The identifier for a snippet.\n    pub(crate) id: Option<String>,\n}\n\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) enum SnippetRepr {\n    #[default]\n    SnippetOutput,\n    Image,\n    ExecReplace,\n    AcquireTerminal,\n}\n\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) struct PtyArgs {\n    pub(crate) columns: Option<u16>,\n    pub(crate) rows: Option<u16>,\n    pub(crate) standby: bool,\n}\n\nimpl FromStr for PtyArgs {\n    type Err = SnippetBlockParseError;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let mut standby = false;\n        let s = match s.strip_prefix(\"standby\") {\n            Some(rest) => {\n                let rest = match rest.get(..1) {\n                    Some(\":\") => &rest[1..],\n                    Some(_) => return Err(SnippetBlockParseError::InvalidPtyArgs),\n                    None => rest,\n                };\n                standby = true;\n                rest\n            }\n            None => s,\n        };\n        if s.is_empty() {\n            return Ok(Self { standby, ..Default::default() });\n        }\n        let Some((columns, rows)) = s.split_once(':') else {\n            return Err(SnippetBlockParseError::InvalidPtyArgs);\n        };\n        let columns = columns.parse().map_err(|_| SnippetBlockParseError::InvalidPtyArgs)?;\n        let rows = rows.parse().map_err(|_| SnippetBlockParseError::InvalidPtyArgs)?;\n        Ok(Self { columns: Some(columns), rows: Some(rows), standby })\n    }\n}\n\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) struct SnippetExecArgs {\n    pub(crate) spec: SnippetExecutorSpec,\n    pub(crate) auto: bool,\n    pub(crate) pty: Option<PtyArgs>,\n    pub(crate) repr: SnippetRepr,\n}\n\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) enum SnippetExecution {\n    #[default]\n    None,\n    Render,\n    Exec(SnippetExecArgs),\n}\n\nimpl SnippetExecution {\n    fn try_merge(self, other: SnippetExecution) -> ParseResult<Self> {\n        match (self, other) {\n            (Self::None, other) => Ok(other),\n            (Self::Render, Self::None) => Ok(Self::Render),\n            (Self::Render, Self::Render) => Err(SnippetBlockParseError::DuplicateAttribute(\"+render\")),\n            (Self::Render, Self::Exec(_)) | (Self::Exec(_), Self::Render) => {\n                Err(SnippetBlockParseError::MultipleRepresentation)\n            }\n            (Self::Exec(mut ours), Self::Exec(theirs)) => {\n                let SnippetExecArgs { spec, auto, pty, repr } = theirs;\n                ours.auto = ours.auto || auto;\n                ours.pty = pty.or(ours.pty);\n                ours.spec = match ours.spec {\n                    SnippetExecutorSpec::Default => spec,\n                    SnippetExecutorSpec::Alternative(spec) => SnippetExecutorSpec::Alternative(spec),\n                };\n                ours.repr = match (ours.repr, repr) {\n                    (SnippetRepr::SnippetOutput, other) => other,\n                    (ours, SnippetRepr::SnippetOutput) => ours,\n                    _ => return Err(SnippetBlockParseError::MultipleRepresentation),\n                };\n                Ok(Self::Exec(ours))\n            }\n            (Self::Exec(args), Self::None) => Ok(Self::Exec(args)),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) struct HighlightGroup(Vec<Highlight>);\n\nimpl HighlightGroup {\n    pub(crate) fn new(highlights: Vec<Highlight>) -> Self {\n        Self(highlights)\n    }\n\n    pub(crate) fn contains(&self, line_number: u16) -> bool {\n        for higlight in &self.0 {\n            match higlight {\n                Highlight::All => return true,\n                Highlight::Single(number) if number == &line_number => return true,\n                Highlight::Range(range) if range.contains(&line_number) => return true,\n                _ => continue,\n            };\n        }\n        false\n    }\n}\n\n/// A highlighted set of lines\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) enum Highlight {\n    All,\n    Single(u16),\n    Range(Range<u16>),\n}\n\n#[derive(Debug, Deserialize)]\npub(crate) struct ExternalFile {\n    pub(crate) path: PathBuf,\n    pub(crate) language: SnippetLanguage,\n    pub(crate) start_line: Option<usize>,\n    pub(crate) end_line: Option<usize>,\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use Highlight::*;\n    use rstest::rstest;\n\n    fn parse_language(input: &str) -> SnippetLanguage {\n        let (language, _) = SnippetParser::parse_block_info(input).expect(\"parse failed\");\n        language\n    }\n\n    fn try_parse_attributes(input: &str) -> Result<SnippetAttributes, SnippetBlockParseError> {\n        let (_, attributes) = SnippetParser::parse_block_info(input)?;\n        Ok(attributes)\n    }\n\n    fn parse_attributes(input: &str) -> SnippetAttributes {\n        try_parse_attributes(input).expect(\"parse failed\")\n    }\n\n    #[test]\n    fn code_with_line_numbers() {\n        let total_lines = 11;\n        let input_lines = \"hi\\n\".repeat(total_lines);\n        let code = Snippet {\n            contents: input_lines,\n            language: SnippetLanguage::Unknown(\"\".to_string()),\n            attributes: SnippetAttributes { line_numbers: true, ..Default::default() },\n        };\n        let lines = SnippetSplitter::new(&Default::default(), None).split(&code);\n        assert_eq!(lines.len(), total_lines);\n\n        let mut lines = lines.into_iter().enumerate();\n        // 0..=9\n        for (index, line) in lines.by_ref().take(9) {\n            let line_number = index + 1;\n            assert_eq!(&line.prefix, &format!(\" {line_number} \"));\n        }\n        // 10..\n        for (index, line) in lines {\n            let line_number = index + 1;\n            assert_eq!(&line.prefix, &format!(\"{line_number} \"));\n        }\n    }\n\n    #[test]\n    fn unknown_language() {\n        assert_eq!(parse_language(\"potato\"), SnippetLanguage::Unknown(\"potato\".to_string()));\n    }\n\n    #[test]\n    fn no_attributes() {\n        assert_eq!(parse_language(\"rust\"), SnippetLanguage::Rust);\n    }\n\n    #[test]\n    fn one_attribute() {\n        let attributes = parse_attributes(\"bash +exec\");\n        assert_eq!(attributes.execution, SnippetExecution::Exec(Default::default()));\n        assert!(!attributes.line_numbers);\n    }\n\n    #[test]\n    fn two_attributes() {\n        let attributes = parse_attributes(\"bash +exec +line_numbers\");\n        assert_eq!(attributes.execution, SnippetExecution::Exec(Default::default()));\n        assert!(attributes.line_numbers);\n    }\n\n    #[test]\n    fn acquire_terminal() {\n        let attributes = parse_attributes(\"bash +acquire_terminal +exec\");\n        assert_eq!(\n            attributes.execution,\n            SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::AcquireTerminal, ..Default::default() })\n        );\n        assert!(!attributes.line_numbers);\n    }\n\n    #[test]\n    fn image() {\n        let attributes = parse_attributes(\"bash +image +exec\");\n        assert_eq!(\n            attributes.execution,\n            SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::Image, ..Default::default() })\n        );\n        assert!(!attributes.line_numbers);\n    }\n\n    #[test]\n    fn invalid_attributes() {\n        SnippetParser::parse_block_info(\"bash +potato\").unwrap_err();\n        SnippetParser::parse_block_info(\"bash potato\").unwrap_err();\n    }\n\n    #[rstest]\n    #[case::no_end(\"{\")]\n    #[case::number_no_end(\"{42\")]\n    #[case::comma_nothing(\"{42,\")]\n    #[case::brace_comma(\"{,}\")]\n    #[case::range_no_end(\"{42-\")]\n    #[case::range_end(\"{42-}\")]\n    #[case::too_many_ranges(\"{42-3-5}\")]\n    #[case::range_comma(\"{42-,\")]\n    #[case::too_large(\"{65536}\")]\n    #[case::too_large_end(\"{1-65536}\")]\n    fn invalid_line_highlights(#[case] input: &str) {\n        let input = format!(\"bash {input}\");\n        SnippetParser::parse_block_info(&input).expect_err(\"parsed successfully\");\n    }\n\n    #[test]\n    fn highlight_none() {\n        let attributes = parse_attributes(\"bash {}\");\n        assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]);\n    }\n\n    #[test]\n    fn highlight_specific_lines() {\n        let attributes = parse_attributes(\"bash {   1, 2  , 3   }\");\n        assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]);\n    }\n\n    #[test]\n    fn highlight_line_range() {\n        let attributes = parse_attributes(\"bash {   1, 2-4,6 ,  all , 10 - 12  }\");\n        assert_eq!(\n            attributes.highlight_groups,\n            &[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])]\n        );\n    }\n\n    #[test]\n    fn multiple_groups() {\n        let attributes = parse_attributes(\"bash {1-3,5  |6-9}\");\n        assert_eq!(attributes.highlight_groups.len(), 2);\n        assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)]));\n        assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)]));\n    }\n\n    #[test]\n    fn parse_width() {\n        let attributes = parse_attributes(\"mermaid +width:50% +render\");\n        assert_eq!(attributes.execution, SnippetExecution::Render);\n        assert_eq!(attributes.width, Some(Percent(50)));\n    }\n\n    #[test]\n    fn invalid_width() {\n        try_parse_attributes(\"mermaid +width:50%% +render\").expect_err(\"parse succeeded\");\n        try_parse_attributes(\"mermaid +width: +render\").expect_err(\"parse succeeded\");\n        try_parse_attributes(\"mermaid +width:50%\").expect_err(\"parse succeeded\");\n    }\n\n    #[test]\n    fn code_visible_lines() {\n        let contents = r##\"# fn main() {\nprintln!(\"Hello world\");\n# // The prefix is # .\n# }\n\"##\n        .to_string();\n\n        let expected = vec![\"println!(\\\"Hello world\\\");\"];\n        let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };\n        assert_eq!(expected, code.visible_lines(Some(\"# \")).collect::<Vec<_>>());\n    }\n\n    #[test]\n    fn code_executable_contents() {\n        let contents = r##\"# fn main() {\nprintln!(\"Hello world\");\n# // The prefix is # .\n# }\n\"##\n        .to_string();\n\n        let expected = r##\"fn main() {\nprintln!(\"Hello world\");\n// The prefix is # .\n}\n\"##\n        .to_string();\n\n        let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };\n        assert_eq!(expected, code.executable_contents(Some(\"# \")));\n    }\n\n    #[test]\n    fn tabs_in_snippet() {\n        let snippet = Snippet { contents: \"\\thi\".into(), language: SnippetLanguage::C, attributes: Default::default() };\n        let lines = SnippetSplitter::new(&Default::default(), None).split(&snippet);\n        assert_eq!(lines[0].code, \"    hi\\n\");\n    }\n\n    #[rstest]\n    #[case::exec(\"bash +exec:foo\", SnippetExecutorSpec::Alternative(\"foo\".to_string()))]\n    #[case::exec_and_more(\"bash +exec:foo +line_numbers\", SnippetExecutorSpec::Alternative(\"foo\".to_string()))]\n    #[case::exec_replace(\"bash +exec_replace:foo\", SnippetExecutorSpec::Alternative(\"foo\".to_string()))]\n    #[case::exec_replace_and_more(\"bash +exec_replace:foo +line_numbers\", SnippetExecutorSpec::Alternative(\"foo\".into()))]\n    fn alternative_executor(#[case] input: &str, #[case] spec: SnippetExecutorSpec) {\n        let attributes = parse_attributes(input);\n        let SnippetExecution::Exec(args) = attributes.execution else { panic!(\"not an exec snippet\") };\n        assert_eq!(args.spec, spec);\n    }\n\n    #[test]\n    fn acquire_terminal_alternative() {\n        let attributes = parse_attributes(\"bash +acquire_terminal:foo\");\n        assert_eq!(\n            attributes.execution,\n            SnippetExecution::Exec(SnippetExecArgs {\n                spec: SnippetExecutorSpec::Alternative(\"foo\".into()),\n                repr: SnippetRepr::AcquireTerminal,\n                ..Default::default()\n            })\n        );\n    }\n\n    #[rstest]\n    #[case::success(\"expect:success\", ExpectedSnippetExecutionResult::Success)]\n    #[case::failure(\"expect:failure\", ExpectedSnippetExecutionResult::Failure)]\n    #[case::fail(\"expect:fail\", ExpectedSnippetExecutionResult::Failure)]\n    fn parse_expect(#[case] input: &str, #[case] expected: ExpectedSnippetExecutionResult) {\n        let attributes = parse_attributes(&format!(\"bash +{input}\"));\n        assert_eq!(attributes.expected_execution_result, expected);\n    }\n}\n"
  },
  {
    "path": "src/commands/keyboard.rs",
    "content": "use super::listener::{Command, CommandDiscriminants};\nuse crate::config::KeyBindingsConfig;\nuse crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read};\nuse std::{fmt, io, iter, mem, str::FromStr, time::Duration};\n\n/// A keyboard command listener.\npub struct KeyboardListener {\n    bindings: CommandKeyBindings,\n    events: Vec<KeyEvent>,\n}\n\nimpl KeyboardListener {\n    pub fn new(bindings: CommandKeyBindings) -> Self {\n        Self { bindings, events: Vec::new() }\n    }\n\n    /// Polls for the next input command coming from the keyboard.\n    pub(crate) fn poll_next_command(&mut self, timeout: Duration) -> io::Result<Option<Command>> {\n        if poll(timeout)? { self.next_command() } else { Ok(None) }\n    }\n\n    /// Blocks waiting for the next command.\n    pub(crate) fn next_command(&mut self) -> io::Result<Option<Command>> {\n        let mut events = mem::take(&mut self.events);\n        let (command, events) = match read()? {\n            // Ignore release events\n            Event::Key(event) if event.kind == KeyEventKind::Release => (None, events),\n            Event::Key(event) => {\n                events.push(event);\n                self.match_events(events)\n            }\n            Event::Resize(..) => (Some(Command::Redraw), events),\n            _ => (None, vec![]),\n        };\n        self.events = events;\n        Ok(command)\n    }\n\n    fn match_events(&self, events: Vec<KeyEvent>) -> (Option<Command>, Vec<KeyEvent>) {\n        match self.bindings.apply(&events) {\n            InputAction::Emit(command) => (Some(command), Vec::new()),\n            InputAction::Buffer => (None, events),\n            InputAction::Reset => (None, Vec::new()),\n        }\n    }\n}\n\nenum InputAction {\n    Buffer,\n    Reset,\n    Emit(Command),\n}\n\npub struct CommandKeyBindings {\n    bindings: Vec<(KeyBinding, CommandDiscriminants)>,\n}\n\nimpl CommandKeyBindings {\n    fn apply(&self, events: &[KeyEvent]) -> InputAction {\n        let mut any_partials = false;\n        for (binding, identifier) in &self.bindings {\n            match binding.match_events(events) {\n                BindingMatch::Full(context) => return Self::instantiate(identifier, context),\n                BindingMatch::Partial => any_partials = true,\n                BindingMatch::None => (),\n            }\n        }\n        if any_partials { InputAction::Buffer } else { InputAction::Reset }\n    }\n\n    fn instantiate(discriminant: &CommandDiscriminants, context: MatchContext) -> InputAction {\n        use CommandDiscriminants::*;\n        let command = match discriminant {\n            Redraw => Command::Redraw,\n            Next => Command::Next,\n            NextFast => Command::NextFast,\n            Previous => Command::Previous,\n            PreviousFast => Command::PreviousFast,\n            FirstSlide => Command::FirstSlide,\n            LastSlide => Command::LastSlide,\n            GoToSlide => {\n                match context {\n                    // this means the command is malformed and this should have been caught earlier\n                    // on.\n                    MatchContext::None => return InputAction::Reset,\n                    MatchContext::Number(number) => Command::GoToSlide(number),\n                }\n            }\n            RenderAsyncOperations => Command::RenderAsyncOperations,\n            Exit => Command::Exit,\n            Suspend => Command::Suspend,\n            Reload => Command::Reload,\n            HardReload => Command::HardReload,\n            ToggleSlideIndex => Command::ToggleSlideIndex,\n            ToggleKeyBindingsConfig => Command::ToggleKeyBindingsConfig,\n            ToggleLayoutGrid => Command::ToggleLayoutGrid,\n            CloseModal => Command::CloseModal,\n            SkipPauses => Command::SkipPauses,\n            GoToSlideChunk => panic!(\"go to slide chunk is not configurable\"),\n        };\n        InputAction::Emit(command)\n    }\n\n    fn validate_conflicts<'a>(\n        bindings: impl Iterator<Item = &'a KeyBinding>,\n    ) -> Result<(), KeyBindingsValidationError> {\n        let mut bindings: Vec<_> = bindings.map(|binding| &binding.0).collect();\n        bindings.sort_by(|a, b| a.partial_cmp(b).unwrap());\n        for window in bindings.windows(2) {\n            if window[0].iter().eq(window[1].iter().take(window[0].len())) {\n                return Err(KeyBindingsValidationError::Conflict(\n                    KeyBinding(window[0].clone()),\n                    KeyBinding(window[1].clone()),\n                ));\n            }\n        }\n        Ok(())\n    }\n}\n\nimpl TryFrom<KeyBindingsConfig> for CommandKeyBindings {\n    type Error = KeyBindingsValidationError;\n\n    fn try_from(config: KeyBindingsConfig) -> Result<Self, Self::Error> {\n        let zip = |discriminant, bindings: Vec<KeyBinding>| bindings.into_iter().zip(iter::repeat(discriminant));\n        if !config.go_to_slide.iter().all(|k| k.expects_number()) {\n            return Err(KeyBindingsValidationError::Invalid(\"go_to_slide\", \"<number> matcher required\"));\n        }\n        let KeyBindingsConfig {\n            next,\n            next_fast,\n            previous,\n            previous_fast,\n            first_slide,\n            last_slide,\n            go_to_slide,\n            execute_code,\n            reload,\n            toggle_slide_index,\n            toggle_bindings,\n            toggle_layout_grid,\n            close_modal,\n            exit,\n            suspend,\n            skip_pauses,\n        } = config;\n        let bindings: Vec<_> = iter::empty()\n            .chain(zip(CommandDiscriminants::Next, next))\n            .chain(zip(CommandDiscriminants::NextFast, next_fast))\n            .chain(zip(CommandDiscriminants::Previous, previous))\n            .chain(zip(CommandDiscriminants::PreviousFast, previous_fast))\n            .chain(zip(CommandDiscriminants::FirstSlide, first_slide))\n            .chain(zip(CommandDiscriminants::LastSlide, last_slide))\n            .chain(zip(CommandDiscriminants::GoToSlide, go_to_slide))\n            .chain(zip(CommandDiscriminants::Exit, exit))\n            .chain(zip(CommandDiscriminants::Suspend, suspend))\n            .chain(zip(CommandDiscriminants::HardReload, reload))\n            .chain(zip(CommandDiscriminants::ToggleSlideIndex, toggle_slide_index))\n            .chain(zip(CommandDiscriminants::ToggleKeyBindingsConfig, toggle_bindings))\n            .chain(zip(CommandDiscriminants::ToggleLayoutGrid, toggle_layout_grid))\n            .chain(zip(CommandDiscriminants::RenderAsyncOperations, execute_code))\n            .chain(zip(CommandDiscriminants::CloseModal, close_modal))\n            .chain(zip(CommandDiscriminants::SkipPauses, skip_pauses))\n            .collect();\n        Self::validate_conflicts(bindings.iter().map(|binding| &binding.0))?;\n        Ok(Self { bindings })\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum KeyBindingsValidationError {\n    #[error(\"invalid binding for {0}: {1}\")]\n    Invalid(&'static str, &'static str),\n\n    #[error(\"conflicting keybindings: {0} and {1}\")]\n    Conflict(KeyBinding, KeyBinding),\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\nenum BindingMatch {\n    Full(MatchContext),\n    Partial,\n    None,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\npub struct KeyBinding(#[cfg_attr(feature = \"json-schema\", schemars(with = \"String\"))] Vec<KeyMatcher>);\n\ncrate::utils::impl_deserialize_from_str!(KeyBinding);\n\nimpl KeyBinding {\n    fn match_events(&self, mut events: &[KeyEvent]) -> BindingMatch {\n        let mut output_context = MatchContext::None;\n        for (index, matcher) in self.0.iter().enumerate() {\n            let Some((context, rest)) = matcher.try_match_events(events) else {\n                return BindingMatch::None;\n            };\n            if !matches!(context, MatchContext::None) {\n                output_context = context;\n            }\n            events = rest;\n\n            // We ran all matchers but we have no events left; this is a partial match.\n            if index != self.0.len() - 1 && events.is_empty() {\n                return BindingMatch::Partial;\n            }\n        }\n        // If there's more events than we need, this is an issue on the caller side.\n        BindingMatch::Full(output_context)\n    }\n\n    fn expects_number(&self) -> bool {\n        self.0.iter().any(|m| matches!(m, KeyMatcher::Number))\n    }\n}\n\nimpl FromStr for KeyBinding {\n    type Err = KeyBindingParseError;\n\n    fn from_str(mut input: &str) -> Result<Self, Self::Err> {\n        let mut matchers = Vec::new();\n        let mut has_numbers = false;\n        while !input.is_empty() {\n            let (matcher, rest) = KeyMatcher::parse(input)?;\n            let is_number = matches!(matcher, KeyMatcher::Number);\n            // We don't want more than one <number> matcher\n            if has_numbers && is_number {\n                return Err(KeyBindingParseError::TooManyNumbers);\n            }\n            has_numbers = has_numbers || is_number;\n            matchers.push(matcher);\n            input = rest;\n        }\n        Ok(Self(matchers))\n    }\n}\n\nimpl fmt::Display for KeyBinding {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        for matcher in &self.0 {\n            write!(f, \"{matcher}\")?;\n        }\n        Ok(())\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum KeyBindingParseError {\n    #[error(\"no input\")]\n    NoInput,\n\n    #[error(\"not a valid key: {0}\")]\n    InvalidKey(char),\n\n    #[error(\"too many number placeholders\")]\n    TooManyNumbers,\n\n    #[error(\"invalid control sequence\")]\n    InvalidControlSequence,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, PartialOrd)]\nenum KeyMatcher {\n    Key(KeyCombination),\n    Number,\n}\n\nimpl KeyMatcher {\n    fn try_match_events<'a>(&self, events: &'a [KeyEvent]) -> Option<(MatchContext, &'a [KeyEvent])> {\n        match self {\n            Self::Key(combo) => Self::try_match_key(combo, events),\n            Self::Number => Self::try_match_number(events),\n        }\n    }\n\n    fn try_match_key<'a>(combo: &KeyCombination, events: &'a [KeyEvent]) -> Option<(MatchContext, &'a [KeyEvent])> {\n        let event = events.first()?;\n        let is_control = event.modifiers == KeyModifiers::CONTROL;\n        if combo.key == event.code && combo.control == is_control {\n            let rest = &events[1..];\n            Some((MatchContext::None, rest))\n        } else {\n            None\n        }\n    }\n\n    fn try_match_number(mut events: &[KeyEvent]) -> Option<(MatchContext, &[KeyEvent])> {\n        let mut number = None;\n        while let Some((head, rest)) = events.split_first() {\n            let digit = match head.code {\n                KeyCode::Char(c) if c.is_ascii_digit() => c.to_digit(10).expect(\"not a digit\"),\n                _ => break,\n            };\n\n            let next = number.unwrap_or(0u32).checked_mul(10).and_then(|number| number.checked_add(digit));\n            match next {\n                Some(n) => {\n                    number = Some(n);\n                    events = rest;\n                }\n                // if we overflow we're done\n                None => return None,\n            }\n        }\n        number.map(|number| (MatchContext::Number(number), events))\n    }\n\n    fn parse(input: &str) -> Result<(Self, &str), KeyBindingParseError> {\n        if let Some(input) = input.strip_prefix(\"<number>\") {\n            Ok((Self::Number, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<c-\", \"<C-\"]) {\n            let (key, input) = Self::parse_key_code(input)?;\n            let Some(input) = input.strip_prefix('>') else {\n                return Err(KeyBindingParseError::InvalidControlSequence);\n            };\n            let matcher = Self::Key(KeyCombination { key, control: true });\n            Ok((matcher, input))\n        } else {\n            let (key, input) = Self::parse_key_code(input)?;\n            let matcher = Self::Key(KeyCombination { key, control: false });\n            Ok((matcher, input))\n        }\n    }\n\n    fn parse_key_code(input: &str) -> Result<(KeyCode, &str), KeyBindingParseError> {\n        if let Some(input) = Self::try_match_input(input, &[\"<PageUp>\", \"<page_up>\"]) {\n            Ok((KeyCode::PageUp, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<PageDown>\", \"<page_down>\"]) {\n            Ok((KeyCode::PageDown, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<cr>\", \"<CR>\", \"<Enter>\", \"<enter>\"]) {\n            Ok((KeyCode::Enter, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Home>\", \"<home>\"]) {\n            Ok((KeyCode::Home, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<End>\", \"<end>\"]) {\n            Ok((KeyCode::End, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Left>\", \"<left>\"]) {\n            Ok((KeyCode::Left, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Right>\", \"<right>\"]) {\n            Ok((KeyCode::Right, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Up>\", \"<up>\"]) {\n            Ok((KeyCode::Up, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Down>\", \"<down>\"]) {\n            Ok((KeyCode::Down, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Esc>\", \"<esc>\"]) {\n            Ok((KeyCode::Esc, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Tab>\", \"<tab>\"]) {\n            Ok((KeyCode::Tab, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<Backspace>\", \"<backspace>\"]) {\n            Ok((KeyCode::Backspace, input))\n        } else if let Some(input) = Self::try_match_input(input, &[\"<F\", \"<f\"]) {\n            let (number, rest) = input.split_once('>').ok_or(KeyBindingParseError::InvalidControlSequence)?;\n            let number: u8 = number.parse().map_err(|_| KeyBindingParseError::InvalidControlSequence)?;\n            if number > 12 { Err(KeyBindingParseError::InvalidControlSequence) } else { Ok((KeyCode::F(number), rest)) }\n        } else {\n            let next = input.chars().next().ok_or(KeyBindingParseError::NoInput)?;\n            // don't allow these as they create ambiguity\n            if next == '<' || next == '>' {\n                Err(KeyBindingParseError::InvalidKey(next))\n            } else if next.is_alphanumeric() || next.is_ascii_punctuation() || next == ' ' {\n                let key = KeyCode::Char(next);\n                Ok((key, &input[next.len_utf8()..]))\n            } else {\n                Err(KeyBindingParseError::InvalidKey(next))\n            }\n        }\n    }\n\n    fn try_match_input<'a>(input: &'a str, aliases: &[&str]) -> Option<&'a str> {\n        for alias in aliases {\n            if let Some(input) = input.strip_prefix(alias) {\n                return Some(input);\n            }\n        }\n        None\n    }\n}\n\nimpl fmt::Display for KeyMatcher {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::Number => write!(f, \"<number>\"),\n            Self::Key(combo) => {\n                if combo.control {\n                    write!(f, \"<c-\")?;\n                }\n                match combo.key {\n                    KeyCode::Char(' ') => write!(f, \"' '\")?,\n                    KeyCode::Char(c) => write!(f, \"{}\", c)?,\n                    other => write!(f, \"<{other:?}>\")?,\n                };\n                if combo.control {\n                    write!(f, \">\")?;\n                }\n                Ok(())\n            }\n        }\n    }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\nenum MatchContext {\n    Number(u32),\n    None,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, PartialOrd)]\nstruct KeyCombination {\n    key: KeyCode,\n    control: bool,\n}\n\nimpl KeyCombination {\n    #[cfg(test)]\n    fn char(c: char) -> Self {\n        Self { key: KeyCode::Char(c), control: false }\n    }\n\n    #[cfg(test)]\n    fn control_char(c: char) -> Self {\n        Self { key: KeyCode::Char(c), control: true }\n    }\n}\n\nimpl From<KeyCode> for KeyCombination {\n    fn from(key: KeyCode) -> Self {\n        Self { key, control: false }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crossterm::event::KeyEventState;\n    use rstest::rstest;\n\n    trait KeyEventSource {\n        fn into_event(self) -> KeyEvent;\n    }\n\n    impl KeyEventSource for KeyCode {\n        fn into_event(self) -> KeyEvent {\n            KeyEvent {\n                code: self,\n                modifiers: KeyModifiers::empty(),\n                kind: KeyEventKind::Press,\n                state: KeyEventState::NONE,\n            }\n        }\n    }\n\n    impl KeyEventSource for char {\n        fn into_event(self) -> KeyEvent {\n            KeyCode::Char(self).into_event()\n        }\n    }\n\n    trait KeyEventExt {\n        fn with_control(self) -> Self;\n    }\n\n    impl KeyEventExt for KeyEvent {\n        fn with_control(mut self) -> Self {\n            self.modifiers = KeyModifiers::CONTROL;\n            self\n        }\n    }\n\n    #[rstest]\n    #[case::number(\"<number>\", vec![KeyMatcher::Number])]\n    #[case::char(\"w\", vec![KeyMatcher::Key(KeyCombination::char('w'))])]\n    #[case::ctrl_char1(\"<c-w>\", vec![KeyMatcher::Key(KeyCombination::control_char('w'))])]\n    #[case::ctrl_char2(\"<C-w>\", vec![KeyMatcher::Key(KeyCombination::control_char('w'))])]\n    #[case::dot(\".\", vec![KeyMatcher::Key(KeyCombination::char('.'))])]\n    #[case::dot(\" \", vec![KeyMatcher::Key(KeyCombination::char(' '))])]\n    #[case::multi(\"hi\", vec![KeyMatcher::Key(KeyCombination::char('h')), KeyMatcher::Key(KeyCombination::char('i'))])]\n    #[case::page_up1(\"<page_up>\", vec![KeyMatcher::Key(KeyCode::PageUp.into())])]\n    #[case::page_up2(\"<PageUp>\", vec![KeyMatcher::Key(KeyCode::PageUp.into())])]\n    #[case::page_down1(\"<page_down>\", vec![KeyMatcher::Key(KeyCode::PageDown.into())])]\n    #[case::page_down2(\"<PageDown>\", vec![KeyMatcher::Key(KeyCode::PageDown.into())])]\n    #[case::enter1(\"<CR>\", vec![KeyMatcher::Key(KeyCode::Enter.into())])]\n    #[case::enter2(\"<cr>\", vec![KeyMatcher::Key(KeyCode::Enter.into())])]\n    #[case::enter3(\"<enter>\", vec![KeyMatcher::Key(KeyCode::Enter.into())])]\n    #[case::home1(\"<home>\", vec![KeyMatcher::Key(KeyCode::Home.into())])]\n    #[case::home2(\"<Home>\", vec![KeyMatcher::Key(KeyCode::Home.into())])]\n    #[case::end1(\"<End>\", vec![KeyMatcher::Key(KeyCode::End.into())])]\n    #[case::end2(\"<end>\", vec![KeyMatcher::Key(KeyCode::End.into())])]\n    #[case::left1(\"<Left>\", vec![KeyMatcher::Key(KeyCode::Left.into())])]\n    #[case::left2(\"<left>\", vec![KeyMatcher::Key(KeyCode::Left.into())])]\n    #[case::right1(\"<Right>\", vec![KeyMatcher::Key(KeyCode::Right.into())])]\n    #[case::right2(\"<right>\", vec![KeyMatcher::Key(KeyCode::Right.into())])]\n    #[case::up1(\"<Up>\", vec![KeyMatcher::Key(KeyCode::Up.into())])]\n    #[case::up2(\"<up>\", vec![KeyMatcher::Key(KeyCode::Up.into())])]\n    #[case::down1(\"<Down>\", vec![KeyMatcher::Key(KeyCode::Down.into())])]\n    #[case::down2(\"<down>\", vec![KeyMatcher::Key(KeyCode::Down.into())])]\n    #[case::esc1(\"<Esc>\", vec![KeyMatcher::Key(KeyCode::Esc.into())])]\n    #[case::esc2(\"<esc>\", vec![KeyMatcher::Key(KeyCode::Esc.into())])]\n    #[case::f1(\"<f1>\", vec![KeyMatcher::Key(KeyCode::F(1).into())])]\n    #[case::f12(\"<f12>\", vec![KeyMatcher::Key(KeyCode::F(12).into())])]\n    #[case::backspace1(\"<Backspace>\", vec![KeyMatcher::Key(KeyCode::Backspace.into())])]\n    #[case::backspace2(\"<backspace>\", vec![KeyMatcher::Key(KeyCode::Backspace.into())])]\n    #[case::tab1(\"<Tab>\", vec![KeyMatcher::Key(KeyCode::Tab.into())])]\n    #[case::tab2(\"<tab>\", vec![KeyMatcher::Key(KeyCode::Tab.into())])]\n    fn parse_key_binding(#[case] pattern: &str, #[case] matchers: Vec<KeyMatcher>) {\n        let binding = KeyBinding::from_str(pattern).expect(\"failed to parse\");\n        let expected = KeyBinding(matchers);\n        assert_eq!(binding, expected);\n    }\n\n    #[rstest]\n    #[case::invalid_tag(\"<hi>\")]\n    #[case::invalid_char(\"🚀\")]\n    #[case::too_many_numbers(\"<number><number>\")]\n    #[case::control_sequence(\"<C-w\")]\n    #[case::f10(\"<f13>\")]\n    #[case::unfinished_f(\"<f1\")]\n    fn invalid_key_bindings(#[case] input: &str) {\n        let result = KeyBinding::from_str(input);\n        assert!(result.is_err(), \"not an error\");\n    }\n\n    #[rstest]\n    #[case::single(\"g\", &['g'.into_event()])]\n    #[case::single_uppercase(\"G\", &['G'.into_event()])]\n    #[case::multi(\"gg\", &['g'.into_event(), 'g'.into_event()])]\n    #[case::multi_space(\" g\", &[' '.into_event(), 'g'.into_event()])]\n    #[case::control(\"<c-w>\", &['w'.into_event().with_control()])]\n    #[case::page_up(\"<PageUp>\", &[KeyCode::PageUp.into_event()])]\n    #[case::page_down(\"<PageDown>\", &[KeyCode::PageDown.into_event()])]\n    #[case::enter(\"<Enter>\", &[KeyCode::Enter.into_event()])]\n    #[case::home(\"<Home>\", &[KeyCode::Home.into_event()])]\n    #[case::end(\"<End>\", &[KeyCode::End.into_event()])]\n    fn matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) {\n        let binding = KeyBinding::from_str(pattern).expect(\"failed to parse\");\n        let result = binding.match_events(events);\n        assert!(matches!(result, BindingMatch::Full(_)), \"not full match: {result:?}\");\n    }\n\n    #[rstest]\n    #[case::fewer(\"gg\", &['g'.into_event()])]\n    #[case::number_something1(\"<number>G\", &['4'.into_event()])]\n    #[case::number_something2(\"<number>G\", &['4'.into_event(), '2'.into_event()])]\n    #[case::number_something3(\":<number><CR>\", &[':'.into_event(), '4'.into_event()])]\n    fn partial_matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) {\n        let binding = KeyBinding::from_str(pattern).expect(\"failed to parse\");\n        let result = binding.match_events(events);\n        assert!(matches!(result, BindingMatch::Partial), \"not partial match: {result:?}\");\n    }\n\n    #[rstest]\n    #[case::number_something(\"<number>G\", &['4'.into_event(), 'K'.into_event()])]\n    fn no_matching(#[case] pattern: &str, #[case] events: &[KeyEvent]) {\n        let binding = KeyBinding::from_str(pattern).expect(\"failed to parse\");\n        let result = binding.match_events(events);\n        assert!(matches!(result, BindingMatch::None), \"some match: {result:?}\");\n    }\n\n    #[rstest]\n    #[case::number_something(\"<number>G\", &['4'.into_event(), '2'.into_event(), 'G'.into_event()])]\n    #[case::number_something(\n        \":<number><cr>\",\n        &[':'.into_event(), '4'.into_event(), '2'.into_event(), KeyCode::Enter.into_event()]\n    )]\n    fn match_number(#[case] pattern: &str, #[case] events: &[KeyEvent]) {\n        let binding = KeyBinding::from_str(pattern).expect(\"failed to parse\");\n        let result = binding.match_events(events);\n        let BindingMatch::Full(MatchContext::Number(number)) = result else {\n            panic!(\"unexpected match: {result:?}\");\n        };\n        assert_eq!(number, 42);\n    }\n\n    #[rstest]\n    #[case(&[\"<number>G\", \"other\", \"<number>Go\"])]\n    #[case(&[\"<PageUp><PageDown>\", \"something\", \"<PageUp>\"])]\n    #[case(&[\"<cr><cr>\", \"<cr><cr>\"])]\n    #[case(&[\"<c-w>\", \"<c-w>a\"])]\n    #[case(&[\"<c-w>\", \"<c-w>\"])]\n    #[case(&[\"<number>\", \"<number>\"])]\n    fn conflicts(#[case] patterns: &[&str]) {\n        let bindings: Vec<_> = patterns.iter().map(|p| KeyBinding::from_str(p).unwrap()).collect();\n        let result = CommandKeyBindings::validate_conflicts(bindings.iter());\n        assert!(result.is_err(), \"not an error: {result:?}\");\n    }\n\n    #[rstest]\n    #[case(&[\"<number>Ga\", \"<number>Go\"])]\n    #[case(&[\"<c-a><number>\", \"<c-a>hi\"])]\n    fn no_conflicts(#[case] patterns: &[&str]) {\n        let bindings: Vec<_> = patterns.iter().map(|p| KeyBinding::from_str(p).unwrap()).collect();\n        let result = CommandKeyBindings::validate_conflicts(bindings.iter());\n        assert!(result.is_ok(), \"got error: {result:?}\");\n    }\n\n    #[rstest]\n    #[case(\"<number>G\")]\n    #[case(\"<PageUp>potato\")]\n    #[case(\"<Esc><number><PageUp>\")]\n    fn display(#[case] pattern: &str) {\n        let binding = KeyBinding::from_str(pattern).expect(\"invalid pattern\");\n        let rendered = binding.to_string();\n        assert_eq!(rendered, pattern);\n    }\n}\n"
  },
  {
    "path": "src/commands/listener.rs",
    "content": "use super::{\n    keyboard::{CommandKeyBindings, KeyBindingsValidationError, KeyboardListener},\n    speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventListener},\n};\nuse crate::{config::KeyBindingsConfig, presenter::PresentationError};\nuse serde::Deserialize;\nuse std::time::Duration;\nuse strum::EnumDiscriminants;\n\n/// A command listener that allows polling all command sources in a single place.\npub struct CommandListener {\n    keyboard: KeyboardListener,\n    speaker_notes_event_listener: Option<SpeakerNotesEventListener>,\n}\n\nimpl CommandListener {\n    /// Create a new command source over the given presentation path.\n    pub fn new(\n        config: KeyBindingsConfig,\n        speaker_notes_event_listener: Option<SpeakerNotesEventListener>,\n    ) -> Result<Self, KeyBindingsValidationError> {\n        let bindings = CommandKeyBindings::try_from(config)?;\n        Ok(Self { keyboard: KeyboardListener::new(bindings), speaker_notes_event_listener })\n    }\n\n    /// Try to get the next command.\n    ///\n    /// This attempts to get a command and returns `Ok(None)` on timeout.\n    pub(crate) fn try_next_command(&mut self) -> Result<Option<Command>, PresentationError> {\n        if let Some(receiver) = &self.speaker_notes_event_listener {\n            if let Some(msg) = receiver.try_recv()? {\n                let command = match msg {\n                    SpeakerNotesEvent::GoTo { slide, chunk } => Command::GoToSlideChunk { slide, chunk },\n                    SpeakerNotesEvent::Exit => Command::Exit,\n                };\n                return Ok(Some(command));\n            }\n        }\n        match self.keyboard.poll_next_command(Duration::from_millis(20))? {\n            Some(command) => Ok(Some(command)),\n            None => Ok(None),\n        }\n    }\n}\n\n/// A command.\n#[derive(Clone, Debug, PartialEq, Eq, EnumDiscriminants)]\n#[strum_discriminants(derive(Deserialize))]\npub(crate) enum Command {\n    /// Redraw the presentation.\n    ///\n    /// This can happen on terminal resize.\n    Redraw,\n\n    /// Move forward in the presentation.\n    Next,\n\n    /// Move to the next slide fast.\n    NextFast,\n\n    /// Move backwards in the presentation.\n    Previous,\n\n    /// Move to the previous slide fast.\n    PreviousFast,\n\n    /// Go to the first slide.\n    FirstSlide,\n\n    /// Go to the last slide.\n    LastSlide,\n\n    /// Go to one particular slide.\n    GoToSlide(u32),\n\n    /// Go to one particular slide + chunk.\n    GoToSlideChunk { slide: u32, chunk: u32 },\n\n    /// Render any async render operations in the current slide.\n    RenderAsyncOperations,\n\n    /// Exit the presentation.\n    Exit,\n\n    /// Suspend the presentation.\n    Suspend,\n\n    /// The presentation has changed and needs to be reloaded.\n    Reload,\n\n    /// Hard reload the presentation.\n    ///\n    /// Like [Command::Reload] but also reloads any external resources like images and themes.\n    HardReload,\n\n    /// Toggle the slide index view.\n    ToggleSlideIndex,\n\n    /// Toggle the key bindings config view.\n    ToggleKeyBindingsConfig,\n\n    /// Toggle layout grid.\n    ToggleLayoutGrid,\n\n    /// Hide the currently open modal, if any.\n    CloseModal,\n\n    /// Skip pauses in the current slide.\n    SkipPauses,\n}\n"
  },
  {
    "path": "src/commands/mod.rs",
    "content": "pub(crate) mod keyboard;\npub(crate) mod listener;\npub(crate) mod speaker_notes;\n"
  },
  {
    "path": "src/commands/speaker_notes.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse socket2::{Domain, Protocol, Socket, Type};\nuse std::{\n    io,\n    net::{SocketAddr, UdpSocket},\n    path::PathBuf,\n};\n\npub struct SpeakerNotesEventPublisher {\n    socket: UdpSocket,\n    presentation_path: PathBuf,\n}\n\nimpl SpeakerNotesEventPublisher {\n    pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {\n        let socket = UdpSocket::bind(\"127.0.0.1:0\")?;\n        socket.set_broadcast(true)?;\n        socket.connect(address)?;\n        Ok(Self { socket, presentation_path })\n    }\n\n    pub(crate) fn send(&self, event: SpeakerNotesEvent) -> io::Result<()> {\n        // Wrap this event in an envelope that contains the presentation path so listeners can\n        // ignore unrelated events.\n        let envelope = SpeakerNotesEventEnvelope { event, presentation_path: self.presentation_path.clone() };\n        let data = serde_json::to_string(&envelope).expect(\"serialization failed\");\n        match self.socket.send(data.as_bytes()) {\n            Ok(_) => Ok(()),\n            Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => Ok(()),\n            Err(e) => Err(e),\n        }\n    }\n}\n\npub struct SpeakerNotesEventListener {\n    socket: UdpSocket,\n    presentation_path: PathBuf,\n}\n\nimpl SpeakerNotesEventListener {\n    pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {\n        let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;\n        // Use SO_REUSEADDR so we can have multiple listeners on the same port.\n        #[cfg(not(target_os = \"macos\"))]\n        s.set_reuse_address(true)?;\n        // Don't block so we can listen to the keyboard and this socket at the same time.\n        s.set_nonblocking(true)?;\n        s.bind(&address.into())?;\n        Ok(Self { socket: s.into(), presentation_path })\n    }\n\n    pub(crate) fn try_recv(&self) -> io::Result<Option<SpeakerNotesEvent>> {\n        let mut buffer = [0; 1024];\n        let bytes_read = match self.socket.recv(&mut buffer) {\n            Ok(bytes_read) => bytes_read,\n            Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(None),\n            Err(e) => return Err(e),\n        };\n        // Ignore garbage. Odds are this is someone else sending garbage rather than presenterm\n        // itself.\n        let Ok(envelope) = serde_json::from_slice::<SpeakerNotesEventEnvelope>(&buffer[0..bytes_read]) else {\n            return Ok(None);\n        };\n        if envelope.presentation_path == self.presentation_path { Ok(Some(envelope.event)) } else { Ok(None) }\n    }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n#[serde(tag = \"command\")]\npub(crate) enum SpeakerNotesEvent {\n    GoTo { slide: u32, chunk: u32 },\n    Exit,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\nstruct SpeakerNotesEventEnvelope {\n    presentation_path: PathBuf,\n    event: SpeakerNotesEvent,\n}\n\n#[cfg(not(target_os = \"macos\"))]\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::config::{default_speaker_notes_listen_address, default_speaker_notes_publish_address};\n    use std::{thread::sleep, time::Duration};\n\n    fn make_listener(path: PathBuf) -> SpeakerNotesEventListener {\n        SpeakerNotesEventListener::new(default_speaker_notes_listen_address(), path).expect(\"building listener\")\n    }\n\n    fn make_publisher(path: PathBuf) -> SpeakerNotesEventPublisher {\n        SpeakerNotesEventPublisher::new(default_speaker_notes_publish_address(), path).expect(\"building publisher\")\n    }\n\n    #[test]\n    fn bind_multiple() {\n        let _l1 = make_listener(\"\".into());\n        let _l2 = make_listener(\"\".into());\n    }\n\n    #[test]\n    fn multicast() {\n        let path = PathBuf::from(\"/tmp/test.md\");\n        let l1 = make_listener(path.clone());\n        let l2 = make_listener(path.clone());\n        let publisher = make_publisher(path);\n        let event = SpeakerNotesEvent::Exit;\n        publisher.send(event.clone()).expect(\"send failed\");\n        sleep(Duration::from_millis(100));\n\n        assert_eq!(l1.try_recv().expect(\"recv first failed\"), Some(event.clone()));\n        assert_eq!(l2.try_recv().expect(\"recv second failed\"), Some(event));\n    }\n}\n"
  },
  {
    "path": "src/config.rs",
    "content": "use crate::{\n    code::snippet::SnippetLanguage,\n    commands::keyboard::KeyBinding,\n    terminal::{GraphicsMode, emulator::TerminalEmulator, image::protocols::kitty::KittyMode},\n};\nuse clap::ValueEnum;\nuse serde::Deserialize;\nuse std::{\n    collections::{BTreeMap, HashMap},\n    fs, io,\n    net::{IpAddr, Ipv4Addr, SocketAddr},\n    num::NonZeroU8,\n    path::{Path, PathBuf},\n};\n\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct Config {\n    /// The default configuration for the presentation.\n    #[serde(default)]\n    pub defaults: DefaultsConfig,\n\n    #[serde(default)]\n    pub typst: TypstConfig,\n\n    #[serde(default)]\n    pub mermaid: MermaidConfig,\n\n    #[serde(default)]\n    pub d2: D2Config,\n\n    #[serde(default)]\n    pub options: OptionsConfig,\n\n    #[serde(default)]\n    pub bindings: KeyBindingsConfig,\n\n    #[serde(default)]\n    pub snippet: SnippetConfig,\n\n    #[serde(default)]\n    pub speaker_notes: SpeakerNotesConfig,\n\n    #[serde(default)]\n    pub export: ExportConfig,\n\n    #[serde(default)]\n    pub transition: Option<SlideTransitionConfig>,\n}\n\nimpl Config {\n    /// Load the config from a path.\n    pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {\n        let contents = match fs::read_to_string(path) {\n            Ok(contents) => contents,\n            Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound),\n            Err(e) => return Err(e.into()),\n        };\n        let config = serde_yaml::from_str(&contents)?;\n        Ok(config)\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum ConfigLoadError {\n    #[error(\"io: {0}\")]\n    Io(#[from] io::Error),\n\n    #[error(\"config file not found\")]\n    NotFound,\n\n    #[error(\"invalid configuration: {0}\")]\n    Invalid(#[from] serde_yaml::Error),\n}\n\n#[derive(Clone, Debug, Deserialize, Default)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\n#[serde(untagged)]\n#[cfg_attr(feature = \"json-schema\", schemars(with = \"ThemeConfigSchema\"))]\npub enum ThemeConfig {\n    #[default]\n    None,\n    /// Theme of the presentation.\n    Some(String),\n    /// Automatic dark/light theme switch based on the terminal background luminance.\n    Dynamic {\n        /// Dark theme of the presentation.\n        dark: String,\n        /// Light theme of the presentation.\n        light: String,\n        /// Light/Dark detection timeout in ms.\n        #[cfg_attr(feature = \"json-schema\", validate(range(min = 1)))]\n        timeout: Option<u64>,\n    },\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct DefaultsConfig {\n    /// The theme to use by default in every presentation unless overridden.\n    #[serde(default)]\n    pub theme: ThemeConfig,\n\n    /// Override the terminal font size when in windows or when using sixel.\n    #[serde(default = \"default_terminal_font_size\")]\n    #[cfg_attr(feature = \"json-schema\", validate(range(min = 1)))]\n    pub terminal_font_size: u8,\n\n    /// The image protocol to use.\n    #[serde(default)]\n    pub image_protocol: ImageProtocol,\n\n    /// Validate that the presentation does not overflow the terminal screen.\n    #[serde(default)]\n    pub validate_overflows: ValidateOverflows,\n\n    /// A max width in columns that the presentation must always be capped to.\n    #[serde(default = \"default_u16_max\")]\n    pub max_columns: u16,\n\n    /// The alignment the presentation should have if `max_columns` is set and the terminal is\n    /// larger than that.\n    #[serde(default)]\n    pub max_columns_alignment: MaxColumnsAlignment,\n\n    /// A max height in rows that the presentation must always be capped to.\n    #[serde(default = \"default_u16_max\")]\n    pub max_rows: u16,\n\n    /// The alignment the presentation should have if `max_rows` is set and the terminal is\n    /// larger than that.\n    #[serde(default)]\n    pub max_rows_alignment: MaxRowsAlignment,\n\n    /// The configuration for lists when incremental lists are enabled.\n    #[serde(default)]\n    pub incremental_lists: IncrementalElementConfig,\n\n    /// The configuration for tables when incremental tables are enabled.\n    #[serde(default)]\n    pub incremental_tables: IncrementalElementConfig,\n}\n\nimpl Default for DefaultsConfig {\n    fn default() -> Self {\n        Self {\n            theme: Default::default(),\n            terminal_font_size: default_terminal_font_size(),\n            image_protocol: Default::default(),\n            validate_overflows: Default::default(),\n            max_columns: default_u16_max(),\n            max_columns_alignment: Default::default(),\n            max_rows: default_u16_max(),\n            max_rows_alignment: Default::default(),\n            incremental_lists: Default::default(),\n            incremental_tables: Default::default(),\n        }\n    }\n}\n\n/// The configuration for incrementally shown elements.\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct IncrementalElementConfig {\n    /// Whether to pause before.\n    #[serde(default)]\n    pub pause_before: Option<bool>,\n\n    /// Whether to pause after.\n    #[serde(default)]\n    pub pause_after: Option<bool>,\n}\n\nfn default_terminal_font_size() -> u8 {\n    16\n}\n\n/// The alignment to use when `defaults.max_columns` is set.\n#[derive(Clone, Copy, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(rename_all = \"snake_case\")]\npub enum MaxColumnsAlignment {\n    /// Align the presentation to the left.\n    Left,\n\n    /// Align the presentation on the center.\n    #[default]\n    Center,\n\n    /// Align the presentation to the right.\n    Right,\n}\n\n/// The alignment to use when `defaults.max_rows` is set.\n#[derive(Clone, Copy, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(rename_all = \"snake_case\")]\npub enum MaxRowsAlignment {\n    /// Align the presentation to the top.\n    Top,\n\n    /// Align the presentation on the center.\n    #[default]\n    Center,\n\n    /// Align the presentation to the bottom.\n    Bottom,\n}\n\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(rename_all = \"snake_case\")]\npub enum ValidateOverflows {\n    #[default]\n    Never,\n    Always,\n    WhenPresenting,\n    WhenDeveloping,\n}\n\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct OptionsConfig {\n    /// Whether slides are automatically terminated when a slide title is found.\n    pub implicit_slide_ends: Option<bool>,\n\n    /// The prefix to use for commands.\n    pub command_prefix: Option<String>,\n\n    /// The prefix to use for image attributes.\n    pub image_attributes_prefix: Option<String>,\n\n    /// Show all lists incrementally, by implicitly adding pauses in between elements.\n    pub incremental_lists: Option<bool>,\n\n    /// Show all tables incrementally, by implicitly adding pauses in between rows.\n    pub incremental_tables: Option<bool>,\n\n    /// The number of newlines in between list items.\n    pub list_item_newlines: Option<NonZeroU8>,\n\n    /// Whether to treat a thematic break as a slide end.\n    pub end_slide_shorthand: Option<bool>,\n\n    /// Whether to be strict about parsing the presentation's front matter.\n    pub strict_front_matter_parsing: Option<bool>,\n\n    /// Assume snippets for these languages contain `+render` and render them automatically.\n    #[serde(default)]\n    pub auto_render_languages: Vec<SnippetLanguage>,\n\n    /// Whether the first `h1` header on a slide should be considered a slide title.\n    pub h1_slide_titles: Option<bool>,\n}\n\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct SnippetConfig {\n    /// The properties for snippet execution.\n    #[serde(default)]\n    pub exec: SnippetExecConfig,\n\n    /// The properties for snippet execution.\n    #[serde(default)]\n    pub exec_replace: SnippetExecReplaceConfig,\n\n    /// The properties for snippet auto rendering.\n    #[serde(default)]\n    pub render: SnippetRenderConfig,\n\n    /// Whether to validate snippets.\n    #[serde(default)]\n    pub validate: bool,\n}\n\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct SnippetExecConfig {\n    /// Whether to enable snippet execution.\n    #[serde(default)]\n    pub enable: bool,\n\n    /// Custom snippet executors.\n    #[serde(default)]\n    pub custom: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,\n}\n\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct SnippetExecReplaceConfig {\n    /// Whether to enable snippet replace-executions, which automatically run code snippets without\n    /// the user's intervention.\n    pub enable: bool,\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct SnippetRenderConfig {\n    /// The number of threads to use when rendering.\n    #[serde(default = \"default_snippet_render_threads\")]\n    pub threads: usize,\n}\n\nimpl Default for SnippetRenderConfig {\n    fn default() -> Self {\n        Self { threads: default_snippet_render_threads() }\n    }\n}\n\npub(crate) fn default_snippet_render_threads() -> usize {\n    2\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct TypstConfig {\n    /// The pixels per inch when rendering latex/typst formulas.\n    #[serde(default = \"default_typst_ppi\")]\n    pub ppi: u32,\n}\n\nimpl Default for TypstConfig {\n    fn default() -> Self {\n        Self { ppi: default_typst_ppi() }\n    }\n}\n\npub(crate) fn default_typst_ppi() -> u32 {\n    300\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct MermaidConfig {\n    /// The scaling parameter to be used in the mermaid CLI.\n    #[serde(default = \"default_mermaid_scale\")]\n    pub scale: u32,\n\n    /// A path to a puppeteer JSON configuration file to be used by the `mmdc` tool.\n    pub puppeteer_config_path: Option<String>,\n\n    /// A path to a mermaid JSON configuration file to be used by the `mmdc` tool.\n    pub config_path: Option<String>,\n}\n\nimpl Default for MermaidConfig {\n    fn default() -> Self {\n        Self { scale: default_mermaid_scale(), puppeteer_config_path: None, config_path: None }\n    }\n}\n\npub(crate) fn default_mermaid_scale() -> u32 {\n    2\n}\n\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct D2Config {\n    /// The scaling parameter to be used in the d2 CLI.\n    #[serde(default)]\n    pub scale: Option<f32>,\n}\n\npub(crate) fn default_u16_max() -> u16 {\n    u16::MAX\n}\n\n/// The snippet execution configuration for a specific programming language.\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\npub struct LanguageSnippetExecutionConfig {\n    #[serde(flatten)]\n    pub executor: SnippetExecutorConfig,\n\n    /// The prefix to use to hide lines visually but still execute them.\n    pub hidden_line_prefix: Option<String>,\n\n    /// Alternative executors for this language.\n    #[serde(default)]\n    pub alternative: HashMap<String, SnippetExecutorConfig>,\n}\n\n/// A snippet executor configuration.\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\npub struct SnippetExecutorConfig {\n    /// The filename to use for the snippet input file.\n    pub filename: String,\n\n    /// The environment variables to set before invoking every command.\n    #[serde(default)]\n    pub environment: HashMap<String, String>,\n\n    /// The commands to be ran when executing snippets for this programming language.\n    pub commands: Vec<Vec<String>>,\n}\n\n#[derive(Clone, Debug, Default, Deserialize, ValueEnum)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(rename_all = \"kebab-case\")]\npub enum ImageProtocol {\n    /// Automatically detect the best image protocol to use.\n    #[default]\n    Auto,\n\n    /// Use the iTerm2 image protocol.\n    Iterm2,\n\n    /// Use the iTerm2 image protocol in multipart mode.\n    Iterm2Multipart,\n\n    /// Use the kitty protocol in \"local\" mode, meaning both presenterm and the terminal run in the\n    /// same host and can share the filesystem to communicate.\n    KittyLocal,\n\n    /// Use the kitty protocol in \"remote\" mode, meaning presenterm and the terminal run in\n    /// different hosts and therefore can only communicate via terminal escape codes.\n    KittyRemote,\n\n    /// Use the sixel protocol.\n    Sixel,\n\n    /// The default image protocol to use when no other is specified.\n    AsciiBlocks,\n}\n\nimpl From<&ImageProtocol> for GraphicsMode {\n    fn from(protocol: &ImageProtocol) -> Self {\n        match protocol {\n            ImageProtocol::Auto => {\n                let emulator = TerminalEmulator::detect();\n                emulator.preferred_protocol()\n            }\n            ImageProtocol::Iterm2 => GraphicsMode::Iterm2,\n            ImageProtocol::Iterm2Multipart => GraphicsMode::Iterm2Multipart,\n            ImageProtocol::KittyLocal => GraphicsMode::Kitty { mode: KittyMode::Local },\n            ImageProtocol::KittyRemote => GraphicsMode::Kitty { mode: KittyMode::Remote },\n            ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks,\n            ImageProtocol::Sixel => GraphicsMode::Sixel,\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct KeyBindingsConfig {\n    /// The keys that cause the presentation to move forwards.\n    #[serde(default = \"default_next_bindings\")]\n    pub(crate) next: Vec<KeyBinding>,\n\n    /// 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\n    /// the next slide.\n    #[serde(default = \"default_next_fast_bindings\")]\n    pub(crate) next_fast: Vec<KeyBinding>,\n\n    /// The keys that cause the presentation to move backwards.\n    #[serde(default = \"default_previous_bindings\")]\n    pub(crate) previous: Vec<KeyBinding>,\n\n    /// 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\n    /// the previous slide.\n    #[serde(default = \"default_previous_fast_bindings\")]\n    pub(crate) previous_fast: Vec<KeyBinding>,\n\n    /// The key binding to jump to the first slide.\n    #[serde(default = \"default_first_slide_bindings\")]\n    pub(crate) first_slide: Vec<KeyBinding>,\n\n    /// The key binding to jump to the last slide.\n    #[serde(default = \"default_last_slide_bindings\")]\n    pub(crate) last_slide: Vec<KeyBinding>,\n\n    /// The key binding to jump to a specific slide.\n    #[serde(default = \"default_go_to_slide_bindings\")]\n    pub(crate) go_to_slide: Vec<KeyBinding>,\n\n    /// The key binding to execute a piece of shell code.\n    #[serde(default = \"default_execute_code_bindings\")]\n    pub(crate) execute_code: Vec<KeyBinding>,\n\n    /// The key binding to reload the presentation.\n    #[serde(default = \"default_reload_bindings\")]\n    pub(crate) reload: Vec<KeyBinding>,\n\n    /// The key binding to toggle the slide index modal.\n    #[serde(default = \"default_toggle_index_bindings\")]\n    pub(crate) toggle_slide_index: Vec<KeyBinding>,\n\n    /// The key binding to toggle the key bindings modal.\n    #[serde(default = \"default_toggle_bindings_modal_bindings\")]\n    pub(crate) toggle_bindings: Vec<KeyBinding>,\n\n    /// The key binding to toggle the layout grid.\n    #[serde(default = \"default_toggle_layout_grid\")]\n    pub(crate) toggle_layout_grid: Vec<KeyBinding>,\n\n    /// The key binding to close the currently open modal.\n    #[serde(default = \"default_close_modal_bindings\")]\n    pub(crate) close_modal: Vec<KeyBinding>,\n\n    /// The key binding to close the application.\n    #[serde(default = \"default_exit_bindings\")]\n    pub(crate) exit: Vec<KeyBinding>,\n\n    /// The key binding to suspend the application.\n    #[serde(default = \"default_suspend_bindings\")]\n    pub(crate) suspend: Vec<KeyBinding>,\n\n    /// The key binding to show the entire slide, after skipping any pauses in it.\n    #[serde(default = \"default_skip_pauses\")]\n    pub(crate) skip_pauses: Vec<KeyBinding>,\n}\n\nimpl Default for KeyBindingsConfig {\n    fn default() -> Self {\n        Self {\n            next: default_next_bindings(),\n            next_fast: default_next_fast_bindings(),\n            previous: default_previous_bindings(),\n            previous_fast: default_previous_fast_bindings(),\n            first_slide: default_first_slide_bindings(),\n            last_slide: default_last_slide_bindings(),\n            go_to_slide: default_go_to_slide_bindings(),\n            execute_code: default_execute_code_bindings(),\n            reload: default_reload_bindings(),\n            toggle_slide_index: default_toggle_index_bindings(),\n            toggle_bindings: default_toggle_bindings_modal_bindings(),\n            toggle_layout_grid: default_toggle_layout_grid(),\n            close_modal: default_close_modal_bindings(),\n            exit: default_exit_bindings(),\n            suspend: default_suspend_bindings(),\n            skip_pauses: default_skip_pauses(),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct SpeakerNotesConfig {\n    /// The address in which to listen for speaker note events.\n    #[serde(default = \"default_speaker_notes_listen_address\")]\n    pub listen_address: SocketAddr,\n\n    /// The address in which to publish speaker notes events.\n    #[serde(default = \"default_speaker_notes_publish_address\")]\n    pub publish_address: SocketAddr,\n\n    /// Whether to always publish speaker notes.\n    #[serde(default)]\n    pub always_publish: bool,\n}\n\nimpl Default for SpeakerNotesConfig {\n    fn default() -> Self {\n        Self {\n            listen_address: default_speaker_notes_listen_address(),\n            publish_address: default_speaker_notes_publish_address(),\n            always_publish: false,\n        }\n    }\n}\n\n/// The export configuration.\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct ExportConfig {\n    /// The dimensions to use for presentation exports.\n    pub dimensions: Option<ExportDimensionsConfig>,\n\n    /// Whether pauses should create new slides.\n    #[serde(default)]\n    pub pauses: PauseExportPolicy,\n\n    /// The policy for executable snippets when exporting.\n    #[serde(default)]\n    pub snippets: SnippetsExportPolicy,\n\n    /// The PDF specific export configs.\n    #[serde(default)]\n    pub pdf: PdfExportConfig,\n}\n\n/// The policy for pauses when exporting.\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields, rename_all = \"snake_case\")]\npub enum PauseExportPolicy {\n    /// Whether to ignore pauses.\n    #[default]\n    Ignore,\n\n    /// Create a new slide when a pause is found.\n    NewSlide,\n}\n\n/// The policy for executable snippets when exporting.\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields, rename_all = \"snake_case\")]\npub enum SnippetsExportPolicy {\n    /// Render all executable snippets in parallel.\n    #[default]\n    Parallel,\n\n    /// Render all executable snippets sequentially.\n    Sequential,\n}\n\n/// The dimensions to use for presentation exports.\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields)]\npub struct ExportDimensionsConfig {\n    /// The number of rows.\n    pub rows: u16,\n\n    /// The number of columns.\n    pub columns: u16,\n}\n\n/// The PDF export specific configs.\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields, rename_all = \"snake_case\")]\npub struct PdfExportConfig {\n    /// The path to the font file to be used.\n    pub fonts: Option<ExportFontsConfig>,\n}\n\n/// The fonts used for exports.\n#[derive(Clone, Debug, Default, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(deny_unknown_fields, rename_all = \"snake_case\")]\npub struct ExportFontsConfig {\n    /// The path to the font file to be used for the \"normal\" variable of this font.\n    pub normal: PathBuf,\n\n    /// The path to the font file to be used for the \"bold\" variable of this font.\n    pub bold: Option<PathBuf>,\n\n    /// The path to the font file to be used for the \"italic\" variable of this font.\n    pub italic: Option<PathBuf>,\n\n    /// The path to the font file to be used for the \"bold+italic\" variable of this font.\n    pub bold_italic: Option<PathBuf>,\n}\n\n// The slide transition configuration.\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(tag = \"style\", deny_unknown_fields)]\npub struct SlideTransitionConfig {\n    /// The amount of time to take to perform the transition.\n    #[serde(default = \"default_transition_duration_millis\")]\n    pub duration_millis: u16,\n\n    /// The number of frames in a transition.\n    #[serde(default = \"default_transition_frames\")]\n    pub frames: usize,\n\n    /// The slide transition style.\n    pub animation: SlideTransitionStyleConfig,\n}\n\n// The slide transition style configuration.\n#[derive(Clone, Debug, Deserialize)]\n#[cfg_attr(feature = \"json-schema\", derive(schemars::JsonSchema))]\n#[serde(tag = \"style\", rename_all = \"snake_case\", deny_unknown_fields)]\npub enum SlideTransitionStyleConfig {\n    /// Slide horizontally.\n    SlideHorizontal,\n\n    /// Fade the new slide into the previous one.\n    Fade,\n\n    /// Collapse the current slide into the center of the screen.\n    CollapseHorizontal,\n}\n\nfn make_keybindings<const N: usize>(raw_bindings: [&str; N]) -> Vec<KeyBinding> {\n    let mut bindings = Vec::new();\n    for binding in raw_bindings {\n        bindings.push(binding.parse().expect(\"invalid binding\"));\n    }\n    bindings\n}\n\nfn default_next_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"l\", \"j\", \"<right>\", \"<page_down>\", \"<down>\", \" \"])\n}\n\nfn default_next_fast_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"n\"])\n}\n\nfn default_previous_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"h\", \"k\", \"<left>\", \"<page_up>\", \"<up>\"])\n}\n\nfn default_previous_fast_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"p\"])\n}\n\nfn default_first_slide_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"gg\"])\n}\n\nfn default_last_slide_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"G\"])\n}\n\nfn default_go_to_slide_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"<number>G\"])\n}\n\nfn default_execute_code_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"<c-e>\"])\n}\n\nfn default_reload_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"<c-r>\"])\n}\n\nfn default_toggle_index_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"<c-p>\"])\n}\n\nfn default_toggle_bindings_modal_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"?\"])\n}\n\nfn default_toggle_layout_grid() -> Vec<KeyBinding> {\n    make_keybindings([\"T\"])\n}\n\nfn default_close_modal_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"<esc>\"])\n}\n\nfn default_exit_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"<c-c>\", \"q\"])\n}\n\nfn default_suspend_bindings() -> Vec<KeyBinding> {\n    make_keybindings([\"<c-z>\"])\n}\n\nfn default_skip_pauses() -> Vec<KeyBinding> {\n    make_keybindings([\"s\"])\n}\n\nfn default_transition_duration_millis() -> u16 {\n    1000\n}\n\nfn default_transition_frames() -> usize {\n    30\n}\n\n#[cfg(target_os = \"linux\")]\npub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {\n    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)\n}\n\n#[cfg(not(target_os = \"linux\"))]\npub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {\n    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)\n}\n\n#[cfg(not(target_os = \"macos\"))]\npub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {\n    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)\n}\n\n#[cfg(target_os = \"macos\")]\npub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {\n    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::commands::keyboard::CommandKeyBindings;\n\n    #[test]\n    fn default_bindings() {\n        let config = KeyBindingsConfig::default();\n        CommandKeyBindings::try_from(config).expect(\"construction failed\");\n    }\n\n    #[test]\n    fn default_options_serde() {\n        serde_yaml::from_str::<'_, OptionsConfig>(\"implicit_slide_ends: true\").expect(\"failed to parse\");\n    }\n}\n"
  },
  {
    "path": "src/demo.rs",
    "content": "use crate::{\n    ImageRegistry, MarkdownParser, PresentationBuilderOptions, Resources, ThemeOptions, Themes, ThirdPartyRender,\n    code::execute::SnippetExecutor,\n    commands::{\n        keyboard::{CommandKeyBindings, KeyboardListener},\n        listener::Command,\n    },\n    markdown::elements::MarkdownElement,\n    presentation::{\n        Presentation,\n        builder::{PresentationBuilder, error::BuildError},\n    },\n    render::TerminalDrawer,\n    terminal::emulator::TerminalEmulator,\n    theme::raw::PresentationTheme,\n};\nuse std::{io, sync::Arc};\n\nconst PRESENTATION: &str = r#\"\n# Header 1\n## Header 2\n### Header 3\n#### Header 4\n##### Header 5\n###### Header 6\n\n```rust\nfn greet(name: &str) -> String {\n    format!(\"hi {name}\")\n}\n````\n\n* **bold text**\n* _italics_\n    * `some inline code`\n    * ~strikethrough~\n\n> a block quote\n\n<!-- end_slide -->\n<!-- end_slide -->\n\"#;\n\npub struct ThemesDemo {\n    themes: Themes,\n    input: KeyboardListener,\n    drawer: TerminalDrawer,\n}\n\nimpl ThemesDemo {\n    pub fn new(themes: Themes, bindings: CommandKeyBindings) -> io::Result<Self> {\n        let input = KeyboardListener::new(bindings);\n        let drawer = TerminalDrawer::new(Default::default(), Default::default())?;\n        Ok(Self { themes, input, drawer })\n    }\n\n    pub fn run(mut self) -> Result<(), Box<dyn std::error::Error>> {\n        let arena = Default::default();\n        let parser = MarkdownParser::new(&arena);\n        let elements = parser.parse(PRESENTATION).expect(\"broken demo presentation\");\n        let mut presentations = Vec::new();\n        for theme_name in self.themes.presentation.theme_names() {\n            let theme = self.themes.presentation.load_by_name(&theme_name).expect(\"theme not found\");\n            let presentation = self.build(&elements, &theme_name, &theme)?;\n            presentations.push(presentation);\n        }\n        let mut current = 0;\n        loop {\n            self.drawer.render_operations(presentations[current].current_slide().iter_visible_operations())?;\n\n            let command = self.next_command()?;\n            match command {\n                DemoCommand::Next => current = (current + 1).min(presentations.len() - 1),\n                DemoCommand::Previous => current = current.saturating_sub(1),\n                DemoCommand::First => current = 0,\n                DemoCommand::Last => current = presentations.len() - 1,\n                DemoCommand::Exit => return Ok(()),\n            };\n        }\n    }\n\n    fn next_command(&mut self) -> io::Result<DemoCommand> {\n        loop {\n            let mut command = self.input.next_command()?;\n            while command.is_none() {\n                command = self.input.next_command()?;\n            }\n            match command.unwrap() {\n                Command::Next => return Ok(DemoCommand::Next),\n                Command::Previous => return Ok(DemoCommand::Previous),\n                Command::FirstSlide => return Ok(DemoCommand::First),\n                Command::LastSlide => return Ok(DemoCommand::Last),\n                Command::Exit => return Ok(DemoCommand::Exit),\n                _ => continue,\n            }\n        }\n    }\n\n    fn build(\n        &self,\n        base_elements: &[MarkdownElement],\n        theme_name: &str,\n        theme: &PresentationTheme,\n    ) -> Result<Presentation, BuildError> {\n        let image_registry = ImageRegistry::default();\n        let resources = Resources::new(\"non_existent\", \"non_existent\", image_registry.clone());\n        let mut third_party = ThirdPartyRender::default();\n        let options = PresentationBuilderOptions {\n            theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },\n            ..Default::default()\n        };\n        let executer = Arc::new(SnippetExecutor::default());\n        let bindings_config = Default::default();\n        let arena = Default::default();\n        let parser = MarkdownParser::new(&arena);\n        let builder = PresentationBuilder::new(\n            theme,\n            resources,\n            &mut third_party,\n            executer,\n            &self.themes,\n            image_registry,\n            bindings_config,\n            &parser,\n            options,\n        )?;\n        let mut elements = vec![MarkdownElement::SetexHeading { text: vec![format!(\"theme: {theme_name}\").into()] }];\n        elements.extend(base_elements.iter().cloned());\n        builder.build_from_parsed(elements)\n    }\n}\n\nenum DemoCommand {\n    Next,\n    Previous,\n    First,\n    Last,\n    Exit,\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn demo_presentation() {\n        let arena = Default::default();\n        let parser = MarkdownParser::new(&arena);\n        parser.parse(PRESENTATION).expect(\"broken demo presentation\");\n    }\n}\n"
  },
  {
    "path": "src/export/exporter.rs",
    "content": "use crate::{\n    MarkdownParser, Resources,\n    code::execute::SnippetExecutor,\n    config::{KeyBindingsConfig, PauseExportPolicy, PdfExportConfig, SnippetsExportPolicy},\n    export::output::{ExportRenderer, OutputFormat},\n    markdown::text_style::Color,\n    presentation::{\n        Presentation,\n        builder::{PresentationBuilder, PresentationBuilderOptions, Themes, error::BuildError},\n        poller::{Poller, PollerCommand},\n    },\n    render::{\n        RenderError,\n        operation::{AsRenderOperations, PollableState, RenderOperation},\n        properties::WindowSize,\n    },\n    terminal::image::printer::{ImagePrinter, ImageRegistry},\n    theme::{ProcessingThemeError, raw::PresentationTheme},\n    third_party::ThirdPartyRender,\n    tools::{ExecutionError, ThirdPartyTools},\n};\nuse crossterm::{\n    cursor::{MoveToColumn, MoveToNextLine, MoveUp},\n    execute,\n    style::{Print, PrintStyledContent, Stylize},\n    terminal::{Clear, ClearType},\n};\nuse image::ImageError;\nuse std::{\n    fs, io,\n    path::{Path, PathBuf},\n    rc::Rc,\n    sync::Arc,\n};\nuse tempfile::TempDir;\n\npub enum OutputDirectory {\n    Temporary(TempDir),\n    External(PathBuf),\n}\n\nimpl OutputDirectory {\n    pub fn temporary() -> io::Result<Self> {\n        let dir = TempDir::with_suffix(\"presenterm\")?;\n        Ok(Self::Temporary(dir))\n    }\n\n    pub fn external(path: PathBuf) -> io::Result<Self> {\n        fs::create_dir_all(&path)?;\n        Ok(Self::External(path))\n    }\n\n    pub(crate) fn path(&self) -> &Path {\n        match self {\n            Self::Temporary(temp) => temp.path(),\n            Self::External(path) => path,\n        }\n    }\n}\n\n/// Allows exporting presentations into PDF.\npub struct Exporter<'a> {\n    parser: MarkdownParser<'a>,\n    default_theme: &'a PresentationTheme,\n    resources: Resources,\n    third_party: ThirdPartyRender,\n    code_executor: Arc<SnippetExecutor>,\n    image_printer: Arc<ImagePrinter>,\n    themes: Themes,\n    dimensions: WindowSize,\n    options: PresentationBuilderOptions,\n    snippet_policy: SnippetsExportPolicy,\n}\n\nimpl<'a> Exporter<'a> {\n    /// Construct a new exporter.\n    #[allow(clippy::too_many_arguments)]\n    pub fn new(\n        parser: MarkdownParser<'a>,\n        default_theme: &'a PresentationTheme,\n        resources: Resources,\n        third_party: ThirdPartyRender,\n        code_executor: Arc<SnippetExecutor>,\n        image_printer: Arc<ImagePrinter>,\n        themes: Themes,\n        mut options: PresentationBuilderOptions,\n        mut dimensions: WindowSize,\n        pause_policy: PauseExportPolicy,\n        snippet_policy: SnippetsExportPolicy,\n    ) -> Self {\n        // We don't want dynamically highlighted code blocks.\n        options.allow_mutations = false;\n        options.theme_options.font_size_supported = true;\n        options.pause_create_new_slide = match pause_policy {\n            PauseExportPolicy::Ignore => false,\n            PauseExportPolicy::NewSlide => true,\n        };\n\n        // Make sure we have a 1:2 aspect ratio.\n        let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64);\n        dimensions.width = width as u16;\n\n        Self {\n            parser,\n            default_theme,\n            resources,\n            third_party,\n            code_executor,\n            image_printer,\n            themes,\n            options,\n            dimensions,\n            snippet_policy,\n        }\n    }\n\n    fn build_renderer(\n        &mut self,\n        presentation_path: &Path,\n        output_directory: OutputDirectory,\n        renderer: OutputFormat,\n    ) -> Result<ExportRenderer, ExportError> {\n        let mut presentation = PresentationBuilder::new(\n            self.default_theme,\n            self.resources.clone(),\n            &mut self.third_party,\n            self.code_executor.clone(),\n            &self.themes,\n            ImageRegistry::new(self.image_printer.clone()),\n            KeyBindingsConfig::default(),\n            &self.parser,\n            self.options.clone(),\n        )?\n        .build(presentation_path)?;\n        Self::validate_theme_colors(&presentation)?;\n\n        let mut render = ExportRenderer::new(self.dimensions, output_directory, renderer);\n        Self::log(\"waiting for images to be generated and code to be executed, if any...\")?;\n        match self.snippet_policy {\n            SnippetsExportPolicy::Parallel => Self::wait_async_renders_parallel(&mut presentation),\n            SnippetsExportPolicy::Sequential => Self::wait_async_renders_sequential(&mut presentation),\n        };\n\n        for (index, slide) in presentation.into_slides().into_iter().enumerate() {\n            let index = index + 1;\n            Self::log(&format!(\"processing slide {index}...\"))?;\n            render.process_slide(slide)?;\n        }\n        Self::log(\"invoking weasyprint...\")?;\n\n        Ok(render)\n    }\n\n    /// Export the given presentation into PDF.\n    pub fn export_pdf(\n        mut self,\n        presentation_path: &Path,\n        output_directory: OutputDirectory,\n        output_path: Option<&Path>,\n        config: PdfExportConfig,\n    ) -> Result<(), ExportError> {\n        println!(\n            \"exporting using rows={}, columns={}, width={}, height={}\",\n            self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height\n        );\n\n        println!(\"checking for weasyprint...\");\n        Self::validate_weasyprint_exists()?;\n        Self::log(\"weasyprint installation found\")?;\n\n        let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Pdf)?;\n\n        let pdf_path = match output_path {\n            Some(path) => path.to_path_buf(),\n            None => presentation_path.with_extension(\"pdf\"),\n        };\n\n        render.generate(&pdf_path, &config.fonts)?;\n\n        execute!(\n            io::stdout(),\n            PrintStyledContent(\n                format!(\"output file is at {}\\n\", pdf_path.display()).stylize().with(Color::Green.into())\n            )\n        )?;\n        Ok(())\n    }\n\n    /// Export the given presentation into HTML.\n    pub fn export_html(\n        mut self,\n        presentation_path: &Path,\n        output_directory: OutputDirectory,\n        output_path: Option<&Path>,\n    ) -> Result<(), ExportError> {\n        println!(\n            \"exporting using rows={}, columns={}, width={}, height={}\",\n            self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height\n        );\n\n        let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Html)?;\n\n        let output_path = match output_path {\n            Some(path) => path.to_path_buf(),\n            None => presentation_path.with_extension(\"html\"),\n        };\n\n        render.generate(&output_path, &None)?;\n\n        execute!(\n            io::stdout(),\n            PrintStyledContent(\n                format!(\"output file is at {}\\n\", output_path.display()).stylize().with(Color::Green.into())\n            )\n        )?;\n        Ok(())\n    }\n\n    fn wait_async_renders_parallel(presentation: &mut Presentation) {\n        let poller = Poller::launch();\n        let mut pollables = Vec::new();\n        for (index, slide) in presentation.iter_slides().enumerate() {\n            for op in slide.iter_operations() {\n                if let RenderOperation::RenderAsync(inner) = op {\n                    // Send a pollable to the poller and keep one for ourselves.\n                    poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index });\n                    pollables.push(inner.pollable())\n                }\n            }\n        }\n\n        // Poll until they're all done\n        for mut pollable in pollables {\n            while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {}\n        }\n\n        // Replace render asyncs with new operations that contains the replaced image\n        // and any other unmodified operations.\n        for slide in presentation.iter_slides_mut() {\n            for op in slide.iter_operations_mut() {\n                if let RenderOperation::RenderAsync(inner) = op {\n                    let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };\n                    let new_operations = inner.as_render_operations(&window_size);\n                    *op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));\n                }\n            }\n        }\n    }\n\n    fn wait_async_renders_sequential(presentation: &mut Presentation) {\n        let poller = Poller::launch();\n        for (index, slide) in presentation.iter_slides_mut().enumerate() {\n            for op in slide.iter_operations_mut() {\n                if let RenderOperation::RenderAsync(inner) = op {\n                    // Send a pollable to the poller\n                    poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index });\n\n                    // Poll until it's done\n                    let mut pollable = inner.pollable();\n                    while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {}\n\n                    // Replace it with its contents\n                    let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };\n                    let new_operations = inner.as_render_operations(&window_size);\n                    *op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));\n                }\n            }\n        }\n    }\n\n    fn validate_weasyprint_exists() -> Result<(), ExportError> {\n        let result = ThirdPartyTools::weasyprint(&[\"--version\"]).run_and_capture_stdout();\n        match result {\n            Ok(_) => Ok(()),\n            Err(ExecutionError::Execution { .. }) => Err(ExportError::WeasyprintMissing),\n            Err(e) => Err(e.into()),\n        }\n    }\n\n    fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> {\n        for slide in presentation.iter_slides() {\n            for operation in slide.iter_visible_operations() {\n                let RenderOperation::SetColors(colors) = operation else {\n                    continue;\n                };\n                // The PDF requires a specific theme to be set, as \"no background\" means \"what the\n                // browser uses\" which is likely white and it will probably look terrible. It's\n                // better to err early and let you choose a theme that contains _some_ color.\n                if colors.background.is_none() {\n                    return Err(ExportError::UnsupportedColor(\"background\"));\n                }\n                if colors.foreground.is_none() {\n                    return Err(ExportError::UnsupportedColor(\"foreground\"));\n                }\n            }\n        }\n        Ok(())\n    }\n\n    fn log(text: &str) -> io::Result<()> {\n        execute!(\n            io::stdout(),\n            MoveUp(1),\n            Clear(ClearType::CurrentLine),\n            MoveToColumn(0),\n            Print(text),\n            MoveToNextLine(1)\n        )\n    }\n}\n\n#[derive(thiserror::Error, Debug)]\npub enum ExportError {\n    #[error(\"failed to build presentation: {0}\")]\n    BuildPresentation(#[from] BuildError),\n\n    #[error(\"unsupported {0} color in theme\")]\n    UnsupportedColor(&'static str),\n\n    #[error(\"generating images: {0}\")]\n    GeneratingImages(#[from] ImageError),\n\n    #[error(transparent)]\n    Execution(#[from] ExecutionError),\n\n    #[error(\"weasyprint not found\")]\n    WeasyprintMissing,\n\n    #[error(\"processing theme: {0}\")]\n    ProcessingTheme(#[from] ProcessingThemeError),\n\n    #[error(\"io: {0}\")]\n    Io(#[from] io::Error),\n\n    #[error(\"render: {0}\")]\n    Render(#[from] RenderError),\n}\n\n#[derive(Debug)]\nstruct RenderMany(Vec<RenderOperation>);\n\nimpl AsRenderOperations for RenderMany {\n    fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {\n        self.0.clone()\n    }\n}\n"
  },
  {
    "path": "src/export/html.rs",
    "content": "use crate::markdown::text_style::{Color, TextAttribute, TextStyle};\nuse std::{borrow::Cow, fmt};\n\npub(crate) enum HtmlText {\n    Plain(String),\n    Styled { text: String, style: String },\n}\n\nimpl HtmlText {\n    pub(crate) fn new(text: &str, style: &TextStyle, font_size: FontSize) -> Self {\n        let mut text = text.to_string();\n        if style == &TextStyle::default() {\n            return Self::Plain(text);\n        }\n        let mut css_styles = Vec::new();\n        let mut text_decorations = Vec::new();\n        for attr in style.iter_attributes() {\n            match attr {\n                TextAttribute::Bold => css_styles.push(Cow::Borrowed(\"font-weight: bold\")),\n                TextAttribute::Italics => css_styles.push(Cow::Borrowed(\"font-style: italic\")),\n                TextAttribute::Strikethrough => text_decorations.push(Cow::Borrowed(\"line-through\")),\n                TextAttribute::Underlined => text_decorations.push(Cow::Borrowed(\"underline\")),\n                TextAttribute::Superscript => text = format!(\"<sup>{text}</sup>\"),\n                TextAttribute::ForegroundColor(color) => {\n                    let color = color_to_html(&color);\n                    css_styles.push(format!(\"color: {color}\").into());\n                }\n                TextAttribute::BackgroundColor(color) => {\n                    let color = color_to_html(&color);\n                    css_styles.push(format!(\"background-color: {color}\").into());\n                }\n            };\n        }\n        if !text_decorations.is_empty() {\n            let text_decoration = text_decorations.join(\" \");\n            css_styles.push(format!(\"text-decoration: {text_decoration}\").into());\n        }\n        if style.size > 1 {\n            let font_size = font_size.scale(style.size);\n            css_styles.push(format!(\"font-size: {font_size}\").into());\n        }\n        let css_style = css_styles.join(\"; \");\n        Self::Styled { text, style: css_style }\n    }\n}\n\nimpl fmt::Display for HtmlText {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::Plain(text) => write!(f, \"{text}\"),\n            Self::Styled { text, style } => write!(f, \"<span style=\\\"{style}\\\">{text}</span>\"),\n        }\n    }\n}\n\npub(crate) enum FontSize {\n    Pixels(u16),\n}\n\nimpl FontSize {\n    fn scale(&self, size: u8) -> String {\n        match self {\n            Self::Pixels(scale) => format!(\"{}px\", scale * size as u16),\n        }\n    }\n}\n\npub(crate) fn color_to_html(color: &Color) -> String {\n    match color {\n        Color::Black => \"#000000\".into(),\n        Color::DarkGrey => \"#5a5a5a\".into(),\n        Color::Red => \"#ff0000\".into(),\n        Color::DarkRed => \"#8b0000\".into(),\n        Color::Green => \"#00ff00\".into(),\n        Color::DarkGreen => \"#006400\".into(),\n        Color::Yellow => \"#ffff00\".into(),\n        Color::DarkYellow => \"#8b8000\".into(),\n        Color::Blue => \"#0000ff\".into(),\n        Color::DarkBlue => \"#00008b\".into(),\n        Color::Magenta => \"#ff00ff\".into(),\n        Color::DarkMagenta => \"#8b008b\".into(),\n        Color::Cyan => \"#00ffff\".into(),\n        Color::DarkCyan => \"#008b8b\".into(),\n        Color::White => \"#ffffff\".into(),\n        Color::Grey => \"#808080\".into(),\n        Color::Rgb { r, g, b } => format!(\"#{r:02x}{g:02x}{b:02x}\"),\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::none(TextStyle::default(), \"\")]\n    #[case::bold(TextStyle::default().bold(), \"font-weight: bold\")]\n    #[case::italics(TextStyle::default().italics(), \"font-style: italic\")]\n    #[case::bold_italics(TextStyle::default().bold().italics(), \"font-weight: bold; font-style: italic\")]\n    #[case::strikethrough(TextStyle::default().strikethrough(), \"text-decoration: line-through\")]\n    #[case::underlined(TextStyle::default().underlined(), \"text-decoration: underline\")]\n    #[case::strikethrough_underlined(\n        TextStyle::default().strikethrough().underlined(),\n        \"text-decoration: line-through underline\"\n    )]\n    #[case::foreground_color(TextStyle::default().fg_color(Color::new(1,2,3)), \"color: #010203\")]\n    #[case::background_color(TextStyle::default().bg_color(Color::new(1,2,3)), \"background-color: #010203\")]\n    #[case::font_size(TextStyle::default().size(3), \"font-size: 6px\")]\n    fn html_text(#[case] style: TextStyle, #[case] expected_style: &str) {\n        let html_text = HtmlText::new(\"\", &style, FontSize::Pixels(2));\n        let style = match &html_text {\n            HtmlText::Plain(_) => \"\",\n            HtmlText::Styled { style, .. } => style,\n        };\n        assert_eq!(style, expected_style);\n    }\n\n    #[test]\n    fn render_span() {\n        let html_text = HtmlText::new(\"hi\", &TextStyle::default().bold(), FontSize::Pixels(1));\n        let rendered = html_text.to_string();\n        assert_eq!(rendered, \"<span style=\\\"font-weight: bold\\\">hi</span>\");\n    }\n}\n"
  },
  {
    "path": "src/export/mod.rs",
    "content": "pub mod exporter;\npub(crate) mod html;\npub(crate) mod output;\n"
  },
  {
    "path": "src/export/output.rs",
    "content": "use super::{\n    exporter::{ExportError, OutputDirectory},\n    html::{FontSize, color_to_html},\n};\nuse crate::{\n    config::ExportFontsConfig,\n    export::html::HtmlText,\n    markdown::text_style::TextStyle,\n    presentation::Slide,\n    render::{engine::RenderEngine, properties::WindowSize},\n    terminal::{\n        image::printer::TerminalImage,\n        virt::{TerminalGrid, VirtualTerminal},\n    },\n    tools::ThirdPartyTools,\n};\nuse std::{\n    fs, io,\n    path::{Path, PathBuf},\n};\n\nconst FONT_NAME: &str = \"presenterm-font\";\n\n// A magical multiplier that converts a font size in pixels to a font width.\n//\n// There's probably something somewhere that specifies what the relationship\n// really is but I found this by trial and error an I'm okay with that.\nconst FONT_SIZE_WIDTH: f64 = 0.605;\n\nconst FONT_SIZE: u16 = 10;\nconst LINE_HEIGHT: u16 = 12;\n\nstruct HtmlSlide {\n    rows: Vec<String>,\n    background_color: Option<String>,\n}\n\nimpl HtmlSlide {\n    fn new(grid: TerminalGrid) -> Result<Self, ExportError> {\n        let mut rows = Vec::new();\n        rows.push(String::from(\"<div class=\\\"container\\\">\"));\n        for (y, row) in grid.rows.into_iter().enumerate() {\n            let mut finalized_row = \"<div class=\\\"content-line\\\"><pre>\".to_string();\n            let mut current_style = row.first().map(|c| c.style).unwrap_or_default();\n            let mut current_string = String::new();\n            let mut x = 0;\n            while x < row.len() {\n                let c = row[x];\n                if c.style != current_style {\n                    finalized_row.push_str(&Self::finalize_string(&current_string, &current_style));\n                    current_string = String::new();\n                    current_style = c.style;\n                }\n                match c.character {\n                    '<' => current_string.push_str(\"&lt;\"),\n                    '>' => current_string.push_str(\"&gt;\"),\n                    other => current_string.push(other),\n                }\n                if let Some(image) = grid.images.get(&(y as u16, x as u16)) {\n                    let TerminalImage::Raw(raw_image) = image.image.image() else { panic!(\"not in raw image mode\") };\n                    let image_contents = raw_image.to_inline_html();\n                    let width_pixels = (image.width_columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();\n                    let image_tag = format!(\n                        \"<img width=\\\"{width_pixels}\\\" src=\\\"{image_contents}\\\" style=\\\"position: absolute\\\" />\"\n                    );\n                    current_string.push_str(&image_tag);\n                }\n                x += c.style.size as usize;\n            }\n            if !current_string.is_empty() {\n                finalized_row.push_str(&Self::finalize_string(&current_string, &current_style));\n            }\n            finalized_row.push_str(\"</pre></div>\");\n            rows.push(finalized_row);\n        }\n        rows.push(String::from(\"</div>\"));\n\n        Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) })\n    }\n\n    fn finalize_string(s: &str, style: &TextStyle) -> String {\n        HtmlText::new(s, style, FontSize::Pixels(FONT_SIZE)).to_string()\n    }\n}\n\npub(crate) struct ContentManager {\n    output_directory: OutputDirectory,\n}\n\nimpl ContentManager {\n    pub(crate) fn new(output_directory: OutputDirectory) -> Self {\n        Self { output_directory }\n    }\n\n    fn persist_file(&self, name: &str, data: &[u8]) -> io::Result<PathBuf> {\n        let path = self.output_directory.path().join(name);\n        fs::write(&path, data)?;\n        Ok(path)\n    }\n}\n\npub(crate) enum OutputFormat {\n    Pdf,\n    Html,\n}\n\npub(crate) struct ExportRenderer {\n    content_manager: ContentManager,\n    output_format: OutputFormat,\n    dimensions: WindowSize,\n    html_body: String,\n    background_color: Option<String>,\n}\n\nimpl ExportRenderer {\n    pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory, output_type: OutputFormat) -> Self {\n        let image_manager = ContentManager::new(output_directory);\n        Self {\n            content_manager: image_manager,\n            dimensions,\n            html_body: \"\".to_string(),\n            background_color: None,\n            output_format: output_type,\n        }\n    }\n\n    pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> {\n        let mut terminal = VirtualTerminal::new(self.dimensions, Default::default());\n        let engine = RenderEngine::new(&mut terminal, self.dimensions, Default::default());\n        engine.render(slide.iter_operations())?;\n\n        let grid = terminal.into_contents();\n        let slide = HtmlSlide::new(grid)?;\n        if self.background_color.is_none() {\n            self.background_color.clone_from(&slide.background_color);\n        }\n        for row in slide.rows {\n            self.html_body.push_str(&row);\n            self.html_body.push('\\n');\n        }\n        Ok(())\n    }\n\n    pub(crate) fn generate(self, output_path: &Path, fonts: &Option<ExportFontsConfig>) -> Result<(), ExportError> {\n        let html_body = &self.html_body;\n        let script = include_str!(\"script.js\");\n        let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();\n        let height = self.dimensions.rows * LINE_HEIGHT;\n        let background_color = self.background_color.unwrap_or_else(|| \"black\".into());\n        let container = match self.output_format {\n            OutputFormat::Pdf => String::from(\"display: contents;\"),\n            OutputFormat::Html => String::from(\n                \"\n                    width: 100%;\n                    height: 100%;\n                    display: flex;\n                    flex-direction: column;\n                    align-items: center;\n                \",\n            ),\n        };\n        let FontConfig { font_face, font_family } = fonts.as_ref().map(Self::font_configs).unwrap_or_default();\n        let css = format!(\n            r\"\n        pre {{\n            margin: 0;\n            padding: 0;\n            {font_family}\n        }}\n\n        span {{\n            display: inline-block;\n        }}\n\n        {font_face} \n\n        body {{\n            margin: 0;\n            font-size: {FONT_SIZE}px;\n            line-height: {LINE_HEIGHT}px;\n            width: {width}px;\n            height: {height}px;\n            transform-origin: top left;\n            background-color: {background_color};\n        }}\n\n        .container {{\n            {container}\n        }}\n\n        .content-line {{\n            line-height: {LINE_HEIGHT}px; \n            height: {LINE_HEIGHT}px;\n            margin: 0px;\n            width: {width}px;\n        }}\n\n        .hidden {{\n            display: none;\n        }}\n\n        @page {{\n            margin: 0;\n            height: {height}px;\n            width: {width}px;\n        }}\"\n        );\n        let html_script = match self.output_format {\n            OutputFormat::Pdf => String::new(),\n            OutputFormat::Html => {\n                format!(\n                    \"\n<script>\nlet originalWidth = {width};\nlet originalHeight = {height};\n{script}\n</script>\"\n                )\n            }\n        };\n        let style = match self.output_format {\n            OutputFormat::Pdf => String::new(),\n            OutputFormat::Html => format!(\n                \"\n<head>\n<meta charset=\\\"UTF-8\\\">\n<style>\n{css}\n</style>\n</head>\n                \"\n            ),\n        };\n        let html = format!(\n            r\"\n<html>\n{style}\n<body>\n{html_body}\n{html_script}\n</body>\n</html>\"\n        );\n\n        let html_path = self.content_manager.persist_file(\"index.html\", html.as_bytes())?;\n        let css_path = self.content_manager.persist_file(\"styles.css\", css.as_bytes())?;\n\n        match self.output_format {\n            OutputFormat::Pdf => {\n                ThirdPartyTools::weasyprint(&[\n                    \"-s\",\n                    css_path.to_string_lossy().as_ref(),\n                    \"--presentational-hints\",\n                    \"-e\",\n                    \"utf8\",\n                    html_path.to_string_lossy().as_ref(),\n                    output_path.to_string_lossy().as_ref(),\n                ])\n                .run()?;\n            }\n            OutputFormat::Html => {\n                fs::write(output_path, html.as_bytes())?;\n            }\n        }\n\n        Ok(())\n    }\n\n    fn font_configs(config: &ExportFontsConfig) -> FontConfig {\n        let mut font_face = Self::make_font_face(&config.normal, \"normal\", \"normal\");\n        if let Some(path) = &config.bold {\n            font_face.push_str(&Self::make_font_face(path, \"bold\", \"normal\"));\n        }\n        if let Some(path) = &config.italic {\n            font_face.push_str(&Self::make_font_face(path, \"normal\", \"italic\"));\n        }\n        if let Some(path) = &config.bold_italic {\n            font_face.push_str(&Self::make_font_face(path, \"bold\", \"italic\"));\n        }\n        let font_family = format!(\"font-family: {FONT_NAME}\");\n        FontConfig { font_face, font_family }\n    }\n\n    fn make_font_face(path: &Path, weight: &str, style: &str) -> String {\n        let path = path.display();\n        format!(\n            r\"\n    @font-face {{\n        font-family: {FONT_NAME};\n        src: url(file://{path});\n        font-weight: {weight};\n        font-style: {style};\n    }}\"\n        )\n    }\n}\n\n#[derive(Default)]\nstruct FontConfig {\n    font_face: String,\n    font_family: String,\n}\n"
  },
  {
    "path": "src/export/script.js",
    "content": "document.addEventListener('DOMContentLoaded', function() {\n  const allLines = document.querySelectorAll('body > div');\n  const pageBreakMarkers = document.querySelectorAll('.container');\n  let currentPageIndex = 0;\n\n\n  function showCurrentPage() {\n    allLines.forEach((line) => {\n      line.classList.add('hidden');\n    });\n\n    allLines[currentPageIndex].classList.remove('hidden');\n  }\n\n\n  function scaler() {\n    var w = document.documentElement.clientWidth;\n    var h = document.documentElement.clientHeight;\n    let widthScaledAmount= w/originalWidth;\n    let heightScaledAmount= h/originalHeight;\n    let scaledAmount = Math.min(widthScaledAmount, heightScaledAmount);\n    document.querySelector(\"body\").style.transform = `scale(${scaledAmount})`;\n  }\n\n  function handleKeyPress(event) {\n    if (event.key === 'ArrowLeft') {\n      if (currentPageIndex > 0) {\n        currentPageIndex--;\n        showCurrentPage();\n      }\n    } else if (event.key === 'ArrowRight') {\n      if (currentPageIndex < pageBreakMarkers.length - 1) {\n        currentPageIndex++;\n        showCurrentPage();\n      }\n    }\n  }\n\n  document.addEventListener('keydown', handleKeyPress);\n  window.addEventListener(\"resize\", scaler);\n\n  scaler();\n  showCurrentPage();\n});\n\n"
  },
  {
    "path": "src/main.rs",
    "content": "use crate::{\n    code::{execute::SnippetExecutor, highlighting::HighlightThemeSet},\n    commands::listener::CommandListener,\n    config::{Config, ImageProtocol, ValidateOverflows},\n    demo::ThemesDemo,\n    export::exporter::Exporter,\n    markdown::parse::MarkdownParser,\n    presentation::builder::{CommentCommand, PresentationBuilderOptions, Themes},\n    presenter::{PresentMode, Presenter, PresenterOptions},\n    resource::Resources,\n    terminal::{\n        GraphicsMode,\n        image::printer::{ImagePrinter, ImageRegistry},\n    },\n    theme::{raw::PresentationTheme, registry::PresentationThemeRegistry},\n    third_party::{ThirdPartyConfigs, ThirdPartyRender},\n};\nuse anyhow::anyhow;\nuse clap::{CommandFactory, Parser, error::ErrorKind};\nuse commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher};\nuse comrak::Arena;\nuse config::ConfigLoadError;\nuse crossterm::{\n    execute,\n    style::{PrintStyledContent, Stylize},\n};\nuse directories::ProjectDirs;\nuse export::exporter::OutputDirectory;\nuse render::{engine::MaxSize, properties::WindowSize};\nuse std::{\n    env::{self, current_dir},\n    io,\n    path::{Path, PathBuf},\n    sync::Arc,\n    time::Duration,\n};\nuse terminal::emulator::TerminalEmulator;\nuse theme::ThemeOptions;\n\nmod code;\nmod commands;\nmod config;\nmod demo;\nmod export;\nmod markdown;\nmod presentation;\nmod presenter;\nmod render;\nmod resource;\nmod terminal;\nmod theme;\nmod third_party;\nmod tools;\nmod transitions;\nmod ui;\nmod utils;\n\nconst DEFAULT_THEME: &str = \"dark\";\nconst DEFAULT_THEME_DYNAMIC_DETECTION_TIMEOUT: u64 = 100;\nconst DEFAULT_EXPORT_PIXELS_PER_COLUMN: u16 = 20;\nconst DEFAULT_EXPORT_PIXELS_PER_ROW: u16 = DEFAULT_EXPORT_PIXELS_PER_COLUMN * 2;\n\n/// Run slideshows from your terminal.\n#[derive(Parser)]\n#[command()]\n#[command(author, version, about = create_splash(), arg_required_else_help = true)]\nstruct Cli {\n    /// The path to the markdown file that contains the presentation.\n    #[clap(group = \"target\")]\n    path: Option<PathBuf>,\n\n    /// Export the presentation as a PDF rather than displaying it.\n    #[clap(short, long, group = \"export\")]\n    export_pdf: bool,\n\n    /// Export the presentation as a HTML rather than displaying it.\n    #[clap(short = 'E', long, group = \"export\")]\n    export_html: bool,\n\n    /// The path in which to store temporary files used when exporting.\n    #[clap(long, requires = \"export\")]\n    export_temporary_path: Option<PathBuf>,\n\n    /// The output path for the exported PDF.\n    #[clap(short = 'o', long = \"output\", requires = \"export\")]\n    export_output: Option<PathBuf>,\n\n    /// Generate a JSON schema for the configuration file.\n    #[clap(long)]\n    #[cfg(feature = \"json-schema\")]\n    generate_config_file_schema: bool,\n\n    /// Use presentation mode.\n    #[clap(short, long, default_value_t = false)]\n    present: bool,\n\n    /// The theme to use.\n    #[clap(short, long)]\n    theme: Option<String>,\n\n    /// List all supported themes.\n    #[clap(long, group = \"target\")]\n    list_themes: bool,\n\n    /// Print the theme in use.\n    #[clap(long, group = \"target\")]\n    current_theme: bool,\n\n    /// Display acknowledgements.\n    #[clap(long, group = \"target\")]\n    acknowledgements: bool,\n\n    /// The image protocol to use.\n    #[clap(long)]\n    image_protocol: Option<ImageProtocol>,\n\n    /// Validate that the presentation does not overflow the terminal screen.\n    #[clap(long)]\n    validate_overflows: bool,\n\n    /// Enable code snippet execution.\n    #[clap(short = 'x', long)]\n    enable_snippet_execution: bool,\n\n    /// Enable code snippet auto execution via `+exec_replace` blocks.\n    #[clap(short = 'X', long)]\n    enable_snippet_execution_replace: bool,\n\n    /// The path to the configuration file.\n    #[clap(short, long, env = \"PRESENTERM_CONFIG_FILE\")]\n    config_file: Option<String>,\n\n    /// Whether to publish speaker notes to local listeners.\n    #[clap(short = 'P', long, group = \"speaker-notes\")]\n    publish_speaker_notes: bool,\n\n    /// Whether to listen for speaker notes.\n    #[clap(short, long, group = \"speaker-notes\")]\n    listen_speaker_notes: bool,\n\n    /// Whether to validate snippets.\n    #[clap(long)]\n    validate_snippets: bool,\n\n    /// List all available comment commands.\n    #[clap(long, group = \"target\")]\n    list_comment_commands: bool,\n}\n\nfn create_splash() -> String {\n    let crate_version = env!(\"CARGO_PKG_VERSION\");\n\n    format!(\n        r#\"\n  ┌─┐┬─┐┌─┐┌─┐┌─┐┌┐┌┌┬┐┌─┐┬─┐┌┬┐\n  ├─┘├┬┘├┤ └─┐├┤ │││ │ ├┤ ├┬┘│││\n  ┴  ┴└─└─┘└─┘└─┘┘└┘ ┴ └─┘┴└─┴ ┴ v{crate_version}\n    A terminal slideshow tool \n                    @mfontanini/presenterm\n\"#,\n    )\n}\n\n#[derive(Default)]\nstruct Customizations {\n    config: Config,\n    themes: Themes,\n    themes_path: Option<PathBuf>,\n    code_executor: SnippetExecutor,\n}\n\nimpl Customizations {\n    fn load(config_file_path: Option<PathBuf>, cwd: &Path) -> Result<Self, Box<dyn std::error::Error>> {\n        let configs_path: PathBuf = match env::var(\"XDG_CONFIG_HOME\") {\n            Ok(path) => Path::new(&path).join(\"presenterm\"),\n            Err(_) => {\n                let Some(project_dirs) = ProjectDirs::from(\"\", \"\", \"presenterm\") else {\n                    return Ok(Default::default());\n                };\n                project_dirs.config_dir().into()\n            }\n        };\n        let themes_path = configs_path.join(\"themes\");\n        let themes = Self::load_themes(&themes_path)?;\n        let require_config_file = config_file_path.is_some();\n        let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join(\"config.yaml\"));\n        let config = match Config::load(&config_file_path) {\n            Ok(config) => config,\n            Err(ConfigLoadError::NotFound) if !require_config_file => Default::default(),\n            Err(e) => return Err(e.into()),\n        };\n        let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone(), cwd.to_path_buf())?;\n        Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor })\n    }\n\n    fn load_themes(themes_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {\n        let mut highlight_themes = HighlightThemeSet::default();\n        highlight_themes.register_from_directory(themes_path.join(\"highlighting\"))?;\n\n        let mut presentation_themes = PresentationThemeRegistry::default();\n        presentation_themes.register_from_directory(themes_path)?;\n\n        let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };\n        Ok(themes)\n    }\n}\n\nstruct CoreComponents {\n    third_party: ThirdPartyRender,\n    code_executor: Arc<SnippetExecutor>,\n    resources: Resources,\n    printer: Arc<ImagePrinter>,\n    builder_options: PresentationBuilderOptions,\n    themes: Themes,\n    default_theme: PresentationTheme,\n    config: Config,\n    present_mode: PresentMode,\n    graphics_mode: GraphicsMode,\n}\n\nimpl CoreComponents {\n    fn new(cli: &Cli, path: &Path) -> Result<Self, Box<dyn std::error::Error>> {\n        let mut resources_path = path.parent().unwrap_or(Path::new(\"./\")).to_path_buf();\n        if resources_path == Path::new(\"\") {\n            resources_path = \"./\".into();\n        }\n        let resources_path = resources_path.canonicalize().unwrap_or(resources_path);\n\n        let Customizations { config, themes, code_executor, themes_path } =\n            Customizations::load(cli.config_file.clone().map(PathBuf::from), &resources_path)?;\n\n        let default_theme = Self::load_default_theme(&config, &themes, cli);\n        let force_default_theme = cli.theme.is_some();\n        let present_mode = match (cli.present, cli.export_pdf) {\n            (true, _) | (_, true) => PresentMode::Presentation,\n            (false, false) => PresentMode::Development,\n        };\n\n        let mut builder_options = Self::make_builder_options(&config, force_default_theme, cli.listen_speaker_notes);\n        if cli.enable_snippet_execution {\n            builder_options.enable_snippet_execution = true;\n        }\n        if cli.enable_snippet_execution_replace {\n            builder_options.enable_snippet_execution_replace = true;\n        }\n        let graphics_mode = Self::select_graphics_mode(cli, &config);\n        let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);\n        let registry = ImageRegistry::new(printer.clone());\n        let resources = Resources::new(\n            resources_path.clone(),\n            themes_path.unwrap_or_else(|| resources_path.clone()),\n            registry.clone(),\n        );\n        let third_party_config = ThirdPartyConfigs {\n            typst_ppi: config.typst.ppi.to_string(),\n            mermaid_scale: config.mermaid.scale.to_string(),\n            mermaid_puppeteer_file: config.mermaid.puppeteer_config_path.clone(),\n            mermaid_config_file: config.mermaid.config_path.clone(),\n            d2_scale: config.d2.scale.map(|s| s.to_string()).unwrap_or_else(|| \"-1\".to_string()),\n            threads: config.snippet.render.threads,\n        };\n        let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path);\n        let code_executor = Arc::new(code_executor);\n        Ok(Self {\n            third_party,\n            code_executor,\n            resources,\n            printer,\n            builder_options,\n            themes,\n            default_theme,\n            config,\n            present_mode,\n            graphics_mode,\n        })\n    }\n\n    fn make_builder_options(\n        config: &Config,\n        force_default_theme: bool,\n        render_speaker_notes_only: bool,\n    ) -> PresentationBuilderOptions {\n        let options = &config.options;\n        PresentationBuilderOptions {\n            allow_mutations: true,\n            implicit_slide_ends: options.implicit_slide_ends.unwrap_or_default(),\n            command_prefix: options.command_prefix.clone().unwrap_or_default(),\n            image_attribute_prefix: options.image_attributes_prefix.clone().unwrap_or_else(|| \"image:\".to_string()),\n            incremental_lists: options.incremental_lists.unwrap_or_default(),\n            incremental_tables: options.incremental_tables.unwrap_or_default(),\n            force_default_theme,\n            end_slide_shorthand: options.end_slide_shorthand.unwrap_or_default(),\n            print_modal_background: false,\n            strict_front_matter_parsing: options.strict_front_matter_parsing.unwrap_or(true),\n            enable_snippet_execution: config.snippet.exec.enable,\n            enable_snippet_execution_replace: config.snippet.exec_replace.enable,\n            render_speaker_notes_only,\n            auto_render_languages: options.auto_render_languages.clone(),\n            theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },\n            pause_before_incremental_lists: config.defaults.incremental_lists.pause_before.unwrap_or(true),\n            pause_after_incremental_lists: config.defaults.incremental_lists.pause_after.unwrap_or(true),\n            pause_before_incremental_tables: config.defaults.incremental_tables.pause_before.unwrap_or(true),\n            pause_after_incremental_tables: config.defaults.incremental_tables.pause_after.unwrap_or(true),\n            pause_create_new_slide: false,\n            list_item_newlines: options.list_item_newlines.map(Into::into).unwrap_or(1),\n            validate_snippets: config.snippet.validate,\n            layout_grid: false,\n            h1_slide_titles: options.h1_slide_titles.unwrap_or_default(),\n        }\n    }\n\n    fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {\n        if cli.export_pdf | cli.export_html {\n            GraphicsMode::Raw\n        } else {\n            cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol).into()\n        }\n    }\n\n    fn theme_name(config: &Config, cli: &Cli) -> String {\n        if let Some(name) = cli.theme.as_ref() {\n            name.clone()\n        } else {\n            match &config.defaults.theme {\n                config::ThemeConfig::None => DEFAULT_THEME.into(),\n                config::ThemeConfig::Some(theme_name) => theme_name.clone(),\n                config::ThemeConfig::Dynamic { dark, light, timeout } => {\n                    let default_timeout = timeout.unwrap_or(DEFAULT_THEME_DYNAMIC_DETECTION_TIMEOUT);\n                    let timeout_duration = Duration::from_millis(default_timeout);\n                    if let Ok(theme) = termbg::theme(timeout_duration) {\n                        if theme == termbg::Theme::Dark { dark.clone() } else { light.clone() }\n                    } else {\n                        Cli::command()\n                            .error(\n                                ErrorKind::Io,\n                                \"terminal theme detection failed, unsupported terminal or timeout exceeded\",\n                            )\n                            .exit();\n                    }\n                }\n            }\n        }\n    }\n\n    fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme {\n        let default_theme_name = Self::theme_name(config, cli);\n        let Some(default_theme) = themes.presentation.load_by_name(default_theme_name.as_str()) else {\n            let valid_themes = themes.presentation.theme_names().join(\", \");\n            let error_message = format!(\"invalid theme name, valid themes are: {valid_themes}\");\n            Cli::command().error(ErrorKind::InvalidValue, error_message).exit();\n        };\n        default_theme\n    }\n}\n\nstruct SpeakerNotesComponents {\n    events_listener: Option<SpeakerNotesEventListener>,\n    events_publisher: Option<SpeakerNotesEventPublisher>,\n}\n\nimpl SpeakerNotesComponents {\n    fn new(cli: &Cli, config: &Config, path: &Path) -> anyhow::Result<Self> {\n        let full_presentation_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());\n        let publish_speaker_notes =\n            cli.publish_speaker_notes || (config.speaker_notes.always_publish && !cli.listen_speaker_notes);\n        let events_publisher = publish_speaker_notes\n            .then(|| {\n                SpeakerNotesEventPublisher::new(config.speaker_notes.publish_address, full_presentation_path.clone())\n            })\n            .transpose()\n            .map_err(|e| anyhow!(\"failed to create speaker notes publisher: {e}\"))?;\n        let events_listener = cli\n            .listen_speaker_notes\n            .then(|| SpeakerNotesEventListener::new(config.speaker_notes.listen_address, full_presentation_path))\n            .transpose()\n            .map_err(|e| anyhow!(\"failed to create speaker notes listener: {e}\"))?;\n        Ok(Self { events_listener, events_publisher })\n    }\n}\n\nfn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -> bool {\n    match (config, mode) {\n        (ValidateOverflows::Always, _) => true,\n        (ValidateOverflows::Never, _) => false,\n        (ValidateOverflows::WhenPresenting, PresentMode::Presentation) => true,\n        (ValidateOverflows::WhenDeveloping, PresentMode::Development) => true,\n        _ => false,\n    }\n}\n\nfn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {\n    #[cfg(feature = \"json-schema\")]\n    if cli.generate_config_file_schema {\n        let schema = schemars::schema_for!(Config);\n        serde_json::to_writer_pretty(io::stdout(), &schema).map_err(|e| format!(\"failed to write schema: {e}\"))?;\n        return Ok(());\n    }\n    if cli.acknowledgements {\n        let acknowledgements = include_bytes!(\"../bat/acknowledgements.txt\");\n        println!(\"{}\", String::from_utf8_lossy(acknowledgements));\n        return Ok(());\n    } else if cli.list_themes {\n        // Load this ahead of time so we don't do it when we're already in raw mode.\n        TerminalEmulator::capabilities();\n        let Customizations { config, themes, .. } =\n            Customizations::load(cli.config_file.clone().map(PathBuf::from), &current_dir()?)?;\n        let bindings = config.bindings.try_into()?;\n        let demo = ThemesDemo::new(themes, bindings)?;\n        demo.run()?;\n        return Ok(());\n    } else if cli.current_theme {\n        let Customizations { config, .. } =\n            Customizations::load(cli.config_file.clone().map(PathBuf::from), &current_dir()?)?;\n        let theme_name = CoreComponents::theme_name(&config, &cli);\n        println!(\"{theme_name}\");\n        return Ok(());\n    } else if cli.list_comment_commands {\n        let samples = CommentCommand::generate_samples();\n        for sample in samples {\n            println!(\"{}\", sample);\n        }\n        return Ok(());\n    }\n    // Disable this so we don't mess things up when generating PDFs\n    if cli.export_pdf {\n        TerminalEmulator::disable_capability_detection();\n    }\n\n    let Some(path) = cli.path.clone() else {\n        Cli::command().error(ErrorKind::MissingRequiredArgument, \"no path specified\").exit();\n    };\n    let CoreComponents {\n        third_party,\n        code_executor,\n        resources,\n        printer,\n        mut builder_options,\n        themes,\n        default_theme,\n        config,\n        present_mode,\n        graphics_mode,\n    } = CoreComponents::new(&cli, &path)?;\n    let arena = Arena::new();\n    let parser = MarkdownParser::new(&arena);\n    let validate_overflows =\n        overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows;\n    if cli.validate_snippets {\n        builder_options.validate_snippets = cli.validate_snippets;\n    }\n    if cli.export_pdf || cli.export_html {\n        let dimensions = match config.export.dimensions {\n            Some(dimensions) => WindowSize {\n                rows: dimensions.rows,\n                columns: dimensions.columns,\n                height: dimensions.rows * DEFAULT_EXPORT_PIXELS_PER_ROW,\n                width: dimensions.columns * DEFAULT_EXPORT_PIXELS_PER_COLUMN,\n            },\n            None => WindowSize::current(config.defaults.terminal_font_size)?,\n        };\n        let exporter = Exporter::new(\n            parser,\n            &default_theme,\n            resources,\n            third_party,\n            code_executor,\n            printer,\n            themes,\n            builder_options,\n            dimensions,\n            config.export.pauses,\n            config.export.snippets,\n        );\n        let output_directory = match cli.export_temporary_path {\n            Some(path) => OutputDirectory::external(path),\n            None => OutputDirectory::temporary(),\n        }?;\n        if cli.export_pdf {\n            exporter.export_pdf(&path, output_directory, cli.export_output.as_deref(), config.export.pdf)?;\n        } else {\n            exporter.export_html(&path, output_directory, cli.export_output.as_deref())?;\n        }\n    } else {\n        let SpeakerNotesComponents { events_listener, events_publisher } =\n            SpeakerNotesComponents::new(&cli, &config, &path)?;\n        let command_listener = CommandListener::new(config.bindings.clone(), events_listener)?;\n\n        builder_options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. });\n        let options = PresenterOptions {\n            builder_options,\n            mode: present_mode,\n            font_size_fallback: config.defaults.terminal_font_size,\n            bindings: config.bindings,\n            validate_overflows,\n            max_size: MaxSize {\n                max_columns: config.defaults.max_columns,\n                max_columns_alignment: config.defaults.max_columns_alignment,\n                max_rows: config.defaults.max_rows,\n                max_rows_alignment: config.defaults.max_rows_alignment,\n            },\n            transition: config.transition,\n        };\n        let presenter = Presenter::new(\n            &default_theme,\n            command_listener,\n            parser,\n            resources,\n            third_party,\n            code_executor,\n            themes,\n            printer,\n            options,\n            events_publisher,\n        );\n        presenter.present(&path)?;\n    }\n    Ok(())\n}\n\nfn main() {\n    let cli = Cli::parse();\n    if let Err(e) = run(cli) {\n        let _ =\n            execute!(io::stdout(), PrintStyledContent(format!(\"{e}\\n\").stylize().with(crossterm::style::Color::Red)));\n        std::process::exit(1);\n    }\n}\n"
  },
  {
    "path": "src/markdown/elements.rs",
    "content": "use super::text_style::{Color, TextStyle, UndefinedPaletteColorError};\nuse crate::theme::{ColorPalette, raw::RawColor};\nuse comrak::nodes::AlertType;\nuse std::{fmt, iter, path::PathBuf, str::FromStr};\nuse unicode_width::UnicodeWidthStr;\n\n/// A markdown element.\n///\n/// This represents each of the supported markdown elements. The structure here differs a bit from\n/// the spec, mostly in how inlines are handled, to simplify its processing.\n#[derive(Clone, Debug)]\npub(crate) enum MarkdownElement {\n    /// The front matter that optionally shows up at the beginning of the file.\n    FrontMatter(String),\n\n    /// A setex heading.\n    SetexHeading { text: Vec<Line<RawColor>> },\n\n    /// A normal heading.\n    Heading { level: u8, text: Line<RawColor> },\n\n    /// A paragraph composed by a list of lines.\n    Paragraph(Vec<Line<RawColor>>),\n\n    /// An image.\n    Image { path: PathBuf, title: String, source_position: SourcePosition },\n\n    /// A list.\n    ///\n    /// All contiguous list items are merged into a single one, regardless of levels of nesting.\n    List(Vec<ListItem>),\n\n    /// A code snippet.\n    Snippet {\n        /// The information line that specifies this code's language, attributes, etc.\n        info: String,\n\n        /// The code in this snippet.\n        code: String,\n\n        /// The position in the source file this snippet came from.\n        source_position: SourcePosition,\n    },\n\n    /// A table.\n    Table(Table),\n\n    /// A thematic break.\n    ThematicBreak,\n\n    /// An HTML comment.\n    Comment { comment: String, source_position: SourcePosition },\n\n    /// A block quote containing a list of lines.\n    BlockQuote(Vec<Line<RawColor>>),\n\n    /// An alert.\n    Alert {\n        /// The alert's type.\n        alert_type: AlertType,\n\n        /// The optional title.\n        title: Option<String>,\n\n        /// The content lines in this alert.\n        lines: Vec<Line<RawColor>>,\n    },\n\n    /// A footnote definition.\n    Footnote(Line<RawColor>),\n}\n\n#[derive(Clone, Copy, Debug, Default)]\npub struct SourcePosition {\n    pub(crate) start: LineColumn,\n}\n\nimpl fmt::Display for SourcePosition {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}:{}\", self.start.line, self.start.column)\n    }\n}\n\nimpl From<comrak::nodes::Sourcepos> for SourcePosition {\n    fn from(position: comrak::nodes::Sourcepos) -> Self {\n        Self { start: position.start.into() }\n    }\n}\n\n#[derive(Clone, Copy, Debug, Default)]\npub(crate) struct LineColumn {\n    pub(crate) line: usize,\n    pub(crate) column: usize,\n}\n\nimpl From<comrak::nodes::LineColumn> for LineColumn {\n    fn from(position: comrak::nodes::LineColumn) -> Self {\n        Self { line: position.line, column: position.column }\n    }\n}\n\n/// A text line.\n///\n/// Text is represented as a series of chunks, each with their own formatting.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct Line<C = Color>(pub(crate) Vec<Text<C>>);\n\nimpl<C> Default for Line<C> {\n    fn default() -> Self {\n        Self(vec![])\n    }\n}\n\nimpl<C> Line<C> {\n    /// Get the total width for this text.\n    pub(crate) fn width(&self) -> usize {\n        self.0.iter().map(|text| text.content.width()).sum()\n    }\n}\n\nimpl Line<Color> {\n    /// Applies the given style to this text.\n    pub(crate) fn apply_style(&mut self, style: &TextStyle) {\n        for text in &mut self.0 {\n            text.style.merge(style);\n        }\n    }\n}\n\nimpl Line<RawColor> {\n    /// Resolve the colors in this line.\n    pub(crate) fn resolve(self, palette: &ColorPalette) -> Result<Line<Color>, UndefinedPaletteColorError> {\n        let mut output = Vec::with_capacity(self.0.len());\n        for text in self.0 {\n            let style = text.style.resolve(palette)?;\n            output.push(Text::new(text.content, style));\n        }\n        Ok(Line(output))\n    }\n}\n\nimpl<C, T: Into<Text<C>>> From<T> for Line<C> {\n    fn from(text: T) -> Self {\n        Self(vec![text.into()])\n    }\n}\n\n/// A styled piece of text.\n///\n/// This is the most granular text representation: a `String` and a style.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct Text<C = Color> {\n    pub(crate) content: String,\n    pub(crate) style: TextStyle<C>,\n}\n\nimpl<C> Default for Text<C> {\n    fn default() -> Self {\n        Self { content: Default::default(), style: TextStyle::default() }\n    }\n}\n\nimpl<C> Text<C> {\n    /// Construct a new styled text.\n    pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle<C>) -> Self {\n        Self { content: content.into(), style }\n    }\n\n    /// Get the width of this text.\n    pub(crate) fn width(&self) -> usize {\n        self.content.width()\n    }\n}\n\nimpl<C> From<String> for Text<C> {\n    fn from(text: String) -> Self {\n        Self { content: text, style: TextStyle::default() }\n    }\n}\n\nimpl<C> From<&str> for Text<C> {\n    fn from(text: &str) -> Self {\n        Self { content: text.into(), style: TextStyle::default() }\n    }\n}\n\n/// A list item.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct ListItem {\n    /// The depth of this item.\n    ///\n    /// This increases by one for every nested list level.\n    pub(crate) depth: u8,\n\n    /// The contents of this list item.\n    pub(crate) contents: Line<RawColor>,\n\n    /// The type of list item.\n    pub(crate) item_type: ListItemType,\n}\n\n/// The type of a list item.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) enum ListItemType {\n    /// A list item for an unordered list.\n    Unordered,\n\n    /// A list item for an ordered list that uses parenthesis after the list item number.\n    OrderedParens(usize),\n\n    /// A list item for an ordered list that uses a period after the list item number.\n    OrderedPeriod(usize),\n}\n\n/// A table.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct Table {\n    /// The table's header.\n    pub(crate) header: TableRow,\n\n    /// All of the rows in this table, excluding the header.\n    pub(crate) rows: Vec<TableRow>,\n}\n\nimpl Table {\n    /// gets the number of columns in this table.\n    pub(crate) fn columns(&self) -> usize {\n        self.header.0.len()\n    }\n\n    /// Iterates all the text entries in a column.\n    ///\n    /// This includes the header.\n    pub(crate) fn iter_column(&self, column: usize) -> impl Iterator<Item = &Line<RawColor>> {\n        let header_element = &self.header.0[column];\n        let row_elements = self.rows.iter().map(move |row| &row.0[column]);\n        iter::once(header_element).chain(row_elements)\n    }\n}\n\n/// A table row.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct TableRow(pub(crate) Vec<Line<RawColor>>);\n\n/// A percentage.\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct Percent(pub(crate) u8);\n\nimpl Percent {\n    pub(crate) fn as_ratio(&self) -> f64 {\n        self.0 as f64 / 100.0\n    }\n}\n\nimpl FromStr for Percent {\n    type Err = PercentParseError;\n\n    fn from_str(input: &str) -> Result<Self, Self::Err> {\n        let (prefix, suffix) = input.split_once('%').ok_or(PercentParseError::Unit)?;\n        let value: u8 = prefix.parse().map_err(|_| PercentParseError::Value)?;\n        if !(1..=100).contains(&value) {\n            return Err(PercentParseError::Value);\n        }\n        if !suffix.is_empty() {\n            return Err(PercentParseError::Trailer(suffix.into()));\n        }\n        Ok(Percent(value))\n    }\n}\n\n#[derive(thiserror::Error, Debug)]\npub enum PercentParseError {\n    #[error(\"value must be a number between 1-100\")]\n    Value,\n\n    #[error(\"no unit provided\")]\n    Unit,\n\n    #[error(\"unexpected: '{0}'\")]\n    Trailer(String),\n}\n"
  },
  {
    "path": "src/markdown/html.rs",
    "content": "use super::text_style::{Color, TextStyle};\nuse crate::theme::raw::{ParseColorError, RawColor};\nuse std::{borrow::Cow, str, str::Utf8Error};\nuse tl::Attributes;\n\npub(crate) struct HtmlParseOptions {\n    pub(crate) strict: bool,\n}\n\nimpl Default for HtmlParseOptions {\n    fn default() -> Self {\n        Self { strict: true }\n    }\n}\n\n#[derive(Default)]\npub(crate) struct HtmlParser {\n    options: HtmlParseOptions,\n}\n\nimpl HtmlParser {\n    pub(crate) fn parse(self, input: &str) -> Result<HtmlInline, ParseHtmlError> {\n        if input.starts_with(\"</\") {\n            if input.starts_with(\"</span\") {\n                return Ok(HtmlInline::CloseTag { tag: HtmlTag::Span });\n            } else if input.starts_with(\"</sup\") {\n                return Ok(HtmlInline::CloseTag { tag: HtmlTag::Sup });\n            } else {\n                return Err(ParseHtmlError::UnsupportedClosingTag(input.to_string()));\n            }\n        }\n        let dom = tl::parse(input, Default::default())?;\n        let top = dom.children().iter().next().ok_or(ParseHtmlError::NoTags)?;\n        let node = top.get(dom.parser()).expect(\"failed to get\");\n        let tag = node.as_tag().ok_or(ParseHtmlError::NoTags)?;\n        let (output_tag, base_style) = match tag.name().as_bytes() {\n            b\"span\" => (HtmlTag::Span, TextStyle::default()),\n            b\"sup\" => (HtmlTag::Sup, TextStyle::default().superscript()),\n            _ => return Err(ParseHtmlError::UnsupportedHtml),\n        };\n        let style = self.parse_attributes(tag.attributes())?;\n        Ok(HtmlInline::OpenTag { style: style.merged(&base_style), tag: output_tag })\n    }\n\n    fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle<RawColor>, ParseHtmlError> {\n        let mut style = TextStyle::default();\n        for (name, value) in attributes.iter() {\n            let value = value.unwrap_or(Cow::Borrowed(\"\"));\n            match name.as_ref() {\n                \"style\" => self.parse_css_attribute(&value, &mut style)?,\n                \"class\" => {\n                    style = style.fg_color(RawColor::ForegroundClass(value.to_string()));\n                    style = style.bg_color(RawColor::BackgroundClass(value.to_string()));\n                }\n                _ => {\n                    if self.options.strict {\n                        return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string()));\n                    }\n                }\n            }\n        }\n        Ok(style)\n    }\n\n    fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle<RawColor>) -> Result<(), ParseHtmlError> {\n        for attribute in attribute.split(';') {\n            let attribute = attribute.trim();\n            if attribute.is_empty() {\n                continue;\n            }\n            let (key, value) = attribute.split_once(':').ok_or(ParseHtmlError::NoColonInAttribute)?;\n            let key = key.trim();\n            let value = value.trim();\n            match key {\n                \"color\" => style.colors.foreground = Some(Self::parse_color(value)?),\n                \"background-color\" => style.colors.background = Some(Self::parse_color(value)?),\n                _ => {\n                    if self.options.strict {\n                        return Err(ParseHtmlError::UnsupportedCssAttribute(key.into()));\n                    }\n                }\n            }\n        }\n        Ok(())\n    }\n\n    fn parse_color(input: &str) -> Result<RawColor, ParseHtmlError> {\n        if input.starts_with('#') {\n            let color = input.strip_prefix('#').unwrap().parse()?;\n            if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) }\n        } else {\n            let color = input.parse::<RawColor>()?;\n            if matches!(color, RawColor::Color(Color::Rgb { .. })) {\n                Err(ParseHtmlError::InvalidColor(\"missing '#' in rgb color\".into()))\n            } else {\n                Ok(color)\n            }\n        }\n    }\n}\n\n#[derive(Debug, PartialEq)]\npub(crate) enum HtmlInline {\n    OpenTag { style: TextStyle<RawColor>, tag: HtmlTag },\n    CloseTag { tag: HtmlTag },\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) enum HtmlTag {\n    Span,\n    Sup,\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum ParseHtmlError {\n    #[error(\"parsing html failed: {0}\")]\n    ParsingHtml(#[from] tl::ParseError),\n\n    #[error(\"no html tags found\")]\n    NoTags,\n\n    #[error(\"non utf8 content: {0}\")]\n    NotUtf8(#[from] Utf8Error),\n\n    #[error(\"attribute has no ':'\")]\n    NoColonInAttribute,\n\n    #[error(\"invalid color: {0}\")]\n    InvalidColor(String),\n\n    #[error(\"invalid css attribute: {0}\")]\n    UnsupportedCssAttribute(String),\n\n    #[error(\"HTML can only contain span and sup tags\")]\n    UnsupportedHtml,\n\n    #[error(\"unsupported tag attribute: {0}\")]\n    UnsupportedTagAttribute(String),\n\n    #[error(\"unsupported closing tag: {0}\")]\n    UnsupportedClosingTag(String),\n}\n\nimpl From<ParseColorError> for ParseHtmlError {\n    fn from(e: ParseColorError) -> Self {\n        Self::InvalidColor(e.to_string())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use rstest::rstest;\n\n    #[test]\n    fn parse_style() {\n        let tag =\n            HtmlParser::default().parse(r#\"<span style=\"color: red; background-color: black\">\"#).expect(\"parse failed\");\n        let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!(\"not an open tag\") };\n        assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red));\n    }\n\n    #[test]\n    fn parse_sup() {\n        let tag = HtmlParser::default().parse(r#\"<sup>\"#).expect(\"parse failed\");\n        let HtmlInline::OpenTag { style, tag: HtmlTag::Sup } = tag else { panic!(\"not an open tag\") };\n        assert_eq!(style, TextStyle::default().superscript());\n    }\n\n    #[test]\n    fn parse_class() {\n        let tag = HtmlParser::default().parse(r#\"<span class=\"foo\">\"#).expect(\"parse failed\");\n        let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!(\"not an open tag\") };\n        assert_eq!(\n            style,\n            TextStyle::default()\n                .bg_color(RawColor::BackgroundClass(\"foo\".into()))\n                .fg_color(RawColor::ForegroundClass(\"foo\".into()))\n        );\n    }\n\n    #[rstest]\n    #[case::span(\"</span>\", HtmlTag::Span)]\n    #[case::sup(\"</sup>\", HtmlTag::Sup)]\n    fn parse_end_tag(#[case] input: &str, #[case] tag: HtmlTag) {\n        let inline = HtmlParser::default().parse(input).expect(\"parse failed\");\n        assert_eq!(inline, HtmlInline::CloseTag { tag });\n    }\n\n    #[rstest]\n    #[case::invalid_start_tag(\"<div>\")]\n    #[case::invalid_end_tag(\"</div>\")]\n    #[case::invalid_attribute(\"<span foo=\\\"bar\\\">\")]\n    #[case::invalid_attribute(\"<span style=\\\"bleh: 42\\\"\")]\n    #[case::invalid_color(\"<span style=\\\"color: 42\\\"\")]\n    fn parse_invalid_html(#[case] input: &str) {\n        HtmlParser::default().parse(input).expect_err(\"parse succeeded\");\n    }\n\n    #[rstest]\n    #[case::rgb(\"#ff0000\", Color::Rgb{r: 255, g: 0, b: 0})]\n    #[case::red(\"red\", Color::Red)]\n    fn parse_color(#[case] input: &str, #[case] expected: Color) {\n        let color = HtmlParser::parse_color(input).expect(\"parse failed\");\n        assert_eq!(color, expected.into());\n    }\n\n    #[rstest]\n    #[case::rgb(\"ff0000\")]\n    #[case::red(\"#red\")]\n    fn parse_invalid_color(#[case] input: &str) {\n        HtmlParser::parse_color(input).expect_err(\"parse succeeded\");\n    }\n}\n"
  },
  {
    "path": "src/markdown/mod.rs",
    "content": "pub(crate) mod elements;\npub(crate) mod html;\npub(crate) mod parse;\npub(crate) mod text;\npub(crate) mod text_style;\n"
  },
  {
    "path": "src/markdown/parse.rs",
    "content": "use super::{\n    elements::{Line, ListItem, ListItemType, MarkdownElement, SourcePosition, Table, TableRow, Text},\n    html::{HtmlInline, HtmlParser, ParseHtmlError},\n    text_style::TextStyle,\n};\nuse crate::{markdown::html::HtmlTag, theme::raw::RawColor};\nuse comrak::{\n    Arena, Options,\n    arena_tree::Node,\n    format_commonmark,\n    nodes::{\n        Ast, AstNode, ListDelimType, ListType, NodeAlert, NodeCodeBlock, NodeFootnoteDefinition, NodeHeading,\n        NodeHtmlBlock, NodeList, NodeValue, Sourcepos,\n    },\n    parse_document,\n};\nuse std::{\n    cell::RefCell,\n    fmt::{self, Debug, Display},\n    mem,\n};\n\n/// The result of parsing a markdown file.\npub(crate) type ParseResult<T> = Result<T, ParseError>;\n\nstruct ParserOptions(comrak::Options<'static>);\n\nimpl Default for ParserOptions {\n    fn default() -> Self {\n        let mut options = Options::default();\n        options.extension.front_matter_delimiter = Some(\"---\".into());\n        options.extension.table = true;\n        options.extension.strikethrough = true;\n        options.extension.multiline_block_quotes = true;\n        options.extension.alerts = true;\n        options.extension.wikilinks_title_before_pipe = true;\n        options.extension.superscript = true;\n        options.extension.footnotes = true;\n        options.parse.leave_footnote_definitions = true;\n        Self(options)\n    }\n}\n\n/// A markdown parser.\n///\n/// This takes the contents of a markdown file and parses it into a list of [MarkdownElement].\npub struct MarkdownParser<'a> {\n    arena: &'a Arena<'a>,\n    options: comrak::Options<'static>,\n}\n\nimpl<'a> MarkdownParser<'a> {\n    /// Construct a new markdown parser.\n    pub fn new(arena: &'a Arena<'a>) -> Self {\n        Self { arena, options: ParserOptions::default().0 }\n    }\n\n    /// Parse the contents of a markdown file.\n    pub(crate) fn parse(&self, contents: &str) -> ParseResult<Vec<MarkdownElement>> {\n        let node = parse_document(self.arena, contents, &self.options);\n        let mut elements = Vec::new();\n        for node in node.children() {\n            let parsed_elements = self.parse_node(node).map_err(|e| ParseError::new(e.kind, e.sourcepos))?;\n            elements.extend(parsed_elements);\n        }\n        Ok(elements)\n    }\n\n    /// Parse inlines in a markdown input.\n    pub(crate) fn parse_inlines(&self, line: &str) -> Result<Line<RawColor>, ParseInlinesError> {\n        let node = parse_document(self.arena, line, &self.options);\n        if node.children().count() == 0 {\n            return Ok(Default::default());\n        }\n        if node.children().count() > 1 {\n            return Err(ParseInlinesError(\"inline must be simple text\".into()));\n        }\n        let node = node.first_child().expect(\"must have one child\");\n        let data = node.data.borrow();\n        let NodeValue::Paragraph = &data.value else {\n            return Err(ParseInlinesError(\"inline must be simple text\".into()));\n        };\n        let parser = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No);\n        let inlines = parser.parse(node).map_err(|e| ParseInlinesError(e.to_string()))?;\n        let mut output = Line::default();\n        for inline in inlines {\n            match inline {\n                Inline::Text(line) => {\n                    output.0.extend(line.0);\n                }\n                Inline::Image { .. } => return Err(ParseInlinesError(\"images not supported\".into())),\n                Inline::LineBreak => return Err(ParseInlinesError(\"line breaks not supported\".into())),\n            };\n        }\n        Ok(output)\n    }\n\n    fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {\n        let data = node.data.borrow();\n        let element = match &data.value {\n            // Paragraphs are the only ones that can actually yield more than one.\n            NodeValue::Paragraph => return self.parse_paragraph(node),\n            NodeValue::FrontMatter(contents) => Self::parse_front_matter(contents)?,\n            NodeValue::Heading(heading) => self.parse_heading(heading, node)?,\n            NodeValue::List(list) => {\n                let items = self.parse_list(node, list.marker_offset as u8 / 2)?;\n                MarkdownElement::List(items)\n            }\n            NodeValue::Table(_) => self.parse_table(node)?,\n            NodeValue::CodeBlock(block) => Self::parse_code_block(block, data.sourcepos)?,\n            NodeValue::ThematicBreak => MarkdownElement::ThematicBreak,\n            NodeValue::HtmlBlock(block) => self.parse_html_block(block, data.sourcepos)?,\n            NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => self.parse_block_quote(node)?,\n            NodeValue::Alert(alert) => self.parse_alert(alert, node)?,\n            NodeValue::FootnoteDefinition(definition) => self.parse_footnote_definition(definition, node)?,\n            other => return Err(ParseErrorKind::UnsupportedElement(other.identifier()).with_sourcepos(data.sourcepos)),\n        };\n        Ok(vec![element])\n    }\n\n    fn parse_front_matter(contents: &str) -> ParseResult<MarkdownElement> {\n        // Remove leading and trailing delimiters before parsing. This is quite poopy but hey, it\n        // works.\n        let contents = contents.strip_prefix(\"---\\n\").unwrap_or(contents);\n        let contents = contents.strip_prefix(\"---\\r\\n\").unwrap_or(contents);\n        let contents = contents.strip_suffix(\"---\\n\").unwrap_or(contents);\n        let contents = contents.strip_suffix(\"---\\r\\n\").unwrap_or(contents);\n        let contents = contents.strip_suffix(\"---\\n\\n\").unwrap_or(contents);\n        let contents = contents.strip_suffix(\"---\\r\\n\\r\\n\").unwrap_or(contents);\n        Ok(MarkdownElement::FrontMatter(contents.into()))\n    }\n\n    fn parse_html_block(&self, block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult<MarkdownElement> {\n        let block = block.literal.trim();\n        let start_tag = \"<!--\";\n        let end_tag = \"-->\";\n        if !block.starts_with(start_tag) || !block.ends_with(end_tag) {\n            return Err(ParseErrorKind::UnsupportedElement(\"html block\").with_sourcepos(sourcepos));\n        }\n        let block = &block[start_tag.len()..];\n        let block = &block[0..block.len() - end_tag.len()];\n        Ok(MarkdownElement::Comment { comment: block.into(), source_position: sourcepos.into() })\n    }\n\n    fn parse_block_quote(&self, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {\n        let mut lines = Vec::new();\n        let inlines = InlinesParser::new(self.arena, SoftBreak::Newline, StringifyImages::Yes).parse(node)?;\n        for inline in inlines {\n            match inline {\n                Inline::Text(text) => lines.push(text),\n                Inline::LineBreak => lines.push(Line::from(\"\")),\n                Inline::Image { .. } => {}\n            }\n        }\n        if lines.last() == Some(&Line::<RawColor>::from(\"\")) {\n            lines.pop();\n        }\n        Ok(MarkdownElement::BlockQuote(lines))\n    }\n\n    fn parse_code_block(block: &NodeCodeBlock, sourcepos: Sourcepos) -> ParseResult<MarkdownElement> {\n        if !block.fenced {\n            return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos));\n        }\n        Ok(MarkdownElement::Snippet {\n            info: block.info.clone(),\n            code: block.literal.clone(),\n            source_position: sourcepos.into(),\n        })\n    }\n\n    fn parse_alert(&self, alert: &NodeAlert, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {\n        let MarkdownElement::BlockQuote(lines) = self.parse_block_quote(node)? else { panic!(\"not a block quote\") };\n        Ok(MarkdownElement::Alert { alert_type: alert.alert_type, title: alert.title.clone(), lines })\n    }\n\n    fn parse_footnote_definition(\n        &self,\n        definition: &NodeFootnoteDefinition,\n        node: &'a AstNode<'a>,\n    ) -> ParseResult<MarkdownElement> {\n        let mut line = vec![Text::new(definition.name.clone(), TextStyle::default().superscript())];\n        let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::Yes).parse(node)?;\n        for inline in inlines {\n            match inline {\n                Inline::Text(text) => line.extend(text.0),\n                Inline::LineBreak | Inline::Image { .. } => {}\n            }\n        }\n        Ok(MarkdownElement::Footnote(Line(line)))\n    }\n\n    fn parse_heading(&self, heading: &NodeHeading, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {\n        if heading.setext {\n            let text = self.parse_exheading(node)?;\n            Ok(MarkdownElement::SetexHeading { text })\n        } else {\n            let text = self.parse_text(node)?;\n            Ok(MarkdownElement::Heading { text, level: heading.level })\n        }\n    }\n\n    fn parse_paragraph(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {\n        let mut elements = Vec::new();\n        let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;\n        let mut paragraph_elements = Vec::new();\n        for inline in inlines {\n            match inline {\n                Inline::Text(text) => paragraph_elements.push(text),\n                Inline::LineBreak => (),\n                Inline::Image { path, title } => {\n                    if !paragraph_elements.is_empty() {\n                        elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements)));\n                    }\n                    elements.push(MarkdownElement::Image {\n                        path: path.into(),\n                        title,\n                        source_position: node.data.borrow().sourcepos.into(),\n                    });\n                }\n            }\n        }\n        if !paragraph_elements.is_empty() {\n            elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements)));\n        }\n        Ok(elements)\n    }\n\n    fn parse_exheading(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<Line<RawColor>>> {\n        let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;\n        let mut lines = Vec::new();\n        let mut chunks = Vec::new();\n        for inline in inlines {\n            match inline {\n                Inline::Text(text) => chunks.extend(text.0),\n                Inline::LineBreak => {\n                    lines.push(Line(chunks));\n                    chunks = Vec::new();\n                }\n                other => {\n                    return Err(ParseErrorKind::UnsupportedStructure { container: \"text\", element: other.kind() }\n                        .with_sourcepos(node.data.borrow().sourcepos));\n                }\n            };\n        }\n        lines.push(Line(chunks));\n        Ok(lines)\n    }\n\n    fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<Line<RawColor>> {\n        let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;\n        let mut chunks = Vec::new();\n        for inline in inlines {\n            match inline {\n                Inline::Text(text) => chunks.extend(text.0),\n                other => {\n                    return Err(ParseErrorKind::UnsupportedStructure { container: \"text\", element: other.kind() }\n                        .with_sourcepos(node.data.borrow().sourcepos));\n                }\n            };\n        }\n        Ok(Line(chunks))\n    }\n\n    fn parse_list(&self, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {\n        let mut elements = Vec::new();\n        for node in root.children() {\n            let data = node.data.borrow();\n            match &data.value {\n                NodeValue::Item(item) => {\n                    elements.extend(self.parse_list_item(item, node, depth)?);\n                }\n                other => {\n                    return Err(ParseErrorKind::UnsupportedStructure {\n                        container: \"list\",\n                        element: other.identifier(),\n                    }\n                    .with_sourcepos(data.sourcepos));\n                }\n            };\n        }\n        Ok(elements)\n    }\n\n    fn parse_list_item(&self, item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {\n        let item_type = match (item.list_type, item.delimiter) {\n            (ListType::Bullet, _) => ListItemType::Unordered,\n            (ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens(item.start),\n            (ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod(item.start),\n        };\n        let mut elements = Vec::new();\n        for node in root.children() {\n            let data = node.data.borrow();\n            match &data.value {\n                NodeValue::Paragraph => {\n                    let contents = self.parse_text(node)?;\n                    elements.push(ListItem { contents, depth, item_type: item_type.clone() });\n                }\n                NodeValue::List(_) => {\n                    elements.extend(self.parse_list(node, depth + 1)?);\n                }\n                other => {\n                    return Err(ParseErrorKind::UnsupportedStructure {\n                        container: \"list\",\n                        element: other.identifier(),\n                    }\n                    .with_sourcepos(data.sourcepos));\n                }\n            }\n        }\n        Ok(elements)\n    }\n\n    fn parse_table(&self, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {\n        let mut header = TableRow(Vec::new());\n        let mut rows = Vec::new();\n        for node in node.children() {\n            let data = node.data.borrow();\n            let NodeValue::TableRow(_) = &data.value else {\n                return Err(ParseErrorKind::UnsupportedStructure {\n                    container: \"table\",\n                    element: data.value.identifier(),\n                }\n                .with_sourcepos(data.sourcepos));\n            };\n            let row = self.parse_table_row(node)?;\n            if header.0.is_empty() {\n                header = row;\n            } else {\n                rows.push(row)\n            }\n        }\n        Ok(MarkdownElement::Table(Table { header, rows }))\n    }\n\n    fn parse_table_row(&self, node: &'a AstNode<'a>) -> ParseResult<TableRow> {\n        let mut cells = Vec::new();\n        for node in node.children() {\n            let data = node.data.borrow();\n            let NodeValue::TableCell = &data.value else {\n                return Err(ParseErrorKind::UnsupportedStructure {\n                    container: \"table\",\n                    element: data.value.identifier(),\n                }\n                .with_sourcepos(data.sourcepos));\n            };\n            let text = self.parse_text(node)?;\n            cells.push(text);\n        }\n        Ok(TableRow(cells))\n    }\n}\n\nenum SoftBreak {\n    Newline,\n    Space,\n}\n\nenum StringifyImages {\n    Yes,\n    No,\n}\n\nstruct InlinesParser<'a> {\n    inlines: Vec<Inline>,\n    pending_text: Vec<Text<RawColor>>,\n    arena: &'a Arena<'a>,\n    soft_break: SoftBreak,\n    stringify_images: StringifyImages,\n}\n\nimpl<'a> InlinesParser<'a> {\n    fn new(arena: &'a Arena<'a>, soft_break: SoftBreak, stringify_images: StringifyImages) -> Self {\n        Self { inlines: Vec::new(), pending_text: Vec::new(), arena, soft_break, stringify_images }\n    }\n\n    fn parse(mut self, node: &'a AstNode<'a>) -> ParseResult<Vec<Inline>> {\n        self.process_children(node, TextStyle::default())?;\n        self.store_pending_text();\n        Ok(self.inlines)\n    }\n\n    fn store_pending_text(&mut self) {\n        let chunks = mem::take(&mut self.pending_text);\n        if !chunks.is_empty() {\n            self.inlines.push(Inline::Text(Line(chunks)));\n        }\n    }\n\n    fn process_node(\n        &mut self,\n        node: &'a AstNode<'a>,\n        parent: &'a AstNode<'a>,\n        style: TextStyle<RawColor>,\n    ) -> ParseResult<Option<HtmlStyle>> {\n        let data = node.data.borrow();\n        match &data.value {\n            NodeValue::Text(text) => {\n                self.pending_text.push(Text::new(text.clone(), style));\n            }\n            NodeValue::Code(code) => {\n                self.pending_text.push(Text::new(code.literal.clone(), TextStyle::default().code()));\n            }\n            NodeValue::Strong => self.process_children(node, style.bold())?,\n            NodeValue::Emph => self.process_children(node, style.italics())?,\n            NodeValue::Strikethrough => self.process_children(node, style.strikethrough())?,\n            NodeValue::Superscript => self.process_children(node, style.superscript())?,\n            NodeValue::SoftBreak => {\n                match self.soft_break {\n                    SoftBreak::Newline => {\n                        self.store_pending_text();\n                    }\n                    SoftBreak::Space => self.pending_text.push(Text::new(\" \", style)),\n                };\n            }\n            NodeValue::Link(link) => {\n                let has_label = node.first_child().is_some();\n                if has_label {\n                    self.process_children(node, TextStyle::default().link_label())?;\n                    self.pending_text.push(Text::from(\" (\"));\n                }\n                self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url()));\n                if !link.title.is_empty() {\n                    self.pending_text.push(Text::from(\" \\\"\"));\n                    self.pending_text.push(Text::new(link.title.clone(), TextStyle::default().link_title()));\n                    self.pending_text.push(Text::from(\"\\\"\"));\n                }\n                if has_label {\n                    self.pending_text.push(Text::from(\")\"));\n                }\n            }\n            NodeValue::WikiLink(link) => {\n                self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url()));\n            }\n            NodeValue::LineBreak => {\n                self.store_pending_text();\n                self.inlines.push(Inline::LineBreak);\n            }\n            NodeValue::Image(link) => {\n                if link.url.starts_with(\"http://\") || link.url.starts_with(\"https://\") {\n                    return Err(ParseErrorKind::ExternalImageUrl.with_sourcepos(data.sourcepos));\n                }\n                if matches!(self.stringify_images, StringifyImages::Yes) {\n                    self.pending_text.push(Text::from(format!(\"![{}]({})\", link.title, link.url)));\n                    return Ok(None);\n                }\n                self.store_pending_text();\n\n                // The image \"title\" contains inlines so we create a dummy paragraph node that\n                // contains it so we can flatten it back into text. We could walk the tree but this\n                // is good enough.\n                let mut buffer = String::new();\n                let paragraph =\n                    self.arena.alloc(Node::new(RefCell::new(Ast::new(NodeValue::Paragraph, data.sourcepos.start))));\n                for child in node.children() {\n                    paragraph.append(child);\n                }\n                format_commonmark(paragraph, &ParserOptions::default().0, &mut buffer)\n                    .map_err(|e| ParseErrorKind::Internal(e.to_string()).with_sourcepos(data.sourcepos))?;\n\n                let title = buffer.trim_end().to_string();\n                self.inlines.push(Inline::Image { path: link.url.clone(), title });\n            }\n            NodeValue::Paragraph => {\n                self.process_children(node, style)?;\n                self.store_pending_text();\n                if matches!(parent.data.borrow().value, NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_)) {\n                    self.inlines.push(Inline::LineBreak);\n                }\n            }\n            NodeValue::List(_) => {\n                self.process_children(node, style)?;\n                self.store_pending_text();\n                self.inlines.push(Inline::LineBreak);\n            }\n            NodeValue::Item(item) => {\n                match (item.list_type, item.delimiter) {\n                    (ListType::Bullet, _) => self.pending_text.push(Text::from(\"* \")),\n                    (ListType::Ordered, ListDelimType::Period) => {\n                        self.pending_text.push(Text::from(format!(\"{}. \", item.start)))\n                    }\n                    (ListType::Ordered, ListDelimType::Paren) => {\n                        self.pending_text.push(Text::from(format!(\"{}) \", item.start)))\n                    }\n                };\n                self.process_children(node, style)?;\n            }\n            NodeValue::HtmlInline(html) => {\n                let html_inline = HtmlParser::default()\n                    .parse(html)\n                    .map_err(|e| ParseErrorKind::InvalidHtml(e).with_sourcepos(data.sourcepos))?;\n                match html_inline {\n                    HtmlInline::OpenTag { style, tag } => return Ok(Some(HtmlStyle::Add(style, tag))),\n                    HtmlInline::CloseTag { tag } => return Ok(Some(HtmlStyle::Remove(tag))),\n                };\n            }\n            NodeValue::FootnoteReference(reference) => {\n                // Keep only colors here, we don't care about e.g. italics footnotes.\n                let style = TextStyle::colored(style.colors).superscript();\n                self.pending_text.push(Text::new(reference.name.clone(), style));\n            }\n            other => {\n                return Err(ParseErrorKind::UnsupportedStructure { container: \"text\", element: other.identifier() }\n                    .with_sourcepos(data.sourcepos));\n            }\n        };\n        Ok(None)\n    }\n\n    fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle<RawColor>) -> ParseResult<()> {\n        let mut html_styles = Vec::new();\n        let mut style = base_style.clone();\n        for node in root.children() {\n            if let Some(html_style) = self.process_node(node, root, style.clone())? {\n                match html_style {\n                    HtmlStyle::Add(style, tag) => html_styles.push((style, tag)),\n                    HtmlStyle::Remove(tag) => {\n                        let popped_tag = html_styles\n                            .pop()\n                            .ok_or_else(|| ParseErrorKind::NoOpenTag.with_sourcepos(node.data.borrow().sourcepos))?\n                            .1;\n                        if popped_tag != tag {\n                            return Err(ParseErrorKind::CloseTagMismatch.with_sourcepos(node.data.borrow().sourcepos));\n                        }\n                    }\n                };\n                style = base_style.clone();\n                for html_style in html_styles.iter().rev() {\n                    style.merge(&html_style.0);\n                }\n            }\n        }\n        Ok(())\n    }\n}\n\nenum HtmlStyle {\n    Add(TextStyle<RawColor>, HtmlTag),\n    Remove(HtmlTag),\n}\n\nenum Inline {\n    Text(Line<RawColor>),\n    Image { path: String, title: String },\n    LineBreak,\n}\n\nimpl Inline {\n    fn kind(&self) -> &'static str {\n        match self {\n            Self::Text(_) => \"text\",\n            Self::Image { .. } => \"image\",\n            Self::LineBreak => \"line break\",\n        }\n    }\n}\n\n/// A parsing error.\n#[derive(thiserror::Error, Debug)]\npub struct ParseError {\n    /// The kind of error.\n    pub(crate) kind: ParseErrorKind,\n\n    /// The position in the source file this error originated from.\n    pub(crate) sourcepos: SourcePosition,\n}\n\nimpl Display for ParseError {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"parse error at {}: {}\", self.sourcepos, self.kind)\n    }\n}\n\nimpl ParseError {\n    fn new<S: Into<SourcePosition>>(kind: ParseErrorKind, sourcepos: S) -> Self {\n        Self { kind, sourcepos: sourcepos.into() }\n    }\n}\n\n/// The kind of error.\n#[derive(Debug)]\npub(crate) enum ParseErrorKind {\n    /// We don't support parsing this element.\n    UnsupportedElement(&'static str),\n\n    /// We don't support parsing an element in a specific container.\n    UnsupportedStructure { container: &'static str, element: &'static str },\n\n    /// We don't support unfenced code blocks.\n    UnfencedCodeBlock,\n\n    /// We don't support external URLs in images.\n    ExternalImageUrl,\n\n    /// Invalid HTML was found.\n    InvalidHtml(ParseHtmlError),\n\n    /// HTML tag closed without having an open one.\n    NoOpenTag,\n\n    /// HTML tag closed for a different opened one.\n    CloseTagMismatch,\n\n    /// An internal parsing error.\n    Internal(String),\n}\n\nimpl Display for ParseErrorKind {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::UnsupportedElement(element) => write!(f, \"unsupported element: {element}\"),\n            Self::UnsupportedStructure { container, element } => {\n                write!(f, \"unsupported structure in {container}: {element}\")\n            }\n            Self::ExternalImageUrl => write!(f, \"external URLs are not supported in image tags\"),\n            Self::UnfencedCodeBlock => write!(f, \"only fenced code blocks are supported\"),\n            Self::InvalidHtml(inner) => write!(f, \"invalid HTML: {inner}\"),\n            Self::NoOpenTag => write!(f, \"closing tag without an open one\"),\n            Self::CloseTagMismatch => write!(f, \"closing tag does not match last open one\"),\n            Self::Internal(message) => write!(f, \"internal error: {message}\"),\n        }\n    }\n}\n\nimpl ParseErrorKind {\n    fn with_sourcepos<S: Into<SourcePosition>>(self, sourcepos: S) -> ParseError {\n        ParseError::new(self, sourcepos)\n    }\n}\n\ntrait Identifier {\n    fn identifier(&self) -> &'static str;\n}\n\nimpl Identifier for NodeValue {\n    fn identifier(&self) -> &'static str {\n        match self {\n            NodeValue::Document => \"document\",\n            NodeValue::FrontMatter(_) => \"front matter\",\n            NodeValue::BlockQuote => \"block quote\",\n            NodeValue::List(_) => \"list\",\n            NodeValue::Item(_) => \"item\",\n            NodeValue::DescriptionList => \"description list\",\n            NodeValue::DescriptionItem(_) => \"description item\",\n            NodeValue::DescriptionTerm => \"description term\",\n            NodeValue::DescriptionDetails => \"description details\",\n            NodeValue::CodeBlock(_) => \"code block\",\n            NodeValue::HtmlBlock(_) => \"html block\",\n            NodeValue::Paragraph => \"paragraph\",\n            NodeValue::Heading(_) => \"heading\",\n            NodeValue::ThematicBreak => \"thematic break\",\n            NodeValue::FootnoteDefinition(_) => \"footnote definition\",\n            NodeValue::Table(_) => \"table\",\n            NodeValue::TableRow(_) => \"table row\",\n            NodeValue::TableCell => \"table cell\",\n            NodeValue::Text(_) => \"text\",\n            NodeValue::TaskItem(_) => \"task item\",\n            NodeValue::SoftBreak => \"soft break\",\n            NodeValue::LineBreak => \"line break\",\n            NodeValue::Code(_) => \"code\",\n            NodeValue::HtmlInline(_) => \"inline html\",\n            NodeValue::Emph => \"emph\",\n            NodeValue::Strong => \"strong\",\n            NodeValue::Strikethrough => \"strikethrough\",\n            NodeValue::Superscript => \"superscript\",\n            NodeValue::Link(_) => \"link\",\n            NodeValue::Image(_) => \"image\",\n            NodeValue::FootnoteReference(_) => \"footnote reference\",\n            NodeValue::MultilineBlockQuote(_) => \"multiline block quote\",\n            NodeValue::Math(_) => \"math\",\n            NodeValue::Escaped => \"escaped\",\n            NodeValue::WikiLink(_) => \"wiki link\",\n            NodeValue::Underline => \"underline\",\n            NodeValue::SpoileredText => \"spoilered text\",\n            NodeValue::EscapedTag(_) => \"escaped tag\",\n            NodeValue::Subscript => \"subscript\",\n            NodeValue::Raw(_) => \"raw\",\n            NodeValue::Alert(_) => \"alert\",\n            NodeValue::Subtext => \"subtext\",\n            NodeValue::Highlight => \"highlight\",\n        }\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\n#[error(\"invalid markdown line: {0}\")]\npub(crate) struct ParseInlinesError(String);\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::markdown::text_style::Color;\n    use rstest::rstest;\n    use std::path::Path;\n\n    fn try_parse(input: &str) -> Result<Vec<MarkdownElement>, ParseError> {\n        let arena = Arena::new();\n        MarkdownParser::new(&arena).parse(input)\n    }\n\n    fn parse_single(input: &str) -> MarkdownElement {\n        let elements = try_parse(input).expect(\"failed to parse\");\n        assert_eq!(elements.len(), 1, \"more than one element: {elements:?}\");\n        elements.into_iter().next().unwrap()\n    }\n\n    fn parse_all(input: &str) -> Vec<MarkdownElement> {\n        try_parse(input).expect(\"parsing failed\")\n    }\n\n    #[test]\n    fn slide_metadata() {\n        let parsed = parse_single(\n            r\"---\nbeep\nboop\n---\n\",\n        );\n        let MarkdownElement::FrontMatter(contents) = parsed else { panic!(\"not a front matter: {parsed:?}\") };\n        assert_eq!(contents, \"beep\\nboop\\n\");\n    }\n\n    #[test]\n    fn paragraph() {\n        let parsed =\n            parse_single(\"some **bold text**, _italics_, *italics*, **nested _italics_**, ~strikethrough~, ^super^\");\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks = vec![\n            Text::from(\"some \"),\n            Text::new(\"bold text\", TextStyle::default().bold()),\n            Text::from(\", \"),\n            Text::new(\"italics\", TextStyle::default().italics()),\n            Text::from(\", \"),\n            Text::new(\"italics\", TextStyle::default().italics()),\n            Text::from(\", \"),\n            Text::new(\"nested \", TextStyle::default().bold()),\n            Text::new(\"italics\", TextStyle::default().italics().bold()),\n            Text::from(\", \"),\n            Text::new(\"strikethrough\", TextStyle::default().strikethrough()),\n            Text::from(\", \"),\n            Text::new(\"super\", TextStyle::default().superscript()),\n        ];\n\n        let expected_elements = &[Line(expected_chunks)];\n        assert_eq!(elements, expected_elements);\n    }\n\n    #[test]\n    fn html_inlines() {\n        let parsed = parse_single(\n            \"hi<span style=\\\"color: red\\\">red<span style=\\\"background-color: blue\\\">blue<span style=\\\"color: yellow\\\">yell<sup>ow</sup></span></span></span>\",\n        );\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks = vec![\n            Text::from(\"hi\"),\n            Text::new(\"red\", TextStyle::default().fg_color(Color::Red)),\n            Text::new(\"blue\", TextStyle::default().fg_color(Color::Red).bg_color(Color::Blue)),\n            Text::new(\"yell\", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue)),\n            Text::new(\"ow\", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue).superscript()),\n        ];\n\n        let expected_elements = &[Line(expected_chunks)];\n        assert_eq!(elements, expected_elements);\n    }\n\n    #[rstest]\n    #[case::closed_no_open(\"<span></span></sup>\", ParseErrorKind::NoOpenTag)]\n    #[case::mismatch_open1(\"<span></sup>\", ParseErrorKind::CloseTagMismatch)]\n    #[case::mismatch_open2(\"<span><span></sup></span>\", ParseErrorKind::CloseTagMismatch)]\n    #[case::mismatch_open3(\"<span><span></span></sup>\", ParseErrorKind::CloseTagMismatch)]\n    fn invalid_html_inlines(#[case] input: &str, #[case] expected_error: ParseErrorKind) {\n        let ParseError { kind, .. } = try_parse(input).expect_err(\"no failure\");\n        assert_eq!(kind.to_string(), expected_error.to_string());\n    }\n\n    #[test]\n    fn link_wo_label_wo_title() {\n        let parsed = parse_single(\"my [](https://example.com)\");\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks =\n            vec![Text::from(\"my \"), Text::new(\"https://example.com\", TextStyle::default().link_url())];\n\n        let expected_elements = &[Line(expected_chunks)];\n        assert_eq!(elements, expected_elements);\n    }\n\n    #[test]\n    fn link_w_label_wo_title() {\n        let parsed = parse_single(\"my [website](https://example.com)\");\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks = vec![\n            Text::from(\"my \"),\n            Text::new(\"website\", TextStyle::default().link_label()),\n            Text::from(\" (\"),\n            Text::new(\"https://example.com\", TextStyle::default().link_url()),\n            Text::from(\")\"),\n        ];\n\n        let expected_elements = &[Line(expected_chunks)];\n        assert_eq!(elements, expected_elements);\n    }\n\n    #[test]\n    fn link_wo_label_w_title() {\n        let parsed = parse_single(\"my [](https://example.com \\\"Example\\\")\");\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks = vec![\n            Text::from(\"my \"),\n            Text::new(\"https://example.com\", TextStyle::default().link_url()),\n            Text::from(\" \\\"\"),\n            Text::new(\"Example\", TextStyle::default().link_title()),\n            Text::from(\"\\\"\"),\n        ];\n\n        let expected_elements = &[Line(expected_chunks)];\n        assert_eq!(elements, expected_elements);\n    }\n\n    #[test]\n    fn link_w_label_w_title() {\n        let parsed = parse_single(\"my [website](https://example.com \\\"Example\\\")\");\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks = vec![\n            Text::from(\"my \"),\n            Text::new(\"website\", TextStyle::default().link_label()),\n            Text::from(\" (\"),\n            Text::new(\"https://example.com\", TextStyle::default().link_url()),\n            Text::from(\" \\\"\"),\n            Text::new(\"Example\", TextStyle::default().link_title()),\n            Text::from(\"\\\"\"),\n            Text::from(\")\"),\n        ];\n\n        let expected_elements = &[Line(expected_chunks)];\n        assert_eq!(elements, expected_elements);\n    }\n\n    #[test]\n    fn wikilink_wo_title() {\n        let parsed = parse_single(\"[[https://example.com]]\");\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks = vec![Text::new(\"https://example.com\", TextStyle::default().link_url())];\n\n        let expected_elements = &[Line(expected_chunks)];\n        assert_eq!(elements, expected_elements);\n    }\n\n    #[test]\n    fn image() {\n        let parsed = parse_single(\"![](potato.png)\");\n        let MarkdownElement::Image { path, .. } = parsed else { panic!(\"not an image: {parsed:?}\") };\n        assert_eq!(path, Path::new(\"potato.png\"));\n    }\n\n    #[test]\n    fn image_within_text() {\n        let parsed = parse_all(\n            r\"\npicture of potato: ![](potato.png)\n\",\n        );\n        assert_eq!(parsed.len(), 2);\n    }\n\n    #[test]\n    fn external_image() {\n        let result = try_parse(\"![](https://example.com/potato.png)\");\n        let Err(ParseError { kind: ParseErrorKind::ExternalImageUrl, .. }) = result else {\n            panic!(\"not the expected error: {result:?}\")\n        };\n    }\n\n    #[test]\n    fn setex_heading() {\n        let parsed = parse_single(\n            r\"\nTitle\n===\n\",\n        );\n        let MarkdownElement::SetexHeading { text } = parsed else { panic!(\"not a slide title: {parsed:?}\") };\n        let expected_chunks = [Text::from(\"Title\")];\n        assert_eq!(text[0].0, expected_chunks);\n    }\n\n    #[test]\n    fn heading() {\n        let parsed = parse_single(\"# Title **with bold**\");\n        let MarkdownElement::Heading { text, level } = parsed else { panic!(\"not a heading: {parsed:?}\") };\n        let expected_chunks = vec![Text::from(\"Title \"), Text::new(\"with bold\", TextStyle::default().bold())];\n\n        assert_eq!(level, 1);\n        assert_eq!(text.0, expected_chunks);\n    }\n\n    #[test]\n    fn unordered_list() {\n        let parsed = parse_single(\n            r\"\n * One\n    * Sub1\n    * Sub2\n * Two\n * Three\",\n        );\n        let MarkdownElement::List(items) = parsed else { panic!(\"not a list: {parsed:?}\") };\n        let mut items = items.into_iter();\n        let mut next = || items.next().expect(\"list ended prematurely\");\n        assert_eq!(next().depth, 0);\n        assert_eq!(next().depth, 1);\n        assert_eq!(next().depth, 1);\n        assert_eq!(next().depth, 0);\n        assert_eq!(next().depth, 0);\n    }\n\n    #[test]\n    fn ordered_list_starting_non_one() {\n        let parsed = parse_single(\n            r\"\n 4. One\n    1. Sub1\n    2. Sub2\n 5. Two\n 6. Three\",\n        );\n        let MarkdownElement::List(items) = parsed else { panic!(\"not a list: {parsed:?}\") };\n        let mut items = items.into_iter();\n        let mut next = || items.next().expect(\"list ended prematurely\");\n        assert_eq!(next().item_type, ListItemType::OrderedPeriod(4));\n        assert_eq!(next().item_type, ListItemType::OrderedPeriod(1));\n        assert_eq!(next().item_type, ListItemType::OrderedPeriod(2));\n        assert_eq!(next().item_type, ListItemType::OrderedPeriod(5));\n        assert_eq!(next().item_type, ListItemType::OrderedPeriod(6));\n    }\n\n    #[test]\n    fn line_breaks() {\n        let parsed = parse_all(\n            r\"\nsome text\nwith line breaks  \na hard break\n\nanother\",\n        );\n        // note that \"with line breaks\" also has a hard break (\"  \") at the end, hence the 3.\n        assert_eq!(parsed.len(), 2);\n\n        let MarkdownElement::Paragraph(elements) = &parsed[0] else { panic!(\"not a line break: {parsed:?}\") };\n        assert_eq!(elements.len(), 2);\n\n        let expected_chunks = &[Text::from(\"some text\"), Text::from(\" \"), Text::from(\"with line breaks\")];\n        let text = &elements[0];\n        assert_eq!(text.0, expected_chunks);\n    }\n\n    #[test]\n    fn code_block() {\n        let parsed = parse_single(\n            r\"\n```rust +exec\nlet q = 42;\n````\n\",\n        );\n        let MarkdownElement::Snippet { info, code, .. } = parsed else { panic!(\"not a code block: {parsed:?}\") };\n        assert_eq!(info, \"rust +exec\");\n        assert_eq!(code, \"let q = 42;\\n\");\n    }\n\n    #[test]\n    fn inline_code() {\n        let parsed = parse_single(\"some `inline code`\");\n        let MarkdownElement::Paragraph(elements) = parsed else { panic!(\"not a paragraph: {parsed:?}\") };\n        let expected_chunks = &[Text::from(\"some \"), Text::new(\"inline code\", TextStyle::default().code())];\n        assert_eq!(elements.len(), 1);\n\n        let text = &elements[0];\n        assert_eq!(text.0, expected_chunks);\n    }\n\n    #[test]\n    fn table() {\n        let parsed = parse_single(\n            r\"\n| Name | Taste |\n| ------ | ------ |\n| Potato | Great |\n| Carrot | Yuck |\n\",\n        );\n        let MarkdownElement::Table(Table { header, rows }) = parsed else { panic!(\"not a table: {parsed:?}\") };\n        assert_eq!(header.0.len(), 2);\n        assert_eq!(rows.len(), 2);\n        assert_eq!(rows[0].0.len(), 2);\n        assert_eq!(rows[1].0.len(), 2);\n    }\n\n    #[test]\n    fn comment() {\n        let parsed = parse_single(\n            r\"\n<!-- foo -->\n\",\n        );\n        let MarkdownElement::Comment { comment, .. } = parsed else { panic!(\"not a comment: {parsed:?}\") };\n        assert_eq!(comment, \" foo \");\n    }\n\n    #[test]\n    fn list_comment_in_between() {\n        let parsed = parse_all(\n            r\"\n* A\n<!-- foo -->\n  * B\n\",\n        );\n        assert_eq!(parsed.len(), 3);\n        let MarkdownElement::List(items) = &parsed[2] else { panic!(\"not a list item: {parsed:?}\") };\n        assert_eq!(items[0].depth, 1);\n    }\n\n    #[test]\n    fn block_quote() {\n        let parsed = parse_single(\n            r#\"\n> foo **is not** bar\n> ![](hehe.png) test ![](potato.png)\n> \n> * a\n> * b\n>\n> 1. a\n> 2. b\n>\n> 1) a\n> 2) b\n\"#,\n        );\n        let MarkdownElement::BlockQuote(lines) = parsed else { panic!(\"not a block quote: {parsed:?}\") };\n        assert_eq!(lines.len(), 11);\n        assert_eq!(\n            lines[0],\n            Line(vec![Text::from(\"foo \"), Text::new(\"is not\", TextStyle::default().bold()), Text::from(\" bar\")])\n        );\n        assert_eq!(\n            lines[1],\n            Line(vec![Text::from(\"![](hehe.png)\"), Text::from(\" test \"), Text::from(\"![](potato.png)\")])\n        );\n        assert_eq!(lines[2], Line::from(\"\"));\n        assert_eq!(lines[3], Line(vec![Text::from(\"* \"), Text::from(\"a\")]));\n        assert_eq!(lines[4], Line(vec![Text::from(\"* \"), Text::from(\"b\")]));\n        assert_eq!(lines[5], Line::from(\"\"));\n        assert_eq!(lines[6], Line(vec![Text::from(\"1. \"), Text::from(\"a\")]));\n        assert_eq!(lines[7], Line(vec![Text::from(\"2. \"), Text::from(\"b\")]));\n        assert_eq!(lines[8], Line::from(\"\"));\n        assert_eq!(lines[9], Line(vec![Text::from(\"1) \"), Text::from(\"a\")]));\n        assert_eq!(lines[10], Line(vec![Text::from(\"2) \"), Text::from(\"b\")]));\n    }\n\n    #[test]\n    fn multiline_block_quote() {\n        let parsed = parse_single(\n            r\"\n>>>\nbar\nfoo\n\n* a\n* b\n>>>\",\n        );\n        let MarkdownElement::BlockQuote(lines) = parsed else { panic!(\"not a block quote: {parsed:?}\") };\n        assert_eq!(lines.len(), 5);\n        assert_eq!(lines[0], Line::from(\"bar\"));\n        assert_eq!(lines[1], Line::from(\"foo\"));\n        assert_eq!(lines[2], Line::from(\"\"));\n        assert_eq!(lines[3], Line(vec![Text::from(\"* \"), Text::from(\"a\")]));\n        assert_eq!(lines[4], Line(vec![Text::from(\"* \"), Text::from(\"b\")]));\n    }\n\n    #[test]\n    fn thematic_break() {\n        let parsed = parse_all(\n            r\"\nhello\n\n---\n\nbye\n\",\n        );\n        assert_eq!(parsed.len(), 3);\n        assert!(matches!(parsed[1], MarkdownElement::ThematicBreak));\n    }\n\n    #[test]\n    fn error_lines_offset_by_front_matter() {\n        let input = r\"---\nhi\nmom\n---\n\n* ![](potato.png)\n\";\n        let arena = Arena::new();\n        let result = MarkdownParser::new(&arena).parse(input);\n        let Err(e) = result else {\n            panic!(\"parsing didn't fail\");\n        };\n        assert_eq!(e.sourcepos.start.line, 6);\n        assert_eq!(e.sourcepos.start.column, 3);\n    }\n\n    #[test]\n    fn comment_lines_offset_by_front_matter() {\n        let parsed = parse_all(\n            r\"---\nhi\nmom\n---\n\n<!-- hello -->\n\",\n        );\n        let MarkdownElement::Comment { source_position, .. } = &parsed[1] else { panic!(\"not a comment\") };\n        assert_eq!(source_position.start.line, 6);\n        assert_eq!(source_position.start.column, 1);\n    }\n\n    #[rstest]\n    #[case::lf(\"\\n\")]\n    #[case::crlf(\"\\r\\n\")]\n    fn front_matter_newlines(#[case] nl: &str) {\n        let input = format!(\"---{nl}hi{nl}mom{nl}---{nl}\");\n        let parsed = parse_single(&input);\n        let MarkdownElement::FrontMatter(contents) = &parsed else { panic!(\"not a front matter\") };\n\n        let expected = format!(\"hi{nl}mom{nl}\");\n        assert_eq!(contents, &expected);\n    }\n\n    #[test]\n    fn parse_alert() {\n        let input = r\"\n> [!note]\n> hi mom\n> bye **mom**\n\";\n        let MarkdownElement::Alert { lines, .. } = parse_single(input) else {\n            panic!(\"not an alert\");\n        };\n        assert_eq!(lines.len(), 2);\n    }\n\n    #[test]\n    fn parse_inlines() {\n        let arena = Arena::new();\n        let input = \"hello **mom** how _are you_?\";\n        let parsed = MarkdownParser::new(&arena).parse_inlines(input).expect(\"parse failed\");\n        let expected = &[\n            \"hello \".into(),\n            Text::new(\"mom\", TextStyle::default().bold()),\n            \" how \".into(),\n            Text::new(\"are you\", TextStyle::default().italics()),\n            \"?\".into(),\n        ];\n        assert_eq!(parsed.0, expected);\n    }\n\n    #[test]\n    fn footnote() {\n        let input = r\"\nthis[^1]\n\n[^1]: ref\n\nabc\n        \";\n        let elements = parse_all(input);\n        assert_eq!(elements.len(), 3);\n\n        let MarkdownElement::Paragraph(line) = &elements[0] else { panic!(\"not a paragraph\") };\n        assert_eq!(line, &[Line(vec![Text::from(\"this\"), Text::new(\"1\", TextStyle::default().superscript())])]);\n\n        let MarkdownElement::Footnote(line) = &elements[1] else { panic!(\"not a footnote\") };\n        assert_eq!(line, &Line(vec![Text::new(\"1\", TextStyle::default().superscript()), Text::from(\"ref\")]));\n    }\n}\n"
  },
  {
    "path": "src/markdown/text.rs",
    "content": "use super::{\n    elements::{Line, Text},\n    text_style::TextStyle,\n};\nuse std::{fmt, mem};\nuse unicode_width::{UnicodeWidthChar, UnicodeWidthStr};\n\n/// A weighted line of text.\n///\n/// The weight of a character is its given by its width in unicode.\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(crate) struct WeightedLine {\n    text: Vec<WeightedText>,\n    width: usize,\n    font_size: u8,\n}\n\nimpl WeightedLine {\n    /// Split this line into chunks of at most `max_length` width.\n    pub(crate) fn split(&self, max_length: usize) -> SplitTextIter<'_> {\n        SplitTextIter::new(&self.text, max_length)\n    }\n\n    /// The total width of this line.\n    pub(crate) fn width(&self) -> usize {\n        self.width\n    }\n\n    /// The height of this line.\n    pub(crate) fn font_size(&self) -> u8 {\n        self.font_size\n    }\n}\n\nimpl From<Line> for WeightedLine {\n    fn from(block: Line) -> Self {\n        block.0.into()\n    }\n}\n\nimpl From<Vec<Text>> for WeightedLine {\n    fn from(mut texts: Vec<Text>) -> Self {\n        let mut output = Vec::new();\n        let mut index = 0;\n        let mut width = 0;\n        let mut font_size = 1;\n        // Compact chunks so any consecutive chunk with the same style is merged into the same block.\n        while index < texts.len() {\n            let mut target = mem::replace(&mut texts[index], Text::from(\"\"));\n            let mut current = index + 1;\n            while current < texts.len() && texts[current].style == target.style {\n                let current_content = mem::take(&mut texts[current].content);\n                target.content.push_str(&current_content);\n                current += 1;\n            }\n            let size = target.style.size.max(1);\n            width += target.content.width() * size as usize;\n            output.push(target.into());\n            index = current;\n            font_size = font_size.max(size);\n        }\n        Self { text: output, width, font_size }\n    }\n}\n\nimpl From<String> for WeightedLine {\n    fn from(text: String) -> Self {\n        let width = text.width();\n        let text = vec![WeightedText::from(text)];\n        Self { text, width, font_size: 1 }\n    }\n}\n\nimpl From<&str> for WeightedLine {\n    fn from(text: &str) -> Self {\n        Self::from(text.to_string())\n    }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\nstruct CharAccumulator {\n    width: usize,\n    bytes: usize,\n}\n\n/// A piece of weighted text.\n#[derive(Clone, PartialEq, Eq)]\npub(crate) struct WeightedText {\n    text: Text,\n    accumulators: Vec<CharAccumulator>,\n}\n\nimpl WeightedText {\n    fn to_ref(&self) -> WeightedTextRef<'_> {\n        WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style }\n    }\n\n    pub(crate) fn width(&self) -> usize {\n        self.to_ref().width()\n    }\n\n    pub(crate) fn text(&self) -> &Text {\n        &self.text\n    }\n}\n\nimpl<S: Into<String>> From<S> for WeightedText {\n    fn from(text: S) -> Self {\n        Self::from(Text::from(text.into()))\n    }\n}\n\nimpl From<Text> for WeightedText {\n    fn from(text: Text) -> Self {\n        let mut accumulators = Vec::new();\n        let mut width = 0;\n        let mut bytes = 0;\n        for c in text.content.chars() {\n            accumulators.push(CharAccumulator { width, bytes });\n            width += c.width().unwrap_or(0);\n            bytes += c.len_utf8();\n        }\n        accumulators.push(CharAccumulator { width, bytes });\n        Self { text, accumulators }\n    }\n}\n\nimpl fmt::Debug for WeightedText {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.debug_struct(\"WeightedText\").field(\"text\", &self.text).finish()\n    }\n}\n\n/// An iterator over the chunks in a [WeightedLine].\npub(crate) struct SplitTextIter<'a> {\n    texts: &'a [WeightedText],\n    max_length: usize,\n    current: Option<WeightedTextRef<'a>>,\n}\n\nimpl<'a> SplitTextIter<'a> {\n    fn new(texts: &'a [WeightedText], max_length: usize) -> Self {\n        Self { texts, max_length, current: texts.first().map(WeightedText::to_ref) }\n    }\n}\n\nimpl<'a> Iterator for SplitTextIter<'a> {\n    type Item = Vec<WeightedTextRef<'a>>;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        self.current.as_ref()?;\n\n        let mut elements = Vec::new();\n        let mut remaining = self.max_length as i64;\n        while let Some(current) = self.current.take() {\n            let (head, rest) = current.word_split_at_length(remaining as usize);\n            // Prevent splitting a word partially. We do allow this on the first chunk as otherwise\n            // a word longer than `max_length` would never be split.\n            if !rest.text.is_empty() && !rest.text.starts_with(' ') && !elements.is_empty() {\n                self.current = Some(current);\n                break;\n            }\n            let head_width = head.width();\n            remaining -= head_width as i64;\n            elements.push(head);\n\n            // The moment we hit a chunk we couldn't fully split, we're done.\n            if !rest.text.is_empty() {\n                self.current = Some(rest.trim_start());\n                break;\n            }\n\n            // Consume the first one and point to the next one, if any.\n            self.texts = &self.texts[1..];\n            self.current = self.texts.first().map(WeightedText::to_ref);\n        }\n        Some(elements)\n    }\n}\n\n/// A reference of a piece of a [WeightedText].\n#[derive(Clone, Debug)]\npub(crate) struct WeightedTextRef<'a> {\n    text: &'a str,\n    accumulators: &'a [CharAccumulator],\n    style: TextStyle,\n}\n\nimpl<'a> WeightedTextRef<'a> {\n    /// Decompose this into its parts.\n    pub(crate) fn into_parts(self) -> (&'a str, TextStyle) {\n        (self.text, self.style)\n    }\n\n    // Attempts to split this at a word boundary.\n    //\n    // This will try to consume as many words as possible up to the given maximum length, and\n    // return the text before and after that split point.\n    fn word_split_at_length(&self, max_length: usize) -> (Self, Self) {\n        if self.width() <= max_length {\n            return (self.make_ref(0, self.text.len()), self.make_ref(0, 0));\n        }\n\n        let max_length = (max_length / self.style.size as usize).max(1);\n        let target_chunk = self.substr(max_length + 1);\n        let output_chunk = match target_chunk.rsplit_once(' ') {\n            Some((before, _)) => before,\n            None => self.substr(max_length),\n        };\n        (self.make_ref(0, output_chunk.len()), self.make_ref(output_chunk.len(), self.text.len()))\n    }\n\n    fn substr(&self, max_length: usize) -> &'a str {\n        let last_index = self.bytes_until(max_length);\n        &self.text[0..last_index]\n    }\n\n    fn make_ref(&self, from: usize, to: usize) -> Self {\n        let text = &self.text[from..to];\n        let leading_char_count = self.text[0..from].chars().count();\n        let output_char_count = text.chars().count();\n        let character_lengths = &self.accumulators[leading_char_count..leading_char_count + output_char_count + 1];\n        WeightedTextRef { text, accumulators: character_lengths, style: self.style }\n    }\n\n    fn trim_start(self) -> Self {\n        let text = self.text.trim_start();\n        let trimmed = self.text.chars().count() - text.chars().count();\n        let accumulators = &self.accumulators[trimmed..];\n        Self { text, accumulators, style: self.style }\n    }\n\n    pub(crate) fn width(&self) -> usize {\n        let last_width = self.accumulators.last().map(|a| a.width).unwrap_or(0);\n        let first_width = self.accumulators.first().map(|a| a.width).unwrap_or(0);\n        (last_width - first_width) * self.style.size as usize\n    }\n\n    fn bytes_until(&self, index: usize) -> usize {\n        let last_bytes =\n            self.accumulators.get(index).or_else(|| self.accumulators.last()).map(|a| a.bytes).unwrap_or(0);\n        let first_bytes = self.accumulators.first().map(|a| a.bytes).unwrap_or(0);\n        last_bytes - first_bytes\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use rstest::rstest;\n\n    fn join_lines<'a>(lines: impl Iterator<Item = Vec<WeightedTextRef<'a>>>) -> Vec<String> {\n        lines.map(|l| l.iter().map(|weighted| weighted.text).collect::<Vec<_>>().join(\" \")).collect()\n    }\n\n    #[test]\n    fn text_creation() {\n        let text = WeightedText::from(\"hello world\");\n\n        let text_ref = text.to_ref();\n        assert_eq!(text_ref.width(), 11);\n    }\n\n    #[test]\n    fn text_creation_utf8() {\n        let text = WeightedText::from(\"█████\");\n\n        let text_ref = text.to_ref();\n        assert_eq!(text_ref.width(), 5);\n        assert_eq!(text_ref.bytes_until(0), 0);\n        assert_eq!(text_ref.bytes_until(1), 3);\n        assert_eq!(text_ref.bytes_until(2), 6);\n        assert_eq!(text_ref.bytes_until(3), 9);\n        assert_eq!(text_ref.bytes_until(4), 12);\n\n        let text_ref = text_ref.make_ref(3, 12);\n        assert_eq!(text_ref.width(), 3);\n        assert_eq!(text_ref.bytes_until(0), 0);\n        assert_eq!(text_ref.bytes_until(1), 3);\n        assert_eq!(text_ref.bytes_until(2), 6);\n\n        let text_ref = text_ref.make_ref(0, 9);\n        assert_eq!(text_ref.width(), 3);\n        assert_eq!(text_ref.bytes_until(0), 0);\n        assert_eq!(text_ref.bytes_until(1), 3);\n        assert_eq!(text_ref.bytes_until(2), 6);\n    }\n\n    #[test]\n    fn minimal_split() {\n        let text = WeightedText::from(\"█████\");\n        let text_ref = text.to_ref();\n        let (head, rest) = text_ref.word_split_at_length(1);\n        assert_eq!(head.width(), 1);\n        assert_eq!(rest.width(), 4);\n    }\n\n    #[test]\n    fn no_spaces_split() {\n        let text = WeightedText::from(\"█████\");\n        let text_ref = text.to_ref();\n        let (head, rest) = text_ref.word_split_at_length(2);\n        assert_eq!(head.width(), 2);\n        assert_eq!(rest.width(), 3);\n    }\n\n    #[test]\n    fn font_size_split() {\n        let text = WeightedText::from(Text::new(\"█████\", TextStyle::default().size(2)));\n        let text_ref = text.to_ref();\n        let (head, rest) = text_ref.word_split_at_length(3);\n        assert_eq!(head.width(), 2);\n        assert_eq!(rest.width(), 8);\n    }\n\n    #[test]\n    fn make_ref() {\n        let text = WeightedText::from(\"hello world\");\n        let text_ref = text.to_ref();\n        let head = text_ref.make_ref(0, 1);\n        assert_eq!(head.text, \"h\");\n        assert_eq!(head.width(), 1);\n\n        let rest = text_ref.make_ref(1, 11);\n        assert_eq!(rest.text, \"ello world\");\n        assert_eq!(rest.width(), 10);\n    }\n\n    #[test]\n    fn word_split() {\n        let text = WeightedText::from(\"short string\");\n        let (head, rest) = text.to_ref().word_split_at_length(7);\n        assert_eq!(head.text, \"short\");\n        assert_eq!(rest.text, \" string\");\n    }\n\n    #[test]\n    fn split_at_full_length() {\n        let text = WeightedLine::from(\"hello world\");\n        let lines = join_lines(text.split(11));\n        let expected = vec![\"hello world\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn no_split_necessary() {\n        let text = WeightedLine {\n            text: vec![WeightedText::from(\"short\"), WeightedText::from(\"text\")],\n            width: 0,\n            font_size: 1,\n        };\n        let lines = join_lines(text.split(50));\n        let expected = vec![\"short text\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn split_lines_single() {\n        let text =\n            WeightedLine { text: vec![WeightedText::from(\"this is a slightly long line\")], width: 0, font_size: 1 };\n        let lines = join_lines(text.split(6));\n        let expected = vec![\"this\", \"is a\", \"slight\", \"ly\", \"long\", \"line\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn split_lines_multi() {\n        let text = WeightedLine {\n            text: vec![\n                WeightedText::from(\"this is a slightly long line\"),\n                WeightedText::from(\"another chunk\"),\n                WeightedText::from(\"yet some other piece\"),\n            ],\n            width: 0,\n            font_size: 1,\n        };\n        let lines = join_lines(text.split(10));\n        let expected = vec![\"this is a\", \"slightly\", \"long line\", \"another\", \"chunk yet\", \"some other\", \"piece\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn long_splits() {\n        let text = WeightedLine {\n            text: vec![\n                WeightedText::from(\"this is a slightly long line\"),\n                WeightedText::from(\"another chunk\"),\n                WeightedText::from(\"yet some other piece\"),\n            ],\n            width: 0,\n            font_size: 1,\n        };\n        let lines = join_lines(text.split(50));\n        let expected = vec![\"this is a slightly long line another chunk yet some\", \"other piece\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn prefixed_by_whitespace() {\n        let text = WeightedLine::from(\"   * bullet\");\n        let lines = join_lines(text.split(50));\n        let expected = vec![\"   * bullet\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn utf8_character() {\n        let text = WeightedLine::from(\"• A\");\n        let lines = join_lines(text.split(50));\n        let expected = vec![\"• A\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn many_utf8_characters() {\n        let content = \"█████ ██\";\n        let text = WeightedLine::from(content);\n        let lines = join_lines(text.split(3));\n        let expected = vec![\"███\", \"██\", \"██\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn no_whitespaces_ascii() {\n        let content = \"X\".repeat(10);\n        let text = WeightedLine::from(content);\n        let lines = join_lines(text.split(3));\n        let expected = vec![\"XXX\", \"XXX\", \"XXX\", \"X\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn no_whitespaces_utf8() {\n        let content = \"─\".repeat(10);\n        let text = WeightedLine::from(content);\n        let lines = join_lines(text.split(3));\n        let expected = vec![\"───\", \"───\", \"───\", \"─\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn wide_characters() {\n        let content = \"Ｈｅｌｌｏ ｗｏｒｌｄ\";\n        let text = WeightedLine::from(content);\n        let lines = join_lines(text.split(10));\n        // Each word is 10 characters long\n        let expected = vec![\"Ｈｅｌｌｏ\", \"ｗｏｒｌｄ\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[rstest]\n    #[case::single(&[\"hello\".into()], 1)]\n    #[case::two(&[\"hello\".into(), \" world\".into()], 1)]\n    #[case::three(&[\"hello\".into(), \" \".into(), \"world\".into()], 1)]\n    #[case::split(&[\"hello\".into(), Text::new(\" \", TextStyle::default().bold()), \"world\".into()], 3)]\n    #[case::split_merged(&[\"hello\".into(), Text::new(\" \", TextStyle::default().bold()), Text::new(\"w\", TextStyle::default().bold()), \"orld\".into()], 3)]\n    fn compaction(#[case] texts: &[Text], #[case] expected: usize) {\n        let block = WeightedLine::from(texts.to_vec());\n        assert_eq!(block.text.len(), expected);\n    }\n}\n"
  },
  {
    "path": "src/markdown/text_style.rs",
    "content": "use crate::{\n    terminal::capabilities::TerminalCapabilities,\n    theme::{ColorPalette, raw::RawColor},\n};\nuse crossterm::style::{ContentStyle, StyledContent, Stylize};\nuse serde::{Deserialize, Serialize};\nuse std::{\n    borrow::Cow,\n    fmt::{self, Display},\n};\n\n/// The style of a piece of text.\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub(crate) struct TextStyle<C = Color> {\n    flags: u8,\n    pub(crate) colors: Colors<C>,\n    pub(crate) size: u8,\n}\n\nimpl<C> Default for TextStyle<C> {\n    fn default() -> Self {\n        Self { flags: Default::default(), colors: Default::default(), size: 1 }\n    }\n}\n\nimpl<C> TextStyle<C>\nwhere\n    C: Clone,\n{\n    pub(crate) fn colored(colors: Colors<C>) -> Self {\n        Self { colors, ..Default::default() }\n    }\n\n    pub(crate) fn size(mut self, size: u8) -> Self {\n        self.size = size.min(16);\n        self\n    }\n\n    /// Add bold to this style.\n    pub(crate) fn bold(self) -> Self {\n        self.add_flag(TextFormatFlags::Bold)\n    }\n\n    /// Add italics to this style.\n    pub(crate) fn italics(self) -> Self {\n        self.add_flag(TextFormatFlags::Italics)\n    }\n\n    /// Indicate this text is a piece of inline code.\n    pub(crate) fn code(self) -> Self {\n        self.add_flag(TextFormatFlags::Code)\n    }\n\n    /// Add strikethrough to this style.\n    pub(crate) fn strikethrough(self) -> Self {\n        self.add_flag(TextFormatFlags::Strikethrough)\n    }\n\n    /// Add underline to this style.\n    pub(crate) fn underlined(self) -> Self {\n        self.add_flag(TextFormatFlags::Underlined)\n    }\n\n    /// Indicate this is a link label.\n    pub(crate) fn link_label(self) -> Self {\n        self.bold()\n    }\n\n    /// Indicate this is a link title.\n    pub(crate) fn link_title(self) -> Self {\n        self.italics()\n    }\n\n    /// Indicate this is a link url.\n    pub(crate) fn link_url(self) -> Self {\n        self.italics().underlined()\n    }\n\n    /// Indicate this is a superscript.\n    pub(crate) fn superscript(self) -> Self {\n        self.add_flag(TextFormatFlags::Superscript)\n    }\n\n    /// Set the background color for this text style.\n    pub(crate) fn bg_color<U: Into<C>>(mut self, color: U) -> Self {\n        self.colors.background = Some(color.into());\n        self\n    }\n\n    /// Set the foreground color for this text style.\n    pub(crate) fn fg_color<U: Into<C>>(mut self, color: U) -> Self {\n        self.colors.foreground = Some(color.into());\n        self\n    }\n\n    /// Set the colors on this style.\n    pub(crate) fn colors(mut self, colors: Colors<C>) -> Self {\n        self.colors = colors;\n        self\n    }\n\n    /// Check whether this text is code.\n    pub(crate) fn is_code(&self) -> bool {\n        self.has_flag(TextFormatFlags::Code)\n    }\n\n    /// Check whether this text is bold.\n    pub(crate) fn is_bold(&self) -> bool {\n        self.has_flag(TextFormatFlags::Bold)\n    }\n\n    /// Check whether this text is italics.\n    pub(crate) fn is_italics(&self) -> bool {\n        self.has_flag(TextFormatFlags::Italics)\n    }\n\n    /// Merge this style with another one.\n    pub(crate) fn merge(&mut self, other: &TextStyle<C>) {\n        self.flags |= other.flags;\n        self.size = self.size.max(other.size);\n        self.colors.background = self.colors.background.clone().or(other.colors.background.clone());\n        self.colors.foreground = self.colors.foreground.clone().or(other.colors.foreground.clone());\n    }\n\n    /// Return a new style merged with the one passed in.\n    pub(crate) fn merged(mut self, other: &TextStyle<C>) -> Self {\n        self.merge(other);\n        self\n    }\n\n    fn add_flag(mut self, flag: TextFormatFlags) -> Self {\n        self.flags |= flag as u8;\n        self\n    }\n\n    fn has_flag(&self, flag: TextFormatFlags) -> bool {\n        self.flags & flag as u8 != 0\n    }\n}\n\nimpl TextStyle<Color> {\n    /// Apply this style to a piece of text.\n    pub(crate) fn apply<'a>(\n        &self,\n        text: &'a str,\n        capabilities: &TerminalCapabilities,\n    ) -> StyledContent<impl Display + Clone + 'a> {\n        let mut contents = Cow::Borrowed(text);\n        let mut font_size = FontSize::Scaled(self.size);\n        let mut style = ContentStyle::default();\n        for attr in self.iter_attributes() {\n            style = match attr {\n                TextAttribute::Bold => style.bold(),\n                TextAttribute::Italics => style.italic(),\n                TextAttribute::Strikethrough => style.crossed_out(),\n                TextAttribute::Underlined => style.underlined(),\n                TextAttribute::Superscript => {\n                    if capabilities.fractional_font_size {\n                        font_size = FontSize::Fractional { numerator: self.size, denominator: 2 }\n                    } else if let Some(t) = text.try_into_superscript() {\n                        contents = Cow::Owned(t);\n                    }\n                    style\n                }\n                TextAttribute::ForegroundColor(color) => style.with(color.into()),\n                TextAttribute::BackgroundColor(color) => style.on(color.into()),\n            }\n        }\n        let text = FontSizedStr { contents, font_size };\n        StyledContent::new(style, text)\n    }\n\n    pub(crate) fn into_raw(self) -> TextStyle<RawColor> {\n        let colors = Colors {\n            background: self.colors.background.map(Into::into),\n            foreground: self.colors.foreground.map(Into::into),\n        };\n        TextStyle { flags: self.flags, colors, size: self.size }\n    }\n\n    /// Iterate all attributes in this style.\n    pub(crate) fn iter_attributes(&self) -> AttributeIterator {\n        AttributeIterator {\n            flags: self.flags,\n            next_mask: Some(TextFormatFlags::Bold),\n            background_color: self.colors.background,\n            foreground_color: self.colors.foreground,\n        }\n    }\n}\n\nimpl TextStyle<RawColor> {\n    pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<TextStyle, UndefinedPaletteColorError> {\n        let colors = self.colors.resolve(palette)?;\n        Ok(TextStyle { flags: self.flags, colors, size: self.size })\n    }\n}\n\npub(crate) struct AttributeIterator {\n    flags: u8,\n    next_mask: Option<TextFormatFlags>,\n    background_color: Option<Color>,\n    foreground_color: Option<Color>,\n}\n\nimpl Iterator for AttributeIterator {\n    type Item = TextAttribute;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        if let Some(c) = self.background_color.take() {\n            return Some(TextAttribute::BackgroundColor(c));\n        }\n        if let Some(c) = self.foreground_color.take() {\n            return Some(TextAttribute::ForegroundColor(c));\n        }\n        use TextFormatFlags::*;\n        loop {\n            let next_mask = self.next_mask?;\n            self.next_mask = match next_mask {\n                Bold => Some(Italics),\n                Italics => Some(Strikethrough),\n                Code => Some(Strikethrough),\n                Strikethrough => Some(Superscript),\n                Superscript => Some(Underlined),\n                Underlined => None,\n            };\n            if self.flags & next_mask as u8 != 0 {\n                let attr = match next_mask {\n                    Bold => TextAttribute::Bold,\n                    Italics => TextAttribute::Italics,\n                    Code => panic!(\"code shouldn't reach here\"),\n                    Strikethrough => TextAttribute::Strikethrough,\n                    Superscript => TextAttribute::Superscript,\n                    Underlined => TextAttribute::Underlined,\n                };\n                return Some(attr);\n            }\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq)]\npub(crate) enum TextAttribute {\n    Bold,\n    Italics,\n    Strikethrough,\n    Underlined,\n    Superscript,\n    ForegroundColor(Color),\n    BackgroundColor(Color),\n}\n\n#[derive(Clone, Debug)]\nstruct FontSizedStr<'a> {\n    contents: Cow<'a, str>,\n    font_size: FontSize,\n}\n\nimpl fmt::Display for FontSizedStr<'_> {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        let contents = &self.contents;\n        match self.font_size {\n            FontSize::Scaled(0 | 1) => write!(f, \"{contents}\"),\n            FontSize::Scaled(size) => write!(f, \"\\x1b]66;s={size};{contents}\\x1b\\\\\"),\n            FontSize::Fractional { numerator, denominator } => {\n                write!(f, \"\\x1b]66;n={numerator}:d={denominator};{contents}\\x1b\\\\\")\n            }\n        }\n    }\n}\n\n#[derive(Clone, Debug)]\nenum FontSize {\n    Scaled(u8),\n    Fractional { numerator: u8, denominator: u8 },\n}\n\n#[derive(Clone, Copy, Debug)]\nenum TextFormatFlags {\n    Bold = 1,\n    Italics = 2,\n    Code = 4,\n    Strikethrough = 8,\n    Underlined = 16,\n    Superscript = 32,\n}\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq)]\npub(crate) enum Color {\n    Black,\n    DarkGrey,\n    Red,\n    DarkRed,\n    Green,\n    DarkGreen,\n    Yellow,\n    DarkYellow,\n    Blue,\n    DarkBlue,\n    Magenta,\n    DarkMagenta,\n    Cyan,\n    DarkCyan,\n    White,\n    Grey,\n    Rgb { r: u8, g: u8, b: u8 },\n}\n\nimpl Color {\n    pub(crate) fn new(r: u8, g: u8, b: u8) -> Self {\n        Self::Rgb { r, g, b }\n    }\n\n    pub(crate) fn from_8bit(color: u8) -> Option<Self> {\n        match color {\n            0 => Self::Black.into(),\n            1 => Self::DarkRed.into(),\n            2 => Self::DarkGreen.into(),\n            3 => Self::DarkYellow.into(),\n            4 => Self::DarkBlue.into(),\n            5 => Self::DarkMagenta.into(),\n            6 => Self::DarkCyan.into(),\n            7 => Self::Grey.into(),\n            8 => Self::DarkGrey.into(),\n            9 => Self::Red.into(),\n            10 => Self::Green.into(),\n            11 => Self::Yellow.into(),\n            12 => Self::Blue.into(),\n            13 => Self::Magenta.into(),\n            14 => Self::Cyan.into(),\n            15 => Self::White.into(),\n            16..=231 => {\n                let mapping = [0, 95, 95 + 40, 95 + 80, 95 + 120, 95 + 160];\n                let mut value = color - 16;\n                let b = (value % 6) as usize;\n                value /= 6;\n                let g = (value % 6) as usize;\n                value /= 6;\n                let r = (value % 6) as usize;\n                Some(Self::new(mapping[r], mapping[g], mapping[b]))\n            }\n            _ => None,\n        }\n    }\n\n    pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> {\n        match self {\n            Self::Rgb { r, g, b } => Some((*r, *g, *b)),\n            _ => None,\n        }\n    }\n\n    pub(crate) fn from_ansi(color: u8) -> Option<Self> {\n        let color = match color {\n            30 | 40 => Color::Black,\n            31 | 41 => Color::Red,\n            32 | 42 => Color::Green,\n            33 | 43 => Color::Yellow,\n            34 | 44 => Color::Blue,\n            35 | 45 => Color::Magenta,\n            36 | 46 => Color::Cyan,\n            37 | 47 => Color::White,\n            _ => return None,\n        };\n        Some(color)\n    }\n}\n\nimpl From<Color> for crossterm::style::Color {\n    fn from(value: Color) -> Self {\n        use crossterm::style::Color as C;\n        match value {\n            Color::Black => C::Black,\n            Color::DarkGrey => C::DarkGrey,\n            Color::Red => C::Red,\n            Color::DarkRed => C::DarkRed,\n            Color::Green => C::Green,\n            Color::DarkGreen => C::DarkGreen,\n            Color::Yellow => C::Yellow,\n            Color::DarkYellow => C::DarkYellow,\n            Color::Blue => C::Blue,\n            Color::DarkBlue => C::DarkBlue,\n            Color::Magenta => C::Magenta,\n            Color::DarkMagenta => C::DarkMagenta,\n            Color::Cyan => C::Cyan,\n            Color::DarkCyan => C::DarkCyan,\n            Color::White => C::White,\n            Color::Grey => C::Grey,\n            Color::Rgb { r, g, b } => C::Rgb { r, g, b },\n        }\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\n#[error(\"unresolved palette color: {0}\")]\npub(crate) struct PaletteColorError(String);\n\n#[derive(Debug, thiserror::Error)]\n#[error(\"undefined palette color: {0}\")]\npub(crate) struct UndefinedPaletteColorError(pub(crate) String);\n\n/// Text colors.\n#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]\npub(crate) struct Colors<C = Color> {\n    /// The background color.\n    pub(crate) background: Option<C>,\n\n    /// The foreground color.\n    pub(crate) foreground: Option<C>,\n}\n\nimpl<C> Default for Colors<C> {\n    fn default() -> Self {\n        Self { background: None, foreground: None }\n    }\n}\n\nimpl Colors<RawColor> {\n    pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<Colors<Color>, UndefinedPaletteColorError> {\n        let background = self.background.clone().map(|c| c.resolve(palette)).transpose()?.flatten();\n        let foreground = self.foreground.clone().map(|c| c.resolve(palette)).transpose()?.flatten();\n        Ok(Colors { foreground, background })\n    }\n}\n\nimpl From<Colors> for crossterm::style::Colors {\n    fn from(value: Colors) -> Self {\n        let foreground = value.foreground.map(Color::into);\n        let background = value.background.map(Color::into);\n        Self { foreground, background }\n    }\n}\n\ntrait TryIntoSuperscript {\n    fn try_into_superscript(&self) -> Option<String>;\n}\n\nimpl TryIntoSuperscript for &'_ str {\n    fn try_into_superscript(&self) -> Option<String> {\n        let mut output = String::new();\n        for from in self.chars() {\n            let to = match from {\n                '0' => '⁰',\n                '1' => '¹',\n                '2' => '²',\n                '3' => '³',\n                '4' => '⁴',\n                '5' => '⁵',\n                '6' => '⁶',\n                '7' => '⁷',\n                '8' => '⁸',\n                '9' => '⁹',\n                '+' => '⁺',\n                '-' => '⁻',\n                '=' => '⁼',\n                '(' => '⁽',\n                ')' => '⁾',\n                'a' => 'ᵃ',\n                'b' => 'ᵇ',\n                'c' => 'ᶜ',\n                'd' => 'ᵈ',\n                'e' => 'ᵉ',\n                'f' => 'ᶠ',\n                'g' => 'ᵍ',\n                'h' => 'ʰ',\n                'i' => 'ⁱ',\n                'j' => 'ʲ',\n                'k' => 'ᵏ',\n                'l' => 'ˡ',\n                'm' => 'ᵐ',\n                'n' => 'ⁿ',\n                'o' => 'ᵒ',\n                'p' => 'ᵖ',\n                'q' => '𐞥',\n                'r' => 'ʳ',\n                's' => 'ˢ',\n                't' => 'ᵗ',\n                'u' => 'ᵘ',\n                'v' => 'ᵛ',\n                'w' => 'ʷ',\n                'x' => 'ˣ',\n                'y' => 'ʸ',\n                'z' => 'ᶻ',\n                _ => return None,\n            };\n            output.push(to);\n        }\n        Some(output)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::default(TextStyle::default(), &[])]\n    #[case::code(TextStyle::default().code(), &[])]\n    #[case::bold(TextStyle::default().bold(), &[TextAttribute::Bold])]\n    #[case::italics(TextStyle::default().italics(), &[TextAttribute::Italics])]\n    #[case::strikethrough(TextStyle::default().strikethrough(), &[TextAttribute::Strikethrough])]\n    #[case::underlined(TextStyle::default().underlined(), &[TextAttribute::Underlined])]\n    #[case::bg_color(TextStyle::default().bg_color(Color::Red), &[TextAttribute::BackgroundColor(Color::Red)])]\n    #[case::bg_color(TextStyle::default().fg_color(Color::Red), &[TextAttribute::ForegroundColor(Color::Red)])]\n    #[case::all(\n        TextStyle::default().bold().code().italics().strikethrough().underlined().bg_color(Color::Black).fg_color(Color::Red),\n        &[\n            TextAttribute::BackgroundColor(Color::Black),\n            TextAttribute::ForegroundColor(Color::Red),\n            TextAttribute::Bold,\n            TextAttribute::Italics,\n            TextAttribute::Strikethrough,\n            TextAttribute::Underlined,\n        ]\n    )]\n    fn iterate_attributes(#[case] style: TextStyle, #[case] expected: &[TextAttribute]) {\n        let attrs: Vec<_> = style.iter_attributes().collect();\n        assert_eq!(attrs, expected);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/comment.rs",
    "content": "use crate::{\n    markdown::elements::{MarkdownElement, SourcePosition},\n    presentation::builder::{BuildResult, LayoutState, PresentationBuilder, error::InvalidPresentation},\n    render::operation::{LayoutGrid, RenderOperation},\n    theme::{Alignment, ElementType},\n};\nuse serde::Deserialize;\nuse std::{fmt, num::NonZeroU8, path::PathBuf, str::FromStr};\nuse strum::{EnumDiscriminants, EnumIter};\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn process_comment(&mut self, comment: String, source_position: SourcePosition) -> BuildResult {\n        let comment = comment.trim();\n        let trimmed_comment = comment.trim_start_matches(&self.options.command_prefix);\n        let command = match trimmed_comment.parse::<CommentCommand>() {\n            Ok(comment) => comment,\n            Err(error) => {\n                // If we failed to parse this, make sure we shouldn't have ignored it\n                if self.should_ignore_comment(comment) {\n                    // Ignored comments should not add line breaks\n                    self.slide_state.ignore_element_line_break = true;\n                    return Ok(());\n                }\n                return Err(self.invalid_presentation(source_position, error));\n            }\n        };\n\n        if self.options.render_speaker_notes_only {\n            self.process_comment_command_speaker_notes_mode(command);\n        } else {\n            self.process_comment_command_presentation_mode(command, source_position)?;\n        }\n        Ok(())\n    }\n\n    fn process_comment_command_presentation_mode(\n        &mut self,\n        command: CommentCommand,\n        source_position: SourcePosition,\n    ) -> BuildResult {\n        match command {\n            CommentCommand::Pause => self.push_pause(),\n            CommentCommand::EndSlide => self.terminate_slide(),\n            CommentCommand::NewLine => self.push_line_breaks(self.slide_font_size() as usize),\n            CommentCommand::NewLines(count) => {\n                self.push_line_breaks(count as usize * self.slide_font_size() as usize);\n            }\n            CommentCommand::Comment(_) => {}\n            CommentCommand::JumpToMiddle => self.chunk_operations.push(RenderOperation::JumpToVerticalCenter),\n            CommentCommand::InitColumnLayout(columns) => {\n                self.validate_column_layout(&columns, source_position)?;\n                let resolved_position = self.sources.resolve_source_position(source_position);\n                self.slide_state.last_layout_comment = Some(resolved_position);\n                self.slide_state.layout = LayoutState::InLayout { columns_count: columns.len() };\n                let grid = if self.options.layout_grid {\n                    LayoutGrid::Draw(self.theme.layout_grid.style)\n                } else {\n                    LayoutGrid::None\n                };\n                self.chunk_operations.push(RenderOperation::InitColumnLayout {\n                    columns,\n                    grid,\n                    margin: self.theme.column_layout.margin,\n                });\n                self.slide_state.needs_enter_column = true;\n            }\n            CommentCommand::ResetLayout => {\n                self.slide_state.layout = LayoutState::Default;\n                self.chunk_operations.push(RenderOperation::ExitLayout);\n            }\n            CommentCommand::Column(column) => {\n                let (current_column, columns_count) = match self.slide_state.layout {\n                    LayoutState::InColumn { column, columns_count } => (Some(column), columns_count),\n                    LayoutState::InLayout { columns_count } => (None, columns_count),\n                    LayoutState::Default => {\n                        return Err(self.invalid_presentation(source_position, InvalidPresentation::NoLayout));\n                    }\n                };\n                if current_column == Some(column) {\n                    return Err(self.invalid_presentation(source_position, InvalidPresentation::AlreadyInColumn));\n                } else if column >= columns_count {\n                    return Err(self.invalid_presentation(source_position, InvalidPresentation::ColumnIndexTooLarge));\n                }\n                self.slide_state.layout = LayoutState::InColumn { column, columns_count };\n                self.chunk_operations.push(RenderOperation::EnterColumn { column });\n            }\n            CommentCommand::IncrementalLists(value) => {\n                self.slide_state.incremental_lists = Some(value);\n            }\n            CommentCommand::IncrementalTables(value) => {\n                self.slide_state.incremental_tables = Some(value);\n            }\n            CommentCommand::NoFooter => {\n                self.slide_state.ignore_footer = true;\n            }\n            CommentCommand::SpeakerNote(_) => {}\n            CommentCommand::FontSize(size) => {\n                if size == 0 || size > 7 {\n                    return Err(self.invalid_presentation(source_position, InvalidPresentation::InvalidFontSize));\n                }\n                self.slide_state.font_size = Some(size)\n            }\n            CommentCommand::Alignment(alignment) => {\n                let alignment = match alignment {\n                    CommentCommandAlignment::Left => Alignment::Left { margin: Default::default() },\n                    CommentCommandAlignment::Center => {\n                        Alignment::Center { minimum_margin: Default::default(), minimum_size: Default::default() }\n                    }\n                    CommentCommandAlignment::Right => Alignment::Right { margin: Default::default() },\n                };\n                self.slide_state.alignment = Some(alignment);\n            }\n            CommentCommand::SkipSlide => {\n                self.slide_state.skip_slide = true;\n            }\n            CommentCommand::ListItemNewlines(count) => {\n                self.slide_state.list_item_newlines = Some(count.into());\n            }\n            CommentCommand::Include(path) => {\n                self.process_include(path, source_position)?;\n                return Ok(());\n            }\n            CommentCommand::SnippetOutput(id) => {\n                let handle = self.executable_snippets.get(&id).cloned().ok_or_else(|| {\n                    self.invalid_presentation(source_position, InvalidPresentation::UndefinedSnippetId(id))\n                })?;\n                self.push_detached_code_execution(handle)?;\n                return Ok(());\n            }\n        };\n        // Don't push line breaks for any comments.\n        self.slide_state.ignore_element_line_break = true;\n        Ok(())\n    }\n\n    fn process_comment_command_speaker_notes_mode(&mut self, comment_command: CommentCommand) {\n        match comment_command {\n            CommentCommand::SpeakerNote(note) => {\n                for line in note.lines() {\n                    self.push_text(line.into(), ElementType::Paragraph);\n                    self.push_line_break();\n                }\n                self.push_line_break();\n            }\n            CommentCommand::EndSlide => self.terminate_slide(),\n            CommentCommand::Pause => self.push_pause(),\n            CommentCommand::SkipSlide => self.slide_state.skip_slide = true,\n            _ => {}\n        }\n    }\n\n    fn should_ignore_comment(&self, comment: &str) -> bool {\n        if comment.contains('\\n') || !comment.starts_with(&self.options.command_prefix) {\n            // Ignore any multi line comment; those are assumed to be user comments\n            // Ignore any line that doesn't start with the selected prefix.\n            true\n        } else if comment.trim().starts_with(\"vim:\") {\n            // ignore vim: commands\n            true\n        } else {\n            // Ignore vim-like code folding tags\n            let comment = comment.trim();\n            comment == \"{{{\" || comment == \"}}}\" || comment.starts_with(\"//\")\n        }\n    }\n\n    fn process_include(&mut self, path: PathBuf, source_position: SourcePosition) -> BuildResult {\n        let base = self.resource_base_path();\n        let resolved_path = self.resources.resolve_path(&path, &base);\n        let contents = self.resources.external_text_file(&path, &base).map_err(|e| {\n            self.invalid_presentation(\n                source_position,\n                InvalidPresentation::IncludeMarkdown { path: path.clone(), error: e },\n            )\n        })?;\n        let elements = self.markdown_parser.parse(&contents).map_err(|e| {\n            self.invalid_presentation(\n                source_position,\n                InvalidPresentation::ParseInclude { path: path.clone(), error: e },\n            )\n        })?;\n        let _guard = self\n            .sources\n            .enter(resolved_path)\n            .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Import { path, error: e }))?;\n        for element in elements {\n            if let MarkdownElement::FrontMatter(_) = element {\n                return Err(self.invalid_presentation(source_position, InvalidPresentation::IncludeFrontMatter));\n            }\n            self.slide_state.ignore_element_line_break = false;\n            self.process_element_for_presentation_mode(element)?;\n            if !self.slide_state.ignore_element_line_break {\n                self.push_line_break();\n            }\n        }\n        self.slide_state.ignore_element_line_break = true;\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Deserialize, EnumDiscriminants)]\n#[strum_discriminants(derive(EnumIter))]\n#[serde(rename_all = \"snake_case\")]\npub(crate) enum CommentCommand {\n    Alignment(CommentCommandAlignment),\n    Column(usize),\n    EndSlide,\n    FontSize(u8),\n    Include(PathBuf),\n    IncrementalLists(bool),\n    IncrementalTables(bool),\n    #[serde(rename = \"column_layout\")]\n    InitColumnLayout(Vec<u8>),\n    JumpToMiddle,\n    ListItemNewlines(NonZeroU8),\n    #[serde(alias = \"newline\")]\n    NewLine,\n    #[serde(alias = \"newlines\")]\n    NewLines(u32),\n    NoFooter,\n    Pause,\n    ResetLayout,\n    SkipSlide,\n    SpeakerNote(String),\n    SnippetOutput(String),\n    Comment(String),\n}\n\nimpl CommentCommand {\n    /// Generate sample comment strings for all available commands\n    pub(crate) fn generate_samples() -> Vec<&'static str> {\n        use strum::IntoEnumIterator;\n\n        CommentCommandDiscriminants::iter()\n            .flat_map(|variant| {\n                use CommentCommandDiscriminants::*;\n                match variant {\n                    Alignment => {\n                        vec![\"<!-- alignment: left -->\", \"<!-- alignment: center -->\", \"<!-- alignment: right -->\"]\n                    }\n                    Column => vec![\"<!-- column: 0 -->\"],\n                    EndSlide => vec![\"<!-- end_slide -->\"],\n                    FontSize => vec![\"<!-- font_size: 2 -->\"],\n                    Include => vec![\"<!-- include: file.md -->\"],\n                    IncrementalLists => {\n                        vec![\"<!-- incremental_lists: true -->\", \"<!-- incremental_lists: false -->\"]\n                    }\n                    IncrementalTables => {\n                        vec![\"<!-- incremental_tables: true -->\", \"<!-- incremental_tables: false -->\"]\n                    }\n                    InitColumnLayout => vec![\"<!-- column_layout: [1, 2] -->\"],\n                    JumpToMiddle => vec![\"<!-- jump_to_middle -->\"],\n                    ListItemNewlines => vec![\"<!-- list_item_newlines: 2 -->\"],\n                    NewLine => vec![\"<!-- new_line -->\"],\n                    NewLines => vec![\"<!-- new_lines: 2 -->\"],\n                    NoFooter => vec![\"<!-- no_footer -->\"],\n                    Pause => vec![\"<!-- pause -->\"],\n                    ResetLayout => vec![\"<!-- reset_layout -->\"],\n                    SkipSlide => vec![\"<!-- skip_slide -->\"],\n                    SpeakerNote => vec![\"<!-- speaker_note: Your note here -->\"],\n                    SnippetOutput => vec![\"<!-- snippet_output: identifier -->\"],\n                    Comment => vec![\"<!-- comment: hi mom -->\"],\n                }\n            })\n            .collect()\n    }\n}\n\nimpl FromStr for CommentCommand {\n    type Err = CommandParseError;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        #[derive(Deserialize)]\n        struct CommandWrapper(#[serde(with = \"serde_yaml::with::singleton_map\")] CommentCommand);\n\n        let wrapper = serde_yaml::from_str::<CommandWrapper>(s)?;\n        Ok(wrapper.0)\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub(crate) enum CommentCommandAlignment {\n    Left,\n    Center,\n    Right,\n}\n\n#[derive(thiserror::Error, Debug)]\npub struct CommandParseError(#[from] serde_yaml::Error);\n\nimpl fmt::Display for CommandParseError {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        let inner = self.0.to_string();\n        // Remove the trailing \"at line X, ...\" that comes from serde_yaml. This otherwise claims\n        // we're always in line 1 because the yaml is parsed in isolation out of the HTML comment.\n        let inner = inner.split(\" at line\").next().unwrap();\n        write!(f, \"{inner}\")\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::presentation::builder::{PresentationBuilderOptions, utils::Test};\n    use image::{DynamicImage, ImageEncoder, codecs::png::PngEncoder};\n    use rstest::rstest;\n    use std::{fs, io::BufWriter};\n    use tempfile::tempdir;\n\n    #[rstest]\n    #[case::pause(\"pause\", CommentCommand::Pause)]\n    #[case::pause(\" pause \", CommentCommand::Pause)]\n    #[case::end_slide(\"end_slide\", CommentCommand::EndSlide)]\n    #[case::column_layout(\"column_layout: [1, 2]\", CommentCommand::InitColumnLayout(vec![1, 2]))]\n    #[case::column(\"column: 1\", CommentCommand::Column(1))]\n    #[case::reset_layout(\"reset_layout\", CommentCommand::ResetLayout)]\n    #[case::incremental_lists(\"incremental_lists: true\", CommentCommand::IncrementalLists(true))]\n    #[case::incremental_lists(\"new_lines: 2\", CommentCommand::NewLines(2))]\n    #[case::incremental_lists(\"newlines: 2\", CommentCommand::NewLines(2))]\n    #[case::incremental_lists(\"new_line\", CommentCommand::NewLine)]\n    #[case::incremental_lists(\"newline\", CommentCommand::NewLine)]\n    #[case::comment(\"comment: This is a user comment\", CommentCommand::Comment(\"This is a user comment\".into()))]\n    fn command_formatting(#[case] input: &str, #[case] expected: CommentCommand) {\n        let parsed: CommentCommand = input.parse().expect(\"deserialization failed\");\n        assert_eq!(parsed, expected);\n    }\n\n    #[rstest]\n    #[case::multiline(\"hello\\nworld\")]\n    #[case::many_open_braces(\"{{{\")]\n    #[case::many_close_braces(\"}}}\")]\n    #[case::vim_command(\"vim: hi\")]\n    #[case::padded_vim_command(\"vim: hi\")]\n    #[case::double_slash(\"// This is a user comment\")]\n    #[case::double_slash_padded(\"  // This is a padded comment  \")]\n    #[case::comment_colon(\"comment: This is a user comment\")]\n    fn ignore_comments(#[case] comment: &str) {\n        let input = format!(\"<!-- {comment} -->\");\n        Test::new(input).build();\n    }\n\n    #[rstest]\n    #[case::command_with_prefix(\"cmd:end_slide\", true)]\n    #[case::non_command_with_prefix(\"cmd:bogus\", false)]\n    #[case::non_prefixed(\"random\", true)]\n    fn comment_prefix(#[case] comment: &str, #[case] should_work: bool) {\n        let options = PresentationBuilderOptions { command_prefix: \"cmd:\".into(), ..Default::default() };\n\n        let element = MarkdownElement::Comment { comment: comment.into(), source_position: Default::default() };\n        let result = Test::new(vec![element]).options(options).try_build();\n        assert_eq!(result.is_ok(), should_work, \"{result:?}\");\n    }\n\n    #[test]\n    fn layout_without_init() {\n        let input = \"<!-- column: 0 -->\";\n        Test::new(input).expect_invalid();\n    }\n\n    #[test]\n    fn already_in_column() {\n        let input = \"\n<!-- column_layout: [1] -->\n<!-- column: 0 -->\n<!-- column: 0 -->\n\";\n        Test::new(input).expect_invalid();\n    }\n\n    #[test]\n    fn column_index_overflow() {\n        let input = \"\n<!-- column_layout: [1] -->\n<!-- column: 1 -->\n\";\n        Test::new(input).expect_invalid();\n    }\n\n    #[rstest]\n    #[case::empty(\"column_layout: []\")]\n    #[case::zero(\"column_layout: [0]\")]\n    #[case::one_is_zero(\"column_layout: [1, 0]\")]\n    fn invalid_layouts(#[case] definition: &str) {\n        let input = format!(\"<!-- {definition} -->\");\n        Test::new(input).expect_invalid();\n    }\n\n    #[test]\n    fn operation_without_enter_column() {\n        let input = \"\n<!-- column_layout: [1] -->\n\n# hi\n\";\n        Test::new(input).expect_invalid();\n    }\n\n    #[test]\n    fn end_slide_inside_layout() {\n        let input = \"\n<!-- column_layout: [1] -->\n<!-- end_slide -->\n\";\n        let presentation = Test::new(input).build();\n        assert_eq!(presentation.iter_slides().count(), 2);\n    }\n\n    #[test]\n    fn end_slide_inside_column() {\n        let input = \"\n<!-- column_layout: [1] -->\n<!-- column: 0 -->\n<!-- end_slide -->\n\";\n        let presentation = Test::new(input).build();\n        assert_eq!(presentation.iter_slides().count(), 2);\n    }\n\n    #[test]\n    fn columns() {\n        let input = \"---\ntheme:\n  override:\n    column_layout:\n      margin:\n        fixed: 2\n---\n\n<!-- column_layout: [1, 1] -->\n<!-- column: 0 -->\nfoo1\n\nfoo2\n\n---\n\n\n<!-- column: 1 -->\nbar1\n\nbar2\n\n---\n\";\n        let lines = Test::new(input).render().rows(7).columns(20).into_lines();\n        let expected = &[\n            \"                    \",\n            \"foo1        bar1    \",\n            \"                    \",\n            \"foo2        bar2    \",\n            \"                    \",\n            \"————————    ————————\",\n            \"                    \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn columns_back_and_forth() {\n        // this is the same as the above but we run back and forth between the columns\n        let input = \"---\ntheme:\n  override:\n    column_layout:\n      margin:\n        fixed: 2\n---\n\n<!-- column_layout: [1, 1] -->\n\n<!-- column: 0 -->\nfoo1\n\n<!-- column: 1 -->\n\nbar1\n\n\n<!-- column: 0 -->\n\nfoo2\n\n---\n\n<!-- column: 1 -->\n\nbar2\n\n---\n\";\n        let lines = Test::new(input).render().rows(7).columns(20).into_lines();\n        let expected = &[\n            \"                    \",\n            \"foo1        bar1    \",\n            \"                    \",\n            \"foo2        bar2    \",\n            \"                    \",\n            \"————————    ————————\",\n            \"                    \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn uneven_columns() {\n        let input = \"---\ntheme:\n  override:\n    column_layout:\n      margin:\n        fixed: 4\n---\n\n<!-- column_layout: [2, 1] -->\n<!-- column: 0 -->\nfoo1\n\nfoo2\n\n---\n\n\n<!-- column: 1 -->\nbar1\n\nbar2\n\n---\n\";\n        let lines = Test::new(input).render().rows(7).columns(24).into_lines();\n        let expected = &[\n            \"                        \",\n            \"foo1                bar1\",\n            \"                        \",\n            \"foo2                bar2\",\n            \"                        \",\n            \"————————————        ————\",\n            \"                        \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn uneven_three_columns() {\n        let input = \"---\ntheme:\n  override:\n    column_layout:\n      margin:\n        fixed: 4\n---\n\n<!-- column_layout: [1, 2, 1] -->\n<!-- column: 0 -->\n\n---\n\n<!-- column: 1 -->\n\n---\n\n<!-- column: 2 -->\n\n---\n\";\n        let lines = Test::new(input).render().rows(2).columns(32).into_lines();\n        let expected = &[\n            //\n            \"                                \",\n            \"————      ————————————      ————\",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn pause_layout() {\n        let input = r\"\n<!-- column_layout: [1, 1] -->\n<!-- pause -->\n<!-- column: 0 -->\nhi\n<!-- pause -->\n<!-- column: 1 -->\nbye\n\";\n        let lines = Test::new(input).render().rows(5).columns(12).advances(1).into_lines();\n        let expected = &[\"            \", \"hi          \", \"            \", \"            \", \"            \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn pause_new_slide() {\n        let input = \"\nhi\n\n<!-- pause -->\n\nbye\n\";\n        let options = PresentationBuilderOptions { pause_create_new_slide: true, ..Default::default() };\n        let slides = Test::new(input).options(options).build().into_slides();\n        assert_eq!(slides.len(), 2);\n    }\n\n    #[test]\n    fn pause_layout_new_slide() {\n        let input = r\"---\ntheme:\n  override:\n    column_layout:\n      margin:\n        fixed: 4\n---\n\n<!-- column_layout: [1, 1] -->\n<!-- column: 0 -->\nhi\n<!-- pause -->\n<!-- column: 1 -->\nbye\n\";\n        let options = PresentationBuilderOptions { pause_create_new_slide: true, ..Default::default() };\n        let lines = Test::new(input).options(options).render().rows(3).columns(15).advances(1).into_lines();\n        let expected = &[\"               \", \"hi         bye \", \"               \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn skip_slide() {\n        let input = \"\nhi\n\n<!-- skip_slide -->\n<!-- end_slide -->\n\nbye\n\";\n        let lines = Test::new(input).render().rows(5).columns(3).into_lines();\n        let expected = &[\"   \", \"bye\", \"   \", \"   \", \"   \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn skip_all_slides() {\n        let input = \"\nhi\n\n<!-- skip_slide -->\n\";\n        let lines = Test::new(input).render().rows(5).columns(3).into_lines();\n        let expected = &[\"   \", \"   \", \"   \", \"   \", \"   \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn skip_slide_pauses() {\n        let input = \"\nhi\n\n<!-- pause -->\n<!-- skip_slide -->\n<!-- end_slide -->\n\nbye\n\";\n        let lines = Test::new(input).render().rows(2).columns(3).into_lines();\n        let expected = &[\"   \", \"bye\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn skip_slide_speaker_note() {\n        let input = \"\nhi\n\n<!-- skip_slide -->\n<!-- end_slide -->\n<!-- speaker_note: bye -->\n\";\n        let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() };\n        let lines = Test::new(input).options(options).render().rows(2).columns(3).into_lines();\n        let expected = &[\"   \", \"bye\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn speaker_notes() {\n        let input = \"\n<!-- speaker_note: hi -->\n\n<!-- speaker_note: bye -->\n\";\n        let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() };\n        let lines = Test::new(input).options(options).render().rows(4).columns(3).into_lines();\n        let expected = &[\"   \", \"hi \", \"   \", \"bye\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn speaker_notes_pause() {\n        let input = \"\n<!-- speaker_note: hi -->\n\n<!-- pause -->\n\n<!-- speaker_note: bye -->\n\";\n        let options = PresentationBuilderOptions { render_speaker_notes_only: true, ..Default::default() };\n        let lines = Test::new(input).options(options).render().rows(4).columns(3).advances(0).into_lines();\n        let expected = &[\"   \", \"hi \", \"   \", \"   \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn alignment() {\n        let input = \"\nhi\n\n<!-- alignment: center -->\n\nhello            \n\n<!-- alignment: right -->\n\nhola\n\";\n\n        let lines = Test::new(input).render().rows(6).columns(16).into_lines();\n        let expected = &[\n            \"                \",\n            \"hi              \",\n            \"                \",\n            \"     hello      \",\n            \"                \",\n            \"            hola\",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn include() {\n        let dir = tempdir().expect(\"failed to created tempdir\");\n        let path = dir.path();\n        let inner_path = path.join(\"inner\");\n        fs::create_dir_all(path.join(&inner_path)).expect(\"failed to create dir\");\n\n        let image = DynamicImage::new_rgba8(1, 1);\n        let mut buffer = BufWriter::new(fs::File::create(inner_path.join(\"img.png\")).expect(\"failed to write image\"));\n        PngEncoder::new(&mut buffer)\n            .write_image(image.as_bytes(), 1, 1, image.color().into())\n            .expect(\"failed to create imager\");\n        drop(buffer);\n\n        fs::write(\n            path.join(\"first.md\"),\n            r\"\nfirst\n===\n\nfoo\n\n![](inner/img.png)\n\n<!-- include: inner/second.md -->\n\n```file\npath: inner/foo.txt\nlanguage: text\n```\n\",\n        )\n        .unwrap();\n\n        fs::write(\n            inner_path.join(\"second.md\"),\n            r\"\n<!-- column_layout: [1] -->\n<!-- column: 0 -->\nsecond\n<!-- reset_layout -->\n\n![](img.png)\n\",\n        )\n        .unwrap();\n\n        fs::write(inner_path.join(\"foo.txt\"), \"a\").unwrap();\n\n        let input = \"\nhi\n\n<!-- include: first.md -->\n        \";\n\n        let lines = Test::new(input).resources_path(path).render().rows(14).columns(12).into_lines();\n        let expected = &[\n            \"            \",\n            \"hi          \",\n            \"            \",\n            \"first       \",\n            \"            \",\n            \"foo         \",\n            \"            \",\n            \"            \",\n            \"            \",\n            \"second      \",\n            \"            \",\n            \"            \",\n            \"            \",\n            \"a           \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn self_include() {\n        let dir = tempdir().expect(\"failed to created tempdir\");\n        let path = dir.path();\n\n        fs::write(path.join(\"main.md\"), \"<!-- include: main.md -->\").unwrap();\n        let input = \"<!-- include: main.md -->\";\n\n        let err = Test::new(input).resources_path(path).expect_invalid();\n        assert!(err.to_string().contains(\"was already imported\"), \"{err:?}\");\n    }\n\n    #[test]\n    fn include_cycle() {\n        let dir = tempdir().expect(\"failed to created tempdir\");\n        let path = dir.path();\n\n        fs::write(path.join(\"main.md\"), \"<!-- include: inner.md -->\").unwrap();\n        fs::write(path.join(\"inner.md\"), \"<!-- include: main.md -->\").unwrap();\n        let input = \"<!-- include: main.md -->\";\n\n        let err = Test::new(input).resources_path(path).expect_invalid();\n        assert!(err.to_string().contains(\"was already imported\"), \"{err:?}\");\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/error.rs",
    "content": "use crate::{\n    code::execute::UnsupportedExecution,\n    markdown::{\n        elements::SourcePosition,\n        parse::ParseError,\n        text_style::{Color, TextStyle, UndefinedPaletteColorError},\n    },\n    presentation::builder::{comment::CommandParseError, images::ImageAttributeError, sources::MarkdownSourceError},\n    terminal::{capabilities::TerminalCapabilities, image::printer::RegisterImageError},\n    theme::{ProcessingThemeError, registry::LoadThemeError},\n    third_party::ThirdPartyRenderError,\n    ui::footer::InvalidFooterTemplateError,\n};\nuse std::{\n    fmt,\n    io::{self},\n    path::PathBuf,\n};\n\n/// An error when building a presentation.\n#[derive(thiserror::Error, Debug)]\npub(crate) enum BuildError {\n    #[error(\"failed to read presentation file {0:?}: {1:?}\")]\n    ReadPresentation(PathBuf, io::Error),\n\n    #[error(\"failed to register image: {0}\")]\n    RegisterImage(#[from] RegisterImageError),\n\n    #[error(\"invalid theme: {0}\")]\n    InvalidTheme(#[from] LoadThemeError),\n\n    #[error(\"invalid code highlighter theme: '{0}'\")]\n    InvalidCodeTheme(String),\n\n    #[error(\"third party render failed: {0}\")]\n    ThirdPartyRender(#[from] ThirdPartyRenderError),\n\n    #[error(transparent)]\n    UnsupportedExecution(#[from] UnsupportedExecution),\n\n    #[error(transparent)]\n    UndefinedPaletteColor(#[from] UndefinedPaletteColorError),\n\n    #[error(\"processing theme: {0}\")]\n    ThemeProcessing(#[from] ProcessingThemeError),\n\n    #[error(\"invalid footer: {0}\")]\n    InvalidFooter(#[from] InvalidFooterTemplateError),\n\n    #[error(\n        \"invalid markdown at {display_path}:{line}:{column}:\\n\\n{context}\",\n        display_path = .path.display(),\n        line = .error.sourcepos.start.line,\n        column = .error.sourcepos.start.column,\n    )]\n    Parse { path: PathBuf, error: ParseError, context: String },\n\n    #[error(\"cannot process presentation file: {0}\")]\n    EnterRoot(MarkdownSourceError),\n\n    #[error(\n        \"error at {display_path}:{line}:{column}:\\n\\n{context}\",\n        display_path = .path.display(),\n        line = .source_position.start.line,\n        column = .source_position.start.column,\n    )]\n    InvalidPresentation { path: PathBuf, source_position: SourcePosition, context: String },\n\n    #[error(\"error in frontmatter:\\n\\n{0}\")]\n    InvalidFrontmatter(String),\n\n    #[error(\"need to enter layout column explicitly using `column` command\\n\\n{0}\")]\n    NotInsideColumn(String),\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum InvalidPresentation {\n    #[error(\"could not load image '{path}': {error}\")]\n    LoadImage { path: PathBuf, error: String },\n\n    #[error(\"invalid image attribute: {0}\")]\n    ParseImageAttribute(#[from] ImageAttributeError),\n\n    #[error(\"invalid snippet: {0}\")]\n    Snippet(String),\n\n    #[error(\"invalid command: {0}\")]\n    CommandParse(#[from] CommandParseError),\n\n    #[error(\"invalid markdown in imported file {path:?}: {error}\")]\n    ParseInclude { path: PathBuf, error: ParseError },\n\n    #[error(\"could not read included markdown file {path:?}: {error}\")]\n    IncludeMarkdown { path: PathBuf, error: io::Error },\n\n    #[error(\"included markdown files cannot contain a front matter\")]\n    IncludeFrontMatter,\n\n    #[error(\"cannot include markdown file at {path}: {error}\")]\n    Import { path: PathBuf, error: MarkdownSourceError },\n\n    #[error(\"can't enter layout: no layout defined\")]\n    NoLayout,\n\n    #[error(\"can't enter layout column: already in it\")]\n    AlreadyInColumn,\n\n    #[error(\"can't enter layout column: column index too large\")]\n    ColumnIndexTooLarge,\n\n    #[error(\"invalid layout: {0}\")]\n    InvalidLayout(&'static str),\n\n    #[error(\"font sizes must be >= 1 and <= 7\")]\n    InvalidFontSize,\n\n    #[error(\"snippet id '{0}' not defined\")]\n    UndefinedSnippetId(String),\n\n    #[error(\"snippet identifiers can only be used in +exec blocks\")]\n    SnippetIdNonExec,\n\n    #[error(\"snippet id '{0}' already exists\")]\n    SnippetAlreadyExists(String),\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct FileSourcePosition {\n    pub(crate) source_position: SourcePosition,\n    pub(crate) file: PathBuf,\n}\n\nimpl fmt::Display for FileSourcePosition {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        let file = self.file.display();\n        let pos = &self.source_position;\n        write!(f, \"{file}:{pos}\")\n    }\n}\n\npub(super) trait FormatError {\n    fn format_error(self) -> String;\n}\n\nimpl FormatError for String {\n    fn format_error(self) -> String {\n        TextStyle::default().fg_color(Color::Red).apply(&self, &Default::default()).to_string()\n    }\n}\n\n#[derive(Default)]\npub(super) struct ErrorContextBuilder<'a> {\n    line: Option<usize>,\n    column: Option<usize>,\n    source_line: &'a str,\n    error: &'a str,\n    prefix_style: TextStyle,\n    error_style: TextStyle,\n}\n\nimpl<'a> ErrorContextBuilder<'a> {\n    pub(super) fn new(source_line: &'a str, error: &'a str) -> Self {\n        Self {\n            line: None,\n            column: None,\n            source_line,\n            error,\n            prefix_style: TextStyle::default().fg_color(Color::Blue),\n            error_style: TextStyle::default().fg_color(Color::Red),\n        }\n    }\n\n    pub(super) fn position(mut self, position: SourcePosition) -> Self {\n        self.line = Some(position.start.line);\n        self.column = Some(position.start.column);\n        self\n    }\n\n    pub(super) fn column(mut self, column: usize) -> Self {\n        self.column = Some(column);\n        self\n    }\n\n    pub(super) fn build(self) -> String {\n        let Self { line, column, source_line, error, prefix_style, error_style } = self;\n        let (error_line_prefix, empty_line, source_line) = match line {\n            Some(line) => {\n                let line_number = line.to_string();\n                let empty_prefix = \" \".repeat(line_number.len());\n                let error_line_prefix = format!(\"{line_number} | \");\n                let empty_line = format!(\"{empty_prefix} | \");\n                let source_line = source_line.lines().nth(line.saturating_sub(1)).unwrap_or_default();\n                (error_line_prefix, empty_line, source_line)\n            }\n            None => {\n                let prefix = \" | \".to_string();\n                (prefix.clone(), prefix, source_line)\n            }\n        };\n        let column = column.map(|c| c.saturating_sub(1)).unwrap_or_default();\n        let capabilities = TerminalCapabilities::default();\n        let empty_line = prefix_style.apply(&empty_line, &capabilities).to_string();\n        let mut output = empty_line.clone();\n        output.push('\\n');\n        let prefix = prefix_style.apply(&error_line_prefix, &capabilities).to_string();\n        output.push_str(&format!(\"{prefix}{source_line}\\n\"));\n\n        let indicator = format!(\"{}^ {error}\", \" \".repeat(column));\n        let indicator = error_style.apply(&indicator, &capabilities).to_string();\n        let indicator_line = format!(\"{empty_line}{indicator}\");\n        output.push_str(&indicator_line);\n        output\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::markdown::elements::LineColumn;\n\n    trait ErrorContextBuilderExt {\n        fn into_lines(self) -> Vec<String>;\n    }\n\n    impl ErrorContextBuilderExt for ErrorContextBuilder<'_> {\n        fn into_lines(self) -> Vec<String> {\n            let error = self.build();\n            error.lines().map(ToString::to_string).collect()\n        }\n    }\n\n    fn make_builder<'a>(source_line: &'a str, error: &'a str) -> ErrorContextBuilder<'a> {\n        let mut builder = ErrorContextBuilder::new(source_line, error);\n        builder.prefix_style = Default::default();\n        builder.error_style = Default::default();\n        builder\n    }\n\n    #[test]\n    fn position() {\n        let lines = make_builder(\"foo\\nbear\\ntar\", \"'a' not allowed\")\n            .position(SourcePosition { start: LineColumn { line: 2, column: 3 } })\n            .into_lines();\n        let expected = &[\n            //\n            \"  | \",\n            \"2 | bear\",\n            \"  |   ^ 'a' not allowed\",\n        ];\n        assert_eq!(&lines, expected);\n    }\n\n    #[test]\n    fn no_position() {\n        let lines = make_builder(\"bear\", \"'b' not allowed\").into_lines();\n        let expected = &[\n            //\n            \" | \",\n            \" | bear\",\n            \" | ^ 'b' not allowed\",\n        ];\n        assert_eq!(&lines, expected);\n    }\n\n    #[test]\n    fn column() {\n        let lines = make_builder(\"bear\", \"'e' not allowed\").column(2).into_lines();\n        let expected = &[\n            //\n            \" | \",\n            \" | bear\",\n            \" |  ^ 'e' not allowed\",\n        ];\n        assert_eq!(&lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/frontmatter.rs",
    "content": "use crate::{\n    config::OptionsConfig,\n    markdown::{\n        elements::{Line, Text},\n        parse::MarkdownParser,\n        text_style::TextStyle,\n    },\n    presentation::{\n        PresentationMetadata, PresentationThemeMetadata,\n        builder::{\n            BuildResult, ErrorContextBuilder, PresentationBuilder,\n            error::{BuildError, FormatError},\n        },\n    },\n    render::operation::RenderOperation,\n    theme::{AuthorPositioning, ElementType, PresentationTheme},\n};\nuse comrak::Arena;\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn process_front_matter(&mut self, contents: &str) -> BuildResult {\n        let metadata = match self.options.strict_front_matter_parsing {\n            true => serde_yaml::from_str::<StrictPresentationMetadata>(contents).map(PresentationMetadata::from),\n            false => serde_yaml::from_str::<PresentationMetadata>(contents),\n        };\n        let mut metadata = metadata.map_err(|e| BuildError::InvalidFrontmatter(e.to_string().format_error()))?;\n        if metadata.author.is_some() && !metadata.authors.is_empty() {\n            return Err(BuildError::InvalidFrontmatter(\n                ErrorContextBuilder::new(\"authors:\", \"cannot have both 'author' and 'authors'\").build(),\n            ));\n        }\n\n        if let Some(options) = metadata.options.take() {\n            self.options.merge(options);\n        }\n\n        {\n            let footer_context = &mut self.footer_vars;\n            footer_context.title.clone_from(&metadata.title);\n            footer_context.sub_title.clone_from(&metadata.sub_title);\n            footer_context.location.clone_from(&metadata.location);\n            footer_context.event.clone_from(&metadata.event);\n            footer_context.date.clone_from(&metadata.date);\n            footer_context.author.clone_from(&metadata.author);\n        }\n\n        self.set_theme(&metadata.theme)?;\n        if metadata.has_frontmatter() {\n            self.push_slide_prelude();\n            self.push_intro_slide(metadata)?;\n        }\n        Ok(())\n    }\n\n    fn set_theme(&mut self, metadata: &PresentationThemeMetadata) -> BuildResult {\n        if metadata.name.is_some() && metadata.path.is_some() {\n            return Err(BuildError::InvalidFrontmatter(\n                ErrorContextBuilder::new(\"path:\", \"cannot set both 'theme.path' and 'theme.name'\").build(),\n            ));\n        }\n        let mut new_theme = None;\n        // Only override the theme if we're not forced to use the default one.\n        if !self.options.force_default_theme {\n            if let Some(theme_name) = &metadata.name {\n                let theme = self.themes.presentation.load_by_name(theme_name).ok_or_else(|| {\n                    BuildError::InvalidFrontmatter(\n                        ErrorContextBuilder::new(&format!(\"name: {theme_name}\"), \"theme does not exist\")\n                            .column(7)\n                            .build(),\n                    )\n                })?;\n                new_theme = Some(theme);\n            }\n            if let Some(theme_path) = &metadata.path {\n                let mut theme = self.resources.theme(theme_path)?;\n                if let Some(name) = &theme.extends {\n                    let base = self.themes.presentation.load_by_name(name).ok_or_else(|| {\n                        BuildError::InvalidFrontmatter(\n                            ErrorContextBuilder::new(&format!(\"extends: {name}\"), \"extended theme does not exist\")\n                                .column(10)\n                                .build(),\n                        )\n                    })?;\n                    theme = merge_struct::merge(&theme, &base)\n                        .map_err(|e| BuildError::InvalidFrontmatter(format!(\"malformed theme: {e}\")))?;\n                }\n                new_theme = Some(theme);\n            }\n        }\n        if let Some(overrides) = &metadata.overrides {\n            if let Some(extends) = &overrides.extends {\n                return Err(BuildError::InvalidFrontmatter(\n                    ErrorContextBuilder::new(&format!(\"extends: {extends}\"), \"theme overrides can't use 'extends'\")\n                        .build(),\n                ));\n            }\n            let base = new_theme.as_ref().unwrap_or(self.default_raw_theme);\n            // This shouldn't fail as the models are already correct.\n            let theme = merge_struct::merge(base, overrides)\n                .map_err(|e| BuildError::InvalidFrontmatter(format!(\"malformed theme: {e}\")))?;\n            new_theme = Some(theme);\n        }\n        if let Some(theme) = new_theme {\n            self.theme = PresentationTheme::new(&theme, &self.resources, &self.options.theme_options)?;\n        }\n        Ok(())\n    }\n\n    fn push_intro_slide(&mut self, metadata: PresentationMetadata) -> BuildResult {\n        let styles = &self.theme.intro_slide;\n\n        let create_text =\n            |text: Option<String>, style: TextStyle| -> Option<Text> { text.map(|text| Text::new(text, style)) };\n        let title_lines = metadata\n            .title\n            .map(|t| self.format_multiline(t, &self.theme.intro_slide.title.style, \"title\"))\n            .transpose()?;\n\n        let sub_title_lines = metadata\n            .sub_title\n            .map(|t| self.format_multiline(t, &self.theme.intro_slide.subtitle.style, \"sub_title\"))\n            .transpose()?;\n        let event = create_text(metadata.event, styles.event.style);\n        let location = create_text(metadata.location, styles.location.style);\n        let date = create_text(metadata.date, styles.date.style);\n        let authors: Vec<_> = metadata\n            .author\n            .into_iter()\n            .chain(metadata.authors)\n            .map(|author| Text::new(author, styles.author.style))\n            .collect();\n        if !styles.footer {\n            self.slide_state.ignore_footer = true;\n        }\n        self.chunk_operations.push(RenderOperation::JumpToVerticalCenter);\n        if let Some(title_lines) = title_lines {\n            for line in title_lines {\n                self.push_text(line, ElementType::PresentationTitle);\n                self.push_line_break();\n            }\n        }\n\n        if let Some(sub_title_lines) = sub_title_lines {\n            for line in sub_title_lines {\n                self.push_text(line, ElementType::PresentationSubTitle);\n                self.push_line_break();\n            }\n        }\n        if event.is_some() || location.is_some() || date.is_some() {\n            self.push_line_breaks(2);\n            if let Some(event) = event {\n                self.push_intro_slide_text(event, ElementType::PresentationEvent);\n            }\n            if let Some(location) = location {\n                self.push_intro_slide_text(location, ElementType::PresentationLocation);\n            }\n            if let Some(date) = date {\n                self.push_intro_slide_text(date, ElementType::PresentationDate);\n            }\n        }\n        if !authors.is_empty() {\n            match self.theme.intro_slide.author.positioning {\n                AuthorPositioning::BelowTitle => {\n                    self.push_line_breaks(3);\n                }\n                AuthorPositioning::PageBottom => {\n                    self.chunk_operations.push(RenderOperation::JumpToBottomRow { index: authors.len() as u16 - 1 });\n                }\n            };\n            for author in authors {\n                self.push_intro_slide_text(author, ElementType::PresentationAuthor);\n            }\n        }\n        self.slide_state.title = Some(Line::from(\"[Introduction]\"));\n        self.terminate_slide();\n        Ok(())\n    }\n\n    fn push_intro_slide_text(&mut self, text: Text, element_type: ElementType) {\n        self.push_text(Line::from(text), element_type);\n        self.push_line_break();\n    }\n\n    fn format_multiline(\n        &self,\n        text: String,\n        style: &TextStyle,\n        attribute: &'static str,\n    ) -> Result<Vec<Line>, BuildError> {\n        let arena = Arena::default();\n        let parser = MarkdownParser::new(&arena);\n        let mut lines = Vec::new();\n        for line in text.lines() {\n            let line = parser.parse_inlines(line).map_err(|e| {\n                BuildError::InvalidFrontmatter(\n                    ErrorContextBuilder::new(&format!(\"{attribute}: ...\"), &e.to_string())\n                        .column(attribute.len() + 3)\n                        .build(),\n                )\n            })?;\n\n            let mut line = line.resolve(&self.theme.palette)?;\n            line.apply_style(style);\n            lines.push(line);\n        }\n        Ok(lines)\n    }\n}\n\n#[derive(serde::Deserialize)]\n#[serde(deny_unknown_fields)]\nstruct StrictPresentationMetadata {\n    #[serde(default)]\n    title: Option<String>,\n\n    #[serde(default)]\n    sub_title: Option<String>,\n\n    #[serde(default)]\n    event: Option<String>,\n\n    #[serde(default)]\n    location: Option<String>,\n\n    #[serde(default)]\n    date: Option<String>,\n\n    #[serde(default)]\n    author: Option<String>,\n\n    #[serde(default)]\n    authors: Vec<String>,\n\n    #[serde(default)]\n    theme: PresentationThemeMetadata,\n\n    #[serde(default)]\n    options: Option<OptionsConfig>,\n}\n\nimpl From<StrictPresentationMetadata> for PresentationMetadata {\n    fn from(strict: StrictPresentationMetadata) -> Self {\n        let StrictPresentationMetadata { title, sub_title, event, location, date, author, authors, theme, options } =\n            strict;\n        Self { title, sub_title, event, location, date, author, authors, theme, options }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::{presentation::builder::utils::Test, theme::raw};\n\n    #[test]\n    fn multiline_centered_title() {\n        let input = \"---\ntitle: |\n    Beep\n    Boop boop\n---\n\";\n        let theme = raw::PresentationTheme {\n            intro_slide: raw::IntroSlideStyle {\n                title: raw::IntroSlideTitleStyle {\n                    alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(2), minimum_size: 1 }),\n                    ..Default::default()\n                },\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let lines = Test::new(input).theme(theme).render().rows(7).columns(16).advances(0).into_lines();\n        let expected = &[\n            \"                \",\n            \"                \",\n            \"      Beep      \",\n            \"   Boop boop    \",\n            \"                \",\n            \"                \",\n            \"                \",\n        ];\n        assert_eq!(lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/heading.rs",
    "content": "use crate::{\n    markdown::elements::{Line, Text},\n    presentation::builder::{BuildResult, LastElement, PresentationBuilder},\n    theme::{ElementType, raw::RawColor},\n    ui::separator::RenderSeparator,\n};\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn push_slide_title(&mut self, text: Vec<Line<RawColor>>) -> BuildResult {\n        if self.options.implicit_slide_ends && !matches!(self.slide_state.last_element, LastElement::None) {\n            self.terminate_slide();\n        }\n\n        let mut style = self.theme.slide_title.clone();\n        self.push_line_breaks(style.padding_top as usize);\n        for (index, title_line) in text.into_iter().enumerate() {\n            let mut title_line = title_line.resolve(&self.theme.palette)?;\n            self.slide_state.title.get_or_insert_with(|| title_line.clone());\n\n            if let (prefix, 0) = (&style.prefix, index) {\n                if !prefix.is_empty() {\n                    let mut prefix = prefix.clone();\n                    prefix.push(' ');\n                    title_line.0.insert(0, Text::from(prefix));\n                }\n            }\n            if let Some(font_size) = self.slide_state.font_size {\n                style.style = style.style.size(font_size);\n            }\n            title_line.apply_style(&style.style);\n            self.push_text(title_line, ElementType::SlideTitle);\n            self.push_line_break();\n        }\n\n        for _ in 0..style.padding_bottom {\n            self.push_line_break();\n        }\n        if style.separator {\n            self.chunk_operations\n                .push(RenderSeparator::new(Line::default(), Default::default(), style.style.size).into());\n            self.push_line_break();\n        }\n        self.push_line_break();\n        self.slide_state.ignore_element_line_break = true;\n        Ok(())\n    }\n\n    pub(crate) fn push_heading(&mut self, level: u8, text: Line<RawColor>) -> BuildResult {\n        if level == 1\n            && self.options.h1_slide_titles\n            && (self.slide_state.title.is_none() || self.options.implicit_slide_ends)\n        {\n            return self.push_slide_title(vec![text]);\n        }\n        let mut text = text.resolve(&self.theme.palette)?;\n        let (element_type, style) = match level {\n            1 => (ElementType::Heading1, &self.theme.headings.h1),\n            2 => (ElementType::Heading2, &self.theme.headings.h2),\n            3 => (ElementType::Heading3, &self.theme.headings.h3),\n            4 => (ElementType::Heading4, &self.theme.headings.h4),\n            5 => (ElementType::Heading5, &self.theme.headings.h5),\n            6 => (ElementType::Heading6, &self.theme.headings.h6),\n            other => panic!(\"unexpected heading level {other}\"),\n        };\n        if let Some(prefix) = &style.prefix {\n            if !prefix.is_empty() {\n                let mut prefix = prefix.clone();\n                prefix.push(' ');\n                text.0.insert(0, Text::from(prefix));\n            }\n        }\n        text.apply_style(&style.style);\n\n        self.push_text(text, element_type);\n        self.push_line_break();\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::{\n        markdown::text_style::Color,\n        presentation::builder::{PresentationBuilderOptions, utils::Test},\n        theme::raw,\n    };\n\n    #[test]\n    fn slide_title() {\n        let input = \"\ntitle\n===\n\nhi\n\";\n        let color = Color::new(1, 1, 1);\n        let theme = raw::PresentationTheme {\n            slide_title: raw::SlideTitleStyle {\n                separator: true,\n                padding_top: Some(1),\n                padding_bottom: Some(1),\n                colors: raw::RawColors { foreground: None, background: Some(raw::RawColor::Color(color)) },\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let lines = Test::new(input).theme(theme).render().rows(8).columns(5).into_lines();\n        let expected = &[\"     \", \"     \", \"title\", \"     \", \"—————\", \"     \", \"hi   \", \"     \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn slide_title_prefix() {\n        let input = \"---\ntheme:\n  override:\n    slide_title:\n      prefix: \\\"#\\\"\n---\n\ntitle\n===\n\n\";\n        let lines = Test::new(input).render().rows(2).columns(7).into_lines();\n        let expected = &[\"       \", \"# title\"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn h1_slide_title() {\n        let input = \"---\noptions:\n  h1_slide_titles: true\ntheme:\n  override:\n    slide_title:\n      separator: true\n---\n\n# title\n\nhi\n\";\n        let lines = Test::new(input).render().rows(6).columns(5).into_lines();\n        let expected = &[\"     \", \"title\", \"—————\", \"     \", \"hi   \", \"     \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn h1_slide_title_implicit_slides() {\n        let input = \"---\noptions:\n  h1_slide_titles: true\n  implicit_slide_ends: true\ntheme:\n  override:\n    slide_title:\n      separator: true\n---\n\n# title\n\nhi\n\n# other\n\nbye\n\";\n        let lines = Test::new(input).render().rows(8).columns(5).into_lines();\n        let expected = &[\"     \", \"title\", \"—————\", \"     \", \"hi   \", \"     \", \"     \", \"     \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn centered_slide_title() {\n        let input = \"\nhi\n===\n\n\";\n        let theme = raw::PresentationTheme {\n            slide_title: raw::SlideTitleStyle {\n                alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 0 }),\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let lines = Test::new(input).theme(theme).render().rows(3).columns(6).into_lines();\n        let expected = &[\"      \", \"  hi  \", \"      \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn implicit_slide_ends() {\n        let input = \"\nhi\n===\n\nfoo\n\nbye\n===\n\nbar\n\n\";\n        let options = PresentationBuilderOptions { implicit_slide_ends: true, ..Default::default() };\n        let lines = Test::new(input).options(options).render().rows(4).columns(6).advances(1).into_lines();\n        let expected = &[\"      \", \"bye   \", \"      \", \"bar   \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn headings() {\n        let input = \"\n# A\n## B\n### C\n#### D\n##### E\n\";\n        let theme = raw::PresentationTheme {\n            headings: raw::HeadingStyles {\n                h1: raw::HeadingStyle { prefix: Some(\"!\".to_string()), ..Default::default() },\n                h2: raw::HeadingStyle { prefix: Some(\"@@\".to_string()), ..Default::default() },\n                h3: raw::HeadingStyle {\n                    alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 0 }),\n                    ..Default::default()\n                },\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let lines = Test::new(input).theme(theme).render().rows(10).columns(6).advances(1).into_lines();\n        let expected =\n            &[\"      \", \"! A   \", \"      \", \"@@ B  \", \"      \", \"  C   \", \"      \", \"D     \", \"      \", \"E     \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn heading_font_size() {\n        let input = \"\n<!-- font_size: 3 -->\n# hi\n<!-- font_size: 2 -->\n## bye\n\";\n        let lines = Test::new(input).render().rows(6).columns(10).into_lines();\n        let expected = &[\n            //\n            \"          \",\n            \"h  i      \",\n            \"          \",\n            \"          \", // text end (3 rows)\n            \"          \", // a single new line\n            \"b y e     \", // the next text\n        ];\n        assert_eq!(lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/images.rs",
    "content": "use crate::{\n    markdown::elements::{Percent, PercentParseError, SourcePosition},\n    presentation::builder::{\n        BuildResult, PresentationBuilder,\n        error::{BuildError, InvalidPresentation},\n    },\n    render::operation::{ImageRenderProperties, ImageSize, RenderOperation},\n    terminal::image::Image,\n};\nuse std::path::PathBuf;\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn push_image_from_path(\n        &mut self,\n        path: PathBuf,\n        title: String,\n        source_position: SourcePosition,\n    ) -> BuildResult {\n        let base_path = self.resource_base_path();\n        let image = self.resources.image(&path, &base_path).map_err(|e| {\n            self.invalid_presentation(source_position, InvalidPresentation::LoadImage { path, error: e.to_string() })\n        })?;\n        self.push_image(image, title, source_position)\n    }\n\n    pub(crate) fn push_image(&mut self, image: Image, title: String, source_position: SourcePosition) -> BuildResult {\n        let attributes = self.parse_image_attributes(&title, &self.options.image_attribute_prefix, source_position)?;\n        let size = match attributes.width {\n            Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() },\n            None => ImageSize::ShrinkIfNeeded,\n        };\n        let properties = ImageRenderProperties {\n            size,\n            background_color: self.theme.default_style.style.colors.background,\n            ..Default::default()\n        };\n        self.chunk_operations.push(RenderOperation::RenderImage(image, properties));\n        Ok(())\n    }\n\n    fn parse_image_attributes(\n        &self,\n        input: &str,\n        attribute_prefix: &str,\n        source_position: SourcePosition,\n    ) -> Result<ImageAttributes, BuildError> {\n        let mut attributes = ImageAttributes::default();\n        for attribute in input.split(',') {\n            let Some((prefix, suffix)) = attribute.split_once(attribute_prefix) else { continue };\n            if !prefix.is_empty() || (attribute_prefix.is_empty() && suffix.is_empty()) {\n                continue;\n            }\n            Self::parse_image_attribute(suffix, &mut attributes)\n                .map_err(|e| self.invalid_presentation(source_position, e))?;\n        }\n        Ok(attributes)\n    }\n\n    fn parse_image_attribute(input: &str, attributes: &mut ImageAttributes) -> Result<(), ImageAttributeError> {\n        let Some((key, value)) = input.split_once(':') else {\n            return Err(ImageAttributeError::AttributeMissing);\n        };\n        match key {\n            \"width\" | \"w\" => {\n                let width = value.parse().map_err(ImageAttributeError::InvalidWidth)?;\n                attributes.width = Some(width);\n                Ok(())\n            }\n            _ => Err(ImageAttributeError::UnknownAttribute(key.to_string())),\n        }\n    }\n}\n\n#[derive(thiserror::Error, Debug)]\npub(crate) enum ImageAttributeError {\n    #[error(\"invalid width: {0}\")]\n    InvalidWidth(PercentParseError),\n\n    #[error(\"no attribute given\")]\n    AttributeMissing,\n\n    #[error(\"unknown attribute: '{0}'\")]\n    UnknownAttribute(String),\n}\n\n#[derive(Clone, Debug, Default, PartialEq)]\nstruct ImageAttributes {\n    width: Option<Percent>,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::presentation::builder::utils::Test;\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::width(\"image:width:50%\", Some(50))]\n    #[case::w(\"image:w:50%\", Some(50))]\n    #[case::nothing(\"\", None)]\n    #[case::no_prefix(\"width\", None)]\n    fn image_attributes(#[case] input: &str, #[case] expectation: Option<u8>) {\n        let attributes = Test::new(\"\").with_builder(|builder| {\n            builder.parse_image_attributes(input, \"image:\", Default::default()).expect(\"failed to parse\")\n        });\n        assert_eq!(attributes.width, expectation.map(Percent));\n    }\n\n    #[rstest]\n    #[case::width(\"width:50%\", Some(50))]\n    #[case::empty(\"\", None)]\n    fn image_attributes_empty_prefix(#[case] input: &str, #[case] expectation: Option<u8>) {\n        let attributes = Test::new(\"\").with_builder(|builder| {\n            builder.parse_image_attributes(input, \"\", Default::default()).expect(\"failed to parse\")\n        });\n        assert_eq!(attributes.width, expectation.map(Percent));\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/list.rs",
    "content": "use crate::{\n    markdown::{\n        elements::{ListItem, ListItemType, Text},\n        text_style::TextStyle,\n    },\n    presentation::builder::{BuildResult, LastElement, PresentationBuilder},\n    render::operation::{BlockLine, RenderOperation},\n    theme::ElementType,\n};\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn push_list(&mut self, list: Vec<ListItem>) -> BuildResult {\n        let last_chunk_operation = self.slide_chunks.last().and_then(|chunk| chunk.iter_operations().last());\n        // If the last chunk ended in a list, pop the newline so we get them all next to each\n        // other.\n        if matches!(last_chunk_operation, Some(RenderOperation::RenderLineBreak))\n            && self.slide_state.last_chunk_ended_in_list\n            && self.chunk_operations.is_empty()\n        {\n            self.slide_chunks.last_mut().unwrap().pop_last();\n        }\n        // If this chunk just starts (because there was a pause), pick up from the last index.\n        let start_index = match self.slide_state.last_element {\n            LastElement::List { last_index } if self.chunk_operations.is_empty() => last_index + 1,\n            _ => 0,\n        };\n\n        let block_length =\n            list.iter().map(|l| self.list_item_prefix(l).width() + l.contents.width()).max().unwrap_or_default() as u16;\n        let block_length = block_length * self.slide_font_size() as u16;\n        let incremental = self.slide_state.incremental_lists.unwrap_or(self.options.incremental_lists);\n        let iter = ListIterator::new(list, start_index);\n        if incremental && self.options.pause_before_incremental_lists {\n            self.push_pause();\n        }\n        for (index, item) in iter.enumerate() {\n            if index > 0 && incremental {\n                self.push_pause();\n            }\n            self.push_list_item(item.index, item.item, block_length)?;\n        }\n        if incremental && self.options.pause_after_incremental_lists {\n            self.push_pause();\n        }\n        Ok(())\n    }\n\n    fn push_list_item(&mut self, index: usize, item: ListItem, block_length: u16) -> BuildResult {\n        let prefix = self.list_item_prefix(&item);\n        let mut text = item.contents.resolve(&self.theme.palette)?;\n        let font_size = self.slide_font_size();\n        for piece in &mut text.0 {\n            self.apply_theme_text_style(piece);\n            piece.style = piece.style.size(font_size);\n        }\n        let alignment = self.slide_state.alignment.unwrap_or(self.theme.alignment(&ElementType::List));\n        self.chunk_operations.push(RenderOperation::RenderBlockLine(BlockLine {\n            prefix: prefix.into(),\n            right_padding_length: 0,\n            repeat_prefix_on_wrap: false,\n            text: text.into(),\n            block_length,\n            alignment,\n            block_color: None,\n        }));\n        let newlines = self.slide_state.list_item_newlines.unwrap_or(self.options.list_item_newlines);\n        self.push_line_breaks(newlines as usize);\n        if item.depth == 0 {\n            self.slide_state.last_element = LastElement::List { last_index: index };\n        }\n        Ok(())\n    }\n\n    fn list_item_prefix(&self, item: &ListItem) -> Text {\n        let font_size = self.slide_font_size();\n        let spaces_per_indent = match item.depth {\n            0 => 3_u8.div_ceil(font_size),\n            _ => {\n                if font_size == 1 {\n                    3\n                } else {\n                    2\n                }\n            }\n        };\n        let padding_length = (item.depth as usize + 1) * spaces_per_indent as usize;\n        let mut prefix: String = \" \".repeat(padding_length);\n        match item.item_type {\n            ListItemType::Unordered => {\n                let delimiter = match item.depth {\n                    0 => '•',\n                    1 => '◦',\n                    _ => '▪',\n                };\n                prefix.push(delimiter);\n                prefix.push_str(\"  \");\n            }\n            ListItemType::OrderedParens(value) => {\n                prefix.push_str(&value.to_string());\n                prefix.push_str(\") \");\n            }\n            ListItemType::OrderedPeriod(value) => {\n                prefix.push_str(&value.to_string());\n                prefix.push_str(\". \");\n            }\n        };\n        Text::new(prefix, TextStyle::default().size(font_size))\n    }\n}\n\nstruct ListIterator<I> {\n    remaining: I,\n    next_index: usize,\n    current_depth: u8,\n    saved_indexes: Vec<usize>,\n}\n\nimpl<I> ListIterator<I> {\n    fn new<T>(remaining: T, next_index: usize) -> Self\n    where\n        I: Iterator<Item = ListItem>,\n        T: IntoIterator<IntoIter = I, Item = ListItem>,\n    {\n        Self { remaining: remaining.into_iter(), next_index, current_depth: 0, saved_indexes: Vec::new() }\n    }\n}\n\nimpl<I> Iterator for ListIterator<I>\nwhere\n    I: Iterator<Item = ListItem>,\n{\n    type Item = IndexedListItem;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        let head = self.remaining.next()?;\n        if head.depth != self.current_depth {\n            if head.depth > self.current_depth {\n                // If we're going deeper, save the next index so we can continue later on and start\n                // from 0.\n                self.saved_indexes.push(self.next_index);\n                self.next_index = 0;\n            } else {\n                // if we're getting out, recover the index we had previously saved.\n                for _ in head.depth..self.current_depth {\n                    self.next_index = self.saved_indexes.pop().unwrap_or(0);\n                }\n            }\n            self.current_depth = head.depth;\n        }\n        let index = self.next_index;\n        self.next_index += 1;\n        Some(IndexedListItem { index, item: head })\n    }\n}\n\n#[derive(Debug)]\nstruct IndexedListItem {\n    index: usize,\n    item: ListItem,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::presentation::builder::{PresentationBuilderOptions, utils::Test};\n    use rstest::rstest;\n    use std::iter;\n\n    #[test]\n    fn iterate_list() {\n        let iter = ListIterator::new(\n            vec![\n                ListItem { depth: 0, contents: \"0\".into(), item_type: ListItemType::Unordered },\n                ListItem { depth: 0, contents: \"1\".into(), item_type: ListItemType::Unordered },\n                ListItem { depth: 1, contents: \"00\".into(), item_type: ListItemType::Unordered },\n                ListItem { depth: 1, contents: \"01\".into(), item_type: ListItemType::Unordered },\n                ListItem { depth: 1, contents: \"02\".into(), item_type: ListItemType::Unordered },\n                ListItem { depth: 2, contents: \"001\".into(), item_type: ListItemType::Unordered },\n                ListItem { depth: 0, contents: \"2\".into(), item_type: ListItemType::Unordered },\n            ],\n            0,\n        );\n        let expected_indexes = [0, 1, 0, 1, 2, 0, 2];\n        let indexes: Vec<_> = iter.map(|item| item.index).collect();\n        assert_eq!(indexes, expected_indexes);\n    }\n\n    #[test]\n    fn iterate_list_starting_from_other() {\n        let list = ListIterator::new(\n            vec![\n                ListItem { depth: 0, contents: \"0\".into(), item_type: ListItemType::Unordered },\n                ListItem { depth: 0, contents: \"1\".into(), item_type: ListItemType::Unordered },\n            ],\n            3,\n        );\n        let expected_indexes = [3, 4];\n        let indexes: Vec<_> = list.into_iter().map(|item| item.index).collect();\n        assert_eq!(indexes, expected_indexes);\n    }\n\n    #[test]\n    fn unordered() {\n        let input = \"\n* A\n    * AA\n        * AAA\n    * AB\n* B\n    * BA \n\";\n        let lines = Test::new(input).render().rows(7).columns(16).into_lines();\n        let expected = &[\n            \"                \",\n            \"   •  A         \",\n            \"      ◦  AA     \",\n            \"         ▪  AAA \",\n            \"      ◦  AB     \",\n            \"   •  B         \",\n            \"      ◦  BA     \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn unordered_paused() {\n        let input = \"\n* A\n<!-- pause -->\n* B\n<!-- pause -->\n* C\n\";\n        let lines = Test::new(input).render().rows(4).columns(8).into_lines();\n        let expected = &[\"        \", \"   •  A \", \"   •  B \", \"   •  C \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn ordered_period() {\n        let input = \"\n1. A\n    1. AA\n        1. AAA\n    2. AB\n2. B\n    1. BA \n\";\n        let lines = Test::new(input).render().rows(7).columns(16).into_lines();\n        let expected = &[\n            \"                \",\n            \"   1. A         \",\n            \"      1. AA     \",\n            \"         1. AAA \",\n            \"      2. AB     \",\n            \"   2. B         \",\n            \"      1. BA     \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn ordered_parens() {\n        let input = \"\n1) A\n    1) AA\n2) B\n\";\n        let lines = Test::new(input).render().rows(4).columns(12).into_lines();\n        let expected = &[\"            \", \"   1) A     \", \"      1) AA \", \"   2) B     \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn ordered_paused() {\n        let input = \"\n1. A\n<!-- pause -->\n2. B\n<!-- pause -->\n3. C\n\";\n        let lines = Test::new(input).render().rows(4).columns(8).into_lines();\n        let expected = &[\"        \", \"   1. A \", \"   2. B \", \"   3. C \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[rstest]\n    #[case::zero(0)]\n    #[case::one(1)]\n    #[case::two(2)]\n    fn visible_pauses(#[case] advances: usize) {\n        let input = \"\n* A\n<!-- pause -->\n* B\n<!-- pause -->\n* C\n\";\n        let lines = Test::new(input).render().rows(4).columns(8).advances(advances).into_lines();\n        let mut expected = vec![\"        \", \"   •  A \"];\n        if advances >= 1 {\n            expected.push(\"   •  B \");\n        }\n        if advances >= 2 {\n            expected.push(\"   •  C \");\n        }\n        expected.extend(iter::repeat_n(\"        \", 4 - expected.len()));\n        assert_eq!(lines, expected);\n    }\n\n    #[rstest]\n    #[case::first_no_before_no_after(true, true, 0, 0)]\n    #[case::first_no_before(false, true, 0, 1)]\n    #[case::second_no_before_no_after(true, true, 1, 1)]\n    #[case::second_no_before(false, true, 1, 2)]\n    #[case::second(false, false, 2, 4)]\n    #[case::third_no_before_no_after(true, true, 2, 2)]\n    #[case::third_no_before(false, true, 3, 4)]\n    #[case::third_no_after(true, false, 3, 4)]\n    fn incremental_lists(\n        #[case] pause_before: bool,\n        #[case] pause_after: bool,\n        #[case] advances: usize,\n        #[case] visible: usize,\n    ) {\n        let input = \"\n<!-- incremental_lists: true -->\n* A\n* B\n* C\n\nhi\n\";\n        let options = PresentationBuilderOptions {\n            pause_before_incremental_lists: pause_before,\n            pause_after_incremental_lists: pause_after,\n            ..Default::default()\n        };\n        let lines = Test::new(input).options(options).render().rows(6).columns(8).advances(advances).into_lines();\n        let mut expected = vec![\"        \"];\n        if visible >= 1 {\n            expected.push(\"   •  A \");\n        }\n        if visible >= 2 {\n            expected.push(\"   •  B \");\n        }\n        if visible >= 3 {\n            expected.push(\"   •  C \");\n        }\n        if visible >= 4 {\n            expected.push(\"        \");\n            expected.push(\"hi      \");\n        }\n        expected.extend(iter::repeat_n(\"        \", 6 - expected.len()));\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn font_size() {\n        let input = \"\n<!-- font_size: 2 -->\n* A\n* B\n\";\n        let lines = Test::new(input).render().rows(4).columns(12).into_lines();\n        let expected = &[\"            \", \"    •     A \", \"            \", \"    •     B \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn newlines() {\n        let input = \"\n<!-- list_item_newlines: 2 -->\n* A\n* B\n\";\n        let lines = Test::new(input).render().rows(4).columns(8).into_lines();\n        let expected = &[\"        \", \"   •  A \", \"        \", \"   •  B \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn incremental_lists_end_of_slide() {\n        let input = \"\n<!-- incremental_lists: true -->\n* A\n* B\n\n<!-- end_slide -->\n\nother\n\";\n        // 3 moves forward should land in the second slide, not an extra pause at the end\n        let lines = Test::new(input).render().rows(4).columns(8).advances(3).into_lines();\n        let expected = &[\"        \", \"other   \", \"        \", \"        \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn pause_after_list() {\n        let input = \"\n1. A\n\n<!-- pause -->\n\n# hi\n\n2. B\n\";\n        let lines = Test::new(input).render().rows(4).columns(8).advances(0).into_lines();\n        let expected = &[\"        \", \"   1. A \", \"        \", \"        \"];\n        assert_eq!(lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/mod.rs",
    "content": "use crate::{\n    code::{\n        execute::SnippetExecutor,\n        highlighting::{HighlightThemeSet, SnippetHighlighter},\n        snippet::SnippetLanguage,\n    },\n    config::{KeyBindingsConfig, OptionsConfig},\n    markdown::{\n        elements::{Line, MarkdownElement, SourcePosition, Text},\n        parse::MarkdownParser,\n        text::WeightedLine,\n        text_style::{Color, Colors},\n    },\n    presentation::{\n        ChunkMutator, Modals, Presentation, PresentationState, RenderOperation, SlideBuilder, SlideChunk,\n        builder::{\n            error::{BuildError, ErrorContextBuilder, FileSourcePosition, InvalidPresentation},\n            sources::MarkdownSources,\n        },\n    },\n    render::operation::MarginProperties,\n    resource::{ResourceBasePath, Resources},\n    terminal::image::{\n        Image,\n        printer::{ImageRegistry, ImageSpec, RegisterImageError},\n    },\n    theme::{\n        Alignment, ElementType, PresentationTheme, ProcessingThemeError, ThemeOptions,\n        raw::{self, RawColor},\n        registry::PresentationThemeRegistry,\n    },\n    third_party::ThirdPartyRender,\n    ui::{\n        execution::output::WrappedSnippetHandle,\n        footer::{FooterGenerator, FooterVariables},\n        modals::{IndexBuilder, KeyBindingsModalBuilder},\n        separator::RenderSeparator,\n    },\n};\nuse image::DynamicImage;\nuse std::{\n    collections::{HashMap, HashSet},\n    fs, io, iter, mem,\n    path::Path,\n    rc::Rc,\n    sync::Arc,\n};\n\npub(crate) mod error;\n\nmod comment;\npub(crate) use comment::CommentCommand;\n\nmod frontmatter;\nmod heading;\nmod images;\nmod list;\nmod quote;\nmod snippet;\nmod sources;\nmod table;\n\n#[cfg(test)]\nmod tests;\n\npub(crate) type BuildResult = Result<(), BuildError>;\n\n#[derive(Default)]\npub struct Themes {\n    pub presentation: PresentationThemeRegistry,\n    pub highlight: HighlightThemeSet,\n}\n\n#[derive(Clone, Debug)]\npub struct PresentationBuilderOptions {\n    pub allow_mutations: bool,\n    pub implicit_slide_ends: bool,\n    pub command_prefix: String,\n    pub image_attribute_prefix: String,\n    pub incremental_lists: bool,\n    pub incremental_tables: bool,\n    pub force_default_theme: bool,\n    pub end_slide_shorthand: bool,\n    pub print_modal_background: bool,\n    pub strict_front_matter_parsing: bool,\n    pub enable_snippet_execution: bool,\n    pub enable_snippet_execution_replace: bool,\n    pub render_speaker_notes_only: bool,\n    pub auto_render_languages: Vec<SnippetLanguage>,\n    pub theme_options: ThemeOptions,\n    pub pause_before_incremental_lists: bool,\n    pub pause_after_incremental_lists: bool,\n    pub pause_before_incremental_tables: bool,\n    pub pause_after_incremental_tables: bool,\n    pub pause_create_new_slide: bool,\n    pub list_item_newlines: u8,\n    pub validate_snippets: bool,\n    pub layout_grid: bool,\n    pub h1_slide_titles: bool,\n}\n\nimpl PresentationBuilderOptions {\n    fn merge(&mut self, options: OptionsConfig) {\n        self.implicit_slide_ends = options.implicit_slide_ends.unwrap_or(self.implicit_slide_ends);\n        self.incremental_lists = options.incremental_lists.unwrap_or(self.incremental_lists);\n        self.incremental_tables = options.incremental_tables.unwrap_or(self.incremental_tables);\n        self.end_slide_shorthand = options.end_slide_shorthand.unwrap_or(self.end_slide_shorthand);\n        self.strict_front_matter_parsing =\n            options.strict_front_matter_parsing.unwrap_or(self.strict_front_matter_parsing);\n        self.h1_slide_titles = options.h1_slide_titles.unwrap_or(self.h1_slide_titles);\n        if let Some(prefix) = options.command_prefix {\n            self.command_prefix = prefix;\n        }\n        if let Some(prefix) = options.image_attributes_prefix {\n            self.image_attribute_prefix = prefix;\n        }\n        if !options.auto_render_languages.is_empty() {\n            self.auto_render_languages = options.auto_render_languages;\n        }\n        if let Some(count) = options.list_item_newlines {\n            self.list_item_newlines = count.into();\n        }\n    }\n}\n\nimpl Default for PresentationBuilderOptions {\n    fn default() -> Self {\n        Self {\n            allow_mutations: true,\n            implicit_slide_ends: false,\n            command_prefix: String::default(),\n            image_attribute_prefix: \"image:\".to_string(),\n            incremental_lists: false,\n            incremental_tables: false,\n            force_default_theme: false,\n            end_slide_shorthand: false,\n            print_modal_background: false,\n            strict_front_matter_parsing: true,\n            enable_snippet_execution: false,\n            enable_snippet_execution_replace: false,\n            render_speaker_notes_only: false,\n            auto_render_languages: Default::default(),\n            theme_options: ThemeOptions { font_size_supported: false },\n            pause_before_incremental_lists: true,\n            pause_after_incremental_lists: true,\n            pause_before_incremental_tables: true,\n            pause_after_incremental_tables: true,\n            pause_create_new_slide: false,\n            list_item_newlines: 1,\n            validate_snippets: false,\n            layout_grid: false,\n            h1_slide_titles: false,\n        }\n    }\n}\n\n/// Builds a presentation.\n///\n/// This type transforms [MarkdownElement]s and turns them into a presentation, which is made up of\n/// render operations.\npub(crate) struct PresentationBuilder<'a, 'b> {\n    slide_chunks: Vec<SlideChunk>,\n    chunk_operations: Vec<RenderOperation>,\n    chunk_mutators: Vec<Box<dyn ChunkMutator>>,\n    slide_builders: Vec<SlideBuilder>,\n    highlighter: SnippetHighlighter,\n    snippet_executor: Arc<SnippetExecutor>,\n    theme: PresentationTheme,\n    default_raw_theme: &'a raw::PresentationTheme,\n    resources: Resources,\n    third_party: &'a mut ThirdPartyRender,\n    slide_state: SlideState,\n    presentation_state: PresentationState,\n    footer_vars: FooterVariables,\n    themes: &'a Themes,\n    index_builder: IndexBuilder,\n    image_registry: ImageRegistry,\n    bindings_config: KeyBindingsConfig,\n    slides_without_footer: HashSet<usize>,\n    markdown_parser: &'a MarkdownParser<'b>,\n    executable_snippets: HashMap<String, WrappedSnippetHandle>,\n    sources: MarkdownSources,\n    options: PresentationBuilderOptions,\n}\n\nimpl<'a, 'b> PresentationBuilder<'a, 'b> {\n    /// Construct a new builder.\n    #[allow(clippy::too_many_arguments)]\n    pub(crate) fn new(\n        default_raw_theme: &'a raw::PresentationTheme,\n        resources: Resources,\n        third_party: &'a mut ThirdPartyRender,\n        code_executor: Arc<SnippetExecutor>,\n        themes: &'a Themes,\n        image_registry: ImageRegistry,\n        bindings_config: KeyBindingsConfig,\n        markdown_parser: &'a MarkdownParser<'b>,\n        options: PresentationBuilderOptions,\n    ) -> Result<Self, ProcessingThemeError> {\n        let theme = PresentationTheme::new(default_raw_theme, &resources, &options.theme_options)?;\n        Ok(Self {\n            slide_chunks: Vec::new(),\n            chunk_operations: Vec::new(),\n            chunk_mutators: Vec::new(),\n            slide_builders: Vec::new(),\n            highlighter: SnippetHighlighter::default(),\n            snippet_executor: code_executor,\n            theme,\n            default_raw_theme,\n            resources,\n            third_party,\n            slide_state: Default::default(),\n            presentation_state: Default::default(),\n            footer_vars: Default::default(),\n            themes,\n            index_builder: Default::default(),\n            image_registry,\n            bindings_config,\n            slides_without_footer: HashSet::new(),\n            markdown_parser,\n            sources: Default::default(),\n            executable_snippets: Default::default(),\n            options,\n        })\n    }\n\n    /// Build a presentation from a markdown input.\n    pub(crate) fn build(self, path: &Path) -> Result<Presentation, BuildError> {\n        self.build_with_reader(path, FilesystemPresentationReader)\n    }\n\n    /// Build a presentation from already parsed elements.\n    pub(crate) fn build_from_parsed(mut self, elements: Vec<MarkdownElement>) -> Result<Presentation, BuildError> {\n        let mut skip_first = false;\n        if let Some(MarkdownElement::FrontMatter(contents)) = elements.first() {\n            self.process_front_matter(contents)?;\n            skip_first = true;\n        }\n        let mut elements = elements.into_iter();\n        if skip_first {\n            elements.next();\n        }\n\n        self.set_code_theme()?;\n\n        if self.chunk_operations.is_empty() {\n            self.push_slide_prelude();\n        }\n        for element in elements {\n            self.slide_state.ignore_element_line_break = false;\n            if self.options.render_speaker_notes_only {\n                self.process_element_for_speaker_notes_mode(element)?;\n            } else {\n                self.process_element_for_presentation_mode(element)?;\n            }\n            if !self.slide_state.ignore_element_line_break {\n                self.push_line_break();\n            }\n        }\n        if !self.chunk_operations.is_empty() || !self.slide_chunks.is_empty() {\n            self.terminate_slide();\n        }\n\n        // Always have at least one empty slide\n        if self.slide_builders.is_empty() {\n            self.terminate_slide();\n        }\n\n        let mut bindings_modal_builder = KeyBindingsModalBuilder::default();\n        if self.options.print_modal_background {\n            let background = self.build_modal_background()?;\n            self.index_builder.set_background(background.clone());\n            bindings_modal_builder.set_background(background);\n        };\n\n        let mut slides = Vec::new();\n        let builders = mem::take(&mut self.slide_builders);\n        self.footer_vars.total_slides = builders.len();\n        for (index, mut builder) in builders.into_iter().enumerate() {\n            self.footer_vars.current_slide = index + 1;\n            if !self.slides_without_footer.contains(&index) {\n                builder = builder.footer(self.generate_footer()?);\n            }\n            slides.push(builder.build());\n        }\n\n        let bindings = bindings_modal_builder.build(&self.theme, &self.bindings_config);\n        let slide_index = self.index_builder.build(&self.theme, self.presentation_state.clone());\n        let modals = Modals { slide_index, bindings };\n        let presentation = Presentation::new(slides, modals, self.presentation_state);\n        Ok(presentation)\n    }\n\n    fn build_with_reader<F: PresentationReader>(self, path: &Path, reader: F) -> Result<Presentation, BuildError> {\n        let _guard = self.sources.enter(path).map_err(BuildError::EnterRoot)?;\n        let contents = reader.read(path).map_err(|e| BuildError::ReadPresentation(path.into(), e))?;\n        let elements = self.markdown_parser.parse(&contents).map_err(|error| {\n            let context =\n                ErrorContextBuilder::new(&contents, &error.kind.to_string()).position(error.sourcepos).build();\n            BuildError::Parse { path: path.into(), error, context }\n        })?;\n        self.build_from_parsed(elements)\n    }\n\n    fn build_modal_background(&self) -> Result<Image, RegisterImageError> {\n        let color = self.theme.modals.style.colors.background.as_ref().and_then(Color::as_rgb);\n        // If we don't have an rgb color (or we don't have a color at all), we default to a dark\n        // background.\n        let rgba = match color {\n            Some((r, g, b)) => [r, g, b, 255],\n            None => [0, 0, 0, 255],\n        };\n        let mut image = DynamicImage::new_rgba8(1, 1);\n        image.as_mut_rgba8().unwrap().get_pixel_mut(0, 0).0 = rgba;\n        let image = self.image_registry.register(ImageSpec::Generated(image))?;\n        Ok(image)\n    }\n\n    fn validate_last_operation(&mut self) -> BuildResult {\n        if !self.slide_state.needs_enter_column {\n            return Ok(());\n        }\n        let Some(last) = self.chunk_operations.last() else {\n            return Ok(());\n        };\n        if matches!(last, RenderOperation::InitColumnLayout { .. }) {\n            return Ok(());\n        }\n        self.slide_state.needs_enter_column = false;\n        let last_valid = matches!(last, RenderOperation::EnterColumn { .. } | RenderOperation::ExitLayout);\n        if last_valid {\n            Ok(())\n        } else {\n            let position = self.slide_state.last_layout_comment.as_ref().expect(\"no last position\");\n            let context = fs::read_to_string(&position.file)\n                .ok()\n                .map(|s| {\n                    ErrorContextBuilder::new(&s, \"layout was created here\").position(position.source_position).build()\n                })\n                .unwrap_or_default();\n            Err(BuildError::NotInsideColumn(context))\n        }\n    }\n\n    fn set_colors(&mut self, colors: Colors) {\n        self.chunk_operations.push(RenderOperation::SetColors(colors));\n    }\n\n    fn push_slide_prelude(&mut self) {\n        let style = self.theme.default_style.style;\n        self.set_colors(style.colors);\n\n        let footer_height = self.theme.footer.height();\n        self.chunk_operations.extend([\n            RenderOperation::ClearScreen,\n            RenderOperation::ApplyMargin(MarginProperties {\n                horizontal: self.theme.default_style.margin,\n                top: 0,\n                bottom: footer_height,\n            }),\n        ]);\n        self.push_line_break();\n    }\n\n    fn process_element_for_presentation_mode(&mut self, element: MarkdownElement) -> BuildResult {\n        let should_clear_last = !matches!(element, MarkdownElement::List(_) | MarkdownElement::Comment { .. });\n        match element {\n            // This one is processed before everything else as it affects how the rest of the\n            // elements is rendered.\n            MarkdownElement::FrontMatter(_) => self.slide_state.ignore_element_line_break = true,\n            MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?,\n            MarkdownElement::Heading { level, text } => self.push_heading(level, text)?,\n            MarkdownElement::Paragraph(elements) => self.push_paragraph(elements)?,\n            MarkdownElement::List(elements) => self.push_list(elements)?,\n            MarkdownElement::Snippet { info, code, source_position } => self.push_code(info, code, source_position)?,\n            MarkdownElement::Table(table) => self.push_table(table)?,\n            MarkdownElement::ThematicBreak => self.process_thematic_break(),\n            MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?,\n            MarkdownElement::BlockQuote(lines) => self.push_block_quote(lines)?,\n            MarkdownElement::Image { path, title, source_position } => {\n                self.push_image_from_path(path, title, source_position)?\n            }\n            MarkdownElement::Alert { alert_type, title, lines } => self.push_alert(alert_type, title, lines)?,\n            MarkdownElement::Footnote(line) => {\n                let line = line.resolve(&self.theme.palette)?;\n                self.push_text(line, ElementType::Paragraph);\n            }\n        };\n        if should_clear_last {\n            self.slide_state.last_element = LastElement::Other;\n        }\n        self.validate_last_operation()?;\n        Ok(())\n    }\n\n    fn process_element_for_speaker_notes_mode(&mut self, element: MarkdownElement) -> BuildResult {\n        match element {\n            MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?,\n            MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?,\n            MarkdownElement::ThematicBreak => {\n                if self.options.end_slide_shorthand {\n                    self.terminate_slide();\n                    self.slide_state.ignore_element_line_break = true;\n                }\n            }\n            _ => {}\n        }\n        // Allows us to start the next speaker slide when a title is pushed and implicit_slide_ends is enabled.\n        self.slide_state.last_element = LastElement::Other;\n        self.slide_state.ignore_element_line_break = true;\n        Ok(())\n    }\n\n    fn set_code_theme(&mut self) -> BuildResult {\n        let theme = &self.theme.code.theme_name;\n        let highlighter =\n            self.themes.highlight.load_by_name(theme).ok_or_else(|| BuildError::InvalidCodeTheme(theme.clone()))?;\n        self.highlighter = highlighter;\n        Ok(())\n    }\n\n    fn invalid_presentation<E>(&self, source_position: SourcePosition, error: E) -> BuildError\n    where\n        E: Into<InvalidPresentation>,\n    {\n        let error = error.into();\n        let source_position = self.sources.resolve_source_position(source_position);\n        let context = fs::read_to_string(&source_position.file)\n            .ok()\n            .map(|s| ErrorContextBuilder::new(&s, &error.to_string()).position(source_position.source_position).build())\n            .unwrap_or_default();\n\n        let FileSourcePosition { source_position, file } = source_position;\n        BuildError::InvalidPresentation { source_position, path: file, context }\n    }\n\n    fn resource_base_path(&self) -> ResourceBasePath {\n        ResourceBasePath::Custom(self.sources.current_base_path())\n    }\n\n    fn validate_column_layout(&self, columns: &[u8], source_position: SourcePosition) -> BuildResult {\n        if columns.is_empty() {\n            Err(self\n                .invalid_presentation(source_position, InvalidPresentation::InvalidLayout(\"need at least one column\")))\n        } else if columns.iter().any(|column| column == &0) {\n            Err(self.invalid_presentation(\n                source_position,\n                InvalidPresentation::InvalidLayout(\"can't have zero sized columns\"),\n            ))\n        } else {\n            Ok(())\n        }\n    }\n\n    fn push_pause(&mut self) {\n        if self.options.pause_create_new_slide {\n            let operations = self.chunk_operations.clone();\n            let slide_state = self.slide_state.clone();\n            self.terminate_slide();\n            self.chunk_operations = operations;\n            self.slide_state = slide_state;\n            return;\n        }\n        self.slide_state.last_chunk_ended_in_list = matches!(self.slide_state.last_element, LastElement::List { .. });\n\n        let chunk_operations = mem::take(&mut self.chunk_operations);\n        let mutators = mem::take(&mut self.chunk_mutators);\n        self.slide_chunks.push(SlideChunk::new(chunk_operations, mutators));\n    }\n\n    fn push_paragraph(&mut self, lines: Vec<Line<RawColor>>) -> BuildResult {\n        for line in lines {\n            let line = line.resolve(&self.theme.palette)?;\n            self.push_text(line, ElementType::Paragraph);\n            self.push_line_breaks(self.slide_font_size() as usize);\n        }\n        Ok(())\n    }\n\n    fn process_thematic_break(&mut self) {\n        if self.options.end_slide_shorthand {\n            self.terminate_slide();\n            self.slide_state.ignore_element_line_break = true;\n        } else {\n            self.chunk_operations.extend([\n                RenderSeparator::new(Line::default(), Default::default(), self.slide_font_size()).into(),\n                RenderOperation::RenderLineBreak,\n            ]);\n        }\n    }\n\n    fn push_text(&mut self, line: Line, element_type: ElementType) {\n        let alignment = self.slide_state.alignment.unwrap_or_else(|| self.theme.alignment(&element_type));\n        self.push_aligned_text(line, alignment);\n    }\n\n    fn push_aligned_text(&mut self, mut block: Line, alignment: Alignment) {\n        let default_font_size = self.slide_font_size();\n        for chunk in &mut block.0 {\n            self.apply_theme_text_style(chunk);\n            if default_font_size > 1 {\n                chunk.style = chunk.style.size(default_font_size);\n            }\n        }\n        if !block.0.is_empty() {\n            self.chunk_operations.push(RenderOperation::RenderText { line: WeightedLine::from(block), alignment });\n        }\n    }\n\n    fn push_line_break(&mut self) {\n        self.push_line_breaks(1)\n    }\n\n    fn push_line_breaks(&mut self, count: usize) {\n        self.chunk_operations.extend(iter::repeat_n(RenderOperation::RenderLineBreak, count));\n    }\n\n    fn terminate_slide(&mut self) {\n        let operations = mem::take(&mut self.chunk_operations);\n        let mutators = mem::take(&mut self.chunk_mutators);\n        // Don't allow a last empty pause in slide since it adds nothing\n        if self.slide_chunks.is_empty() || !Self::is_chunk_empty(&operations) {\n            self.slide_chunks.push(SlideChunk::new(operations, mutators));\n        }\n        let chunks = mem::take(&mut self.slide_chunks);\n\n        if !self.slide_state.skip_slide {\n            let builder = SlideBuilder::default().chunks(chunks);\n            self.index_builder\n                .add_title(self.slide_state.title.take().unwrap_or_else(|| Text::from(\"<no title>\").into()));\n\n            if self.slide_state.ignore_footer {\n                self.slides_without_footer.insert(self.slide_builders.len());\n            }\n            self.slide_builders.push(builder);\n        }\n\n        self.push_slide_prelude();\n        self.slide_state = Default::default();\n    }\n\n    fn apply_theme_text_style(&self, text: &mut Text) {\n        if text.style.is_code() {\n            text.style.merge(&self.theme.inline_code.style);\n        }\n        if text.style.is_bold() {\n            text.style.merge(&self.theme.bold.style);\n        }\n        if text.style.is_italics() {\n            text.style.merge(&self.theme.italics.style);\n        }\n    }\n\n    fn is_chunk_empty(operations: &[RenderOperation]) -> bool {\n        if operations.is_empty() {\n            return true;\n        }\n        for operation in operations {\n            if !matches!(operation, RenderOperation::RenderLineBreak) {\n                return false;\n            }\n        }\n        true\n    }\n\n    fn generate_footer(&self) -> Result<Vec<RenderOperation>, BuildError> {\n        let generator = FooterGenerator::new(self.theme.footer.clone(), &self.footer_vars, &self.theme.palette)?;\n        Ok(vec![\n            // Exit any layout we're in so this gets rendered on a default screen size.\n            RenderOperation::ExitLayout,\n            // Pop the slide margin so we're at the terminal rect.\n            RenderOperation::PopMargin,\n            RenderOperation::RenderDynamic(Rc::new(generator)),\n        ])\n    }\n\n    fn slide_font_size(&self) -> u8 {\n        let font_size = self.slide_state.font_size.unwrap_or(1);\n        if self.options.theme_options.font_size_supported { font_size.clamp(1, 7) } else { 1 }\n    }\n}\n\ntrait PresentationReader {\n    fn read(&self, path: &Path) -> io::Result<String>;\n}\n\nstruct FilesystemPresentationReader;\n\nimpl PresentationReader for FilesystemPresentationReader {\n    fn read(&self, path: &Path) -> io::Result<String> {\n        fs::read_to_string(path)\n    }\n}\n\n#[derive(Clone, Debug, Default)]\nstruct SlideState {\n    ignore_element_line_break: bool,\n    ignore_footer: bool,\n    needs_enter_column: bool,\n    last_chunk_ended_in_list: bool,\n    last_element: LastElement,\n    incremental_lists: Option<bool>,\n    incremental_tables: Option<bool>,\n    list_item_newlines: Option<u8>,\n    layout: LayoutState,\n    title: Option<Line>,\n    font_size: Option<u8>,\n    alignment: Option<Alignment>,\n    skip_slide: bool,\n    last_layout_comment: Option<FileSourcePosition>,\n}\n\n#[derive(Clone, Debug, Default)]\nenum LayoutState {\n    #[default]\n    Default,\n    InLayout {\n        columns_count: usize,\n    },\n    InColumn {\n        column: usize,\n        columns_count: usize,\n    },\n}\n\n#[derive(Clone, Debug, Default)]\nenum LastElement {\n    #[default]\n    None,\n    List {\n        last_index: usize,\n    },\n    Other,\n}\n\n#[cfg(test)]\npub(crate) mod utils {\n    use super::*;\n    use crate::{\n        render::{engine::RenderEngine, operation::RenderAsyncStartPolicy, properties::WindowSize},\n        terminal::virt::VirtualTerminal,\n    };\n    use std::{path::PathBuf, thread::sleep, time::Duration};\n\n    struct MemoryPresentationReader {\n        contents: String,\n    }\n\n    impl PresentationReader for MemoryPresentationReader {\n        fn read(&self, _path: &Path) -> io::Result<String> {\n            Ok(self.contents.clone())\n        }\n    }\n\n    pub(crate) enum Input {\n        Markdown(String),\n        Parsed(Vec<MarkdownElement>),\n    }\n\n    impl From<&'_ str> for Input {\n        fn from(value: &'_ str) -> Self {\n            Self::Markdown(value.to_string())\n        }\n    }\n\n    impl From<String> for Input {\n        fn from(value: String) -> Self {\n            Self::Markdown(value)\n        }\n    }\n\n    impl From<Vec<MarkdownElement>> for Input {\n        fn from(value: Vec<MarkdownElement>) -> Self {\n            Self::Parsed(value)\n        }\n    }\n\n    pub(crate) struct Test {\n        input: Input,\n        options: PresentationBuilderOptions,\n        resources_path: PathBuf,\n        theme: raw::PresentationTheme,\n    }\n\n    impl Test {\n        pub(crate) fn new<T: Into<Input>>(input: T) -> Self {\n            let options = PresentationBuilderOptions {\n                enable_snippet_execution: true,\n                enable_snippet_execution_replace: true,\n                theme_options: ThemeOptions { font_size_supported: true },\n                ..Default::default()\n            };\n            Self { input: input.into(), options, resources_path: std::env::temp_dir(), theme: Default::default() }\n        }\n\n        pub(crate) fn options(mut self, options: PresentationBuilderOptions) -> Self {\n            self.options = options;\n            self\n        }\n\n        pub(crate) fn resources_path<P: Into<PathBuf>>(mut self, path: P) -> Self {\n            self.resources_path = path.into();\n            self\n        }\n\n        pub(crate) fn theme(mut self, theme: raw::PresentationTheme) -> Self {\n            self.theme = theme;\n            self\n        }\n\n        pub(crate) fn disable_exec_replace(mut self) -> Self {\n            self.options.enable_snippet_execution_replace = false;\n            self\n        }\n\n        pub(crate) fn disable_exec(mut self) -> Self {\n            self.options.enable_snippet_execution = false;\n            self\n        }\n\n        pub(crate) fn with_builder<T, F>(&self, callback: F) -> T\n        where\n            F: for<'a, 'b> Fn(PresentationBuilder<'a, 'b>) -> T,\n        {\n            let theme = &self.theme;\n            let resources = Resources::new(&self.resources_path, &self.resources_path, Default::default());\n            let mut third_party = ThirdPartyRender::default();\n            let code_executor = Arc::new(SnippetExecutor::default());\n            let themes = Themes::default();\n            let bindings = KeyBindingsConfig::default();\n            let arena = Default::default();\n            let parser = MarkdownParser::new(&arena);\n            let builder = PresentationBuilder::new(\n                theme,\n                resources,\n                &mut third_party,\n                code_executor,\n                &themes,\n                Default::default(),\n                bindings,\n                &parser,\n                self.options.clone(),\n            )\n            .expect(\"failed to create builder\");\n            callback(builder)\n        }\n\n        pub(crate) fn render(self) -> PresentationRender {\n            let presentation = self.build();\n            PresentationRender::new(presentation)\n        }\n\n        pub(crate) fn build(self) -> Presentation {\n            self.try_build().expect(\"build failed\")\n        }\n\n        pub(crate) fn expect_invalid(self) -> BuildError {\n            self.try_build().expect_err(\"build succeeded\")\n        }\n\n        pub(crate) fn try_build(self) -> Result<Presentation, BuildError> {\n            self.with_builder(|builder| match &self.input {\n                Input::Markdown(input) => {\n                    let reader = MemoryPresentationReader { contents: input.clone() };\n                    let path = self.resources_path.join(\"presentation.md\");\n                    builder.build_with_reader(&path, reader)\n                }\n                Input::Parsed(elements) => builder.build_from_parsed(elements.clone()),\n            })\n        }\n    }\n\n    pub(crate) struct PresentationRender {\n        presentation: Presentation,\n        columns: Option<u16>,\n        rows: Option<u16>,\n        run_async_renders: RunAsyncRendersPolicy,\n        background_maps: Vec<(Color, char)>,\n        advances: Option<usize>,\n    }\n\n    impl PresentationRender {\n        fn new(presentation: Presentation) -> Self {\n            Self {\n                presentation,\n                columns: None,\n                rows: None,\n                run_async_renders: RunAsyncRendersPolicy::All,\n                background_maps: Default::default(),\n                advances: None,\n            }\n        }\n\n        pub(crate) fn rows(mut self, rows: u16) -> Self {\n            self.rows = Some(rows);\n            self\n        }\n\n        pub(crate) fn columns(mut self, columns: u16) -> Self {\n            self.columns = Some(columns);\n            self\n        }\n\n        pub(crate) fn advances(mut self, number: usize) -> Self {\n            self.advances = Some(number);\n            self\n        }\n\n        pub(crate) fn run_async_renders(mut self, policy: RunAsyncRendersPolicy) -> Self {\n            self.run_async_renders = policy;\n            self\n        }\n\n        pub(crate) fn map_background(mut self, color: Color, c: char) -> Self {\n            self.background_maps.push((color, c));\n            self\n        }\n\n        pub(crate) fn into_lines(self) -> Vec<String> {\n            self.into_parts().0\n        }\n\n        pub(crate) fn into_parts(self) -> (Vec<String>, Vec<String>) {\n            let Self { mut presentation, columns, rows, run_async_renders, background_maps, advances } = self;\n            let columns = columns.expect(\"no columns\");\n            let rows = rows.expect(\"no rows\");\n            let dimensions = WindowSize { rows, columns, width: 0, height: 0 };\n            let only_visible = advances.is_some();\n            if let Some(advances) = advances {\n                for _ in 0..advances {\n                    presentation.jump_next();\n                }\n            }\n\n            let slide = presentation.current_slide_mut();\n            for operation in slide.iter_operations_mut() {\n                if let RenderOperation::RenderAsync(operation) = operation {\n                    let mut pollable = operation.pollable();\n                    let run = match &run_async_renders {\n                        RunAsyncRendersPolicy::None => false,\n                        RunAsyncRendersPolicy::All => true,\n                        RunAsyncRendersPolicy::OnlyAutomatic => {\n                            matches!(operation.start_policy(), RenderAsyncStartPolicy::Automatic)\n                        }\n                    };\n                    if !run {\n                        continue;\n                    }\n                    while !pollable.poll().is_completed() {\n                        sleep(Duration::from_millis(1));\n                    }\n                }\n            }\n\n            let mut term = VirtualTerminal::new(dimensions, Default::default());\n            let engine = RenderEngine::new(&mut term, dimensions, Default::default());\n            if only_visible {\n                engine.render(slide.iter_visible_operations()).expect(\"failed to render\");\n            } else {\n                engine.render(slide.iter_operations()).expect(\"failed to render\");\n            }\n            let mut lines = Vec::new();\n            let mut styles = Vec::new();\n            for row in term.into_contents().rows {\n                let mut line = String::new();\n                let mut style = String::new();\n                for character in &row {\n                    let style_char = background_maps\n                        .iter()\n                        .filter_map(|(b, c)| (character.style.colors.background == Some(*b)).then_some(c))\n                        .next()\n                        .unwrap_or(&' ');\n                    line.push(character.character);\n                    style.push(*style_char);\n                }\n                lines.push(line);\n                styles.push(style);\n            }\n            (lines, styles)\n        }\n    }\n\n    pub(crate) enum RunAsyncRendersPolicy {\n        None,\n        All,\n        OnlyAutomatic,\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/quote.rs",
    "content": "use crate::{\n    markdown::{\n        elements::{Line, Text},\n        text_style::{Colors, TextStyle},\n    },\n    presentation::builder::{BuildResult, PresentationBuilder},\n    render::operation::{BlockLine, RenderOperation},\n    theme::{Alignment, ElementType, raw::RawColor},\n};\nuse comrak::nodes::AlertType;\nuse unicode_width::UnicodeWidthStr;\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn push_block_quote(&mut self, lines: Vec<Line<RawColor>>) -> BuildResult {\n        let prefix = self.theme.block_quote.prefix.clone();\n        let prefix_style = self.theme.block_quote.prefix_style;\n        self.push_quoted_text(\n            lines,\n            prefix,\n            self.theme.block_quote.base_style.colors,\n            prefix_style,\n            self.theme.alignment(&ElementType::BlockQuote),\n        )\n    }\n\n    pub(crate) fn push_alert(\n        &mut self,\n        alert_type: AlertType,\n        title: Option<String>,\n        mut lines: Vec<Line<RawColor>>,\n    ) -> BuildResult {\n        let style = match alert_type {\n            AlertType::Note => &self.theme.alert.styles.note,\n            AlertType::Tip => &self.theme.alert.styles.tip,\n            AlertType::Important => &self.theme.alert.styles.important,\n            AlertType::Warning => &self.theme.alert.styles.warning,\n            AlertType::Caution => &self.theme.alert.styles.caution,\n        };\n\n        let title = format!(\"{} {}\", style.icon, title.as_deref().unwrap_or(style.title.as_ref()));\n        lines.insert(0, Line::from(Text::from(\"\")));\n        lines.insert(0, Line::from(Text::new(title, style.style.into_raw())));\n\n        let prefix = self.theme.alert.prefix.clone();\n        self.push_quoted_text(\n            lines,\n            prefix,\n            self.theme.alert.base_style.colors,\n            style.style,\n            self.theme.alert.alignment,\n        )\n    }\n\n    fn push_quoted_text(\n        &mut self,\n        lines: Vec<Line<RawColor>>,\n        prefix: String,\n        base_colors: Colors,\n        prefix_style: TextStyle,\n        alignment: Alignment,\n    ) -> BuildResult {\n        let block_length = lines.iter().map(|line| line.width() + prefix.width()).max().unwrap_or(0) as u16;\n        let font_size = self.slide_font_size();\n        let prefix = Text::new(prefix, prefix_style.size(font_size));\n\n        for line in lines {\n            let mut line = line.resolve(&self.theme.palette)?;\n            // Apply our colors to each chunk in this line.\n            for text in &mut line.0 {\n                if text.style.colors.background.is_none() && text.style.colors.foreground.is_none() {\n                    text.style.colors = base_colors;\n                    self.apply_theme_text_style(text);\n                }\n                text.style = text.style.size(font_size);\n            }\n            self.chunk_operations.push(RenderOperation::RenderBlockLine(BlockLine {\n                prefix: prefix.clone().into(),\n                right_padding_length: 0,\n                repeat_prefix_on_wrap: true,\n                text: line.into(),\n                block_length,\n                alignment,\n                block_color: base_colors.background,\n            }));\n            self.push_line_break();\n        }\n        self.set_colors(self.theme.default_style.style.colors);\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::{markdown::text_style::Color, presentation::builder::utils::Test, theme::raw};\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::left_no_margin(raw::Alignment::Left{ margin: raw::Margin::Fixed(0) },\"▍ hi   \", \"XXXXXXX\", )]\n    #[case::left_one_margin(raw::Alignment::Left{ margin: raw::Margin::Fixed(1) }, \" ▍ hi  \", \" XXXXX \")]\n    #[case::center(raw::Alignment::Center{ minimum_margin: raw::Margin::Fixed(0), minimum_size: 0 }, \" ▍ hi  \", \" XXXX  \")]\n    #[test]\n    fn quote(#[case] alignment: raw::Alignment, #[case] line: &str, #[case] style: &str) {\n        let input = \"\n> hi\n> hi\n\";\n        let color = Color::new(1, 1, 1);\n        let theme = raw::PresentationTheme {\n            block_quote: raw::BlockQuoteStyle {\n                colors: raw::BlockQuoteColors {\n                    base: raw::RawColors { foreground: None, background: Some(raw::RawColor::Color(color)) },\n                    prefix: None,\n                },\n                alignment: Some(alignment),\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let (lines, styles) =\n            Test::new(input).theme(theme).render().map_background(color, 'X').rows(4).columns(7).into_parts();\n        let expected_lines = &[\"       \", line, line, \"       \"];\n        let expected_styles = &[\"       \", style, style, \"       \"];\n        assert_eq!(lines, expected_lines);\n        assert_eq!(styles, expected_styles);\n    }\n\n    #[test]\n    fn alert() {\n        let input = \"\n> [!note]\n> hi\n\";\n        let theme = raw::PresentationTheme {\n            alert: raw::AlertStyle {\n                styles: raw::AlertTypeStyles {\n                    note: raw::AlertTypeStyle { icon: Some(\"!\".to_string()), ..Default::default() },\n                    ..Default::default()\n                },\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let lines = Test::new(input).theme(theme).render().rows(5).columns(9).into_lines();\n        let expected = &[\"         \", \"▍ ! Note \", \"▍        \", \"▍ hi     \", \"         \"];\n        assert_eq!(lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/snippet.rs",
    "content": "use super::{BuildError, BuildResult};\nuse crate::{\n    code::{\n        execute::LanguageSnippetExecutor,\n        snippet::{\n            ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, PtyArgs,\n            Snippet, SnippetExecArgs, SnippetExecution, SnippetExecutorSpec, SnippetLanguage, SnippetLine,\n            SnippetParser, SnippetRepr, SnippetSplitter,\n        },\n    },\n    markdown::elements::SourcePosition,\n    presentation::builder::{PresentationBuilder, error::InvalidPresentation},\n    render::{\n        operation::{AsRenderOperations, RenderAsyncStartPolicy, RenderOperation},\n        properties::WindowSize,\n    },\n    theme::{Alignment, CodeBlockStyle},\n    third_party::ThirdPartyRenderRequest,\n    ui::execution::{\n        RunAcquireTerminalSnippet, RunImageSnippet, SnippetExecutionDisabledOperation, SnippetOutputOperation,\n        disabled::ExecutionType,\n        output::{ExecIndicator, ExecIndicatorStyle, RunSnippetTrigger, SnippetHandle, WrappedSnippetHandle},\n        pty::{PtySnippetHandle, PtySnippetOutputOperation, RunPtySnippetTrigger},\n        validator::ValidateSnippetOperation,\n    },\n};\nuse itertools::Itertools;\nuse std::{cell::RefCell, rc::Rc};\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn push_code(&mut self, info: String, code: String, source_position: SourcePosition) -> BuildResult {\n        let mut snippet = SnippetParser::parse(info, code)\n            .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Snippet(e.to_string())))?;\n        if matches!(snippet.language, SnippetLanguage::File) {\n            snippet = self.load_external_snippet(snippet, source_position)?;\n        }\n        if self.theme.code.line_numbers {\n            snippet.attributes.line_numbers = true;\n        }\n        if self.options.auto_render_languages.contains(&snippet.language) {\n            snippet.attributes.execution = SnippetExecution::Render;\n        }\n        // Ids can only be used in `+exec` snippets.\n        if snippet.attributes.id.is_some()\n            && !matches!(\n                snippet.attributes.execution,\n                SnippetExecution::Exec(SnippetExecArgs { repr: SnippetRepr::SnippetOutput, .. })\n            )\n        {\n            return Err(self.invalid_presentation(source_position, InvalidPresentation::SnippetIdNonExec));\n        }\n\n        self.push_differ(snippet.contents.clone());\n        // Redraw slide if attributes change\n        self.push_differ(format!(\"{:?}\", snippet.attributes));\n\n        if let Some(spec) = &snippet.attributes.validate {\n            // Take the execution spec if we use the default one, otherwise use the one provided.\n            // This allows using `rust +exec:foo +validate` and using `foo` as the executor.\n            let spec = match (spec, &snippet.attributes.execution) {\n                (SnippetExecutorSpec::Default, SnippetExecution::Exec(args)) => &args.spec,\n                _ => spec,\n            };\n            let executor = self.snippet_executor.language_executor(&snippet.language, spec)?;\n            self.push_validator(&snippet, &executor);\n        }\n\n        match &snippet.attributes.execution {\n            SnippetExecution::None => {\n                self.push_code_lines(&snippet);\n            }\n            SnippetExecution::Render => {\n                self.push_rendered_code(snippet, source_position)?;\n            }\n            SnippetExecution::Exec(args) if !self.is_execution_allowed(args) => {\n                self.push_code_lines(&snippet);\n                let mut exec_type = match args.repr {\n                    SnippetRepr::Image => ExecutionType::Image,\n                    SnippetRepr::ExecReplace => ExecutionType::ExecReplace,\n                    SnippetRepr::SnippetOutput | SnippetRepr::AcquireTerminal => ExecutionType::Execute,\n                };\n                if args.auto {\n                    exec_type = ExecutionType::ExecReplace;\n                }\n                self.push_execution_disabled_operation(exec_type);\n            }\n            SnippetExecution::Exec(args) => {\n                let executor = self.snippet_executor.language_executor(&snippet.language, &args.spec)?;\n                match args.repr {\n                    SnippetRepr::Image => {\n                        self.push_code_as_image(snippet, executor)?;\n                    }\n                    SnippetRepr::ExecReplace => match &args.pty {\n                        Some(args) => {\n                            let args = args.clone();\n                            self.push_replace_pty_code_execution(snippet, executor, args)?;\n                        }\n                        None => {\n                            self.push_replace_code_execution(snippet, executor)?;\n                        }\n                    },\n                    SnippetRepr::AcquireTerminal => {\n                        let block_length = self.push_code_lines(&snippet);\n                        self.push_acquire_terminal_execution(snippet, block_length, executor)?;\n                    }\n                    SnippetRepr::SnippetOutput => {\n                        let block_length = self.push_code_lines(&snippet);\n                        let policy = if args.auto {\n                            RenderAsyncStartPolicy::Automatic\n                        } else {\n                            RenderAsyncStartPolicy::OnDemand\n                        };\n                        let handle: WrappedSnippetHandle = match &args.pty {\n                            Some(args) => PtySnippetHandle::new(snippet.clone(), executor, policy, args.clone()).into(),\n                            None => SnippetHandle::new(snippet.clone(), executor, policy).into(),\n                        };\n                        self.chunk_operations.push(RenderOperation::RenderAsync(handle.build_trigger().into()));\n\n                        let alignment = self.code_style(&snippet).alignment;\n                        self.push_indicator(handle.clone(), block_length, alignment);\n                        match snippet.attributes.id.clone() {\n                            Some(id) => {\n                                if self.executable_snippets.insert(id.clone(), handle).is_some() {\n                                    return Err(self.invalid_presentation(\n                                        source_position,\n                                        InvalidPresentation::SnippetAlreadyExists(id),\n                                    ));\n                                }\n                            }\n                            None => {\n                                self.push_line_break();\n                                match handle {\n                                    WrappedSnippetHandle::Normal(handle) => {\n                                        self.push_code_execution(block_length, handle, alignment)?;\n                                    }\n                                    WrappedSnippetHandle::Pty(handle) => self.push_pty_code_execution(handle)?,\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        };\n        Ok(())\n    }\n\n    pub(crate) fn push_detached_code_execution(&mut self, handle: WrappedSnippetHandle) -> BuildResult {\n        match handle {\n            WrappedSnippetHandle::Normal(handle) => {\n                let alignment = self.code_style(&handle.snippet()).alignment;\n                self.push_code_execution(0, handle, alignment)\n            }\n            WrappedSnippetHandle::Pty(handle) => self.push_pty_code_execution(handle),\n        }\n    }\n\n    fn is_execution_allowed(&self, args: &SnippetExecArgs) -> bool {\n        match args.repr {\n            SnippetRepr::SnippetOutput | SnippetRepr::AcquireTerminal => self.options.enable_snippet_execution,\n            SnippetRepr::Image | SnippetRepr::ExecReplace => self.options.enable_snippet_execution_replace,\n        }\n    }\n\n    fn push_code_lines(&mut self, snippet: &Snippet) -> u16 {\n        let lines = SnippetSplitter::new(&self.theme.code, self.snippet_executor.hidden_line_prefix(&snippet.language))\n            .split(snippet);\n        let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0) * self.slide_font_size() as usize;\n        let block_length = block_length as u16;\n        let (lines, context) = self.highlight_lines(snippet, lines, block_length);\n        for line in lines {\n            self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(line)));\n        }\n        self.chunk_operations.push(RenderOperation::SetColors(self.theme.default_style.style.colors));\n        if self.options.allow_mutations && context.borrow().groups.len() > 1 {\n            self.chunk_mutators.push(Box::new(HighlightMutator::new(context)));\n        }\n        block_length\n    }\n\n    fn push_replace_code_execution(&mut self, snippet: Snippet, executor: LanguageSnippetExecutor) -> BuildResult {\n        let alignment = match self.code_style(&snippet).alignment {\n            // If we're replacing the snippet output, we have center alignment and no background, use\n            // center alignment but without any margins and minimum sizes so we truly center the output.\n            Alignment::Center { .. } if snippet.attributes.no_background => {\n                Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 }\n            }\n            other => other,\n        };\n        let handle = SnippetHandle::new(snippet, executor, RenderAsyncStartPolicy::Automatic);\n        self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(RunSnippetTrigger::new(handle.clone()))));\n        self.push_code_execution(0, handle, alignment)\n    }\n\n    fn push_replace_pty_code_execution(\n        &mut self,\n        snippet: Snippet,\n        executor: LanguageSnippetExecutor,\n        args: PtyArgs,\n    ) -> BuildResult {\n        let standby = args.standby;\n        let policy = if standby { RenderAsyncStartPolicy::OnDemand } else { RenderAsyncStartPolicy::Automatic };\n        let handle = PtySnippetHandle::new(snippet, executor, policy, args);\n        // If we're using standby mode we still need a trigger\n        if standby {\n            self.chunk_operations\n                .push(RenderOperation::RenderAsync(WrappedSnippetHandle::from(handle.clone()).build_trigger().into()));\n        }\n        self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(RunPtySnippetTrigger::new(handle.clone()))));\n        self.push_pty_code_execution(handle)\n    }\n\n    fn load_external_snippet(\n        &mut self,\n        mut code: Snippet,\n        source_position: SourcePosition,\n    ) -> Result<Snippet, BuildError> {\n        let file: ExternalFile = serde_yaml::from_str(&code.contents)\n            .map_err(|e| self.invalid_presentation(source_position, InvalidPresentation::Snippet(e.to_string())))?;\n        let path = file.path;\n        let base_path = self.resource_base_path();\n        let contents = self.resources.external_text_file(&path, &base_path).map_err(|e| {\n            self.invalid_presentation(\n                source_position,\n                InvalidPresentation::Snippet(format!(\"failed to load snippet {path:?}: {e}\")),\n            )\n        })?;\n        code.language = file.language;\n        code.contents = Self::filter_lines(contents, file.start_line, file.end_line);\n        Ok(code)\n    }\n\n    fn filter_lines(code: String, start: Option<usize>, end: Option<usize>) -> String {\n        let start = start.map(|s| s.saturating_sub(1));\n        match (start, end) {\n            (None, None) => code,\n            (None, Some(end)) => code.lines().take(end).join(\"\\n\"),\n            (Some(start), None) => code.lines().skip(start).join(\"\\n\"),\n            (Some(start), Some(end)) => code.lines().skip(start).take(end.saturating_sub(start)).join(\"\\n\"),\n        }\n    }\n\n    fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> BuildResult {\n        let Snippet { contents, language, attributes } = code;\n        let request = match language {\n            SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()),\n            SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()),\n            SnippetLanguage::Mermaid => ThirdPartyRenderRequest::Mermaid(contents, self.theme.mermaid.clone()),\n            SnippetLanguage::D2 => ThirdPartyRenderRequest::D2(contents, self.theme.d2.clone()),\n            _ => {\n                return Err(self.invalid_presentation(\n                    source_position,\n                    InvalidPresentation::Snippet(format!(\"language {language:?} doesn't support rendering\")),\n                ));\n            }\n        };\n        let operation = self.third_party.render(request, &self.theme, attributes.width)?;\n        self.chunk_operations.push(operation);\n        Ok(())\n    }\n\n    fn highlight_lines(\n        &self,\n        code: &Snippet,\n        lines: Vec<SnippetLine>,\n        block_length: u16,\n    ) -> (Vec<HighlightedLine>, Rc<RefCell<HighlightContext>>) {\n        let mut code_highlighter = self.highlighter.language_highlighter(&code.language);\n        let style = self.code_style(code);\n        let block_length = self.theme.code.alignment.adjust_size(block_length);\n        let font_size = self.slide_font_size();\n        let dim_style = {\n            let mut highlighter = self.highlighter.language_highlighter(&SnippetLanguage::Rust);\n            highlighter.style_line(\"//\", &style).0.first().expect(\"no styles\").style.size(font_size)\n        };\n        let groups = match self.options.allow_mutations {\n            true => code.attributes.highlight_groups.clone(),\n            false => vec![HighlightGroup::new(vec![Highlight::All])],\n        };\n        let context =\n            Rc::new(RefCell::new(HighlightContext { groups, current: 0, block_length, alignment: style.alignment }));\n\n        let mut output = Vec::new();\n        for line in lines.into_iter() {\n            let prefix = line.dim_prefix(&dim_style);\n            let highlighted = line.highlight(&mut code_highlighter, &style, font_size);\n            let not_highlighted = line.dim(&dim_style);\n            let line_number = line.line_number;\n            let context = context.clone();\n            output.push(HighlightedLine {\n                prefix,\n                right_padding_length: line.right_padding_length * font_size as u16,\n                highlighted,\n                not_highlighted,\n                line_number,\n                context,\n                block_color: dim_style.colors.background,\n            });\n        }\n        (output, context)\n    }\n\n    fn code_style(&self, snippet: &Snippet) -> CodeBlockStyle {\n        let mut style = self.theme.code.clone();\n        if snippet.attributes.no_background {\n            style.background = false;\n        }\n        style\n    }\n\n    fn push_execution_disabled_operation(&mut self, exec_type: ExecutionType) {\n        let policy = match exec_type {\n            ExecutionType::ExecReplace | ExecutionType::Image => RenderAsyncStartPolicy::Automatic,\n            ExecutionType::Execute => RenderAsyncStartPolicy::OnDemand,\n        };\n        let operation = SnippetExecutionDisabledOperation::new(\n            self.theme.execution_output.status.failure_style,\n            self.theme.code.alignment,\n            policy,\n            exec_type,\n        );\n        self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation)));\n    }\n\n    fn push_code_as_image(&mut self, snippet: Snippet, executor: LanguageSnippetExecutor) -> BuildResult {\n        let operation =\n            RunImageSnippet::new(snippet, executor, self.image_registry.clone(), self.theme.execution_output.status);\n        let operation = RenderOperation::RenderAsync(Rc::new(operation));\n        self.chunk_operations.push(operation);\n        Ok(())\n    }\n\n    fn push_acquire_terminal_execution(\n        &mut self,\n        snippet: Snippet,\n        block_length: u16,\n        executor: LanguageSnippetExecutor,\n    ) -> BuildResult {\n        let block_length = self.theme.code.alignment.adjust_size(block_length);\n        let operation = RunAcquireTerminalSnippet::new(\n            snippet,\n            executor,\n            self.theme.execution_output.status,\n            block_length,\n            self.slide_font_size(),\n        );\n        let operation = RenderOperation::RenderAsync(Rc::new(operation));\n        self.chunk_operations.push(operation);\n        Ok(())\n    }\n\n    fn push_indicator<T: Into<WrappedSnippetHandle>>(&mut self, handle: T, block_length: u16, alignment: Alignment) {\n        let style = ExecIndicatorStyle {\n            theme: self.theme.execution_output.status,\n            block_length,\n            font_size: self.slide_font_size(),\n            alignment,\n        };\n        let indicator = Rc::new(ExecIndicator::new(handle, style));\n        self.chunk_operations.push(RenderOperation::RenderDynamic(indicator));\n    }\n\n    fn push_code_execution(&mut self, block_length: u16, handle: SnippetHandle, alignment: Alignment) -> BuildResult {\n        let snippet = handle.snippet();\n        let default_colors = self.theme.default_style.style.colors;\n        let mut execution_output_style = self.theme.execution_output.clone();\n        if snippet.attributes.no_background {\n            execution_output_style.style.colors.background = None;\n            execution_output_style.padding = Default::default();\n        }\n        let operation = SnippetOutputOperation::new(\n            handle,\n            default_colors,\n            execution_output_style,\n            block_length,\n            alignment,\n            self.slide_font_size(),\n        );\n        let operation = RenderOperation::RenderDynamic(Rc::new(operation));\n        self.chunk_operations.push(operation);\n        Ok(())\n    }\n\n    fn push_pty_code_execution(&mut self, handle: PtySnippetHandle) -> BuildResult {\n        let snippet = handle.snippet();\n        let mut style = self.theme.pty_output.clone();\n        if snippet.attributes.no_background {\n            style.style.colors.background = None;\n        }\n        let operation = PtySnippetOutputOperation::new(handle, style, self.slide_font_size());\n        let operation = RenderOperation::RenderDynamic(Rc::new(operation));\n        self.chunk_operations.push(operation);\n        Ok(())\n    }\n\n    fn push_differ(&mut self, text: String) {\n        self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text))));\n    }\n\n    fn push_validator(&mut self, snippet: &Snippet, executor: &LanguageSnippetExecutor) {\n        if !self.options.validate_snippets {\n            return;\n        }\n        let operation = ValidateSnippetOperation::new(snippet.clone(), executor.clone());\n        self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation)));\n    }\n}\n\n#[derive(Debug)]\nstruct Differ(String);\n\nimpl AsRenderOperations for Differ {\n    fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {\n        Vec::new()\n    }\n\n    fn diffable_content(&self) -> Option<&str> {\n        Some(&self.0)\n    }\n}\n\n#[cfg(all(test, target_os = \"linux\"))]\nmod tests {\n    use super::*;\n    use crate::{\n        markdown::text_style::Color,\n        presentation::builder::utils::{RunAsyncRendersPolicy, Test},\n        theme::raw,\n    };\n    use rstest::rstest;\n    use std::fs;\n\n    #[rstest]\n    #[case::no_filters(None, None, &[\"a\", \"b\", \"c\", \"d\", \"e\"])]\n    #[case::start_from_first(Some(1), None, &[\"a\", \"b\", \"c\", \"d\", \"e\"])]\n    #[case::start_from_second(Some(2), None, &[\"b\", \"c\", \"d\", \"e\"])]\n    #[case::start_from_end(Some(5), None, &[\"e\"])]\n    #[case::start_from_past_end(Some(6), None, &[])]\n    #[case::end_last(None, Some(5), &[\"a\", \"b\", \"c\", \"d\", \"e\"])]\n    #[case::end_one_before_last(None, Some(4), &[\"a\", \"b\", \"c\", \"d\"])]\n    #[case::end_at_first(None, Some(1), &[\"a\"])]\n    #[case::end_at_zero(None, Some(0), &[])]\n    #[case::start_and_end(Some(2), Some(3), &[\"b\", \"c\"])]\n    #[case::crossed(Some(2), Some(1), &[])]\n    fn filter_lines(#[case] start: Option<usize>, #[case] end: Option<usize>, #[case] expected: &[&str]) {\n        let code = [\"a\", \"b\", \"c\", \"d\", \"e\"].join(\"\\n\");\n        let output = PresentationBuilder::filter_lines(code, start, end);\n        let expected = expected.join(\"\\n\");\n        assert_eq!(output, expected);\n    }\n\n    #[test]\n    fn plain() {\n        let input = \"\n```bash\necho hi\n```\";\n        let lines = Test::new(input).render().rows(3).columns(7).into_lines();\n        let expected = &[\"       \", \"echo hi\", \"       \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn external_snippet() {\n        let temp = tempfile::NamedTempFile::new().expect(\"failed to create tempfile\");\n        let path = temp.path();\n        fs::write(path, \"echo hi\").unwrap();\n\n        let path = path.to_string_lossy();\n        let input = format!(\n            \"\n```file\npath: {path}\nlanguage: bash\n```\n\"\n        );\n        let lines = Test::new(input).render().rows(3).columns(7).into_lines();\n        let expected = &[\"       \", \"echo hi\", \"       \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn line_numbers() {\n        let input = \"\n```bash +line_numbers\nhi\nbye\n```\";\n        let lines = Test::new(input).render().rows(4).columns(5).into_lines();\n        let expected = &[\"     \", \"1 hi \", \"2 bye\", \"     \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn line_numbers_via_theme() {\n        let input = \"---\ntheme:\n  override:\n    code:\n      line_numbers: true\n---\n\n```bash\nhi\nbye\n```\";\n        let lines = Test::new(input).render().rows(4).columns(5).into_lines();\n        let expected = &[\"     \", \"1 hi \", \"2 bye\", \"     \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn surroundings() {\n        let input = \"\n---\n```bash\necho hi\n```\n---\";\n        let lines = Test::new(input).render().rows(7).columns(7).into_lines();\n        let expected = &[\n            //\n            \"       \",\n            \"———————\",\n            \"       \",\n            \"echo hi\",\n            \"       \",\n            \"———————\",\n            \"       \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn padding() {\n        let input = \"\n```bash\necho hi\n```\";\n        let theme = raw::PresentationTheme {\n            code: raw::CodeBlockStyle {\n                padding: raw::PaddingRect { horizontal: Some(2), vertical: Some(1) },\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let lines = Test::new(input).theme(theme).render().rows(5).columns(13).into_lines();\n        let expected = &[\n            //\n            \"             \",\n            \"             \",\n            \"  echo hi    \",\n            \"             \",\n            \"             \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_no_run() {\n        let input = \"\n```bash +exec\necho hi\n```\";\n        let lines =\n            Test::new(input).render().rows(4).columns(19).run_async_renders(RunAsyncRendersPolicy::None).into_lines();\n        let expected = &[\n            //\n            \"                   \",\n            \"echo hi            \",\n            \"                   \",\n            \"—— [not started] ——\",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_auto() {\n        let input = \"\n```bash +auto_exec\necho hi\n```\";\n        let lines = Test::new(input)\n            .render()\n            .rows(6)\n            .columns(19)\n            .run_async_renders(RunAsyncRendersPolicy::OnlyAutomatic)\n            .into_lines();\n        let expected = &[\n            //\n            \"                   \",\n            \"echo hi            \",\n            \"                   \",\n            \"——— [finished] ————\",\n            \"                   \",\n            \"hi                 \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn validate() {\n        let input = \"\n```bash +validate\necho hi\n```\";\n        let lines =\n            Test::new(input).render().rows(4).columns(19).run_async_renders(RunAsyncRendersPolicy::None).into_lines();\n        let expected = &[\"                   \", \"echo hi            \", \"                   \", \"                   \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_disabled() {\n        let input = \"\n```bash +exec\necho hi\n```\";\n        let lines = Test::new(input).disable_exec().render().rows(6).columns(25).into_lines();\n        let expected = &[\n            \"                         \",\n            \"echo hi                  \",\n            \"                         \",\n            \"snippet +exec is         \",\n            \"disabled, run with -x to \",\n            \"enable                   \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_replace_disabled() {\n        let input = \"\n```bash +exec_replace\necho hi\n```\";\n        let lines = Test::new(input).disable_exec_replace().render().rows(6).columns(25).into_lines();\n        let expected = &[\n            \"                         \",\n            \"echo hi                  \",\n            \"                         \",\n            \"snippet +exec_replace is \",\n            \"disabled, run with -X to \",\n            \"enable                   \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec() {\n        let input = \"\n```bash +exec\necho hi\n```\";\n        let theme = raw::PresentationTheme {\n            execution_output: raw::ExecutionOutputBlockStyle {\n                colors: raw::RawColors {\n                    background: Some(raw::RawColor::Color(Color::new(45, 45, 45))),\n                    foreground: None,\n                },\n                padding: raw::PaddingRect { horizontal: Some(1), vertical: Some(1) },\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let (lines, styles) = Test::new(input)\n            .theme(theme)\n            .render()\n            .map_background(Color::new(45, 45, 45), 'x')\n            .rows(8)\n            .columns(16)\n            .into_parts();\n        let expected_lines = &[\n            \"                \",\n            \"echo hi         \",\n            \"                \",\n            \"—— [finished] ——\",\n            \"                \",\n            \"                \",\n            \" hi             \",\n            \"                \",\n        ];\n        let expected_styles = &[\n            \"                \",\n            \"xxxxxxxxxxxxxxxx\",\n            \"                \",\n            \"                \",\n            \"                \",\n            \"xxxxxxxxxxxxxxxx\",\n            \"xxxxxxxxxxxxxxxx\",\n            \"xxxxxxxxxxxxxxxx\",\n        ];\n        assert_eq!(lines, expected_lines);\n        assert_eq!(styles, expected_styles);\n    }\n\n    #[test]\n    fn exec_font_size() {\n        let input = \"\n<!-- font_size: 2 -->\n```bash +exec\necho hi\n```\";\n        let lines = Test::new(input).render().rows(8).columns(32).into_lines();\n        let expected = &[\n            \"                                \",\n            \"e c h o   h i                   \",\n            \"                                \",\n            \"                                \",\n            \"— —   [ f i n i s h e d ]   — — \",\n            \"                                \",\n            \"                                \",\n            \"h i                             \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_font_size_centered() {\n        let input = \"\n<!-- font_size: 2 -->\n```bash +exec\necho hi\n```\";\n        let theme = raw::PresentationTheme {\n            code: raw::CodeBlockStyle {\n                alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(0), minimum_size: 40 }),\n                ..Default::default()\n            },\n            execution_output: raw::ExecutionOutputBlockStyle {\n                colors: raw::RawColors {\n                    background: Some(raw::RawColor::Color(Color::new(45, 45, 45))),\n                    foreground: None,\n                },\n                padding: raw::PaddingRect { horizontal: Some(1), vertical: Some(1) },\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let (lines, styles) = Test::new(input)\n            .theme(theme)\n            .render()\n            .map_background(Color::new(45, 45, 45), 'x')\n            .rows(10)\n            .columns(40)\n            .into_parts();\n        let expected_lines = &[\n            \"                                        \",\n            \"e c h o   h i                           \",\n            \"                                        \",\n            \"                                        \",\n            \"— — — —   [ f i n i s h e d ]   — — — — \",\n            \"                                        \",\n            \"                                        \",\n            \"                                        \",\n            \"                                        \",\n            \"  h i                                   \",\n        ];\n        let expected_styles = &[\n            \"                                        \",\n            \"x x x x x x x x x x x x x x x x x x x x \",\n            \"                                        \",\n            \"                                        \",\n            \"                                        \",\n            \"                                        \",\n            \"                                        \",\n            \"x x x x x x x x x x x x x x x x x x x x \",\n            \"                                        \",\n            \"x x x x x x x x x x x x x x x x x x x x \",\n        ];\n        assert_eq!(lines, expected_lines);\n        assert_eq!(styles, expected_styles);\n    }\n\n    #[test]\n    fn exec_adjacent_detached_output() {\n        let input = \"\n```bash +exec +id:foo\necho hi\n```\n<!-- snippet_output: foo -->\";\n        let lines =\n            Test::new(input).render().rows(4).columns(19).run_async_renders(RunAsyncRendersPolicy::None).into_lines();\n        // this should look exactly the same as if we hadn't detached the output\n        let expected = &[\n            //\n            \"                   \",\n            \"echo hi            \",\n            \"                   \",\n            \"—— [not started] ——\",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_detached_output() {\n        let input = \"\n```bash +exec +id:foo\necho hi\n```\n\nbar\n\n<!-- snippet_output: foo -->\";\n        let lines = Test::new(input).render().rows(8).columns(16).into_lines();\n        let expected = &[\n            \"                \",\n            \"echo hi         \",\n            \"                \",\n            \"—— [finished] ——\",\n            \"                \",\n            \"bar             \",\n            \"                \",\n            \"hi              \",\n        ];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_replace() {\n        let input = \"\n```bash +exec_replace\necho hi\n```\";\n        let lines = Test::new(input).render().rows(3).columns(7).into_lines();\n        let expected = &[\"       \", \"hi     \", \"       \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn snippet_exec_replace_centered() {\n        let input = \"\n```bash +exec_replace\necho hi\n```\";\n        let theme = raw::PresentationTheme {\n            code: raw::CodeBlockStyle {\n                alignment: Some(raw::Alignment::Center { minimum_margin: raw::Margin::Fixed(1), minimum_size: 1 }),\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n        let lines = Test::new(input).theme(theme).render().rows(3).columns(6).into_lines();\n        let expected = &[\"      \", \"  hi  \", \"      \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_replace_font_size() {\n        let input = \"\n<!-- font_size: 2 -->\n```bash +exec_replace\necho hi\n```\";\n        let lines = Test::new(input).render().rows(3).columns(7).into_lines();\n        let expected = &[\"       \", \"h i    \", \"       \"];\n        assert_eq!(lines, expected);\n    }\n\n    #[test]\n    fn exec_replace_long() {\n        let qr = [\n            \"█▀▀▀▀▀█ ▄▀ ▄▀ █▀▀▀▀▀█\",\n            \"█ ███ █ ▄▀ ▄  █ ███ █\",\n            \"█ ▀▀▀ █ ▄▄█▀█ █ ▀▀▀ █\",\n            \"▀▀▀▀▀▀▀ ▀ █▄█ ▀▀▀▀▀▀▀\",\n            \"█▀▀██ ▀▀█▀  █▀ █ ▀ ▀▄\",\n            \"▄▄██▀▄▀▀▄ █▀ ▀ ▄█▀█▀ \",\n            \"▀  ▀▀ ▀▀▄█▄█▄█▄▄▀ ▄ █\",\n            \"█▀▀▀▀▀█ ▀▀ ▄█▄█▀ ▄█▀▄\",\n            \"█ ███ █ ██▀ █  ▄█▄ ▀ \",\n            \"█ ▀▀▀ █ █ ▄▀ ▀  ▄██  \",\n            \"▀▀▀▀▀▀▀ ▀▀ ▀ ▀  ▀  ▀ \",\n        ]\n        .join(\"\\n\");\n\n        let input = format!(\n            r#\"\n```bash +exec_replace\necho \"{qr}\"\n```\n\"#\n        );\n        let rows = 13;\n        let columns = 21;\n        let lines = Test::new(input).render().rows(rows).columns(columns).into_lines();\n        let empty = \" \".repeat(columns as usize);\n        let expected: Vec<_> = [empty.as_str()].into_iter().chain(qr.lines()).chain([empty.as_str()]).collect();\n        assert_eq!(lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/sources.rs",
    "content": "use crate::{markdown::elements::SourcePosition, presentation::builder::error::FileSourcePosition};\nuse std::{cell::RefCell, path::PathBuf, rc::Rc};\n\n#[derive(Default)]\nstruct Inner {\n    include_paths: Vec<PathBuf>,\n}\n\n#[derive(Default)]\npub(crate) struct MarkdownSources {\n    inner: Rc<RefCell<Inner>>,\n}\n\nimpl MarkdownSources {\n    pub(crate) fn enter<P: Into<PathBuf>>(&self, path: P) -> Result<SourceGuard, MarkdownSourceError> {\n        let path = path.into();\n        if path.parent().is_none() {\n            return Err(MarkdownSourceError::NoParent);\n        }\n\n        let mut inner = self.inner.borrow_mut();\n        if inner.include_paths.contains(&path) {\n            return Err(MarkdownSourceError::IncludeCycle(path));\n        }\n        inner.include_paths.push(path);\n        Ok(SourceGuard(self.inner.clone()))\n    }\n\n    pub(crate) fn current_base_path(&self) -> PathBuf {\n        self.inner\n            .borrow()\n            .include_paths\n            .last()\n            // SAFETY: we validate we know the parent before pushing into `include_paths`\n            .map(|path| path.parent().expect(\"no parent\").to_path_buf())\n            .unwrap_or_else(|| PathBuf::from(\".\"))\n    }\n\n    pub(crate) fn resolve_source_position(&self, source_position: SourcePosition) -> FileSourcePosition {\n        let file = self.inner.borrow().include_paths.last().cloned().unwrap_or_else(|| PathBuf::from(\".\"));\n        FileSourcePosition { source_position, file }\n    }\n}\n\n#[must_use]\npub(crate) struct SourceGuard(Rc<RefCell<Inner>>);\n\nimpl Drop for SourceGuard {\n    fn drop(&mut self) {\n        self.0.borrow_mut().include_paths.pop();\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum MarkdownSourceError {\n    #[error(\"cannot detect path's parent\")]\n    NoParent,\n\n    #[error(\"{0:?} was already imported\")]\n    IncludeCycle(PathBuf),\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::path::Path;\n\n    #[test]\n    fn paths() {\n        let sources = MarkdownSources::default();\n        assert_eq!(sources.current_base_path(), Path::new(\".\"));\n\n        {\n            let _guard1 = sources.enter(\"foo.md\");\n            assert_eq!(sources.current_base_path(), Path::new(\"\"));\n\n            {\n                let _guard2 = sources.enter(\"inner/bar.md\");\n                assert_eq!(sources.current_base_path(), Path::new(\"inner\"));\n            }\n\n            assert_eq!(sources.current_base_path(), Path::new(\"\"));\n        }\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/table.rs",
    "content": "use crate::{\n    markdown::elements::{Line, Table, TableRow, Text},\n    presentation::builder::{BuildResult, PresentationBuilder, error::BuildError},\n    theme::ElementType,\n};\nuse std::iter;\n\nimpl PresentationBuilder<'_, '_> {\n    pub(crate) fn push_table(&mut self, table: Table) -> BuildResult {\n        let widths: Vec<_> = (0..table.columns())\n            .map(|column| table.iter_column(column).map(|text| text.width()).max().unwrap_or(0))\n            .collect();\n        let incremental = self.slide_state.incremental_tables.unwrap_or(self.options.incremental_tables);\n        let flattened_header = self.prepare_table_row(table.header, &widths)?;\n        if incremental && self.options.pause_before_incremental_tables {\n            self.push_pause();\n        }\n        self.push_text(flattened_header, ElementType::Table);\n        self.push_line_break();\n\n        let mut separator = Line(Vec::new());\n        for (index, width) in widths.iter().enumerate() {\n            let mut contents = String::new();\n            let mut margin = 1;\n            if index > 0 {\n                contents.push('┼');\n                // Append an extra dash to have 1 column margin on both sides\n                if index < widths.len() - 1 {\n                    margin += 1;\n                }\n            }\n            contents.extend(iter::repeat_n(\"─\", *width + margin));\n            separator.0.push(Text::from(contents));\n        }\n\n        self.push_text(separator, ElementType::Table);\n        self.push_line_break();\n\n        for row in table.rows {\n            if incremental {\n                self.push_pause();\n            }\n            let flattened_row = self.prepare_table_row(row, &widths)?;\n            self.push_text(flattened_row, ElementType::Table);\n            self.push_line_break();\n        }\n        if incremental && self.options.pause_after_incremental_tables {\n            self.push_pause();\n        }\n        Ok(())\n    }\n\n    fn prepare_table_row(&self, row: TableRow, widths: &[usize]) -> Result<Line, BuildError> {\n        let mut flattened_row = Line(Vec::new());\n        for (column, text) in row.0.into_iter().enumerate() {\n            let text = text.resolve(&self.theme.palette)?;\n            if column > 0 {\n                flattened_row.0.push(Text::from(\" │ \"));\n            }\n            let text_length = text.width();\n            flattened_row.0.extend(text.0.into_iter());\n\n            let cell_width = widths[column];\n            if text_length < cell_width {\n                let padding = \" \".repeat(cell_width - text_length);\n                flattened_row.0.push(Text::from(padding));\n            }\n        }\n        Ok(flattened_row)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::presentation::builder::utils::Test;\n\n    #[test]\n    fn table() {\n        let input = \"\n| Name   | Taste  |\n| ------ | ------ |\n| Potato | Great  |\n| Carrot | Yuck   |\n\";\n        let lines = Test::new(input).render().rows(6).columns(22).into_lines();\n        let expected_lines = &[\n            \"                      \",\n            \"Name   │ Taste        \",\n            \"───────┼──────        \",\n            \"Potato │ Great        \",\n            \"Carrot │ Yuck         \",\n            \"                      \",\n        ];\n        assert_eq!(lines, expected_lines);\n    }\n}\n"
  },
  {
    "path": "src/presentation/builder/tests.rs",
    "content": "use super::*;\nuse crate::presentation::builder::utils::Test;\n\n#[test]\nfn prelude_appears_once() {\n    let input = \"---\nauthor: bob\n---\n\n# hello\n\n<!-- end_slide -->\n\n# bye\n\";\n    let presentation = Test::new(input).build();\n    for (index, slide) in presentation.iter_slides().enumerate() {\n        let clear_screen_count =\n            slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::ClearScreen)).count();\n        let set_colors_count =\n            slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::SetColors(_))).count();\n        assert_eq!(clear_screen_count, 1, \"{clear_screen_count} clear screens in slide {index}\");\n        assert_eq!(set_colors_count, 1, \"{set_colors_count} clear screens in slide {index}\");\n    }\n}\n\n#[test]\nfn slides_start_with_one_newline() {\n    let input = r#\"---\nauthor: bob\n---\n\n# hello\n\n<!-- end_slide -->\n\n# bye\n\"#;\n    // land in first slide after into\n    let lines = Test::new(input).render().rows(2).columns(5).advances(1).into_lines();\n    assert_eq!(lines, &[\"     \", \"hello\"]);\n\n    // land in second one\n    let lines = Test::new(input).render().rows(2).columns(5).advances(2).into_lines();\n    assert_eq!(lines, &[\"     \", \"bye  \"]);\n}\n\n#[test]\nfn extra_fields_in_metadata() {\n    let element = MarkdownElement::FrontMatter(\"nope: 42\".into());\n    Test::new(vec![element]).expect_invalid();\n}\n\n#[test]\nfn end_slide_shorthand() {\n    let input = \"\nhola\n\n---\n\nhi\n\";\n    // first slide\n    let options = PresentationBuilderOptions { end_slide_shorthand: true, ..Default::default() };\n    let lines = Test::new(input).options(options.clone()).render().rows(2).columns(5).into_lines();\n    assert_eq!(lines, &[\"     \", \"hola \"]);\n\n    // second slide\n    let lines = Test::new(input).options(options).render().rows(2).columns(5).advances(1).into_lines();\n    assert_eq!(lines, &[\"     \", \"hi   \"]);\n}\n\n#[test]\nfn parse_front_matter_strict() {\n    let options = PresentationBuilderOptions { strict_front_matter_parsing: false, ..Default::default() };\n    let elements = vec![MarkdownElement::FrontMatter(\"potato: yes\".into())];\n    let result = Test::new(elements).options(options).try_build();\n    assert!(result.is_ok());\n}\n\n#[test]\nfn footnote() {\n    let elements = vec![MarkdownElement::Footnote(Line::from(\"hi\")), MarkdownElement::Footnote(Line::from(\"bye\"))];\n    let lines = Test::new(elements).render().rows(3).columns(5).into_lines();\n    let expected = &[\"     \", \"hi   \", \"bye  \"];\n    assert_eq!(lines, expected);\n}\n"
  },
  {
    "path": "src/presentation/diff.rs",
    "content": "use crate::presentation::{Presentation, RenderOperation, SlideChunk};\nuse std::{any::Any, cmp::Ordering, fmt::Debug, mem};\n\n/// Allow diffing presentations.\npub(crate) struct PresentationDiffer;\n\nimpl PresentationDiffer {\n    /// Find the first modification between two presentations.\n    pub(crate) fn find_first_modification(original: &Presentation, updated: &Presentation) -> Option<Modification> {\n        let original_slides = original.iter_slides();\n        let updated_slides = updated.iter_slides();\n        for (slide_index, (original, updated)) in original_slides.zip(updated_slides).enumerate() {\n            for (chunk_index, (original, updated)) in original.iter_chunks().zip(updated.iter_chunks()).enumerate() {\n                if original.is_content_different(updated) {\n                    return Some(Modification { slide_index, chunk_index });\n                }\n            }\n            let total_original = original.iter_chunks().count();\n            let total_updated = updated.iter_chunks().count();\n            match total_original.cmp(&total_updated) {\n                Ordering::Equal => (),\n                Ordering::Less => return Some(Modification { slide_index, chunk_index: total_original }),\n                Ordering::Greater => {\n                    return Some(Modification { slide_index, chunk_index: total_updated.saturating_sub(1) });\n                }\n            }\n        }\n        let total_original = original.iter_slides().count();\n        let total_updated = updated.iter_slides().count();\n        match total_original.cmp(&total_updated) {\n            // If they have the same number of slides there's no difference.\n            Ordering::Equal => None,\n            // If the original had fewer, let's scroll to the first new one.\n            Ordering::Less => Some(Modification { slide_index: total_original, chunk_index: 0 }),\n            // If the original had more, let's scroll to the last one.\n            Ordering::Greater => {\n                Some(Modification { slide_index: total_updated.saturating_sub(1), chunk_index: usize::MAX })\n            }\n        }\n    }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) struct Modification {\n    pub(crate) slide_index: usize,\n    pub(crate) chunk_index: usize,\n}\n\ntrait ContentDiff {\n    fn is_content_different(&self, other: &Self) -> bool;\n}\n\nimpl ContentDiff for SlideChunk {\n    fn is_content_different(&self, other: &Self) -> bool {\n        self.iter_operations().is_content_different(&other.iter_operations())\n    }\n}\n\nimpl ContentDiff for RenderOperation {\n    fn is_content_different(&self, other: &Self) -> bool {\n        use RenderOperation::*;\n        let same_variant = mem::discriminant(self) == mem::discriminant(other);\n        // If variants don't even match, content is different.\n        if !same_variant {\n            return true;\n        }\n\n        match (self, other) {\n            (SetColors(original), SetColors(updated)) if original != updated => false,\n            (RenderText { line: original, .. }, RenderText { line: updated, .. }) if original != updated => true,\n            (RenderText { alignment: original, .. }, RenderText { alignment: updated, .. }) if original != updated => {\n                false\n            }\n            (RenderImage(original, original_properties), RenderImage(updated, updated_properties))\n                if original != updated || original_properties != updated_properties =>\n            {\n                true\n            }\n            (RenderBlockLine(original), RenderBlockLine(updated)) if original != updated => true,\n            (InitColumnLayout { columns: original, .. }, InitColumnLayout { columns: updated, .. })\n                if original != updated =>\n            {\n                true\n            }\n            (EnterColumn { column: original }, EnterColumn { column: updated }) if original != updated => true,\n            (RenderDynamic(original), RenderDynamic(updated)) if original.type_id() != updated.type_id() => true,\n            (RenderDynamic(original), RenderDynamic(updated)) => {\n                original.diffable_content() != updated.diffable_content()\n            }\n            (RenderAsync(original), RenderAsync(updated)) if original.type_id() != updated.type_id() => true,\n            (RenderAsync(original), RenderAsync(updated)) => original.diffable_content() != updated.diffable_content(),\n            _ => false,\n        }\n    }\n}\n\nimpl<'a, T, U> ContentDiff for T\nwhere\n    T: IntoIterator<Item = &'a U> + Clone,\n    U: ContentDiff + 'a,\n{\n    fn is_content_different(&self, other: &Self) -> bool {\n        let lhs = self.clone().into_iter();\n        let rhs = other.clone().into_iter();\n        for (lhs, rhs) in lhs.zip(rhs) {\n            if lhs.is_content_different(rhs) {\n                return true;\n            }\n        }\n        // If either have more than the other, they've changed\n        self.clone().into_iter().count() != other.clone().into_iter().count()\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::{\n        markdown::{\n            text::WeightedLine,\n            text_style::{Color, Colors},\n        },\n        presentation::{Slide, SlideBuilder},\n        render::{\n            operation::{AsRenderOperations, BlockLine, LayoutGrid, Pollable, RenderAsync, ToggleState},\n            properties::WindowSize,\n        },\n        theme::{Alignment, Margin},\n    };\n    use rstest::rstest;\n    use std::rc::Rc;\n\n    #[derive(Debug)]\n    struct Dynamic;\n\n    impl AsRenderOperations for Dynamic {\n        fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {\n            Vec::new()\n        }\n    }\n\n    impl RenderAsync for Dynamic {\n        fn pollable(&self) -> Box<dyn Pollable> {\n            // Use some random one, we don't care\n            Box::new(ToggleState::new(Default::default()))\n        }\n    }\n\n    #[rstest]\n    #[case(RenderOperation::ClearScreen)]\n    #[case(RenderOperation::JumpToVerticalCenter)]\n    #[case(RenderOperation::JumpToBottomRow{ index: 0 })]\n    #[case(RenderOperation::RenderLineBreak)]\n    #[case(RenderOperation::SetColors(Colors{background: None, foreground: None}))]\n    #[case(RenderOperation::RenderText{line: String::from(\"asd\").into(), alignment: Default::default()})]\n    #[case(RenderOperation::RenderBlockLine(\n        BlockLine{\n            prefix: \"\".into(),\n            right_padding_length: 0,\n            repeat_prefix_on_wrap: false,\n            text: WeightedLine::from(\"\".to_string()),\n            alignment: Default::default(),\n            block_length: 42,\n            block_color: None,\n        }\n    ))]\n    #[case(RenderOperation::RenderDynamic(Rc::new(Dynamic)))]\n    #[case(RenderOperation::RenderAsync(Rc::new(Dynamic)))]\n    #[case(RenderOperation::InitColumnLayout{ columns: vec![1, 2], grid: LayoutGrid::None, margin: Default::default() })]\n    #[case(RenderOperation::EnterColumn{ column: 1 })]\n    #[case(RenderOperation::ExitLayout)]\n    fn same_not_modified(#[case] operation: RenderOperation) {\n        let diff = operation.is_content_different(&operation);\n        assert!(!diff);\n    }\n\n    #[test]\n    fn different_text() {\n        let lhs = RenderOperation::RenderText { line: String::from(\"foo\").into(), alignment: Default::default() };\n        let rhs = RenderOperation::RenderText { line: String::from(\"bar\").into(), alignment: Default::default() };\n        assert!(lhs.is_content_different(&rhs));\n    }\n\n    #[test]\n    fn different_text_alignment() {\n        let lhs = RenderOperation::RenderText {\n            line: String::from(\"foo\").into(),\n            alignment: Alignment::Left { margin: Margin::Fixed(42) },\n        };\n        let rhs = RenderOperation::RenderText {\n            line: String::from(\"foo\").into(),\n            alignment: Alignment::Left { margin: Margin::Fixed(1337) },\n        };\n        assert!(!lhs.is_content_different(&rhs));\n    }\n\n    #[test]\n    fn different_colors() {\n        let lhs = RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(1, 2, 3)) });\n        let rhs = RenderOperation::SetColors(Colors { background: None, foreground: Some(Color::new(3, 2, 1)) });\n        assert!(!lhs.is_content_different(&rhs));\n    }\n\n    #[test]\n    fn different_column_layout() {\n        let lhs = RenderOperation::InitColumnLayout {\n            columns: vec![1, 2],\n            grid: LayoutGrid::None,\n            margin: Default::default(),\n        };\n        let rhs = RenderOperation::InitColumnLayout {\n            columns: vec![1, 3],\n            grid: LayoutGrid::None,\n            margin: Default::default(),\n        };\n        assert!(lhs.is_content_different(&rhs));\n    }\n\n    #[test]\n    fn different_column() {\n        let lhs = RenderOperation::EnterColumn { column: 0 };\n        let rhs = RenderOperation::EnterColumn { column: 1 };\n        assert!(lhs.is_content_different(&rhs));\n    }\n\n    #[test]\n    fn no_slide_changes() {\n        let presentation = Presentation::from(vec![\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            Slide::from(vec![RenderOperation::ClearScreen]),\n        ]);\n        assert_eq!(PresentationDiffer::find_first_modification(&presentation, &presentation), None);\n    }\n\n    #[test]\n    fn slides_truncated() {\n        let lhs = Presentation::from(vec![\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            Slide::from(vec![RenderOperation::ClearScreen]),\n        ]);\n        let rhs = Presentation::from(vec![Slide::from(vec![RenderOperation::ClearScreen])]);\n\n        assert_eq!(\n            PresentationDiffer::find_first_modification(&lhs, &rhs),\n            Some(Modification { slide_index: 0, chunk_index: usize::MAX })\n        );\n    }\n\n    #[test]\n    fn slides_added() {\n        let lhs = Presentation::from(vec![Slide::from(vec![RenderOperation::ClearScreen])]);\n        let rhs = Presentation::from(vec![\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            Slide::from(vec![RenderOperation::ClearScreen]),\n        ]);\n\n        assert_eq!(\n            PresentationDiffer::find_first_modification(&lhs, &rhs),\n            Some(Modification { slide_index: 1, chunk_index: 0 })\n        );\n    }\n\n    #[test]\n    fn second_slide_content_changed() {\n        let lhs = Presentation::from(vec![\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            Slide::from(vec![RenderOperation::ClearScreen]),\n        ]);\n        let rhs = Presentation::from(vec![\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            Slide::from(vec![RenderOperation::JumpToVerticalCenter]),\n            Slide::from(vec![RenderOperation::ClearScreen]),\n        ]);\n\n        assert_eq!(\n            PresentationDiffer::find_first_modification(&lhs, &rhs),\n            Some(Modification { slide_index: 1, chunk_index: 0 })\n        );\n    }\n\n    #[test]\n    fn presentation_changed_style() {\n        let lhs = Presentation::from(vec![Slide::from(vec![RenderOperation::SetColors(Colors {\n            background: None,\n            foreground: Some(Color::new(255, 0, 0)),\n        })])]);\n        let rhs = Presentation::from(vec![Slide::from(vec![RenderOperation::SetColors(Colors {\n            background: None,\n            foreground: Some(Color::new(0, 0, 0)),\n        })])]);\n\n        assert_eq!(PresentationDiffer::find_first_modification(&lhs, &rhs), None);\n    }\n\n    #[test]\n    fn chunk_change() {\n        let lhs = Presentation::from(vec![\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            SlideBuilder::default()\n                .chunks(vec![SlideChunk::default(), SlideChunk::new(vec![RenderOperation::ClearScreen], vec![])])\n                .build(),\n        ]);\n        let rhs = Presentation::from(vec![\n            Slide::from(vec![RenderOperation::ClearScreen]),\n            SlideBuilder::default()\n                .chunks(vec![\n                    SlideChunk::default(),\n                    SlideChunk::new(vec![RenderOperation::ClearScreen, RenderOperation::ClearScreen], vec![]),\n                ])\n                .build(),\n        ]);\n\n        assert_eq!(\n            PresentationDiffer::find_first_modification(&lhs, &rhs),\n            Some(Modification { slide_index: 1, chunk_index: 1 })\n        );\n        assert_eq!(\n            PresentationDiffer::find_first_modification(&rhs, &lhs),\n            Some(Modification { slide_index: 1, chunk_index: 1 })\n        );\n    }\n}\n"
  },
  {
    "path": "src/presentation/mod.rs",
    "content": "use crate::{config::OptionsConfig, render::operation::RenderOperation};\nuse serde::Deserialize;\nuse std::{\n    cell::RefCell,\n    fmt::Debug,\n    ops::Deref,\n    rc::Rc,\n    sync::{Arc, Mutex},\n};\n\npub(crate) mod builder;\npub(crate) mod diff;\npub(crate) mod poller;\n\n#[derive(Debug)]\npub(crate) struct Modals {\n    pub(crate) slide_index: Vec<RenderOperation>,\n    pub(crate) bindings: Vec<RenderOperation>,\n}\n\n/// A presentation.\n#[derive(Debug)]\npub(crate) struct Presentation {\n    slides: Vec<Slide>,\n    modals: Modals,\n    pub(crate) state: PresentationState,\n}\n\nimpl Presentation {\n    /// Construct a new presentation.\n    pub(crate) fn new(slides: Vec<Slide>, modals: Modals, state: PresentationState) -> Self {\n        Self { slides, modals, state }\n    }\n\n    /// Iterate the slides in this presentation.\n    pub(crate) fn iter_slides(&self) -> impl Iterator<Item = &Slide> {\n        self.slides.iter()\n    }\n\n    /// Iterate the slides in this presentation.\n    pub(crate) fn iter_slides_mut(&mut self) -> impl Iterator<Item = &mut Slide> {\n        self.slides.iter_mut()\n    }\n\n    /// Iterate the operations that render the slide index.\n    pub(crate) fn iter_slide_index_operations(&self) -> impl Iterator<Item = &RenderOperation> {\n        self.modals.slide_index.iter()\n    }\n\n    /// Iterate the operations that render the key bindings modal.\n    pub(crate) fn iter_bindings_operations(&self) -> impl Iterator<Item = &RenderOperation> {\n        self.modals.bindings.iter()\n    }\n\n    /// Consume this presentation and return its slides.\n    pub(crate) fn into_slides(self) -> Vec<Slide> {\n        self.slides\n    }\n\n    /// Get the current slide.\n    pub(crate) fn current_slide(&self) -> &Slide {\n        &self.slides[self.current_slide_index()]\n    }\n\n    /// Get the current slide index.\n    pub(crate) fn current_slide_index(&self) -> usize {\n        self.state.current_slide_index()\n    }\n\n    /// Jump forwards.\n    pub(crate) fn jump_next(&mut self) -> bool {\n        let current_slide = self.current_slide_mut();\n        if current_slide.move_next() {\n            return true;\n        }\n        self.jump_next_slide()\n    }\n\n    /// Jump to the next slide, ignoring any chunks and modifiers.\n    pub(crate) fn jump_next_fast(&mut self) -> bool {\n        self.jump_next_slide()\n    }\n\n    /// Jump backwards.\n    pub(crate) fn jump_previous(&mut self) -> bool {\n        let current_slide = self.current_slide_mut();\n        if current_slide.move_previous() {\n            return true;\n        }\n        self.jump_previous_slide()\n    }\n\n    /// Jump to the previous slide ignoring any chunks and modifiers.\n    pub(crate) fn jump_previous_fast(&mut self) -> bool {\n        let output = self.jump_previous_slide();\n        self.current_slide_mut().show_first_chunk();\n        output\n    }\n\n    /// Jump to the first slide.\n    pub(crate) fn jump_first_slide(&mut self) -> bool {\n        self.go_to_slide(0)\n    }\n\n    /// Jump to the last slide.\n    pub(crate) fn jump_last_slide(&mut self) -> bool {\n        let last_slide_index = self.slides.len().saturating_sub(1);\n        self.go_to_slide(last_slide_index)\n    }\n\n    /// Jump to a specific slide.\n    pub(crate) fn go_to_slide(&mut self, slide_index: usize) -> bool {\n        if slide_index < self.slides.len() {\n            self.state.set_current_slide_index(slide_index);\n            // Always show only the first slide when jumping to a particular one.\n            self.current_slide_mut().show_first_chunk();\n            true\n        } else {\n            false\n        }\n    }\n\n    /// Jump to a specific chunk within the current slide.\n    pub(crate) fn jump_chunk(&mut self, chunk_index: usize) {\n        self.current_slide_mut().jump_chunk(chunk_index);\n    }\n\n    /// Get the current slide's chunk.\n    pub(crate) fn current_chunk(&self) -> usize {\n        self.current_slide().current_chunk_index()\n    }\n\n    pub(crate) fn current_slide_mut(&mut self) -> &mut Slide {\n        let index = self.current_slide_index();\n        &mut self.slides[index]\n    }\n\n    /// Show all chunks in the current slide.\n    pub(crate) fn show_all_slide_chunks(&mut self) {\n        self.current_slide_mut().show_all_chunks();\n    }\n\n    fn jump_next_slide(&mut self) -> bool {\n        let current_slide_index = self.current_slide_index();\n        if current_slide_index < self.slides.len() - 1 {\n            self.state.set_current_slide_index(current_slide_index + 1);\n            // Going forward we show only the first chunk.\n            self.current_slide_mut().show_first_chunk();\n            true\n        } else {\n            false\n        }\n    }\n\n    fn jump_previous_slide(&mut self) -> bool {\n        let current_slide_index = self.current_slide_index();\n        if current_slide_index > 0 {\n            self.state.set_current_slide_index(current_slide_index - 1);\n            // Going backwards we show all chunks.\n            self.current_slide_mut().show_all_chunks();\n            true\n        } else {\n            false\n        }\n    }\n}\n\nimpl From<Vec<Slide>> for Presentation {\n    fn from(slides: Vec<Slide>) -> Self {\n        let modals = Modals { slide_index: vec![], bindings: vec![] };\n        Self::new(slides, modals, Default::default())\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct AsyncPresentationError {\n    pub(crate) slide: usize,\n    pub(crate) error: String,\n}\n\npub(crate) type AsyncPresentationErrorHolder = Arc<Mutex<Option<AsyncPresentationError>>>;\n\n#[derive(Debug, Default)]\npub(crate) struct PresentationStateInner {\n    current_slide_index: usize,\n    async_error_holder: AsyncPresentationErrorHolder,\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct PresentationState {\n    inner: Rc<RefCell<PresentationStateInner>>,\n}\n\nimpl PresentationState {\n    pub(crate) fn async_error_holder(&self) -> AsyncPresentationErrorHolder {\n        self.inner.deref().borrow().async_error_holder.clone()\n    }\n\n    pub(crate) fn current_slide_index(&self) -> usize {\n        self.inner.deref().borrow().current_slide_index\n    }\n\n    fn set_current_slide_index(&self, value: usize) {\n        self.inner.deref().borrow_mut().current_slide_index = value;\n    }\n}\n\n/// A slide builder.\n#[derive(Default)]\npub(crate) struct SlideBuilder {\n    chunks: Vec<SlideChunk>,\n    footer: Vec<RenderOperation>,\n}\n\nimpl SlideBuilder {\n    pub(crate) fn chunks(mut self, chunks: Vec<SlideChunk>) -> Self {\n        self.chunks = chunks;\n        self\n    }\n\n    pub(crate) fn footer(mut self, footer: Vec<RenderOperation>) -> Self {\n        self.footer = footer;\n        self\n    }\n\n    pub(crate) fn build(self) -> Slide {\n        Slide::new(self.chunks, self.footer)\n    }\n}\n\n/// A slide.\n///\n/// Slides are composed of render operations that can be carried out to materialize this slide into\n/// the terminal's screen.\n#[derive(Debug)]\npub(crate) struct Slide {\n    chunks: Vec<SlideChunk>,\n    footer: Vec<RenderOperation>,\n    visible_chunks: usize,\n}\n\nimpl Slide {\n    pub(crate) fn new(chunks: Vec<SlideChunk>, footer: Vec<RenderOperation>) -> Self {\n        Self { chunks, footer, visible_chunks: 1 }\n    }\n\n    pub(crate) fn iter_operations(&self) -> impl Iterator<Item = &RenderOperation> + Clone {\n        self.chunks.iter().flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter())\n    }\n\n    pub(crate) fn iter_operations_mut(&mut self) -> impl Iterator<Item = &mut RenderOperation> {\n        self.chunks.iter_mut().flat_map(|chunk| chunk.operations.iter_mut()).chain(self.footer.iter_mut())\n    }\n\n    pub(crate) fn iter_visible_operations(&self) -> impl Iterator<Item = &RenderOperation> + Clone {\n        self.chunks.iter().take(self.visible_chunks).flat_map(|chunk| chunk.operations.iter()).chain(self.footer.iter())\n    }\n\n    pub(crate) fn iter_visible_operations_mut(&mut self) -> impl Iterator<Item = &mut RenderOperation> {\n        self.chunks\n            .iter_mut()\n            .take(self.visible_chunks)\n            .flat_map(|chunk| chunk.operations.iter_mut())\n            .chain(self.footer.iter_mut())\n    }\n\n    pub(crate) fn iter_chunks(&self) -> impl Iterator<Item = &SlideChunk> {\n        self.chunks.iter()\n    }\n\n    pub(crate) fn current_chunk_index(&self) -> usize {\n        self.visible_chunks.saturating_sub(1)\n    }\n\n    pub(crate) fn jump_chunk(&mut self, chunk_index: usize) {\n        self.visible_chunks = chunk_index.saturating_add(1).min(self.chunks.len());\n        for chunk in self.chunks.iter().take(self.visible_chunks - 1) {\n            chunk.apply_all_mutations();\n        }\n    }\n\n    fn current_chunk(&self) -> &SlideChunk {\n        &self.chunks[self.current_chunk_index()]\n    }\n\n    fn show_first_chunk(&mut self) {\n        self.visible_chunks = 1;\n        self.current_chunk().reset_mutations();\n    }\n\n    pub(crate) fn show_all_chunks(&mut self) {\n        self.visible_chunks = self.chunks.len();\n        for chunk in &self.chunks {\n            chunk.apply_all_mutations();\n        }\n    }\n\n    fn move_next(&mut self) -> bool {\n        if self.chunks[self.current_chunk_index()].mutate_next() {\n            return true;\n        }\n\n        if self.visible_chunks == self.chunks.len() {\n            false\n        } else {\n            self.visible_chunks += 1;\n            self.current_chunk().reset_mutations();\n            true\n        }\n    }\n\n    fn move_previous(&mut self) -> bool {\n        if self.chunks[self.current_chunk_index()].mutate_previous() {\n            return true;\n        }\n        if self.visible_chunks == 1 {\n            false\n        } else {\n            self.visible_chunks -= 1;\n            self.current_chunk().apply_all_mutations();\n            true\n        }\n    }\n}\n\nimpl From<Vec<RenderOperation>> for Slide {\n    fn from(operations: Vec<RenderOperation>) -> Self {\n        Self::new(vec![SlideChunk::new(operations, Vec::new())], vec![])\n    }\n}\n\n#[derive(Debug, Default)]\npub(crate) struct SlideChunk {\n    operations: Vec<RenderOperation>,\n    mutators: Vec<Box<dyn ChunkMutator>>,\n}\n\nimpl SlideChunk {\n    pub(crate) fn new(operations: Vec<RenderOperation>, mutators: Vec<Box<dyn ChunkMutator>>) -> Self {\n        Self { operations, mutators }\n    }\n\n    pub(crate) fn iter_operations(&self) -> impl Iterator<Item = &RenderOperation> + Clone {\n        self.operations.iter()\n    }\n\n    pub(crate) fn pop_last(&mut self) -> Option<RenderOperation> {\n        self.operations.pop()\n    }\n\n    fn mutate_next(&self) -> bool {\n        for mutator in &self.mutators {\n            if mutator.mutate_next() {\n                return true;\n            }\n        }\n        false\n    }\n\n    fn mutate_previous(&self) -> bool {\n        for mutator in self.mutators.iter().rev() {\n            if mutator.mutate_previous() {\n                return true;\n            }\n        }\n        false\n    }\n\n    fn reset_mutations(&self) {\n        for mutator in &self.mutators {\n            mutator.reset_mutations();\n        }\n    }\n\n    fn apply_all_mutations(&self) {\n        for mutator in &self.mutators {\n            mutator.apply_all_mutations();\n        }\n    }\n}\n\npub(crate) trait ChunkMutator: Debug {\n    fn mutate_next(&self) -> bool;\n    fn mutate_previous(&self) -> bool;\n    fn reset_mutations(&self);\n    fn apply_all_mutations(&self);\n    #[allow(dead_code)]\n    fn mutations(&self) -> (usize, usize);\n}\n\n/// The metadata for a presentation.\n#[derive(Clone, Debug, Deserialize)]\npub(crate) struct PresentationMetadata {\n    /// The presentation title.\n    pub(crate) title: Option<String>,\n\n    /// The presentation sub-title.\n    #[serde(default)]\n    pub(crate) sub_title: Option<String>,\n\n    /// The presentation event.\n    #[serde(default)]\n    pub(crate) event: Option<String>,\n\n    /// The presentation location.\n    #[serde(default)]\n    pub(crate) location: Option<String>,\n\n    /// The presentation date.\n    #[serde(default)]\n    pub(crate) date: Option<String>,\n\n    /// The presentation author.\n    #[serde(default)]\n    pub(crate) author: Option<String>,\n\n    /// The presentation authors.\n    #[serde(default)]\n    pub(crate) authors: Vec<String>,\n\n    /// The presentation's theme metadata.\n    #[serde(default)]\n    pub(crate) theme: PresentationThemeMetadata,\n\n    /// The presentation's options.\n    #[serde(default)]\n    pub(crate) options: Option<OptionsConfig>,\n}\n\nimpl PresentationMetadata {\n    /// Check if this presentation has frontmatter.\n    pub(crate) fn has_frontmatter(&self) -> bool {\n        self.title.is_some()\n            || self.sub_title.is_some()\n            || self.event.is_some()\n            || self.location.is_some()\n            || self.date.is_some()\n            || self.author.is_some()\n            || !self.authors.is_empty()\n    }\n}\n\n/// A presentation's theme metadata.\n#[derive(Clone, Debug, Default, Deserialize)]\npub(crate) struct PresentationThemeMetadata {\n    /// The theme name.\n    #[serde(default)]\n    pub(crate) name: Option<String>,\n\n    /// the theme path.\n    #[serde(default)]\n    pub(crate) path: Option<String>,\n\n    /// Any specific overrides for the presentation's theme.\n    #[serde(default, rename = \"override\")]\n    pub(crate) overrides: Option<crate::theme::raw::PresentationTheme>,\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use rstest::rstest;\n    use std::cell::RefCell;\n\n    #[derive(Clone)]\n    enum Jump {\n        First,\n        Last,\n        Next,\n        NextFast,\n        Previous,\n        PreviousFast,\n        Specific(usize),\n    }\n\n    impl Jump {\n        fn apply(&self, presentation: &mut Presentation) {\n            use Jump::*;\n            match self {\n                First => presentation.jump_first_slide(),\n                Last => presentation.jump_last_slide(),\n                Next => presentation.jump_next(),\n                NextFast => presentation.jump_next_fast(),\n                Previous => presentation.jump_previous(),\n                PreviousFast => presentation.jump_previous_fast(),\n                Specific(index) => presentation.go_to_slide(*index),\n            };\n        }\n\n        fn repeat(&self, count: usize) -> Vec<Self> {\n            vec![self.clone(); count]\n        }\n    }\n\n    #[derive(Debug)]\n    struct DummyMutator {\n        current: RefCell<usize>,\n        limit: usize,\n    }\n\n    impl DummyMutator {\n        fn new(limit: usize) -> Self {\n            Self { current: 0.into(), limit }\n        }\n    }\n\n    impl ChunkMutator for DummyMutator {\n        fn mutate_next(&self) -> bool {\n            let mut current = self.current.borrow_mut();\n            if *current < self.limit {\n                *current += 1;\n                true\n            } else {\n                false\n            }\n        }\n\n        fn mutate_previous(&self) -> bool {\n            let mut current = self.current.borrow_mut();\n            if *current > 0 {\n                *current -= 1;\n                true\n            } else {\n                false\n            }\n        }\n\n        fn reset_mutations(&self) {\n            *self.current.borrow_mut() = 0;\n        }\n\n        fn apply_all_mutations(&self) {\n            *self.current.borrow_mut() = self.limit;\n        }\n\n        fn mutations(&self) -> (usize, usize) {\n            (*self.current.borrow(), self.limit)\n        }\n    }\n\n    #[rstest]\n    #[case::previous_from_first(0, &[Jump::Previous], 0, 0)]\n    #[case::next_from_first(0, &[Jump::Next], 0, 1)]\n    #[case::next_next_from_first(0, &[Jump::Next, Jump::Next], 0, 2)]\n    #[case::next_next_next_from_first(0, &[Jump::Next, Jump::Next, Jump::Next], 1, 0)]\n    #[case::next_fast_from_first(0, &[Jump::NextFast], 1, 0)]\n    #[case::next_fast_twice_from_first(0, &[Jump::NextFast, Jump::NextFast], 2, 0)]\n    #[case::last_from_first(0, &[Jump::Last], 2, 0)]\n    #[case::previous_from_second(1, &[Jump::Previous], 0, 2)]\n    #[case::previous_fast_from_second(1, &[Jump::PreviousFast], 0, 0)]\n    #[case::previous_fast_twice_from_second(1, &[Jump::PreviousFast, Jump::PreviousFast], 0, 0)]\n    #[case::next_from_second(1, &[Jump::Next], 1, 1)]\n    #[case::specific_first_from_second(1, &[Jump::Specific(0)], 0, 0)]\n    #[case::specific_last_from_second(1, &[Jump::Specific(2)], 2, 0)]\n    #[case::first_from_last(2, &[Jump::First], 0, 0)]\n    fn jumping(\n        #[case] from: usize,\n        #[case] jumps: &[Jump],\n        #[case] expected_slide: usize,\n        #[case] expected_chunk: usize,\n    ) {\n        let mut presentation = Presentation::from(vec![\n            Slide::new(vec![SlideChunk::default(), SlideChunk::default(), SlideChunk::default()], vec![]),\n            Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]),\n            Slide::new(vec![SlideChunk::default(), SlideChunk::default()], vec![]),\n        ]);\n        presentation.go_to_slide(from);\n\n        for jump in jumps {\n            jump.apply(&mut presentation);\n        }\n        assert_eq!(presentation.current_slide_index(), expected_slide);\n        assert_eq!(presentation.current_slide().visible_chunks - 1, expected_chunk);\n    }\n\n    #[rstest]\n    #[case::next_1(0, &[Jump::Next], [1, 0, 0], 0, 0)]\n    #[case::next_previous(0, &[Jump::Next, Jump::Previous], [0, 0, 0], 0, 0)]\n    #[case::next_2(0, &Jump::Next.repeat(2), [1, 1, 0], 0, 0)]\n    #[case::next_3(0, &Jump::Next.repeat(3), [1, 2, 0], 0, 0)]\n    #[case::next_4(0, &Jump::Next.repeat(4), [1, 2, 0], 0, 1)]\n    #[case::next_4_back_4(\n        0,\n        &[Jump::Next.repeat(4), Jump::Previous.repeat(4)].concat(),\n        [0, 0, 0],\n        0,\n        0\n    )]\n    #[case::last_first(0, &[Jump::Last, Jump::First], [0, 0, 0], 0, 0)]\n    #[case::back_from_second(0, &[Jump::Specific(1), Jump::Previous], [1, 2, 0], 0, 1)]\n    #[case::specific_from_second(0, &[Jump::Specific(1), Jump::Previous, Jump::Specific(0)], [0, 0, 0], 0, 0)]\n    fn jumping_with_mutations(\n        #[case] from: usize,\n        #[case] jumps: &[Jump],\n        #[case] mutations: [usize; 3],\n        #[case] expected_slide: usize,\n        #[case] expected_chunk: usize,\n    ) {\n        let mut presentation = Presentation::from(vec![\n            SlideBuilder::default()\n                .chunks(vec![\n                    SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(1)), Box::new(DummyMutator::new(2))]),\n                    SlideChunk::default(),\n                ])\n                .build(),\n            SlideBuilder::default()\n                .chunks(vec![SlideChunk::new(vec![], vec![Box::new(DummyMutator::new(2))]), SlideChunk::default()])\n                .build(),\n        ]);\n        presentation.go_to_slide(from);\n\n        for jump in jumps {\n            jump.apply(&mut presentation);\n        }\n        let mutators: Vec<_> = presentation\n            .iter_slides()\n            .flat_map(|slide| slide.chunks.iter())\n            .flat_map(|chunk| chunk.mutators.iter())\n            .collect();\n        assert_eq!(mutators.len(), mutations.len(), \"unexpected mutation count\");\n        for (index, (mutator, expected_mutations)) in mutators.into_iter().zip(mutations).enumerate() {\n            assert_eq!(mutator.mutations().0, expected_mutations, \"diff on {index}\");\n        }\n        assert_eq!(presentation.current_slide_index(), expected_slide, \"slide differs\");\n        assert_eq!(presentation.current_slide().visible_chunks - 1, expected_chunk, \"chunk differs\");\n    }\n}\n"
  },
  {
    "path": "src/presentation/poller.rs",
    "content": "use crate::render::operation::{Pollable, PollableState};\nuse std::{\n    sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel},\n    thread,\n    time::Duration,\n};\n\nconst POLL_INTERVAL: Duration = Duration::from_millis(25);\n\npub(crate) struct Poller {\n    sender: Sender<PollerCommand>,\n    receiver: Receiver<PollableEffect>,\n}\n\nimpl Poller {\n    pub(crate) fn launch() -> Self {\n        let (command_sender, command_receiver) = channel();\n        let (effect_sender, effect_receiver) = channel();\n        let worker = PollerWorker::new(command_receiver, effect_sender);\n        thread::spawn(move || {\n            worker.run();\n        });\n        Self { sender: command_sender, receiver: effect_receiver }\n    }\n\n    pub(crate) fn send(&self, command: PollerCommand) {\n        let _ = self.sender.send(command);\n    }\n\n    pub(crate) fn next_effect(&mut self) -> Option<PollableEffect> {\n        self.receiver.try_recv().ok()\n    }\n}\n\n/// An effect caused by a pollable.\n#[derive(Clone)]\npub(crate) enum PollableEffect {\n    /// Refresh the given slide.\n    RefreshSlide(usize),\n\n    /// Display an error for the given slide.\n    DisplayError { slide: usize, error: String },\n}\n\n/// A poller command.\npub(crate) enum PollerCommand {\n    /// Start polling a pollable that's positioned in the given slide.\n    Poll { pollable: Box<dyn Pollable>, slide: usize },\n\n    /// Reset all pollables.\n    Reset,\n}\n\nstruct PollerWorker {\n    receiver: Receiver<PollerCommand>,\n    sender: Sender<PollableEffect>,\n    pollables: Vec<(Box<dyn Pollable>, usize)>,\n}\n\nimpl PollerWorker {\n    fn new(receiver: Receiver<PollerCommand>, sender: Sender<PollableEffect>) -> Self {\n        Self { receiver, sender, pollables: Default::default() }\n    }\n\n    fn run(mut self) {\n        loop {\n            match self.receiver.recv_timeout(POLL_INTERVAL) {\n                Ok(command) => self.process_command(command),\n                // TODO don't loop forever.\n                Err(RecvTimeoutError::Timeout) => self.poll(),\n                Err(RecvTimeoutError::Disconnected) => break,\n            };\n        }\n    }\n\n    fn process_command(&mut self, command: PollerCommand) {\n        match command {\n            PollerCommand::Poll { mut pollable, slide } => {\n                // Poll and only insert if it's still running.\n                match pollable.poll() {\n                    PollableState::Unmodified | PollableState::Modified => {\n                        self.pollables.push((pollable, slide));\n                    }\n                    PollableState::Done => {\n                        let _ = self.sender.send(PollableEffect::RefreshSlide(slide));\n                    }\n                    PollableState::Failed { error } => {\n                        let _ = self.sender.send(PollableEffect::DisplayError { slide, error });\n                    }\n                };\n            }\n            PollerCommand::Reset => self.pollables.clear(),\n        }\n    }\n\n    fn poll(&mut self) {\n        let mut removables = Vec::new();\n        for (index, (pollable, slide)) in self.pollables.iter_mut().enumerate() {\n            let slide = *slide;\n            let (effect, remove) = match pollable.poll() {\n                PollableState::Unmodified => (None, false),\n                PollableState::Modified => (Some(PollableEffect::RefreshSlide(slide)), false),\n                PollableState::Done => (Some(PollableEffect::RefreshSlide(slide)), true),\n                PollableState::Failed { error } => (Some(PollableEffect::DisplayError { slide, error }), true),\n            };\n            if let Some(effect) = effect {\n                let _ = self.sender.send(effect);\n            }\n            if remove {\n                removables.push(index);\n            }\n        }\n        // Walk back and swap remove to avoid invalidating indexes.\n        for index in removables.iter().rev() {\n            self.pollables.swap_remove(*index);\n        }\n    }\n}\n"
  },
  {
    "path": "src/presenter.rs",
    "content": "use crate::{\n    code::execute::SnippetExecutor,\n    commands::{\n        listener::{Command, CommandListener},\n        speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher},\n    },\n    config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig},\n    markdown::parse::MarkdownParser,\n    presentation::{\n        Presentation, Slide,\n        builder::{PresentationBuilder, PresentationBuilderOptions, Themes, error::BuildError},\n        diff::PresentationDiffer,\n        poller::{PollableEffect, Poller, PollerCommand},\n    },\n    render::{\n        ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions,\n        ascii_scaler::AsciiScaler,\n        engine::{MaxSize, RenderEngine, RenderEngineOptions},\n        operation::{Pollable, RenderAsyncStartPolicy, RenderOperation},\n        properties::WindowSize,\n        validate::OverflowValidator,\n    },\n    resource::Resources,\n    terminal::{\n        image::printer::{ImagePrinter, ImageRegistry},\n        printer::{TerminalCommand, TerminalIo},\n        virt::{ImageBehavior, TerminalGrid, VirtualTerminal},\n    },\n    theme::{ProcessingThemeError, raw::PresentationTheme},\n    third_party::ThirdPartyRender,\n    transitions::{\n        AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection,\n        collapse_horizontal::CollapseHorizontalAnimation, fade::FadeAnimation,\n        slide_horizontal::SlideHorizontalAnimation,\n    },\n};\nuse std::{\n    fmt::Display,\n    io::{self},\n    mem,\n    ops::Deref,\n    path::Path,\n    sync::Arc,\n    time::{Duration, Instant},\n};\n\npub struct PresenterOptions {\n    pub mode: PresentMode,\n    pub builder_options: PresentationBuilderOptions,\n    pub font_size_fallback: u8,\n    pub bindings: KeyBindingsConfig,\n    pub validate_overflows: bool,\n    pub max_size: MaxSize,\n    pub transition: Option<SlideTransitionConfig>,\n}\n\n/// A slideshow presenter.\n///\n/// This type puts everything else together.\npub struct Presenter<'a> {\n    default_theme: &'a PresentationTheme,\n    listener: CommandListener,\n    parser: MarkdownParser<'a>,\n    resources: Resources,\n    third_party: ThirdPartyRender,\n    code_executor: Arc<SnippetExecutor>,\n    state: PresenterState,\n    image_printer: Arc<ImagePrinter>,\n    themes: Themes,\n    options: PresenterOptions,\n    speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,\n    poller: Poller,\n}\n\nimpl<'a> Presenter<'a> {\n    /// Construct a new presenter.\n    #[allow(clippy::too_many_arguments)]\n    pub fn new(\n        default_theme: &'a PresentationTheme,\n        listener: CommandListener,\n        parser: MarkdownParser<'a>,\n        resources: Resources,\n        third_party: ThirdPartyRender,\n        code_executor: Arc<SnippetExecutor>,\n        themes: Themes,\n        image_printer: Arc<ImagePrinter>,\n        options: PresenterOptions,\n        speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,\n    ) -> Self {\n        Self {\n            default_theme,\n            listener,\n            parser,\n            resources,\n            third_party,\n            code_executor,\n            state: PresenterState::Empty,\n            image_printer,\n            themes,\n            options,\n            speaker_notes_event_publisher,\n            poller: Poller::launch(),\n        }\n    }\n\n    /// Run a presentation.\n    pub fn present(mut self, path: &Path) -> Result<(), PresentationError> {\n        if matches!(self.options.mode, PresentMode::Development) {\n            self.resources.watch_presentation_file(path.to_path_buf());\n        }\n        self.state = PresenterState::Presenting(Presentation::from(vec![]));\n        self.try_reload(path, true)?;\n\n        let drawer_options = TerminalDrawerOptions {\n            font_size_fallback: self.options.font_size_fallback,\n            max_size: self.options.max_size.clone(),\n        };\n        let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?;\n        loop {\n            // Poll async renders once before we draw just in case.\n            self.render(&mut drawer)?;\n\n            loop {\n                if self.process_poller_effects()? {\n                    self.render(&mut drawer)?;\n                }\n\n                let command = match self.listener.try_next_command()? {\n                    Some(command) => command,\n                    _ => match self.resources.resources_modified() {\n                        true => Command::Reload,\n                        false => {\n                            if self.check_async_error() {\n                                break;\n                            }\n                            continue;\n                        }\n                    },\n                };\n                match self.apply_command(command) {\n                    CommandSideEffect::Exit => {\n                        self.publish_event(SpeakerNotesEvent::Exit)?;\n                        return Ok(());\n                    }\n                    CommandSideEffect::Suspend => {\n                        self.suspend(&mut drawer);\n                        break;\n                    }\n                    CommandSideEffect::Reload => {\n                        self.try_reload(path, false)?;\n                        break;\n                    }\n                    CommandSideEffect::Redraw => {\n                        self.try_scale_transition_images()?;\n                        break;\n                    }\n                    CommandSideEffect::AnimateNextSlide => {\n                        self.animate_next_slide(&mut drawer)?;\n                        break;\n                    }\n                    CommandSideEffect::AnimatePreviousSlide => {\n                        self.animate_previous_slide(&mut drawer)?;\n                        break;\n                    }\n                    CommandSideEffect::None => (),\n                };\n            }\n\n            if !matches!(self.state, PresenterState::Failure { .. }) {\n                let slide_index = self.state.presentation().current_slide_index() as u32 + 1;\n                let slide = self.state.presentation().current_slide();\n                self.publish_event(SpeakerNotesEvent::GoTo {\n                    slide: slide_index,\n                    chunk: slide.current_chunk_index() as u32,\n                })?;\n            }\n        }\n    }\n\n    fn process_poller_effects(&mut self) -> Result<bool, PresentationError> {\n        let current_slide = match &self.state {\n            PresenterState::Presenting(presentation)\n            | PresenterState::SlideIndex(presentation)\n            | PresenterState::KeyBindings(presentation)\n            | PresenterState::Failure { presentation, .. } => presentation.current_slide_index(),\n            PresenterState::Empty => usize::MAX,\n        };\n        let mut refreshed = false;\n        let mut needs_render = false;\n        while let Some(effect) = self.poller.next_effect() {\n            match effect {\n                PollableEffect::RefreshSlide(index) => {\n                    needs_render = needs_render || index == current_slide;\n                    refreshed = true;\n                }\n                PollableEffect::DisplayError { slide, error } => {\n                    let presentation = mem::take(&mut self.state).into_presentation();\n                    self.state =\n                        PresenterState::failure(error, presentation, ErrorSource::Slide(slide + 1), FailureMode::Other);\n                    needs_render = true;\n                }\n            }\n        }\n        if refreshed {\n            self.try_scale_transition_images()?;\n        }\n        Ok(needs_render)\n    }\n\n    fn publish_event(&self, event: SpeakerNotesEvent) -> io::Result<()> {\n        if let Some(publisher) = &self.speaker_notes_event_publisher {\n            publisher.send(event)?;\n        }\n        Ok(())\n    }\n\n    fn check_async_error(&mut self) -> bool {\n        let error_holder = self.state.presentation().state.async_error_holder();\n        let error_holder = error_holder.lock().unwrap();\n        match error_holder.deref() {\n            Some(error) => {\n                let presentation = mem::take(&mut self.state).into_presentation();\n                self.state = PresenterState::failure(\n                    &error.error,\n                    presentation,\n                    ErrorSource::Slide(error.slide),\n                    FailureMode::Other,\n                );\n                true\n            }\n            None => false,\n        }\n    }\n\n    fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {\n        let result = match &self.state {\n            PresenterState::Presenting(presentation) => {\n                drawer.render_operations(presentation.current_slide().iter_visible_operations())\n            }\n            PresenterState::SlideIndex(presentation) => {\n                drawer.render_operations(presentation.current_slide().iter_visible_operations())?;\n                drawer.render_operations(presentation.iter_slide_index_operations())\n            }\n            PresenterState::KeyBindings(presentation) => {\n                drawer.render_operations(presentation.current_slide().iter_visible_operations())?;\n                drawer.render_operations(presentation.iter_bindings_operations())\n            }\n            PresenterState::Failure { error, source, .. } => drawer.render_error(error, source),\n            PresenterState::Empty => panic!(\"cannot render without state\"),\n        };\n        // If the screen is too small, simply ignore this. Eventually the user will resize the\n        // screen.\n        if matches!(result, Err(RenderError::TerminalTooSmall)) { Ok(()) } else { result }\n    }\n\n    fn apply_command(&mut self, command: Command) -> CommandSideEffect {\n        // These ones always happens no matter our state.\n        match command {\n            Command::Reload => {\n                return CommandSideEffect::Reload;\n            }\n            Command::HardReload => {\n                if matches!(self.options.mode, PresentMode::Development) {\n                    self.resources.clear();\n                }\n                return CommandSideEffect::Reload;\n            }\n            Command::ToggleLayoutGrid => {\n                self.options.builder_options.layout_grid = !self.options.builder_options.layout_grid;\n                return CommandSideEffect::Reload;\n            }\n            Command::Exit => return CommandSideEffect::Exit,\n            Command::Suspend => return CommandSideEffect::Suspend,\n            _ => (),\n        };\n        if matches!(command, Command::Redraw) {\n            if !self.is_displaying_other_error() {\n                let presentation = mem::take(&mut self.state).into_presentation();\n                self.state = self.validate_overflows(presentation);\n            }\n            return CommandSideEffect::Redraw;\n        }\n\n        // Now apply the commands that require a presentation.\n        let presentation = match &mut self.state {\n            PresenterState::Presenting(presentation)\n            | PresenterState::SlideIndex(presentation)\n            | PresenterState::KeyBindings(presentation) => presentation,\n            _ => {\n                return CommandSideEffect::None;\n            }\n        };\n        let needs_redraw = match command {\n            Command::Next => {\n                let current_slide = presentation.current_slide_index();\n                if !presentation.jump_next() {\n                    false\n                } else if presentation.current_slide_index() != current_slide {\n                    return CommandSideEffect::AnimateNextSlide;\n                } else {\n                    true\n                }\n            }\n            Command::NextFast => presentation.jump_next_fast(),\n            Command::Previous => {\n                let current_slide = presentation.current_slide_index();\n                if !presentation.jump_previous() {\n                    false\n                } else if presentation.current_slide_index() != current_slide {\n                    return CommandSideEffect::AnimatePreviousSlide;\n                } else {\n                    true\n                }\n            }\n            Command::PreviousFast => presentation.jump_previous_fast(),\n            Command::FirstSlide => presentation.jump_first_slide(),\n            Command::LastSlide => presentation.jump_last_slide(),\n            Command::GoToSlide(number) => presentation.go_to_slide(number.saturating_sub(1) as usize),\n            Command::GoToSlideChunk { slide, chunk } => {\n                presentation.go_to_slide(slide.saturating_sub(1) as usize);\n                presentation.current_slide_mut().jump_chunk(chunk as usize);\n                true\n            }\n            Command::RenderAsyncOperations => {\n                let pollables = Self::trigger_slide_async_renders(presentation);\n                if !pollables.is_empty() {\n                    for pollable in pollables {\n                        self.poller.send(PollerCommand::Poll { pollable, slide: presentation.current_slide_index() });\n                    }\n                    return CommandSideEffect::Redraw;\n                } else {\n                    return CommandSideEffect::None;\n                }\n            }\n            Command::ToggleSlideIndex => {\n                self.toggle_slide_index();\n                true\n            }\n            Command::ToggleKeyBindingsConfig => {\n                self.toggle_key_bindings();\n                true\n            }\n            Command::CloseModal => {\n                let presentation = mem::take(&mut self.state).into_presentation();\n                self.state = PresenterState::Presenting(presentation);\n                true\n            }\n            Command::SkipPauses => {\n                presentation.show_all_slide_chunks();\n                true\n            }\n            // These are handled above as they don't require the presentation\n            Command::Reload\n            | Command::HardReload\n            | Command::Exit\n            | Command::Suspend\n            | Command::Redraw\n            | Command::ToggleLayoutGrid => {\n                panic!(\"unreachable commands\")\n            }\n        };\n        if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None }\n    }\n\n    fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult {\n        if matches!(self.options.mode, PresentMode::Presentation) && !force {\n            return Ok(());\n        }\n        self.poller.send(PollerCommand::Reset);\n        self.resources.clear_watches();\n        match self.load_presentation(path) {\n            Ok(mut presentation) => {\n                let current = self.state.presentation();\n                if let Some(modification) = PresentationDiffer::find_first_modification(current, &presentation) {\n                    presentation.go_to_slide(modification.slide_index);\n                    presentation.jump_chunk(modification.chunk_index);\n                } else {\n                    presentation.go_to_slide(current.current_slide_index());\n                    presentation.jump_chunk(current.current_chunk());\n                }\n                self.start_automatic_async_renders(&mut presentation);\n                self.state = self.validate_overflows(presentation);\n                self.try_scale_transition_images()?;\n            }\n            Err(e) => {\n                let presentation = mem::take(&mut self.state).into_presentation();\n                self.state = PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other);\n            }\n        };\n        Ok(())\n    }\n\n    fn try_scale_transition_images(&self) -> RenderResult {\n        if self.options.transition.is_none() {\n            return Ok(());\n        }\n        let options = RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() };\n        let scaler = AsciiScaler::new(options);\n        let dimensions = WindowSize::current(self.options.font_size_fallback)?;\n        scaler.process(self.state.presentation(), &dimensions)?;\n        Ok(())\n    }\n\n    fn trigger_slide_async_renders(presentation: &mut Presentation) -> Vec<Box<dyn Pollable>> {\n        let slide = presentation.current_slide_mut();\n        let mut pollables = Vec::new();\n        for operation in slide.iter_visible_operations_mut() {\n            if let RenderOperation::RenderAsync(operation) = operation {\n                if let RenderAsyncStartPolicy::OnDemand = operation.start_policy() {\n                    pollables.push(operation.pollable());\n                }\n            }\n        }\n        pollables\n    }\n\n    fn is_displaying_other_error(&self) -> bool {\n        matches!(self.state, PresenterState::Failure { mode: FailureMode::Other, .. })\n    }\n\n    fn validate_overflows(&self, presentation: Presentation) -> PresenterState {\n        if self.options.validate_overflows {\n            let dimensions = match WindowSize::current(self.options.font_size_fallback) {\n                Ok(dimensions) => dimensions,\n                Err(e) => {\n                    return PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other);\n                }\n            };\n            match OverflowValidator::validate(&presentation, dimensions) {\n                Ok(()) => PresenterState::Presenting(presentation),\n                Err(e) => PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Overflow),\n            }\n        } else {\n            PresenterState::Presenting(presentation)\n        }\n    }\n\n    fn load_presentation(&mut self, path: &Path) -> Result<Presentation, LoadPresentationError> {\n        let presentation = PresentationBuilder::new(\n            self.default_theme,\n            self.resources.clone(),\n            &mut self.third_party,\n            self.code_executor.clone(),\n            &self.themes,\n            ImageRegistry::new(self.image_printer.clone()),\n            self.options.bindings.clone(),\n            &self.parser,\n            self.options.builder_options.clone(),\n        )?\n        .build(path)?;\n        Ok(presentation)\n    }\n\n    fn toggle_slide_index(&mut self) {\n        let state = mem::take(&mut self.state);\n        match state {\n            PresenterState::Presenting(presentation) | PresenterState::KeyBindings(presentation) => {\n                self.state = PresenterState::SlideIndex(presentation)\n            }\n            PresenterState::SlideIndex(presentation) => self.state = PresenterState::Presenting(presentation),\n            other => self.state = other,\n        }\n    }\n\n    fn toggle_key_bindings(&mut self) {\n        let state = mem::take(&mut self.state);\n        match state {\n            PresenterState::Presenting(presentation) | PresenterState::SlideIndex(presentation) => {\n                self.state = PresenterState::KeyBindings(presentation)\n            }\n            PresenterState::KeyBindings(presentation) => self.state = PresenterState::Presenting(presentation),\n            other => self.state = other,\n        }\n    }\n\n    fn suspend(&self, drawer: &mut TerminalDrawer) {\n        #[cfg(unix)]\n        unsafe {\n            drawer.terminal.suspend();\n            libc::raise(libc::SIGTSTP);\n            drawer.terminal.resume();\n        }\n    }\n\n    fn animate_next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {\n        let Some(config) = self.options.transition.clone() else {\n            return Ok(());\n        };\n\n        let options = drawer.render_engine_options();\n        let presentation = self.state.presentation_mut();\n        let dimensions = WindowSize::current(self.options.font_size_fallback)?;\n        presentation.jump_previous();\n        let left = Self::virtual_render(presentation.current_slide(), dimensions, &options)?;\n        presentation.jump_next();\n        let right = Self::virtual_render(presentation.current_slide(), dimensions, &options)?;\n        let direction = TransitionDirection::Next;\n        self.animate_transition(drawer, left, right, direction, dimensions, config)\n    }\n\n    fn animate_previous_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {\n        let Some(config) = self.options.transition.clone() else {\n            return Ok(());\n        };\n\n        let options = drawer.render_engine_options();\n        let presentation = self.state.presentation_mut();\n        let dimensions = WindowSize::current(self.options.font_size_fallback)?;\n        presentation.jump_next();\n\n        // Re-borrow to avoid calling fns above while mutably borrowing\n        let presentation = self.state.presentation_mut();\n\n        let right = Self::virtual_render(presentation.current_slide(), dimensions, &options)?;\n        presentation.jump_previous();\n        let left = Self::virtual_render(presentation.current_slide(), dimensions, &options)?;\n        let direction = TransitionDirection::Previous;\n        self.animate_transition(drawer, left, right, direction, dimensions, config)\n    }\n\n    fn animate_transition(\n        &mut self,\n        drawer: &mut TerminalDrawer,\n        left: TerminalGrid,\n        right: TerminalGrid,\n        direction: TransitionDirection,\n        dimensions: WindowSize,\n        config: SlideTransitionConfig,\n    ) -> RenderResult {\n        let first = match &direction {\n            TransitionDirection::Next => left.clone(),\n            TransitionDirection::Previous => right.clone(),\n        };\n        match &config.animation {\n            SlideTransitionStyleConfig::SlideHorizontal => self.run_animation(\n                drawer,\n                first,\n                SlideHorizontalAnimation::new(left, right, dimensions, direction),\n                config,\n            ),\n            SlideTransitionStyleConfig::Fade => {\n                self.run_animation(drawer, first, FadeAnimation::new(left, right, direction), config)\n            }\n            SlideTransitionStyleConfig::CollapseHorizontal => {\n                self.run_animation(drawer, first, CollapseHorizontalAnimation::new(left, right, direction), config)\n            }\n        }\n    }\n\n    fn run_animation<T>(\n        &mut self,\n        drawer: &mut TerminalDrawer,\n        first: TerminalGrid,\n        animation: T,\n        config: SlideTransitionConfig,\n    ) -> RenderResult\n    where\n        T: AnimateTransition,\n    {\n        let total_time = Duration::from_millis(config.duration_millis as u64);\n        let frames: usize = config.frames;\n        let total_frames = animation.total_frames();\n        let step = total_time / (frames as u32 * 2);\n        let mut last_frame_index = 0;\n        let mut frame_index = 1;\n        // Render the first frame as text to have images as ascii\n        Self::render_frame(&LinesFrame::from(&first).build_commands(), drawer)?;\n        while frame_index < total_frames {\n            let start = Instant::now();\n            let frame = animation.build_frame(frame_index, last_frame_index);\n            let commands = frame.build_commands();\n            Self::render_frame(&commands, drawer)?;\n\n            let elapsed = start.elapsed();\n            let sleep_needed = step.saturating_sub(elapsed);\n            if sleep_needed.as_millis() > 0 {\n                std::thread::sleep(step);\n            }\n            last_frame_index = frame_index;\n            frame_index += total_frames.div_ceil(frames);\n        }\n        Ok(())\n    }\n\n    fn render_frame(commands: &[TerminalCommand<'_>], drawer: &mut TerminalDrawer) -> RenderResult {\n        drawer.terminal.execute(&TerminalCommand::BeginUpdate)?;\n        for command in commands {\n            drawer.terminal.execute(command)?;\n        }\n        drawer.terminal.execute(&TerminalCommand::EndUpdate)?;\n        drawer.terminal.execute(&TerminalCommand::Flush)?;\n        Ok(())\n    }\n\n    fn virtual_render(\n        slide: &Slide,\n        dimensions: WindowSize,\n        options: &RenderEngineOptions,\n    ) -> Result<TerminalGrid, RenderError> {\n        let mut term = VirtualTerminal::new(dimensions, ImageBehavior::PrintAscii);\n        let engine = RenderEngine::new(&mut term, dimensions, options.clone());\n        engine.render(slide.iter_visible_operations())?;\n        Ok(term.into_contents())\n    }\n\n    fn start_automatic_async_renders(&self, presentation: &mut Presentation) {\n        for (index, slide) in presentation.iter_slides_mut().enumerate() {\n            for operation in slide.iter_operations_mut() {\n                if let RenderOperation::RenderAsync(operation) = operation {\n                    if let RenderAsyncStartPolicy::Automatic = operation.start_policy() {\n                        let pollable = operation.pollable();\n                        self.poller.send(PollerCommand::Poll { pollable, slide: index });\n                    }\n                }\n            }\n        }\n    }\n}\n\nenum CommandSideEffect {\n    Exit,\n    Suspend,\n    Redraw,\n    Reload,\n    AnimateNextSlide,\n    AnimatePreviousSlide,\n    None,\n}\n\n#[derive(Default)]\nenum PresenterState {\n    #[default]\n    Empty,\n    Presenting(Presentation),\n    SlideIndex(Presentation),\n    KeyBindings(Presentation),\n    Failure {\n        error: String,\n        presentation: Presentation,\n        source: ErrorSource,\n        mode: FailureMode,\n    },\n}\n\nimpl PresenterState {\n    pub(crate) fn failure<E: Display>(\n        error: E,\n        presentation: Presentation,\n        source: ErrorSource,\n        mode: FailureMode,\n    ) -> Self {\n        PresenterState::Failure { error: error.to_string(), presentation, source, mode }\n    }\n\n    fn presentation(&self) -> &Presentation {\n        match self {\n            Self::Presenting(presentation)\n            | Self::SlideIndex(presentation)\n            | Self::KeyBindings(presentation)\n            | Self::Failure { presentation, .. } => presentation,\n            Self::Empty => panic!(\"state is empty\"),\n        }\n    }\n\n    fn presentation_mut(&mut self) -> &mut Presentation {\n        match self {\n            Self::Presenting(presentation)\n            | Self::SlideIndex(presentation)\n            | Self::KeyBindings(presentation)\n            | Self::Failure { presentation, .. } => presentation,\n            Self::Empty => panic!(\"state is empty\"),\n        }\n    }\n\n    fn into_presentation(self) -> Presentation {\n        match self {\n            Self::Presenting(presentation)\n            | Self::SlideIndex(presentation)\n            | Self::KeyBindings(presentation)\n            | Self::Failure { presentation, .. } => presentation,\n            Self::Empty => panic!(\"state is empty\"),\n        }\n    }\n}\n\nenum FailureMode {\n    Overflow,\n    Other,\n}\n\n/// This presentation mode.\npub enum PresentMode {\n    /// We are developing the presentation so we want live reloads when the input changes.\n    Development,\n\n    /// This is a live presentation so we don't want hot reloading.\n    Presentation,\n}\n\n/// An error when loading a presentation.\n#[derive(thiserror::Error, Debug)]\npub enum LoadPresentationError {\n    #[error(transparent)]\n    Processing(#[from] BuildError),\n\n    #[error(\"processing theme: {0}\")]\n    ProcessingTheme(#[from] ProcessingThemeError),\n}\n\n/// An error during the presentation.\n#[derive(thiserror::Error, Debug)]\npub enum PresentationError {\n    #[error(transparent)]\n    Render(#[from] RenderError),\n\n    #[error(\"io: {0}\")]\n    Io(#[from] io::Error),\n}\n"
  },
  {
    "path": "src/render/ascii_scaler.rs",
    "content": "use super::{\n    RenderError,\n    engine::{RenderEngine, RenderEngineOptions},\n};\nuse crate::{\n    WindowSize,\n    presentation::Presentation,\n    terminal::{\n        image::Image,\n        printer::{TerminalCommand, TerminalError, TerminalIo},\n    },\n};\nuse std::thread;\nuse unicode_width::UnicodeWidthStr;\n\npub(crate) struct AsciiScaler {\n    options: RenderEngineOptions,\n}\n\nimpl AsciiScaler {\n    pub(crate) fn new(options: RenderEngineOptions) -> Self {\n        Self { options }\n    }\n\n    pub(crate) fn process(self, presentation: &Presentation, dimensions: &WindowSize) -> Result<(), RenderError> {\n        let mut collector = ImageCollector::default();\n        for slide in presentation.iter_slides() {\n            let engine = RenderEngine::new(&mut collector, *dimensions, self.options.clone());\n            engine.render(slide.iter_operations())?;\n        }\n        thread::spawn(move || Self::scale(collector.images));\n        Ok(())\n    }\n\n    fn scale(images: Vec<ScalableImage>) {\n        for image in images {\n            let ascii_image = image.image.to_ascii();\n            ascii_image.cache_scaling(image.columns, image.rows);\n        }\n    }\n}\n\nstruct ScalableImage {\n    image: Image,\n    rows: u16,\n    columns: u16,\n}\n\nstruct ImageCollector {\n    current_column: u16,\n    current_row: u16,\n    current_row_height: u16,\n    images: Vec<ScalableImage>,\n}\n\nimpl Default for ImageCollector {\n    fn default() -> Self {\n        Self { current_row: 0, current_column: 0, current_row_height: 1, images: Default::default() }\n    }\n}\n\nimpl TerminalIo for ImageCollector {\n    fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {\n        use TerminalCommand::*;\n        match command {\n            MoveTo { column, row } => {\n                self.current_column = *column;\n                self.current_row = *row;\n            }\n            MoveToRow(row) => self.current_row = *row,\n            MoveToColumn(column) => self.current_column = *column,\n            MoveDown(amount) => self.current_row = self.current_row.saturating_add(*amount),\n            MoveRight(amount) => self.current_column = self.current_column.saturating_add(*amount),\n            MoveLeft(amount) => self.current_column = self.current_column.saturating_sub(*amount),\n            MoveToNextLine => {\n                self.current_row = self.current_row.saturating_add(1);\n                self.current_column = 0;\n                self.current_row_height = 1;\n            }\n            PrintText { content, style } => {\n                self.current_column = self.current_column.saturating_add(content.width() as u16);\n                self.current_row_height = self.current_row_height.max(style.size as u16);\n            }\n            PrintImage { image, options } => {\n                // we can only really cache filesystem images for now\n                let image = ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns };\n                self.images.push(image);\n            }\n            ClearScreen => {\n                self.current_column = 0;\n                self.current_row = 0;\n                self.current_row_height = 1;\n            }\n            BeginUpdate | EndUpdate | Flush | SetColors(_) | SetBackgroundColor(_) | SetCursorBoundaries { .. } => (),\n        };\n        Ok(())\n    }\n\n    fn cursor_row(&self) -> u16 {\n        self.current_row\n    }\n}\n"
  },
  {
    "path": "src/render/engine.rs",
    "content": "use super::{\n    RenderError, RenderResult, layout::Layout, operation::ImagePosition, properties::CursorPosition, text::TextDrawer,\n};\nuse crate::{\n    config::{MaxColumnsAlignment, MaxRowsAlignment},\n    markdown::{text::WeightedLine, text_style::Colors},\n    render::{\n        operation::{\n            AsRenderOperations, BlockLine, ImageRenderProperties, ImageSize, LayoutGrid, MarginProperties, RenderAsync,\n            RenderOperation,\n        },\n        properties::WindowSize,\n    },\n    terminal::{\n        image::{\n            Image,\n            printer::{ImageProperties, PrintOptions},\n            scale::{ImageScaler, ScaleImage},\n        },\n        printer::{TerminalCommand, TerminalIo},\n    },\n    theme::{Alignment, Margin},\n};\nuse std::mem;\n\nconst MINIMUM_LINE_LENGTH: u16 = 10;\n\n#[derive(Clone, Debug)]\npub(crate) struct MaxSize {\n    pub(crate) max_columns: u16,\n    pub(crate) max_columns_alignment: MaxColumnsAlignment,\n    pub(crate) max_rows: u16,\n    pub(crate) max_rows_alignment: MaxRowsAlignment,\n}\n\nimpl Default for MaxSize {\n    fn default() -> Self {\n        Self {\n            max_columns: u16::MAX,\n            max_columns_alignment: Default::default(),\n            max_rows: u16::MAX,\n            max_rows_alignment: Default::default(),\n        }\n    }\n}\n\n#[derive(Clone, Default, Debug)]\npub(crate) struct RenderEngineOptions {\n    pub(crate) validate_overflows: bool,\n    pub(crate) max_size: MaxSize,\n}\n\npub(crate) struct RenderEngine<'a, T>\nwhere\n    T: TerminalIo,\n{\n    terminal: &'a mut T,\n    window_rects: Vec<WindowRect>,\n    colors: Colors,\n    max_modified_row: u16,\n    layout: LayoutState,\n    options: RenderEngineOptions,\n    image_scaler: Box<dyn ScaleImage>,\n}\n\nimpl<'a, T> RenderEngine<'a, T>\nwhere\n    T: TerminalIo,\n{\n    pub(crate) fn new(terminal: &'a mut T, window_dimensions: WindowSize, options: RenderEngineOptions) -> Self {\n        let max_modified_row = terminal.cursor_row();\n        let current_rect = Self::starting_rect(window_dimensions, &options);\n        let window_rects = vec![current_rect.clone()];\n        Self {\n            terminal,\n            window_rects,\n            colors: Default::default(),\n            max_modified_row,\n            layout: Default::default(),\n            options,\n            image_scaler: Box::<ImageScaler>::default(),\n        }\n    }\n\n    fn starting_rect(mut dimensions: WindowSize, options: &RenderEngineOptions) -> WindowRect {\n        let mut start_row = 0;\n        let mut start_column = 0;\n        if dimensions.columns > options.max_size.max_columns {\n            let extra_width = dimensions.columns - options.max_size.max_columns;\n            dimensions = dimensions.shrink_columns(extra_width);\n            start_column = match options.max_size.max_columns_alignment {\n                MaxColumnsAlignment::Left => 0,\n                MaxColumnsAlignment::Center => extra_width / 2,\n                MaxColumnsAlignment::Right => extra_width,\n            };\n        }\n        if dimensions.rows > options.max_size.max_rows {\n            let extra_height = dimensions.rows - options.max_size.max_rows;\n            dimensions = dimensions.shrink_rows(extra_height);\n            start_row = match options.max_size.max_rows_alignment {\n                MaxRowsAlignment::Top => 0,\n                MaxRowsAlignment::Center => extra_height / 2,\n                MaxRowsAlignment::Bottom => extra_height,\n            };\n        }\n        WindowRect { dimensions, start_column, start_row }\n    }\n\n    pub(crate) fn render<'b>(mut self, operations: impl Iterator<Item = &'b RenderOperation>) -> RenderResult {\n        let current_rect = self.current_rect().clone();\n        self.terminal.execute(&TerminalCommand::SetCursorBoundaries {\n            rows: current_rect.dimensions.rows.saturating_add(current_rect.start_row),\n        })?;\n        self.terminal.execute(&TerminalCommand::BeginUpdate)?;\n        if current_rect.start_row != 0 || current_rect.start_column != 0 {\n            self.terminal\n                .execute(&TerminalCommand::MoveTo { column: current_rect.start_column, row: current_rect.start_row })?;\n        }\n        for operation in operations {\n            self.render_one(operation)?;\n        }\n        self.terminal.execute(&TerminalCommand::EndUpdate)?;\n        self.terminal.execute(&TerminalCommand::Flush)?;\n        if self.options.validate_overflows && self.max_modified_row > self.window_rects[0].dimensions.rows {\n            return Err(RenderError::VerticalOverflow);\n        }\n        Ok(())\n    }\n\n    fn render_one(&mut self, operation: &RenderOperation) -> RenderResult {\n        match operation {\n            RenderOperation::ClearScreen => self.clear_screen(),\n            RenderOperation::ApplyMargin(properties) => self.apply_margin(properties),\n            RenderOperation::PopMargin => self.pop_margin(),\n            RenderOperation::SetColors(colors) => self.set_colors(colors),\n            RenderOperation::JumpToVerticalCenter => self.jump_to_vertical_center(),\n            RenderOperation::JumpToRow { index } => self.jump_to_row(*index),\n            RenderOperation::JumpToBottomRow { index } => self.jump_to_bottom(*index),\n            RenderOperation::JumpToColumn { index } => self.jump_to_column(*index),\n            RenderOperation::RenderText { line, alignment } => self.render_text(line, *alignment),\n            RenderOperation::RenderLineBreak => self.render_line_break(),\n            RenderOperation::RenderImage(image, properties) => self.render_image(image, properties),\n            RenderOperation::RenderBlockLine(operation) => self.render_block_line(operation),\n            RenderOperation::RenderDynamic(generator) => self.render_dynamic(generator.as_ref()),\n            RenderOperation::RenderDynamicTopLevel(generator) => self.render_dynamic_top_level(generator.as_ref()),\n            RenderOperation::RenderAsync(generator) => self.render_async(generator.as_ref()),\n            RenderOperation::InitColumnLayout { columns, grid, margin } => {\n                self.init_column_layout(columns, *grid, *margin)\n            }\n            RenderOperation::EnterColumn { column } => self.enter_column(*column),\n            RenderOperation::ExitLayout => self.exit_layout(),\n        }?;\n        if let LayoutState::EnteredColumn { column, columns, .. } = &mut self.layout {\n            columns[*column].current_row = self.terminal.cursor_row();\n        };\n        self.max_modified_row = self.max_modified_row.max(self.terminal.cursor_row());\n        Ok(())\n    }\n\n    fn current_rect(&self) -> &WindowRect {\n        // This invariant is enforced when popping.\n        self.window_rects.last().expect(\"no rects\")\n    }\n\n    fn current_dimensions(&self) -> &WindowSize {\n        &self.current_rect().dimensions\n    }\n\n    fn current_available_dimensions(&self) -> WindowSize {\n        let rect = self.current_rect();\n        rect.dimensions.shrink_rows(self.terminal.cursor_row())\n    }\n\n    fn clear_screen(&mut self) -> RenderResult {\n        let current = self.current_rect().clone();\n        self.terminal.execute(&TerminalCommand::ClearScreen)?;\n        self.terminal.execute(&TerminalCommand::MoveTo { column: current.start_column, row: current.start_row })?;\n        self.max_modified_row = 0;\n        Ok(())\n    }\n\n    fn apply_margin(&mut self, properties: &MarginProperties) -> RenderResult {\n        let MarginProperties { horizontal: horizontal_margin, top, bottom } = properties;\n        let current = self.current_rect();\n        let margin = horizontal_margin.as_characters(current.dimensions.columns);\n        let new_rect = current.shrink_horizontal(margin).shrink_bottom(*bottom).shrink_top(*top);\n        if new_rect.start_row != self.terminal.cursor_row() {\n            self.terminal.execute(&TerminalCommand::MoveToRow(new_rect.start_row))?;\n        }\n        self.window_rects.push(new_rect);\n        Ok(())\n    }\n\n    fn pop_margin(&mut self) -> RenderResult {\n        if self.window_rects.len() == 1 {\n            return Err(RenderError::PopDefaultScreen);\n        }\n        self.window_rects.pop();\n        Ok(())\n    }\n\n    fn set_colors(&mut self, colors: &Colors) -> RenderResult {\n        self.colors = *colors;\n        self.apply_colors()\n    }\n\n    fn apply_colors(&mut self) -> RenderResult {\n        self.terminal.execute(&TerminalCommand::SetColors(self.colors))?;\n        Ok(())\n    }\n\n    fn jump_to_vertical_center(&mut self) -> RenderResult {\n        let current = self.current_rect();\n        let center_row = current.dimensions.rows / 2;\n        let center_row = center_row.saturating_add(current.start_row);\n        self.terminal.execute(&TerminalCommand::MoveToRow(center_row))?;\n        Ok(())\n    }\n\n    fn jump_to_row(&mut self, row: u16) -> RenderResult {\n        // Make this relative to the beginning of the current rect.\n        let row = self.current_rect().start_row.saturating_add(row);\n        self.terminal.execute(&TerminalCommand::MoveToRow(row))?;\n        Ok(())\n    }\n\n    fn jump_to_bottom(&mut self, index: u16) -> RenderResult {\n        let current = self.current_rect();\n        let target_row = current.dimensions.rows.saturating_sub(index).saturating_sub(1);\n        let target_row = target_row.saturating_add(current.start_row);\n        self.terminal.execute(&TerminalCommand::MoveToRow(target_row))?;\n        Ok(())\n    }\n\n    fn jump_to_column(&mut self, column: u16) -> RenderResult {\n        // Make this relative to the beginning of the current rect.\n        let column = self.current_rect().start_column.saturating_add(column);\n        self.terminal.execute(&TerminalCommand::MoveToColumn(column))?;\n        Ok(())\n    }\n\n    fn render_text(&mut self, text: &WeightedLine, alignment: Alignment) -> RenderResult {\n        let layout = self.build_layout(alignment);\n        let dimensions = self.current_dimensions();\n        let positioning = layout.compute(dimensions, text.width() as u16);\n        let prefix = \"\".into();\n        let text_drawer = TextDrawer::new(&prefix, 0, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?;\n        let center_newlines = matches!(alignment, Alignment::Center { .. });\n        let text_drawer = text_drawer.center_newlines(center_newlines);\n        text_drawer.draw(self.terminal)?;\n        // Restore colors\n        self.apply_colors()\n    }\n\n    fn render_line_break(&mut self) -> RenderResult {\n        self.terminal.execute(&TerminalCommand::MoveToNextLine)?;\n        Ok(())\n    }\n\n    fn render_image(&mut self, image: &Image, properties: &ImageRenderProperties) -> RenderResult {\n        let rect = self.current_rect().clone();\n        let starting_row = self.terminal.cursor_row();\n        let starting_cursor =\n            CursorPosition { row: starting_row.saturating_sub(rect.start_row), column: rect.start_column };\n\n        let (width, height) = image.image().dimensions();\n        let (columns, rows) = match properties.size {\n            ImageSize::ShrinkIfNeeded => {\n                let image_scale =\n                    self.image_scaler.fit_image_to_rect(&rect.dimensions, width, height, &starting_cursor);\n                (image_scale.columns, image_scale.rows)\n            }\n            ImageSize::Specific(columns, rows) => (columns, rows),\n            ImageSize::WidthScaled { ratio } => {\n                let extra_columns = (rect.dimensions.columns as f64 * (1.0 - ratio)).ceil() as u16;\n                let dimensions = rect.dimensions.shrink_columns(extra_columns);\n                let image_scale =\n                    self.image_scaler.scale_image(&dimensions, &rect.dimensions, width, height, &starting_cursor);\n                (image_scale.columns, image_scale.rows)\n            }\n        };\n        let cursor = match &properties.position {\n            ImagePosition::Cursor => starting_cursor.clone(),\n            ImagePosition::Center => Self::center_cursor(columns, &rect.dimensions, &starting_cursor),\n            ImagePosition::Right => Self::align_cursor_right(columns, &rect.dimensions, &starting_cursor),\n        };\n        self.terminal.execute(&TerminalCommand::MoveToColumn(cursor.column))?;\n\n        let options = PrintOptions {\n            columns,\n            rows,\n            z_index: properties.z_index,\n            column_width: rect.dimensions.pixels_per_column() as u16,\n            row_height: rect.dimensions.pixels_per_row() as u16,\n            background_color: properties.background_color,\n        };\n        self.terminal.execute(&TerminalCommand::PrintImage { image: image.clone(), options })?;\n        if properties.restore_cursor {\n            self.terminal.execute(&TerminalCommand::MoveTo { column: starting_cursor.column, row: starting_row })?;\n        } else {\n            self.terminal.execute(&TerminalCommand::MoveToRow(starting_row + rows))?;\n        }\n        self.apply_colors()\n    }\n\n    fn center_cursor(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {\n        let start_column = window.columns / 2 - (columns / 2);\n        let start_column = start_column + cursor.column;\n        CursorPosition { row: cursor.row, column: start_column }\n    }\n\n    fn align_cursor_right(columns: u16, window: &WindowSize, cursor: &CursorPosition) -> CursorPosition {\n        let start_column = window.columns.saturating_sub(columns).saturating_add(cursor.column);\n        CursorPosition { row: cursor.row, column: start_column }\n    }\n\n    fn render_block_line(&mut self, operation: &BlockLine) -> RenderResult {\n        let BlockLine {\n            text,\n            block_length,\n            alignment,\n            block_color,\n            prefix,\n            right_padding_length,\n            repeat_prefix_on_wrap,\n        } = operation;\n        let layout = self.build_layout(*alignment).with_font_size(text.font_size());\n\n        let dimensions = self.current_dimensions();\n\n        let positioning = layout.compute(dimensions, *block_length);\n        if self.options.validate_overflows && text.width() as u16 > positioning.max_line_length {\n            return Err(RenderError::HorizontalOverflow);\n        }\n\n        self.terminal.execute(&TerminalCommand::MoveToColumn(positioning.start_column))?;\n        let text_drawer =\n            TextDrawer::new(prefix, *right_padding_length, text, positioning, &self.colors, MINIMUM_LINE_LENGTH)?\n                .with_surrounding_block(*block_color)\n                .repeat_prefix_on_wrap(*repeat_prefix_on_wrap);\n        text_drawer.draw(self.terminal)?;\n\n        // Restore colors\n        self.apply_colors()?;\n        Ok(())\n    }\n\n    fn render_dynamic(&mut self, generator: &dyn AsRenderOperations) -> RenderResult {\n        let operations = generator.as_render_operations(&self.current_available_dimensions());\n        for operation in operations {\n            self.render_one(&operation)?;\n        }\n        Ok(())\n    }\n\n    fn render_dynamic_top_level(&mut self, generator: &dyn AsRenderOperations) -> RenderResult {\n        let dimensions = self.window_rects.first().expect(\"no rects\").dimensions;\n        let operations = generator.as_render_operations(&dimensions);\n        for operation in operations {\n            self.render_one(&operation)?;\n        }\n        Ok(())\n    }\n\n    fn render_async(&mut self, generator: &dyn RenderAsync) -> RenderResult {\n        let operations = generator.as_render_operations(&self.current_available_dimensions());\n        for operation in operations {\n            self.render_one(&operation)?;\n        }\n        Ok(())\n    }\n\n    fn init_column_layout(&mut self, widths: &[u8], grid: LayoutGrid, margin: Margin) -> RenderResult {\n        if !matches!(self.layout, LayoutState::Default) {\n            self.exit_layout()?;\n        }\n        let current_row = self.terminal.cursor_row();\n        let rect = self.current_rect();\n        let total_units = widths.iter().copied().map(u16::from).sum::<u16>();\n        let total_columns = rect.dimensions.columns;\n        let unit_columns = total_columns as f64 / total_units as f64;\n        let mut columns = Vec::new();\n        let mut current_column = rect.start_column;\n        for width in widths {\n            let width = (f64::from(*width) * unit_columns) as u16;\n            let end_column = current_column + width;\n            columns.push(Column { start_column: current_column, end_column, current_row });\n            current_column = end_column;\n        }\n        self.layout = LayoutState::InitializedColumn { columns, grid, margin, start_row: current_row };\n        Ok(())\n    }\n\n    fn enter_column(&mut self, column_index: usize) -> RenderResult {\n        let (columns, margin, grid, start_row) = match mem::take(&mut self.layout) {\n            LayoutState::Default => return Err(RenderError::InvalidLayoutEnter),\n            LayoutState::InitializedColumn { columns, .. } | LayoutState::EnteredColumn { columns, .. }\n                if column_index >= columns.len() =>\n            {\n                return Err(RenderError::InvalidLayoutEnter);\n            }\n            LayoutState::InitializedColumn { columns, margin, grid, start_row } => (columns, margin, grid, start_row),\n            LayoutState::EnteredColumn { columns, margin, grid, start_row, .. } => {\n                // Pop this one and start clean\n                self.pop_margin()?;\n                (columns, margin, grid, start_row)\n            }\n        };\n        let column = &columns[column_index];\n        let current_rect = self.current_rect();\n        let total_columns = column.end_column.saturating_sub(column.start_column);\n        let new_size = current_rect\n            .dimensions\n            .set_columns(total_columns)\n            .shrink_rows(column.current_row.saturating_sub(current_rect.start_row));\n        let mut dimensions =\n            WindowRect { dimensions: new_size, start_column: column.start_column, start_row: column.current_row };\n\n        let total_margin = margin.as_characters(self.window_rects[0].dimensions.columns);\n        if column_index == 0 {\n            dimensions = dimensions.shrink_right(total_margin);\n        } else if column_index == columns.len() - 1 {\n            dimensions = dimensions.shrink_left(total_margin);\n        } else {\n            let margin = total_margin / 2;\n            dimensions = dimensions.shrink_left(margin).shrink_right(margin);\n        }\n\n        self.window_rects.push(dimensions);\n        self.terminal.execute(&TerminalCommand::MoveToRow(column.current_row))?;\n        self.layout = LayoutState::EnteredColumn { column: column_index, columns, margin, grid, start_row };\n\n        Ok(())\n    }\n\n    fn exit_layout(&mut self) -> RenderResult {\n        match &self.layout {\n            LayoutState::Default => return Ok(()),\n            LayoutState::InitializedColumn { columns, grid, start_row, .. }\n            | LayoutState::EnteredColumn { columns, grid, start_row, .. } => {\n                if let LayoutGrid::Draw(style) = grid {\n                    let style = *style;\n                    let max_row = columns.iter().map(|c| c.current_row).max().unwrap_or(0);\n                    for row in *start_row..=max_row {\n                        self.terminal.execute(&TerminalCommand::MoveToRow(row))?;\n                        for column in columns.iter().skip(1) {\n                            self.terminal.execute(&TerminalCommand::MoveToColumn(column.start_column))?;\n                            self.terminal.execute(&TerminalCommand::PrintText { content: \"│\", style })?;\n                        }\n                    }\n                    self.apply_colors()?;\n                }\n            }\n        };\n        match &self.layout {\n            LayoutState::Default | LayoutState::InitializedColumn { .. } => Ok(()),\n            LayoutState::EnteredColumn { .. } => {\n                self.terminal.execute(&TerminalCommand::MoveTo { column: 0, row: self.max_modified_row })?;\n                self.layout = LayoutState::Default;\n                self.pop_margin()?;\n                Ok(())\n            }\n        }\n    }\n\n    fn build_layout(&self, alignment: Alignment) -> Layout {\n        Layout::new(alignment).with_start_column(self.current_rect().start_column)\n    }\n}\n\n#[derive(Default)]\nenum LayoutState {\n    #[default]\n    Default,\n    InitializedColumn {\n        start_row: u16,\n        columns: Vec<Column>,\n        margin: Margin,\n        grid: LayoutGrid,\n    },\n    EnteredColumn {\n        start_row: u16,\n        column: usize,\n        columns: Vec<Column>,\n        margin: Margin,\n        grid: LayoutGrid,\n    },\n}\n\n#[derive(Debug)]\nstruct Column {\n    start_column: u16,\n    end_column: u16,\n    current_row: u16,\n}\n\n#[derive(Clone, Debug)]\nstruct WindowRect {\n    dimensions: WindowSize,\n    start_column: u16,\n    start_row: u16,\n}\n\nimpl WindowRect {\n    fn shrink_horizontal(&self, margin: u16) -> Self {\n        let dimensions = self.dimensions.shrink_columns(margin.saturating_mul(2));\n        let start_column = self.start_column + margin;\n        Self { dimensions, start_column, start_row: self.start_row }\n    }\n\n    fn shrink_left(&self, size: u16) -> Self {\n        let dimensions = self.dimensions.shrink_columns(size);\n        let start_column = self.start_column.saturating_add(size);\n        Self { dimensions, start_column, start_row: self.start_row }\n    }\n\n    fn shrink_right(&self, size: u16) -> Self {\n        let dimensions = self.dimensions.shrink_columns(size);\n        Self { dimensions, start_column: self.start_column, start_row: self.start_row }\n    }\n\n    fn shrink_top(&self, rows: u16) -> Self {\n        let dimensions = self.dimensions.shrink_rows(rows);\n        let start_row = self.start_row.saturating_add(rows);\n        Self { dimensions, start_column: self.start_column, start_row }\n    }\n\n    fn shrink_bottom(&self, rows: u16) -> Self {\n        let dimensions = self.dimensions.shrink_rows(rows);\n        Self { dimensions, start_column: self.start_column, start_row: self.start_row }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::{\n        markdown::text_style::{Color, TextStyle},\n        terminal::{\n            image::{\n                ImageSource,\n                printer::{PrintImageError, TerminalImage},\n                scale::TerminalRect,\n            },\n            printer::TerminalError,\n        },\n        theme::Margin,\n    };\n    use ::image::{ColorType, DynamicImage};\n    use rstest::rstest;\n    use std::io;\n    use unicode_width::UnicodeWidthStr;\n\n    #[derive(Debug, PartialEq)]\n    enum Instruction {\n        MoveTo(u16, u16),\n        MoveToRow(u16),\n        MoveToColumn(u16),\n        MoveDown(u16),\n        MoveRight(u16),\n        MoveLeft(u16),\n        MoveToNextLine,\n        PrintText(String),\n        ClearScreen,\n        SetBackgroundColor(Color),\n        PrintImage(PrintOptions),\n    }\n\n    #[derive(Default)]\n    struct TerminalBuf {\n        instructions: Vec<Instruction>,\n        cursor_row: u16,\n    }\n\n    impl TerminalBuf {\n        fn push(&mut self, instruction: Instruction) -> io::Result<()> {\n            self.instructions.push(instruction);\n            Ok(())\n        }\n\n        fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {\n            self.cursor_row = row;\n            self.push(Instruction::MoveTo(column, row))\n        }\n\n        fn move_to_row(&mut self, row: u16) -> io::Result<()> {\n            self.cursor_row = row;\n            self.push(Instruction::MoveToRow(row))\n        }\n\n        fn move_to_column(&mut self, column: u16) -> io::Result<()> {\n            self.push(Instruction::MoveToColumn(column))\n        }\n\n        fn move_down(&mut self, amount: u16) -> io::Result<()> {\n            self.push(Instruction::MoveDown(amount))\n        }\n\n        fn move_right(&mut self, amount: u16) -> io::Result<()> {\n            self.push(Instruction::MoveRight(amount))\n        }\n\n        fn move_left(&mut self, amount: u16) -> io::Result<()> {\n            self.push(Instruction::MoveLeft(amount))\n        }\n\n        fn move_to_next_line(&mut self) -> io::Result<()> {\n            self.push(Instruction::MoveToNextLine)\n        }\n\n        fn print_text(&mut self, content: &str, _style: &TextStyle) -> io::Result<()> {\n            let content = content.to_string();\n            if content.is_empty() {\n                return Ok(());\n            }\n            self.cursor_row = content.width() as u16;\n            self.push(Instruction::PrintText(content))\n        }\n\n        fn clear_screen(&mut self) -> io::Result<()> {\n            self.cursor_row = 0;\n            self.push(Instruction::ClearScreen)\n        }\n\n        fn set_colors(&mut self, _colors: Colors) -> io::Result<()> {\n            Ok(())\n        }\n\n        fn set_background_color(&mut self, color: Color) -> io::Result<()> {\n            self.push(Instruction::SetBackgroundColor(color))\n        }\n\n        fn flush(&mut self) -> io::Result<()> {\n            Ok(())\n        }\n\n        fn print_image(&mut self, _image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {\n            let _ = self.push(Instruction::PrintImage(options.clone()));\n            Ok(())\n        }\n    }\n\n    impl TerminalIo for TerminalBuf {\n        fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {\n            use TerminalCommand::*;\n            match command {\n                BeginUpdate => (),\n                EndUpdate => (),\n                MoveTo { column, row } => self.move_to(*column, *row)?,\n                MoveToRow(row) => self.move_to_row(*row)?,\n                MoveToColumn(column) => self.move_to_column(*column)?,\n                MoveDown(amount) => self.move_down(*amount)?,\n                MoveRight(amount) => self.move_right(*amount)?,\n                MoveLeft(amount) => self.move_left(*amount)?,\n                MoveToNextLine => self.move_to_next_line()?,\n                PrintText { content, style } => self.print_text(content, style)?,\n                ClearScreen => self.clear_screen()?,\n                SetColors(colors) => self.set_colors(*colors)?,\n                SetBackgroundColor(color) => self.set_background_color(*color)?,\n                Flush => self.flush()?,\n                PrintImage { image, options } => self.print_image(image, options)?,\n                SetCursorBoundaries { .. } => (),\n            };\n            Ok(())\n        }\n\n        fn cursor_row(&self) -> u16 {\n            self.cursor_row\n        }\n    }\n\n    struct DummyImageScaler;\n\n    impl ScaleImage for DummyImageScaler {\n        fn scale_image(\n            &self,\n            _scale_size: &WindowSize,\n            _window_dimensions: &WindowSize,\n            image_width: u32,\n            image_height: u32,\n            _position: &CursorPosition,\n        ) -> TerminalRect {\n            TerminalRect { rows: image_width as u16, columns: image_height as u16 }\n        }\n\n        fn fit_image_to_rect(\n            &self,\n            _dimensions: &WindowSize,\n            image_width: u32,\n            image_height: u32,\n            _position: &CursorPosition,\n        ) -> TerminalRect {\n            TerminalRect { rows: image_width as u16, columns: image_height as u16 }\n        }\n    }\n\n    fn do_render(max_size: MaxSize, operations: &[RenderOperation]) -> Vec<Instruction> {\n        let mut buf = TerminalBuf::default();\n        let dimensions = WindowSize { rows: 100, columns: 100, height: 200, width: 200 };\n        let options = RenderEngineOptions { validate_overflows: false, max_size };\n        let mut engine = RenderEngine::new(&mut buf, dimensions, options);\n        engine.image_scaler = Box::new(DummyImageScaler);\n        engine.render(operations.iter()).expect(\"render failed\");\n        buf.instructions\n    }\n\n    fn render(operations: &[RenderOperation]) -> Vec<Instruction> {\n        do_render(Default::default(), operations)\n    }\n\n    fn render_with_max_size(operations: &[RenderOperation]) -> Vec<Instruction> {\n        let max_size = MaxSize {\n            max_rows: 10,\n            max_rows_alignment: MaxRowsAlignment::Center,\n            max_columns: 20,\n            max_columns_alignment: MaxColumnsAlignment::Center,\n        };\n        do_render(max_size, operations)\n    }\n\n    #[test]\n    fn columns() {\n        let ops = render(&[\n            RenderOperation::InitColumnLayout {\n                columns: vec![1, 1],\n                grid: LayoutGrid::None,\n                margin: Default::default(),\n            },\n            // print on column 0\n            RenderOperation::EnterColumn { column: 0 },\n            RenderOperation::RenderText { line: \"A\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            // print on column 1\n            RenderOperation::EnterColumn { column: 1 },\n            RenderOperation::RenderText { line: \"B\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            // go back to column 0 and print\n            RenderOperation::EnterColumn { column: 0 },\n            RenderOperation::RenderText { line: \"1\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n        ]);\n        let expected = [\n            Instruction::MoveToRow(0),\n            Instruction::MoveToColumn(0),\n            Instruction::PrintText(\"A\".into()),\n            Instruction::MoveToRow(0),\n            Instruction::MoveToColumn(50),\n            Instruction::PrintText(\"B\".into()),\n            // when we go back we should proceed from where we left off (row == 1)\n            Instruction::MoveToRow(1),\n            Instruction::MoveToColumn(0),\n            Instruction::PrintText(\"1\".into()),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    #[test]\n    fn bottom_margin() {\n        let ops = render(&[\n            RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),\n            RenderOperation::RenderText { line: \"A\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            RenderOperation::JumpToBottomRow { index: 0 },\n            RenderOperation::RenderText { line: \"B\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n        ]);\n        let expected = [\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText(\"A\".into()),\n            // 100 - 10 (bottom margin)\n            Instruction::MoveToRow(89),\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText(\"B\".into()),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    #[test]\n    fn top_margin() {\n        let ops = render(&[\n            RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 0 }),\n            RenderOperation::RenderText { line: \"A\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n        ]);\n        let expected = [Instruction::MoveToRow(3), Instruction::MoveToColumn(1), Instruction::PrintText(\"A\".into())];\n        assert_eq!(ops, expected);\n    }\n\n    #[test]\n    fn margins() {\n        let ops = render(&[\n            RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 3, bottom: 10 }),\n            RenderOperation::JumpToRow { index: 0 },\n            RenderOperation::RenderText { line: \"A\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            RenderOperation::JumpToBottomRow { index: 0 },\n            RenderOperation::RenderText { line: \"B\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n        ]);\n        let expected = [\n            Instruction::MoveToRow(3),\n            Instruction::MoveToRow(3),\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText(\"A\".into()),\n            // 100 - 10 (bottom margin)\n            Instruction::MoveToRow(89),\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText(\"B\".into()),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    #[test]\n    fn nested_margins() {\n        let ops = render(&[\n            RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),\n            RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 0, bottom: 10 }),\n            RenderOperation::RenderText { line: \"A\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            RenderOperation::JumpToBottomRow { index: 0 },\n            RenderOperation::RenderText { line: \"B\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            // pop and go to bottom, this should go back up to the end of the first margin\n            RenderOperation::PopMargin,\n            RenderOperation::JumpToBottomRow { index: 0 },\n            RenderOperation::RenderText { line: \"C\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n        ]);\n        let expected = [\n            Instruction::MoveToColumn(2),\n            Instruction::PrintText(\"A\".into()),\n            // 100 - 10 (margin) - 10 (second margin)\n            Instruction::MoveToRow(79),\n            Instruction::MoveToColumn(2),\n            Instruction::PrintText(\"B\".into()),\n            // 100 - 10 (margin)\n            Instruction::MoveToRow(89),\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText(\"C\".into()),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    #[test]\n    fn margin_with_max_size() {\n        let ops = render_with_max_size(&[\n            RenderOperation::RenderText { line: \"A\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            RenderOperation::ApplyMargin(MarginProperties { horizontal: Margin::Fixed(1), top: 2, bottom: 1 }),\n            RenderOperation::RenderText { line: \"B\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n            RenderOperation::JumpToBottomRow { index: 0 },\n            RenderOperation::RenderText { line: \"C\".into(), alignment: Alignment::Left { margin: Margin::Fixed(0) } },\n        ]);\n        let expected = [\n            // centered 20x10\n            Instruction::MoveTo(40, 45),\n            Instruction::MoveToColumn(40),\n            Instruction::PrintText(\"A\".into()),\n            // jump 2 down because of top margin\n            Instruction::MoveToRow(47),\n            // jump 1 right because of horizontal margin\n            Instruction::MoveToColumn(41),\n            Instruction::PrintText(\"B\".into()),\n            // rows go from 47 to 53 (7 total)\n            Instruction::MoveToRow(53),\n            Instruction::MoveToColumn(41),\n            Instruction::PrintText(\"C\".into()),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    // print the same 2x2 image with all size configs, they should all yield the same\n    #[rstest]\n    #[case::shrink(ImageSize::ShrinkIfNeeded)]\n    #[case::specific(ImageSize::Specific(2, 2))]\n    #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]\n    fn image(#[case] size: ImageSize) {\n        let image = DynamicImage::new(2, 2, ColorType::Rgba8);\n        let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);\n        let properties = ImageRenderProperties {\n            z_index: 0,\n            size,\n            restore_cursor: false,\n            background_color: None,\n            position: ImagePosition::Cursor,\n        };\n        let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);\n        let expected = [\n            // centered 20x10, the image is 2x2 so we stand one away from center\n            Instruction::MoveTo(40, 45),\n            Instruction::MoveToColumn(40),\n            Instruction::PrintImage(PrintOptions {\n                columns: 2,\n                rows: 2,\n                z_index: 0,\n                background_color: None,\n                column_width: 2,\n                row_height: 2,\n            }),\n            // place cursor after the image\n            Instruction::MoveToRow(47),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    // same as the above but center it\n    #[rstest]\n    #[case::shrink(ImageSize::ShrinkIfNeeded)]\n    #[case::specific(ImageSize::Specific(2, 2))]\n    #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]\n    fn centered_image(#[case] size: ImageSize) {\n        let image = DynamicImage::new(2, 2, ColorType::Rgba8);\n        let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);\n        let properties = ImageRenderProperties {\n            z_index: 0,\n            size,\n            restore_cursor: false,\n            background_color: None,\n            position: ImagePosition::Center,\n        };\n        let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);\n        let expected = [\n            // centered 20x10, the image is 2x2 so we stand one away from center\n            Instruction::MoveTo(40, 45),\n            Instruction::MoveToColumn(49),\n            Instruction::PrintImage(PrintOptions {\n                columns: 2,\n                rows: 2,\n                z_index: 0,\n                background_color: None,\n                column_width: 2,\n                row_height: 2,\n            }),\n            // place cursor after the image\n            Instruction::MoveToRow(47),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    // same as the above but use right alignment\n    #[rstest]\n    #[case::shrink(ImageSize::ShrinkIfNeeded)]\n    #[case::specific(ImageSize::Specific(2, 2))]\n    #[case::width_scaled(ImageSize::WidthScaled { ratio: 1.0 })]\n    fn right_aligned_image(#[case] size: ImageSize) {\n        let image = DynamicImage::new(2, 2, ColorType::Rgba8);\n        let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);\n        let properties = ImageRenderProperties {\n            z_index: 0,\n            size,\n            restore_cursor: false,\n            background_color: None,\n            position: ImagePosition::Right,\n        };\n        let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);\n        let expected = [\n            // right aligned 20x10, the image is 2x2 so we stand one away from the right\n            Instruction::MoveTo(40, 45),\n            Instruction::MoveToColumn(58),\n            Instruction::PrintImage(PrintOptions {\n                columns: 2,\n                rows: 2,\n                z_index: 0,\n                background_color: None,\n                column_width: 2,\n                row_height: 2,\n            }),\n            // place cursor after the image\n            Instruction::MoveToRow(47),\n        ];\n        assert_eq!(ops, expected);\n    }\n\n    // same as the above but center it\n    #[rstest]\n    fn restore_cursor_after_image() {\n        let image = DynamicImage::new(2, 2, ColorType::Rgba8);\n        let image = Image::new(TerminalImage::Ascii(image.into()), ImageSource::Generated);\n        let properties = ImageRenderProperties {\n            z_index: 0,\n            size: ImageSize::ShrinkIfNeeded,\n            restore_cursor: true,\n            background_color: None,\n            position: ImagePosition::Center,\n        };\n        let ops = render_with_max_size(&[RenderOperation::RenderImage(image, properties)]);\n        let expected = [\n            // centered 20x10, the image is 2x2 so we stand one away from center\n            Instruction::MoveTo(40, 45),\n            Instruction::MoveToColumn(49),\n            Instruction::PrintImage(PrintOptions {\n                columns: 2,\n                rows: 2,\n                z_index: 0,\n                background_color: None,\n                column_width: 2,\n                row_height: 2,\n            }),\n            // place cursor after the image\n            Instruction::MoveTo(40, 45),\n        ];\n        assert_eq!(ops, expected);\n    }\n}\n"
  },
  {
    "path": "src/render/layout.rs",
    "content": "use crate::{render::properties::WindowSize, theme::Alignment};\n\n#[derive(Debug)]\npub(crate) struct Layout {\n    alignment: Alignment,\n    start_column_offset: u16,\n    font_size: u16,\n}\n\nimpl Layout {\n    pub(crate) fn new(alignment: Alignment) -> Self {\n        Self { alignment, start_column_offset: 0, font_size: 1 }\n    }\n\n    pub(crate) fn with_start_column(mut self, column: u16) -> Self {\n        self.start_column_offset = column;\n        self\n    }\n\n    pub(crate) fn with_font_size(mut self, font_size: u8) -> Self {\n        self.font_size = font_size as u16;\n        self\n    }\n\n    pub(crate) fn compute(&self, dimensions: &WindowSize, text_length: u16) -> Positioning {\n        let text_length = text_length * self.font_size;\n        let max_line_length;\n        let mut start_column;\n        match &self.alignment {\n            Alignment::Left { margin } => {\n                let margin = margin.as_characters(dimensions.columns);\n                // Ignore the margin if it's larger than the screen: we can't satisfy it so we\n                // might as well not do anything about it.\n                let margin = Self::fit_to_columns(dimensions, margin.saturating_mul(2), margin);\n                start_column = margin;\n                max_line_length = dimensions.columns - margin.saturating_mul(2);\n            }\n            Alignment::Right { margin } => {\n                let margin = margin.as_characters(dimensions.columns);\n                let margin = Self::fit_to_columns(dimensions, margin.saturating_mul(2), margin);\n                start_column = dimensions.columns.saturating_sub(margin).saturating_sub(text_length).max(margin);\n                max_line_length = (dimensions.columns - margin) - start_column;\n            }\n            Alignment::Center { minimum_margin, minimum_size } => {\n                let minimum_margin = minimum_margin.as_characters(dimensions.columns);\n                // Respect minimum size as much as we can if both together overflow.\n                let minimum_size = dimensions.columns.min(*minimum_size);\n                let minimum_margin = Self::fit_to_columns(\n                    dimensions,\n                    minimum_margin.saturating_mul(2).saturating_add(minimum_size),\n                    minimum_margin,\n                );\n                max_line_length =\n                    text_length.min(dimensions.columns - minimum_margin.saturating_mul(2)).max(minimum_size);\n                if max_line_length > dimensions.columns {\n                    start_column = minimum_margin;\n                } else {\n                    start_column = (dimensions.columns - max_line_length) / 2;\n                    start_column = start_column.max(minimum_margin);\n                }\n            }\n        };\n        start_column += self.start_column_offset;\n        Positioning { max_line_length, start_column }\n    }\n\n    fn fit_to_columns(dimensions: &WindowSize, required_fit: u16, actual_fit: u16) -> u16 {\n        if required_fit > dimensions.columns { 0 } else { actual_fit }\n    }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub(crate) struct Positioning {\n    pub(crate) max_line_length: u16,\n    pub(crate) start_column: u16,\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::theme::Margin;\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::left_no_margin(\n        Alignment::Left{ margin: Margin::Fixed(0) },\n        10,\n        Positioning{ max_line_length: 100, start_column: 0 }\n    )]\n    #[case::left_some_margin(\n        Alignment::Left{ margin: Margin::Fixed(5) },\n        10,\n        Positioning{ max_line_length: 90, start_column: 5 }\n    )]\n    #[case::left_line_overflows(\n        Alignment::Left{ margin: Margin::Fixed(5) },\n        150,\n        Positioning{ max_line_length: 90, start_column: 5 }\n    )]\n    #[case::left_large_margin(\n        Alignment::Left{ margin: Margin::Fixed(60) },\n        10,\n        Positioning{ max_line_length: 100, start_column: 0 }\n    )]\n    #[case::left_margin_too_large(\n        Alignment::Left{ margin: Margin::Fixed(105) },\n        10,\n        Positioning{ max_line_length: 100, start_column: 0 }\n    )]\n    #[case::right_no_margin(\n        Alignment::Right{ margin: Margin::Fixed(0) },\n        10,\n        Positioning{ max_line_length: 10, start_column: 90 }\n    )]\n    #[case::right_some_margin(\n        Alignment::Right{ margin: Margin::Fixed(5) },\n        10,\n        Positioning{ max_line_length: 10, start_column: 85 }\n    )]\n    #[case::right_line_overflows(\n        Alignment::Right{ margin: Margin::Fixed(5) },\n        150,\n        Positioning{ max_line_length: 90, start_column: 5 }\n    )]\n    #[case::right_large_margin(\n        Alignment::Right{ margin: Margin::Fixed(60) },\n        10,\n        Positioning{ max_line_length: 10, start_column: 90 }\n    )]\n    #[case::right_margin_too_large(\n        Alignment::Right{ margin: Margin::Fixed(105) },\n        10,\n        Positioning{ max_line_length: 10, start_column: 90 }\n    )]\n    #[case::center_no_minimums(\n        Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 0 },\n        10,\n        Positioning{ max_line_length: 10, start_column: 45 }\n    )]\n    #[case::center_minimum_margin(\n        Alignment::Center{ minimum_margin: Margin::Fixed(10), minimum_size: 0 },\n        100,\n        Positioning{ max_line_length: 80, start_column: 10 }\n    )]\n    #[case::center_minimum_size(\n        Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 50 },\n        10,\n        Positioning{ max_line_length: 50, start_column: 25 }\n    )]\n    #[case::center_large_minimum_margin(\n        Alignment::Center{ minimum_margin: Margin::Fixed(60), minimum_size: 0 },\n        10,\n        Positioning{ max_line_length: 10, start_column: 45 }\n    )]\n    #[case::center_minimum_margin_too_large(\n        Alignment::Center{ minimum_margin: Margin::Fixed(105), minimum_size: 0 },\n        10,\n        Positioning{ max_line_length: 10, start_column: 45 }\n    )]\n    #[case::center_minimum_size_too_large(\n        Alignment::Center{ minimum_margin: Margin::Fixed(0), minimum_size: 105 },\n        10,\n        Positioning{ max_line_length: 100, start_column: 0 }\n    )]\n    #[case::center_margin_and_size_overflows(\n        Alignment::Center{ minimum_margin: Margin::Fixed(30), minimum_size: 60 },\n        10,\n        Positioning{ max_line_length: 60, start_column: 20 }\n    )]\n    fn layout(#[case] alignment: Alignment, #[case] length: u16, #[case] expected: Positioning) {\n        let dimensions = WindowSize { rows: 0, columns: 100, width: 0, height: 0 };\n        let positioning = Layout::new(alignment).compute(&dimensions, length);\n        assert_eq!(positioning, expected);\n    }\n}\n"
  },
  {
    "path": "src/render/mod.rs",
    "content": "pub(crate) mod ascii_scaler;\npub(crate) mod engine;\npub(crate) mod layout;\npub(crate) mod operation;\npub(crate) mod properties;\npub(crate) mod text;\npub(crate) mod validate;\n\nuse crate::{\n    markdown::{\n        elements::Text,\n        text::WeightedLine,\n        text_style::{Color, Colors, PaletteColorError, TextStyle},\n    },\n    render::{operation::RenderOperation, properties::WindowSize},\n    terminal::{\n        Terminal,\n        ansi::AnsiParser,\n        image::printer::{ImagePrinter, PrintImageError},\n        printer::TerminalError,\n    },\n    theme::Margin,\n};\nuse engine::{MaxSize, RenderEngine, RenderEngineOptions};\nuse operation::{AsRenderOperations, MarginProperties};\nuse std::{\n    io::{self, Stdout},\n    iter,\n    rc::Rc,\n    sync::Arc,\n};\n\n/// The result of a render operation.\npub(crate) type RenderResult = Result<(), RenderError>;\n\npub(crate) struct TerminalDrawerOptions {\n    pub(crate) font_size_fallback: u8,\n    pub(crate) max_size: MaxSize,\n}\n\nimpl Default for TerminalDrawerOptions {\n    fn default() -> Self {\n        Self { font_size_fallback: 1, max_size: Default::default() }\n    }\n}\n\n/// Allows drawing on the terminal.\npub(crate) struct TerminalDrawer {\n    pub(crate) terminal: Terminal<Stdout>,\n    options: TerminalDrawerOptions,\n}\n\nimpl TerminalDrawer {\n    pub(crate) fn new(image_printer: Arc<ImagePrinter>, options: TerminalDrawerOptions) -> io::Result<Self> {\n        let terminal = Terminal::new(io::stdout(), image_printer)?;\n        Ok(Self { terminal, options })\n    }\n\n    pub(crate) fn render_operations<'a>(\n        &mut self,\n        operations: impl Iterator<Item = &'a RenderOperation>,\n    ) -> RenderResult {\n        let dimensions = WindowSize::current(self.options.font_size_fallback)?;\n        let engine = self.create_engine(dimensions);\n        engine.render(operations)?;\n        Ok(())\n    }\n\n    pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult {\n        let (lines, _) = AnsiParser::new(Default::default()).parse_lines(message.lines());\n        let lines = lines.into_iter().map(Into::into).collect();\n        let operation = RenderErrorOperation { lines, source: source.clone() };\n        let operation = RenderOperation::RenderDynamic(Rc::new(operation));\n        let dimensions = WindowSize::current(self.options.font_size_fallback)?;\n        let engine = self.create_engine(dimensions);\n        engine.render(iter::once(&operation))?;\n        Ok(())\n    }\n\n    pub(crate) fn render_engine_options(&self) -> RenderEngineOptions {\n        RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() }\n    }\n\n    fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<'_, Terminal<Stdout>> {\n        let options = self.render_engine_options();\n        RenderEngine::new(&mut self.terminal, dimensions, options)\n    }\n}\n\n/// A rendering error.\n#[derive(thiserror::Error, Debug)]\npub(crate) enum RenderError {\n    #[error(\"io: {0}\")]\n    Io(#[from] io::Error),\n\n    #[error(\"terminal: {0}\")]\n    Terminal(#[from] TerminalError),\n\n    #[error(\"screen is too small\")]\n    TerminalTooSmall,\n\n    #[error(\"tried to move to non existent layout location\")]\n    InvalidLayoutEnter,\n\n    #[error(\"tried to pop default screen\")]\n    PopDefaultScreen,\n\n    #[error(\"printing image: {0}\")]\n    PrintImage(#[from] PrintImageError),\n\n    #[error(\"horizontal overflow\")]\n    HorizontalOverflow,\n\n    #[error(\"vertical overflow\")]\n    VerticalOverflow,\n\n    #[error(transparent)]\n    PaletteColor(#[from] PaletteColorError),\n}\n\n#[derive(Clone, Debug)]\npub(crate) enum ErrorSource {\n    Presentation,\n    Slide(usize),\n}\n\n#[derive(Debug)]\nstruct RenderErrorOperation {\n    lines: Vec<WeightedLine>,\n    source: ErrorSource,\n}\n\nimpl AsRenderOperations for RenderErrorOperation {\n    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let heading_text = match self.source {\n            ErrorSource::Presentation => \"Error loading presentation\".to_string(),\n            ErrorSource::Slide(slide) => {\n                format!(\"Error in slide {slide}\")\n            }\n        };\n        let heading = vec![Text::new(heading_text, TextStyle::default().bold().fg_color(Color::Red)), Text::from(\": \")];\n        let content_width: u16 =\n            self.lines.iter().map(|l| l.width()).max().unwrap_or_default().try_into().unwrap_or(u16::MAX);\n        let minimum_margin = (dimensions.columns as f32 * 0.1) as u16;\n        let margin = dimensions.columns.saturating_sub(content_width).max(minimum_margin) / 2;\n\n        let total_lines = self.lines.len();\n        let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);\n\n        let mut operations = vec![\n            RenderOperation::SetColors(Colors {\n                background: Some(Color::Rgb { r: 0, g: 0, b: 0 }),\n                foreground: Some(Color::White),\n            }),\n            RenderOperation::ClearScreen,\n            RenderOperation::ApplyMargin(MarginProperties {\n                horizontal: Margin::Fixed(margin),\n                top: starting_row,\n                bottom: 0,\n            }),\n            RenderOperation::RenderText { line: WeightedLine::from(heading), alignment: Default::default() },\n            RenderOperation::RenderLineBreak,\n            RenderOperation::RenderLineBreak,\n        ];\n        for line in self.lines.iter().cloned() {\n            let op = RenderOperation::RenderText { line, alignment: Default::default() };\n            operations.extend([op, RenderOperation::RenderLineBreak]);\n        }\n        operations\n    }\n}\n"
  },
  {
    "path": "src/render/operation.rs",
    "content": "use super::properties::WindowSize;\nuse crate::{\n    markdown::{\n        text::{WeightedLine, WeightedText},\n        text_style::{Color, Colors, TextStyle},\n    },\n    terminal::image::Image,\n    theme::{Alignment, Margin},\n};\nuse std::{\n    fmt::Debug,\n    rc::Rc,\n    sync::{Arc, Mutex},\n};\n\nconst DEFAULT_IMAGE_Z_INDEX: i32 = -2;\n\n/// A line of preformatted text to be rendered.\n#[derive(Clone, Debug, PartialEq)]\npub(crate) struct BlockLine {\n    pub(crate) prefix: WeightedText,\n    pub(crate) right_padding_length: u16,\n    pub(crate) repeat_prefix_on_wrap: bool,\n    pub(crate) text: WeightedLine,\n    pub(crate) block_length: u16,\n    pub(crate) block_color: Option<Color>,\n    pub(crate) alignment: Alignment,\n}\n\n/// A render operation.\n///\n/// Render operations are primitives that allow the input markdown file to be decoupled with what\n/// we draw on the screen.\n#[derive(Clone, Debug)]\npub(crate) enum RenderOperation {\n    /// Clear the entire screen.\n    ClearScreen,\n\n    /// Set the colors to be used for any subsequent operations.\n    SetColors(Colors),\n\n    /// Jump the draw cursor into the vertical center, that is, at `screen_height / 2`.\n    JumpToVerticalCenter,\n\n    /// Jumps to the N-th row in the current layout.\n    ///\n    /// The index is zero based where 0 represents the top row.\n    JumpToRow { index: u16 },\n\n    /// Jumps to the N-th to last row in the current layout.\n    ///\n    /// The index is zero based where 0 represents the bottom row.\n    JumpToBottomRow { index: u16 },\n\n    /// Jump to the N-th column in the current layout.\n    JumpToColumn { index: u16 },\n\n    /// Render text.\n    RenderText { line: WeightedLine, alignment: Alignment },\n\n    /// Render a line break.\n    RenderLineBreak,\n\n    /// Render an image.\n    RenderImage(Image, ImageRenderProperties),\n\n    /// Render a line.\n    RenderBlockLine(BlockLine),\n\n    /// Render a dynamically generated sequence of render operations.\n    ///\n    /// This allows drawing something on the screen that requires knowing dynamic properties of the\n    /// screen, like window size, without coupling the transformation of markdown into\n    /// [RenderOperation] with the screen itself.\n    RenderDynamic(Rc<dyn AsRenderOperations>),\n\n    /// Render a dynamically sequence of render operations drawing it at the top level margin\n    RenderDynamicTopLevel(Rc<dyn AsRenderOperations>),\n\n    /// An operation that is rendered asynchronously.\n    RenderAsync(Rc<dyn RenderAsync>),\n\n    /// Initialize a column layout.\n    ///\n    /// The value for each column is the width of the column in column-unit units, where the entire\n    /// screen contains `columns.sum()` column-units.\n    InitColumnLayout { columns: Vec<u8>, grid: LayoutGrid, margin: Margin },\n\n    /// Enter a column in a column layout.\n    ///\n    /// The index is 0-index based and will be tied to a previous `InitColumnLayout` operation.\n    EnterColumn { column: usize },\n\n    /// Exit the current layout and go back to the default one.\n    ExitLayout,\n\n    /// Apply a margin to every following operation.\n    ApplyMargin(MarginProperties),\n\n    /// Pop an `ApplyMargin` operation.\n    PopMargin,\n}\n\n/// Grid options for a layout.\n#[derive(Copy, Clone, Debug)]\npub(crate) enum LayoutGrid {\n    None,\n    Draw(TextStyle),\n}\n\n/// The properties of an image being rendered.\n#[derive(Clone, Debug, PartialEq)]\npub(crate) struct ImageRenderProperties {\n    pub(crate) z_index: i32,\n    pub(crate) size: ImageSize,\n    pub(crate) restore_cursor: bool,\n    pub(crate) background_color: Option<Color>,\n    pub(crate) position: ImagePosition,\n}\n\nimpl Default for ImageRenderProperties {\n    fn default() -> Self {\n        Self {\n            z_index: DEFAULT_IMAGE_Z_INDEX,\n            size: Default::default(),\n            restore_cursor: false,\n            background_color: None,\n            position: ImagePosition::Center,\n        }\n    }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) enum ImagePosition {\n    Cursor,\n    Center,\n    Right,\n}\n\n/// The size used when printing an image.\n#[derive(Clone, Debug, Default, PartialEq)]\npub(crate) enum ImageSize {\n    #[default]\n    ShrinkIfNeeded,\n    Specific(u16, u16),\n    WidthScaled {\n        ratio: f64,\n    },\n}\n\n/// Slide properties, set on initialization.\n#[derive(Clone, Debug, Default)]\npub(crate) struct MarginProperties {\n    /// The horizontal margin.\n    pub(crate) horizontal: Margin,\n\n    /// The margin at the top.\n    pub(crate) top: u16,\n\n    /// The margin at the bottom.\n    pub(crate) bottom: u16,\n}\n\n/// A type that can generate render operations.\npub(crate) trait AsRenderOperations: Debug + 'static {\n    /// Generate render operations.\n    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation>;\n\n    /// Get the content in this type to diff it against another `AsRenderOperations`.\n    fn diffable_content(&self) -> Option<&str> {\n        None\n    }\n}\n\n/// An operation that can be rendered asynchronously.\npub(crate) trait RenderAsync: AsRenderOperations {\n    /// Create a pollable for this render async.\n    ///\n    /// The pollable will be used to poll this by a separate thread, so all state that will\n    /// be loaded asynchronously should be shared between this operation and any pollables\n    /// generated from it.\n    fn pollable(&self) -> Box<dyn Pollable>;\n\n    /// Get the start policy for this render.\n    fn start_policy(&self) -> RenderAsyncStartPolicy {\n        RenderAsyncStartPolicy::OnDemand\n    }\n}\n\n/// The start policy for an async render.\n#[derive(Copy, Clone, Debug)]\npub(crate) enum RenderAsyncStartPolicy {\n    /// Start automatically.\n    Automatic,\n\n    /// Start on demand.\n    OnDemand,\n}\n\n/// A pollable that can be used to pull and update the state of an operation asynchronously.\npub(crate) trait Pollable: Send + 'static {\n    /// Update the internal state and return the updated state.\n    fn poll(&mut self) -> PollableState;\n}\n\n/// The state of a [Pollable].\n#[derive(Clone, Debug, PartialEq)]\npub(crate) enum PollableState {\n    Unmodified,\n    Modified,\n    Done,\n    Failed { error: String },\n}\n\nimpl PollableState {\n    #[cfg(test)]\n    pub(crate) fn is_completed(&self) -> bool {\n        match self {\n            Self::Unmodified | Self::Modified => false,\n            Self::Done | Self::Failed { .. } => true,\n        }\n    }\n}\n\npub(crate) struct ToggleState {\n    toggled: Arc<Mutex<bool>>,\n}\n\nimpl ToggleState {\n    pub(crate) fn new(toggled: Arc<Mutex<bool>>) -> Self {\n        Self { toggled }\n    }\n}\n\nimpl Pollable for ToggleState {\n    fn poll(&mut self) -> PollableState {\n        *self.toggled.lock().unwrap() = true;\n        PollableState::Done\n    }\n}\n"
  },
  {
    "path": "src/render/properties.rs",
    "content": "use crossterm::terminal;\nuse std::io::{self, ErrorKind};\n\n/// The size of the terminal window.\n///\n/// This is the same as [crossterm::terminal::window_size] except with some added functionality,\n/// like implementing `Clone`.\n#[derive(Debug, Clone, Copy, PartialEq)]\npub(crate) struct WindowSize {\n    pub(crate) rows: u16,\n    pub(crate) columns: u16,\n    pub(crate) height: u16,\n    pub(crate) width: u16,\n}\n\nimpl WindowSize {\n    /// Get the current window size.\n    pub(crate) fn current(font_size_fallback: u8) -> io::Result<Self> {\n        let mut size: Self = match terminal::window_size() {\n            Ok(size) => size.into(),\n            Err(e) if e.kind() == ErrorKind::Unsupported => {\n                // Fall back to a `WindowSize` that doesn't have pixel support.\n                let size = terminal::size()?;\n                size.into()\n            }\n            Err(e) => return Err(e),\n        };\n        let font_size_fallback = font_size_fallback as u16;\n        if size.width == 0 {\n            size.width = size.columns * font_size_fallback.max(1);\n        }\n        if size.height == 0 {\n            size.height = size.rows * font_size_fallback.max(1) * 2;\n        }\n        Ok(size)\n    }\n\n    /// Shrink a window by the given number of rows.\n    ///\n    /// This preserves the relationship between rows and pixels.\n    pub(crate) fn shrink_rows(&self, amount: u16) -> WindowSize {\n        let pixels_per_row = self.pixels_per_row();\n        let height_to_shrink = (pixels_per_row * amount as f64) as u16;\n        WindowSize {\n            rows: self.rows.saturating_sub(amount),\n            columns: self.columns,\n            height: self.height.saturating_sub(height_to_shrink),\n            width: self.width,\n        }\n    }\n\n    /// Shrink a window by the given number of columns.\n    ///\n    /// This preserves the relationship between columns and pixels.\n    pub(crate) fn shrink_columns(&self, amount: u16) -> WindowSize {\n        let pixels_per_column = self.pixels_per_column();\n        let width_to_shrink = (pixels_per_column * amount as f64) as u16;\n        WindowSize {\n            rows: self.rows,\n            columns: self.columns.saturating_sub(amount),\n            height: self.height,\n            width: self.width.saturating_sub(width_to_shrink),\n        }\n    }\n\n    /// Set the column count.\n    ///\n    /// This preserves the relationship between columns and pixels.\n    pub(crate) fn set_columns(&self, amount: u16) -> WindowSize {\n        let pixels_per_column = self.pixels_per_column();\n        let width = (pixels_per_column * amount as f64) as u16;\n        WindowSize { rows: self.rows, columns: amount, height: self.height, width }\n    }\n\n    /// The number of pixels per column.\n    pub(crate) fn pixels_per_column(&self) -> f64 {\n        self.width as f64 / self.columns as f64\n    }\n\n    /// The number of pixels per row.\n    pub(crate) fn pixels_per_row(&self) -> f64 {\n        self.height as f64 / self.rows as f64\n    }\n\n    /// The aspect ratio for this size.\n    pub(crate) fn aspect_ratio(&self) -> f64 {\n        (self.rows as f64 / self.height as f64) / (self.columns as f64 / self.width as f64)\n    }\n}\n\nimpl From<crossterm::terminal::WindowSize> for WindowSize {\n    fn from(size: crossterm::terminal::WindowSize) -> Self {\n        Self { rows: size.rows, columns: size.columns, width: size.width, height: size.height }\n    }\n}\n\nimpl From<(u16, u16)> for WindowSize {\n    fn from((columns, rows): (u16, u16)) -> Self {\n        Self { columns, rows, width: 0, height: 0 }\n    }\n}\n\n/// The cursor's position.\n#[derive(Debug, Clone, Default, PartialEq)]\npub(crate) struct CursorPosition {\n    pub(crate) column: u16,\n    pub(crate) row: u16,\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn shrink() {\n        let dimensions = WindowSize { rows: 10, columns: 10, width: 200, height: 100 };\n        assert_eq!(dimensions.pixels_per_column(), 20.0);\n        assert_eq!(dimensions.pixels_per_row(), 10.0);\n\n        let new_dimensions = dimensions.shrink_rows(3);\n        assert_eq!(new_dimensions.rows, 7);\n        assert_eq!(new_dimensions.height, 70);\n\n        let new_dimensions = new_dimensions.shrink_columns(3);\n        assert_eq!(new_dimensions.columns, 7);\n        assert_eq!(new_dimensions.width, 140);\n    }\n}\n"
  },
  {
    "path": "src/render/text.rs",
    "content": "use crate::{\n    markdown::{\n        elements::Text,\n        text::{WeightedLine, WeightedText},\n        text_style::{Color, Colors, TextStyle},\n    },\n    render::{RenderError, RenderResult, layout::Positioning},\n    terminal::printer::{TerminalCommand, TerminalIo},\n};\n\n/// Draws text on the screen.\n///\n/// This deals with splitting words and doing word wrapping based on the given positioning.\npub(crate) struct TextDrawer<'a> {\n    prefix: &'a WeightedText,\n    right_padding_length: u16,\n    line: &'a WeightedLine,\n    positioning: Positioning,\n    prefix_width: u16,\n    default_colors: &'a Colors,\n    draw_block: bool,\n    block_color: Option<Color>,\n    repeat_prefix: bool,\n    center_newlines: bool,\n}\n\nimpl<'a> TextDrawer<'a> {\n    pub(crate) fn new(\n        prefix: &'a WeightedText,\n        right_padding_length: u16,\n        line: &'a WeightedLine,\n        positioning: Positioning,\n        default_colors: &'a Colors,\n        minimum_line_length: u16,\n    ) -> Result<Self, RenderError> {\n        let text_length = (line.width() + prefix.width() + right_padding_length as usize) as u16;\n        // If our line doesn't fit and it's just too small then abort\n        if text_length > positioning.max_line_length && positioning.max_line_length <= minimum_line_length {\n            return Err(RenderError::TerminalTooSmall);\n        }\n        let prefix_width = prefix.width() as u16;\n        let positioning = Positioning {\n            max_line_length: positioning\n                .max_line_length\n                .saturating_sub(prefix_width)\n                .saturating_sub(right_padding_length),\n            start_column: positioning.start_column,\n        };\n        Ok(Self {\n            prefix,\n            right_padding_length,\n            line,\n            positioning,\n            prefix_width,\n            default_colors,\n            draw_block: false,\n            block_color: None,\n            repeat_prefix: false,\n            center_newlines: false,\n        })\n    }\n\n    pub(crate) fn with_surrounding_block(mut self, block_color: Option<Color>) -> Self {\n        self.draw_block = true;\n        self.block_color = block_color;\n        self\n    }\n\n    pub(crate) fn repeat_prefix_on_wrap(mut self, value: bool) -> Self {\n        self.repeat_prefix = value;\n        self\n    }\n\n    pub(crate) fn center_newlines(mut self, value: bool) -> Self {\n        self.center_newlines = value;\n        self\n    }\n\n    /// Draw text on the given handle.\n    ///\n    /// This performs word splitting and word wrapping.\n    pub(crate) fn draw<T>(self, terminal: &mut T) -> RenderResult\n    where\n        T: TerminalIo,\n    {\n        let mut line_length: u16 = 0;\n        terminal.execute(&TerminalCommand::MoveToColumn(self.positioning.start_column))?;\n        let font_size = self.line.font_size();\n\n        // Print the prefix at the beginning of the line.\n        if self.prefix_width > 0 {\n            let Text { content, style } = self.prefix.text();\n            terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;\n        }\n        for (line_index, line) in self.line.split(self.positioning.max_line_length as usize).enumerate() {\n            if line_index > 0 {\n                // Complete the current line's block to the right before moving down.\n                self.print_block_background(line_length, terminal)?;\n                terminal.execute(&TerminalCommand::MoveDown(font_size as u16))?;\n                let start_column = match self.center_newlines {\n                    true => {\n                        let line_width = line.iter().map(|l| l.width()).sum::<usize>() as u16;\n                        let extra_space = self.positioning.max_line_length.saturating_sub(line_width);\n                        self.positioning.start_column + extra_space / 2\n                    }\n                    false => self.positioning.start_column,\n                };\n                terminal.execute(&TerminalCommand::MoveToColumn(start_column))?;\n                line_length = 0;\n\n                // Complete the new line in this block to the left where the prefix would be.\n                if self.prefix_width > 0 {\n                    if self.repeat_prefix {\n                        let Text { content, style } = self.prefix.text();\n                        terminal.execute(&TerminalCommand::PrintText { content, style: *style })?;\n                    } else {\n                        if let Some(color) = self.block_color {\n                            terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;\n                        }\n                        let text = \" \".repeat(self.prefix_width as usize / font_size as usize);\n                        let style = TextStyle::default().size(font_size);\n                        terminal.execute(&TerminalCommand::PrintText { content: &text, style })?;\n                    }\n                }\n            }\n            for chunk in line {\n                line_length = line_length.saturating_add(chunk.width() as u16);\n\n                let (text, style) = chunk.into_parts();\n                terminal.execute(&TerminalCommand::PrintText { content: text, style })?;\n\n                // Crossterm resets colors if any attributes are set so let's just re-apply colors\n                // if the format has anything on it at all.\n                if style != Default::default() {\n                    terminal.execute(&TerminalCommand::SetColors(*self.default_colors))?;\n                }\n            }\n        }\n        self.print_block_background(line_length, terminal)?;\n        Ok(())\n    }\n\n    fn print_block_background<T>(&self, line_length: u16, terminal: &mut T) -> RenderResult\n    where\n        T: TerminalIo,\n    {\n        if self.draw_block {\n            let remaining =\n                self.positioning.max_line_length.saturating_sub(line_length).saturating_add(self.right_padding_length);\n            if remaining > 0 {\n                let font_size = self.line.font_size();\n                if let Some(color) = self.block_color {\n                    terminal.execute(&TerminalCommand::SetBackgroundColor(color))?;\n                }\n                let text = \" \".repeat(remaining as usize / font_size as usize);\n                let style = TextStyle::default().size(font_size);\n                terminal.execute(&TerminalCommand::PrintText { content: &text, style })?;\n            }\n        }\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::terminal::printer::TerminalError;\n    use std::io;\n    use unicode_width::UnicodeWidthStr;\n\n    #[derive(Debug, PartialEq)]\n    enum Instruction {\n        MoveDown(u16),\n        MoveToColumn(u16),\n        PrintText { content: String, font_size: u8 },\n    }\n\n    #[derive(Default)]\n    struct TerminalBuf {\n        instructions: Vec<Instruction>,\n        cursor_row: u16,\n    }\n\n    impl TerminalBuf {\n        fn push(&mut self, instruction: Instruction) -> io::Result<()> {\n            self.instructions.push(instruction);\n            Ok(())\n        }\n\n        fn move_to_column(&mut self, column: u16) -> std::io::Result<()> {\n            self.push(Instruction::MoveToColumn(column))\n        }\n\n        fn move_down(&mut self, amount: u16) -> std::io::Result<()> {\n            self.push(Instruction::MoveDown(amount))\n        }\n\n        fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {\n            let content = content.to_string();\n            if content.is_empty() {\n                return Ok(());\n            }\n            self.cursor_row = content.width() as u16;\n            self.push(Instruction::PrintText { content, font_size: style.size })?;\n            Ok(())\n        }\n\n        fn clear_screen(&mut self) -> std::io::Result<()> {\n            unimplemented!()\n        }\n\n        fn set_colors(&mut self, _colors: Colors) -> std::io::Result<()> {\n            Ok(())\n        }\n\n        fn set_background_color(&mut self, _color: Color) -> std::io::Result<()> {\n            Ok(())\n        }\n\n        fn flush(&mut self) -> std::io::Result<()> {\n            Ok(())\n        }\n    }\n\n    impl TerminalIo for TerminalBuf {\n        fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {\n            use TerminalCommand::*;\n            match command {\n                BeginUpdate\n                | EndUpdate\n                | MoveToRow(_)\n                | MoveToNextLine\n                | MoveTo { .. }\n                | MoveRight(_)\n                | MoveLeft(_)\n                | PrintImage { .. }\n                | SetCursorBoundaries { .. } => {\n                    unimplemented!()\n                }\n                MoveToColumn(column) => self.move_to_column(*column)?,\n                MoveDown(amount) => self.move_down(*amount)?,\n                PrintText { content, style } => self.print_text(content, style)?,\n                ClearScreen => self.clear_screen()?,\n                SetColors(colors) => self.set_colors(*colors)?,\n                SetBackgroundColor(color) => self.set_background_color(*color)?,\n                Flush => self.flush()?,\n            };\n            Ok(())\n        }\n\n        fn cursor_row(&self) -> u16 {\n            self.cursor_row\n        }\n    }\n\n    struct TestDrawer {\n        prefix: WeightedText,\n        positioning: Positioning,\n        right_padding_length: u16,\n        repeat_prefix_on_wrap: bool,\n        center_newlines: bool,\n    }\n\n    impl TestDrawer {\n        fn prefix<T: Into<WeightedText>>(mut self, prefix: T) -> Self {\n            self.prefix = prefix.into();\n            self\n        }\n\n        fn start_column(mut self, column: u16) -> Self {\n            self.positioning.start_column = column;\n            self\n        }\n\n        fn max_line_length(mut self, length: u16) -> Self {\n            self.positioning.max_line_length = length;\n            self\n        }\n\n        fn repeat_prefix_on_wrap(mut self) -> Self {\n            self.repeat_prefix_on_wrap = true;\n            self\n        }\n\n        fn center_newlines(mut self) -> Self {\n            self.center_newlines = true;\n            self\n        }\n\n        fn draw<L: Into<WeightedLine>>(self, line: L) -> Vec<Instruction> {\n            let line = line.into();\n            let colors = Default::default();\n            let drawer = TextDrawer::new(&self.prefix, self.right_padding_length, &line, self.positioning, &colors, 0)\n                .expect(\"failed to create drawer\")\n                .repeat_prefix_on_wrap(self.repeat_prefix_on_wrap)\n                .center_newlines(self.center_newlines);\n            let mut buf = TerminalBuf::default();\n            drawer.draw(&mut buf).expect(\"drawing failed\");\n            buf.instructions\n        }\n    }\n\n    impl Default for TestDrawer {\n        fn default() -> Self {\n            Self {\n                prefix: WeightedText::from(\"\"),\n                positioning: Positioning { max_line_length: 100, start_column: 0 },\n                right_padding_length: 0,\n                repeat_prefix_on_wrap: false,\n                center_newlines: false,\n            }\n        }\n    }\n\n    #[test]\n    fn prefix_on_long_line() {\n        let instructions = TestDrawer::default().prefix(\"P\").max_line_length(3).start_column(1).draw(\"AAAA\");\n        let expected = &[\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText { content: \"P\".into(), font_size: 1 },\n            Instruction::PrintText { content: \"AA\".into(), font_size: 1 },\n            Instruction::MoveDown(1),\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText { content: \" \".into(), font_size: 1 },\n            Instruction::PrintText { content: \"AA\".into(), font_size: 1 },\n        ];\n        assert_eq!(instructions, expected);\n    }\n\n    #[test]\n    fn prefix_on_long_line_with_font_size() {\n        let text = WeightedLine::from(vec![Text::new(\"AAAA\", TextStyle::default().size(2))]);\n        let prefix = WeightedText::from(Text::new(\"P\", TextStyle::default().size(2)));\n        let instructions = TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).draw(text);\n        let expected = &[\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText { content: \"P\".into(), font_size: 2 },\n            Instruction::PrintText { content: \"AA\".into(), font_size: 2 },\n            Instruction::MoveDown(2),\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText { content: \" \".into(), font_size: 2 },\n            Instruction::PrintText { content: \"AA\".into(), font_size: 2 },\n        ];\n        assert_eq!(instructions, expected);\n    }\n\n    #[test]\n    fn prefix_on_long_line_with_font_size_and_repeat_prefix() {\n        let text = WeightedLine::from(vec![Text::new(\"AAAA\", TextStyle::default().size(2))]);\n        let prefix = WeightedText::from(Text::new(\"P\", TextStyle::default().size(2)));\n        let instructions =\n            TestDrawer::default().prefix(prefix).max_line_length(6).start_column(1).repeat_prefix_on_wrap().draw(text);\n        let expected = &[\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText { content: \"P\".into(), font_size: 2 },\n            Instruction::PrintText { content: \"AA\".into(), font_size: 2 },\n            Instruction::MoveDown(2),\n            Instruction::MoveToColumn(1),\n            Instruction::PrintText { content: \"P\".into(), font_size: 2 },\n            Instruction::PrintText { content: \"AA\".into(), font_size: 2 },\n        ];\n        assert_eq!(instructions, expected);\n    }\n\n    #[test]\n    fn center_newlines() {\n        let text = WeightedLine::from(vec![Text::from(\"hello world foo\")]);\n        let instructions = TestDrawer::default().center_newlines().max_line_length(11).draw(text);\n        let expected = &[\n            Instruction::MoveToColumn(0),\n            Instruction::PrintText { content: \"hello world\".into(), font_size: 1 },\n            Instruction::MoveDown(1),\n            Instruction::MoveToColumn(4),\n            Instruction::PrintText { content: \"foo\".into(), font_size: 1 },\n        ];\n        assert_eq!(instructions, expected);\n    }\n}\n"
  },
  {
    "path": "src/render/validate.rs",
    "content": "use super::properties::WindowSize;\nuse crate::{\n    ImagePrinter,\n    presentation::Presentation,\n    render::{\n        RenderError,\n        engine::{RenderEngine, RenderEngineOptions},\n    },\n    terminal::{Terminal, TerminalWrite},\n};\nuse std::{io, sync::Arc};\n\npub(crate) struct OverflowValidator;\n\nimpl OverflowValidator {\n    pub(crate) fn validate(presentation: &Presentation, dimensions: WindowSize) -> Result<(), OverflowError> {\n        let printer = Arc::new(ImagePrinter::Null);\n        for (index, slide) in presentation.iter_slides().enumerate() {\n            let index = index + 1;\n            let mut terminal = Terminal::new(io::Empty::default(), printer.clone()).map_err(RenderError::from)?;\n            let options = RenderEngineOptions { validate_overflows: true, ..Default::default() };\n            let engine = RenderEngine::new(&mut terminal, dimensions, options);\n            match engine.render(slide.iter_visible_operations()) {\n                Ok(()) => (),\n                Err(RenderError::HorizontalOverflow) => return Err(OverflowError::Horizontal(index)),\n                Err(RenderError::VerticalOverflow) => return Err(OverflowError::Vertical(index)),\n                Err(e) => return Err(OverflowError::Render(e)),\n            };\n        }\n        Ok(())\n    }\n}\n\nimpl TerminalWrite for io::Empty {\n    fn init(&mut self) -> io::Result<()> {\n        Ok(())\n    }\n\n    fn deinit(&mut self) {}\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum OverflowError {\n    #[error(\"presentation overflows horizontally on slide {0}\")]\n    Horizontal(usize),\n\n    #[error(\"presentation overflows vertically on slide {0}\")]\n    Vertical(usize),\n\n    #[error(transparent)]\n    Render(#[from] RenderError),\n}\n"
  },
  {
    "path": "src/resource.rs",
    "content": "use crate::{\n    terminal::image::{\n        Image,\n        printer::{ImageRegistry, ImageSpec, RegisterImageError},\n    },\n    theme::{raw::PresentationTheme, registry::LoadThemeError},\n};\nuse std::{\n    cell::RefCell,\n    collections::HashMap,\n    fs, io, mem,\n    path::{Path, PathBuf},\n    rc::Rc,\n    sync::{\n        Arc,\n        atomic::{AtomicBool, Ordering},\n        mpsc::{Receiver, Sender, channel},\n    },\n    thread,\n    time::{Duration, SystemTime},\n};\n\nconst LOOP_INTERVAL: Duration = Duration::from_millis(250);\n\n#[derive(Debug)]\nstruct ResourcesInner {\n    themes: HashMap<PathBuf, PresentationTheme>,\n    external_text_files: HashMap<PathBuf, String>,\n    base_path: PathBuf,\n    themes_path: PathBuf,\n    image_registry: ImageRegistry,\n    watcher: FileWatcherHandle,\n}\n\n/// Manages resources pulled from the filesystem such as images.\n///\n/// All resources are cached so once a specific resource is loaded, looking it up with the same\n/// path will involve an in-memory lookup.\n#[derive(Clone, Debug)]\npub struct Resources {\n    inner: Rc<RefCell<ResourcesInner>>,\n}\n\nimpl Resources {\n    /// Construct a new resource manager over the provided based path.\n    ///\n    /// Any relative paths will be assumed to be relative to the given base.\n    pub fn new<P1, P2>(base_path: P1, themes_path: P2, image_registry: ImageRegistry) -> Self\n    where\n        P1: Into<PathBuf>,\n        P2: Into<PathBuf>,\n    {\n        let watcher = FileWatcher::spawn();\n        let inner = ResourcesInner {\n            base_path: base_path.into(),\n            themes_path: themes_path.into(),\n            themes: Default::default(),\n            external_text_files: Default::default(),\n            image_registry,\n            watcher,\n        };\n        Self { inner: Rc::new(RefCell::new(inner)) }\n    }\n\n    pub(crate) fn watch_presentation_file(&self, path: PathBuf) {\n        let inner = self.inner.borrow();\n        inner.watcher.send(WatchEvent::WatchFile { path, watch_forever: true });\n    }\n\n    /// Get the image at the given path.\n    pub(crate) fn image<P: AsRef<Path>>(\n        &self,\n        path: P,\n        base_path: &ResourceBasePath,\n    ) -> Result<Image, RegisterImageError> {\n        let path = self.resolve_path(path, base_path);\n        let inner = self.inner.borrow();\n        let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;\n        Ok(image)\n    }\n\n    pub(crate) fn theme_image<P: AsRef<Path>>(&self, path: P) -> Result<Image, RegisterImageError> {\n        match self.image(&path, &ResourceBasePath::Presentation) {\n            Ok(image) => return Ok(image),\n            Err(RegisterImageError::Io(e)) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()),\n            _ => (),\n        };\n\n        let inner = self.inner.borrow();\n        let path = inner.themes_path.join(path);\n        let image = inner.image_registry.register(ImageSpec::Filesystem(path.clone()))?;\n        Ok(image)\n    }\n\n    /// Get the theme at the given path.\n    pub(crate) fn theme<P: AsRef<Path>>(&self, path: P) -> Result<PresentationTheme, LoadThemeError> {\n        let mut inner = self.inner.borrow_mut();\n        let path = inner.base_path.join(path);\n        if let Some(theme) = inner.themes.get(&path) {\n            return Ok(theme.clone());\n        }\n\n        let theme = PresentationTheme::from_path(&path)?;\n        inner.themes.insert(path, theme.clone());\n        Ok(theme)\n    }\n\n    /// Get the external text file at the given path.\n    pub(crate) fn external_text_file<P: AsRef<Path>>(\n        &self,\n        path: P,\n        base_path: &ResourceBasePath,\n    ) -> io::Result<String> {\n        let path = self.resolve_path(path, base_path);\n        let mut inner = self.inner.borrow_mut();\n        if let Some(contents) = inner.external_text_files.get(&path) {\n            return Ok(contents.clone());\n        }\n\n        let contents = fs::read_to_string(&path)?;\n        inner.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false });\n        inner.external_text_files.insert(path, contents.clone());\n        Ok(contents)\n    }\n\n    pub(crate) fn resources_modified(&self) -> bool {\n        let mut inner = self.inner.borrow_mut();\n        inner.watcher.has_modifications()\n    }\n\n    pub(crate) fn clear_watches(&self) {\n        let mut inner = self.inner.borrow_mut();\n        inner.watcher.send(WatchEvent::ClearWatches);\n        // We could do better than this but this works for now.\n        inner.external_text_files.clear();\n    }\n\n    /// Clears all resources.\n    pub(crate) fn clear(&self) {\n        let mut inner = self.inner.borrow_mut();\n        inner.image_registry.clear();\n        inner.themes.clear();\n    }\n\n    pub(crate) fn resolve_path<P: AsRef<Path>>(&self, path: P, base_path: &ResourceBasePath) -> PathBuf {\n        match base_path {\n            ResourceBasePath::Presentation => {\n                let inner = self.inner.borrow();\n                inner.base_path.join(path)\n            }\n            ResourceBasePath::Custom(base) => base.join(path),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) enum ResourceBasePath {\n    #[default]\n    Presentation,\n    Custom(PathBuf),\n}\n\n/// Watches for file changes.\n///\n/// This uses polling rather than something fancier like `inotify`. The latter turned out to make\n/// code too complex for little added gain. This instead keeps the last modified time for all\n/// watched paths and uses that to determine if they've changed.\nstruct FileWatcher {\n    receiver: Receiver<WatchEvent>,\n    watches: HashMap<PathBuf, WatchMetadata>,\n    modifications: Arc<AtomicBool>,\n}\n\nimpl FileWatcher {\n    fn spawn() -> FileWatcherHandle {\n        let (sender, receiver) = channel();\n        let modifications = Arc::new(AtomicBool::default());\n        let handle = FileWatcherHandle { sender, modifications: modifications.clone() };\n        thread::spawn(move || {\n            let watcher = FileWatcher { receiver, watches: Default::default(), modifications };\n            watcher.run();\n        });\n        handle\n    }\n\n    fn run(mut self) {\n        loop {\n            if let Ok(event) = self.receiver.try_recv() {\n                self.handle_event(event);\n            }\n            if self.watches_modified() {\n                self.modifications.store(true, Ordering::Relaxed);\n            }\n            thread::sleep(LOOP_INTERVAL);\n        }\n    }\n\n    fn handle_event(&mut self, event: WatchEvent) {\n        match event {\n            WatchEvent::ClearWatches => {\n                let new_watches =\n                    mem::take(&mut self.watches).into_iter().filter(|(_, meta)| meta.watch_forever).collect();\n                self.watches = new_watches;\n            }\n            WatchEvent::WatchFile { path, watch_forever } => {\n                // If we're already watching this forever, don't reset it\n                if self.watches.get(&path).is_some_and(|w| w.watch_forever) {\n                    return;\n                }\n                let last_modification =\n                    fs::metadata(&path).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);\n                let meta = WatchMetadata { last_modification, watch_forever };\n                self.watches.insert(path, meta);\n            }\n        }\n    }\n\n    fn watches_modified(&mut self) -> bool {\n        let mut modifications = false;\n        for (path, meta) in &mut self.watches {\n            let Ok(metadata) = fs::metadata(path) else {\n                // If the file no longer exists, it's technically changed since last time.\n                modifications = true;\n                continue;\n            };\n            let Ok(modified_time) = metadata.modified() else {\n                continue;\n            };\n            if modified_time > meta.last_modification {\n                meta.last_modification = modified_time;\n                modifications = true;\n            }\n        }\n        modifications\n    }\n}\n\nstruct WatchMetadata {\n    last_modification: SystemTime,\n    watch_forever: bool,\n}\n\n#[derive(Debug)]\nstruct FileWatcherHandle {\n    sender: Sender<WatchEvent>,\n    modifications: Arc<AtomicBool>,\n}\n\nimpl FileWatcherHandle {\n    fn send(&self, event: WatchEvent) {\n        let _ = self.sender.send(event);\n    }\n\n    fn has_modifications(&mut self) -> bool {\n        self.modifications.swap(false, Ordering::Relaxed)\n    }\n}\n\nenum WatchEvent {\n    /// Clear all watched files.\n    ClearWatches,\n\n    /// Add a file to the watch list.\n    WatchFile { path: PathBuf, watch_forever: bool },\n}\n"
  },
  {
    "path": "src/terminal/ansi.rs",
    "content": "use crate::markdown::{\n    elements::{Line, Text},\n    text_style::{Color, TextStyle},\n};\nuse std::mem;\nuse vte::{ParamsIter, Parser, Perform};\n\npub(crate) struct AnsiParser {\n    starting_style: TextStyle,\n}\n\nimpl AnsiParser {\n    pub(crate) fn new(current_style: TextStyle) -> Self {\n        Self { starting_style: current_style }\n    }\n\n    pub(crate) fn parse_lines<I, S>(self, lines: I) -> (Vec<Line>, TextStyle)\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<str>,\n    {\n        let mut output_lines = Vec::new();\n        let mut style = self.starting_style;\n        for line in lines {\n            let mut handler = Handler::new(style);\n            let mut parser = Parser::new();\n            parser.advance(&mut handler, line.as_ref().as_bytes());\n\n            let (line, ending_style) = handler.into_parts();\n            output_lines.push(line);\n            style = ending_style;\n        }\n        (output_lines, style)\n    }\n}\n\n#[derive(Default)]\npub(crate) struct AnsiColorParser {\n    starting_style: TextStyle,\n}\n\nimpl AnsiColorParser {\n    pub(crate) fn new(starting_style: TextStyle) -> Self {\n        Self { starting_style }\n    }\n\n    fn parse_8bit(value: u16) -> Option<Color> {\n        Color::from_8bit(value.try_into().unwrap_or(u8::MAX))\n    }\n\n    fn parse_color(iter: &mut ParamsIter) -> Option<Color> {\n        match iter.next()? {\n            [2] => {\n                let r = iter.next()?.first()?;\n                let g = iter.next()?.first()?;\n                let b = iter.next()?.first()?;\n                Self::try_build_rgb_color(*r, *g, *b)\n            }\n            [5] => {\n                let color = *iter.next()?.first()?;\n                Color::from_8bit(color.try_into().unwrap_or(u8::MAX))\n            }\n            _ => None,\n        }\n    }\n\n    fn try_build_rgb_color(r: u16, g: u16, b: u16) -> Option<Color> {\n        let r = r.try_into().ok()?;\n        let g = g.try_into().ok()?;\n        let b = b.try_into().ok()?;\n        Some(Color::new(r, g, b))\n    }\n\n    pub(crate) fn parse(self, mut codes: ParamsIter) -> TextStyle {\n        let mut style = self.starting_style;\n        loop {\n            let Some(&[next]) = codes.next() else {\n                break;\n            };\n            match next {\n                0 => style = Default::default(),\n                1 => style = style.bold(),\n                3 => style = style.italics(),\n                4 => style = style.underlined(),\n                9 => style = style.strikethrough(),\n                39 => {\n                    style.colors.foreground = None;\n                }\n                49 => {\n                    style.colors.background = None;\n                }\n                30..=37 => {\n                    if let Some(color) = Self::parse_8bit(next - 30) {\n                        style = style.fg_color(color);\n                    }\n                }\n                40..=47 => {\n                    if let Some(color) = Self::parse_8bit(next - 40) {\n                        style = style.bg_color(color);\n                    }\n                }\n                38 => {\n                    if let Some(color) = Self::parse_color(&mut codes) {\n                        style = style.fg_color(color);\n                    }\n                }\n                48 => {\n                    if let Some(color) = Self::parse_color(&mut codes) {\n                        style = style.bg_color(color);\n                    }\n                }\n                _ => (),\n            };\n        }\n        style\n    }\n}\n\nstruct Handler {\n    line: Line,\n    pending_text: Text,\n    style: TextStyle,\n}\n\nimpl Handler {\n    fn new(style: TextStyle) -> Self {\n        Self { line: Default::default(), pending_text: Default::default(), style }\n    }\n\n    fn into_parts(mut self) -> (Line, TextStyle) {\n        self.save_pending_text();\n        (self.line, self.style)\n    }\n\n    fn save_pending_text(&mut self) {\n        if !self.pending_text.content.is_empty() {\n            self.line.0.push(mem::take(&mut self.pending_text));\n        }\n    }\n}\n\nimpl Perform for Handler {\n    fn print(&mut self, c: char) {\n        self.pending_text.content.push(c);\n    }\n\n    fn csi_dispatch(&mut self, params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char) {\n        if action == 'm' {\n            self.save_pending_text();\n            self.style = AnsiColorParser::new(self.style).parse(params.iter());\n            self.pending_text.style = self.style;\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::text(\"hi\", Line::from(\"hi\"))]\n    #[case::single_attribute(\"\\x1b[1mhi\", Line::from(Text::new(\"hi\", TextStyle::default().bold())))]\n    #[case::two_attributes(\"\\x1b[1;3mhi\", Line::from(Text::new(\"hi\", TextStyle::default().bold().italics())))]\n    #[case::three_attributes(\"\\x1b[1;3;4mhi\", Line::from(Text::new(\"hi\", TextStyle::default().bold().italics().underlined())))]\n    #[case::four_attributes(\n        \"\\x1b[1;3;4;9mhi\", \n        Line::from(Text::new(\"hi\", TextStyle::default().bold().italics().underlined().strikethrough()))\n    )]\n    #[case::standard_foreground1(\n        \"\\x1b[38;5;1mhi\", \n        Line::from(Text::new(\"hi\", TextStyle::default().fg_color(Color::DarkRed)))\n    )]\n    #[case::standard_foreground2(\n        \"\\x1b[31mhi\", \n        Line::from(Text::new(\"hi\", TextStyle::default().fg_color(Color::DarkRed)))\n    )]\n    #[case::rgb_foreground(\n        \"\\x1b[38;2;3;4;5mhi\", \n        Line::from(Text::new(\"hi\", TextStyle::default().fg_color(Color::new(3, 4, 5))))\n    )]\n    #[case::standard_background1(\n        \"\\x1b[48;5;1mhi\", \n        Line::from(Text::new(\"hi\", TextStyle::default().bg_color(Color::DarkRed)))\n    )]\n    #[case::standard_background2(\n        \"\\x1b[41mhi\", \n        Line::from(Text::new(\"hi\", TextStyle::default().bg_color(Color::DarkRed)))\n    )]\n    #[case::rgb_background(\n        \"\\x1b[48;2;3;4;5mhi\", \n        Line::from(Text::new(\"hi\", TextStyle::default().bg_color(Color::new(3, 4, 5))))\n    )]\n    #[case::accumulate(\n        \"\\x1b[1mhi\\x1b[3mbye\", \n        Line(vec![\n            Text::new(\"hi\", TextStyle::default().bold()),\n            Text::new(\"bye\", TextStyle::default().bold().italics())\n        ])\n    )]\n    #[case::reset(\n        \"\\x1b[1mhi\\x1b[0;3mbye\", \n        Line(vec![\n            Text::new(\"hi\", TextStyle::default().bold()),\n            Text::new(\"bye\", TextStyle::default().italics())\n        ])\n    )]\n    #[case::different_action(\n        \"\\x1b[01m\\x1b[Khi\",\n        Line::from(Text::new(\"hi\", TextStyle::default().bold()))\n    )]\n    fn parse_single(#[case] input: &str, #[case] expected: Line) {\n        let splitter = AnsiParser::new(Default::default());\n        let (lines, _) = splitter.parse_lines([input]);\n        assert_eq!(lines, vec![expected]);\n    }\n\n    #[rstest]\n    #[case::reset_all(\"\\x1b[0mhi\", Line::from(\"hi\"))]\n    #[case::reset_foreground(\n        \"\\x1b[39mhi\", \n        Line::from(\n            Text::new(\n                \"hi\", \n                TextStyle::default()\n                    .bold()\n                    .italics()\n                    .underlined()\n                    .strikethrough()\n                    .bg_color(Color::Black)\n            )\n        )\n    )]\n    #[case::reset_background(\n        \"\\x1b[49mhi\", \n        Line::from(\n            Text::new(\n                \"hi\", \n                TextStyle::default()\n                    .bold()\n                    .italics()\n                    .underlined()\n                    .strikethrough()\n                    .fg_color(Color::Red)\n            )\n        )\n    )]\n    fn resets(#[case] input: &str, #[case] expected: Line) {\n        let style = TextStyle::default()\n            .bold()\n            .italics()\n            .underlined()\n            .strikethrough()\n            .fg_color(Color::Red)\n            .bg_color(Color::Black);\n        let splitter = AnsiParser::new(style);\n        let (lines, _) = splitter.parse_lines([input]);\n        assert_eq!(lines, vec![expected]);\n    }\n}\n"
  },
  {
    "path": "src/terminal/capabilities.rs",
    "content": "use super::image::protocols::kitty::{Action, ControlCommand, ControlOption, ImageFormat, TransmissionMedium};\nuse base64::{Engine, engine::general_purpose::STANDARD};\nuse crossterm::{\n    QueueableCommand,\n    cursor::{self},\n    style::Print,\n    terminal,\n};\nuse image::{DynamicImage, EncodableLayout};\nuse std::{\n    env,\n    io::{self, Write},\n    sync::{\n        Arc,\n        atomic::{AtomicBool, Ordering},\n    },\n    thread,\n    time::Duration,\n};\nuse tempfile::NamedTempFile;\n\n#[derive(Default, Debug, Clone)]\npub(crate) struct TerminalCapabilities {\n    pub(crate) kitty_local: bool,\n    pub(crate) kitty_remote: bool,\n    pub(crate) sixel: bool,\n    pub(crate) tmux: bool,\n    pub(crate) font_size: bool,\n    pub(crate) fractional_font_size: bool,\n}\n\nimpl TerminalCapabilities {\n    pub(crate) fn is_inside_tmux() -> bool {\n        env::var(\"TERM_PROGRAM\").ok().as_deref() == Some(\"tmux\")\n    }\n\n    pub(crate) fn query() -> io::Result<Self> {\n        let tmux = Self::is_inside_tmux();\n        let mut file = NamedTempFile::new()?;\n        let image = DynamicImage::new_rgba8(1, 1).into_rgba8();\n        let image_bytes = image.as_raw().as_bytes();\n        file.write_all(image_bytes)?;\n        file.flush()?;\n        let Some(path) = file.path().as_os_str().to_str() else {\n            return Ok(Default::default());\n        };\n        let encoded_path = STANDARD.encode(path);\n\n        let base_image_id = fastrand::u32(0..=u32::MAX);\n        let ids = KittyImageIds { local: base_image_id, remote: base_image_id.wrapping_add(1) };\n        Self::write_kitty_local_query(ids.local, encoded_path, tmux)?;\n        Self::write_kitty_remote_query(ids.remote, image_bytes, tmux)?;\n        let (start, sequence, end) = match tmux {\n            true => (\"\\x1bPtmux;\", \"\\x1b\\x1b\", \"\\x1b\\\\\"),\n            false => (\"\", \"\\x1b\", \"\"),\n        };\n        let _guard = RawModeGuard::new()?;\n        let mut stdout = io::stdout();\n        write!(stdout, \"{start}{sequence}[c{end}\")?;\n        stdout.flush()?;\n\n        // Spawn a thread to \"save us\" in case we don't get an answer from the terminal.\n        let running = Arc::new(AtomicBool::new(true));\n        Self::launch_timeout_trigger(running.clone());\n\n        let response = Self::build_capabilities(ids);\n        running.store(false, Ordering::Relaxed);\n\n        let mut response = response?;\n        response.tmux = tmux;\n        Ok(response)\n    }\n\n    fn build_capabilities(ids: KittyImageIds) -> io::Result<TerminalCapabilities> {\n        let mut response = Self::parse_response(io::stdin(), ids)?;\n\n        // Use kitty's font size protocol to write 1 character using size 2. If after writing the\n        // cursor has moves 2 columns, the protocol is supported.\n        let mut stdout = io::stdout();\n        stdout.queue(terminal::EnterAlternateScreen)?;\n        stdout.queue(cursor::MoveTo(0, 0))?;\n        stdout.queue(Print(\"\\x1b]66;s=2; \\x1b\\\\\"))?;\n        stdout.queue(Print(\"\\x1b]66;n=1:d=2; \\x1b\\\\\"))?;\n        stdout.flush()?;\n        let position = cursor::position()?.0;\n        if position == 1 {\n            // If we only moved one, then only the fractional worked.\n            response.fractional_font_size = true;\n        } else if position == 2 {\n            // If we only moved 2 then the scaled font size one worked.\n            response.font_size = true;\n        } else if position == 3 {\n            // 3 -> both worked.\n            response.font_size = true;\n            response.fractional_font_size = true;\n        }\n        stdout.queue(terminal::LeaveAlternateScreen)?;\n        stdout.flush()?;\n        Ok(response)\n    }\n\n    fn write_kitty_local_query(image_id: u32, path: String, tmux: bool) -> io::Result<()> {\n        let options = &[\n            ControlOption::Format(ImageFormat::Rgba),\n            ControlOption::Action(Action::Query),\n            ControlOption::Medium(TransmissionMedium::LocalFile),\n            ControlOption::ImageId(image_id),\n            ControlOption::Width(1),\n            ControlOption::Height(1),\n        ];\n        let command = ControlCommand { options, payload: path, tmux };\n        write!(io::stdout(), \"{command}\")\n    }\n\n    fn write_kitty_remote_query(image_id: u32, image: &[u8], tmux: bool) -> io::Result<()> {\n        let payload = STANDARD.encode(image);\n        let options = &[\n            ControlOption::Format(ImageFormat::Rgba),\n            ControlOption::Action(Action::Query),\n            ControlOption::Medium(TransmissionMedium::Direct),\n            ControlOption::ImageId(image_id),\n            ControlOption::Width(1),\n            ControlOption::Height(1),\n        ];\n        // The image is small enough to fit in a single request so we don't need to bother with\n        // chunks here.\n        let command = ControlCommand { options, payload, tmux };\n        write!(io::stdout(), \"{command}\")\n    }\n\n    fn parse_response<T: io::Read>(mut term: T, ids: KittyImageIds) -> io::Result<Self> {\n        let mut buffer = [0_u8; 128];\n        let mut state = QueryParseState::default();\n        let mut capabilities = TerminalCapabilities::default();\n        loop {\n            let bytes_read = term.read(&mut buffer)?;\n            if bytes_read == 0 {\n                return Ok(capabilities);\n            }\n            for next in &buffer[0..bytes_read] {\n                let next = char::from(*next);\n                let Some(output) = state.update(next) else {\n                    continue;\n                };\n                match output {\n                    Response::KittySupported { image_id } => {\n                        if image_id == ids.local {\n                            capabilities.kitty_local = true;\n                        } else if image_id == ids.remote {\n                            capabilities.kitty_remote = true;\n                        }\n                    }\n                    Response::Capabilities { sixel } => {\n                        capabilities.sixel = sixel;\n                        return Ok(capabilities);\n                    }\n                    Response::StatusReport => {\n                        return Ok(capabilities);\n                    }\n                }\n            }\n        }\n    }\n\n    fn launch_timeout_trigger(running: Arc<AtomicBool>) {\n        // Spawn a thread that will wait a second and if we still are running, will request the\n        // device status report straight from whoever is on top of us (tmux or terminal if no\n        // tmux), which will cause it to answer and wake up our main thread that's reading on\n        // stdin.\n        thread::spawn(move || {\n            thread::sleep(Duration::from_secs(1));\n            if !running.load(Ordering::Relaxed) {\n                return;\n            }\n            let _ = write!(io::stdout(), \"\\x1b[5n\");\n            let _ = io::stdout().flush();\n        });\n    }\n}\n\nstruct RawModeGuard;\n\nimpl RawModeGuard {\n    fn new() -> io::Result<Self> {\n        terminal::enable_raw_mode()?;\n        Ok(Self)\n    }\n}\n\nimpl Drop for RawModeGuard {\n    fn drop(&mut self) {\n        let _ = terminal::disable_raw_mode();\n    }\n}\n\n#[derive(Default)]\nstruct QueryParseState {\n    data: String,\n    current: ResponseType,\n}\n\nimpl QueryParseState {\n    fn update(&mut self, next: char) -> Option<Response> {\n        match &self.current {\n            ResponseType::Unknown => {\n                match (self.data.as_str(), next) {\n                    (_, '\\x1b') => {\n                        *self = Default::default();\n                        return None;\n                    }\n                    (\"[\", '?') => {\n                        self.current = ResponseType::Capabilities;\n                    }\n                    (\"[\", '0') => {\n                        self.current = ResponseType::StatusReport;\n                    }\n                    (\"_Gi\", '=') => {\n                        self.current = ResponseType::Kitty;\n                    }\n                    _ => (),\n                };\n                self.data.push(next);\n            }\n            ResponseType::Kitty => match next {\n                '\\\\' => {\n                    let response = self.build_kitty_response();\n                    *self = Default::default();\n                    return response;\n                }\n                _ => {\n                    self.data.push(next);\n                }\n            },\n            ResponseType::Capabilities => match next {\n                'c' => {\n                    let mut caps = self.data[2..].split(';');\n                    let sixel = caps.any(|cap| cap == \"4\");\n                    *self = Default::default();\n                    return Some(Response::Capabilities { sixel });\n                }\n                _ => self.data.push(next),\n            },\n            ResponseType::StatusReport => match next {\n                'n' => {\n                    *self = Default::default();\n                    return Some(Response::StatusReport);\n                }\n                _ => self.data.push(next),\n            },\n        };\n        None\n    }\n\n    fn build_kitty_response(&self) -> Option<Response> {\n        if !self.data.ends_with(\";OK\\x1b\") {\n            return None;\n        }\n        let (_, rest) = self.data.split_once(\"_Gi=\").expect(\"no kitty prefix\");\n        let (image_id, _) = rest.split_once(';')?;\n        let image_id = image_id.parse::<u32>().ok()?;\n        Some(Response::KittySupported { image_id })\n    }\n}\n\n#[derive(Default)]\nenum ResponseType {\n    #[default]\n    Unknown,\n    Kitty,\n    Capabilities,\n    StatusReport,\n}\n\nenum Response {\n    KittySupported { image_id: u32 },\n    Capabilities { sixel: bool },\n    StatusReport,\n}\n\nstruct KittyImageIds {\n    local: u32,\n    remote: u32,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use io::Cursor;\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::kitty_local(\"\\x1b_Gi=42;OK\\x1b\\\\\\x1b[?c\", true, false, false)]\n    #[case::kitty_remote(\"\\x1b_Gi=43;OK\\x1b\\\\\\x1b[?c\", false, true, false)]\n    #[case::kitty_both(\"\\x1b_Gi=42;OK\\x1b\\\\\\x1b_Gi=43;OK\\x1b\\\\\\x1b[?c\", true, true, false)]\n    #[case::kitty_flipped(\"\\x1b_Gi=43;OK\\x1b\\\\\\x1b_Gi=42;OK\\x1b\\\\\\x1b[?c\", true, true, false)]\n    #[case::all(\"\\x1b_Gi=42;OK\\x1b\\\\\\x1b_Gi=43;OK\\x1b\\\\\\x1b[?4c\", true, true, true)]\n    #[case::none(\"\\x1b[?c\", false, false, false)]\n    #[case::sixel_single(\"\\x1b[?4c\", false, false, true)]\n    #[case::sixel_first(\"\\x1b[?4;42c\", false, false, true)]\n    #[case::sixel_middle(\"\\x1b[?1337;4;42c\", false, false, true)]\n    fn detection(#[case] input: &str, #[case] kitty_local: bool, #[case] kitty_remote: bool, #[case] sixel: bool) {\n        let input = Cursor::new(input);\n        let ids = KittyImageIds { local: 42, remote: 43 };\n        let capabilities = TerminalCapabilities::parse_response(input, ids).expect(\"reading failed\");\n        assert_eq!(capabilities.kitty_local, kitty_local);\n        assert_eq!(capabilities.kitty_remote, kitty_remote);\n        assert_eq!(capabilities.sixel, sixel);\n    }\n}\n"
  },
  {
    "path": "src/terminal/emulator.rs",
    "content": "use super::{GraphicsMode, capabilities::TerminalCapabilities, image::protocols::kitty::KittyMode};\nuse std::{env, sync::OnceLock};\nuse strum::IntoEnumIterator;\n\nstatic CAPABILITIES: OnceLock<TerminalCapabilities> = OnceLock::new();\n\n#[derive(Debug, strum::EnumIter)]\npub enum TerminalEmulator {\n    Iterm2,\n    WezTerm,\n    Ghostty,\n    Mintty,\n    Kitty,\n    Konsole,\n    Foot,\n    Yaft,\n    Mlterm,\n    St,\n    Xterm,\n    Unknown,\n}\n\nimpl TerminalEmulator {\n    pub fn detect() -> Self {\n        let term = env::var(\"TERM\").unwrap_or_default();\n        let term_program = env::var(\"TERM_PROGRAM\").unwrap_or_default();\n        for emulator in Self::iter() {\n            if emulator.is_detected(&term, &term_program) {\n                return emulator;\n            }\n        }\n        TerminalEmulator::Unknown\n    }\n\n    pub(crate) fn capabilities() -> TerminalCapabilities {\n        CAPABILITIES.get_or_init(|| TerminalCapabilities::query().unwrap_or_default()).clone()\n    }\n\n    pub(crate) fn disable_capability_detection() {\n        CAPABILITIES.get_or_init(TerminalCapabilities::default);\n    }\n\n    pub fn preferred_protocol(&self) -> GraphicsMode {\n        let capabilities = Self::capabilities();\n        // Note: the order here is very important. In particular:\n        //\n        // * We prioritize checking for iterm2 support as the default for terminals that support\n        // it.\n        // * Kitty local is checked before remote since remote should also work when local is\n        // supported but local is more efficient.\n        // * Sixel is not great so we use it as a last resort.\n        // * ASCII blocks is supported by all terminals so it must come last.\n        let modes = [\n            GraphicsMode::Iterm2,\n            GraphicsMode::Iterm2Multipart,\n            GraphicsMode::Kitty { mode: KittyMode::Local },\n            GraphicsMode::Kitty { mode: KittyMode::Remote },\n            GraphicsMode::Sixel,\n            GraphicsMode::AsciiBlocks,\n        ];\n        for mode in modes {\n            if self.supports_graphics_mode(&mode, &capabilities) {\n                return mode;\n            }\n        }\n        unreachable!(\"ascii blocks is always supported\")\n    }\n\n    fn is_detected(&self, term: &str, term_program: &str) -> bool {\n        match self {\n            TerminalEmulator::Iterm2 => {\n                term_program.contains(\"iTerm\") || env::var(\"LC_TERMINAL\").is_ok_and(|c| c.contains(\"iTerm\"))\n            }\n            TerminalEmulator::WezTerm => term_program.contains(\"WezTerm\") || env::var(\"WEZTERM_EXECUTABLE\").is_ok(),\n            TerminalEmulator::Mintty => term_program.contains(\"mintty\"),\n            TerminalEmulator::Ghostty => term_program.contains(\"ghostty\"),\n            TerminalEmulator::Kitty => term.contains(\"kitty\"),\n            TerminalEmulator::Konsole => env::var(\"KONSOLE_VERSION\").is_ok(),\n            TerminalEmulator::Foot => [\"foot\", \"foot-extra\"].contains(&term),\n            TerminalEmulator::Yaft => term == \"yaft-256color\",\n            TerminalEmulator::Mlterm => term == \"mlterm\",\n            TerminalEmulator::St => term == \"st-256color\",\n            TerminalEmulator::Xterm => [\"xterm\", \"xterm-256color\"].contains(&term),\n            TerminalEmulator::Unknown => true,\n        }\n    }\n\n    fn supports_graphics_mode(&self, mode: &GraphicsMode, capabilities: &TerminalCapabilities) -> bool {\n        match (mode, self) {\n            // Use the kitty protocol in any terminal that supports the kitty graphics protocol.\n            //\n            // Note that this could potentially break for terminals that don't support the unicode\n            // placeholder part of the spec which is required for this to work under tmux, but it's\n            // not our fault terminals half implement the protocol.\n            (GraphicsMode::Kitty { mode, .. }, _) => match mode {\n                KittyMode::Local => capabilities.kitty_local,\n                KittyMode::Remote => capabilities.kitty_remote,\n            },\n            // All of these support the iterm2 protocol\n            (GraphicsMode::Iterm2, Self::Iterm2 | Self::WezTerm | Self::Mintty | Self::Konsole) => true,\n            // Only iterm2 supports the iterm2 protocol in multipart form.\n            (GraphicsMode::Iterm2Multipart, Self::Iterm2) => true,\n            // All terminals support ascii protocol\n            (GraphicsMode::AsciiBlocks, _) => true,\n            (GraphicsMode::Sixel, Self::Foot | Self::Yaft | Self::Mlterm) => true,\n            (GraphicsMode::Sixel, Self::St | Self::Xterm | Self::Unknown) => capabilities.sixel,\n            _ => false,\n        }\n    }\n}\n"
  },
  {
    "path": "src/terminal/image/mod.rs",
    "content": "use self::printer::{ImageProperties, TerminalImage};\nuse image::DynamicImage;\nuse protocols::ascii::AsciiImage;\nuse std::{\n    fmt::Debug,\n    ops::Deref,\n    path::PathBuf,\n    sync::{Arc, Mutex},\n};\n\npub(crate) mod printer;\npub(crate) mod protocols;\npub(crate) mod scale;\n\nstruct Inner {\n    image: TerminalImage,\n    ascii_image: Mutex<Option<AsciiImage>>,\n}\n\n/// An image.\n///\n/// This stores the image in an [std::sync::Arc] so it's cheap to clone.\n#[derive(Clone)]\npub(crate) struct Image {\n    inner: Arc<Inner>,\n    pub(crate) source: ImageSource,\n}\n\nimpl Image {\n    /// Constructs a new image.\n    pub(crate) fn new(image: TerminalImage, source: ImageSource) -> Self {\n        let inner = Inner { image, ascii_image: Default::default() };\n        Self { inner: Arc::new(inner), source }\n    }\n\n    pub(crate) fn to_ascii(&self) -> AsciiImage {\n        let mut ascii_image = self.inner.ascii_image.lock().unwrap();\n        match ascii_image.deref() {\n            Some(image) => image.clone(),\n            None => {\n                let image = match &self.inner.image {\n                    TerminalImage::Ascii(image) => image.clone(),\n                    TerminalImage::Kitty(image) => DynamicImage::from(image.as_rgba8()).into(),\n                    TerminalImage::Iterm(image) => DynamicImage::from(image.as_rgba8()).into(),\n                    TerminalImage::Raw(_) => unreachable!(\"raw is only used for exports\"),\n                    TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(),\n                };\n                *ascii_image = Some(image.clone());\n                image\n            }\n        }\n    }\n\n    pub(crate) fn image(&self) -> &TerminalImage {\n        &self.inner.image\n    }\n}\n\nimpl PartialEq for Image {\n    fn eq(&self, other: &Self) -> bool {\n        self.source == other.source\n    }\n}\n\nimpl Debug for Image {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let (width, height) = self.inner.image.dimensions();\n        write!(f, \"Image<{width}x{height}>\")\n    }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) enum ImageSource {\n    Filesystem(PathBuf),\n    Generated,\n}\n"
  },
  {
    "path": "src/terminal/image/printer.rs",
    "content": "use super::{\n    Image, ImageSource,\n    protocols::{\n        ascii::{AsciiImage, AsciiPrinter},\n        iterm::{ItermImage, ItermPrinter},\n        kitty::{KittyImage, KittyPrinter},\n        raw::{RawImage, RawPrinter},\n    },\n};\nuse crate::{\n    markdown::text_style::{Color, PaletteColorError},\n    terminal::{\n        GraphicsMode,\n        emulator::TerminalEmulator,\n        image::protocols::{\n            iterm::ItermMode,\n            sixel::{SixelImage, SixelPrinter},\n        },\n        printer::{TerminalError, TerminalIo},\n    },\n};\nuse image::{DynamicImage, ImageError};\nuse std::{\n    borrow::Cow,\n    collections::HashMap,\n    fmt, io,\n    path::PathBuf,\n    sync::{Arc, Mutex},\n};\n\npub(crate) trait PrintImage {\n    type Image: ImageProperties;\n\n    /// Register an image.\n    fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError>;\n\n    fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo;\n}\n\npub(crate) trait ImageProperties {\n    fn dimensions(&self) -> (u32, u32);\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) struct PrintOptions {\n    pub(crate) columns: u16,\n    pub(crate) rows: u16,\n    pub(crate) z_index: i32,\n    pub(crate) background_color: Option<Color>,\n    // Width/height in pixels.\n    #[allow(dead_code)]\n    pub(crate) column_width: u16,\n    #[allow(dead_code)]\n    pub(crate) row_height: u16,\n}\n\npub(crate) enum TerminalImage {\n    Kitty(KittyImage),\n    Iterm(ItermImage),\n    Ascii(AsciiImage),\n    Raw(RawImage),\n    Sixel(SixelImage),\n}\n\nimpl fmt::Debug for TerminalImage {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::Kitty(_) => f.debug_tuple(\"Kitty\").finish(),\n            Self::Iterm(_) => f.debug_tuple(\"Iterm\").finish(),\n            Self::Ascii(_) => f.debug_tuple(\"Ascii\").finish(),\n            Self::Raw(_) => f.debug_tuple(\"Raw\").finish(),\n            Self::Sixel(_) => f.debug_tuple(\"Sixel\").finish(),\n        }\n    }\n}\n\nimpl ImageProperties for TerminalImage {\n    fn dimensions(&self) -> (u32, u32) {\n        match self {\n            Self::Kitty(image) => image.dimensions(),\n            Self::Iterm(image) => image.dimensions(),\n            Self::Ascii(image) => image.dimensions(),\n            Self::Raw(image) => image.dimensions(),\n            Self::Sixel(image) => image.dimensions(),\n        }\n    }\n}\n\npub enum ImagePrinter {\n    Kitty(KittyPrinter),\n    Iterm(ItermPrinter),\n    Ascii(AsciiPrinter),\n    Raw(RawPrinter),\n    Null,\n    Sixel(SixelPrinter),\n}\n\nimpl Default for ImagePrinter {\n    fn default() -> Self {\n        Self::Ascii(AsciiPrinter)\n    }\n}\n\nimpl ImagePrinter {\n    pub fn new(mode: GraphicsMode) -> Result<Self, CreatePrinterError> {\n        let capabilities = TerminalEmulator::capabilities();\n        let printer = match mode {\n            GraphicsMode::Kitty { mode } => Self::Kitty(KittyPrinter::new(mode, capabilities.tmux)?),\n            GraphicsMode::Iterm2 => Self::Iterm(ItermPrinter::new(ItermMode::Single, capabilities.tmux)),\n            GraphicsMode::Iterm2Multipart => Self::Iterm(ItermPrinter::new(ItermMode::Multipart, capabilities.tmux)),\n            GraphicsMode::AsciiBlocks => Self::Ascii(AsciiPrinter),\n            GraphicsMode::Raw => Self::Raw(RawPrinter),\n            GraphicsMode::Sixel => Self::Sixel(SixelPrinter::new()?),\n        };\n        Ok(printer)\n    }\n}\n\nimpl PrintImage for ImagePrinter {\n    type Image = TerminalImage;\n\n    fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {\n        let image = match self {\n            Self::Kitty(printer) => TerminalImage::Kitty(printer.register(spec)?),\n            Self::Iterm(printer) => TerminalImage::Iterm(printer.register(spec)?),\n            Self::Ascii(printer) => TerminalImage::Ascii(printer.register(spec)?),\n            Self::Null => return Err(RegisterImageError::Unsupported),\n            Self::Raw(printer) => TerminalImage::Raw(printer.register(spec)?),\n            Self::Sixel(printer) => TerminalImage::Sixel(printer.register(spec)?),\n        };\n        Ok(image)\n    }\n\n    fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        match (self, image) {\n            (Self::Kitty(printer), TerminalImage::Kitty(image)) => printer.print(image, options, terminal),\n            (Self::Iterm(printer), TerminalImage::Iterm(image)) => printer.print(image, options, terminal),\n            (Self::Ascii(printer), TerminalImage::Ascii(image)) => printer.print(image, options, terminal),\n            (Self::Null, _) => Ok(()),\n            (Self::Raw(printer), TerminalImage::Raw(image)) => printer.print(image, options, terminal),\n            (Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, terminal),\n            _ => Err(PrintImageError::Unsupported),\n        }\n    }\n}\n\n#[derive(Clone, Default)]\npub(crate) struct ImageRegistry {\n    printer: Arc<ImagePrinter>,\n    images: Arc<Mutex<HashMap<PathBuf, Image>>>,\n}\n\nimpl ImageRegistry {\n    pub fn new(printer: Arc<ImagePrinter>) -> Self {\n        Self { printer, images: Default::default() }\n    }\n}\n\nimpl fmt::Debug for ImageRegistry {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        let inner = match self.printer.as_ref() {\n            ImagePrinter::Kitty(_) => \"Kitty\",\n            ImagePrinter::Iterm(_) => \"Iterm\",\n            ImagePrinter::Ascii(_) => \"Ascii\",\n            ImagePrinter::Null => \"Null\",\n            ImagePrinter::Raw(_) => \"Raw\",\n            ImagePrinter::Sixel(_) => \"Sixel\",\n        };\n        write!(f, \"ImageRegistry<{inner}>\")\n    }\n}\n\nimpl ImageRegistry {\n    pub(crate) fn register(&self, spec: ImageSpec) -> Result<Image, RegisterImageError> {\n        let mut images = self.images.lock().unwrap();\n        let (source, cache_key) = match &spec {\n            ImageSpec::Generated(_) => (ImageSource::Generated, None),\n            ImageSpec::Filesystem(path) => {\n                // Return if already cached\n                if let Some(image) = images.get(path) {\n                    return Ok(image.clone());\n                }\n                (ImageSource::Filesystem(path.clone()), Some(path.clone()))\n            }\n        };\n        let resource = self.printer.register(spec)?;\n        let image = Image::new(resource, source);\n        if let Some(key) = cache_key {\n            images.insert(key.clone(), image.clone());\n        }\n        Ok(image)\n    }\n\n    pub(crate) fn clear(&self) {\n        self.images.lock().unwrap().clear();\n    }\n}\n\npub(crate) enum ImageSpec {\n    Generated(DynamicImage),\n    Filesystem(PathBuf),\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum CreatePrinterError {\n    #[error(\"io: {0}\")]\n    Io(#[from] io::Error),\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum PrintImageError {\n    #[error(transparent)]\n    Io(#[from] io::Error),\n\n    #[error(\"unsupported image type\")]\n    Unsupported,\n\n    #[error(\"image decoding: {0}\")]\n    Image(#[from] ImageError),\n\n    #[error(\"{0}\")]\n    Other(Cow<'static, str>),\n}\n\nimpl From<PaletteColorError> for PrintImageError {\n    fn from(e: PaletteColorError) -> Self {\n        Self::Other(e.to_string().into())\n    }\n}\n\nimpl From<TerminalError> for PrintImageError {\n    fn from(e: TerminalError) -> Self {\n        match e {\n            TerminalError::Io(e) => Self::Io(e),\n            TerminalError::Image(e) => e,\n        }\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum RegisterImageError {\n    #[error(transparent)]\n    Io(#[from] io::Error),\n\n    #[error(\"image decoding: {0}\")]\n    Image(#[from] ImageError),\n\n    #[error(\"printer can't register images\")]\n    Unsupported,\n}\n\nimpl PrintImageError {\n    pub(crate) fn other<S>(message: S) -> Self\n    where\n        S: Into<Cow<'static, str>>,\n    {\n        Self::Other(message.into())\n    }\n}\n"
  },
  {
    "path": "src/terminal/image/protocols/ascii.rs",
    "content": "use crate::{\n    markdown::text_style::{Color, Colors, TextStyle},\n    terminal::{\n        image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},\n        printer::{TerminalCommand, TerminalIo},\n    },\n};\nuse image::{DynamicImage, GenericImageView, Pixel, Rgba, RgbaImage, imageops::FilterType};\nuse itertools::Itertools;\nuse std::{\n    collections::HashMap,\n    fs,\n    sync::{Arc, Mutex},\n};\n\nconst TOP_CHAR: &str = \"▀\";\nconst BOTTOM_CHAR: &str = \"▄\";\n\nstruct Inner {\n    image: DynamicImage,\n    cached_sizes: Mutex<HashMap<(u16, u16), RgbaImage>>,\n}\n\n#[derive(Clone)]\npub(crate) struct AsciiImage {\n    inner: Arc<Inner>,\n}\n\nimpl AsciiImage {\n    pub(crate) fn cache_scaling(&self, columns: u16, rows: u16) {\n        let mut cached_sizes = self.inner.cached_sizes.lock().unwrap();\n        // lookup on cache/resize the image and store it in cache\n        let cache_key = (columns, rows);\n        if cached_sizes.get(&cache_key).is_none() {\n            let image = self.inner.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle);\n            cached_sizes.insert(cache_key, image.into_rgba8());\n        }\n    }\n}\n\nimpl ImageProperties for AsciiImage {\n    fn dimensions(&self) -> (u32, u32) {\n        self.inner.image.dimensions()\n    }\n}\n\nimpl From<DynamicImage> for AsciiImage {\n    fn from(image: DynamicImage) -> Self {\n        let image = image.into_rgba8();\n        let inner = Inner { image: image.into(), cached_sizes: Default::default() };\n        Self { inner: Arc::new(inner) }\n    }\n}\n\n#[derive(Default)]\npub struct AsciiPrinter;\n\nimpl AsciiPrinter {\n    fn pixel_color(pixel: &Rgba<u8>, background: Option<Color>) -> Option<Color> {\n        let [r, g, b, alpha] = pixel.0;\n        if alpha == 0 {\n            None\n        } else if alpha < 255 {\n            // For alpha > 0 && < 255, we blend it with the background color (if any). This helps\n            // smooth the image's borders.\n            let mut pixel = *pixel;\n            match background {\n                Some(Color::Rgb { r, g, b }) => {\n                    pixel.blend(&Rgba([r, g, b, 255 - alpha]));\n                    Some(Color::Rgb { r: pixel[0], g: pixel[1], b: pixel[2] })\n                }\n                // For transparent backgrounds, we can't really know whether we should blend it\n                // towards light or dark.\n                None | Some(_) => Some(Color::Rgb { r, g, b }),\n            }\n        } else {\n            Some(Color::Rgb { r, g, b })\n        }\n    }\n}\n\nimpl PrintImage for AsciiPrinter {\n    type Image = AsciiImage;\n\n    fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {\n        let image = match spec {\n            ImageSpec::Generated(image) => image,\n            ImageSpec::Filesystem(path) => {\n                let contents = fs::read(path)?;\n                image::load_from_memory(&contents)?\n            }\n        };\n        Ok(AsciiImage::from(image))\n    }\n\n    fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        let columns = options.columns;\n        let rows = options.rows * 2;\n        // Scale it first\n        image.cache_scaling(columns, rows);\n\n        // lookup on cache/resize the image and store it in cache\n        let cache_key = (columns, rows);\n        let cached_sizes = image.inner.cached_sizes.lock().unwrap();\n        let image = cached_sizes.get(&cache_key).expect(\"scaled image no longer there\");\n\n        let default_background = options.background_color;\n\n        // Iterate pixel rows in pairs to be able to merge both pixels in a single iteration.\n        // Note that may not have a second row if there's an odd number of them.\n        for mut rows in &image.rows().chunks(2) {\n            let top_row = rows.next().unwrap();\n            let mut bottom_row = rows.next();\n            for top_pixel in top_row {\n                let bottom_pixel = bottom_row.as_mut().and_then(|pixels| pixels.next());\n\n                // Get pixel colors for both of these. At this point the special case for the odd\n                // number of rows disappears as we treat a transparent pixel and a non-existent\n                // one the same: they're simply transparent.\n                let background = default_background;\n                let top = Self::pixel_color(top_pixel, background);\n                let bottom = bottom_pixel.and_then(|c| Self::pixel_color(c, background));\n                let command = match (top, bottom) {\n                    (Some(top), Some(bottom)) => TerminalCommand::PrintText {\n                        content: TOP_CHAR,\n                        style: TextStyle::default().fg_color(top).bg_color(bottom),\n                    },\n                    (Some(top), None) => TerminalCommand::PrintText {\n                        content: TOP_CHAR,\n                        style: TextStyle::colored(Colors { foreground: Some(top), background: default_background }),\n                    },\n                    (None, Some(bottom)) => TerminalCommand::PrintText {\n                        content: BOTTOM_CHAR,\n                        style: TextStyle::colored(Colors { foreground: Some(bottom), background: default_background }),\n                    },\n                    (None, None) => TerminalCommand::MoveRight(1),\n                };\n                terminal.execute(&command)?;\n            }\n            terminal.execute(&TerminalCommand::MoveDown(1))?;\n            terminal.execute(&TerminalCommand::MoveLeft(options.columns))?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/terminal/image/protocols/iterm.rs",
    "content": "use crate::terminal::{\n    image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},\n    printer::{TerminalCommand, TerminalIo},\n};\nuse base64::{Engine, engine::general_purpose::STANDARD};\nuse image::{GenericImageView, ImageEncoder, RgbaImage, codecs::png::PngEncoder};\nuse std::{fs, str};\n\nconst CHUNK_SIZE: usize = 32 * 1024;\n\npub(crate) struct ItermImage {\n    dimensions: (u32, u32),\n    raw_length: usize,\n    base64_contents: String,\n}\n\nimpl ItermImage {\n    pub(crate) fn as_rgba8(&self) -> RgbaImage {\n        let contents = STANDARD.decode(&self.base64_contents).expect(\"base64 must be valid\");\n        let image = image::load_from_memory(&contents).expect(\"image must have been originally valid\");\n        image.to_rgba8()\n    }\n}\n\nimpl ImageProperties for ItermImage {\n    fn dimensions(&self) -> (u32, u32) {\n        self.dimensions\n    }\n}\n\npub enum ItermMode {\n    Single,\n    Multipart,\n}\n\npub struct ItermPrinter {\n    mode: ItermMode,\n    tmux: bool,\n}\n\nimpl ItermPrinter {\n    pub(crate) fn new(mode: ItermMode, tmux: bool) -> Self {\n        Self { mode, tmux }\n    }\n}\n\nimpl PrintImage for ItermPrinter {\n    type Image = ItermImage;\n\n    fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {\n        let (contents, dimensions) = match spec {\n            ImageSpec::Generated(image) => {\n                let dimensions = image.dimensions();\n                let mut contents = Vec::new();\n                let encoder = PngEncoder::new(&mut contents);\n                encoder.write_image(image.as_bytes(), dimensions.0, dimensions.1, image.color().into())?;\n                (contents, dimensions)\n            }\n            ImageSpec::Filesystem(path) => {\n                let contents = fs::read(path)?;\n                let image = image::load_from_memory(&contents)?;\n                (contents, image.dimensions())\n            }\n        };\n        let raw_length = contents.len();\n        let contents = STANDARD.encode(&contents);\n        Ok(ItermImage { dimensions, raw_length, base64_contents: contents })\n    }\n\n    fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        let size = image.raw_length;\n        let columns = options.columns;\n        let rows = options.rows;\n        let (start, end) = match self.tmux {\n            true => (\"\\x1bPtmux;\\x1b\\x1b]1337;\", \"\\x07\\x1b\\\\\"),\n            false => (\"\\x1b]1337;\", \"\\x07\"),\n        };\n        let base64 = &image.base64_contents;\n        match &self.mode {\n            ItermMode::Single => {\n                let content = &format!(\n                    \"{start}File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1:{base64}{end}\"\n                );\n                terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?;\n            }\n            ItermMode::Multipart => {\n                let content = &format!(\n                    \"{start}MultipartFile=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=1{end}\"\n                );\n                terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?;\n                for chunk in base64.as_bytes().chunks(CHUNK_SIZE) {\n                    // SAFETY: this is base64 so it must be utf8\n                    let chunk = str::from_utf8(chunk).expect(\"not utf8\");\n                    let content = &format!(\"{start}FilePart={chunk}{end}\");\n                    terminal.execute(&TerminalCommand::PrintText { content, style: Default::default() })?;\n                }\n                terminal.execute(&TerminalCommand::PrintText {\n                    content: &format!(\"{start}FileEnd{end}\"),\n                    style: Default::default(),\n                })?;\n            }\n        };\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/terminal/image/protocols/kitty.rs",
    "content": "use crate::{\n    markdown::text_style::{Color, TextStyle},\n    terminal::{\n        image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},\n        printer::{TerminalCommand, TerminalIo},\n    },\n};\nuse base64::{Engine, engine::general_purpose::STANDARD};\nuse image::{AnimationDecoder, Delay, EncodableLayout, ImageReader, RgbaImage, codecs::gif::GifDecoder};\nuse std::{\n    fmt,\n    fs::{self, File},\n    io::{self, BufReader},\n    path::{Path, PathBuf},\n    sync::atomic::{AtomicU32, Ordering},\n};\nuse tempfile::{TempDir, tempdir};\n\nconst IMAGE_PLACEHOLDER: &str = \"\\u{10EEEE}\";\nconst DIACRITICS: &[u32] = &[\n    0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357,\n    0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484,\n    0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1,\n    0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658,\n    0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2,\n    0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743,\n    0x745, 0x747, 0x749, 0x74a, 0x7eb, 0x7ec, 0x7ed, 0x7ee, 0x7ef, 0x7f0, 0x7f1, 0x7f3, 0x816, 0x817, 0x818, 0x819,\n    0x81b, 0x81c, 0x81d, 0x81e, 0x81f, 0x820, 0x821, 0x822, 0x823, 0x825, 0x826, 0x827, 0x829, 0x82a, 0x82b, 0x82c,\n    0x82d, 0x951, 0x953, 0x954, 0xf82, 0xf83, 0xf86, 0xf87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75,\n    0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, 0x1b72,\n    0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, 0x1dc5, 0x1dc6, 0x1dc7,\n    0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda,\n    0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0,\n    0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1,\n    0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded,\n    0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, 0x2dfb,\n    0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, 0xa8e2, 0xa8e3, 0xa8e4,\n    0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0,\n    0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26,\n    0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242,\n    0x1d243, 0x1d244,\n];\n\nenum GenericResource<B> {\n    Image(B),\n    Gif(Vec<GifFrame<B>>),\n}\n\ntype RawResource = GenericResource<RgbaImage>;\n\nimpl RawResource {\n    fn into_memory_resource(self) -> KittyImage {\n        match self {\n            Self::Image(image) => KittyImage {\n                dimensions: image.dimensions(),\n                resource: GenericResource::Image(KittyBuffer::Memory(image.into_raw())),\n            },\n            Self::Gif(frames) => {\n                let dimensions = frames[0].buffer.dimensions();\n                let frames = frames\n                    .into_iter()\n                    .map(|frame| GifFrame { delay: frame.delay, buffer: KittyBuffer::Memory(frame.buffer.into_raw()) })\n                    .collect();\n                let resource = GenericResource::Gif(frames);\n                KittyImage { dimensions, resource }\n            }\n        }\n    }\n}\n\npub(crate) struct KittyImage {\n    dimensions: (u32, u32),\n    resource: GenericResource<KittyBuffer>,\n}\n\nimpl KittyImage {\n    pub(crate) fn as_rgba8(&self) -> RgbaImage {\n        let first_frame = match &self.resource {\n            GenericResource::Image(buffer) => buffer,\n            GenericResource::Gif(gif_frames) => &gif_frames[0].buffer,\n        };\n        let buffer = match first_frame {\n            KittyBuffer::Filesystem(path) => {\n                let Ok(contents) = fs::read(path) else {\n                    return RgbaImage::default();\n                };\n                contents\n            }\n            KittyBuffer::Memory(buffer) => buffer.clone(),\n        };\n        RgbaImage::from_raw(self.dimensions.0, self.dimensions.1, buffer).unwrap_or_default()\n    }\n}\n\nimpl ImageProperties for KittyImage {\n    fn dimensions(&self) -> (u32, u32) {\n        self.dimensions\n    }\n}\n\nenum KittyBuffer {\n    Filesystem(PathBuf),\n    Memory(Vec<u8>),\n}\n\nimpl Drop for KittyBuffer {\n    fn drop(&mut self) {\n        if let Self::Filesystem(path) = self {\n            let _ = fs::remove_file(path);\n        }\n    }\n}\n\nstruct GifFrame<T> {\n    delay: Delay,\n    buffer: T,\n}\n\npub struct KittyPrinter {\n    mode: KittyMode,\n    tmux: bool,\n    base_directory: TempDir,\n    next: AtomicU32,\n}\n\nimpl KittyPrinter {\n    pub(crate) fn new(mode: KittyMode, tmux: bool) -> io::Result<Self> {\n        let base_directory = tempdir()?;\n        Ok(Self { mode, tmux, base_directory, next: Default::default() })\n    }\n\n    fn allocate_tempfile(&self) -> PathBuf {\n        let file_number = self.next.fetch_add(1, Ordering::AcqRel);\n        self.base_directory.path().join(file_number.to_string())\n    }\n\n    fn persist_image(&self, image: RgbaImage) -> io::Result<KittyImage> {\n        let path = self.allocate_tempfile();\n        fs::write(&path, image.as_bytes())?;\n\n        let buffer = KittyBuffer::Filesystem(path);\n        let resource = KittyImage { dimensions: image.dimensions(), resource: GenericResource::Image(buffer) };\n        Ok(resource)\n    }\n\n    fn persist_gif(&self, frames: Vec<GifFrame<RgbaImage>>) -> io::Result<KittyImage> {\n        let mut persisted_frames = Vec::new();\n        let mut dimensions = (0, 0);\n        for frame in frames {\n            let path = self.allocate_tempfile();\n            fs::write(&path, frame.buffer.as_bytes())?;\n            dimensions = frame.buffer.dimensions();\n\n            let frame = GifFrame { delay: frame.delay, buffer: KittyBuffer::Filesystem(path) };\n            persisted_frames.push(frame);\n        }\n        Ok(KittyImage { dimensions, resource: GenericResource::Gif(persisted_frames) })\n    }\n\n    fn persist_resource(&self, resource: RawResource) -> io::Result<KittyImage> {\n        match resource {\n            RawResource::Image(image) => self.persist_image(image),\n            RawResource::Gif(frames) => self.persist_gif(frames),\n        }\n    }\n\n    fn generate_image_id() -> u32 {\n        fastrand::u32(1..u32::MAX)\n    }\n\n    fn print_image<T>(\n        &self,\n        dimensions: (u32, u32),\n        buffer: &KittyBuffer,\n        terminal: &mut T,\n        print_options: &PrintOptions,\n    ) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        let mut options = vec![\n            ControlOption::Format(ImageFormat::Rgba),\n            ControlOption::Action(Action::TransmitAndDisplay),\n            ControlOption::Width(dimensions.0),\n            ControlOption::Height(dimensions.1),\n            ControlOption::Columns(print_options.columns),\n            ControlOption::Rows(print_options.rows),\n            ControlOption::ZIndex(print_options.z_index),\n            ControlOption::Quiet(2),\n        ];\n        let mut image_id = 0;\n        if self.tmux {\n            image_id = Self::generate_image_id();\n            options.extend([ControlOption::UnicodePlaceholder, ControlOption::ImageId(image_id)]);\n        }\n\n        match &buffer {\n            KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?,\n            KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, false)?,\n        };\n        if self.tmux {\n            self.print_unicode_placeholders(terminal, print_options, image_id)?;\n        }\n\n        Ok(())\n    }\n\n    fn print_gif<T>(\n        &self,\n        dimensions: (u32, u32),\n        frames: &[GifFrame<KittyBuffer>],\n        terminal: &mut T,\n        print_options: &PrintOptions,\n    ) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        let image_id = Self::generate_image_id();\n        for (frame_id, frame) in frames.iter().enumerate() {\n            let (num, denom) = frame.delay.numer_denom_ms();\n            // default to 100ms in case somehow the denominator is 0\n            let delay = num.checked_div(denom).unwrap_or(100);\n            let mut options = vec![\n                ControlOption::Format(ImageFormat::Rgba),\n                ControlOption::ImageId(image_id),\n                ControlOption::Width(dimensions.0),\n                ControlOption::Height(dimensions.1),\n                ControlOption::ZIndex(print_options.z_index),\n                ControlOption::Quiet(2),\n            ];\n            if frame_id == 0 {\n                options.extend([\n                    ControlOption::Action(Action::TransmitAndDisplay),\n                    ControlOption::Columns(print_options.columns),\n                    ControlOption::Rows(print_options.rows),\n                ]);\n                if self.tmux {\n                    options.push(ControlOption::UnicodePlaceholder);\n                }\n            } else {\n                options.extend([ControlOption::Action(Action::TransmitFrame), ControlOption::Delay(delay)]);\n            }\n\n            let is_frame = frame_id > 0;\n            match &frame.buffer {\n                KittyBuffer::Filesystem(path) => self.print_local(options, path, terminal)?,\n                KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, terminal, is_frame)?,\n            };\n\n            if frame_id == 0 {\n                let options = &[\n                    ControlOption::Action(Action::Animate),\n                    ControlOption::ImageId(image_id),\n                    ControlOption::FrameId(1),\n                    ControlOption::Loops(1),\n                ];\n                let command = self.make_command(options, \"\").to_string();\n                terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;\n            } else if frame_id == 1 {\n                let options = &[\n                    ControlOption::Action(Action::Animate),\n                    ControlOption::ImageId(image_id),\n                    ControlOption::FrameId(1),\n                    ControlOption::AnimationState(2),\n                ];\n                let command = self.make_command(options, \"\").to_string();\n                terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;\n            }\n        }\n        if self.tmux {\n            self.print_unicode_placeholders(terminal, print_options, image_id)?;\n        }\n        let options = &[\n            ControlOption::Action(Action::Animate),\n            ControlOption::ImageId(image_id),\n            ControlOption::FrameId(1),\n            ControlOption::AnimationState(3),\n            ControlOption::Loops(1),\n            ControlOption::Quiet(2),\n        ];\n        let command = self.make_command(options, \"\").to_string();\n        terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;\n        Ok(())\n    }\n\n    fn make_command<'a, P>(&self, options: &'a [ControlOption], payload: P) -> ControlCommand<'a, P> {\n        ControlCommand { options, payload, tmux: self.tmux }\n    }\n\n    fn print_local<T>(\n        &self,\n        mut options: Vec<ControlOption>,\n        path: &Path,\n        terminal: &mut T,\n    ) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        let Some(path) = path.to_str() else {\n            return Err(PrintImageError::other(\"path is not valid utf8\"));\n        };\n        let encoded_path = STANDARD.encode(path);\n        options.push(ControlOption::Medium(TransmissionMedium::LocalFile));\n\n        let command = self.make_command(&options, &encoded_path).to_string();\n        terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;\n        Ok(())\n    }\n\n    fn print_remote<T>(\n        &self,\n        mut options: Vec<ControlOption>,\n        frame: &[u8],\n        terminal: &mut T,\n        is_frame: bool,\n    ) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        options.push(ControlOption::Medium(TransmissionMedium::Direct));\n\n        let payload = STANDARD.encode(frame);\n        let chunk_size = 4096;\n        let mut index = 0;\n        while index < payload.len() {\n            let start = index;\n            let end = payload.len().min(start + chunk_size);\n            index = end;\n\n            let more = end != payload.len();\n            options.push(ControlOption::MoreData(more));\n\n            let payload = &payload[start..end];\n            let command = self.make_command(&options, payload).to_string();\n            terminal.execute(&TerminalCommand::PrintText { content: &command, style: Default::default() })?;\n\n            options.clear();\n            if is_frame {\n                options.push(ControlOption::Action(Action::TransmitFrame));\n            }\n        }\n        Ok(())\n    }\n\n    fn print_unicode_placeholders<T>(\n        &self,\n        terminal: &mut T,\n        options: &PrintOptions,\n        image_id: u32,\n    ) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        let color = Color::new((image_id >> 16) as u8, (image_id >> 8) as u8, image_id as u8);\n        let style = TextStyle::default().fg_color(color);\n        if options.rows.max(options.columns) >= DIACRITICS.len() as u16 {\n            return Err(PrintImageError::other(\"image is too large to fit in tmux\"));\n        }\n\n        let last_byte = char::from_u32(DIACRITICS[(image_id >> 24) as usize]).unwrap();\n        for row in 0..options.rows {\n            let row_diacritic = char::from_u32(DIACRITICS[row as usize]).unwrap();\n            for column in 0..options.columns {\n                let column_diacritic = char::from_u32(DIACRITICS[column as usize]).unwrap();\n                let content = format!(\"{IMAGE_PLACEHOLDER}{row_diacritic}{column_diacritic}{last_byte}\");\n                terminal.execute(&TerminalCommand::PrintText { content: &content, style })?;\n            }\n            if row != options.rows - 1 {\n                terminal.execute(&TerminalCommand::MoveDown(1))?;\n            }\n            terminal.execute(&TerminalCommand::MoveLeft(options.columns))?;\n        }\n        Ok(())\n    }\n\n    fn load_raw_resource(path: &Path) -> Result<RawResource, RegisterImageError> {\n        let file = File::open(path)?;\n        if path.extension().unwrap_or_default() == \"gif\" {\n            let decoder = GifDecoder::new(BufReader::new(file))?;\n            let mut frames = Vec::new();\n            for frame in decoder.into_frames() {\n                let frame = frame?;\n                let frame = GifFrame { delay: frame.delay(), buffer: frame.into_buffer() };\n                frames.push(frame);\n            }\n            Ok(RawResource::Gif(frames))\n        } else {\n            let reader = ImageReader::new(BufReader::new(file)).with_guessed_format()?;\n            let image = reader.decode()?;\n            Ok(RawResource::Image(image.into_rgba8()))\n        }\n    }\n}\n\nimpl PrintImage for KittyPrinter {\n    type Image = KittyImage;\n\n    fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {\n        let image = match spec {\n            ImageSpec::Generated(image) => RawResource::Image(image.into_rgba8()),\n            ImageSpec::Filesystem(path) => Self::load_raw_resource(&path)?,\n        };\n        let resource = match &self.mode {\n            KittyMode::Local => self.persist_resource(image)?,\n            KittyMode::Remote => image.into_memory_resource(),\n        };\n        Ok(resource)\n    }\n\n    fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        match &image.resource {\n            GenericResource::Image(resource) => self.print_image(image.dimensions, resource, terminal, options)?,\n            GenericResource::Gif(frames) => self.print_gif(image.dimensions, frames, terminal, options)?,\n        };\n        Ok(())\n    }\n}\n\n#[derive(Clone, Debug)]\npub enum KittyMode {\n    Local,\n    Remote,\n}\n\npub(crate) struct ControlCommand<'a, D> {\n    pub(crate) options: &'a [ControlOption],\n    pub(crate) payload: D,\n    pub(crate) tmux: bool,\n}\n\nimpl<D: fmt::Display> fmt::Display for ControlCommand<'_, D> {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        if self.tmux {\n            write!(f, \"\\x1bPtmux;\\x1b\")?;\n        }\n        write!(f, \"\\x1b_G\")?;\n        for (index, option) in self.options.iter().enumerate() {\n            if index > 0 {\n                write!(f, \",\")?;\n            }\n            write!(f, \"{option}\")?;\n        }\n        write!(f, \";{}\", &self.payload)?;\n        if self.tmux {\n            write!(f, \"\\x1b\\x1b\\\\\\x1b\\\\\")?;\n        } else {\n            write!(f, \"\\x1b\\\\\")?;\n        }\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum ControlOption {\n    Action(Action),\n    Format(ImageFormat),\n    Medium(TransmissionMedium),\n    Width(u32),\n    Height(u32),\n    Columns(u16),\n    Rows(u16),\n    MoreData(bool),\n    ImageId(u32),\n    FrameId(u32),\n    Delay(u32),\n    AnimationState(u32),\n    Loops(u32),\n    Quiet(u32),\n    ZIndex(i32),\n    UnicodePlaceholder,\n}\n\nimpl fmt::Display for ControlOption {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        use ControlOption::*;\n        match self {\n            Action(action) => write!(f, \"a={action}\"),\n            Format(format) => write!(f, \"f={format}\"),\n            Medium(medium) => write!(f, \"t={medium}\"),\n            Width(width) => write!(f, \"s={width}\"),\n            Height(height) => write!(f, \"v={height}\"),\n            Columns(columns) => write!(f, \"c={columns}\"),\n            Rows(rows) => write!(f, \"r={rows}\"),\n            MoreData(true) => write!(f, \"m=1\"),\n            MoreData(false) => write!(f, \"m=0\"),\n            ImageId(id) => write!(f, \"i={id}\"),\n            FrameId(id) => write!(f, \"r={id}\"),\n            Delay(delay) => write!(f, \"z={delay}\"),\n            AnimationState(state) => write!(f, \"s={state}\"),\n            Loops(count) => write!(f, \"v={count}\"),\n            Quiet(option) => write!(f, \"q={option}\"),\n            ZIndex(index) => write!(f, \"z={index}\"),\n            UnicodePlaceholder => write!(f, \"U=1\"),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum ImageFormat {\n    Rgba,\n}\n\nimpl fmt::Display for ImageFormat {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        use ImageFormat::*;\n        let value = match self {\n            Rgba => 32,\n        };\n        write!(f, \"{value}\")\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum TransmissionMedium {\n    Direct,\n    LocalFile,\n}\n\nimpl fmt::Display for TransmissionMedium {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        use TransmissionMedium::*;\n        let value = match self {\n            Direct => 'd',\n            LocalFile => 'f',\n        };\n        write!(f, \"{value}\")\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) enum Action {\n    Animate,\n    TransmitAndDisplay,\n    TransmitFrame,\n    Query,\n}\n\nimpl fmt::Display for Action {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        use Action::*;\n        let value = match self {\n            Animate => 'a',\n            TransmitAndDisplay => 'T',\n            TransmitFrame => 'f',\n            Query => 'q',\n        };\n        write!(f, \"{value}\")\n    }\n}\n"
  },
  {
    "path": "src/terminal/image/protocols/mod.rs",
    "content": "pub(crate) mod ascii;\npub(crate) mod iterm;\npub(crate) mod kitty;\npub(crate) mod raw;\npub(crate) mod sixel;\n"
  },
  {
    "path": "src/terminal/image/protocols/raw.rs",
    "content": "use crate::terminal::{\n    image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},\n    printer::TerminalIo,\n};\nuse base64::{Engine, engine::general_purpose::STANDARD};\nuse image::{GenericImageView, ImageEncoder, ImageFormat, codecs::png::PngEncoder};\nuse std::fs;\n\npub(crate) struct RawImage {\n    contents: Vec<u8>,\n    format: ImageFormat,\n    width: u32,\n    height: u32,\n}\n\nimpl RawImage {\n    pub(crate) fn to_inline_html(&self) -> String {\n        let mime_type = self.format.to_mime_type();\n        let data = STANDARD.encode(&self.contents);\n        format!(\"data:{mime_type};base64,{data}\")\n    }\n}\n\nimpl ImageProperties for RawImage {\n    fn dimensions(&self) -> (u32, u32) {\n        (self.width, self.height)\n    }\n}\n\npub(crate) struct RawPrinter;\n\nimpl PrintImage for RawPrinter {\n    type Image = RawImage;\n\n    fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {\n        let image = match spec {\n            ImageSpec::Generated(image) => {\n                let mut contents = Vec::new();\n                let encoder = PngEncoder::new(&mut contents);\n                let (width, height) = image.dimensions();\n                encoder.write_image(image.as_bytes(), width, height, image.color().into())?;\n                RawImage { contents, format: ImageFormat::Png, width, height }\n            }\n            ImageSpec::Filesystem(path) => {\n                let contents = fs::read(path)?;\n                let format = image::guess_format(&contents)?;\n                let image = image::load_from_memory_with_format(&contents, format)?;\n                let (width, height) = image.dimensions();\n                RawImage { contents, format, width, height }\n            }\n        };\n        Ok(image)\n    }\n\n    fn print<T>(&self, _image: &Self::Image, _options: &PrintOptions, _terminal: &mut T) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        Err(PrintImageError::Other(\"raw images can't be printed\".into()))\n    }\n}\n"
  },
  {
    "path": "src/terminal/image/protocols/sixel.rs",
    "content": "use crate::terminal::{\n    image::printer::{\n        CreatePrinterError, ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError,\n    },\n    printer::{TerminalCommand, TerminalIo},\n};\nuse icy_sixel::encoder::{EncodeOptions, sixel_encode};\nuse image::{DynamicImage, GenericImageView, RgbaImage, imageops::FilterType};\nuse std::fs;\n\npub(crate) struct SixelImage(DynamicImage);\n\nimpl SixelImage {\n    pub(crate) fn as_rgba8(&self) -> RgbaImage {\n        self.0.to_rgba8()\n    }\n}\n\nimpl ImageProperties for SixelImage {\n    fn dimensions(&self) -> (u32, u32) {\n        self.0.dimensions()\n    }\n}\n\n#[derive(Default)]\npub struct SixelPrinter;\n\nimpl SixelPrinter {\n    pub(crate) fn new() -> Result<Self, CreatePrinterError> {\n        Ok(Self)\n    }\n}\n\nimpl PrintImage for SixelPrinter {\n    type Image = SixelImage;\n\n    fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {\n        match spec {\n            ImageSpec::Generated(image) => Ok(SixelImage(image)),\n            ImageSpec::Filesystem(path) => {\n                let contents = fs::read(path)?;\n                let image = image::load_from_memory(&contents)?;\n                Ok(SixelImage(image))\n            }\n        }\n    }\n\n    fn print<T>(&self, image: &Self::Image, options: &PrintOptions, terminal: &mut T) -> Result<(), PrintImageError>\n    where\n        T: TerminalIo,\n    {\n        // We're already positioned in the right place but we may not have flushed that yet.\n        terminal.execute(&TerminalCommand::Flush)?;\n\n        // This check was taken from viuer: it seems to be a bug in xterm\n        let width = (options.column_width * options.columns).min(1000);\n        let height = options.row_height * options.rows;\n        let image = image.0.resize_exact(width as u32, height as u32, FilterType::Triangle);\n        let bytes = image.into_rgba8().into_raw();\n\n        let content = sixel_encode(&bytes, width as usize, height as usize, &EncodeOptions::default())\n            .map_err(|e| PrintImageError::other(format!(\"encoding sixel image: {e:?}\")))?;\n        terminal.execute(&TerminalCommand::PrintText { content: &content, style: Default::default() })?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/terminal/image/scale.rs",
    "content": "use crate::render::properties::{CursorPosition, WindowSize};\n\npub(crate) trait ScaleImage {\n    /// Scale an image to a specific size.\n    fn scale_image(\n        &self,\n        scale_size: &WindowSize,\n        window_dimensions: &WindowSize,\n        image_width: u32,\n        image_height: u32,\n        position: &CursorPosition,\n    ) -> TerminalRect;\n\n    /// Shrink an image so it fits the dimensions of the layout it's being displayed in.\n    fn fit_image_to_rect(\n        &self,\n        dimensions: &WindowSize,\n        image_width: u32,\n        image_height: u32,\n        position: &CursorPosition,\n    ) -> TerminalRect;\n}\n\npub(crate) struct ImageScaler {\n    horizontal_margin: f64,\n}\n\nimpl ScaleImage for ImageScaler {\n    fn scale_image(\n        &self,\n        scale_size: &WindowSize,\n        window_dimensions: &WindowSize,\n        image_width: u32,\n        image_height: u32,\n        position: &CursorPosition,\n    ) -> TerminalRect {\n        let aspect_ratio = image_height as f64 / image_width as f64;\n        let column_in_pixels = scale_size.pixels_per_column();\n        let width_in_columns = scale_size.columns;\n        let image_width = width_in_columns as f64 * column_in_pixels;\n        let image_height = image_width * aspect_ratio;\n\n        self.fit_image_to_rect(window_dimensions, image_width as u32, image_height as u32, position)\n    }\n\n    fn fit_image_to_rect(\n        &self,\n        dimensions: &WindowSize,\n        image_width: u32,\n        image_height: u32,\n        position: &CursorPosition,\n    ) -> TerminalRect {\n        let aspect_ratio = image_height as f64 / image_width as f64;\n\n        // Compute the image's width in columns by translating pixels -> columns.\n        let column_in_pixels = dimensions.pixels_per_column();\n        let column_margin = (dimensions.columns as f64 * (1.0 - self.horizontal_margin)) as u32;\n        let mut width_in_columns = (image_width as f64 / column_in_pixels) as u32;\n\n        // Do the same for its height.\n        let row_in_pixels = dimensions.pixels_per_row();\n        let height_in_rows = (image_height as f64 / row_in_pixels) as u32;\n\n        // If the image doesn't fit vertically, shrink it.\n        let available_height = dimensions.rows.saturating_sub(position.row) as u32;\n        if height_in_rows > available_height {\n            // Because we only use the width to draw, here we scale the width based on how much we\n            // need to shrink the height.\n            let shrink_ratio = available_height as f64 / height_in_rows as f64;\n            width_in_columns = (width_in_columns as f64 * shrink_ratio).round() as u32;\n        }\n        // Don't go too far wide.\n        let width_in_columns = width_in_columns.min(column_margin);\n\n        // Now translate width -> height by using the original aspect ratio + translate based on\n        // the window size's aspect ratio.\n        let height_in_rows = (width_in_columns as f64 * aspect_ratio * dimensions.aspect_ratio()).round() as u16;\n\n        let width_in_columns = width_in_columns.max(1);\n        let height_in_rows = height_in_rows.max(1);\n\n        TerminalRect { columns: width_in_columns as u16, rows: height_in_rows }\n    }\n}\n\nimpl Default for ImageScaler {\n    fn default() -> Self {\n        Self { horizontal_margin: 0.05 }\n    }\n}\n\n#[derive(Debug, PartialEq)]\npub(crate) struct TerminalRect {\n    pub(crate) columns: u16,\n    pub(crate) rows: u16,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use rstest::rstest;\n\n    const WINDOW: WindowSize = WindowSize { rows: 50, columns: 100, height: 200, width: 200 };\n    const SMALL_WINDOW: WindowSize = WindowSize { rows: 3, columns: 6, height: 10, width: 10 };\n    const OTHER_RATIO: WindowSize = WindowSize { rows: 10, columns: 10, height: 10, width: 10 };\n\n    #[rstest]\n    #[case::squares(WINDOW, 100, 100, TerminalRect { columns: 50, rows: 25 })]\n    #[case::squares_smaller(WINDOW, 50, 50, TerminalRect { columns: 25, rows: 13 })]\n    #[case::square_too_large(WINDOW, 400, 400, TerminalRect { columns: 100, rows: 50 })]\n    #[case::too_tall(WINDOW, 200, 400, TerminalRect { columns: 50, rows: 50 })]\n    #[case::too_wide(WINDOW, 400, 200, TerminalRect { columns: 100, rows: 25 })]\n    #[case::small(SMALL_WINDOW, 899, 872, TerminalRect { columns: 6, rows: 3 })]\n    #[case::other_ratio(OTHER_RATIO, 100, 100, TerminalRect { columns: 10, rows: 10 })]\n    fn image_fitting(\n        #[case] window: WindowSize,\n        #[case] width: u32,\n        #[case] height: u32,\n        #[case] expected: TerminalRect,\n    ) {\n        let cursor = CursorPosition::default();\n        let rect = ImageScaler { horizontal_margin: 0.0 }.fit_image_to_rect(&window, width, height, &cursor);\n        assert_eq!(rect, expected);\n    }\n}\n"
  },
  {
    "path": "src/terminal/mod.rs",
    "content": "pub(crate) mod ansi;\npub(crate) mod capabilities;\npub(crate) mod emulator;\npub(crate) mod image;\npub(crate) mod printer;\npub(crate) mod virt;\n\npub(crate) use printer::{Terminal, TerminalWrite, should_hide_cursor};\n\n#[derive(Clone, Debug)]\npub enum GraphicsMode {\n    Iterm2,\n    Iterm2Multipart,\n    Kitty { mode: image::protocols::kitty::KittyMode },\n    AsciiBlocks,\n    Raw,\n    Sixel,\n}\n"
  },
  {
    "path": "src/terminal/printer.rs",
    "content": "use super::emulator::TerminalEmulator;\nuse crate::{\n    markdown::text_style::{Color, Colors, TextStyle},\n    terminal::image::{\n        Image,\n        printer::{ImagePrinter, PrintImage, PrintImageError, PrintOptions},\n    },\n};\nuse crossterm::{\n    QueueableCommand, cursor, style,\n    terminal::{self},\n};\nuse std::{\n    io::{self, Write},\n    sync::Arc,\n};\n\n#[derive(Debug, PartialEq)]\npub(crate) enum TerminalCommand<'a> {\n    BeginUpdate,\n    EndUpdate,\n    MoveTo { column: u16, row: u16 },\n    MoveToRow(u16),\n    MoveToColumn(u16),\n    MoveDown(u16),\n    MoveRight(u16),\n    MoveLeft(u16),\n    MoveToNextLine,\n    PrintText { content: &'a str, style: TextStyle },\n    ClearScreen,\n    SetColors(Colors),\n    SetBackgroundColor(Color),\n    SetCursorBoundaries { rows: u16 },\n    Flush,\n    PrintImage { image: Image, options: PrintOptions },\n}\n\npub(crate) trait TerminalIo {\n    fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError>;\n    fn cursor_row(&self) -> u16;\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum TerminalError {\n    #[error(\"io: {0}\")]\n    Io(#[from] io::Error),\n\n    #[error(\"image: {0}\")]\n    Image(#[from] PrintImageError),\n}\n\n/// A wrapper over the terminal write handle.\npub(crate) struct Terminal<I: TerminalWrite> {\n    writer: I,\n    image_printer: Arc<ImagePrinter>,\n    cursor_row: u16,\n    current_row_height: u16,\n    rows: u16,\n    last_cleared_background_color: Option<Color>,\n    background_color: Option<Color>,\n    osc11_background: bool,\n}\n\nimpl<I: TerminalWrite> Terminal<I> {\n    pub(crate) fn new(mut writer: I, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {\n        writer.init()?;\n        Ok(Self {\n            writer,\n            image_printer,\n            cursor_row: 0,\n            current_row_height: 1,\n            rows: u16::MAX,\n            last_cleared_background_color: None,\n            background_color: None,\n            // Only use OSC11 when outside of tmux temporarily since it somehow breaks under kitty\n            osc11_background: !TerminalEmulator::capabilities().tmux,\n        })\n    }\n\n    fn begin_update(&mut self) -> io::Result<()> {\n        self.writer.queue(terminal::BeginSynchronizedUpdate)?;\n        Ok(())\n    }\n\n    fn end_update(&mut self) -> io::Result<()> {\n        self.writer.queue(terminal::EndSynchronizedUpdate)?;\n        Ok(())\n    }\n\n    fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {\n        self.writer.queue(cursor::MoveTo(column, row))?;\n        self.cursor_row = row;\n        Ok(())\n    }\n\n    fn move_to_row(&mut self, row: u16) -> io::Result<()> {\n        self.writer.queue(cursor::MoveToRow(row))?;\n        self.cursor_row = row;\n        Ok(())\n    }\n\n    fn move_to_column(&mut self, column: u16) -> io::Result<()> {\n        self.writer.queue(cursor::MoveToColumn(column))?;\n        Ok(())\n    }\n\n    fn move_down(&mut self, amount: u16) -> io::Result<()> {\n        self.writer.queue(cursor::MoveDown(amount))?;\n        self.cursor_row += amount;\n        Ok(())\n    }\n\n    fn move_right(&mut self, amount: u16) -> io::Result<()> {\n        self.writer.queue(cursor::MoveRight(amount))?;\n        Ok(())\n    }\n\n    fn move_left(&mut self, amount: u16) -> io::Result<()> {\n        self.writer.queue(cursor::MoveLeft(amount))?;\n        Ok(())\n    }\n\n    fn move_to_next_line(&mut self) -> io::Result<()> {\n        let amount = self.current_row_height;\n        self.writer.queue(cursor::MoveToNextLine(amount))?;\n        self.cursor_row += amount;\n        self.current_row_height = 1;\n        Ok(())\n    }\n\n    fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {\n        // Don't print text if it overflows vertically.\n        if self.cursor_row.saturating_add(style.size as u16) > self.rows {\n            return Ok(());\n        }\n        let capabilities = TerminalEmulator::capabilities();\n        let content = style.apply(content, &capabilities);\n        self.writer.queue(style::PrintStyledContent(content))?;\n        self.current_row_height = self.current_row_height.max(style.size as u16);\n        Ok(())\n    }\n\n    fn clear_screen(&mut self) -> io::Result<()> {\n        if self.osc11_background {\n            match (self.last_cleared_background_color, self.background_color) {\n                (_, Some(Color::Rgb { r, g, b })) => {\n                    // Set background via OSC 11 if we have an RGB color\n                    write!(self.writer, \"\\x1b]11;#{r:02x}{g:02x}{b:02x}\\x1b\\\\\")?;\n                }\n                // If it was RGB and it no longer is, or we have no background now, clear it.\n                (Some(Color::Rgb { .. }), Some(_)) | (_, None) => write!(self.writer, \"\\x1b]111\\x1b\\\\\")?,\n                _ => (),\n            };\n        }\n        self.last_cleared_background_color = self.background_color;\n        self.writer.queue(terminal::Clear(terminal::ClearType::All))?;\n        self.cursor_row = 0;\n        self.current_row_height = 1;\n        Ok(())\n    }\n\n    fn set_colors(&mut self, colors: Colors) -> io::Result<()> {\n        // Save this for when the screen is cleared..\n        self.background_color = colors.background;\n\n        let colors = colors.into();\n        self.writer.queue(style::ResetColor)?;\n        self.writer.queue(style::SetColors(colors))?;\n        Ok(())\n    }\n\n    fn set_background_color(&mut self, color: Color) -> io::Result<()> {\n        self.background_color = Some(color);\n\n        let color = color.into();\n        self.writer.queue(style::SetBackgroundColor(color))?;\n        Ok(())\n    }\n\n    fn set_cursor_boundaries(&mut self, rows: u16) {\n        self.rows = rows;\n    }\n\n    fn flush(&mut self) -> io::Result<()> {\n        self.writer.flush()?;\n        Ok(())\n    }\n\n    fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {\n        let image_printer = self.image_printer.clone();\n        image_printer.print(image.image(), options, self)?;\n        self.cursor_row += options.rows;\n        Ok(())\n    }\n\n    pub(crate) fn suspend(&mut self) {\n        self.writer.deinit();\n    }\n\n    pub(crate) fn resume(&mut self) {\n        let _ = self.writer.init();\n    }\n}\n\nimpl<I: TerminalWrite> TerminalIo for Terminal<I> {\n    fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {\n        use TerminalCommand::*;\n        match command {\n            BeginUpdate => self.begin_update()?,\n            EndUpdate => self.end_update()?,\n            MoveTo { column, row } => self.move_to(*column, *row)?,\n            MoveToRow(row) => self.move_to_row(*row)?,\n            MoveToColumn(column) => self.move_to_column(*column)?,\n            MoveDown(amount) => self.move_down(*amount)?,\n            MoveRight(amount) => self.move_right(*amount)?,\n            MoveLeft(amount) => self.move_left(*amount)?,\n            MoveToNextLine => self.move_to_next_line()?,\n            PrintText { content, style } => self.print_text(content, style)?,\n            ClearScreen => self.clear_screen()?,\n            SetColors(colors) => self.set_colors(*colors)?,\n            SetBackgroundColor(color) => self.set_background_color(*color)?,\n            SetCursorBoundaries { rows } => self.set_cursor_boundaries(*rows),\n            Flush => self.flush()?,\n            PrintImage { image, options } => self.print_image(image, options)?,\n        };\n        Ok(())\n    }\n\n    fn cursor_row(&self) -> u16 {\n        self.cursor_row\n    }\n}\n\nimpl<I: TerminalWrite> Drop for Terminal<I> {\n    fn drop(&mut self) {\n        if self.osc11_background {\n            if let Some(Color::Rgb { .. }) = self.background_color {\n                let _ = write!(self.writer, \"\\x1b]111\\x1b\\\\\");\n            }\n        }\n        self.writer.deinit();\n    }\n}\n\npub(crate) fn should_hide_cursor() -> bool {\n    // WezTerm on Windows fails to display images if we've hidden the cursor so we **always** hide it\n    // unless we're on WezTerm on Windows.\n    let term = std::env::var(\"TERM_PROGRAM\");\n    let is_wezterm = term.as_ref().map(|s| s.as_str()) == Ok(\"WezTerm\");\n    !(is_windows_based_os() && is_wezterm)\n}\n\nfn is_windows_based_os() -> bool {\n    let is_windows = std::env::consts::OS == \"windows\";\n    let is_wsl = std::env::var(\"WSL_DISTRO_NAME\").is_ok();\n    is_windows || is_wsl\n}\n\npub(crate) trait TerminalWrite: io::Write {\n    fn init(&mut self) -> io::Result<()>;\n    fn deinit(&mut self);\n}\n\nimpl TerminalWrite for io::Stdout {\n    fn init(&mut self) -> io::Result<()> {\n        terminal::enable_raw_mode()?;\n        if should_hide_cursor() {\n            self.queue(cursor::Hide)?;\n        }\n        self.queue(terminal::EnterAlternateScreen)?;\n        Ok(())\n    }\n\n    fn deinit(&mut self) {\n        let _ = self.queue(terminal::LeaveAlternateScreen);\n        if should_hide_cursor() {\n            let _ = self.queue(cursor::Show);\n        }\n        let _ = self.flush();\n        let _ = terminal::disable_raw_mode();\n    }\n}\n"
  },
  {
    "path": "src/terminal/virt.rs",
    "content": "use super::{\n    image::{\n        Image,\n        printer::{PrintImage, PrintImageError, PrintOptions},\n        protocols::ascii::AsciiPrinter,\n    },\n    printer::{TerminalError, TerminalIo},\n};\nuse crate::{\n    WindowSize,\n    markdown::{\n        elements::Text,\n        text_style::{Color, Colors, TextStyle},\n    },\n    terminal::printer::TerminalCommand,\n};\nuse core::fmt;\nuse std::{collections::HashMap, io};\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) struct PrintedImage {\n    pub(crate) image: Image,\n    pub(crate) width_columns: u16,\n}\n\npub(crate) struct TerminalRowIterator<'a> {\n    row: &'a [StyledChar],\n}\n\nimpl<'a> TerminalRowIterator<'a> {\n    pub(crate) fn new(row: &'a [StyledChar]) -> Self {\n        Self { row }\n    }\n}\n\nimpl Iterator for TerminalRowIterator<'_> {\n    type Item = Text;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        let style = self.row.first()?.style;\n        let mut output = String::new();\n        while let Some(c) = self.row.first() {\n            if c.style != style {\n                break;\n            }\n            output.push(c.character);\n            self.row = &self.row[1..];\n        }\n        Some(Text::new(output, style))\n    }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub(crate) struct TerminalGrid {\n    pub(crate) rows: Vec<Vec<StyledChar>>,\n    pub(crate) background_color: Option<Color>,\n    pub(crate) images: HashMap<(u16, u16), PrintedImage>,\n}\n\npub(crate) struct VirtualTerminal {\n    row: u16,\n    column: u16,\n    colors: Colors,\n    rows: Vec<Vec<StyledChar>>,\n    background_color: Option<Color>,\n    images: HashMap<(u16, u16), PrintedImage>,\n    row_heights: Vec<u16>,\n    image_behavior: ImageBehavior,\n}\n\nimpl VirtualTerminal {\n    pub(crate) fn new(dimensions: WindowSize, image_behavior: ImageBehavior) -> Self {\n        let rows = vec![vec![StyledChar::default(); dimensions.columns as usize]; dimensions.rows as usize];\n        let row_heights = vec![1; dimensions.rows as usize];\n        Self {\n            row: 0,\n            column: 0,\n            colors: Default::default(),\n            rows,\n            background_color: None,\n            images: Default::default(),\n            row_heights,\n            image_behavior,\n        }\n    }\n\n    pub(crate) fn into_contents(self) -> TerminalGrid {\n        TerminalGrid { rows: self.rows, background_color: self.background_color, images: self.images }\n    }\n\n    fn current_cell_mut(&mut self) -> Option<&mut StyledChar> {\n        self.rows.get_mut(self.row as usize).and_then(|row| row.get_mut(self.column as usize))\n    }\n\n    fn set_current_row_height(&mut self, height: u16) {\n        if let Some(current) = self.row_heights.get_mut(self.row as usize) {\n            *current = height;\n        }\n    }\n\n    fn current_row_height(&self) -> u16 {\n        *self.row_heights.get(self.row as usize).unwrap_or(&1)\n    }\n\n    fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {\n        self.column = column;\n        self.row = row;\n        Ok(())\n    }\n\n    fn move_to_row(&mut self, row: u16) -> io::Result<()> {\n        self.row = row;\n        self.set_current_row_height(1);\n        Ok(())\n    }\n\n    fn move_to_column(&mut self, column: u16) -> io::Result<()> {\n        self.column = column;\n        Ok(())\n    }\n\n    fn move_down(&mut self, amount: u16) -> io::Result<()> {\n        self.row += amount;\n        Ok(())\n    }\n\n    fn move_right(&mut self, amount: u16) -> io::Result<()> {\n        self.column += amount;\n        Ok(())\n    }\n\n    fn move_left(&mut self, amount: u16) -> io::Result<()> {\n        self.column = self.column.saturating_sub(amount);\n        Ok(())\n    }\n\n    fn move_to_next_line(&mut self) -> io::Result<()> {\n        let amount = self.current_row_height();\n        self.row += amount;\n        self.column = 0;\n        self.set_current_row_height(1);\n        Ok(())\n    }\n\n    fn print_text(&mut self, content: &str, style: &TextStyle) -> io::Result<()> {\n        let style = style.merged(&TextStyle::default().colors(self.colors));\n        for c in content.chars() {\n            let Some(cell) = self.current_cell_mut() else {\n                continue;\n            };\n            cell.character = c;\n            cell.style = style;\n            self.column += style.size as u16;\n        }\n        let height = self.current_row_height().max(style.size as u16);\n        self.set_current_row_height(height);\n        Ok(())\n    }\n\n    fn clear_screen(&mut self) -> io::Result<()> {\n        for row in &mut self.rows {\n            for cell in row {\n                cell.character = ' ';\n            }\n        }\n        self.background_color = self.colors.background;\n        Ok(())\n    }\n\n    fn set_colors(&mut self, colors: crate::markdown::text_style::Colors) -> io::Result<()> {\n        self.colors = colors;\n        Ok(())\n    }\n\n    fn set_background_color(&mut self, color: Color) -> io::Result<()> {\n        self.colors.background = Some(color);\n        Ok(())\n    }\n\n    fn flush(&mut self) -> io::Result<()> {\n        Ok(())\n    }\n\n    fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {\n        match &self.image_behavior {\n            ImageBehavior::Store => {\n                let key = (self.row, self.column);\n                let image = PrintedImage { image: image.clone(), width_columns: options.columns };\n                self.images.insert(key, image);\n            }\n            ImageBehavior::PrintAscii => {\n                let image = image.to_ascii();\n                let image_printer = AsciiPrinter;\n                image_printer.print(&image, options, self)?\n            }\n        };\n        Ok(())\n    }\n}\n\nimpl fmt::Debug for VirtualTerminal {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"VirtualTerminal\")\n            .field(\"row\", &self.row)\n            .field(\"column\", &self.column)\n            .field(\"colors\", &self.colors)\n            .field(\"background_color\", &self.background_color)\n            .field(\"images\", &self.images)\n            .field(\"row_heights\", &self.row_heights)\n            .field(\"image_behavior\", &self.image_behavior)\n            .finish()\n    }\n}\n\nimpl TerminalIo for VirtualTerminal {\n    fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {\n        use TerminalCommand::*;\n        match command {\n            BeginUpdate | EndUpdate => (),\n            MoveTo { column, row } => self.move_to(*column, *row)?,\n            MoveToRow(row) => self.move_to_row(*row)?,\n            MoveToColumn(column) => self.move_to_column(*column)?,\n            MoveDown(amount) => self.move_down(*amount)?,\n            MoveRight(amount) => self.move_right(*amount)?,\n            MoveLeft(amount) => self.move_left(*amount)?,\n            MoveToNextLine => self.move_to_next_line()?,\n            PrintText { content, style } => self.print_text(content, style)?,\n            ClearScreen => self.clear_screen()?,\n            SetColors(colors) => self.set_colors(*colors)?,\n            SetBackgroundColor(color) => self.set_background_color(*color)?,\n            SetCursorBoundaries { .. } => (),\n            Flush => self.flush()?,\n            PrintImage { image, options } => self.print_image(image, options)?,\n        };\n        Ok(())\n    }\n\n    fn cursor_row(&self) -> u16 {\n        self.row\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) enum ImageBehavior {\n    #[default]\n    Store,\n    PrintAscii,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq)]\npub(crate) struct StyledChar {\n    pub(crate) character: char,\n    pub(crate) style: TextStyle,\n}\n\nimpl StyledChar {\n    #[cfg(test)]\n    pub(crate) fn new(character: char, style: TextStyle) -> Self {\n        Self { character, style }\n    }\n}\n\nimpl From<char> for StyledChar {\n    fn from(character: char) -> Self {\n        Self { character, style: Default::default() }\n    }\n}\n\nimpl Default for StyledChar {\n    fn default() -> Self {\n        Self { character: ' ', style: Default::default() }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    trait TerminalGridExt {\n        fn assert_contents(&self, lines: &[&str]);\n    }\n\n    impl TerminalGridExt for TerminalGrid {\n        fn assert_contents(&self, lines: &[&str]) {\n            assert_eq!(self.rows.len(), lines.len());\n            for (line, expected) in self.rows.iter().zip(lines) {\n                let line: String = line.iter().map(|c| c.character).collect();\n                assert_eq!(line, *expected);\n            }\n        }\n    }\n\n    #[test]\n    fn text() {\n        let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };\n        let mut term = VirtualTerminal::new(dimensions, Default::default());\n        for c in \"abc\".chars() {\n            term.print_text(&c.to_string(), &Default::default()).expect(\"print failed\");\n        }\n        term.move_to_next_line().unwrap();\n        term.print_text(\"A\", &Default::default()).expect(\"print failed\");\n        let grid = term.into_contents();\n        grid.assert_contents(&[\"abc\", \"A  \"]);\n    }\n\n    #[test]\n    fn movement() {\n        let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };\n        let mut term = VirtualTerminal::new(dimensions, Default::default());\n        term.print_text(\"A\", &Default::default()).unwrap();\n        term.move_down(1).unwrap();\n        term.print_text(\"B\", &Default::default()).unwrap();\n        term.move_to(2, 0).unwrap();\n        term.print_text(\"C\", &Default::default()).unwrap();\n        term.move_to_row(1).unwrap();\n        term.move_to_column(2).unwrap();\n        term.print_text(\"D\", &Default::default()).unwrap();\n\n        let grid = term.into_contents();\n        grid.assert_contents(&[\"A C\", \" BD\"]);\n    }\n\n    #[test]\n    fn iterator() {\n        let row = &[\n            StyledChar { character: ' ', style: TextStyle::default() },\n            StyledChar { character: 'A', style: TextStyle::default() },\n            StyledChar { character: 'B', style: TextStyle::default().bold() },\n            StyledChar { character: 'C', style: TextStyle::default().bold() },\n            StyledChar { character: 'D', style: TextStyle::default() },\n        ];\n        let texts: Vec<_> = TerminalRowIterator::new(row).collect();\n        assert_eq!(texts, &[Text::from(\" A\"), Text::new(\"BC\", TextStyle::default().bold()), Text::from(\"D\")]);\n    }\n}\n"
  },
  {
    "path": "src/theme/clean.rs",
    "content": "use super::{\n    AuthorPositioning, FooterTemplate, Margin,\n    raw::{self, RawColor},\n};\nuse crate::{\n    markdown::text_style::{Color, Colors, TextStyle, UndefinedPaletteColorError},\n    resource::Resources,\n    terminal::image::{Image, printer::RegisterImageError},\n};\nuse std::collections::BTreeMap;\n\nconst DEFAULT_CODE_HIGHLIGHT_THEME: &str = \"base16-eighties.dark\";\nconst DEFAULT_BLOCK_QUOTE_PREFIX: &str = \"▍ \";\nconst DEFAULT_PROGRESS_BAR_CHAR: char = '█';\nconst DEFAULT_FOOTER_HEIGHT: u16 = 3;\nconst DEFAULT_TYPST_HORIZONTAL_MARGIN: u16 = 5;\nconst DEFAULT_TYPST_VERTICAL_MARGIN: u16 = 7;\nconst DEFAULT_MERMAID_THEME: &str = \"default\";\nconst DEFAULT_MERMAID_BACKGROUND: &str = \"transparent\";\nconst DEFAULT_D2_THEME: u32 = 0;\nconst DEFAULT_PTY_CURSOR_SYMBOL: char = '█';\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct ThemeOptions {\n    pub(crate) font_size_supported: bool,\n}\n\nimpl ThemeOptions {\n    fn adjust_font_size(&self, font_size: Option<u8>) -> u8 {\n        if !self.font_size_supported { 1 } else { font_size.unwrap_or(1).clamp(1, 7) }\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct PresentationTheme {\n    pub(crate) slide_title: SlideTitleStyle,\n    pub(crate) code: CodeBlockStyle,\n    pub(crate) execution_output: ExecutionOutputBlockStyle,\n    pub(crate) pty_output: PtyOutputBlockStyle,\n    pub(crate) inline_code: ModifierStyle,\n    pub(crate) bold: ModifierStyle,\n    pub(crate) italics: ModifierStyle,\n    pub(crate) table: Option<Alignment>,\n    pub(crate) block_quote: BlockQuoteStyle,\n    pub(crate) alert: AlertStyle,\n    pub(crate) default_style: DefaultStyle,\n    pub(crate) column_layout: ColumnLayoutStyle,\n    pub(crate) headings: HeadingStyles,\n    pub(crate) intro_slide: IntroSlideStyle,\n    pub(crate) footer: FooterStyle,\n    pub(crate) typst: TypstStyle,\n    pub(crate) mermaid: MermaidStyle,\n    pub(crate) d2: D2Style,\n    pub(crate) modals: ModalStyle,\n    pub(crate) layout_grid: LayoutGridStyle,\n    pub(crate) palette: ColorPalette,\n}\n\nimpl PresentationTheme {\n    pub(crate) fn new(\n        raw: &raw::PresentationTheme,\n        resources: &Resources,\n        options: &ThemeOptions,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::PresentationTheme {\n            slide_title,\n            code,\n            execution_output,\n            pty_output,\n            inline_code,\n            bold,\n            italics,\n            table,\n            block_quote,\n            alert,\n            default_style,\n            column_layout,\n            headings,\n            intro_slide,\n            footer,\n            typst,\n            mermaid,\n            d2,\n            modals,\n            layout_grid,\n            palette,\n            extends: _,\n        } = raw;\n\n        let palette = ColorPalette::try_from(palette)?;\n        let default_style = DefaultStyle::new(default_style, &palette)?;\n        Ok(Self {\n            slide_title: SlideTitleStyle::new(slide_title, &palette, options)?,\n            code: CodeBlockStyle::new(code),\n            execution_output: ExecutionOutputBlockStyle::new(execution_output, &palette)?,\n            pty_output: PtyOutputBlockStyle::new(pty_output, &palette)?,\n            inline_code: ModifierStyle::new(inline_code, &palette)?,\n            bold: ModifierStyle::new(bold, &palette)?,\n            italics: ModifierStyle::new(italics, &palette)?,\n            table: table.clone().map(Into::into),\n            block_quote: BlockQuoteStyle::new(block_quote, &palette)?,\n            alert: AlertStyle::new(alert, &palette)?,\n            default_style: default_style.clone(),\n            column_layout: ColumnLayoutStyle::new(column_layout),\n            headings: HeadingStyles::new(headings, &palette, options)?,\n            intro_slide: IntroSlideStyle::new(intro_slide, &palette, options)?,\n            footer: FooterStyle::new(&footer.clone().unwrap_or_default(), &palette, resources)?,\n            typst: TypstStyle::new(typst, &palette)?,\n            mermaid: MermaidStyle::new(mermaid),\n            d2: D2Style::new(d2),\n            modals: ModalStyle::new(modals, &default_style, &palette)?,\n            layout_grid: LayoutGridStyle::new(layout_grid, &default_style, &palette)?,\n            palette,\n        })\n    }\n\n    pub(crate) fn alignment(&self, element: &ElementType) -> Alignment {\n        use ElementType::*;\n\n        let alignment = match element {\n            SlideTitle => self.slide_title.alignment,\n            Heading1 => self.headings.h1.alignment,\n            Heading2 => self.headings.h2.alignment,\n            Heading3 => self.headings.h3.alignment,\n            Heading4 => self.headings.h4.alignment,\n            Heading5 => self.headings.h5.alignment,\n            Heading6 => self.headings.h6.alignment,\n            Paragraph | List => Some(self.default_style.alignment),\n            PresentationTitle => self.intro_slide.title.alignment,\n            PresentationSubTitle => self.intro_slide.subtitle.alignment,\n            PresentationEvent => self.intro_slide.event.alignment,\n            PresentationLocation => self.intro_slide.location.alignment,\n            PresentationDate => self.intro_slide.date.alignment,\n            PresentationAuthor => self.intro_slide.author.alignment,\n            Table => self.table,\n            BlockQuote => self.block_quote.alignment,\n        };\n        alignment.unwrap_or(self.default_style.alignment)\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum ProcessingThemeError {\n    #[error(transparent)]\n    Palette(#[from] UndefinedPaletteColorError),\n\n    #[error(\"palette cannot contain other palette colors\")]\n    PaletteColorInPalette,\n\n    #[error(\"invalid footer image: {0}\")]\n    FooterImage(RegisterImageError),\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct SlideTitleStyle {\n    pub(crate) alignment: Option<Alignment>,\n    pub(crate) separator: bool,\n    pub(crate) padding_top: u8,\n    pub(crate) padding_bottom: u8,\n    pub(crate) style: TextStyle,\n    pub(crate) prefix: String,\n}\n\nimpl SlideTitleStyle {\n    fn new(\n        raw: &raw::SlideTitleStyle,\n        palette: &ColorPalette,\n        options: &ThemeOptions,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::SlideTitleStyle {\n            alignment,\n            separator,\n            padding_top,\n            padding_bottom,\n            colors,\n            bold,\n            italics,\n            underlined,\n            font_size,\n            prefix,\n        } = raw;\n        let colors = colors.resolve(palette)?;\n        let mut style = TextStyle::colored(colors).size(options.adjust_font_size(*font_size));\n        if bold.unwrap_or_default() {\n            style = style.bold();\n        }\n        if italics.unwrap_or_default() {\n            style = style.italics();\n        }\n        if underlined.unwrap_or_default() {\n            style = style.underlined();\n        }\n        Ok(Self {\n            alignment: alignment.clone().map(Into::into),\n            separator: *separator,\n            padding_top: padding_top.unwrap_or_default(),\n            padding_bottom: padding_bottom.unwrap_or_default(),\n            style,\n            prefix: prefix.clone().unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct HeadingStyles {\n    pub(crate) h1: HeadingStyle,\n    pub(crate) h2: HeadingStyle,\n    pub(crate) h3: HeadingStyle,\n    pub(crate) h4: HeadingStyle,\n    pub(crate) h5: HeadingStyle,\n    pub(crate) h6: HeadingStyle,\n}\n\nimpl HeadingStyles {\n    fn new(\n        raw: &raw::HeadingStyles,\n        palette: &ColorPalette,\n        options: &ThemeOptions,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::HeadingStyles { h1, h2, h3, h4, h5, h6 } = raw;\n        Ok(Self {\n            h1: HeadingStyle::new(h1, palette, options)?,\n            h2: HeadingStyle::new(h2, palette, options)?,\n            h3: HeadingStyle::new(h3, palette, options)?,\n            h4: HeadingStyle::new(h4, palette, options)?,\n            h5: HeadingStyle::new(h5, palette, options)?,\n            h6: HeadingStyle::new(h6, palette, options)?,\n        })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct HeadingStyle {\n    pub(crate) alignment: Option<Alignment>,\n    pub(crate) prefix: Option<String>,\n    pub(crate) style: TextStyle,\n}\n\nimpl HeadingStyle {\n    fn new(\n        raw: &raw::HeadingStyle,\n        palette: &ColorPalette,\n        options: &ThemeOptions,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::HeadingStyle { alignment, prefix, colors, font_size, bold, underlined, italics } = raw;\n        let alignment = alignment.clone().map(Into::into);\n        let mut style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size));\n        if bold.unwrap_or_default() {\n            style = style.bold();\n        }\n        if underlined.unwrap_or_default() {\n            style = style.underlined();\n        }\n        if italics.unwrap_or_default() {\n            style = style.italics();\n        }\n        Ok(Self { alignment, prefix: prefix.clone(), style })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct BlockQuoteStyle {\n    pub(crate) alignment: Option<Alignment>,\n    pub(crate) prefix: String,\n    pub(crate) base_style: TextStyle,\n    pub(crate) prefix_style: TextStyle,\n}\n\nimpl BlockQuoteStyle {\n    fn new(raw: &raw::BlockQuoteStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::BlockQuoteStyle { alignment, prefix, colors } = raw;\n        let alignment = alignment.clone().map(Into::into);\n        let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string();\n        let base_style = TextStyle::colored(colors.base.resolve(palette)?);\n        let mut prefix_style = TextStyle::colored(colors.base.resolve(palette)?);\n        if let Some(color) = &colors.prefix {\n            prefix_style.colors.foreground = color.resolve(palette)?;\n        }\n        Ok(Self { alignment, prefix, base_style, prefix_style })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct AlertStyle {\n    pub(crate) alignment: Alignment,\n    pub(crate) base_style: TextStyle,\n    pub(crate) prefix: String,\n    pub(crate) styles: AlertTypeStyles,\n}\n\nimpl AlertStyle {\n    fn new(raw: &raw::AlertStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::AlertStyle { alignment, base_colors, prefix, styles } = raw;\n        let alignment = alignment.clone().unwrap_or_default().into();\n        let base_style = TextStyle::colored(base_colors.resolve(palette)?);\n        let prefix = prefix.as_deref().unwrap_or(DEFAULT_BLOCK_QUOTE_PREFIX).to_string();\n        let styles = AlertTypeStyles::new(styles, base_style, palette)?;\n        Ok(Self { alignment, base_style, prefix, styles })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct AlertTypeStyles {\n    pub(crate) note: AlertTypeStyle,\n    pub(crate) tip: AlertTypeStyle,\n    pub(crate) important: AlertTypeStyle,\n    pub(crate) warning: AlertTypeStyle,\n    pub(crate) caution: AlertTypeStyle,\n}\n\nimpl AlertTypeStyles {\n    fn new(\n        raw: &raw::AlertTypeStyles,\n        base_style: TextStyle,\n        palette: &ColorPalette,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::AlertTypeStyles { note, tip, important, warning, caution } = raw;\n        Ok(Self {\n            note: AlertTypeStyle::new(\n                note,\n                &AlertTypeDefaults { title: \"Note\", icon: \"󰋽\", color: Color::Blue },\n                base_style,\n                palette,\n            )?,\n            tip: AlertTypeStyle::new(\n                tip,\n                &AlertTypeDefaults { title: \"Tip\", icon: \"\", color: Color::Green },\n                base_style,\n                palette,\n            )?,\n            important: AlertTypeStyle::new(\n                important,\n                &AlertTypeDefaults { title: \"Important\", icon: \"\", color: Color::Cyan },\n                base_style,\n                palette,\n            )?,\n            warning: AlertTypeStyle::new(\n                warning,\n                &AlertTypeDefaults { title: \"Warning\", icon: \"\", color: Color::Yellow },\n                base_style,\n                palette,\n            )?,\n            caution: AlertTypeStyle::new(\n                caution,\n                &AlertTypeDefaults { title: \"Caution\", icon: \"󰳦\", color: Color::Red },\n                base_style,\n                palette,\n            )?,\n        })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct AlertTypeStyle {\n    pub(crate) style: TextStyle,\n    pub(crate) title: String,\n    pub(crate) icon: String,\n}\n\nimpl AlertTypeStyle {\n    fn new(\n        raw: &raw::AlertTypeStyle,\n        defaults: &AlertTypeDefaults,\n        base_style: TextStyle,\n        palette: &ColorPalette,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::AlertTypeStyle { color, title, icon, .. } = raw;\n        let color = color.as_ref().map(|c| c.resolve(palette)).transpose()?.flatten().unwrap_or(defaults.color);\n        let style = base_style.fg_color(color);\n        let title = title.as_deref().unwrap_or(defaults.title).to_string();\n        let icon = icon.as_deref().unwrap_or(defaults.icon).to_string();\n        Ok(Self { style, title, icon })\n    }\n}\n\nstruct AlertTypeDefaults {\n    title: &'static str,\n    icon: &'static str,\n    color: Color,\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct IntroSlideStyle {\n    pub(crate) title: IntroSlideTitleStyle,\n    pub(crate) subtitle: IntroSlideLabelStyle,\n    pub(crate) event: IntroSlideLabelStyle,\n    pub(crate) location: IntroSlideLabelStyle,\n    pub(crate) date: IntroSlideLabelStyle,\n    pub(crate) author: AuthorStyle,\n    pub(crate) footer: bool,\n}\n\nimpl IntroSlideStyle {\n    fn new(\n        raw: &raw::IntroSlideStyle,\n        palette: &ColorPalette,\n        options: &ThemeOptions,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::IntroSlideStyle { title, subtitle, event, location, date, author, footer } = raw;\n        Ok(Self {\n            title: IntroSlideTitleStyle::new(title, palette, options)?,\n            subtitle: IntroSlideLabelStyle::new(subtitle, palette)?,\n            event: IntroSlideLabelStyle::new(event, palette)?,\n            location: IntroSlideLabelStyle::new(location, palette)?,\n            date: IntroSlideLabelStyle::new(date, palette)?,\n            author: AuthorStyle::new(author, palette)?,\n            footer: footer.unwrap_or(false),\n        })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct IntroSlideLabelStyle {\n    pub(crate) alignment: Option<Alignment>,\n    pub(crate) style: TextStyle,\n}\n\nimpl IntroSlideLabelStyle {\n    fn new(raw: &raw::BasicStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::BasicStyle { alignment, colors } = raw;\n        let style = TextStyle::colored(colors.resolve(palette)?);\n        Ok(Self { alignment: alignment.clone().map(Into::into), style })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct IntroSlideTitleStyle {\n    pub(crate) alignment: Option<Alignment>,\n    pub(crate) style: TextStyle,\n}\n\nimpl IntroSlideTitleStyle {\n    fn new(\n        raw: &raw::IntroSlideTitleStyle,\n        palette: &ColorPalette,\n        options: &ThemeOptions,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::IntroSlideTitleStyle { alignment, colors, font_size } = raw;\n        let style = TextStyle::colored(colors.resolve(palette)?).size(options.adjust_font_size(*font_size));\n        Ok(Self { alignment: alignment.clone().map(Into::into), style })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct AuthorStyle {\n    pub(crate) alignment: Option<Alignment>,\n    pub(crate) style: TextStyle,\n    pub(crate) positioning: AuthorPositioning,\n}\n\nimpl AuthorStyle {\n    fn new(raw: &raw::AuthorStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::AuthorStyle { alignment, colors, positioning } = raw;\n        let style = TextStyle::colored(colors.resolve(palette)?);\n        Ok(Self { alignment: alignment.clone().map(Into::into), style, positioning: positioning.clone() })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct DefaultStyle {\n    pub(crate) margin: Margin,\n    pub(crate) style: TextStyle,\n    pub(crate) alignment: Alignment,\n}\n\nimpl DefaultStyle {\n    fn new(raw: &raw::DefaultStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::DefaultStyle { margin, colors, alignment } = raw;\n        let margin = margin.unwrap_or_default();\n        let style = TextStyle::colored(colors.resolve(palette)?);\n        let alignment = alignment.clone().unwrap_or_default().into();\n        Ok(Self { margin, style, alignment })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct ColumnLayoutStyle {\n    pub(crate) margin: Margin,\n}\n\nimpl ColumnLayoutStyle {\n    fn new(raw: &raw::ColumnLayoutStyle) -> Self {\n        let raw::ColumnLayoutStyle { margin } = raw;\n        let margin = margin.unwrap_or_default();\n        Self { margin }\n    }\n}\n\n#[derive(Copy, Clone, Debug, PartialEq)]\npub(crate) enum Alignment {\n    Left { margin: Margin },\n    Right { margin: Margin },\n    Center { minimum_margin: Margin, minimum_size: u16 },\n}\n\nimpl Alignment {\n    pub(crate) fn adjust_size(&self, size: u16) -> u16 {\n        match self {\n            Self::Left { .. } | Self::Right { .. } => size,\n            Self::Center { minimum_size, .. } => size.max(*minimum_size),\n        }\n    }\n}\n\nimpl From<raw::Alignment> for Alignment {\n    fn from(alignment: raw::Alignment) -> Self {\n        match alignment {\n            raw::Alignment::Left { margin } => Self::Left { margin },\n            raw::Alignment::Right { margin } => Self::Right { margin },\n            raw::Alignment::Center { minimum_margin, minimum_size } => Self::Center { minimum_margin, minimum_size },\n        }\n    }\n}\n\nimpl Default for Alignment {\n    fn default() -> Self {\n        Self::Left { margin: Margin::Fixed(0) }\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) enum FooterStyle {\n    Template {\n        left: Option<FooterContent>,\n        center: Option<FooterContent>,\n        right: Option<FooterContent>,\n        style: TextStyle,\n        height: u16,\n    },\n    ProgressBar {\n        character: char,\n        style: TextStyle,\n    },\n    #[default]\n    Empty,\n}\n\nimpl FooterStyle {\n    fn new(\n        raw: &raw::FooterStyle,\n        palette: &ColorPalette,\n        resources: &Resources,\n    ) -> Result<Self, ProcessingThemeError> {\n        match raw {\n            raw::FooterStyle::Template { left, center, right, colors, height } => {\n                let left = left.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;\n                let center = center.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;\n                let right = right.as_ref().map(|t| FooterContent::new(t, resources)).transpose()?;\n                let style = TextStyle::colored(colors.resolve(palette)?);\n                let height = height.unwrap_or(DEFAULT_FOOTER_HEIGHT);\n                Ok(Self::Template { left, center, right, style, height })\n            }\n            raw::FooterStyle::ProgressBar { character, colors } => {\n                let character = character.unwrap_or(DEFAULT_PROGRESS_BAR_CHAR);\n                let style = TextStyle::colored(colors.resolve(palette)?);\n                Ok(Self::ProgressBar { character, style })\n            }\n            raw::FooterStyle::Empty => Ok(Self::Empty),\n        }\n    }\n\n    pub(crate) fn height(&self) -> u16 {\n        match self {\n            Self::Template { height, .. } => *height,\n            _ => DEFAULT_FOOTER_HEIGHT,\n        }\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) enum FooterContent {\n    Template(FooterTemplate),\n    Image(Image),\n}\n\nimpl FooterContent {\n    fn new(raw: &raw::FooterContent, resources: &Resources) -> Result<Self, ProcessingThemeError> {\n        match raw {\n            raw::FooterContent::Template(template) => Ok(Self::Template(template.clone())),\n            raw::FooterContent::Image { path } => {\n                let image = resources.theme_image(path).map_err(ProcessingThemeError::FooterImage)?;\n                Ok(Self::Image(image))\n            }\n        }\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct CodeBlockStyle {\n    pub(crate) alignment: Alignment,\n    pub(crate) padding: PaddingRect,\n    pub(crate) theme_name: String,\n    pub(crate) background: bool,\n    pub(crate) line_numbers: bool,\n}\n\nimpl CodeBlockStyle {\n    fn new(raw: &raw::CodeBlockStyle) -> Self {\n        let raw::CodeBlockStyle { alignment, padding, theme_name, background, line_numbers } = raw;\n        let padding = PaddingRect {\n            horizontal: padding.horizontal.unwrap_or_default(),\n            vertical: padding.vertical.unwrap_or_default(),\n        };\n        Self {\n            alignment: alignment.clone().unwrap_or_default().into(),\n            padding,\n            theme_name: theme_name.as_deref().unwrap_or(DEFAULT_CODE_HIGHLIGHT_THEME).to_string(),\n            background: background.unwrap_or(true),\n            line_numbers: line_numbers.unwrap_or_default(),\n        }\n    }\n}\n\n/// Vertical/horizontal padding.\n#[derive(Clone, Copy, Debug, Default)]\npub(crate) struct PaddingRect {\n    /// The number of columns to use as horizontal padding.\n    pub(crate) horizontal: u8,\n\n    /// The number of rows to use as vertical padding.\n    pub(crate) vertical: u8,\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct ExecutionOutputBlockStyle {\n    pub(crate) style: TextStyle,\n    pub(crate) status: ExecutionStatusBlockStyle,\n    pub(crate) padding: PaddingRect,\n}\n\nimpl ExecutionOutputBlockStyle {\n    fn new(raw: &raw::ExecutionOutputBlockStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::ExecutionOutputBlockStyle { colors, status, padding } = raw;\n        let colors = colors.resolve(palette)?;\n        let style = TextStyle::colored(colors);\n        let padding = PaddingRect {\n            horizontal: padding.horizontal.unwrap_or_default(),\n            vertical: padding.vertical.unwrap_or_default(),\n        };\n        Ok(Self { style, status: ExecutionStatusBlockStyle::new(status, palette)?, padding })\n    }\n}\n\n#[derive(Copy, Clone, Debug, Default)]\npub(crate) struct ExecutionStatusBlockStyle {\n    pub(crate) running_style: TextStyle,\n    pub(crate) success_style: TextStyle,\n    pub(crate) failure_style: TextStyle,\n    pub(crate) not_started_style: TextStyle,\n}\n\nimpl ExecutionStatusBlockStyle {\n    fn new(raw: &raw::ExecutionStatusBlockStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::ExecutionStatusBlockStyle { running, success, failure, not_started } = raw;\n        let running_style = TextStyle::colored(running.resolve(palette)?);\n        let success_style = TextStyle::colored(success.resolve(palette)?);\n        let failure_style = TextStyle::colored(failure.resolve(palette)?);\n        let not_started_style = TextStyle::colored(not_started.resolve(palette)?);\n        Ok(Self { running_style, success_style, failure_style, not_started_style })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct PtyOutputBlockStyle {\n    pub(crate) style: TextStyle,\n    pub(crate) standby: PtyStandbyStyle,\n    pub(crate) cursor: PtyCursorStyle,\n}\n\nimpl PtyOutputBlockStyle {\n    fn new(raw: &raw::PtyOutputBlockStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::PtyOutputBlockStyle { colors, standby, cursor } = raw;\n        let colors = colors.resolve(palette)?;\n        let style = TextStyle::colored(colors);\n        let standby = match standby {\n            Some(raw::PtyStandbyStyle::LargePlay) => PtyStandbyStyle::LargePlay,\n            None => Default::default(),\n        };\n        let cursor = PtyCursorStyle::new(cursor, palette)?;\n        Ok(Self { style, standby, cursor })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct PtyCursorStyle {\n    pub(crate) symbol: String,\n    pub(crate) highlight_style: TextStyle,\n}\n\nimpl PtyCursorStyle {\n    fn new(raw: &raw::PtyCursorStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::PtyCursorStyle { symbol, highlight_colors } = raw;\n        let symbol = symbol.unwrap_or(DEFAULT_PTY_CURSOR_SYMBOL).to_string();\n        let highlight_style = TextStyle::colored(highlight_colors.resolve(palette)?);\n        Ok(Self { symbol, highlight_style })\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) enum PtyStandbyStyle {\n    #[default]\n    LargePlay,\n}\n\nimpl PtyStandbyStyle {\n    pub(crate) fn as_lines(&self) -> &[&str] {\n        match self {\n            Self::LargePlay => &[\n                \"⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⣤⣶⣶⣶⣶⣤⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀\",\n                \"⠀⠀⠀⠀⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀\",\n                \"⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀\",\n                \"⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀\",\n                \"⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⡇⠈⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀\",\n                \"⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⡄\",\n                \"⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⣉⣿⣿⣿⣿⣿⣿⣿⡇\",\n                \"⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⠃\",\n                \"⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀\",\n                \"⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀\",\n                \"⠀⠀⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⠀\",\n                \"⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀\",\n                \"⠀⠀⠀⠀⠀⠀⠀⠈⠙⠛⠛⠿⠿⠿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀⠀⠀\",\n            ],\n        }\n    }\n}\n\n#[derive(Clone, Debug, Default)]\npub(crate) struct ModifierStyle {\n    pub(crate) style: TextStyle,\n}\n\nimpl ModifierStyle {\n    fn new(raw: &raw::ModifierStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::ModifierStyle { colors } = raw;\n        let style = TextStyle::colored(colors.resolve(palette)?);\n        Ok(Self { style })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) enum ElementType {\n    SlideTitle,\n    Heading1,\n    Heading2,\n    Heading3,\n    Heading4,\n    Heading5,\n    Heading6,\n    Paragraph,\n    PresentationTitle,\n    PresentationSubTitle,\n    PresentationEvent,\n    PresentationLocation,\n    PresentationDate,\n    PresentationAuthor,\n    Table,\n    BlockQuote,\n    List,\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct TypstStyle {\n    pub(crate) horizontal_margin: u16,\n    pub(crate) vertical_margin: u16,\n    pub(crate) style: TextStyle,\n}\n\nimpl TypstStyle {\n    fn new(raw: &raw::TypstStyle, palette: &ColorPalette) -> Result<Self, ProcessingThemeError> {\n        let raw::TypstStyle { horizontal_margin, vertical_margin, colors } = raw;\n        let horizontal_margin = horizontal_margin.unwrap_or(DEFAULT_TYPST_HORIZONTAL_MARGIN);\n        let vertical_margin = vertical_margin.unwrap_or(DEFAULT_TYPST_VERTICAL_MARGIN);\n        let style = TextStyle::colored(colors.resolve(palette)?);\n        Ok(Self { horizontal_margin, vertical_margin, style })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct MermaidStyle {\n    pub(crate) theme: String,\n    pub(crate) background: String,\n}\n\nimpl MermaidStyle {\n    fn new(raw: &raw::MermaidStyle) -> Self {\n        let raw::MermaidStyle { theme, background } = raw;\n        let theme = theme.as_deref().unwrap_or(DEFAULT_MERMAID_THEME).to_string();\n        let background = background.as_deref().unwrap_or(DEFAULT_MERMAID_BACKGROUND).to_string();\n        Self { theme, background }\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct D2Style {\n    pub(crate) theme: String,\n}\n\nimpl D2Style {\n    fn new(raw: &raw::D2Style) -> Self {\n        let raw::D2Style { theme } = raw;\n        let theme = theme.unwrap_or(DEFAULT_D2_THEME).to_string();\n        Self { theme }\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct ModalStyle {\n    pub(crate) style: TextStyle,\n    pub(crate) selection_style: TextStyle,\n}\n\nimpl ModalStyle {\n    fn new(\n        raw: &raw::ModalStyle,\n        default_style: &DefaultStyle,\n        palette: &ColorPalette,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::ModalStyle { colors, selection_colors } = raw;\n        let mut style = default_style.style;\n        style.merge(&TextStyle::colored(colors.resolve(palette)?));\n\n        let mut selection_style = style.bold();\n        selection_style.merge(&TextStyle::colored(selection_colors.resolve(palette)?));\n        Ok(Self { style, selection_style })\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct LayoutGridStyle {\n    pub(crate) style: TextStyle,\n}\n\nimpl LayoutGridStyle {\n    fn new(\n        raw: &raw::LayoutGridStyle,\n        default_style: &DefaultStyle,\n        palette: &ColorPalette,\n    ) -> Result<Self, ProcessingThemeError> {\n        let raw::LayoutGridStyle { color } = raw;\n        let mut style = default_style.style;\n        if let Some(color) = color {\n            style.colors.foreground = color.resolve(palette)?;\n        }\n        Ok(Self { style })\n    }\n}\n\n/// The color palette.\n#[derive(Clone, Debug, Default)]\npub(crate) struct ColorPalette {\n    pub(crate) colors: BTreeMap<String, Color>,\n    pub(crate) classes: BTreeMap<String, Colors>,\n}\n\nimpl TryFrom<&raw::ColorPalette> for ColorPalette {\n    type Error = ProcessingThemeError;\n\n    fn try_from(palette: &raw::ColorPalette) -> Result<Self, Self::Error> {\n        let mut colors = BTreeMap::new();\n        let mut classes = BTreeMap::new();\n\n        for (name, color) in &palette.colors {\n            let raw::RawColor::Color(color) = color else {\n                return Err(ProcessingThemeError::PaletteColorInPalette);\n            };\n            colors.insert(name.clone(), *color);\n        }\n\n        let resolve_local = |color: &RawColor| match color {\n            raw::RawColor::Color(c) => Ok(*c),\n            raw::RawColor::Palette(name) => colors\n                .get(name)\n                .copied()\n                .ok_or_else(|| ProcessingThemeError::Palette(UndefinedPaletteColorError(name.clone()))),\n            _ => Err(ProcessingThemeError::PaletteColorInPalette),\n        };\n        for (name, colors) in &palette.classes {\n            let foreground = colors.foreground.as_ref().map(resolve_local).transpose()?;\n            let background = colors.background.as_ref().map(resolve_local).transpose()?;\n            classes.insert(name.clone(), Colors { foreground, background });\n        }\n        Ok(Self { colors, classes })\n    }\n}\n"
  },
  {
    "path": "src/theme/mod.rs",
    "content": "pub(crate) mod clean;\npub(crate) mod raw;\npub(crate) mod registry;\n\npub(crate) use clean::*;\npub(crate) use raw::{AuthorPositioning, FooterTemplate, FooterTemplateChunk, Margin};\n"
  },
  {
    "path": "src/theme/raw.rs",
    "content": "use super::registry::LoadThemeError;\nuse crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError};\nuse hex::{FromHex, FromHexError};\nuse serde::{Deserialize, Serialize, de::Visitor};\nuse std::{\n    collections::BTreeMap,\n    fmt, fs,\n    path::{Path, PathBuf},\n    str::FromStr,\n};\n\npub(crate) type RawColors = Colors<RawColor>;\n\n/// A presentation theme.\n#[derive(Default, Clone, Debug, Deserialize, Serialize)]\n#[serde(deny_unknown_fields)]\npub struct PresentationTheme {\n    /// The theme this theme extends from.\n    #[serde(default)]\n    pub(crate) extends: Option<String>,\n\n    /// The style for a slide's title.\n    #[serde(default)]\n    pub(crate) slide_title: SlideTitleStyle,\n\n    /// The style for a block of code.\n    #[serde(default)]\n    pub(crate) code: CodeBlockStyle,\n\n    /// The style for the execution output of a piece of code.\n    #[serde(default)]\n    pub(crate) execution_output: ExecutionOutputBlockStyle,\n\n    /// The style for the pty output of a piece of code.\n    #[serde(default)]\n    pub(crate) pty_output: PtyOutputBlockStyle,\n\n    /// The style for inline code.\n    #[serde(default)]\n    pub(crate) inline_code: ModifierStyle,\n\n    /// The style for bold text.\n    #[serde(default)]\n    pub(crate) bold: ModifierStyle,\n\n    /// The style for italics.\n    #[serde(default, alias = \"italic\")]\n    pub(crate) italics: ModifierStyle,\n\n    /// The style for a table.\n    #[serde(default)]\n    pub(crate) table: Option<Alignment>,\n\n    /// The style for a block quote.\n    #[serde(default)]\n    pub(crate) block_quote: BlockQuoteStyle,\n\n    /// The style for an alert.\n    #[serde(default)]\n    pub(crate) alert: AlertStyle,\n\n    /// The default style.\n    #[serde(rename = \"default\", default)]\n    pub(crate) default_style: DefaultStyle,\n\n    /// The style for column layouts.\n    #[serde(default)]\n    pub(crate) column_layout: ColumnLayoutStyle,\n\n    //// The style of all headings.\n    #[serde(default)]\n    pub(crate) headings: HeadingStyles,\n\n    /// The style of the introduction slide.\n    #[serde(default)]\n    pub(crate) intro_slide: IntroSlideStyle,\n\n    /// The style of the presentation footer.\n    #[serde(default)]\n    pub(crate) footer: Option<FooterStyle>,\n\n    /// The style for typst auto-rendered code blocks.\n    #[serde(default)]\n    pub(crate) typst: TypstStyle,\n\n    /// The style for mermaid auto-rendered code blocks.\n    #[serde(default)]\n    pub(crate) mermaid: MermaidStyle,\n\n    /// The style for d2 auto-rendered code blocks.\n    #[serde(default)]\n    pub(crate) d2: D2Style,\n\n    /// The style for modals.\n    #[serde(default)]\n    pub(crate) modals: ModalStyle,\n\n    /// The style for layouts.\n    #[serde(default)]\n    pub(crate) layout_grid: LayoutGridStyle,\n\n    /// The color palette.\n    #[serde(default)]\n    pub(crate) palette: ColorPalette,\n}\n\nimpl PresentationTheme {\n    /// Construct a presentation from a path.\n    pub(crate) fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, LoadThemeError> {\n        let contents = fs::read_to_string(&path).map_err(|e| LoadThemeError::Reading(path.as_ref().into(), e))?;\n        let theme = serde_yaml::from_str(&contents)\n            .map_err(|e| LoadThemeError::Corrupted(path.as_ref().display().to_string(), e.into()))?;\n        Ok(theme)\n    }\n}\n\n/// The style of a slide title.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct SlideTitleStyle {\n    /// The alignment.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// Whether to use a separator line.\n    #[serde(default)]\n    pub(crate) separator: bool,\n\n    /// The padding that should be added before the text.\n    #[serde(default)]\n    pub(crate) padding_top: Option<u8>,\n\n    /// The padding that should be added after the text.\n    #[serde(default)]\n    pub(crate) padding_bottom: Option<u8>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The prefix to be added to the slide title.\n    #[serde(default)]\n    pub(crate) prefix: Option<String>,\n\n    /// Whether to use bold font for slide titles.\n    #[serde(default)]\n    pub(crate) bold: Option<bool>,\n\n    /// Whether to use italics font for slide titles.\n    #[serde(default, alias = \"italic\")]\n    pub(crate) italics: Option<bool>,\n\n    /// Whether to use underlined font for slide titles.\n    #[serde(default)]\n    pub(crate) underlined: Option<bool>,\n\n    /// The font size to be used if the terminal supports it.\n    #[serde(default)]\n    pub(crate) font_size: Option<u8>,\n}\n\n/// The style for all headings.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct HeadingStyles {\n    /// H1 style.\n    #[serde(default)]\n    pub(crate) h1: HeadingStyle,\n\n    /// H2 style.\n    #[serde(default)]\n    pub(crate) h2: HeadingStyle,\n\n    /// H3 style.\n    #[serde(default)]\n    pub(crate) h3: HeadingStyle,\n\n    /// H4 style.\n    #[serde(default)]\n    pub(crate) h4: HeadingStyle,\n\n    /// H5 style.\n    #[serde(default)]\n    pub(crate) h5: HeadingStyle,\n\n    /// H6 style.\n    #[serde(default)]\n    pub(crate) h6: HeadingStyle,\n}\n\n/// The style for a heading.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct HeadingStyle {\n    /// The alignment.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// The prefix to be added to this heading.\n    ///\n    /// This allows adding text like \"->\" to every heading.\n    #[serde(default)]\n    pub(crate) prefix: Option<String>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The font size to be used if the terminal supports it.\n    #[serde(default)]\n    pub(crate) font_size: Option<u8>,\n\n    /// Whether the heading is bold.\n    #[serde(default)]\n    pub(crate) bold: Option<bool>,\n\n    /// Whether the heading is underlined.\n    #[serde(default)]\n    pub(crate) underlined: Option<bool>,\n\n    /// Whether the heading uses italics.\n    #[serde(default, alias = \"italic\")]\n    pub(crate) italics: Option<bool>,\n}\n\n/// The style of a block quote.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct BlockQuoteStyle {\n    /// The alignment.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// The prefix to be added to this block quote.\n    ///\n    /// This allows adding something like a vertical bar before the text.\n    #[serde(default)]\n    pub(crate) prefix: Option<String>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: BlockQuoteColors,\n}\n\n/// The colors of a block quote.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct BlockQuoteColors {\n    /// The foreground/background colors.\n    #[serde(flatten)]\n    pub(crate) base: RawColors,\n\n    /// The color of the vertical bar that prefixes each line in the quote.\n    #[serde(default)]\n    pub(crate) prefix: Option<RawColor>,\n}\n\n/// The style of an alert.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct AlertStyle {\n    /// The alignment.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// The base colors.\n    #[serde(default)]\n    pub(crate) base_colors: RawColors,\n\n    /// The prefix to be added to this block quote.\n    ///\n    /// This allows adding something like a vertical bar before the text.\n    #[serde(default)]\n    pub(crate) prefix: Option<String>,\n\n    /// The style for each alert type.\n    #[serde(default)]\n    pub(crate) styles: AlertTypeStyles,\n}\n\n/// The style for each alert type.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct AlertTypeStyles {\n    /// The style for note alert types.\n    #[serde(default)]\n    pub(crate) note: AlertTypeStyle,\n\n    /// The style for tip alert types.\n    #[serde(default)]\n    pub(crate) tip: AlertTypeStyle,\n\n    /// The style for important alert types.\n    #[serde(default)]\n    pub(crate) important: AlertTypeStyle,\n\n    /// The style for warning alert types.\n    #[serde(default)]\n    pub(crate) warning: AlertTypeStyle,\n\n    /// The style for caution alert types.\n    #[serde(default)]\n    pub(crate) caution: AlertTypeStyle,\n}\n\n/// The style for an alert type.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct AlertTypeStyle {\n    /// The color to be used.\n    #[serde(default)]\n    pub(crate) color: Option<RawColor>,\n\n    /// The title to be used.\n    #[serde(default)]\n    pub(crate) title: Option<String>,\n\n    /// The icon to be used.\n    #[serde(default)]\n    pub(crate) icon: Option<String>,\n}\n\n/// The style for the presentation introduction slide.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct IntroSlideStyle {\n    /// The style of the title line.\n    #[serde(default)]\n    pub(crate) title: IntroSlideTitleStyle,\n\n    /// The style of the subtitle line.\n    #[serde(default)]\n    pub(crate) subtitle: BasicStyle,\n\n    /// The style of the event line.\n    #[serde(default)]\n    pub(crate) event: BasicStyle,\n\n    /// The style of the location line.\n    #[serde(default)]\n    pub(crate) location: BasicStyle,\n\n    /// The style of the date line.\n    #[serde(default)]\n    pub(crate) date: BasicStyle,\n\n    /// The style of the author line.\n    #[serde(default)]\n    pub(crate) author: AuthorStyle,\n\n    /// Whether we want a footer in the intro slide.\n    #[serde(default)]\n    pub(crate) footer: Option<bool>,\n}\n\n/// A simple style.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct DefaultStyle {\n    /// The margin on the left/right of the screen.\n    #[serde(default, with = \"serde_yaml::with::singleton_map\")]\n    pub(crate) margin: Option<Margin>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The alignment for all elements.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n}\n\n/// The column layout style.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct ColumnLayoutStyle {\n    /// The margin in between two columns.\n    #[serde(default, with = \"serde_yaml::with::singleton_map\")]\n    pub(crate) margin: Option<Margin>,\n}\n\n/// A simple style.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct BasicStyle {\n    /// The alignment.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n}\n\n/// The intro slide title's style.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct IntroSlideTitleStyle {\n    /// The alignment.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The font size to be used if the terminal supports it.\n    #[serde(default)]\n    pub(crate) font_size: Option<u8>,\n}\n\n/// Text alignment.\n///\n/// This allows anchoring presentation elements to the left, center, or right of the screen.\n#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(tag = \"alignment\", rename_all = \"snake_case\")]\npub(crate) enum Alignment {\n    /// Left alignment.\n    Left {\n        /// The margin before any text.\n        #[serde(default)]\n        margin: Margin,\n    },\n\n    /// Right alignment.\n    Right {\n        /// The margin after any text.\n        #[serde(default)]\n        margin: Margin,\n    },\n\n    /// Center alignment.\n    Center {\n        /// The minimum margin expected.\n        #[serde(default)]\n        minimum_margin: Margin,\n\n        /// The minimum size of this element, in columns.\n        #[serde(default)]\n        minimum_size: u16,\n    },\n}\n\nimpl Default for Alignment {\n    fn default() -> Self {\n        Self::Left { margin: Margin::Fixed(0) }\n    }\n}\n\n/// The style for the author line in the presentation intro slide.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct AuthorStyle {\n    /// The alignment.\n    #[serde(flatten, default)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The positioning of the author's name.\n    #[serde(default)]\n    pub(crate) positioning: AuthorPositioning,\n}\n\n/// The style of the footer that's shown in every slide.\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(tag = \"style\", rename_all = \"snake_case\")]\npub(crate) enum FooterStyle {\n    /// Use a template to generate the footer.\n    Template {\n        /// The content to be put on the left.\n        left: Option<FooterContent>,\n\n        /// The content to be put on the center.\n        center: Option<FooterContent>,\n\n        /// The content to be put on the right.\n        right: Option<FooterContent>,\n\n        /// The colors to be used.\n        #[serde(default)]\n        colors: RawColors,\n\n        /// The height of the footer area.\n        height: Option<u16>,\n    },\n\n    /// Use a progress bar.\n    ProgressBar {\n        /// The character that will be used for the progress bar.\n        character: Option<char>,\n\n        /// The colors to be used.\n        #[serde(default)]\n        colors: RawColors,\n    },\n\n    /// No footer.\n    Empty,\n}\n\nimpl Default for FooterStyle {\n    fn default() -> Self {\n        Self::Template { left: None, center: None, right: None, colors: RawColors::default(), height: None }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]\npub(crate) enum FooterTemplateChunk {\n    Literal(String),\n    OpenBrace,\n    ClosedBrace,\n    CurrentSlide,\n    TotalSlides,\n    Author,\n    Title,\n    SubTitle,\n    Event,\n    Location,\n    Date,\n}\n\n#[derive(Clone, Debug, Serialize)]\n#[serde(untagged)]\npub(crate) enum FooterContent {\n    Template(FooterTemplate),\n    Image {\n        #[serde(rename = \"image\")]\n        path: PathBuf,\n    },\n}\n\nstruct FooterContentVisitor;\n\nimpl<'de> Visitor<'de> for FooterContentVisitor {\n    type Value = FooterContent;\n\n    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {\n        formatter.write_str(\"a valid footer\")\n    }\n\n    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>\n    where\n        E: serde::de::Error,\n    {\n        let template = FooterTemplate::from_str(v).map_err(|e| E::custom(e.to_string()))?;\n        Ok(FooterContent::Template(template))\n    }\n\n    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>\n    where\n        A: serde::de::MapAccess<'de>,\n    {\n        let Some((key, value)): Option<(String, PathBuf)> = map.next_entry()? else {\n            return Err(serde::de::Error::custom(\"invalid footer\"));\n        };\n\n        match key.as_str() {\n            \"image\" => Ok(FooterContent::Image { path: value }),\n            _ => Err(serde::de::Error::invalid_value(serde::de::Unexpected::Str(&key), &self)),\n        }\n    }\n}\n\nimpl<'de> Deserialize<'de> for FooterContent {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>,\n    {\n        deserializer.deserialize_any(FooterContentVisitor)\n    }\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct FooterTemplate(pub(crate) Vec<FooterTemplateChunk>);\n\ncrate::utils::impl_deserialize_from_str!(FooterTemplate);\ncrate::utils::impl_serialize_from_display!(FooterTemplate);\n\nimpl FromStr for FooterTemplate {\n    type Err = ParseFooterTemplateError;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let mut chunks = Vec::new();\n        let mut chunk_start = 0;\n        let mut in_variable = false;\n        let mut iter = s.char_indices().peekable();\n        while let Some((index, c)) = iter.next() {\n            if c == '{' {\n                if in_variable {\n                    return Err(ParseFooterTemplateError::NestedOpenBrace);\n                }\n                let double_brace = iter.peek() == Some(&(index + 1, '{'));\n                if double_brace {\n                    iter.next();\n                    if chunk_start != index {\n                        chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));\n                    }\n                    chunks.push(FooterTemplateChunk::OpenBrace);\n                    chunk_start = index + 2;\n                } else {\n                    in_variable = true;\n                    if chunk_start != index {\n                        chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));\n                    }\n                    chunk_start = index + 1;\n                }\n            } else if c == '}' {\n                if !in_variable {\n                    let double_brace = iter.peek() == Some(&(index + 1, '}'));\n                    if double_brace {\n                        iter.next();\n                        chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));\n                        chunks.push(FooterTemplateChunk::ClosedBrace);\n                        in_variable = false;\n                        chunk_start = index + 2;\n                        continue;\n                    }\n                    return Err(ParseFooterTemplateError::ClosedBraceWithoutOpen);\n                }\n                let variable = &s[chunk_start..index];\n                let chunk = match variable {\n                    \"current_slide\" => FooterTemplateChunk::CurrentSlide,\n                    \"total_slides\" => FooterTemplateChunk::TotalSlides,\n                    \"author\" => FooterTemplateChunk::Author,\n                    \"title\" => FooterTemplateChunk::Title,\n                    \"sub_title\" => FooterTemplateChunk::SubTitle,\n                    \"event\" => FooterTemplateChunk::Event,\n                    \"location\" => FooterTemplateChunk::Location,\n                    \"date\" => FooterTemplateChunk::Date,\n                    _ => return Err(ParseFooterTemplateError::UnsupportedVariable(variable.to_string())),\n                };\n                chunks.push(chunk);\n                in_variable = false;\n                chunk_start = index + 1;\n            }\n        }\n        if in_variable {\n            return Err(ParseFooterTemplateError::TrailingBrace);\n        } else if chunk_start != s.len() {\n            chunks.push(FooterTemplateChunk::Literal(s[chunk_start..].to_string()));\n        }\n        Ok(Self(chunks))\n    }\n}\n\nimpl fmt::Display for FooterTemplate {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        use FooterTemplateChunk::*;\n        for c in &self.0 {\n            match c {\n                Literal(l) => write!(f, \"{l}\"),\n                OpenBrace => write!(f, \"{{{{\"),\n                ClosedBrace => write!(f, \"}}}}\"),\n                CurrentSlide => write!(f, \"{{current_slide}}\"),\n                TotalSlides => write!(f, \"{{total_slides}}\"),\n                Author => write!(f, \"{{author}}\"),\n                Title => write!(f, \"{{title}}\"),\n                SubTitle => write!(f, \"{{sub_title}}\"),\n                Event => write!(f, \"{{event}}\"),\n                Location => write!(f, \"{{location}}\"),\n                Date => write!(f, \"{{date}}\"),\n            }?;\n        }\n        Ok(())\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum ParseFooterTemplateError {\n    #[error(\"found '{{' while already inside '{{' scope\")]\n    NestedOpenBrace,\n\n    #[error(\"open '{{' was not closed\")]\n    TrailingBrace,\n\n    #[error(\"found '}}' but no '{{' was found\")]\n    ClosedBraceWithoutOpen,\n\n    #[error(\"unsupported variable: '{0}'\")]\n    UnsupportedVariable(String),\n}\n\n/// The style for a piece of code.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct CodeBlockStyle {\n    /// The alignment.\n    #[serde(flatten)]\n    pub(crate) alignment: Option<Alignment>,\n\n    /// The padding.\n    #[serde(default)]\n    pub(crate) padding: PaddingRect,\n\n    /// The syntect theme name to use.\n    #[serde(default)]\n    pub(crate) theme_name: Option<String>,\n\n    /// Whether to use the theme's background color.\n    pub(crate) background: Option<bool>,\n\n    /// Whether to show line numbers in all code blocks.\n    #[serde(default)]\n    pub(crate) line_numbers: Option<bool>,\n}\n\n/// The style for the output of a code execution block.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct ExecutionOutputBlockStyle {\n    /// The colors to be used for the output pane.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The colors to be used for the text that represents the status of the execution block.\n    #[serde(default)]\n    pub(crate) status: ExecutionStatusBlockStyle,\n\n    /// The padding.\n    #[serde(default)]\n    pub(crate) padding: PaddingRect,\n}\n\n/// The style for the output of a code execution block running in pty mode.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct PtyOutputBlockStyle {\n    /// The colors to be used for the output pane.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The style for the standby state.\n    #[serde(default)]\n    pub(crate) standby: Option<PtyStandbyStyle>,\n\n    #[serde(default)]\n    pub(crate) cursor: PtyCursorStyle,\n}\n\n/// The style for a PTY's cursor.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct PtyCursorStyle {\n    /// The symbol to use on the cursor.\n    #[serde(default)]\n    pub(crate) symbol: Option<char>,\n\n    /// The colors used when the cursor is on top of non empty cells.\n    #[serde(default)]\n    pub(crate) highlight_colors: RawColors,\n}\n\n/// The style for the standby state.\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub(crate) enum PtyStandbyStyle {\n    /// Show a play icon.\n    LargePlay,\n}\n\n/// The style for the status of a code execution block.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct ExecutionStatusBlockStyle {\n    /// The colors for the \"running\" status.\n    #[serde(default)]\n    pub(crate) running: RawColors,\n\n    /// The colors for the \"finished\" status.\n    #[serde(default)]\n    pub(crate) success: RawColors,\n\n    /// The colors for the \"finished with error\" status.\n    #[serde(default)]\n    pub(crate) failure: RawColors,\n\n    /// The colors for the \"not started\" status.\n    #[serde(default)]\n    pub(crate) not_started: RawColors,\n}\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct ModifierStyle {\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n}\n\n/// Vertical/horizontal padding.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct PaddingRect {\n    /// The number of columns to use as horizontal padding.\n    #[serde(default)]\n    pub(crate) horizontal: Option<u8>,\n\n    /// The number of rows to use as vertical padding.\n    #[serde(default)]\n    pub(crate) vertical: Option<u8>,\n}\n\n/// A margin.\n#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\npub(crate) enum Margin {\n    /// A fixed number of characters.\n    Fixed(u16),\n\n    /// A percent of the screen size.\n    Percent(u16),\n}\n\nimpl Margin {\n    pub(crate) fn as_characters(&self, screen_size: u16) -> u16 {\n        match *self {\n            Self::Fixed(value) => value,\n            Self::Percent(percent) => {\n                let ratio = percent as f64 / 100.0;\n                (screen_size as f64 * ratio).ceil() as u16\n            }\n        }\n    }\n\n    pub(crate) fn is_empty(&self) -> bool {\n        matches!(self, Self::Fixed(0) | Self::Percent(0))\n    }\n}\n\nimpl Default for Margin {\n    fn default() -> Self {\n        Self::Fixed(0)\n    }\n}\n\n/// Where to position the author's name in the intro slide.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub(crate) enum AuthorPositioning {\n    /// Right below the title.\n    BelowTitle,\n\n    /// At the bottom of the page.\n    #[default]\n    PageBottom,\n}\n\n/// Typst styles.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct TypstStyle {\n    /// The horizontal margin on the generated images.\n    pub(crate) horizontal_margin: Option<u16>,\n\n    /// The vertical margin on the generated images.\n    pub(crate) vertical_margin: Option<u16>,\n\n    /// The colors to be used.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n}\n\n/// Mermaid styles.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct MermaidStyle {\n    /// The mermaidjs theme to use.\n    pub(crate) theme: Option<String>,\n\n    /// The background color to use.\n    pub(crate) background: Option<String>,\n}\n\n/// D2 styles.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct D2Style {\n    /// The d2 theme id to use.\n    pub(crate) theme: Option<u32>,\n}\n\n/// Modals style.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct ModalStyle {\n    /// The default colors to use for everything in the modal.\n    #[serde(default)]\n    pub(crate) colors: RawColors,\n\n    /// The colors to use for selected lines.\n    #[serde(default)]\n    pub(crate) selection_colors: RawColors,\n}\n\n/// Layout grid style.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct LayoutGridStyle {\n    /// The color for layout grids.\n    #[serde(default)]\n    pub(crate) color: Option<RawColor>,\n}\n\n/// The color palette.\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub(crate) struct ColorPalette {\n    #[serde(default)]\n    pub(crate) colors: BTreeMap<String, RawColor>,\n\n    #[serde(default)]\n    pub(crate) classes: BTreeMap<String, RawColors>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub(crate) enum RawColor {\n    Color(Color),\n    Palette(String),\n    ForegroundClass(String),\n    BackgroundClass(String),\n}\n\ncrate::utils::impl_deserialize_from_str!(RawColor);\ncrate::utils::impl_serialize_from_display!(RawColor);\n\nimpl RawColor {\n    fn new_palette(name: &str) -> Result<Self, ParseColorError> {\n        if name.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(name.into())) }\n    }\n\n    pub(crate) fn resolve(\n        &self,\n        palette: &crate::theme::clean::ColorPalette,\n    ) -> Result<Option<Color>, UndefinedPaletteColorError> {\n        let color = match self {\n            Self::Color(c) => Some(*c),\n            Self::Palette(name) => {\n                Some(palette.colors.get(name).copied().ok_or(UndefinedPaletteColorError(name.clone()))?)\n            }\n            Self::ForegroundClass(name) => {\n                palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.foreground\n            }\n            Self::BackgroundClass(name) => {\n                palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.background\n            }\n        };\n        Ok(color)\n    }\n}\n\nimpl From<Color> for RawColor {\n    fn from(color: Color) -> Self {\n        Self::Color(color)\n    }\n}\n\nimpl FromStr for RawColor {\n    type Err = ParseColorError;\n\n    fn from_str(input: &str) -> Result<Self, Self::Err> {\n        let output = match input {\n            \"black\" => Color::Black.into(),\n            \"white\" => Color::White.into(),\n            \"grey\" => Color::Grey.into(),\n            \"dark_grey\" => Color::DarkGrey.into(),\n            \"red\" => Color::Red.into(),\n            \"dark_red\" => Color::DarkRed.into(),\n            \"green\" => Color::Green.into(),\n            \"dark_green\" => Color::DarkGreen.into(),\n            \"blue\" => Color::Blue.into(),\n            \"dark_blue\" => Color::DarkBlue.into(),\n            \"yellow\" => Color::Yellow.into(),\n            \"dark_yellow\" => Color::DarkYellow.into(),\n            \"magenta\" => Color::Magenta.into(),\n            \"dark_magenta\" => Color::DarkMagenta.into(),\n            \"cyan\" => Color::Cyan.into(),\n            \"dark_cyan\" => Color::DarkCyan.into(),\n            other if other.starts_with(\"palette:\") => Self::new_palette(other.trim_start_matches(\"palette:\"))?,\n            other if other.starts_with(\"p:\") => Self::new_palette(other.trim_start_matches(\"p:\"))?,\n            // Fallback to hex-encoded rgb\n            _ => {\n                let hex = match input.len() {\n                    6 => input.to_string(),\n                    3 => input.chars().flat_map(|c| [c, c]).collect::<String>(),\n                    len => return Err(ParseColorError::InvalidHexLength(len)),\n                };\n                let values = <[u8; 3]>::from_hex(hex)?;\n                Color::Rgb { r: values[0], g: values[1], b: values[2] }.into()\n            }\n        };\n        Ok(output)\n    }\n}\n\nimpl fmt::Display for RawColor {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        use Color::*;\n        match self {\n            Self::Color(Rgb { r, g, b }) => write!(f, \"{}\", hex::encode([*r, *g, *b])),\n            Self::Color(Black) => write!(f, \"black\"),\n            Self::Color(White) => write!(f, \"white\"),\n            Self::Color(Grey) => write!(f, \"grey\"),\n            Self::Color(DarkGrey) => write!(f, \"dark_grey\"),\n            Self::Color(Red) => write!(f, \"red\"),\n            Self::Color(DarkRed) => write!(f, \"dark_red\"),\n            Self::Color(Green) => write!(f, \"green\"),\n            Self::Color(DarkGreen) => write!(f, \"dark_green\"),\n            Self::Color(Blue) => write!(f, \"blue\"),\n            Self::Color(DarkBlue) => write!(f, \"dark_blue\"),\n            Self::Color(Yellow) => write!(f, \"yellow\"),\n            Self::Color(DarkYellow) => write!(f, \"dark_yellow\"),\n            Self::Color(Magenta) => write!(f, \"magenta\"),\n            Self::Color(DarkMagenta) => write!(f, \"dark_magenta\"),\n            Self::Color(Cyan) => write!(f, \"cyan\"),\n            Self::Color(DarkCyan) => write!(f, \"dark_cyan\"),\n            Self::Palette(name) => write!(f, \"palette:{name}\"),\n            Self::ForegroundClass(_) => Err(fmt::Error),\n            Self::BackgroundClass(_) => Err(fmt::Error),\n        }\n    }\n}\n\n#[derive(thiserror::Error, Debug)]\npub(crate) enum ParseColorError {\n    #[error(\"invalid hex color: {0}\")]\n    Hex(#[from] FromHexError),\n\n    #[error(\"hex color should only be 3 or 6 long, got hex string of length {0}\")]\n    InvalidHexLength(usize),\n\n    #[error(\"palette color name is empty\")]\n    PaletteColorEmpty,\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use rstest::rstest;\n\n    #[test]\n    fn parse_all_footer_template_variables() {\n        use FooterTemplateChunk::*;\n        let raw = \"hi {current_slide} {total_slides} {author} {title} {sub_title} {event} {location} {event}\";\n        let t: FooterTemplate = raw.parse().expect(\"invalid input\");\n        let expected = vec![\n            Literal(\"hi \".into()),\n            CurrentSlide,\n            Literal(\" \".into()),\n            TotalSlides,\n            Literal(\" \".into()),\n            Author,\n            Literal(\" \".into()),\n            Title,\n            Literal(\" \".into()),\n            SubTitle,\n            Literal(\" \".into()),\n            Event,\n            Literal(\" \".into()),\n            Location,\n            Literal(\" \".into()),\n            Event,\n        ];\n        assert_eq!(t.0, expected);\n        assert_eq!(t.to_string(), raw);\n    }\n\n    #[test]\n    fn parse_double_braces() {\n        use FooterTemplateChunk::*;\n        let raw = \"hi {{beep}} {{author}} {{{{}}}}\";\n        let t: FooterTemplate = raw.parse().expect(\"invalid input\");\n        let merged: String =\n            t.0.into_iter()\n                .map(|l| match l {\n                    Literal(s) => s,\n                    OpenBrace => \"{\".to_string(),\n                    ClosedBrace => \"}\".to_string(),\n                    _ => panic!(\"not a literal\"),\n                })\n                .collect();\n        assert_eq!(merged, \"hi {beep} {author} {{}}\");\n    }\n\n    #[rstest]\n    #[case::trailing(\"{author\")]\n    #[case::close_without_open2(\"author}\")]\n    fn invalid_footer_templates(#[case] input: &str) {\n        FooterTemplate::from_str(input).expect_err(\"parse succeeded\");\n    }\n\n    #[test]\n    fn color_serde() {\n        let color: RawColor = \"beef42\".parse().unwrap();\n        assert_eq!(color.to_string(), \"beef42\");\n\n        let short_color: RawColor = \"ded\".parse().unwrap();\n        assert_eq!(short_color.to_string(), \"ddeedd\");\n    }\n\n    #[rstest]\n    #[case::empty1(\"p:\")]\n    #[case::empty2(\"palette:\")]\n    fn invalid_palette_color_names(#[case] input: &str) {\n        RawColor::from_str(input).expect_err(\"not an error\");\n    }\n\n    #[rstest]\n    #[case::short(\"p:hi\", \"hi\")]\n    #[case::long(\"palette:bye\", \"bye\")]\n    fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) {\n        let color = RawColor::from_str(input).expect(\"failed to parse\");\n        let RawColor::Palette(name) = color else { panic!(\"not a palette color\") };\n        assert_eq!(name, expected);\n    }\n}\n"
  },
  {
    "path": "src/theme/registry.rs",
    "content": "use super::raw::PresentationTheme;\nuse std::{\n    collections::BTreeMap,\n    fs, io,\n    path::{Path, PathBuf},\n};\n\ninclude!(concat!(env!(\"OUT_DIR\"), \"/themes.rs\"));\n\n#[derive(Default)]\npub struct PresentationThemeRegistry {\n    custom_themes: BTreeMap<String, PresentationTheme>,\n}\n\nimpl PresentationThemeRegistry {\n    /// Loads a theme from its name.\n    pub fn load_by_name(&self, name: &str) -> Option<PresentationTheme> {\n        match THEMES.get(name) {\n            Some(contents) => {\n                // This is going to be caught by the test down here.\n                let theme = serde_yaml::from_slice(contents).expect(\"corrupted theme\");\n                Some(theme)\n            }\n            None => self.custom_themes.get(name).cloned(),\n        }\n    }\n\n    /// Register all the themes in the given directory.\n    pub fn register_from_directory<P: AsRef<Path>>(&mut self, path: P) -> Result<(), LoadThemeError> {\n        let handle = match fs::read_dir(&path) {\n            Ok(handle) => handle,\n            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),\n            Err(e) => return Err(e.into()),\n        };\n        let mut dependencies = BTreeMap::new();\n        for entry in handle {\n            let entry = entry?;\n            let Some(file_name) = entry.file_name().to_str().map(ToOwned::to_owned) else {\n                continue;\n            };\n            if file_name.ends_with(\".yaml\") {\n                let theme_name = file_name.trim_end_matches(\".yaml\");\n                if THEMES.contains_key(theme_name) {\n                    return Err(LoadThemeError::Duplicate(theme_name.into()));\n                }\n                let theme = PresentationTheme::from_path(entry.path())?;\n                let base = theme.extends.clone();\n                self.custom_themes.insert(theme_name.into(), theme);\n                dependencies.insert(theme_name.to_string(), base);\n            }\n        }\n        let mut graph = ThemeGraph::new(dependencies);\n        for theme_name in graph.dependents.keys() {\n            let theme_name = theme_name.as_str();\n            if !THEMES.contains_key(theme_name) && !self.custom_themes.contains_key(theme_name) {\n                return Err(LoadThemeError::ExtendedThemeNotFound(theme_name.into()));\n            }\n        }\n\n        while let Some(theme_name) = graph.pop() {\n            self.extend_theme(&theme_name)?;\n        }\n        if !graph.dependents.is_empty() {\n            return Err(LoadThemeError::ExtensionLoop(graph.dependents.into_keys().collect()));\n        }\n        Ok(())\n    }\n\n    fn extend_theme(&mut self, theme_name: &str) -> Result<(), LoadThemeError> {\n        let Some(base_name) = self.custom_themes.get(theme_name).expect(\"theme not found\").extends.clone() else {\n            return Ok(());\n        };\n        let Some(base_theme) = self.load_by_name(&base_name) else {\n            return Err(LoadThemeError::ExtendedThemeNotFound(base_name.clone()));\n        };\n        let theme = self.custom_themes.get_mut(theme_name).expect(\"theme not found\");\n        *theme = merge_struct::merge(&base_theme, theme)\n            .map_err(|e| LoadThemeError::Corrupted(base_name.to_string(), e.into()))?;\n        Ok(())\n    }\n\n    /// Get all the registered theme names.\n    pub fn theme_names(&self) -> Vec<String> {\n        let builtin_themes = THEMES.keys().map(|name| name.to_string());\n        let themes = self.custom_themes.keys().cloned().chain(builtin_themes).collect();\n        themes\n    }\n}\n\nstruct ThemeGraph {\n    dependents: BTreeMap<String, Vec<String>>,\n    ready: Vec<String>,\n}\n\nimpl ThemeGraph {\n    fn new<I>(dependencies: I) -> Self\n    where\n        I: IntoIterator<Item = (String, Option<String>)>,\n    {\n        let mut dependents: BTreeMap<_, Vec<_>> = BTreeMap::new();\n        let mut ready = Vec::new();\n        for (name, extends) in dependencies {\n            dependents.entry(name.clone()).or_default();\n            match extends {\n                // If we extend from a non built in theme, make ourselves their dependent\n                Some(base) if !THEMES.contains_key(base.as_str()) => {\n                    dependents.entry(base).or_default().push(name);\n                }\n                // Otherwise this theme is ready to be processed\n                _ => ready.push(name),\n            }\n        }\n        Self { dependents, ready }\n    }\n\n    fn pop(&mut self) -> Option<String> {\n        let theme = self.ready.pop()?;\n        if let Some(dependents) = self.dependents.remove(&theme) {\n            self.ready.extend(dependents);\n        }\n        Some(theme)\n    }\n}\n\n/// An error loading a presentation theme.\n#[derive(thiserror::Error, Debug)]\npub enum LoadThemeError {\n    #[error(transparent)]\n    Io(#[from] io::Error),\n\n    #[error(\"failed to read custom theme {0:?}: {1}\")]\n    Reading(PathBuf, io::Error),\n\n    #[error(\"theme '{0}' is corrupted: {1}\")]\n    Corrupted(String, Box<dyn std::error::Error>),\n\n    #[error(\"duplicate custom theme '{0}'\")]\n    Duplicate(String),\n\n    #[error(\"extended theme does not exist: {0}\")]\n    ExtendedThemeNotFound(String),\n\n    #[error(\"theme has an extension loop involving: {0:?}\")]\n    ExtensionLoop(Vec<String>),\n}\n\n#[cfg(test)]\nmod test {\n    use crate::resource::Resources;\n\n    use super::*;\n    use tempfile::{TempDir, tempdir};\n\n    fn write_theme(name: &str, theme: PresentationTheme, directory: &TempDir) {\n        let theme = serde_yaml::to_string(&theme).unwrap();\n        let file_name = format!(\"{name}.yaml\");\n        fs::write(directory.path().join(file_name), theme).expect(\"writing theme\");\n    }\n\n    #[test]\n    fn validate_themes() {\n        let themes = PresentationThemeRegistry::default();\n        for theme_name in THEMES.keys() {\n            let Some(theme) = themes.load_by_name(theme_name).clone() else {\n                panic!(\"theme '{theme_name}' is corrupted\");\n            };\n\n            // Built-in themes can't use this because... I don't feel like supporting this now.\n            assert!(theme.extends.is_none(), \"theme '{theme_name}' uses extends\");\n\n            let merged = merge_struct::merge(&PresentationTheme::default(), &theme);\n            assert!(merged.is_ok(), \"theme '{theme_name}' can't be merged: {}\", merged.unwrap_err());\n\n            let resources = Resources::new(\"/tmp/foo\", \"/tmp/foo\", Default::default());\n            crate::theme::PresentationTheme::new(&theme, &resources, &Default::default()).expect(\"malformed theme\");\n        }\n    }\n\n    #[test]\n    fn load_custom() {\n        let directory = tempdir().expect(\"creating tempdir\");\n        write_theme(\n            \"potato\",\n            PresentationTheme { extends: Some(\"dark\".to_string()), ..Default::default() },\n            &directory,\n        );\n\n        let mut themes = PresentationThemeRegistry::default();\n        themes.register_from_directory(directory.path()).expect(\"loading themes\");\n        let mut theme = themes.load_by_name(\"potato\").expect(\"theme not found\");\n\n        // Since we extend the dark theme they must match after we remove the \"extends\" field.\n        let dark = themes.load_by_name(\"dark\");\n        theme.extends.take().expect(\"no extends\");\n        assert_eq!(serde_yaml::to_string(&theme).unwrap(), serde_yaml::to_string(&dark).unwrap());\n    }\n\n    #[test]\n    fn load_derive_chain() {\n        let directory = tempdir().expect(\"creating tempdir\");\n        write_theme(\"A\", PresentationTheme { extends: Some(\"dark\".to_string()), ..Default::default() }, &directory);\n        write_theme(\"B\", PresentationTheme { extends: Some(\"C\".to_string()), ..Default::default() }, &directory);\n        write_theme(\"C\", PresentationTheme { extends: Some(\"A\".to_string()), ..Default::default() }, &directory);\n        write_theme(\"D\", PresentationTheme::default(), &directory);\n\n        let mut themes = PresentationThemeRegistry::default();\n        themes.register_from_directory(directory.path()).expect(\"loading themes\");\n        themes.load_by_name(\"A\").expect(\"A not found\");\n        themes.load_by_name(\"B\").expect(\"B not found\");\n        themes.load_by_name(\"C\").expect(\"C not found\");\n        themes.load_by_name(\"D\").expect(\"D not found\");\n    }\n\n    #[test]\n    fn invalid_derives() {\n        let directory = tempdir().expect(\"creating tempdir\");\n        write_theme(\n            \"A\",\n            PresentationTheme { extends: Some(\"non-existent-theme\".to_string()), ..Default::default() },\n            &directory,\n        );\n\n        let mut themes = PresentationThemeRegistry::default();\n        themes.register_from_directory(directory.path()).expect_err(\"loading themes succeeded\");\n    }\n\n    #[test]\n    fn load_derive_chain_loop() {\n        let directory = tempdir().expect(\"creating tempdir\");\n        write_theme(\"A\", PresentationTheme { extends: Some(\"B\".to_string()), ..Default::default() }, &directory);\n        write_theme(\"B\", PresentationTheme { extends: Some(\"A\".to_string()), ..Default::default() }, &directory);\n\n        let mut themes = PresentationThemeRegistry::default();\n        let err = themes.register_from_directory(directory.path()).expect_err(\"loading themes succeeded\");\n        let LoadThemeError::ExtensionLoop(names) = err else { panic!(\"not an extension loop error\") };\n        assert_eq!(names, &[\"A\", \"B\"]);\n    }\n\n    #[test]\n    fn register_from_missing_directory() {\n        let mut themes = PresentationThemeRegistry::default();\n        let result = themes.register_from_directory(\"/tmp/presenterm/8ee2027983915ec78acc45027d874316\");\n        result.expect(\"loading failed\");\n    }\n}\n"
  },
  {
    "path": "src/third_party.rs",
    "content": "use crate::{\n    ImageRegistry,\n    config::{default_mermaid_scale, default_snippet_render_threads, default_typst_ppi},\n    markdown::{\n        elements::{Line, Percent, Text},\n        text_style::{Color, TextStyle},\n    },\n    render::{\n        operation::{\n            AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync,\n            RenderAsyncStartPolicy, RenderOperation,\n        },\n        properties::WindowSize,\n    },\n    terminal::image::{\n        Image,\n        printer::{ImageSpec, RegisterImageError},\n    },\n    theme::{Alignment, D2Style, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor},\n    tools::{ExecutionError, ThirdPartyTools},\n};\nuse std::{\n    collections::{HashMap, VecDeque},\n    fs, io, mem,\n    path::Path,\n    rc::Rc,\n    sync::{Arc, Condvar, Mutex},\n    thread,\n};\n\npub struct ThirdPartyConfigs {\n    pub typst_ppi: String,\n    pub mermaid_scale: String,\n    pub mermaid_puppeteer_file: Option<String>,\n    pub mermaid_config_file: Option<String>,\n    pub d2_scale: String,\n    pub threads: usize,\n}\n\npub struct ThirdPartyRender {\n    render_pool: RenderPool,\n}\n\nimpl ThirdPartyRender {\n    pub fn new(config: ThirdPartyConfigs, image_registry: ImageRegistry, root_dir: &Path) -> Self {\n        // typst complains about empty paths so we give it a \".\" if we don't have one.\n        let root_dir = match root_dir.to_string_lossy().to_string() {\n            path if path.is_empty() => \".\".into(),\n            path => path,\n        };\n        let render_pool = RenderPool::new(config, root_dir, image_registry);\n        Self { render_pool }\n    }\n\n    pub(crate) fn render(\n        &self,\n        request: ThirdPartyRenderRequest,\n        theme: &PresentationTheme,\n        width: Option<Percent>,\n    ) -> Result<RenderOperation, ThirdPartyRenderError> {\n        let result = self.render_pool.render(request);\n        let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, width));\n        Ok(RenderOperation::RenderAsync(operation))\n    }\n}\n\nimpl Default for ThirdPartyRender {\n    fn default() -> Self {\n        let config = ThirdPartyConfigs {\n            typst_ppi: default_typst_ppi().to_string(),\n            mermaid_scale: default_mermaid_scale().to_string(),\n            mermaid_puppeteer_file: None,\n            mermaid_config_file: None,\n            d2_scale: \"-1\".to_string(),\n            threads: default_snippet_render_threads(),\n        };\n        Self::new(config, Default::default(), Path::new(\".\"))\n    }\n}\n\n#[derive(Debug)]\npub(crate) enum ThirdPartyRenderRequest {\n    Typst(String, TypstStyle),\n    Latex(String, TypstStyle),\n    Mermaid(String, MermaidStyle),\n    D2(String, D2Style),\n}\n\n#[derive(Debug, Default)]\nenum RenderResult {\n    Success(Image),\n    Failure(String),\n    #[default]\n    Pending,\n}\n\nstruct RenderPoolState {\n    requests: VecDeque<(ThirdPartyRenderRequest, Arc<Mutex<RenderResult>>)>,\n    image_registry: ImageRegistry,\n    cache: HashMap<ImageSnippet, Image>,\n}\n\nstruct Shared {\n    config: ThirdPartyConfigs,\n    root_dir: String,\n    signal: Condvar,\n}\n\nstruct RenderPool {\n    state: Arc<Mutex<RenderPoolState>>,\n    shared: Arc<Shared>,\n}\n\nimpl RenderPool {\n    fn new(config: ThirdPartyConfigs, root_dir: String, image_registry: ImageRegistry) -> Self {\n        let threads = config.threads;\n        let shared = Shared { config, root_dir, signal: Default::default() };\n        let state = RenderPoolState { requests: Default::default(), image_registry, cache: Default::default() };\n\n        let this = Self { state: Arc::new(Mutex::new(state)), shared: Arc::new(shared) };\n        for _ in 0..threads {\n            let worker = Worker { state: this.state.clone(), shared: this.shared.clone() };\n            thread::spawn(move || worker.run());\n        }\n        this\n    }\n\n    fn render(&self, request: ThirdPartyRenderRequest) -> Arc<Mutex<RenderResult>> {\n        let result: Arc<Mutex<RenderResult>> = Default::default();\n        let mut state = self.state.lock().expect(\"lock poisoned\");\n        state.requests.push_back((request, result.clone()));\n        self.shared.signal.notify_one();\n        result\n    }\n}\n\nstruct Worker {\n    state: Arc<Mutex<RenderPoolState>>,\n    shared: Arc<Shared>,\n}\n\nimpl Worker {\n    fn run(self) {\n        loop {\n            let mut state = self.state.lock().unwrap();\n            let (request, result) = loop {\n                let Some((request, result)) = state.requests.pop_front() else {\n                    state = self.shared.signal.wait(state).unwrap();\n                    continue;\n                };\n                break (request, result);\n            };\n            drop(state);\n\n            self.render(request, result);\n        }\n    }\n\n    fn render(&self, request: ThirdPartyRenderRequest, result: Arc<Mutex<RenderResult>>) {\n        let output = match request {\n            ThirdPartyRenderRequest::Typst(input, style) => self.render_typst(input, &style),\n            ThirdPartyRenderRequest::Latex(input, style) => self.render_latex(input, &style),\n            ThirdPartyRenderRequest::Mermaid(input, style) => self.render_mermaid(input, &style),\n            ThirdPartyRenderRequest::D2(input, style) => self.render_d2(input, &style),\n        };\n        let mut result = result.lock().unwrap();\n        match output {\n            Ok(image) => *result = RenderResult::Success(image),\n            Err(error) => *result = RenderResult::Failure(error.to_string()),\n        };\n    }\n\n    pub(crate) fn render_typst(&self, input: String, style: &TypstStyle) -> Result<Image, ThirdPartyRenderError> {\n        let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Typst };\n        if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() {\n            return Ok(image);\n        }\n        self.do_render_typst(snippet, &input, style)\n    }\n\n    pub(crate) fn render_latex(&self, input: String, style: &TypstStyle) -> Result<Image, ThirdPartyRenderError> {\n        let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Latex };\n        if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() {\n            return Ok(image);\n        }\n        let output = ThirdPartyTools::pandoc(&[\"--from\", \"latex\", \"--to\", \"typst\"])\n            .stdin(input.as_bytes().into())\n            .run_and_capture_stdout()?;\n\n        let input = String::from_utf8_lossy(&output);\n        self.do_render_typst(snippet, &input, style)\n    }\n\n    pub(crate) fn render_mermaid(&self, input: String, style: &MermaidStyle) -> Result<Image, ThirdPartyRenderError> {\n        let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Mermaid };\n        if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() {\n            return Ok(image);\n        }\n        let workdir = tempfile::Builder::default().prefix(\".presenterm\").tempdir()?;\n        let output_path = workdir.path().join(\"output.png\");\n        let input_path = workdir.path().join(\"input.mmd\");\n        fs::write(&input_path, input)?;\n\n        let input_path = input_path.to_string_lossy();\n        let output_path_str = output_path.to_string_lossy();\n        let mut args = vec![\n            \"-i\",\n            &input_path,\n            \"-o\",\n            &output_path_str,\n            \"-s\",\n            &self.shared.config.mermaid_scale,\n            \"-t\",\n            &style.theme,\n            \"-b\",\n            &style.background,\n        ];\n        if let Some(path) = &self.shared.config.mermaid_puppeteer_file {\n            args.extend(&[\"-p\", path]);\n        }\n        if let Some(path) = &self.shared.config.mermaid_config_file {\n            args.extend(&[\"-c\", path]);\n        }\n\n        ThirdPartyTools::mermaid(&args).run()?;\n\n        self.load_image(snippet, &output_path)\n    }\n\n    pub(crate) fn render_d2(&self, input: String, style: &D2Style) -> Result<Image, ThirdPartyRenderError> {\n        let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::D2 };\n        let workdir = tempfile::Builder::default().prefix(\".presenterm\").tempdir()?;\n        let output_path = workdir.path().join(\"output.png\");\n        let input_path = workdir.path().join(\"input.d2\");\n        fs::write(&input_path, input)?;\n        ThirdPartyTools::d2(&[\n            &input_path.to_string_lossy(),\n            &output_path.to_string_lossy(),\n            \"--pad\",\n            \"0\",\n            \"--scale\",\n            &self.shared.config.d2_scale,\n            \"--theme\",\n            &style.theme,\n        ])\n        .run()?;\n\n        self.load_image(snippet, &output_path)\n    }\n\n    fn do_render_typst(\n        &self,\n        snippet: ImageSnippet,\n        input: &str,\n        style: &TypstStyle,\n    ) -> Result<Image, ThirdPartyRenderError> {\n        let workdir = tempfile::Builder::default().prefix(\".presenterm\").tempdir_in(&self.shared.root_dir)?;\n        let mut typst_input = Self::generate_page_header(style)?;\n        typst_input.push_str(input);\n\n        let input_path = workdir.path().join(\"input.typst\");\n        fs::write(&input_path, &typst_input)?;\n\n        let output_path = workdir.path().join(\"output.png\");\n        ThirdPartyTools::typst(&[\n            \"compile\",\n            \"--format\",\n            \"png\",\n            \"--root\",\n            &self.shared.root_dir,\n            \"--ppi\",\n            &self.shared.config.typst_ppi,\n            &input_path.to_string_lossy(),\n            &output_path.to_string_lossy(),\n        ])\n        .run()?;\n\n        self.load_image(snippet, &output_path)\n    }\n\n    fn generate_page_header(style: &TypstStyle) -> Result<String, ThirdPartyRenderError> {\n        let x_margin = style.horizontal_margin;\n        let y_margin = style.vertical_margin;\n        let background = style\n            .style\n            .colors\n            .background\n            .as_ref()\n            .map(Self::as_typst_color)\n            .unwrap_or_else(|| Ok(String::from(\"none\")))?;\n        let mut header = format!(\n            \"#set page(width: auto, height: auto, margin: (x: {x_margin}pt, y: {y_margin}pt), fill: {background})\\n\"\n        );\n        if let Some(color) = &style.style.colors.foreground {\n            let color = Self::as_typst_color(color)?;\n            header.push_str(&format!(\"#set text(fill: {color})\\n\"));\n        }\n        Ok(header)\n    }\n\n    fn as_typst_color(color: &Color) -> Result<String, ThirdPartyRenderError> {\n        match color.as_rgb() {\n            Some((r, g, b)) => Ok(format!(\"rgb(\\\"#{r:02x}{g:02x}{b:02x}\\\")\")),\n            None => Err(ThirdPartyRenderError::UnsupportedColor(RawColor::from(*color).to_string())),\n        }\n    }\n\n    fn load_image(&self, snippet: ImageSnippet, path: &Path) -> Result<Image, ThirdPartyRenderError> {\n        let contents = fs::read(path)?;\n        let image = image::load_from_memory(&contents)?;\n        let image = self.state.lock().unwrap().image_registry.register(ImageSpec::Generated(image))?;\n        self.state.lock().unwrap().cache.insert(snippet, image.clone());\n        Ok(image)\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum ThirdPartyRenderError {\n    #[error(transparent)]\n    Execution(#[from] ExecutionError),\n\n    #[error(\"io: {0}\")]\n    Io(#[from] io::Error),\n\n    #[error(\"invalid image: {0}\")]\n    InvalidImage(#[from] image::ImageError),\n\n    #[error(\"invalid image: {0}\")]\n    RegisterImage(#[from] RegisterImageError),\n\n    #[error(\"unsupported color '{0}', only RGB is supported\")]\n    UnsupportedColor(String),\n}\n\n#[derive(Hash, PartialEq, Eq)]\nenum SnippetSource {\n    Typst,\n    Latex,\n    Mermaid,\n    D2,\n}\n\n#[derive(Hash, PartialEq, Eq)]\nstruct ImageSnippet {\n    snippet: String,\n    source: SnippetSource,\n}\n\n#[derive(Debug)]\npub(crate) struct RenderThirdParty {\n    contents: Arc<Mutex<Option<Output>>>,\n    pending_result: Arc<Mutex<RenderResult>>,\n    default_style: TextStyle,\n    width: Option<Percent>,\n}\n\nimpl RenderThirdParty {\n    fn new(pending_result: Arc<Mutex<RenderResult>>, default_style: TextStyle, width: Option<Percent>) -> Self {\n        Self { contents: Default::default(), pending_result, default_style, width }\n    }\n}\n\nimpl RenderAsync for RenderThirdParty {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        Box::new(OperationPollable { contents: self.contents.clone(), pending_result: self.pending_result.clone() })\n    }\n\n    fn start_policy(&self) -> RenderAsyncStartPolicy {\n        RenderAsyncStartPolicy::Automatic\n    }\n}\n\nimpl AsRenderOperations for RenderThirdParty {\n    fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {\n        match &*self.contents.lock().unwrap() {\n            Some(Output::Image(image)) => {\n                let size = match &self.width {\n                    Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() },\n                    None => Default::default(),\n                };\n                let properties = ImageRenderProperties {\n                    size,\n                    background_color: self.default_style.colors.background,\n                    ..Default::default()\n                };\n\n                vec![RenderOperation::RenderImage(image.clone(), properties)]\n            }\n            Some(Output::Error) => Vec::new(),\n            None => {\n                let text = Line::from(Text::new(\"Loading...\", TextStyle::default().bold()));\n                vec![RenderOperation::RenderText {\n                    line: text.into(),\n                    alignment: Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 },\n                }]\n            }\n        }\n    }\n}\n\n#[derive(Debug)]\nenum Output {\n    Image(Image),\n    Error,\n}\n\n#[derive(Clone)]\nstruct OperationPollable {\n    contents: Arc<Mutex<Option<Output>>>,\n    pending_result: Arc<Mutex<RenderResult>>,\n}\n\nimpl Pollable for OperationPollable {\n    fn poll(&mut self) -> PollableState {\n        let mut contents = self.contents.lock().unwrap();\n        if contents.is_some() {\n            return PollableState::Done;\n        }\n        match mem::take(&mut *self.pending_result.lock().unwrap()) {\n            RenderResult::Success(image) => {\n                *contents = Some(Output::Image(image));\n                PollableState::Done\n            }\n            RenderResult::Failure(error) => {\n                *contents = Some(Output::Error);\n                PollableState::Failed { error }\n            }\n            RenderResult::Pending => PollableState::Unmodified,\n        }\n    }\n}\n"
  },
  {
    "path": "src/tools.rs",
    "content": "use itertools::Itertools;\nuse std::{\n    io::{self, Write},\n    process::{Command, Output, Stdio},\n};\n\nconst DEFAULT_MAX_ERROR_LINES: usize = 10;\n\npub(crate) struct ThirdPartyTools;\n\nimpl ThirdPartyTools {\n    pub(crate) fn pandoc(args: &[&str]) -> Tool {\n        Tool::new(\"pandoc\", args)\n    }\n\n    pub(crate) fn typst(args: &[&str]) -> Tool {\n        Tool::new(\"typst\", args)\n    }\n\n    pub(crate) fn mermaid(args: &[&str]) -> Tool {\n        let mmdc = if cfg!(windows) { \"mmdc.cmd\" } else { \"mmdc\" };\n        Tool::new(mmdc, args)\n    }\n\n    pub(crate) fn d2(args: &[&str]) -> Tool {\n        Tool::new(\"d2\", args)\n    }\n\n    pub(crate) fn weasyprint(args: &[&str]) -> Tool {\n        Tool::new(\"weasyprint\", args).inherit_stdout().max_error_lines(100)\n    }\n}\n\npub(crate) struct Tool {\n    command_name: &'static str,\n    command: Command,\n    stdin: Option<Vec<u8>>,\n    max_error_lines: usize,\n}\n\nimpl Tool {\n    fn new(command_name: &'static str, args: &[&str]) -> Self {\n        let mut command = Command::new(command_name);\n        command.args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());\n        Self { command_name, command, stdin: None, max_error_lines: DEFAULT_MAX_ERROR_LINES }\n    }\n\n    pub(crate) fn stdin(mut self, stdin: Vec<u8>) -> Self {\n        self.stdin = Some(stdin);\n        self\n    }\n\n    pub(crate) fn inherit_stdout(mut self) -> Self {\n        self.command.stdout(Stdio::inherit());\n        self\n    }\n\n    pub(crate) fn max_error_lines(mut self, value: usize) -> Self {\n        self.max_error_lines = value;\n        self\n    }\n\n    pub(crate) fn run(self) -> Result<(), ExecutionError> {\n        self.spawn()?;\n        Ok(())\n    }\n\n    pub(crate) fn run_and_capture_stdout(mut self) -> Result<Vec<u8>, ExecutionError> {\n        self.command.stdout(Stdio::piped());\n\n        let output = self.spawn()?;\n        Ok(output.stdout)\n    }\n\n    fn spawn(mut self) -> Result<Output, ExecutionError> {\n        use ExecutionError::*;\n        if self.stdin.is_some() {\n            self.command.stdin(Stdio::piped());\n        }\n        let mut child = match self.command.spawn() {\n            Ok(child) => child,\n            Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(SpawnNotFound { command: self.command_name }),\n            Err(error) => return Err(Spawn { command: self.command_name, error }),\n        };\n        if let Some(data) = &self.stdin {\n            let mut stdin = child.stdin.take().expect(\"no stdin\");\n            stdin\n                .write_all(data)\n                .and_then(|_| stdin.flush())\n                .map_err(|error| Communication { command: self.command_name, error })?;\n        }\n        let output = child.wait_with_output().map_err(|error| Communication { command: self.command_name, error })?;\n        self.validate_output(&output)?;\n        Ok(output)\n    }\n\n    fn validate_output(self, output: &Output) -> Result<(), ExecutionError> {\n        if output.status.success() {\n            Ok(())\n        } else {\n            let stderr = String::from_utf8_lossy(&output.stderr).lines().take(self.max_error_lines).join(\"\\n\");\n            Err(ExecutionError::Execution { command: self.command_name, stderr })\n        }\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum ExecutionError {\n    #[error(\"spawning '{command}' failed: {error}\")]\n    Spawn { command: &'static str, error: io::Error },\n\n    #[error(\"spawning '{command}' failed (is '{command}' installed?)\")]\n    SpawnNotFound { command: &'static str },\n\n    #[error(\"communicating with '{command}' failed: {error}\")]\n    Communication { command: &'static str, error: io::Error },\n\n    #[error(\"'{command}' execution failed: \\n{stderr}\")]\n    Execution { command: &'static str, stderr: String },\n}\n"
  },
  {
    "path": "src/transitions/collapse_horizontal.rs",
    "content": "use super::{AnimateTransition, LinesFrame, TransitionDirection};\nuse crate::terminal::virt::TerminalGrid;\n\npub(crate) struct CollapseHorizontalAnimation {\n    from: TerminalGrid,\n    to: TerminalGrid,\n}\n\nimpl CollapseHorizontalAnimation {\n    pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection) -> Self {\n        let (from, to) = match direction {\n            TransitionDirection::Next => (left, right),\n            TransitionDirection::Previous => (right, left),\n        };\n        Self { from, to }\n    }\n}\n\nimpl AnimateTransition for CollapseHorizontalAnimation {\n    type Frame = LinesFrame;\n\n    fn build_frame(&self, frame: usize, _previous_frame: usize) -> Self::Frame {\n        let mut rows = Vec::new();\n        for (from, to) in self.from.rows.iter().zip(&self.to.rows) {\n            // Take the first and last `frame` cells\n            let to_prefix = to.iter().take(frame);\n            let to_suffix = to.iter().rev().take(frame).rev();\n\n            let total_rows_from = from.len() - frame * 2;\n            let from = from.iter().skip(frame).take(total_rows_from);\n            let row = to_prefix.chain(from).chain(to_suffix).copied().collect();\n            rows.push(row)\n        }\n        let grid = TerminalGrid { rows, background_color: self.from.background_color, images: Default::default() };\n        LinesFrame::from(&grid)\n    }\n\n    fn total_frames(&self) -> usize {\n        self.from.rows[0].len() / 2\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::{markdown::elements::Line, transitions::utils::build_grid};\n    use rstest::rstest;\n\n    fn as_text(line: Line) -> String {\n        line.0.into_iter().map(|l| l.content).collect()\n    }\n\n    #[rstest]\n    #[case(0, &[\"ABCDEF\"])]\n    #[case(1, &[\"1BCDE6\"])]\n    #[case(2, &[\"12CD56\"])]\n    #[case(3, &[\"123456\"])]\n    fn transition(#[case] frame: usize, #[case] expected: &[&str]) {\n        let left = build_grid(&[\"ABCDEF\"]);\n        let right = build_grid(&[\"123456\"]);\n        let transition = CollapseHorizontalAnimation::new(left, right, TransitionDirection::Next);\n        let lines: Vec<_> = transition.build_frame(frame, 0).lines.into_iter().map(as_text).collect();\n        assert_eq!(lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/transitions/fade.rs",
    "content": "use super::{AnimateTransition, AnimationFrame, TransitionDirection};\nuse crate::{\n    markdown::text_style::TextStyle,\n    terminal::{\n        printer::TerminalCommand,\n        virt::{StyledChar, TerminalGrid},\n    },\n};\nuse std::str;\n\npub(crate) struct FadeAnimation {\n    changes: Vec<Change>,\n}\n\nimpl FadeAnimation {\n    pub(crate) fn new(left: TerminalGrid, right: TerminalGrid, direction: TransitionDirection) -> Self {\n        let mut changes = Vec::new();\n        let background = left.background_color;\n        for (row, (left, right)) in left.rows.into_iter().zip(right.rows).enumerate() {\n            for (column, (left, right)) in left.into_iter().zip(right).enumerate() {\n                let character = match &direction {\n                    TransitionDirection::Next => right,\n                    TransitionDirection::Previous => left,\n                };\n                if left != right {\n                    let StyledChar { character, mut style } = character;\n                    // If we don't have an explicit background color fall back to the default\n                    style.colors.background = style.colors.background.or(background);\n\n                    let mut char_buffer = [0; 4];\n                    let char_buffer_len = character.encode_utf8(&mut char_buffer).len() as u8;\n                    changes.push(Change {\n                        row: row as u16,\n                        column: column as u16,\n                        char_buffer,\n                        char_buffer_len,\n                        style,\n                    });\n                }\n            }\n        }\n        fastrand::shuffle(&mut changes);\n        Self { changes }\n    }\n}\n\nimpl AnimateTransition for FadeAnimation {\n    type Frame = FadeCellsFrame;\n\n    fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame {\n        let last_frame = self.changes.len().saturating_sub(1);\n        let previous_frame = previous_frame.min(last_frame);\n        let frame_index = frame.min(self.changes.len());\n        let changes = self.changes[previous_frame..frame_index].to_vec();\n        FadeCellsFrame { changes }\n    }\n\n    fn total_frames(&self) -> usize {\n        self.changes.len()\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct FadeCellsFrame {\n    changes: Vec<Change>,\n}\n\nimpl AnimationFrame for FadeCellsFrame {\n    fn build_commands(&self) -> Vec<TerminalCommand<'_>> {\n        let mut commands = Vec::new();\n        for change in &self.changes {\n            let Change { row, column, char_buffer, char_buffer_len, style } = change;\n            let char_buffer_len = *char_buffer_len as usize;\n            // SAFETY: this is an utf8 encoded char so it must be valid\n            let content = str::from_utf8(&char_buffer[..char_buffer_len]).expect(\"invalid utf8\");\n            commands.push(TerminalCommand::MoveTo { row: *row, column: *column });\n            commands.push(TerminalCommand::PrintText { content, style: *style });\n        }\n        commands\n    }\n}\n\n#[derive(Clone, Debug)]\nstruct Change {\n    row: u16,\n    column: u16,\n    char_buffer: [u8; 4],\n    char_buffer_len: u8,\n    style: TextStyle,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::{\n        WindowSize,\n        terminal::{printer::TerminalIo, virt::VirtualTerminal},\n    };\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::next(TransitionDirection::Next)]\n    #[case::previous(TransitionDirection::Previous)]\n    fn transition(#[case] direction: TransitionDirection) {\n        let left = TerminalGrid {\n            rows: vec![\n                vec!['X'.into(), ' '.into(), 'B'.into()],\n                vec!['C'.into(), StyledChar::new('X', TextStyle::default().size(2)), 'D'.into()],\n            ],\n            background_color: None,\n            images: Default::default(),\n        };\n        let right = TerminalGrid {\n            rows: vec![\n                vec![' '.into(), 'A'.into(), StyledChar::new('B', TextStyle::default().bold())],\n                vec![StyledChar::new('C', TextStyle::default().size(2)), ' '.into(), '🚀'.into()],\n            ],\n            background_color: None,\n            images: Default::default(),\n        };\n        let expected = match direction {\n            TransitionDirection::Next => right.clone(),\n            TransitionDirection::Previous => left.clone(),\n        };\n        let dimensions = WindowSize { rows: 2, columns: 3, height: 0, width: 0 };\n        let mut virt = VirtualTerminal::new(dimensions, Default::default());\n        let animation = FadeAnimation::new(left, right, direction);\n        for command in animation.build_frame(animation.total_frames(), 0).build_commands() {\n            virt.execute(&command).expect(\"failed to run\")\n        }\n        let output = virt.into_contents();\n        assert_eq!(output, expected);\n    }\n}\n"
  },
  {
    "path": "src/transitions/mod.rs",
    "content": "use crate::{\n    markdown::{elements::Line, text_style::Color},\n    terminal::{\n        printer::TerminalCommand,\n        virt::{TerminalGrid, TerminalRowIterator},\n    },\n};\nuse std::fmt::Debug;\nuse unicode_width::UnicodeWidthStr;\n\npub(crate) mod collapse_horizontal;\npub(crate) mod fade;\npub(crate) mod slide_horizontal;\n\n#[derive(Clone, Debug)]\npub(crate) enum TransitionDirection {\n    Next,\n    Previous,\n}\n\npub(crate) trait AnimateTransition {\n    type Frame: AnimationFrame + Debug;\n\n    fn build_frame(&self, frame: usize, previous_frame: usize) -> Self::Frame;\n    fn total_frames(&self) -> usize;\n}\n\npub(crate) trait AnimationFrame {\n    fn build_commands(&self) -> Vec<TerminalCommand<'_>>;\n}\n\n#[derive(Debug)]\npub(crate) struct LinesFrame {\n    pub(crate) lines: Vec<Line>,\n    pub(crate) background_color: Option<Color>,\n}\n\nimpl LinesFrame {\n    fn skip_whitespace(mut text: &str) -> (&str, usize, usize) {\n        let mut trimmed_before = 0;\n        while let Some(' ') = text.chars().next() {\n            text = &text[1..];\n            trimmed_before += 1;\n        }\n        let mut trimmed_after = 0;\n        let mut rev = text.chars().rev();\n        while let Some(' ') = rev.next() {\n            text = &text[..text.len() - 1];\n            trimmed_after += 1;\n        }\n        (text, trimmed_before, trimmed_after)\n    }\n}\n\nimpl From<&TerminalGrid> for LinesFrame {\n    fn from(grid: &TerminalGrid) -> Self {\n        let mut lines = Vec::new();\n        for row in &grid.rows {\n            let line = TerminalRowIterator::new(row).collect();\n            lines.push(Line(line));\n        }\n        Self { lines, background_color: grid.background_color }\n    }\n}\n\nimpl AnimationFrame for LinesFrame {\n    fn build_commands(&self) -> Vec<TerminalCommand<'_>> {\n        use TerminalCommand::*;\n        let mut commands = vec![];\n        if let Some(color) = self.background_color {\n            commands.push(SetBackgroundColor(color));\n        }\n        commands.push(ClearScreen);\n        for (row, line) in self.lines.iter().enumerate() {\n            let mut column = 0;\n            let mut is_in_column = false;\n            let mut is_in_row = false;\n            for chunk in &line.0 {\n                let (text, white_before, white_after) = match chunk.style.colors.background {\n                    Some(_) => (chunk.content.as_str(), 0, 0),\n                    None => Self::skip_whitespace(&chunk.content),\n                };\n                // If this is an empty line just skip it\n                if text.is_empty() {\n                    column += chunk.content.width();\n                    is_in_column = false;\n                    continue;\n                }\n                if !is_in_row {\n                    commands.push(MoveToRow(row as u16));\n                    is_in_row = true;\n                }\n                if white_before > 0 {\n                    column += white_before;\n                    is_in_column = false;\n                }\n                if !is_in_column {\n                    commands.push(MoveToColumn(column as u16));\n                    is_in_column = true;\n                }\n                commands.push(PrintText { content: text, style: chunk.style });\n                column += text.width();\n                if white_after > 0 {\n                    column += white_after;\n                    is_in_column = false;\n                }\n            }\n        }\n        commands\n    }\n}\n\n#[cfg(test)]\nmod utils {\n    use crate::terminal::virt::{StyledChar, TerminalGrid};\n\n    pub(crate) fn build_grid(rows: &[&str]) -> TerminalGrid {\n        let rows = rows\n            .iter()\n            .map(|r| r.chars().map(|c| StyledChar { character: c, style: Default::default() }).collect())\n            .collect();\n        TerminalGrid { rows, background_color: None, images: Default::default() }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::markdown::elements::Text;\n\n    #[test]\n    fn commands() {\n        let animation = LinesFrame {\n            lines: vec![\n                Line(vec![Text::from(\"  hi  \"), Text::from(\"bye\"), Text::from(\"s\")]),\n                Line(vec![Text::from(\"hello\"), Text::from(\" wor\"), Text::from(\"s\")]),\n            ],\n            background_color: Some(Color::Red),\n        };\n        let commands = animation.build_commands();\n        use TerminalCommand::*;\n        let expected = &[\n            SetBackgroundColor(Color::Red),\n            ClearScreen,\n            MoveToRow(0),\n            MoveToColumn(2),\n            PrintText { content: \"hi\", style: Default::default() },\n            MoveToColumn(6),\n            PrintText { content: \"bye\", style: Default::default() },\n            PrintText { content: \"s\", style: Default::default() },\n            MoveToRow(1),\n            MoveToColumn(0),\n            PrintText { content: \"hello\", style: Default::default() },\n            MoveToColumn(6),\n            PrintText { content: \"wor\", style: Default::default() },\n            PrintText { content: \"s\", style: Default::default() },\n        ];\n        assert_eq!(commands, expected);\n    }\n}\n"
  },
  {
    "path": "src/transitions/slide_horizontal.rs",
    "content": "use super::{AnimateTransition, LinesFrame, TransitionDirection};\nuse crate::{\n    WindowSize,\n    markdown::elements::Line,\n    terminal::virt::{TerminalGrid, TerminalRowIterator},\n};\n\npub(crate) struct SlideHorizontalAnimation {\n    grid: TerminalGrid,\n    dimensions: WindowSize,\n    direction: TransitionDirection,\n}\n\nimpl SlideHorizontalAnimation {\n    pub(crate) fn new(\n        left: TerminalGrid,\n        right: TerminalGrid,\n        dimensions: WindowSize,\n        direction: TransitionDirection,\n    ) -> Self {\n        let mut rows = Vec::new();\n        for (mut row, right) in left.rows.into_iter().zip(right.rows) {\n            row.extend(right);\n            rows.push(row);\n        }\n        let grid = TerminalGrid { rows, background_color: left.background_color, images: Default::default() };\n        Self { grid, dimensions, direction }\n    }\n}\n\nimpl AnimateTransition for SlideHorizontalAnimation {\n    type Frame = LinesFrame;\n\n    fn build_frame(&self, frame: usize, _previous_frame: usize) -> Self::Frame {\n        let total = self.total_frames();\n        let frame = frame.min(total);\n        let index = match &self.direction {\n            TransitionDirection::Next => frame,\n            TransitionDirection::Previous => total.saturating_sub(frame),\n        };\n        let mut lines = Vec::new();\n        for row in &self.grid.rows {\n            let row = &row[index..index + self.dimensions.columns as usize];\n            let mut line = Vec::new();\n            let max_width = self.dimensions.columns as usize;\n            let mut width = 0;\n            for mut text in TerminalRowIterator::new(row) {\n                let text_width = text.width() * text.style.size as usize;\n                if width + text_width > max_width {\n                    let capped_width = max_width.saturating_sub(width) / text.style.size as usize;\n                    if capped_width == 0 {\n                        continue;\n                    }\n                    text.content = text.content.chars().take(capped_width).collect();\n                }\n                width += text_width;\n                line.push(text);\n            }\n            lines.push(Line(line));\n        }\n        LinesFrame { lines, background_color: self.grid.background_color }\n    }\n\n    fn total_frames(&self) -> usize {\n        self.grid.rows[0].len().saturating_sub(self.dimensions.columns as usize)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use rstest::rstest;\n\n    fn as_text(line: Line) -> String {\n        line.0.into_iter().map(|l| l.content).collect()\n    }\n\n    #[rstest]\n    #[case::next_frame0(0, TransitionDirection::Next, &[\"AB\", \"CD\"])]\n    #[case::next_frame1(1, TransitionDirection::Next, &[\"BE\", \"DG\"])]\n    #[case::next_frame2(2, TransitionDirection::Next, &[\"EF\", \"GH\"])]\n    #[case::next_way_past(100, TransitionDirection::Next, &[\"EF\", \"GH\"])]\n    #[case::previous_frame0(0, TransitionDirection::Previous, &[\"EF\", \"GH\"])]\n    #[case::previous_frame1(1, TransitionDirection::Previous, &[\"BE\", \"DG\"])]\n    #[case::previous_frame2(2, TransitionDirection::Previous, &[\"AB\", \"CD\"])]\n    #[case::previous_way_past(100, TransitionDirection::Previous, &[\"AB\", \"CD\"])]\n    fn build_frame(#[case] frame: usize, #[case] direction: TransitionDirection, #[case] expected: &[&str]) {\n        use crate::transitions::utils::build_grid;\n\n        let left = build_grid(&[\"AB\", \"CD\"]);\n        let right = build_grid(&[\"EF\", \"GH\"]);\n        let dimensions = WindowSize { rows: 2, columns: 2, height: 0, width: 0 };\n        let transition = SlideHorizontalAnimation::new(left, right, dimensions, direction);\n        let lines: Vec<_> = transition.build_frame(frame, 0).lines.into_iter().map(as_text).collect();\n        assert_eq!(lines, expected);\n    }\n}\n"
  },
  {
    "path": "src/ui/execution/acquire_terminal.rs",
    "content": "use crate::{\n    code::{execute::LanguageSnippetExecutor, snippet::Snippet},\n    markdown::elements::{Line, Text},\n    render::{\n        operation::{AsRenderOperations, Pollable, PollableState, RenderAsync, RenderOperation},\n        properties::WindowSize,\n    },\n    terminal::should_hide_cursor,\n    theme::{Alignment, ExecutionStatusBlockStyle, Margin},\n    ui::separator::{RenderSeparator, SeparatorWidth},\n};\nuse crossterm::{\n    ExecutableCommand, cursor,\n    terminal::{self, disable_raw_mode, enable_raw_mode},\n};\nuse std::{\n    io::{self},\n    ops::Deref,\n    rc::Rc,\n    sync::{Arc, Mutex},\n};\n\nconst MINIMUM_SEPARATOR_WIDTH: u16 = 32;\n\n#[derive(Debug)]\npub(crate) struct RunAcquireTerminalSnippet {\n    snippet: Snippet,\n    block_length: u16,\n    executor: LanguageSnippetExecutor,\n    colors: ExecutionStatusBlockStyle,\n    state: Arc<Mutex<State>>,\n    font_size: u8,\n}\n\nimpl RunAcquireTerminalSnippet {\n    pub(crate) fn new(\n        snippet: Snippet,\n        executor: LanguageSnippetExecutor,\n        colors: ExecutionStatusBlockStyle,\n        block_length: u16,\n        font_size: u8,\n    ) -> Self {\n        Self { snippet, block_length, executor, colors, state: Default::default(), font_size }\n    }\n\n    fn invoke(&self) -> Result<(), String> {\n        let mut stdout = io::stdout();\n        stdout\n            .execute(terminal::LeaveAlternateScreen)\n            .and_then(|_| disable_raw_mode())\n            .map_err(|e| format!(\"failed to deinit terminal: {e}\"))?;\n\n        // save result for later, but first reinit the terminal\n        let result = self.executor.execute_sync(&self.snippet).map_err(|e| format!(\"failed to run snippet: {e}\"));\n\n        stdout\n            .execute(terminal::EnterAlternateScreen)\n            .and_then(|_| enable_raw_mode())\n            .map_err(|e| format!(\"failed to reinit terminal: {e}\"))?;\n        if should_hide_cursor() {\n            stdout.execute(cursor::Hide).map_err(|e| e.to_string())?;\n        }\n        result\n    }\n}\n\nimpl AsRenderOperations for RunAcquireTerminalSnippet {\n    fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let state = self.state.lock().unwrap();\n        let separator_text = match state.deref() {\n            State::NotStarted => Text::new(\"not started\", self.colors.not_started_style),\n            State::Success => Text::new(\"finished\", self.colors.success_style),\n            State::Failure(_) => Text::new(\"finished with error\", self.colors.failure_style),\n        };\n\n        let heading = Line(vec![\" [\".into(), separator_text, \"] \".into()]);\n        let separator_width = SeparatorWidth::Fixed(self.block_length.max(MINIMUM_SEPARATOR_WIDTH));\n        let separator = RenderSeparator::new(heading, separator_width, self.font_size);\n        let mut ops = vec![\n            RenderOperation::RenderLineBreak,\n            RenderOperation::RenderDynamic(Rc::new(separator)),\n            RenderOperation::RenderLineBreak,\n        ];\n        if let State::Failure(lines) = state.deref() {\n            ops.push(RenderOperation::RenderLineBreak);\n            for line in lines {\n                ops.extend([\n                    RenderOperation::RenderText {\n                        line: vec![Text::new(line, self.colors.failure_style)].into(),\n                        alignment: Alignment::Left { margin: Margin::Percent(25) },\n                    },\n                    RenderOperation::RenderLineBreak,\n                ]);\n            }\n        }\n        ops\n    }\n}\n\nimpl RenderAsync for RunAcquireTerminalSnippet {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        // Run within this method because we need to release/acquire the raw terminal in the main\n        // thread.\n        let mut state = self.state.lock().unwrap();\n        if matches!(*state, State::NotStarted) {\n            if let Err(e) = self.invoke() {\n                let lines = e.lines().map(ToString::to_string).collect();\n                *state = State::Failure(lines);\n            } else {\n                *state = State::Success;\n            }\n        }\n        Box::new(OperationPollable)\n    }\n}\n\n#[derive(Default, Clone)]\nenum State {\n    #[default]\n    NotStarted,\n    Success,\n    Failure(Vec<String>),\n}\n\nimpl std::fmt::Debug for State {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::NotStarted => write!(f, \"NotStarted\"),\n            Self::Success => write!(f, \"Success\"),\n            Self::Failure(_) => write!(f, \"Failure\"),\n        }\n    }\n}\n\nstruct OperationPollable;\n\nimpl Pollable for OperationPollable {\n    fn poll(&mut self) -> PollableState {\n        PollableState::Done\n    }\n}\n"
  },
  {
    "path": "src/ui/execution/disabled.rs",
    "content": "use crate::{\n    markdown::{elements::Text, text_style::TextStyle},\n    render::{\n        operation::{AsRenderOperations, Pollable, RenderAsync, RenderAsyncStartPolicy, RenderOperation, ToggleState},\n        properties::WindowSize,\n    },\n    theme::Alignment,\n};\nuse std::sync::{Arc, Mutex};\n\n#[derive(Clone, Debug)]\npub(crate) struct SnippetExecutionDisabledOperation {\n    text: Text,\n    alignment: Alignment,\n    policy: RenderAsyncStartPolicy,\n    toggled: Arc<Mutex<bool>>,\n}\n\nimpl SnippetExecutionDisabledOperation {\n    pub(crate) fn new(\n        style: TextStyle,\n        alignment: Alignment,\n        policy: RenderAsyncStartPolicy,\n        exec_type: ExecutionType,\n    ) -> Self {\n        let (attribute, cli_parameter) = match exec_type {\n            ExecutionType::Execute => (\"+exec\", \"-x\"),\n            ExecutionType::ExecReplace => (\"+exec_replace\", \"-X\"),\n            ExecutionType::Image => (\"+image\", \"-X\"),\n        };\n        let text = Text::new(format!(\"snippet {attribute} is disabled, run with {cli_parameter} to enable\"), style);\n        Self { text, alignment, policy, toggled: Default::default() }\n    }\n}\n\nimpl AsRenderOperations for SnippetExecutionDisabledOperation {\n    fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {\n        if !*self.toggled.lock().unwrap() {\n            return Vec::new();\n        }\n        vec![\n            RenderOperation::RenderLineBreak,\n            RenderOperation::RenderText { line: vec![self.text.clone()].into(), alignment: self.alignment },\n            RenderOperation::RenderLineBreak,\n        ]\n    }\n}\n\nimpl RenderAsync for SnippetExecutionDisabledOperation {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        Box::new(ToggleState::new(self.toggled.clone()))\n    }\n\n    fn start_policy(&self) -> RenderAsyncStartPolicy {\n        self.policy\n    }\n}\n\n#[derive(Debug)]\npub(crate) enum ExecutionType {\n    Execute,\n    ExecReplace,\n    Image,\n}\n"
  },
  {
    "path": "src/ui/execution/image.rs",
    "content": "use crate::{\n    code::{\n        execute::{ExecutionHandle, LanguageSnippetExecutor, ProcessStatus},\n        snippet::Snippet,\n    },\n    markdown::elements::Text,\n    render::{\n        operation::{\n            AsRenderOperations, ImageRenderProperties, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,\n            RenderOperation,\n        },\n        properties::WindowSize,\n    },\n    terminal::image::{\n        Image,\n        printer::{ImageRegistry, ImageSpec},\n    },\n    theme::{Alignment, ExecutionStatusBlockStyle, Margin},\n};\nuse std::{\n    io::BufRead,\n    mem,\n    ops::Deref,\n    sync::{Arc, Mutex},\n};\n\n#[derive(Debug)]\npub(crate) struct RunImageSnippet {\n    snippet: Snippet,\n    state: Arc<Mutex<State>>,\n    image_registry: ImageRegistry,\n    colors: ExecutionStatusBlockStyle,\n}\n\nimpl RunImageSnippet {\n    pub(crate) fn new(\n        snippet: Snippet,\n        executor: LanguageSnippetExecutor,\n        image_registry: ImageRegistry,\n        colors: ExecutionStatusBlockStyle,\n    ) -> Self {\n        let state = Arc::new(Mutex::new(State::NotStarted(executor)));\n        Self { snippet, image_registry, colors, state }\n    }\n}\n\nimpl RenderAsync for RunImageSnippet {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        Box::new(OperationPollable {\n            state: self.state.clone(),\n            snippet: self.snippet.clone(),\n            image_registry: self.image_registry.clone(),\n        })\n    }\n\n    fn start_policy(&self) -> RenderAsyncStartPolicy {\n        RenderAsyncStartPolicy::Automatic\n    }\n}\n\nimpl AsRenderOperations for RunImageSnippet {\n    fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let state = self.state.lock().unwrap();\n        match state.deref() {\n            State::NotStarted(_) | State::Running(_) => vec![],\n            State::Success(image) => {\n                vec![RenderOperation::RenderImage(image.clone(), ImageRenderProperties::default())]\n            }\n            State::Failure(lines) => {\n                let mut output = Vec::new();\n                for line in lines {\n                    output.extend([RenderOperation::RenderText {\n                        line: vec![Text::new(line, self.colors.failure_style)].into(),\n                        alignment: Alignment::Left { margin: Margin::Percent(25) },\n                    }]);\n                }\n                output\n            }\n        }\n    }\n}\n\nstruct OperationPollable {\n    state: Arc<Mutex<State>>,\n    snippet: Snippet,\n    image_registry: ImageRegistry,\n}\n\nimpl OperationPollable {\n    fn load_image(&self, data: &[u8]) -> Result<Image, String> {\n        let image = match image::load_from_memory(data) {\n            Ok(image) => image,\n            Err(e) => {\n                return Err(e.to_string());\n            }\n        };\n        self.image_registry.register(ImageSpec::Generated(image)).map_err(|e| e.to_string())\n    }\n}\n\nimpl Pollable for OperationPollable {\n    fn poll(&mut self) -> PollableState {\n        let mut state = self.state.lock().unwrap();\n        match state.deref() {\n            State::NotStarted(executor) => match executor.execute_async(&self.snippet) {\n                Ok(handle) => {\n                    *state = State::Running(handle);\n                    PollableState::Unmodified\n                }\n                Err(e) => {\n                    *state = State::Failure(e.to_string().lines().map(ToString::to_string).collect());\n                    PollableState::Done\n                }\n            },\n            State::Running(handle) => {\n                let mut inner = handle.state.lock().unwrap();\n                match inner.status {\n                    ProcessStatus::Running => PollableState::Unmodified,\n                    ProcessStatus::Success => {\n                        let data = mem::take(&mut inner.output);\n                        drop(inner);\n\n                        match self.load_image(&data) {\n                            Ok(image) => {\n                                *state = State::Success(image);\n                            }\n                            Err(e) => {\n                                *state = State::Failure(vec![e.to_string()]);\n                            }\n                        };\n                        PollableState::Done\n                    }\n                    ProcessStatus::Failure => {\n                        let mut lines = Vec::new();\n                        for line in inner.output.lines() {\n                            lines.push(line.unwrap_or_else(|_| String::new()));\n                        }\n                        drop(inner);\n\n                        *state = State::Failure(lines);\n                        PollableState::Done\n                    }\n                }\n            }\n            State::Success(_) | State::Failure(_) => PollableState::Done,\n        }\n    }\n}\n\n#[derive(Debug)]\nenum State {\n    NotStarted(LanguageSnippetExecutor),\n    Running(ExecutionHandle),\n    Success(Image),\n    Failure(Vec<String>),\n}\n"
  },
  {
    "path": "src/ui/execution/mod.rs",
    "content": "pub(crate) mod acquire_terminal;\npub(crate) mod disabled;\npub(crate) mod image;\npub(crate) mod output;\npub(crate) mod pty;\npub(crate) mod validator;\n\npub(crate) use acquire_terminal::RunAcquireTerminalSnippet;\npub(crate) use disabled::SnippetExecutionDisabledOperation;\npub(crate) use image::RunImageSnippet;\npub(crate) use output::SnippetOutputOperation;\n"
  },
  {
    "path": "src/ui/execution/output.rs",
    "content": "use crate::{\n    code::{\n        execute::{ExecutionHandle, ExecutionState, LanguageSnippetExecutor, ProcessStatus},\n        snippet::Snippet,\n    },\n    markdown::{\n        elements::{Line, Text},\n        text_style::{Colors, TextStyle},\n    },\n    render::{\n        operation::{\n            AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,\n            RenderOperation,\n        },\n        properties::WindowSize,\n    },\n    terminal::ansi::AnsiParser,\n    theme::{Alignment, ExecutionOutputBlockStyle, ExecutionStatusBlockStyle},\n    ui::{\n        execution::pty::{PtySnippetHandle, RunPtySnippetTrigger},\n        separator::{RenderSeparator, SeparatorWidth},\n    },\n};\nuse std::{\n    io::BufRead,\n    iter,\n    rc::Rc,\n    sync::{Arc, Mutex},\n};\n\nconst MINIMUM_SEPARATOR_WIDTH: u16 = 32;\n\n#[derive(Default, Debug)]\nenum State {\n    #[default]\n    Initial,\n    Running(ExecutionHandle),\n    Done,\n}\n\n#[derive(Debug)]\nstruct Inner {\n    snippet: Snippet,\n    executor: LanguageSnippetExecutor,\n    output_lines: Vec<Line>,\n    max_line_length: u16,\n    process_status: Option<ProcessStatus>,\n    state: State,\n    policy: RenderAsyncStartPolicy,\n}\n\n#[derive(Debug)]\npub(crate) struct SnippetOutputOperation {\n    default_colors: Colors,\n    style: ExecutionOutputBlockStyle,\n    block_length: u16,\n    alignment: Alignment,\n    handle: SnippetHandle,\n    font_size: u8,\n}\n\nimpl SnippetOutputOperation {\n    #[allow(clippy::too_many_arguments)]\n    pub(crate) fn new(\n        handle: SnippetHandle,\n        default_colors: Colors,\n        style: ExecutionOutputBlockStyle,\n        block_length: u16,\n        alignment: Alignment,\n        font_size: u8,\n    ) -> Self {\n        let block_length = alignment.adjust_size(block_length);\n        Self { default_colors, style, block_length, alignment, handle, font_size }\n    }\n}\n\nimpl AsRenderOperations for SnippetOutputOperation {\n    fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let inner = self.handle.0.lock().unwrap();\n        if let State::Initial = inner.state {\n            return Vec::new();\n        }\n\n        let mut operations = vec![];\n        let block_colors = self.style.style.colors;\n        if block_colors.background.is_some() {\n            operations.push(RenderOperation::SetColors(block_colors));\n        }\n\n        if !inner.output_lines.is_empty() {\n            let has_margin = match &self.alignment {\n                Alignment::Left { margin } => !margin.is_empty(),\n                Alignment::Right { margin } => !margin.is_empty(),\n                Alignment::Center { minimum_margin, minimum_size } => !minimum_margin.is_empty() || minimum_size != &0,\n            };\n            let padding = self.style.padding;\n            let block_length =\n                if has_margin { self.block_length.max(inner.max_line_length) } else { inner.max_line_length };\n            let vertical_padding = iter::repeat_n(\" \", padding.vertical as usize).map(Line::from);\n            let lines = vertical_padding.clone().chain(inner.output_lines.iter().cloned()).chain(vertical_padding);\n            let style = TextStyle::default().size(self.font_size);\n            for mut line in lines {\n                line.apply_style(&style);\n                let prefix = Text::new(\" \".repeat(padding.horizontal as usize), style).into();\n                operations.push(RenderOperation::RenderBlockLine(BlockLine {\n                    prefix,\n                    right_padding_length: padding.horizontal as u16,\n                    repeat_prefix_on_wrap: false,\n                    text: line.into(),\n                    block_length,\n                    alignment: self.alignment,\n                    block_color: block_colors.background,\n                }));\n                operations.push(RenderOperation::RenderLineBreak);\n            }\n        }\n        operations.extend([RenderOperation::SetColors(self.default_colors)]);\n        operations\n    }\n}\n\nstruct OperationPollable {\n    inner: Arc<Mutex<Inner>>,\n    last_length: usize,\n}\n\nimpl OperationPollable {\n    fn try_start(&self, inner: &mut Inner) {\n        // Don't run twice.\n        if !matches!(inner.state, State::Initial) {\n            return;\n        }\n        inner.state = match inner.executor.execute_async(&inner.snippet) {\n            Ok(handle) => State::Running(handle),\n            Err(e) => {\n                inner.output_lines = vec![e.to_string().into()];\n                State::Done\n            }\n        }\n    }\n}\n\nimpl Pollable for OperationPollable {\n    fn poll(&mut self) -> PollableState {\n        let mut inner = self.inner.lock().unwrap();\n        self.try_start(&mut inner);\n\n        // At this point if we don't have a handle it's because we're done.\n        let State::Running(handle) = &mut inner.state else {\n            return PollableState::Done;\n        };\n\n        // Pull data out of the process' output and drop the handle state.\n        let mut state = handle.state.lock().unwrap();\n        let ExecutionState { output, status } = &mut *state;\n        let status = *status;\n\n        let modified = output.len() != self.last_length;\n        let mut lines = Vec::new();\n        for line in output.lines() {\n            let mut line = line.expect(\"invalid utf8\");\n            if line.contains('\\t') {\n                line = line.replace('\\t', \"    \");\n            }\n            lines.push(line);\n        }\n        drop(state);\n\n        let mut max_line_length = 0;\n        let (lines, _) = AnsiParser::new(Default::default()).parse_lines(&lines);\n        for line in &lines {\n            let width = u16::try_from(line.width()).unwrap_or(u16::MAX);\n            max_line_length = max_line_length.max(width);\n        }\n\n        let is_finished = status.is_finished();\n        inner.process_status = Some(status);\n        inner.output_lines = lines;\n        inner.max_line_length = inner.max_line_length.max(max_line_length);\n        if is_finished {\n            inner.state = State::Done;\n            PollableState::Done\n        } else {\n            match modified {\n                true => PollableState::Modified,\n                false => PollableState::Unmodified,\n            }\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct SnippetHandle(Arc<Mutex<Inner>>);\n\nimpl SnippetHandle {\n    pub(crate) fn new(code: Snippet, executor: LanguageSnippetExecutor, policy: RenderAsyncStartPolicy) -> Self {\n        let inner = Inner {\n            snippet: code,\n            executor,\n            process_status: Default::default(),\n            output_lines: Default::default(),\n            max_line_length: Default::default(),\n            state: Default::default(),\n            policy,\n        };\n        Self(Arc::new(Mutex::new(inner)))\n    }\n\n    pub(crate) fn snippet(&self) -> Snippet {\n        self.0.lock().unwrap().snippet.clone()\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct RunSnippetTrigger(Arc<Mutex<Inner>>);\n\nimpl RunSnippetTrigger {\n    pub(crate) fn new(handle: SnippetHandle) -> Self {\n        Self(handle.0)\n    }\n}\n\nimpl AsRenderOperations for RunSnippetTrigger {\n    fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {\n        vec![]\n    }\n}\n\nimpl RenderAsync for RunSnippetTrigger {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        Box::new(OperationPollable { inner: self.0.clone(), last_length: 0 })\n    }\n\n    fn start_policy(&self) -> RenderAsyncStartPolicy {\n        self.0.lock().unwrap().policy\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct ExecIndicatorStyle {\n    pub(crate) theme: ExecutionStatusBlockStyle,\n    pub(crate) block_length: u16,\n    pub(crate) font_size: u8,\n    pub(crate) alignment: Alignment,\n}\n\n#[derive(Clone, Debug)]\npub(crate) enum WrappedSnippetHandle {\n    Normal(SnippetHandle),\n    Pty(PtySnippetHandle),\n}\n\nimpl WrappedSnippetHandle {\n    pub(crate) fn process_status(&self) -> Option<ProcessStatus> {\n        match self {\n            Self::Normal(handle) => handle.0.lock().unwrap().process_status,\n            Self::Pty(handle) => handle.process_status(),\n        }\n    }\n\n    pub(crate) fn build_trigger(&self) -> Box<dyn RenderAsync> {\n        match self.clone() {\n            Self::Normal(handle) => Box::new(RunSnippetTrigger::new(handle)),\n            Self::Pty(handle) => Box::new(RunPtySnippetTrigger::new(handle)),\n        }\n    }\n}\n\nimpl From<SnippetHandle> for WrappedSnippetHandle {\n    fn from(handle: SnippetHandle) -> Self {\n        Self::Normal(handle)\n    }\n}\n\nimpl From<PtySnippetHandle> for WrappedSnippetHandle {\n    fn from(handle: PtySnippetHandle) -> Self {\n        Self::Pty(handle)\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct ExecIndicator {\n    handle: WrappedSnippetHandle,\n    separator_width: SeparatorWidth,\n    theme: ExecutionStatusBlockStyle,\n    font_size: u8,\n}\n\nimpl ExecIndicator {\n    pub(crate) fn new<T: Into<WrappedSnippetHandle>>(handle: T, style: ExecIndicatorStyle) -> Self {\n        let ExecIndicatorStyle { theme, block_length, font_size, alignment } = style;\n        let block_length = alignment.adjust_size(block_length);\n        let separator_width = match &alignment {\n            Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow,\n            // We need a minimum here otherwise if the code/block length is too narrow, the separator is\n            // word-wrapped and looks bad.\n            Alignment::Center { .. } => {\n                SeparatorWidth::Fixed(block_length.max(MINIMUM_SEPARATOR_WIDTH * font_size as u16))\n            }\n        };\n        let handle = handle.into();\n        Self { handle, separator_width, theme, font_size }\n    }\n}\n\nimpl AsRenderOperations for ExecIndicator {\n    fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let status = self.handle.process_status();\n        let description = match status {\n            Some(ProcessStatus::Running) => Text::new(\"running\", self.theme.running_style),\n            Some(ProcessStatus::Success) => Text::new(\"finished\", self.theme.success_style),\n            Some(ProcessStatus::Failure) => Text::new(\"finished with error\", self.theme.failure_style),\n            None => Text::new(\"not started\", self.theme.not_started_style),\n        };\n\n        let heading = Line(vec![\" [\".into(), description.clone(), \"] \".into()]);\n        let separator = RenderSeparator::new(heading, self.separator_width, self.font_size);\n        vec![\n            RenderOperation::RenderLineBreak,\n            RenderOperation::RenderDynamic(Rc::new(separator)),\n            RenderOperation::RenderLineBreak,\n        ]\n    }\n}\n\n#[cfg(all(target_os = \"linux\", test))]\nmod tests {\n    use super::*;\n    use crate::{\n        code::{\n            execute::SnippetExecutor,\n            snippet::{SnippetAttributes, SnippetExecution, SnippetLanguage},\n        },\n        markdown::{\n            elements::{Line, Text},\n            text_style::Color,\n        },\n    };\n\n    fn make_run_shell(code: &str) -> RunSnippetTrigger {\n        let snippet = Snippet {\n            contents: code.into(),\n            language: SnippetLanguage::Bash,\n            attributes: SnippetAttributes {\n                execution: SnippetExecution::Exec(Default::default()),\n                ..Default::default()\n            },\n        };\n        let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();\n        let policy = RenderAsyncStartPolicy::OnDemand;\n        let handle = SnippetHandle::new(snippet, executor, policy);\n        RunSnippetTrigger::new(handle)\n    }\n\n    #[test]\n    fn run_command() {\n        let handle = make_run_shell(\"echo -e '\\\\033[1;31mhi mom'\");\n        let mut pollable = handle.pollable();\n        // Run until done\n        while let PollableState::Modified | PollableState::Unmodified = pollable.poll() {}\n\n        // Expect to see the output lines\n        let inner = handle.0.lock().unwrap();\n        let line = Line::from(Text::new(\"hi mom\", TextStyle::default().fg_color(Color::DarkRed).bold()));\n        assert_eq!(inner.output_lines, vec![line]);\n    }\n\n    #[test]\n    fn multiple_pollables() {\n        let handle = make_run_shell(\"echo -e '\\\\033[1;31mhi mom'\");\n        let mut main_pollable = handle.pollable();\n        let mut pollable2 = handle.pollable();\n        // Run until done\n        while let PollableState::Modified | PollableState::Unmodified = main_pollable.poll() {}\n\n        // Polling a pollable created early should return `Done` immediately\n        assert_eq!(pollable2.poll(), PollableState::Done);\n\n        // A new pollable should claim `Done` immediately\n        let mut pollable3 = handle.pollable();\n        assert_eq!(pollable3.poll(), PollableState::Done);\n    }\n}\n"
  },
  {
    "path": "src/ui/execution/pty.rs",
    "content": "use crate::{\n    code::{\n        execute::{LanguageSnippetExecutor, ProcessStatus, PtySnippetContext},\n        snippet::{PtyArgs, Snippet},\n    },\n    markdown::{\n        elements::{Line, Text},\n        text_style::{Color, TextStyle},\n    },\n    render::{\n        operation::{\n            AsRenderOperations, BlockLine, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy,\n            RenderOperation,\n        },\n        properties::WindowSize,\n    },\n    theme::{Alignment, PtyOutputBlockStyle},\n};\nuse portable_pty::{MasterPty, PtySize, native_pty_system};\nuse std::{\n    fmt, io, iter, mem,\n    sync::{Arc, Mutex},\n    thread,\n};\nuse unicode_width::UnicodeWidthStr;\n\nconst DEFAULT_COLUMNS: u16 = 80;\nconst DEFAULT_ROWS: u16 = 24;\n\n#[derive(Default, Debug)]\nenum State {\n    #[default]\n    Initial,\n    Running {\n        pty: PtyMaster,\n        dirty: bool,\n    },\n    ProcessTerminated(ProcessStatus),\n    Done(ProcessStatus),\n}\n\nstruct Inner {\n    snippet: Snippet,\n    executor: LanguageSnippetExecutor,\n    parser: vt100::Parser,\n    expected_size: WindowSize,\n    actual_size: WindowSize,\n    update_size: bool,\n    standby: bool,\n    policy: RenderAsyncStartPolicy,\n    state: State,\n}\n\nimpl fmt::Debug for Inner {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.debug_struct(\"Inner\")\n            .field(\"snippet\", &self.snippet)\n            .field(\"executor\", &self.executor)\n            .field(\"expected_size\", &self.expected_size)\n            .field(\"actual_size\", &self.actual_size)\n            .field(\"update_size\", &self.update_size)\n            .field(\"standby\", &self.standby)\n            .field(\"parser\", &\"...\")\n            .field(\"policy\", &self.policy)\n            .field(\"state\", &\"...\")\n            .finish()\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct PtySnippetOutputOperation {\n    handle: PtySnippetHandle,\n    style: PtyOutputBlockStyle,\n    font_size: u8,\n}\n\nimpl PtySnippetOutputOperation {\n    pub(crate) fn new(handle: PtySnippetHandle, style: PtyOutputBlockStyle, font_size: u8) -> Self {\n        Self { handle, style, font_size }\n    }\n\n    fn standby_row(&self, row: u16, dimensions: &WindowSize) -> Line {\n        let lines = self.style.standby.as_lines();\n        let start_index = (dimensions.rows / 2).saturating_sub(lines.len() as u16 / 2);\n        if row < start_index || row >= start_index + lines.len() as u16 {\n            Text::new(\"\", TextStyle::default().size(self.font_size)).into()\n        } else {\n            let index = (row - start_index) as usize;\n            let padding = usize::from(dimensions.columns / 2).saturating_sub(lines[index].width() / 2);\n            let line: String = iter::repeat_n(' ', padding).chain(lines[index].chars()).collect();\n            Text::new(line, self.style.style.size(self.font_size)).into()\n        }\n    }\n}\n\nimpl AsRenderOperations for PtySnippetOutputOperation {\n    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let mut inner = self.handle.0.lock().unwrap();\n        let dimensions = dimensions\n            .shrink_rows(dimensions.rows - dimensions.rows / self.font_size as u16)\n            .shrink_columns(dimensions.columns - dimensions.columns / self.font_size as u16);\n\n        if inner.update_size && inner.expected_size != dimensions && dimensions.rows > 0 {\n            inner.expected_size = dimensions;\n            inner.parser.screen_mut().set_size(dimensions.rows, dimensions.columns);\n        }\n        if matches!(inner.state, State::Initial) {\n            let mut operations = Vec::new();\n            if inner.standby {\n                let dimensions = inner.expected_size;\n                for row in 0..dimensions.rows {\n                    let line = self.standby_row(row, &dimensions);\n                    operations.extend([\n                        RenderOperation::RenderBlockLine(BlockLine {\n                            prefix: \"\".into(),\n                            right_padding_length: 0,\n                            repeat_prefix_on_wrap: false,\n                            text: line.into(),\n                            block_length: dimensions.columns,\n                            block_color: self.style.style.colors.background,\n                            alignment: Alignment::Center {\n                                minimum_margin: Default::default(),\n                                minimum_size: Default::default(),\n                            },\n                        }),\n                        RenderOperation::RenderLineBreak,\n                    ]);\n                }\n            }\n            return operations;\n        }\n\n        let screen = inner.parser.screen();\n        let (rows, columns) = screen.size();\n        let mut operations = vec![];\n        let cursor_position = if screen.hide_cursor() { None } else { Some(screen.cursor_position()) };\n\n        for row in 0..rows {\n            let mut line = Vec::new();\n            let mut current_text = String::new();\n            let mut current_style = TextStyle::default();\n            for column in 0..columns {\n                let cell = screen.cell(row, column).expect(\"no cell\");\n                let mut style = TextStyle::from(cell).size(self.font_size);\n                if style.colors.foreground.is_none() {\n                    style.colors.foreground = self.style.style.colors.foreground;\n                }\n                if style.colors.background.is_none() {\n                    style.colors.background = self.style.style.colors.background;\n                }\n                let (contents, style) = match cursor_position == Some((row, column)) {\n                    true => {\n                        let contents = cell.contents();\n                        if contents.is_empty() {\n                            (self.style.cursor.symbol.as_str(), style)\n                        } else {\n                            (contents, self.style.cursor.highlight_style)\n                        }\n                    }\n                    false => (cell.contents(), style),\n                };\n                if current_style != style && !current_text.is_empty() {\n                    line.push(Text::new(mem::take(&mut current_text), current_style));\n                }\n                current_style = style;\n                if contents.is_empty() {\n                    current_text.push(' ');\n                } else {\n                    current_text.push_str(contents);\n                }\n            }\n            if !current_text.is_empty() {\n                line.push(Text::new(current_text, current_style));\n            }\n            operations.extend([\n                RenderOperation::RenderBlockLine(BlockLine {\n                    prefix: \"\".into(),\n                    right_padding_length: 0,\n                    repeat_prefix_on_wrap: false,\n                    text: line.into(),\n                    block_length: columns,\n                    block_color: None,\n                    alignment: Alignment::Center {\n                        minimum_margin: Default::default(),\n                        minimum_size: Default::default(),\n                    },\n                }),\n                RenderOperation::RenderLineBreak,\n            ]);\n        }\n        operations\n    }\n}\n\nimpl RenderAsync for PtySnippetOutputOperation {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        Box::new(OperationPollable { handle: self.handle.clone() })\n    }\n}\n\n#[derive(Debug)]\nstruct OperationPollable {\n    handle: PtySnippetHandle,\n}\n\nimpl OperationPollable {\n    fn spawn(ctx: PtySnippetContext, dimensions: WindowSize, handle: PtySnippetHandle) -> anyhow::Result<PtyMaster> {\n        let pty_system = native_pty_system();\n        let pty_size = PtySize {\n            rows: dimensions.rows,\n            cols: dimensions.columns,\n            pixel_width: dimensions.pixels_per_column() as u16,\n            pixel_height: dimensions.pixels_per_row() as u16,\n        };\n        let pair = pty_system.openpty(pty_size)?;\n        pair.slave.spawn_command(ctx.command.clone())?;\n        PtyMaster::new(pair.master, handle, ctx)\n    }\n}\n\nimpl Pollable for OperationPollable {\n    fn poll(&mut self) -> PollableState {\n        let mut inner = self.handle.0.lock().unwrap();\n        let expected_size = inner.expected_size;\n        let actual_size = inner.actual_size;\n        inner.actual_size = expected_size;\n        match &mut inner.state {\n            State::Initial => match inner.executor.pty_execution_context(&inner.snippet) {\n                Ok(ctx) => match Self::spawn(ctx, expected_size, self.handle.clone()) {\n                    Ok(pty) => {\n                        inner.state = State::Running { pty, dirty: true };\n                        PollableState::Modified\n                    }\n                    Err(e) => {\n                        inner.state = State::Done(ProcessStatus::Failure);\n                        PollableState::Failed { error: format!(\"failed to run script: {e}\") }\n                    }\n                },\n                Err(e) => {\n                    inner.state = State::Done(ProcessStatus::Failure);\n                    PollableState::Failed { error: format!(\"failed to run script: {e}\") }\n                }\n            },\n            State::Running { dirty, pty } => {\n                if actual_size != expected_size {\n                    let size = PtySize {\n                        rows: expected_size.rows,\n                        cols: expected_size.columns,\n                        pixel_width: 0,\n                        pixel_height: 0,\n                    };\n                    let _ = pty._master.resize(size);\n                }\n\n                if mem::take(dirty) { PollableState::Modified } else { PollableState::Unmodified }\n            }\n            State::ProcessTerminated(status) => {\n                inner.state = State::Done(*status);\n                PollableState::Modified\n            }\n            _ => PollableState::Unmodified,\n        }\n    }\n}\n\npub(crate) struct PtyMaster {\n    _master: Box<dyn MasterPty>,\n    _ctx: PtySnippetContext,\n}\n\nimpl PtyMaster {\n    fn new(master: Box<dyn MasterPty>, handle: PtySnippetHandle, ctx: PtySnippetContext) -> anyhow::Result<Self> {\n        let reader = master.try_clone_reader()?;\n        thread::spawn(|| process_output(reader, handle));\n        Ok(Self { _master: master, _ctx: ctx })\n    }\n}\n\nimpl fmt::Debug for PtyMaster {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        f.debug_struct(\"PtyMaster\").field(\"master\", &\"...\").finish()\n    }\n}\n\nfn process_output(mut reader: Box<dyn io::Read>, handle: PtySnippetHandle) {\n    let mut input_buffer = [0; 1024];\n    let status = loop {\n        let Ok(bytes_read) = reader.read(&mut input_buffer) else {\n            break ProcessStatus::Failure;\n        };\n        if bytes_read == 0 {\n            break ProcessStatus::Success;\n        }\n        let bytes = &input_buffer[..bytes_read];\n        let mut inner = handle.0.lock().unwrap();\n        inner.parser.process(bytes);\n        if let State::Running { dirty, .. } = &mut inner.state {\n            *dirty = true;\n        };\n    };\n    handle.0.lock().unwrap().state = State::ProcessTerminated(status);\n}\n\nimpl From<&vt100::Cell> for TextStyle {\n    fn from(cell: &vt100::Cell) -> Self {\n        let mut style = TextStyle::default();\n        if cell.bold() {\n            style = style.bold();\n        }\n        if cell.italic() {\n            style = style.italics();\n        }\n        if cell.underline() {\n            style = style.underlined();\n        }\n        style.colors.foreground = parse_color(cell.fgcolor());\n        style.colors.background = parse_color(cell.bgcolor());\n        style\n    }\n}\n\nfn parse_color(color: vt100::Color) -> Option<Color> {\n    match color {\n        vt100::Color::Default => None,\n        vt100::Color::Idx(value) => Color::from_8bit(value),\n        vt100::Color::Rgb(r, g, b) => Some(Color::Rgb { r, g, b }),\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct PtySnippetHandle(Arc<Mutex<Inner>>);\n\nimpl PtySnippetHandle {\n    pub(crate) fn new(\n        snippet: Snippet,\n        executor: LanguageSnippetExecutor,\n        policy: RenderAsyncStartPolicy,\n        args: PtyArgs,\n    ) -> Self {\n        let expected_size = WindowSize {\n            columns: args.columns.unwrap_or(DEFAULT_COLUMNS),\n            rows: args.rows.unwrap_or(DEFAULT_ROWS),\n            height: 0,\n            width: 0,\n        };\n        let update_size = args.columns.is_none() || args.rows.is_none();\n        let parser = vt100::Parser::new(expected_size.rows, expected_size.columns, 1000);\n        let inner = Inner {\n            snippet,\n            executor,\n            parser,\n            expected_size,\n            actual_size: expected_size,\n            update_size,\n            standby: args.standby,\n            state: Default::default(),\n            policy,\n        };\n        Self(Arc::new(Mutex::new(inner)))\n    }\n\n    pub(crate) fn snippet(&self) -> Snippet {\n        self.0.lock().unwrap().snippet.clone()\n    }\n\n    pub(crate) fn process_status(&self) -> Option<ProcessStatus> {\n        match &self.0.lock().unwrap().state {\n            State::Initial => None,\n            State::Running { .. } => Some(ProcessStatus::Running),\n            State::ProcessTerminated(status) | State::Done(status) => Some(*status),\n        }\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct RunPtySnippetTrigger(PtySnippetHandle);\n\nimpl RunPtySnippetTrigger {\n    pub(crate) fn new(handle: PtySnippetHandle) -> Self {\n        Self(handle)\n    }\n}\n\nimpl AsRenderOperations for RunPtySnippetTrigger {\n    fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {\n        vec![]\n    }\n}\n\nimpl RenderAsync for RunPtySnippetTrigger {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        Box::new(OperationPollable { handle: self.0.clone() })\n    }\n\n    fn start_policy(&self) -> RenderAsyncStartPolicy {\n        self.0.0.lock().unwrap().policy\n    }\n}\n"
  },
  {
    "path": "src/ui/execution/validator.rs",
    "content": "use crate::{\n    code::{\n        execute::{ExecutionHandle, LanguageSnippetExecutor, ProcessStatus},\n        snippet::{ExpectedSnippetExecutionResult, Snippet},\n    },\n    render::operation::{\n        AsRenderOperations, Pollable, PollableState, RenderAsync, RenderAsyncStartPolicy, RenderOperation,\n    },\n};\nuse std::{\n    mem,\n    ops::DerefMut,\n    sync::{Arc, Mutex},\n};\n\n#[derive(Debug)]\npub(crate) struct ValidateSnippetOperation {\n    snippet: Snippet,\n    executor: LanguageSnippetExecutor,\n    state: Arc<Mutex<State>>,\n}\n\nimpl ValidateSnippetOperation {\n    pub(crate) fn new(snippet: Snippet, executor: LanguageSnippetExecutor) -> Self {\n        Self { snippet, executor, state: Default::default() }\n    }\n}\n\nimpl AsRenderOperations for ValidateSnippetOperation {\n    fn as_render_operations(&self, _dimensions: &crate::WindowSize) -> Vec<RenderOperation> {\n        vec![]\n    }\n}\n\nimpl RenderAsync for ValidateSnippetOperation {\n    fn pollable(&self) -> Box<dyn Pollable> {\n        Box::new(OperationPollable {\n            snippet: self.snippet.clone(),\n            executor: self.executor.clone(),\n            state: self.state.clone(),\n        })\n    }\n\n    fn start_policy(&self) -> RenderAsyncStartPolicy {\n        RenderAsyncStartPolicy::Automatic\n    }\n}\n\n#[derive(Debug, Default)]\nenum State {\n    #[default]\n    Initial,\n    Running(ExecutionHandle),\n    Done(PollableState),\n}\n\nstruct OperationPollable {\n    snippet: Snippet,\n    executor: LanguageSnippetExecutor,\n    state: Arc<Mutex<State>>,\n}\n\nimpl OperationPollable {\n    fn success_to_pollable_state(&self) -> PollableState {\n        match self.snippet.attributes.expected_execution_result {\n            ExpectedSnippetExecutionResult::Success => PollableState::Done,\n            ExpectedSnippetExecutionResult::Failure => {\n                PollableState::Failed { error: \"expected snippet to fail but it succeeded\".into() }\n            }\n        }\n    }\n\n    fn error_to_pollable_state<S: Into<String>>(&self, error: S) -> PollableState {\n        match self.snippet.attributes.expected_execution_result {\n            ExpectedSnippetExecutionResult::Success => PollableState::Failed { error: error.into() },\n            ExpectedSnippetExecutionResult::Failure => PollableState::Done,\n        }\n    }\n}\n\nimpl Pollable for OperationPollable {\n    fn poll(&mut self) -> PollableState {\n        let mut state = self.state.lock().expect(\"lock poisoned\");\n        let next_state = match mem::take(state.deref_mut()) {\n            State::Initial => match self.executor.execute_async(&self.snippet) {\n                Ok(handle) => State::Running(handle),\n                Err(e) => State::Done(self.error_to_pollable_state(e.to_string())),\n            },\n            State::Running(handle) => {\n                let state = handle.state.lock().expect(\"lock poisoned\");\n                match state.status {\n                    ProcessStatus::Running => {\n                        drop(state);\n                        State::Running(handle)\n                    }\n                    ProcessStatus::Success => State::Done(self.success_to_pollable_state()),\n                    ProcessStatus::Failure => {\n                        State::Done(self.error_to_pollable_state(String::from_utf8_lossy(&state.output)))\n                    }\n                }\n            }\n            State::Done(output) => State::Done(output),\n        };\n        *state = next_state;\n        match &*state {\n            State::Initial | State::Running(_) => PollableState::Unmodified,\n            State::Done(output) => output.clone(),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::code::{\n        execute::SnippetExecutor,\n        snippet::{SnippetAttributes, SnippetLanguage},\n    };\n    use rstest::rstest;\n\n    #[rstest]\n    #[case::success(\"fn main() { println!(\\\"hi\\\"); }\", ExpectedSnippetExecutionResult::Success)]\n    #[case::failure(\"fn main() \", ExpectedSnippetExecutionResult::Failure)]\n    fn expectation_matches(#[case] contents: &str, #[case] expected_execution_result: ExpectedSnippetExecutionResult) {\n        let snippet = Snippet {\n            contents: contents.into(),\n            language: SnippetLanguage::Rust,\n            attributes: SnippetAttributes { expected_execution_result, ..Default::default() },\n        };\n        let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();\n        let state = Arc::new(Mutex::new(State::default()));\n        let mut pollable =\n            OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() };\n        loop {\n            match pollable.poll() {\n                PollableState::Unmodified | PollableState::Modified => continue,\n                PollableState::Done => break,\n                PollableState::Failed { error } => panic!(\"finished with error: {error}\"),\n            }\n        }\n        let mut pollable = OperationPollable { snippet, executor, state: state.clone() };\n        assert!(matches!(pollable.poll(), PollableState::Done), \"different pollable returned different\");\n    }\n\n    #[rstest]\n    #[case::success(\"fn main() { println!(\\\"hi\\\"); }\", ExpectedSnippetExecutionResult::Failure)]\n    #[case::failure(\"fn main() \", ExpectedSnippetExecutionResult::Success)]\n    fn expect_does_not_match(\n        #[case] contents: &str,\n        #[case] expected_execution_result: ExpectedSnippetExecutionResult,\n    ) {\n        let snippet = Snippet {\n            contents: contents.into(),\n            language: SnippetLanguage::Rust,\n            attributes: SnippetAttributes { expected_execution_result, ..Default::default() },\n        };\n        let executor = SnippetExecutor::default().language_executor(&snippet.language, &Default::default()).unwrap();\n        let state = Arc::new(Mutex::new(State::default()));\n        let mut pollable =\n            OperationPollable { snippet: snippet.clone(), executor: executor.clone(), state: state.clone() };\n        loop {\n            match pollable.poll() {\n                PollableState::Unmodified | PollableState::Modified => continue,\n                PollableState::Done => panic!(\"finished successfully\"),\n                PollableState::Failed { .. } => break,\n            }\n        }\n        let mut pollable = OperationPollable { snippet, executor, state: state.clone() };\n        assert!(matches!(pollable.poll(), PollableState::Failed { .. }), \"different pollable returned different\");\n    }\n}\n"
  },
  {
    "path": "src/ui/footer.rs",
    "content": "use crate::{\n    markdown::{\n        elements::{Line, Text},\n        parse::{MarkdownParser, ParseInlinesError},\n        text_style::{TextStyle, UndefinedPaletteColorError},\n    },\n    render::{\n        operation::{AsRenderOperations, ImagePosition, ImageRenderProperties, MarginProperties, RenderOperation},\n        properties::WindowSize,\n    },\n    terminal::image::Image,\n    theme::{Alignment, ColorPalette, FooterContent, FooterStyle, FooterTemplate, FooterTemplateChunk, Margin},\n};\nuse comrak::Arena;\nuse std::borrow::Cow;\nuse unicode_width::UnicodeWidthStr;\n\n#[derive(Debug, Default)]\npub(crate) struct FooterVariables {\n    pub(crate) current_slide: usize,\n    pub(crate) total_slides: usize,\n    pub(crate) author: Option<String>,\n    pub(crate) title: Option<String>,\n    pub(crate) sub_title: Option<String>,\n    pub(crate) event: Option<String>,\n    pub(crate) location: Option<String>,\n    pub(crate) date: Option<String>,\n}\n\n#[derive(Debug)]\npub(crate) struct FooterGenerator {\n    current_slide: usize,\n    total_slides: u64,\n    style: RenderedFooterStyle,\n}\n\nimpl FooterGenerator {\n    pub(crate) fn new(\n        style: FooterStyle,\n        vars: &FooterVariables,\n        palette: &ColorPalette,\n    ) -> Result<Self, InvalidFooterTemplateError> {\n        let style = RenderedFooterStyle::new(style, vars, palette)?;\n        let current_slide = vars.current_slide;\n        let total_slides = vars.total_slides as u64;\n        Ok(Self { current_slide, total_slides, style })\n    }\n\n    fn render_line(line: &FooterLine, alignment: Alignment, height: u16, operations: &mut Vec<RenderOperation>) {\n        operations.extend([\n            RenderOperation::JumpToBottomRow { index: height / 2 },\n            RenderOperation::RenderText { line: line.0.clone().into(), alignment },\n        ]);\n    }\n\n    fn push_image(&self, image: &Image, alignment: Alignment, height: u16, operations: &mut Vec<RenderOperation>) {\n        let mut properties = ImageRenderProperties::default();\n\n        operations.push(RenderOperation::ApplyMargin(MarginProperties {\n            horizontal: Margin::Fixed(0),\n            top: 1,\n            bottom: 1,\n        }));\n        match alignment {\n            Alignment::Left { .. } => {\n                operations.push(RenderOperation::JumpToColumn { index: 0 });\n                properties.position = ImagePosition::Cursor;\n            }\n            Alignment::Right { .. } => {\n                properties.position = ImagePosition::Right;\n            }\n            Alignment::Center { .. } => properties.position = ImagePosition::Center,\n        };\n        operations.extend([\n            // Start printing the image at the top of the footer rect\n            RenderOperation::JumpToBottomRow { index: height.saturating_sub(2) },\n            RenderOperation::RenderImage(image.clone(), properties),\n            RenderOperation::PopMargin,\n        ]);\n    }\n}\n\nimpl AsRenderOperations for FooterGenerator {\n    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {\n        use RenderedFooterStyle::*;\n        match &self.style {\n            Template { left, center, right, height } => {\n                // Crate a margin for ourselves so we can jump to top without stepping over slide\n                // text.\n                let mut operations = vec![RenderOperation::ApplyMargin(MarginProperties {\n                    horizontal: Margin::Fixed(1),\n                    top: dimensions.rows.saturating_sub(*height),\n                    bottom: 0,\n                })];\n                // We print this one row below the bottom so there's one row of padding.\n                let alignments = [\n                    Alignment::Left { margin: Default::default() },\n                    Alignment::Center { minimum_size: 0, minimum_margin: Default::default() },\n                    Alignment::Right { margin: Default::default() },\n                ];\n                for (content, alignment) in [left, center, right].iter().zip(alignments) {\n                    if let Some(content) = content {\n                        match content {\n                            RenderedFooterContent::Line(line) => {\n                                Self::render_line(line, alignment, *height, &mut operations);\n                            }\n                            RenderedFooterContent::Image(image) => {\n                                self.push_image(image, alignment, *height, &mut operations);\n                            }\n                        };\n                    }\n                }\n                operations.push(RenderOperation::PopMargin);\n                operations\n            }\n            ProgressBar { character, style } => {\n                let character = character.to_string();\n                let total_columns = dimensions.columns as usize / character.width();\n                let progress_ratio = (self.current_slide + 1) as f64 / self.total_slides as f64;\n                let columns_ratio = (total_columns as f64 * progress_ratio).ceil();\n                let bar = character.repeat(columns_ratio as usize);\n                let bar = Text::new(bar, *style);\n                vec![\n                    RenderOperation::JumpToBottomRow { index: 0 },\n                    RenderOperation::RenderText {\n                        line: vec![bar].into(),\n                        alignment: Alignment::Left { margin: Margin::Fixed(0) },\n                    },\n                ]\n            }\n            Empty => vec![],\n        }\n    }\n}\n\n#[derive(Debug)]\nenum RenderedFooterStyle {\n    Template {\n        left: Option<RenderedFooterContent>,\n        center: Option<RenderedFooterContent>,\n        right: Option<RenderedFooterContent>,\n        height: u16,\n    },\n    ProgressBar {\n        character: char,\n        style: TextStyle,\n    },\n    Empty,\n}\n\nimpl RenderedFooterStyle {\n    fn new(\n        style: FooterStyle,\n        vars: &FooterVariables,\n        palette: &ColorPalette,\n    ) -> Result<Self, InvalidFooterTemplateError> {\n        match style {\n            FooterStyle::Template { left, center, right, style, height } => {\n                let left = left.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;\n                let center = center.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;\n                let right = right.map(|c| RenderedFooterContent::new(c, &style, vars, palette)).transpose()?;\n                Ok(Self::Template { left, center, right, height })\n            }\n            FooterStyle::ProgressBar { character, style } => Ok(Self::ProgressBar { character, style }),\n            FooterStyle::Empty => Ok(Self::Empty),\n        }\n    }\n}\n\n#[derive(Clone, Debug)]\nstruct FooterLine(Line);\n\nimpl FooterLine {\n    fn new(\n        template: FooterTemplate,\n        style: &TextStyle,\n        vars: &FooterVariables,\n        palette: &ColorPalette,\n    ) -> Result<Self, InvalidFooterTemplateError> {\n        use FooterTemplateChunk::*;\n        let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars;\n        let arena = Arena::default();\n        let mut reassembled = String::new();\n        for chunk in template.0 {\n            let raw_text = match chunk {\n                CurrentSlide => Cow::Owned(current_slide.to_string()),\n                OpenBrace => Cow::Borrowed(\"{\"),\n                ClosedBrace => Cow::Borrowed(\"}\"),\n                Literal(text) => Cow::Owned(text),\n                TotalSlides => Cow::Owned(total_slides.to_string()),\n                Author => Self::extract_variable(\"author\", author)?,\n                Title => Self::extract_variable(\"title\", title)?,\n                SubTitle => Self::extract_variable(\"sub_title\", sub_title)?,\n                Event => Self::extract_variable(\"event\", event)?,\n                Location => Self::extract_variable(\"location\", location)?,\n                Date => Self::extract_variable(\"date\", date)?,\n            };\n            if raw_text.lines().count() != 1 {\n                return Err(InvalidFooterTemplateError::NoNewlines);\n            }\n            reassembled.push_str(&raw_text);\n        }\n        // Inline parsing loses leading/trailing whitespaces so re-add them ourselves\n        let starting_length = reassembled.len();\n        let raw_text = reassembled.trim_start();\n        let left_whitespace = starting_length - raw_text.len();\n        let raw_text = raw_text.trim_end();\n        let right_whitespace = starting_length - raw_text.len() - left_whitespace;\n        let parser = MarkdownParser::new(&arena);\n        let inlines = parser.parse_inlines(&reassembled)?;\n        let mut line = inlines.resolve(palette)?;\n        if left_whitespace != 0 {\n            line.0.insert(0, \" \".repeat(left_whitespace).into());\n        }\n        if right_whitespace != 0 {\n            line.0.push(\" \".repeat(right_whitespace).into());\n        }\n        line.apply_style(style);\n        Ok(Self(line))\n    }\n\n    fn extract_variable<'a>(\n        name: &'static str,\n        variable: &'a Option<String>,\n    ) -> Result<Cow<'a, str>, InvalidFooterTemplateError> {\n        variable.as_deref().map(Cow::Borrowed).ok_or(InvalidFooterTemplateError::VariableNotSet(name))\n    }\n}\n\n#[derive(Clone, Debug)]\nenum RenderedFooterContent {\n    Line(FooterLine),\n    Image(Image),\n}\n\nimpl RenderedFooterContent {\n    fn new(\n        content: FooterContent,\n        style: &TextStyle,\n        vars: &FooterVariables,\n        palette: &ColorPalette,\n    ) -> Result<Self, InvalidFooterTemplateError> {\n        Ok(match content {\n            FooterContent::Template(template) => Self::Line(FooterLine::new(template, style, vars, palette)?),\n            FooterContent::Image(image) => Self::Image(image),\n        })\n    }\n}\n\n#[derive(Debug, thiserror::Error)]\npub(crate) enum InvalidFooterTemplateError {\n    #[error(\"footer cannot contain multiple lines\")]\n    NoNewlines,\n\n    #[error(\"invalid markdown: {0}\")]\n    Inlines(#[from] ParseInlinesError),\n\n    #[error(transparent)]\n    PaletteColor(#[from] UndefinedPaletteColorError),\n\n    #[error(\"variable '{0}' not set\")]\n    VariableNotSet(&'static str),\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::markdown::text_style::Color;\n\n    use super::*;\n    use once_cell::sync::Lazy;\n    use rstest::rstest;\n\n    static VARIABLES: Lazy<FooterVariables> = Lazy::new(|| FooterVariables {\n        current_slide: 1,\n        total_slides: 5,\n        author: Some(\"bob\".into()),\n        title: Some(\"hi\".into()),\n        sub_title: Some(\"bye\".into()),\n        event: Some(\"test\".into()),\n        location: Some(\"here\".into()),\n        date: Some(\"now\".into()),\n    });\n\n    static PALETTE: Lazy<ColorPalette> = Lazy::new(|| ColorPalette {\n        colors: [(\"red\".into(), Color::new(255, 0, 0))].into(),\n        classes: Default::default(),\n    });\n\n    #[rstest]\n    #[case::literal(FooterTemplateChunk::Literal(\"hi\".into()), &[\"hi\".into()])]\n    #[case::literal_whitespaced(FooterTemplateChunk::Literal(\"  hi  \".into()), &[\"  \".into(), \"hi\".into(), \"  \".into()])]\n    #[case::author(FooterTemplateChunk::Author, &[\"bob\".into()])]\n    #[case::title(FooterTemplateChunk::Title, &[\"hi\".into()])]\n    #[case::sub_title(FooterTemplateChunk::SubTitle, &[\"bye\".into()])]\n    #[case::event(FooterTemplateChunk::Event, &[\"test\".into()])]\n    #[case::location(FooterTemplateChunk::Location, &[\"here\".into()])]\n    #[case::date(FooterTemplateChunk::Date, &[\"now\".into()])]\n    #[case::bold(\n        FooterTemplateChunk::Literal(\"**hi** mom\".into()),\n        &[Text::new(\"hi\", TextStyle::default().bold()), \" mom\".into()]\n    )]\n    #[case::colored(\n        FooterTemplateChunk::Literal(\"<span style=\\\"color: palette:red\\\">hi</span> mom\".into()),\n        &[Text::new(\"hi\", TextStyle::default().fg_color(Color::new(255, 0, 0))), \" mom\".into()]\n    )]\n    fn render_valid(#[case] chunk: FooterTemplateChunk, #[case] expected: &[Text]) {\n        let template = FooterTemplate(vec![chunk]);\n        let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect(\"render failed\");\n        assert_eq!(line.0.0, expected);\n    }\n\n    #[rstest]\n    #[case::non_paragraph(\n        FooterTemplateChunk::Literal(\"* hi\".into()),\n    )]\n    #[case::invalid_palette_color(\n        FooterTemplateChunk::Literal(\"<span style=\\\"color: palette:hi\\\">hi</span> mom\".into()),\n    )]\n    #[case::newlines(FooterTemplateChunk::Literal(\"hi\\nmom\".into()))]\n    fn render_invalid(#[case] chunk: FooterTemplateChunk) {\n        let template = FooterTemplate(vec![chunk]);\n        FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect_err(\"render succeeded\");\n    }\n\n    #[test]\n    fn interleaved_spans() {\n        let chunks = vec![\n            FooterTemplateChunk::Literal(\"<span style=\\\"color: palette:red\\\">\".into()),\n            FooterTemplateChunk::CurrentSlide,\n            FooterTemplateChunk::Literal(\" / \".into()),\n            FooterTemplateChunk::TotalSlides,\n            FooterTemplateChunk::Literal(\"</span>\".into()),\n            FooterTemplateChunk::Literal(\"<span style=\\\"color: green\\\">\".into()),\n            FooterTemplateChunk::Title,\n            FooterTemplateChunk::Literal(\"</span>\".into()),\n        ];\n        let template = FooterTemplate(chunks);\n        let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect(\"render failed\");\n        let expected = &[\n            Text::new(\"1 / 5\", TextStyle::default().fg_color(Color::new(255, 0, 0))),\n            Text::new(\"hi\", TextStyle::default().fg_color(Color::Green)),\n        ];\n        assert_eq!(line.0.0, expected);\n    }\n}\n"
  },
  {
    "path": "src/ui/mod.rs",
    "content": "pub(crate) mod execution;\npub(crate) mod footer;\npub(crate) mod modals;\npub(crate) mod separator;\n"
  },
  {
    "path": "src/ui/modals.rs",
    "content": "use crate::{\n    code::padding::NumberPadder,\n    commands::keyboard::KeyBinding,\n    config::KeyBindingsConfig,\n    markdown::{\n        elements::{Line, Text},\n        text::WeightedLine,\n        text_style::TextStyle,\n    },\n    presentation::PresentationState,\n    render::{\n        operation::{\n            AsRenderOperations, ImagePosition, ImageRenderProperties, ImageSize, MarginProperties, RenderOperation,\n        },\n        properties::WindowSize,\n    },\n    terminal::image::Image,\n    theme::{Margin, PresentationTheme},\n};\nuse std::{iter, rc::Rc};\nuse unicode_width::UnicodeWidthStr;\n\nstatic MODAL_Z_INDEX: i32 = -1;\n\n#[derive(Default)]\npub(crate) struct IndexBuilder {\n    titles: Vec<Line>,\n    background: Option<Image>,\n}\n\nimpl IndexBuilder {\n    pub(crate) fn add_title(&mut self, title: Line) {\n        self.titles.push(title);\n    }\n\n    pub(crate) fn set_background(&mut self, background: Image) {\n        self.background = Some(background);\n    }\n\n    pub(crate) fn build(self, theme: &PresentationTheme, state: PresentationState) -> Vec<RenderOperation> {\n        let mut builder = ModalBuilder::new(\"Slides\");\n        let padder = NumberPadder::new(self.titles.len());\n        for (index, mut title) in self.titles.into_iter().enumerate() {\n            let index = padder.pad_right(index + 1);\n            title.0.insert(0, format!(\"{index}: \").into());\n            builder.content.push(title);\n        }\n        let base_style = theme.modals.style;\n        let selection_style = theme.modals.selection_style;\n        let ModalContent { prefix, content, suffix, content_width } = builder.build(base_style);\n        let drawer = IndexDrawer {\n            prefix,\n            rows: content,\n            suffix,\n            state,\n            content_width,\n            selection_style,\n            background: self.background,\n        };\n        vec![RenderOperation::RenderDynamicTopLevel(Rc::new(drawer))]\n    }\n}\n\n#[derive(Debug)]\nstruct IndexDrawer {\n    prefix: Vec<RenderOperation>,\n    rows: Vec<ContentRow>,\n    suffix: Vec<RenderOperation>,\n    content_width: u16,\n    state: PresentationState,\n    selection_style: TextStyle,\n    background: Option<Image>,\n}\n\nimpl AsRenderOperations for IndexDrawer {\n    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let current_slide_index = self.state.current_slide_index();\n        let max_rows = (dimensions.rows as f64 * 0.8) as u16;\n        let (skip, take) = match self.rows.len() as u16 > max_rows {\n            true => {\n                let start = (current_slide_index as u16).saturating_sub(max_rows / 2);\n                let start = start.min(self.rows.len() as u16 - max_rows);\n                (start as usize, max_rows as usize)\n            }\n            false => (0, self.rows.len()),\n        };\n        let visible_rows = self.rows.iter().enumerate().skip(skip).take(take);\n        let mut operations = vec![CenterModalContent::new(self.content_width, take, self.background.clone()).into()];\n        operations.extend(self.prefix.iter().cloned());\n        for (index, row) in visible_rows {\n            let mut row = row.clone();\n            if index == current_slide_index {\n                row = row.with_style(self.selection_style);\n            }\n            let operation = RenderOperation::RenderText { line: row.build(), alignment: Default::default() };\n            operations.extend([operation, RenderOperation::RenderLineBreak]);\n        }\n        operations.extend(self.suffix.iter().cloned());\n        operations\n    }\n}\n\n#[derive(Default)]\npub(crate) struct KeyBindingsModalBuilder {\n    background: Option<Image>,\n}\n\nimpl KeyBindingsModalBuilder {\n    pub(crate) fn set_background(&mut self, background: Image) {\n        self.background = Some(background);\n    }\n\n    pub(crate) fn build(self, theme: &PresentationTheme, config: &KeyBindingsConfig) -> Vec<RenderOperation> {\n        let mut builder = ModalBuilder::new(\"Key bindings\");\n        builder.content.extend([\n            Self::build_line(\"Next\", &config.next),\n            Self::build_line(\"Next (fast)\", &config.next_fast),\n            Self::build_line(\"Previous\", &config.previous),\n            Self::build_line(\"Previous (fast)\", &config.previous_fast),\n            Self::build_line(\"First slide\", &config.first_slide),\n            Self::build_line(\"Last slide\", &config.last_slide),\n            Self::build_line(\"Go to slide\", &config.go_to_slide),\n            Self::build_line(\"Execute code\", &config.execute_code),\n            Self::build_line(\"Reload\", &config.reload),\n            Self::build_line(\"Toggle slide index\", &config.toggle_slide_index),\n            Self::build_line(\"Close modal\", &config.close_modal),\n            Self::build_line(\"Exit\", &config.exit),\n        ]);\n        let lines = builder.content.len();\n        let style = theme.modals.style;\n        let content = builder.build(style);\n        let content_width = content.content_width;\n        let mut operations = content.into_operations();\n        operations.insert(0, CenterModalContent::new(content_width, lines, self.background).into());\n        operations\n    }\n\n    fn build_line(label: &str, bindings: &[KeyBinding]) -> Line {\n        let mut text = vec![Text::new(label, TextStyle::default().bold()), \": \".into()];\n        for (index, binding) in bindings.iter().enumerate() {\n            if index > 0 {\n                text.push(\", \".into());\n            }\n            text.push(Text::new(binding.to_string(), TextStyle::default().italics()));\n        }\n        Line(text)\n    }\n}\n\nstruct ModalBuilder {\n    heading: String,\n    content: Vec<Line>,\n}\n\nimpl ModalBuilder {\n    fn new<S: Into<String>>(heading: S) -> Self {\n        Self { heading: heading.into(), content: Vec::new() }\n    }\n\n    fn build(self, style: TextStyle) -> ModalContent {\n        let longest_line = self.content.iter().map(Line::width).max().unwrap_or(0) as u16;\n        let longest_line = longest_line.max(self.heading.len() as u16);\n        // Ensure we have a minimum width so it doesn't look too narrow.\n        let longest_line = longest_line.max(12);\n        // The final text looks like \"|  <content>  |\"\n        let content_width = longest_line + 6;\n        let mut prefix = vec![RenderOperation::SetColors(style.colors)];\n\n        let heading = Self::center_line(self.heading, longest_line as usize);\n        prefix.extend(Border::Top.render_line(content_width));\n        prefix.extend([\n            RenderOperation::RenderText {\n                line: Self::build_line(vec![Text::from(heading)], content_width).build(),\n                alignment: Default::default(),\n            },\n            RenderOperation::RenderLineBreak,\n        ]);\n        prefix.extend(Border::Separator.render_line(content_width));\n        let mut content = Vec::new();\n        for title in self.content {\n            content.push(Self::build_line(title.0, content_width));\n        }\n        let suffix = Border::Bottom.render_line(content_width).into_iter().collect();\n        ModalContent { prefix, content, suffix, content_width }\n    }\n\n    fn center_line(text: String, longest_line: usize) -> String {\n        let missing = longest_line.saturating_sub(text.len());\n        let padding = missing / 2;\n        let mut output = \" \".repeat(padding);\n        output.push_str(&text);\n        output.extend(iter::repeat_n(' ', padding));\n        output\n    }\n\n    fn build_line(text_chunks: Vec<Text>, content_width: u16) -> ContentRow {\n        let (opening, closing) = Border::Regular.edges();\n        let prefix = Text::from(format!(\"{opening}  \"));\n        let content = text_chunks;\n        let total_width = content.iter().map(|c| c.content.width()).sum::<usize>() + prefix.content.width();\n        let missing = content_width as usize - 1 - total_width;\n\n        let mut suffix = \" \".repeat(missing);\n        suffix.push(closing);\n        ContentRow { prefix, content, suffix: suffix.into() }\n    }\n}\n\nstruct ModalContent {\n    prefix: Vec<RenderOperation>,\n    content: Vec<ContentRow>,\n    suffix: Vec<RenderOperation>,\n    content_width: u16,\n}\n\nimpl ModalContent {\n    fn into_operations(self) -> Vec<RenderOperation> {\n        let mut operations = self.prefix;\n        operations.extend(self.content.into_iter().flat_map(|c| {\n            [\n                RenderOperation::RenderText { line: c.build(), alignment: Default::default() },\n                RenderOperation::RenderLineBreak,\n            ]\n        }));\n        operations.extend(self.suffix);\n        operations\n    }\n}\n\n#[derive(Clone, Debug)]\nstruct ContentRow {\n    prefix: Text,\n    content: Vec<Text>,\n    suffix: Text,\n}\n\nimpl ContentRow {\n    fn with_style(mut self, style: TextStyle) -> ContentRow {\n        for chunk in &mut self.content {\n            chunk.style.merge(&style);\n        }\n        self\n    }\n\n    fn build(self) -> WeightedLine {\n        let mut chunks = self.content;\n        chunks.insert(0, self.prefix);\n        chunks.push(self.suffix);\n        WeightedLine::from(chunks)\n    }\n}\n\nenum Border {\n    Regular,\n    Top,\n    Separator,\n    Bottom,\n}\n\nimpl Border {\n    fn render_line(&self, content_length: u16) -> [RenderOperation; 2] {\n        let (opening, closing) = self.edges();\n        let mut line = String::from(opening);\n        line.push_str(&\"─\".repeat(content_length.saturating_sub(2) as usize));\n        line.push(closing);\n        let horizontal_border = WeightedLine::from(vec![Text::from(line)]);\n        [\n            RenderOperation::RenderText { line: horizontal_border.clone(), alignment: Default::default() },\n            RenderOperation::RenderLineBreak,\n        ]\n    }\n\n    fn edges(&self) -> (char, char) {\n        match self {\n            Self::Regular => ('│', '│'),\n            Self::Top => ('┌', '┐'),\n            Self::Separator => ('├', '┤'),\n            Self::Bottom => ('└', '┘'),\n        }\n    }\n}\n\n#[derive(Debug)]\nstruct CenterModalContent {\n    content_width: u16,\n    content_height: usize,\n    background: Option<Image>,\n}\n\nimpl CenterModalContent {\n    fn new(content_width: u16, content_height: usize, background: Option<Image>) -> Self {\n        Self { content_width, content_height, background }\n    }\n}\n\nimpl AsRenderOperations for CenterModalContent {\n    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let margin = dimensions.columns.saturating_sub(self.content_width) / 2;\n        let properties = MarginProperties { horizontal: Margin::Fixed(margin), top: 0, bottom: 0 };\n        // However many we see + 3 for the title and 1 at the bottom.\n        let content_height = (self.content_height + 4) as u16;\n        let target_row = dimensions.rows.saturating_sub(content_height) / 2;\n\n        let mut operations =\n            vec![RenderOperation::ApplyMargin(properties), RenderOperation::JumpToRow { index: target_row }];\n        if let Some(image) = &self.background {\n            let properties = ImageRenderProperties {\n                z_index: MODAL_Z_INDEX,\n                size: ImageSize::Specific(self.content_width, content_height),\n                restore_cursor: true,\n                background_color: None,\n                position: ImagePosition::Center,\n            };\n            operations.push(RenderOperation::RenderImage(image.clone(), properties));\n        }\n        operations\n    }\n}\n\nimpl From<CenterModalContent> for RenderOperation {\n    fn from(op: CenterModalContent) -> Self {\n        Self::RenderDynamicTopLevel(Rc::new(op))\n    }\n}\n"
  },
  {
    "path": "src/ui/separator.rs",
    "content": "use crate::{\n    markdown::{\n        elements::{Line, Text},\n        text_style::TextStyle,\n    },\n    render::{\n        layout::{Layout, Positioning},\n        operation::{AsRenderOperations, BlockLine, RenderOperation},\n        properties::WindowSize,\n    },\n    theme::{Alignment, Margin},\n};\nuse std::rc::Rc;\n\n#[derive(Clone, Copy, Debug, Default)]\npub(crate) enum SeparatorWidth {\n    Fixed(u16),\n\n    #[default]\n    FitToWindow,\n}\n\n#[derive(Clone, Debug)]\npub(crate) struct RenderSeparator {\n    heading: Line,\n    width: SeparatorWidth,\n    font_size: u8,\n}\n\nimpl RenderSeparator {\n    pub(crate) fn new<S: Into<Line>>(heading: S, width: SeparatorWidth, font_size: u8) -> Self {\n        let mut heading: Line = heading.into();\n        heading.apply_style(&TextStyle::default().size(font_size));\n        Self { heading, width, font_size }\n    }\n}\n\nimpl From<RenderSeparator> for RenderOperation {\n    fn from(separator: RenderSeparator) -> Self {\n        Self::RenderDynamic(Rc::new(separator))\n    }\n}\n\nimpl AsRenderOperations for RenderSeparator {\n    fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {\n        let character = \"—\";\n        let width = match self.width {\n            SeparatorWidth::Fixed(width) => {\n                let Positioning { max_line_length, .. } =\n                    Layout::new(Alignment::Center { minimum_margin: Margin::Fixed(0), minimum_size: 0 })\n                        .with_font_size(self.font_size)\n                        .compute(dimensions, width);\n                max_line_length.min(width) as usize\n            }\n            SeparatorWidth::FitToWindow => dimensions.columns as usize,\n        };\n        let style = TextStyle::default().size(self.font_size);\n        let width = width / self.font_size as usize;\n        let separator = match self.heading.width() == 0 {\n            true => Line::from(Text::new(character.repeat(width), style)),\n            false => {\n                let width = width.saturating_sub(self.heading.width());\n                let (dashes_len, remainder) = (width / 2, width % 2);\n                let mut dashes = character.repeat(dashes_len);\n                let mut line = Line::from(Text::new(dashes.clone(), style));\n                line.0.extend(self.heading.0.iter().cloned());\n\n                if remainder > 0 {\n                    dashes.push_str(character);\n                }\n                line.0.push(Text::new(dashes, style));\n                line\n            }\n        };\n        vec![RenderOperation::RenderBlockLine(BlockLine {\n            prefix: \"\".into(),\n            right_padding_length: 0,\n            repeat_prefix_on_wrap: false,\n            text: separator.into(),\n            block_length: width as u16,\n            block_color: None,\n            alignment: Alignment::Center { minimum_size: 1, minimum_margin: Margin::Fixed(0) },\n        })]\n    }\n}\n"
  },
  {
    "path": "src/utils.rs",
    "content": "use serde::{Deserializer, Serializer};\nuse std::{\n    fmt::{self, Display},\n    marker::PhantomData,\n    str::FromStr,\n};\n\nmacro_rules! impl_deserialize_from_str {\n    ($ty:ty) => {\n        impl<'de> serde::de::Deserialize<'de> for $ty {\n            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n            where\n                D: serde::de::Deserializer<'de>,\n            {\n                $crate::utils::deserialize_from_str(deserializer)\n            }\n        }\n    };\n}\n\nmacro_rules! impl_serialize_from_display {\n    ($ty:ty) => {\n        impl serde::Serialize for $ty {\n            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>\n            where\n                S: serde::Serializer,\n            {\n                $crate::utils::serialize_display(self, serializer)\n            }\n        }\n    };\n}\n\npub(crate) use impl_deserialize_from_str;\npub(crate) use impl_serialize_from_display;\n\n// Same behavior as serde_with::DeserializeFromStr\npub(crate) fn deserialize_from_str<'de, D, T>(deserializer: D) -> Result<T, D::Error>\nwhere\n    D: Deserializer<'de>,\n    T: FromStr,\n    T::Err: Display,\n{\n    struct Visitor<S>(PhantomData<S>);\n\n    impl<S> serde::de::Visitor<'_> for Visitor<S>\n    where\n        S: FromStr,\n        <S as FromStr>::Err: Display,\n    {\n        type Value = S;\n\n        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {\n            write!(formatter, \"a string\")\n        }\n\n        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>\n        where\n            E: serde::de::Error,\n        {\n            value.parse::<S>().map_err(serde::de::Error::custom)\n        }\n    }\n\n    deserializer.deserialize_str(Visitor(PhantomData))\n}\n\n// Same behavior as serde_with::SerializeDisplay\npub(crate) fn serialize_display<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>\nwhere\n    T: Display,\n    S: Serializer,\n{\n    serializer.serialize_str(&value.to_string())\n}\n"
  },
  {
    "path": "themes/catppuccin-frappe.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: palette:text\n    background: palette:base\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: palette:yellow\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  status:\n    running:\n      foreground: palette:blue\n    success:\n      foreground: palette:green\n    failure:\n      foreground: palette:red\n    not_started:\n      foreground: palette:yellow\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  cursor:\n    highlight_colors:\n      foreground: palette:surface0\n      background: palette:text\n\ninline_code:\n  colors:\n    foreground: palette:green\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: palette:green\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  event:\n    alignment: center\n    colors:\n      foreground: palette:green\n  location:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  date:\n    alignment: center\n    colors:\n      foreground: palette:yellow\n  author:\n    alignment: center\n    colors:\n      foreground: palette:subtext1\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: palette:teal\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: palette:mauve\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: palette:blue\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: palette:red\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: palette:green\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: palette:peach\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n    prefix: palette:yellow\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: palette:text\n    background: palette:surface0\n  styles:\n    note:\n      color: palette:blue\n    tip:\n      color: palette:green\n    important:\n      color: palette:mauve\n    warning:\n      color: palette:yellow\n    caution:\n      color: palette:red\n\ntypst:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: palette:sapphire\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 4\n\nlayout_grid:\n  color: palette:blue\n\npalette:\n  colors:\n    rosewater: \"f2d5cf\"\n    flamingo: \"eebebe\"\n    pink: \"f4b8e4\"\n    mauve: \"ca9ee6\"\n    red: \"e78284\"\n    maroon: \"ea999c\"\n    peach: \"ef9f76\"\n    yellow: \"e5c890\"\n    green: \"a6d189\"\n    teal: \"81c8be\"\n    sky: \"99d1db\"\n    sapphire: \"85c1dc\"\n    blue: \"8caaee\"\n    lavender: \"babbf1\"\n    text: \"c6d0f5\"\n    subtext1: \"b5bfe2\"\n    subtext0: \"a5adce\"\n    overlay2: \"949cbb\"\n    overlay1: \"838ba7\"\n    overlay0: \"737994\"\n    surface2: \"626880\"\n    surface1: \"51576d\"\n    surface0: \"414559\"\n    base: \"303446\"\n    mantle: \"292c3c\"\n    crust: \"232634\"\n"
  },
  {
    "path": "themes/catppuccin-latte.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: palette:text\n    background: palette:base\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: palette:yellow\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: GitHub\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  status:\n    running:\n      foreground: palette:sky\n    success:\n      foreground: palette:green\n    failure:\n      foreground: palette:red\n    not_started:\n      foreground: palette:yellow\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  cursor:\n    highlight_colors:\n      foreground: palette:surface0\n      background: palette:text\n\ninline_code:\n  colors:\n    foreground: palette:green\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: palette:green\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  event:\n    alignment: center\n    colors:\n      foreground: palette:green\n  location:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  date:\n    alignment: center\n    colors:\n      foreground: palette:yellow\n  author:\n    alignment: center\n    colors:\n      foreground: palette:subtext1\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: palette:teal\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: palette:mauve\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: palette:blue\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: palette:red\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: palette:green\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: palette:peach\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n    prefix: palette:yellow\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: palette:text\n    background: palette:surface0\n  styles:\n    note:\n      color: palette:blue\n    tip:\n      color: palette:green\n    important:\n      color: palette:mauve\n    warning:\n      color: palette:yellow\n    caution:\n      color: palette:red\n\ntypst:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: palette:sapphire\n\nmermaid:\n  background: transparent\n  theme: default\n\nd2:\n  theme: 100\n\nlayout_grid:\n  color: palette:blue\n\npalette:\n  colors:\n    rosewater: \"dc8a78\"\n    flamingo: \"dd7878\"\n    pink: \"ea76cb\"\n    mauve: \"8839ef\"\n    red: \"d20f39\"\n    maroon: \"e64553\"\n    peach: \"fe640b\"\n    yellow: \"df8e1d\"\n    green: \"40a02b\"\n    teal: \"179299\"\n    sky: \"04a5e5\"\n    sapphire: \"209fb5\"\n    blue: \"1e66f5\"\n    lavender: \"7287fd\"\n    text: \"4c4f69\"\n    subtext1: \"5c5f77\"\n    subtext0: \"6c6f85\"\n    overlay2: \"7c7f93\"\n    overlay1: \"8c8fa1\"\n    overlay0: \"9ca0b0\"\n    surface2: \"acb0be\"\n    surface1: \"bcc0cc\"\n    surface0: \"ccd0da\"\n    base: \"eff1f5\"\n    mantle: \"e6e9ef\"\n    crust: \"dce0e8\"\n"
  },
  {
    "path": "themes/catppuccin-macchiato.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: palette:text\n    background: palette:base\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: palette:yellow\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  status:\n    running:\n      foreground: palette:sky\n    success:\n      foreground: palette:green\n    failure:\n      foreground: palette:red\n    not_started:\n      foreground: palette:yellow\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  cursor:\n    highlight_colors:\n      foreground: palette:surface0\n      background: palette:text\n\ninline_code:\n  colors:\n    foreground: palette:green\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: palette:green\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  event:\n    alignment: center\n    colors:\n      foreground: palette:green\n  location:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  date:\n    alignment: center\n    colors:\n      foreground: palette:yellow\n  author:\n    alignment: center\n    colors:\n      foreground: palette:subtext1\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: palette:teal\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: palette:mauve\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: palette:blue\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: palette:red\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: palette:green\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: palette:peach\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n    prefix: palette:yellow\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: palette:text\n    background: palette:surface0\n  styles:\n    note:\n      color: palette:blue\n    tip:\n      color: palette:green\n    important:\n      color: palette:mauve\n    warning:\n      color: palette:peach\n    caution:\n      color: palette:red\n\ntypst:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: palette:sapphire\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: palette:blue\n\npalette:\n  colors:\n    rosewater: \"f4dbd6\"\n    flamingo: \"f0c6c6\"\n    pink: \"f5bde6\"\n    mauve: \"c6a0f6\"\n    red: \"ed8796\"\n    maroon: \"ee99a0\"\n    peach: \"f5a97f\"\n    yellow: \"eed49f\"\n    green: \"a6da95\"\n    teal: \"8bd5ca\"\n    sky: \"91d7e3\"\n    sapphire: \"7dc4e4\"\n    blue: \"8aadf4\"\n    lavender: \"b7bdf8\"\n    text: \"cad3f5\"\n    subtext1: \"b8c0e0\"\n    subtext0: \"a5adcb\"\n    overlay2: \"939ab7\"\n    overlay1: \"8087a2\"\n    overlay0: \"6e738d\"\n    surface2: \"5b6078\"\n    surface1: \"494d64\"\n    surface0: \"363a4f\"\n    base: \"24273a\"\n    mantle: \"1e2030\"\n    crust: \"181926\"\n"
  },
  {
    "path": "themes/catppuccin-mocha.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: palette:text\n    background: palette:base\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: palette:yellow\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  status:\n    running:\n      foreground: palette:sky\n    success:\n      foreground: palette:green\n    failure:\n      foreground: palette:red\n    not_started:\n      foreground: palette:yellow\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n  cursor:\n    highlight_colors:\n      foreground: palette:surface0\n      background: palette:text\n\ninline_code:\n  colors:\n    foreground: palette:green\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: palette:green\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  event:\n    alignment: center\n    colors:\n      foreground: palette:green\n  location:\n    alignment: center\n    colors:\n      foreground: palette:sapphire\n  date:\n    alignment: center\n    colors:\n      foreground: palette:yellow\n  author:\n    alignment: center\n    colors:\n      foreground: palette:subtext1\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: palette:teal\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: palette:mauve\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: palette:blue\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: palette:red\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: palette:green\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: palette:peach\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n    prefix: palette:yellow\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: palette:text\n    background: palette:surface0\n  styles:\n    note:\n      color: palette:blue\n    tip:\n      color: palette:green\n    important:\n      color: palette:mauve\n    warning:\n      color: palette:peach\n    caution:\n      color: palette:red\n\ntypst:\n  colors:\n    foreground: palette:text\n    background: palette:surface0\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: palette:sapphire\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: palette:blue\n\npalette:\n  colors:\n    rosewater: \"f5e0dc\"\n    flamingo: \"f2cdcd\"\n    pink: \"f5c2e7\"\n    mauve: \"cba6f7\"\n    red: \"f38ba8\"\n    maroon: \"eba0ac\"\n    peach: \"fab387\"\n    yellow: \"f9e2af\"\n    green: \"a6e3a1\"\n    teal: \"94e2d5\"\n    sky: \"89dceb\"\n    sapphire: \"74c7ec\"\n    blue: \"89b4fa\"\n    lavender: \"b4befe\"\n    text: \"cdd6f4\"\n    subtext1: \"bac2de\"\n    subtext0: \"a6adc8\"\n    overlay2: \"9399b2\"\n    overlay1: \"7f849c\"\n    overlay0: \"6c7086\"\n    surface2: \"585b70\"\n    surface1: \"45475a\"\n    surface0: \"313244\"\n    base: \"1e1e2e\"\n    mantle: \"181825\"\n    crust: \"11111b\"\n"
  },
  {
    "path": "themes/dark.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: palette:white\n    background: \"040312\"\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: palette:orange\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: palette:white\n    background: palette:black\n  status:\n    running:\n      foreground: palette:light_blue\n    success:\n      foreground: palette:light_green\n    failure:\n      foreground: palette:red\n    not_started:\n      foreground: palette:orange\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: palette:white\n    background: palette:black\n  cursor:\n    highlight_colors:\n      foreground: palette:black\n      background: palette:white\n\ninline_code:\n  colors:\n    foreground: \"04de20\"\n    background: \"455045\"\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: palette:light_blue\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: palette:aqua\n  event:\n    alignment: center\n    colors:\n      foreground: palette:light_blue\n  location:\n    alignment: center\n    colors:\n      foreground: palette:aqua\n  date:\n    alignment: center\n    colors:\n      foreground: palette:orange\n  author:\n    alignment: center\n    colors:\n      foreground: \"b6eada\"\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: palette:blue \n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: palette:light_green\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: palette:red\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: palette:gray\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: palette:gray\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: palette:gray\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: palette:light_gray\n    background: palette:blue_gray\n    prefix: palette:orange\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: palette:light_gray\n    background: palette:blue_gray\n  styles:\n    note:\n      color: palette:blue\n    tip:\n      color: palette:light_green\n    important:\n      color: palette:purple\n    warning:\n      color: palette:orange\n    caution:\n      color: palette:red\n\ntypst:\n  colors:\n    foreground: palette:light_gray\n    background: palette:blue_gray\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: palette:orange\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: palette:blue\n\npalette:\n  colors:\n    blue: \"3085c3\"\n    light_blue: \"b4ccff\"\n    blue_gray: \"292e42\"\n    aqua: \"a5d7e8\"\n    light_green: \"a8df8e\"\n    red: \"f78ca2\"\n    orange: \"ee9322\"\n    purple: \"986ee2\"\n    white: \"e6e6e6\"\n    black: \"2d2d2d\"\n    gray: \"d2d2d2\"\n    light_gray: \"f0f0f0\"\n"
  },
  {
    "path": "themes/gruvbox-dark.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: \"ebdbb2\"\n    background: \"282828\"\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: \"fabd2f\"\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: \"ebdbb2\"\n    background: \"3c3836\"\n  status:\n    running:\n      foreground: \"83a598\"\n    success:\n      foreground: \"b8bb26\"\n    failure:\n      foreground: \"fb4934\"\n    not_started:\n      foreground: \"fabd2f\"\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: \"ebdbb2\"\n    background: \"3c3836\"\n  cursor:\n    highlight_colors:\n      foreground: \"3c3836\"\n      background: \"ebdbb2\"\n\ninline_code:\n  colors:\n    foreground: \"b8bb26\"\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: \"b8bb26\"\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: \"83a598\"\n  event:\n    alignment: center\n    colors:\n      foreground: \"b8bb26\"\n  location:\n    alignment: center\n    colors:\n      foreground: \"83a598\"\n  date:\n    alignment: center\n    colors:\n      foreground: \"fabd2f\"\n  author:\n    alignment: center\n    colors:\n      foreground: \"d5c4a1\"\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: \"8ec07c\"\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: \"d3869b\"\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: \"83a598\"\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: \"fb4934\"\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: \"b8bb26\"\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: \"fe8019\"\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: \"ebdbb2\"\n    background: \"3c3836\"\n    prefix: \"fabd2f\"\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: \"ebdbb2\"\n    background: \"3c3836\"\n  styles:\n    note:\n      color: \"83a598\"\n    tip:\n      color: \"b8bb26\"\n    important:\n      color: \"d3869b\"\n    warning:\n      color: \"fe8019\"\n    caution:\n      color: \"fb4934\"\n\ntypst:\n  colors:\n    foreground: \"ebdbb2\"\n    background: \"3c3836\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: \"83a598\"\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 103\n\nlayout_grid:\n  color: \"83a598\" \n"
  },
  {
    "path": "themes/light.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: \"212529\"\n    background: \"f8f9fa\"\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: \"f77f00\"\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: GitHub\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: \"212529\"\n    background: \"e9ecef\"\n  status:\n    running:\n      foreground: \"457b9d\"\n    success:\n      foreground: \"52b788\"\n    failure:\n      foreground: \"f07167\"\n    not_started:\n      foreground: \"f77f00\"\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: \"212529\"\n    background: \"e9ecef\"\n  cursor:\n    highlight_colors:\n      foreground: \"e9ecef\"\n      background: \"212529\"\n\ninline_code:\n  colors:\n    foreground: \"f07167\"\n    background: \"f5cac3\"\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: \"52b788\"\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: \"8e9aaf\"\n  event:\n    alignment: center\n    colors:\n      foreground: \"52b788\"\n  location:\n    alignment: center\n    colors:\n      foreground: \"f77f00\"\n  date:\n    alignment: center\n    colors:\n      foreground: \"f77f00\"\n  author:\n    alignment: center\n    colors:\n      foreground: \"4a4e69\"\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: \"1d3557\"\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: \"457b9d\"\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: \"4a4e69\"\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: \"4a4e69\"\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: \"4a4e69\"\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: \"4a4e69\"\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: \"212529\"\n    background: \"e9ecef\"\n    prefix: \"f77f00\"\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: \"212529\"\n    background: \"e9ecef\"\n  styles:\n    note:\n      color: \"1e66f5\"\n    tip:\n      color: \"40a02b\"\n    important:\n      color: \"8839ef\"\n    warning:\n      color: \"df8e1d\"\n    caution:\n      color: \"d20f39\"\n\ntypst:\n  colors:\n    foreground: \"212529\"\n    background: \"e9ecef\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: \"f77f00\"\n\nmermaid:\n  background: transparent\n  theme: default\n\nd2:\n  theme: 4\n\nlayout_grid:\n  color: \"457b9d\"\n"
  },
  {
    "path": "themes/terminal-dark.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: null\n    background: null\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: yellow\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n  background: false\n\nexecution_output:\n  colors:\n    foreground: white\n  status:\n    running:\n      foreground: blue\n    success:\n      foreground: green\n    failure:\n      foreground: red\n    not_started:\n      foreground: yellow\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: white\n  cursor:\n    highlight_colors:\n      foreground: black\n      background: white\n\ninline_code:\n  colors:\n    foreground: green\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: green\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: blue\n  event:\n    alignment: center\n    colors:\n      foreground: green\n  location:\n    alignment: center\n    colors:\n      foreground: blue\n  date:\n    alignment: center\n    colors:\n      foreground: yellow\n  author:\n    alignment: center\n    colors:\n      foreground: white\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: cyan\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: magenta\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: red\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: blue\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: blue\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: blue\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: white\n    background: black\n    prefix: yellow\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: white\n    background: black\n  styles:\n    note:\n      color: blue\n    tip:\n      color: green\n    important:\n      color: magenta\n    warning:\n      color: yellow\n    caution:\n      color: red\n\ntypst:\n  colors:\n    foreground: \"f0f0f0\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: yellow\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: blue\n\n"
  },
  {
    "path": "themes/terminal-light.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: null\n    background: null\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: dark_yellow\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: GitHub\n  padding:\n    horizontal: 2\n    vertical: 1\n  background: false\n\nexecution_output:\n  colors:\n    foreground: black\n  status:\n    running:\n      foreground: dark_blue\n    success:\n      foreground: dark_green\n    failure:\n      foreground: dark_red\n    not_started:\n      foreground: dark_yellow\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: black\n  cursor:\n    highlight_colors:\n      foreground: white\n      background: black\n\ninline_code:\n  colors:\n    foreground: dark_green\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: dark_green\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: dark_blue\n  event:\n    alignment: center\n    colors:\n      foreground: dark_green\n  location:\n    alignment: center\n    colors:\n      foreground: dark_blue\n  date:\n    alignment: center\n    colors:\n      foreground: dark_yellow\n  author:\n    alignment: center\n    colors:\n      foreground: black\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: dark_cyan\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: dark_magenta\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: dark_red\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: dark_blue\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: dark_blue\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: dark_blue\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: black\n    background: grey\n    prefix: dark_red\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: black\n    background: grey\n  styles:\n    note:\n      color: dark_blue\n    tip:\n      color: dark_green\n    important:\n      color: dark_magenta\n    warning:\n      color: dark_yellow\n    caution:\n      color: dark_red\n\ntypst:\n  colors:\n    foreground: \"212529\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: dark_yellow\n\nmermaid:\n  background: transparent\n  theme: default\n\nd2:\n  theme: 4\n\nlayout_grid:\n  color: blue\n"
  },
  {
    "path": "themes/tokyonight-day.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: \"3760bf\"\n    background: \"e1e2e7\"\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: \"8c6c3e\"\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: GitHub\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: \"3760bf\"\n    background: \"2d2d2d\"\n  status:\n    running:\n      foreground: \"2e7de9\"\n    success:\n      foreground: \"587539\"\n    failure:\n      foreground: \"f52a65\"\n    not_started:\n      foreground: \"8c6c3e\"\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: \"3760bf\"\n    background: \"2d2d2d\"\n  cursor:\n    highlight_colors:\n      foreground: \"2d2d2d\"\n      background: \"3760bf\"\n\ninline_code:\n  colors:\n    foreground: \"587539\"\n    background: \"95b96e\"\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: \"2e7de9\"\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: \"b4b5b9\"\n  event:\n    alignment: center\n    colors:\n      foreground: \"2e7de9\"\n  location:\n    alignment: center\n    colors:\n      foreground: \"b4b5b9\"\n  date:\n    alignment: center\n    colors:\n      foreground: \"8c6c3e\"\n  author:\n    alignment: center\n    colors:\n      foreground: \"587539\"\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: \"587539\"\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: \"f52a65\"\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: \"2e7de9\"\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: \"9854f1\"\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: \"9854f1\"\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: \"9854f1\"\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: \"3760bf\"\n    background: \"d4d6e4\"\n    prefix: \"8c6c3e\"\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: \"3760bf\"\n    background: \"d4d6e4\"\n  styles:\n    note:\n      color: \"2e7de9\"\n    tip:\n      color: \"587539\"\n    important:\n      color: \"9854f1\"\n    warning:\n      color: \"8c6c3e\"\n    caution:\n      color: \"f52a65\"\n\ntypst:\n  colors:\n    foreground: \"3760bf\"\n    background: \"545c7e\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: \"8c6c3e\"\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: \"2e7de9\"\n"
  },
  {
    "path": "themes/tokyonight-moon.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: \"c8d3f5\"\n    background: \"222436\"\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: \"ffc777\"\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: \"c8d3f5\"\n    background: \"2d2d2d\"\n  status:\n    running:\n      foreground: \"82aaff\"\n    success:\n      foreground: \"c3e88d\"\n    failure:\n      foreground: \"ff757f\"\n    not_started:\n      foreground: \"ffc777\"\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: \"c8d3f5\"\n    background: \"2d2d2d\"\n  cursor:\n    highlight_colors:\n      foreground: \"2d2d2d\"\n      background: \"c8d3f5\"\n\ninline_code:\n  colors:\n    foreground: \"c3e88d\"\n    background: \"364a82\"\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: \"82aaff\"\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: \"828bb8\"\n  event:\n    alignment: center\n    colors:\n      foreground: \"82aaff\"\n  location:\n    alignment: center\n    colors:\n      foreground: \"828bb8\"\n  date:\n    alignment: center\n    colors:\n      foreground: \"ffc777\"\n  author:\n    alignment: center\n    colors:\n      foreground: \"c3e88d\"\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: \"c3e88d\"\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: \"ff757f\"\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: \"82aaff\"\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: \"c099ff\"\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: \"c099ff\"\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: \"c099ff\"\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n    prefix: \"ffc777\"\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n  styles:\n    note:\n      color: \"82aaff\"\n    tip:\n      color: \"c3e88d\"\n    important:\n      color: \"c099ff\"\n    warning:\n      color: \"ffc777\"\n    caution:\n      color: \"ff757f\"\n\ntypst:\n  colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: \"ffc777\"\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: \"82aaff\"\n"
  },
  {
    "path": "themes/tokyonight-night.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: \"c0caf5\"\n    background: \"1a1b26\"\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: \"e0af68\"\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: \"c0caf5\"\n    background: \"2d2d2d\"\n  status:\n    running:\n      foreground: \"7aa2f7\"\n    success:\n      foreground: \"9ece6a\"\n    failure:\n      foreground: \"f7768e\"\n    not_started:\n      foreground: \"e0af68\"\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: \"c0caf5\"\n    background: \"2d2d2d\"\n  cursor:\n    highlight_colors:\n      foreground: \"2d2d2d\"\n      background: \"c0caf5\"\n\ninline_code:\n  colors:\n    foreground: \"9ece6a\"\n    background: \"364a82\"\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: \"7aa2f7\"\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: \"a9b1d6\"\n  event:\n    alignment: center\n    colors:\n      foreground: \"7aa2f7\"\n  location:\n    alignment: center\n    colors:\n      foreground: \"a9b1d6\"\n  date:\n    alignment: center\n    colors:\n      foreground: \"e0af68\"\n  author:\n    alignment: center\n    colors:\n      foreground: \"9ece6a\"\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: \"9ece6a\"\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: \"f7768e\"\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: \"7aa2f7\"\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: \"bb9af7\"\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: \"bb9af7\"\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: \"bb9af7\"\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n    prefix: \"e0af68\"\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n  styles:\n    note:\n      color: \"7aa2f7\"\n    tip:\n      color: \"9ece6a\"\n    important:\n      color: \"bb9af7\"\n    warning:\n      color: \"e0af68\"\n    caution:\n      color: \"f7768e\"\n\ntypst:\n  colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: \"e0af68\"\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: \"7aa2f7\"\n"
  },
  {
    "path": "themes/tokyonight-storm.yaml",
    "content": "---\ndefault:\n  margin:\n    percent: 8\n  colors:\n    foreground: \"c0caf5\"\n    background: \"24283b\"\n\ncolumn_layout:\n  margin:\n    fixed: 4\n\nslide_title:\n  alignment: center\n  padding_bottom: 1\n  padding_top: 1\n  colors:\n    foreground: \"e0af68\"\n  bold: true\n  font_size: 2\n\ncode:\n  alignment: center\n  minimum_size: 50\n  minimum_margin:\n    percent: 8\n  theme_name: base16-eighties.dark\n  padding:\n    horizontal: 2\n    vertical: 1\n\nexecution_output:\n  colors:\n    foreground: \"c0caf5\"\n    background: \"2d2d2d\"\n  status:\n    running:\n      foreground: \"7aa2f7\"\n    success:\n      foreground: \"9ece6a\"\n    failure:\n      foreground: \"f7768e\"\n    not_started:\n      foreground: \"e0af68\"\n  padding:\n    horizontal: 2\n    vertical: 1\n\npty_output:\n  colors:\n    foreground: \"c0caf5\"\n    background: \"2d2d2d\"\n  cursor:\n    highlight_colors:\n      foreground: \"2d2d2d\"\n      background: \"c0caf5\"\n\ninline_code:\n  colors:\n    foreground: \"9ece6a\"\n    background: \"364a82\"\n\nintro_slide:\n  title:\n    alignment: center\n    colors:\n      foreground: \"7aa2f7\"\n    font_size: 2\n  subtitle:\n    alignment: center\n    colors:\n      foreground: \"a9b1d6\"\n  event:\n    alignment: center\n    colors:\n      foreground: \"7aa2f7\"\n  location:\n    alignment: center\n    colors:\n      foreground: \"a9b1d6\"\n  date:\n    alignment: center\n    colors:\n      foreground: \"e0af68\"\n  author:\n    alignment: center\n    colors:\n      foreground: \"9ece6a\"\n    positioning: page_bottom\n  footer: false\n\nheadings:\n  h1:\n    prefix: \"██\"\n    colors:\n      foreground: \"9ece6a\"\n  h2:\n    prefix: \"▓▓▓\"\n    colors:\n      foreground: \"f7768e\"\n  h3:\n    prefix: \"▒▒▒▒\"\n    colors:\n      foreground: \"7aa2f7\"\n  h4:\n    prefix: \"░░░░░\"\n    colors:\n      foreground: \"bb9af7\"\n  h5:\n    prefix: \"░░░░░░\"\n    colors:\n      foreground: \"bb9af7\"\n  h6:\n    prefix: \"░░░░░░░\"\n    colors:\n      foreground: \"bb9af7\"\n\nblock_quote:\n  prefix: \"▍ \"\n  colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n    prefix: \"e0af68\"\n\nalert:\n  prefix: \"▍ \"\n  base_colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n  styles:\n    note:\n      color: \"7aa2f7\"\n    tip:\n      color: \"9ece6a\"\n    important:\n      color: \"bb9af7\"\n    warning:\n      color: \"e0af68\"\n    caution:\n      color: \"f7768e\"\n\ntypst:\n  colors:\n    foreground: \"f0f0f0\"\n    background: \"545c7e\"\n\nfooter:\n  style: template\n  right: \"{current_slide} / {total_slides}\"\n\nmodals:\n  selection_colors:\n    foreground: \"e0af68\"\n\nmermaid:\n  background: transparent\n  theme: dark\n\nd2:\n  theme: 200\n\nlayout_grid:\n  color: \"7aa2f7\"\n"
  }
]