Compare commits

...

456 Commits

Author SHA1 Message Date
Matias Fontanini
2a4ea80a46
fix: allow interleaved spans and variables in footer (#577)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This allows including spans that contain variables in footers. This does
mean that if you pull in a variable like `{title}` that contains `<` or
`>` it will be included as-is and would allow creating valid HTML tags,
but this is harmless so I don't see a problem with it.

Fixes #574
2025-05-03 13:25:21 -07:00
Matias Fontanini
b25fa12b82 fix: allow interleaved spans and variables in footer 2025-05-03 13:20:49 -07:00
Matias Fontanini
725312e71c
feat: make HTML export self contained (#575)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
#566 did the heavy work to allow HTML exports but the one thing missing
is that images were still referenced on their external paths, which
caused 2 issues:

* Files were not redistributable unless you included images and fixed
their paths.
* Generated images (e.g. mermaid diagrams) were likely pointing to a
temporary directory so they'd be lost.

This changes that so HTML exports are now fully self contained, using
`data:...` notation to inline images within the HTML.
2025-05-02 17:44:53 -07:00
Matias Fontanini
5565d420f5 feat: make HTML export self contained 2025-05-02 17:39:09 -07:00
Matias Fontanini
afb0f0797f
feat: allow exporting to html (#566)
the main modifications are
- makes every page exist in a separate container
- allows switching pages via left and right arrows
- merges the css from an outside file to directly within the html
- zooms the content to fit the browser width
2025-05-02 17:38:24 -07:00
KyleUltimate
68a210da5a feat: make container "invisible" if outputting to pdf 2025-05-01 20:47:37 +08:00
Matias Fontanini
60f6208594
fix: truly center +exec_replace snippet output (#572)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This causes `+exec_replace` blocks to have no margin when using center
alignment. This is a regression introduced somewhere in 0.12.0.

Fixes #571
2025-04-30 17:02:17 -07:00
Matias Fontanini
e2dab4d7ef fix: truly center +exec_replace +no_background snippet output 2025-04-30 16:58:11 -07:00
KyleUltimate
14d2edfeb5 fix: width should not be linked to width 2025-04-29 20:56:20 +08:00
KyleUltimate
8f40a8295b fix: make css into another file 2025-04-28 19:17:48 +08:00
Matias Fontanini
8d54fe225a
fix: Rename parameter name to the correct one in docs (#570)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
Rename `--config-path` in configuration/introduction section to
`--config-file`
2025-04-27 09:00:04 -07:00
dzu_we
257fa137c5 fix: rename parameter name to the correct one 2025-04-27 18:36:24 +03:00
KyleUltimate
262b2af3e7 fix: also calculate scaled amount regarding height 2025-04-27 08:07:03 +08:00
KyleUltimate
7b2ba0eb8c chore: allow compliation with rust version below 1.79.0 2025-04-25 22:01:37 +08:00
Matias Fontanini
cae76380fa chore: fix changelog attribution
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
2025-04-25 06:55:52 -07:00
Matias Fontanini
78a3df199e chore: fix typo in changelog link 2025-04-25 06:16:31 -07:00
KyleUltimate
fe818344fe fix: differentiate widths based on output format 2025-04-25 21:10:43 +08:00
Matias Fontanini
4bf1f10d83
chore: prepare for 0.13.0 changes (#568) 2025-04-25 06:09:22 -07:00
Matias Fontanini
c561907259 chore: bump version to 0.13.0 2025-04-25 06:05:55 -07:00
Matias Fontanini
0836c82f68 chore: update README with 0.13.0 changes 2025-04-25 06:05:55 -07:00
Matias Fontanini
232fc34fce chore: update docs to include 0.13.0 changes 2025-04-25 06:05:55 -07:00
Matias Fontanini
a894105b9b chore: add changelog entries for 0.13.0 2025-04-25 05:56:31 -07:00
KyleUltimate
6ff8e87924 fix: remove hard-fixed presentation width, resolve reviews 2025-04-25 17:47:55 +08:00
KyleUltimate
76561b1281 chore: rename module pdf to output 2025-04-24 22:40:00 +08:00
KyleUltimate
519aad16e8 chore: don't include script when outputting to pdf 2025-04-24 22:30:42 +08:00
KyleUltimate
0d4ffceede feat: allow the ability to create html outputs 2025-04-24 22:26:47 +08:00
KyleUltimate
c3fb212f90 feat: automatically zoom to the browser width 2025-04-24 22:02:56 +08:00
KyleUltimate
d0ea46ce85 feat: make the temporary html generated usable 2025-04-24 21:14:45 +08:00
Matias Fontanini
54bddaf017
feat: allow specifying start/end lines in file snippet type (#565)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This adds new optional `start_line` and `end_line` properties when using
a `file` type snippet, which allow specifying the line range to be used.

Caveats:

* The line selection is applied before highlighting so if you truncate
the file in a place that should be highlighted in some way because of
something that came before (e.g. multi line comments) it won't be
highlighted appropriately. This can be fixed but requires changing a few
things and I think this should not be hit very often.
* Using `+line_numbers` will always start at 1. In the future I may add
a property like `preserve_line_numbers` but again this would require
changing a few things (the same ones as the point above) so I kept it
this way for now.

Example:

~~~markdown
```file +line_numbers
language: rust
path: scripts/foo.rs
start_line: 3
end_line: 20
```
~~~

Closes #562
2025-04-23 19:33:16 -07:00
Matias Fontanini
510af31320 feat: allow specifying start/end lines in file snippet type 2025-04-23 19:28:46 -07:00
Matias Fontanini
abc132e9b5
feat!: hide JSON schema generation behind feature flag (#563)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This hides the JSON schema generation for the config file behind a new
`json-schema` feature flag. This is not fundamental to the functioning
of this tool and also there's already a schema generated
[here](https://github.com/mfontanini/presenterm/blob/master/config-file-schema.json)
so there's no need to have this available in the tool when released. If
anyone does want the JSON schema for some specific version of the tool,
they can always clone the repo and `cargo run --features json-schema`.

This also removes the `serde_with` dependency and instead hand rolls the
`SerializeDisplay` and `DeserializeFromStr` macros manually. These are
very few lines long and doesn't make importing that crate worth it.

Overall these 2 changes cause the number of crates to go down by
something like 20 which is great.
2025-04-22 19:38:05 -07:00
Matias Fontanini
8eaee8355d chore: hand roll serde_with macros and remove it as dependency 2025-04-22 16:34:56 -07:00
Matias Fontanini
d89f25792c chore: gate json schema generation behind feature flag 2025-04-22 16:02:28 -07:00
Matias Fontanini
d2f6617ec6
feat: add julia language highlighting and execution support (#561)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This adds support for highlighting and executing julia code snippets. 

Closes #559
2025-04-21 19:05:06 -07:00
Matias Fontanini
910c9cbe84 feat: add julia language highlighting and execution support 2025-04-21 19:01:44 -07:00
Matias Fontanini
94ce0a9225
feat: add collapse_horizontal slide transition (#560)
This adds a new `collapse_horizontal` slide transition that collapses
the current slide into the center of the screen.



https://github.com/user-attachments/assets/a398c286-ae3e-4c99-81aa-26aba498726d
2025-04-21 18:59:02 -07:00
Matias Fontanini
3d31c5f722 feat: add collapse_horizontal slide transition 2025-04-21 18:52:37 -07:00
Matias Fontanini
0c2f7ee945
feat: allow letting pauses become new slides when exporting (#557)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This introduces the config property `export.pauses` which when defaults
to `ignore` but when set to `new_slide` causes the PDF export to contain
a new slide every time a pause is found.

Closes #555
2025-04-20 14:38:58 -07:00
Matias Fontanini
e063f46a86 feat: allow letting pauses become new slides when exporting 2025-04-20 14:35:40 -07:00
Matias Fontanini
aa7cdae105
chore: refactor async renders (#556)
This refactors how async renders work. Before this change, the presenter
had to periodically poll them to pull their state into the operation,
and had to figure out which slides had async renders to be able to poll
them at the right times. This was tedious and error prone, especially
once slide transitions were introduced: we now had to preemptively poll
the next/previous slide because there could be something being async
rendered (e.g. a mermaid diagram) and we didn't want to transition into
a slide with a "Loading..." text when the generated image was available,
just not polled yet.

This now moves all polling to a separate thread. When an operation needs
to be polled (be it because it automatically starts async rendering or
it requires to be triggered by pressing `<c-e>`), now a `Pollable` type
is created that is essentially a container for the logic to poll and a
state shared with the operation. This lets a `Poller` periodically poll
all `Pollables` that need polling (lols). The result is a lot less code
around `Presenter`/`Presentation` to deal with triggering and polling
async renders. Also the handling for async render errors is now much
nicer because it can be treated almost the same way as a successfully
finished async render.
2025-04-20 14:15:57 -07:00
Matias Fontanini
5ec3a12d30 chore: refactor async renders 2025-04-20 14:04:41 -07:00
Matias Fontanini
31f7c6c1e2
feat: allow using images on right in footer (#554)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This adds support for footer images on the right, just like allowed for
left/center. Now that there's tests for this and after being
restructured fairly recently it's much easier to get _right_.

The following presentation now renders like the following:

```markdown
---
theme:
    override:
        footer:
            height: 5
            style: template
            left:
                image: ../examples/doge.png
            center:
                image: ../examples/doge.png
            right:
                image: ../examples/doge.png
---

Footer images
===
```


![image](https://github.com/user-attachments/assets/2dfeb096-eba6-43a2-973c-b3d187c14086)

Closes #549
2025-04-17 16:04:45 -07:00
Matias Fontanini
93359444de feat: allow using images on right in footer 2025-04-17 15:58:00 -07:00
Matias Fontanini
f59c19af36
perf: pre scale generated images (#553)
Before this change only filesystem images were scaled when transitioning
between slides. This PR does that for generated images as well, and
polls other slides while doing transitions so any images generated
asynchronously are already ASCII'd before we transition into them.

This PR has shown that the async render code has reached proper 💩
level and requires changing it, more so since #537 will be implemented
soon-ish.
2025-04-17 15:38:58 -07:00
Matias Fontanini
7908b65a51 perf: pre-scale generated images in other slides as they pop up 2025-04-17 15:30:23 -07:00
Matias Fontanini
5eee8a9fae perf: cache generated images as ascii 2025-04-16 16:28:56 -07:00
Matias Fontanini
a97e66fedf
perf: pre-scale ascii images when transitions are enabled (#550)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This pre-scales images when the presentation is reloaded and transitions
are enabled. For now only filesystem images are scaled but the next PR
will do this for generated ones as well. The end result of this is when
you transition between slides and there's images in them, the animation
is smooth the first time because the scaling, which is costly, is done
ahead of time.
2025-04-14 17:57:15 -07:00
Matias Fontanini
eca6ce91bf perf: pre-scale ascii images when transitions are enabled 2025-04-14 17:53:13 -07:00
Matias Fontanini
9e1f2beca2
perf: cache resized ascii images (#547)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This caches:
* Ascii images so we don't reload them every time we're doing slide
transitions.
* Ascii image resizes, so if we go to a slide that contains an image and
we're using slide transitions, the next transition that includes that
image won't require resizing it to that same sizes.

The one performance "issue" still present when using slide transitions
is that we still need to pay for that first image -> ascii image
conversion and that first ascii image resize the first time we
transition to a slide that contains an image. This is currently
noticeable in debug mode and not in release mode (at least in my machine
™️), but it would be sweet if it wasn't there ever. For this we need to
run through all slides, find all images, ascii convert them and resize
them to the current terminal size. The same should be done when the
terminal is resized.
2025-04-13 14:29:53 -07:00
Matias Fontanini
73211528a4 perf: cache ascii image resizes 2025-04-13 14:21:04 -07:00
Matias Fontanini
a5e89eb5a3 perf: cache ascii images when doing slide transitions 2025-04-13 14:02:41 -07:00
Matias Fontanini
733786154b chore: move image cache to image printer 2025-04-13 13:38:09 -07:00
Matias Fontanini
4786c5a84c chore: unify image registering functions 2025-04-13 13:18:26 -07:00
Matias Fontanini
7f3d878410
docs: add links to presentations using presenterm (#544)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
It turns out I'm a heavy user of _presenterm_!

I only added my presentations - let me know if you know of any others I
should add.

closes #538
2025-04-13 12:48:38 -07:00
Matias Fontanini
2b6864c215
fix: center overflow lines when using centered text (#546)
This fixes an issue where centered text that overflowed had the lines 2+
start at the same column as the first one rather than being centered.

Fixes #545
2025-04-13 12:45:22 -07:00
Matias Fontanini
02fcba89cc fix: center overflow lines when using centered text 2025-04-13 12:40:28 -07:00
Orhun Parmaksız
1eb7d9e995
docs: add links to presentations using presenterm 2025-04-12 12:37:42 +03:00
Matias Fontanini
913c5ed838
fix: don't add extra space before heading if prefix is empty (#542)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
Headings were being added an extra space if they had an empty string
configured as the prefix. This worked correctly if the prefix was `null`
but not if it was "".

Fixes #541
2025-04-10 05:45:15 -07:00
Matias Fontanini
13ab57f7f6 fix: don't add extra space before heading if prefix is empty 2025-04-10 05:41:03 -07:00
Matias Fontanini
8c5cdf0a92
fix: use no typst background in terminal-* built in themes (#535)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This sets no background for typst renders in the terminal-light and
terminal-dark built in themes. This requires a reasonably recent version
of typst (probably 0.12+) as they changed the meaning of `none` as a
fill background at some point around then.
2025-04-03 16:38:56 -07:00
Matias Fontanini
a060afff7e fix: use no typst background in terminal-* built in themes 2025-04-03 16:34:40 -07:00
Matias Fontanini
58a3ea5b8d
feat: add fade slide transition (#534)
This adds another animation: `fade`. This essentially draws the new
slide on top of the current one by jumping to cells that have changed
between slides randomly and printing their contents.


https://github.com/user-attachments/assets/eb2d7d68-9967-4855-a06b-cbe208b21c6f

Fixes #364
2025-04-03 16:29:35 -07:00
Matias Fontanini
8b0677e418 feat: add fade cells slide transition 2025-04-03 16:26:38 -07:00
Matias Fontanini
e287624595
fix: fixing the external_snippet test error (#533)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
The absolute path to the temporary directories has been replaced with
the path specified in the environment variables.
2025-04-02 17:34:23 -07:00
Sergey Savelev
e8901b2aa2 fix: fixing the external_snippet test error
The absolute path to the temporary directories has been
replaced with the path specified in the environment variables.
2025-04-02 19:06:27 +04:00
Matias Fontanini
81747f7f1d
fix: respect extends in a theme set via path in front matter (#532)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This fixes an issue where a presentation with a `theme.path` in the
front matter that pointed to a theme that contained an `extends`
wouldn't have its `extends` respected.
2025-04-01 17:44:44 -07:00
Matias Fontanini
8749daa537 fix: respect extends in a theme set via path in front matter 2025-04-01 17:06:37 -07:00
Matias Fontanini
33fd38313b
feat: add max_rows configuration to cap vertical size (#531)
Similar to `max_columns` this adds a `max_rows` and `max_rows_alignment`
(valid values are `top`, `center`, `bottom`, the default is `center`)
that cap the vertical presentation size and aligns it accordingly.
2025-04-01 16:52:34 -07:00
Matias Fontanini
4c00f7731f feat: add max_rows configuration to cap vertical size 2025-04-01 16:49:52 -07:00
Matias Fontanini
a30f78e2ed
feat: include images in slide transitions (#530)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This includes images when transitioning between slides; this was the one
detail remaining after #528. This could probably be made a bit more
efficient as it currently causes us to potentially read the image from
disk and decode it. In practice I don't know if it matters though so I'd
rather have this merged now and look at optimizations in the future if
needed.

In this implementation, images are turned into ascii via the ascii
printer and that is used during the transition. I don't think it's
viable/makes sense to print the actual image that many times just for
the transition.

Relates to #364


https://github.com/user-attachments/assets/1f5d13df-d928-4789-87ee-b73824728609
2025-03-30 13:29:48 -07:00
Matias Fontanini
0d9b4ded83 feat: include images in slide transitions 2025-03-30 13:26:51 -07:00
Matias Fontanini
cccfb76545 feat: use TerminalIo in image printers 2025-03-30 10:01:45 -07:00
Matias Fontanini
a239e395d7
chore: get rid of TextProperties (#529)
This was needed at some point because of how text styling worked but
it's no longer the case.
2025-03-30 09:12:11 -07:00
Matias Fontanini
1d7b7d9719 chore: get rid of TextProperties 2025-03-30 09:06:05 -07:00
Matias Fontanini
3a7f6ae661 docs: document speaker notes always on better
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
2025-03-29 15:43:28 -07:00
Matias Fontanini
9f3c53efdc
feat: add slide horizontally slide transition animation (#528)
This adds support for the first slide transition animation that swaps
between slides horizontally. This is still a work in progress; it's
functional but is missing a few things like dealing with images. The
configuration currently looks like the following, but it will likely be
changed slightly once another type of transition is supported (will
happen before next release).

```yaml
transition:
 duration_millis: 750
 animation:
   style: slide_horizontal
```

This looks like the following:


https://github.com/user-attachments/assets/9475caf9-0538-4ffd-8858-115aac348bd1




Relates to #364
2025-03-29 15:10:51 -07:00
Matias Fontanini
b3d386e9dd feat: add slide horizontally animation 2025-03-29 14:57:20 -07:00
Matias Fontanini
f477ba4551 chore: use an enum to represent terminal commands 2025-03-26 16:34:01 -07:00
Matias Fontanini
f2a3abe85d
feat: add --output option for PDF exports (#526)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
## Summary
Adds a new command-line option (`--output`/`-o`) allowing users to
specify custom output paths when exporting presentations to PDF.
Previously, presenterm automatically generated PDFs in the same location
as the input file.

## Motivation
This feature simplifies multi-theme exports without requiring manual
file renaming or temporary copies. It enables clean parallel exports
with different themes (light/dark) to distinct output files.

### Before:
```bash
generate_pdf() {
  local dir="$1"
  local theme="$2"
  cd "$dir" || exit
  # Generate PDF then rename immediately
  "$PRESENTERM_PATH" -e -t "$theme" --export-temporary-path "$TEMP_DIR" slide.md
  mv slide.pdf "slide_${theme}.pdf"
  cd - >/dev/null || exit
}
```

### After:
```bash
generate_pdf() {
  local dir="$1"
  local theme="$2"
  cd "$dir" || exit
  # Directly specify output file with theme name
  "$PRESENTERM_PATH" -e -t "$theme" --output "slide_${theme}.pdf" slide.md
  cd - >/dev/null || exit
}
```

## Example Usage
```
presenterm --export-pdf --output my-slides.pdf presentation.md
```

@mfontanini my rust skills are literally nil, I just copy pasted base on
your code.
2025-03-24 20:06:07 -07:00
Mariano Z.
636cac33b9
feat: add --output option for PDF exports
This change adds a new command-line option (--output/-o) that allows users
to specify a custom output path when exporting presentations to PDF.

Previously, presenterm would automatically generate the PDF file at the same
location as the input presentation file with a .pdf extension. With this
change, users can now explicitly set the destination path.

- Added the export_output CLI option in main.rs
- Modified the export_pdf function to accept custom output path
- Updated the PDF export documentation to explain the new option

Example usage:
presenterm --export-pdf --output my-slides.pdf presentation.md
2025-03-24 23:47:51 -03:00
Matias Fontanini
238c85f849
chore: prepare for 0.12.0 changes (#523) 2025-03-24 18:27:16 -07:00
Matias Fontanini
f8e94a0016 chore: bump version to 0.12.0 2025-03-24 18:24:28 -07:00
Matias Fontanini
3715bfa4da chore: add docs for 0.12.0 2025-03-24 18:24:28 -07:00
Matias Fontanini
ca0a8b3453 chore: add changelog notes for 0.12.0 2025-03-24 18:20:28 -07:00
Matias Fontanini
8ec745a4f0
fix: show error if sixel mode is selected but disabled (#525)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
Fixes #524
2025-03-24 06:20:57 -07:00
Matias Fontanini
ed7f50ef89 fix: show error if sixel mode is selected but disabled 2025-03-24 06:17:38 -07:00
Matias Fontanini
2d40544e58
fix: respect font size in lists (#522)
Regression introduced in #512. This + inline code needs something more
robust.

Fixes #521
2025-03-24 06:03:18 -07:00
Matias Fontanini
78d2695f7a fix: respect font size in lists 2025-03-23 08:47:10 -07:00
Matias Fontanini
74bbe9f8d5
fix: display inline code colors when in list (#520)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This is fragile and broke after #512, eventually it should be cleaned up
so this can't break as easily.
2025-03-22 13:27:25 -07:00
Matias Fontanini
24221f4538 fix: display inline code colors when in list 2025-03-22 13:22:27 -07:00
Matias Fontanini
d7c7dba34f
chore: cleanup text attributes (#519)
This cleans up the code around text attributes and adds more tests when
converting text to html.
2025-03-22 13:19:56 -07:00
Matias Fontanini
9f316abcf9 chore: cleanup text attributes 2025-03-22 13:13:25 -07:00
Matias Fontanini
b52fd4ce8f
chore: allow specifying path for temporary export files (#518)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This adds a `--export-temporary-path` that allows using some specific
path to store intermediate files used when exporting to pdf (currently
an html and a css file) rather than using a temporary path. This helps
debugging #516 but is also generally useful.
2025-03-22 09:11:12 -07:00
Matias Fontanini
3f3b66b52d chore: allow specifying path for temporary export files 2025-03-22 09:01:27 -07:00
Matias Fontanini
3ef9d75277
fix: respect line height when jumping lines when rendering PDF (#517)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
Fixes #515
2025-03-21 16:06:44 -07:00
Matias Fontanini
d7216d2af5 fix: respect line height when jumping lines when rendering PDF 2025-03-21 16:03:09 -07:00
Matias Fontanini
3a7a967a1e
ci: revert build with sixel enabled (#514)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This causes builds to fail. This should maybe be moved to use some sixel
crate that's pure rust and not based on libsixel.
2025-03-20 19:04:15 -07:00
Matias Fontanini
146862f12b Revert "ci: build with sixel enabled"
This reverts commit 9dd4b2105c5abef78ea52e43ab3ba330f7b652ff.
2025-03-20 19:00:02 -07:00
Matias Fontanini
1d4e5b1c59
ci: build with sixel enabled (#513)
This changes the nightly and release builds to enable sixel support.
2025-03-20 16:52:52 -07:00
Matias Fontanini
59b96d718b
fix: center lists correctly (#512)
After #493 lists could be center/right aligned but they looked terrible.
This fixes that.
2025-03-20 16:52:40 -07:00
Matias Fontanini
9dd4b2105c ci: build with sixel enabled 2025-03-20 16:41:37 -07:00
Matias Fontanini
ec6926358a fix: center lists correctly 2025-03-20 16:40:37 -07:00
Matias Fontanini
aa38a7120b
feat: allow specifying export dimensions in config file (#511)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This allows specifying the dimensions to use when exporting a
presentation to PDF. This allows exporting to PDF in contexts without a
tty which should help people write scripts that automatically export
presentations. This now also adds a step in the CI that tries to export
the default presentation as PDF, which couldn't be tested before because
of the tty restriction.

```yaml
export:
  dimensions:
    rows: 35
    columns: 135
```
2025-03-20 06:06:28 -07:00
Matias Fontanini
66091f3b6a ci: export PDF during merge checks 2025-03-20 06:03:49 -07:00
Matias Fontanini
f933032958 feat: allow specifying export dimensions in config file 2025-03-20 06:02:31 -07:00
Matias Fontanini
0e4fad5e5e chore: use nightly for nightly tag
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
2025-03-19 17:37:17 -07:00
Matias Fontanini
2784dee624
feat: respect font sizes in generated PDF (#510)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This respects font sizes in the generated PDF. The one thing that is not
yet supported here is text with font size > 1 _and_ with a background
color. The background color only shows up in the line where the text is.
But this should be rarer (e.g. a font size > 1 code block?) so this
works for most cases.

Fixes https://github.com/mfontanini/presenterm-export/issues/17
2025-03-19 06:39:46 -07:00
Matias Fontanini
4254a0bafd feat: respect font sizes in generated PDF 2025-03-19 06:07:04 -07:00
Matias Fontanini
1b3e79fa57
Create FUNDING.yml
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
2025-03-18 18:22:23 -07:00
Matias Fontanini
cae9452c15
feat: invoke weasyprint directly to generate PDFs (#509)
This removes the need for `presenterm-export` and instead invokes
weasyprint directly from presenterm. This removes lots of logic and
simplifies things considerably. Besides this it also opens the door to
be able to use font sizes in the generated PDF + allowing other types of
export in the future, like HTML.

This also gets rid of the need for tmux, essentially now we take the
operations to render the presentation and use a converter internally
that turns them into HTML, which we then feed to weasyprint.

Relates to https://github.com/mfontanini/presenterm-export/issues/17
2025-03-18 16:42:34 -07:00
Matias Fontanini
ccc58deaea feat: invoke weasyprint directly to generate PDFs 2025-03-18 16:38:47 -07:00
Matias Fontanini
a861501091
feat: add skip_slide command (#505)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This adds a new `skip_slide` comment command that allows skipping that
slide and not including it in the final presentation.

Fixes #502
2025-03-16 12:49:23 -07:00
Matias Fontanini
99be30211b feat: add skip_slide command 2025-03-16 12:46:10 -07:00
Matias Fontanini
995cf9683e
ci: add nightly build job (#496)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This adds a nightly job that builds the latest source code and uploads
it to "pre-release" release.
2025-03-15 13:00:08 -07:00
Matias Fontanini
28f121218e ci: add nightly build job 2025-03-15 12:54:07 -07:00
Matias Fontanini
964b36e0fb
fix: ansi escape code parsing (#500)
Changes in 0.10.0 were made to fix some incorrect ansi escape code
parsing when processing snippet execution output. However, those changes
were still not handling parsing properly and ended up causing it to
break in other cases.

This change hopefully fixes that. This also removes ansi-parser and
starts using vte to parse escape codes as ansi-parser doesn't let you
have more than 5 segments in an escape code. As an added bonus this also
removes a bunch of dependencies.

Fixes #499
2025-03-15 12:52:48 -07:00
Matias Fontanini
e7ee9a7316 fix: ansi escape code parsing 2025-03-15 12:49:16 -07:00
Matias Fontanini
d3d1b29a24
fix; don't wait if incremental lists disabled (#498)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
Fixes #497
2025-03-14 05:29:56 -07:00
Matias Fontanini
788d041ad1 fix; don't wait if incremental lists disabled 2025-03-14 05:25:43 -07:00
Matias Fontanini
4a6bb4197f
Correctly include layout pic (#495)
Some checks failed
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Seems like the example layout picture wasn't referred correctly and
hence doesn't show up when visiting
https://mfontanini.github.io/presenterm/features/layout.html .

This change should probably fix it.
2025-03-13 05:20:50 -07:00
Tonći Galić
af82ee747b
Correctly include layout pic
Seems like the example layout picture wasn't referred correctly and hence doesn't show up when visiting https://mfontanini.github.io/presenterm/features/layout.html . 

This change should probably fix it.
2025-03-13 12:54:42 +01:00
Matias Fontanini
c47721cfca
fix: respect end slide shorthand in speaker notes mode (#494)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
This causes end slide shorthands to be respected when in speaker notes
mode.

This speaker notes/presentation mode code needs some splitting, this
currently makes it hard to know what needs to be done / is done in each
case. But this does for now.

Fixes #491
2025-03-12 19:46:08 -07:00
Matias Fontanini
94f43c4cb9 fix: respect end slide shorthand in speaker notes mode 2025-03-12 19:42:34 -07:00
Matias Fontanini
cbbf0b4c0b
feat: add alignment comment command (#493)
This adds an `alignment` comment command that can have a
left/center/right values. This uncovered a few issues in how some
elements (e.g. lists in particular, but also normal text) render with
center/right alignment, but they'll be fixed separately.

Fixes #492
2025-03-12 19:41:15 -07:00
Matias Fontanini
ed09b06103 feat: add alignment comment command 2025-03-12 19:37:41 -07:00
Matias Fontanini
e5486a8043
feat: add --current-theme to display the theme in use (#489)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
The new `--current-theme` switch will print the theme in use to stdout.
This will be the configured theme in the config file, if set, or the
default theme as a fallback.

Relates to #488
2025-03-11 17:45:23 -07:00
Matias Fontanini
6642a2eb0b
fix!: pause before and after incremental lists (#487)
This changes the behavior for incremental lists so that pauses are added
before and after lists. This changes the default behavior since this
seems more sensible.

To use the original behavior that causes no pauses before nor after set
`defaults.incremental_lists.pause_before` and
`defaults.incremental_lists.pause_after` to `false` in the config file.

The following presentation:

```markdown
<!-- incremental_lists: true -->

greetings I know:

* hi
* bye 


vegetables I know:

* potato
* carrot 
```

Now looks like this:


[![asciicast](https://asciinema.org/a/QQq1IZzyLSyquh9TKRjVGFy0u.svg)](https://asciinema.org/a/QQq1IZzyLSyquh9TKRjVGFy0u)

Fixes #486
2025-03-11 17:45:08 -07:00
Matias Fontanini
6230ef566c
docs: update README.md (#490)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
Customizeable -> Customizable
2025-03-11 06:01:20 -07:00
Ikko Eltociear Ashimine
14e8e3ad49
docs: update README.md
Customizeable -> Customizable
2025-03-11 21:42:58 +09:00
Matias Fontanini
410e671438 feat: add --current-theme to display the theme in use 2025-03-10 19:34:04 -07:00
Matias Fontanini
19364b2193 fix!: pause before and after incremental lists 2025-03-10 15:56:23 -07:00
Matias Fontanini
10bf968f86
fix: snippet execution output colors in terminal-* themes (#485)
For some reason terminal-light and terminal-dark were using bright
background/text in snippet execution outputs.
2025-03-09 15:05:00 -07:00
Matias Fontanini
979aebe6da fix: snippet execution output colors in terminal-* themes 2025-03-09 13:56:05 -07:00
Matias Fontanini
6de1f83105
chore: refactor snippet processing (#484)
This mostly moves the snippet processing to a separate module to remove
the noise in `builder.rs`.
2025-03-09 13:46:54 -07:00
Matias Fontanini
ad0c9badc1 chore: remove redundant checks for alignment 2025-03-09 13:41:23 -07:00
Matias Fontanini
6fb9df56a3 chore: move snippet processing code to separate module 2025-03-09 13:20:29 -07:00
Matias Fontanini
44f0787bb5
Add gruvbox dark theme (#483)
This PR adds a [gruvbox](https://github.com/morhetz/gruvbox) theme to
presenterm.


![image](https://github.com/user-attachments/assets/d8760b5f-3ab7-4e1f-ac34-d3046d44759e)
2025-03-09 08:51:01 -07:00
ret2src
00ed4fb01c
Add Gruvbox Dark theme 2025-03-09 15:19:01 +01:00
Matias Fontanini
c4011b67d3 docs: fix changelog link 2025-03-08 13:25:59 -08:00
Matias Fontanini
0057b8ba5e
chore: prepare for 0.11.0 release (#479) 2025-03-08 13:19:04 -08:00
Matias Fontanini
cafc6bb850 chore: include screenshot of footer example 2025-03-08 13:16:33 -08:00
Matias Fontanini
50040bfcc1 chore: update README 2025-03-08 13:16:33 -08:00
Matias Fontanini
c6223a2ab6 chore: bump version to 0.11.0 2025-03-08 13:16:33 -08:00
Matias Fontanini
60dd8eecc0 chore: update example presentations before 0.11.0 2025-03-08 13:16:33 -08:00
Matias Fontanini
3b40c8fd3d chore: add docs for 0.11.0 2025-03-08 13:16:33 -08:00
Matias Fontanini
d5b172048a docs: add release notes for 0.11.0 2025-03-08 13:16:33 -08:00
Matias Fontanini
77979984bf
chore: cleanup Builder::push_code (#480) 2025-03-08 12:47:12 -08:00
Matias Fontanini
644a57f9f9 chore: cleanup Builder::push_code 2025-03-08 12:43:41 -08:00
Matias Fontanini
f17724bf91
fix: jump to right slide on code attribute change (#478)
Changing attributes like `+line_numbers` wasn't causing the code's slide
to be jumped-to.
2025-03-07 16:24:38 -08:00
Matias Fontanini
92313b4fd9 fix: jump to right slide on code attribute change 2025-03-07 16:21:09 -08:00
Matias Fontanini
4bf584e211
chore: move footer up depending on footer height (#476)
This otherwise causes footers with tall images that need `footer.height`
adjusted to have the text too far down.
2025-03-05 06:13:58 -08:00
Matias Fontanini
6f26928be3 chore: move footer up depending on footer height 2025-03-05 06:11:16 -08:00
Matias Fontanini
0419cf3e2e
feat: allow configuring alignment when max columns is hit (#475)
This introduces a new `defaults.max_columns_alignment` that can have
values `left`, `center`, and `right`. This works along with
`max_columns` and allows specifying how to align the presentation if the
terminal size is greater than it. Before this change, this would behave
like `max_columns_alignment: center` does now so that's the default if
not set.

e.g. setting this to `left` will cause look like this on a wide
terminal:


![image](https://github.com/user-attachments/assets/026b5943-6e22-4450-ae22-3e1e6af84058)
2025-03-03 05:35:48 -08:00
Matias Fontanini
24e6ea8386 feat: allow configuring alignment when max columns is hit 2025-03-03 05:32:48 -08:00
Matias Fontanini
ec1be93a06
fix: fail if --config-file points to non existent file (#474) 2025-03-03 05:14:35 -08:00
Matias Fontanini
6f12f893d0 fix: fail if --config-file points to non existent file 2025-03-03 05:11:27 -08:00
Matias Fontanini
a7973cccb3
chore: bump dependencies (#472) 2025-03-02 12:50:57 -08:00
Matias Fontanini
1f2bea4a67 chore: bump dependencies 2025-03-02 12:29:23 -08:00
Matias Fontanini
7af0e4a18b
chore: display nice error if footer variable not set (#471) 2025-03-02 12:15:45 -08:00
Matias Fontanini
f190910646 chore: display nice error if footer variable not set 2025-03-02 11:39:47 -08:00
Matias Fontanini
5c03cc9950
chore: allow overriding slide title font size (#470) 2025-03-01 15:35:34 -08:00
Matias Fontanini
967db854a2 chore: allow overriding slide title font size 2025-03-01 15:31:59 -08:00
Matias Fontanini
6587cc955d
fix: show meaningful error if footer is broken (#469)
After the change to allow images in footers, the error message when the
footer is malformed was useless. This implements a custom deserializer
to have proper error messages since the derive macro was getting
confused.
2025-03-01 15:30:58 -08:00
Matias Fontanini
ace1dfc18d fix: show meaningful error if footer is broken 2025-03-01 14:09:27 -08:00
Matias Fontanini
a3ef63208f
feat: introduce palette.classes and use span.class to reference it (#468)
This introduces a `palette.classes` entry in the theme that allows
defining a class (a background and foreground color pair). This can then
be referenced when using spans by using the `class` html attribute.

As an example. the following presentation:

~~~markdown
---
theme:
    override:
        palette:
            classes:
                bokita:
                    foreground: yellow
                    background: dark_blue
        footer:
            style: template
            left: "<span class=\"bokita\">hello</span>"
                
---

hi <span class="bokita">hello</span>
~~~

Renders liks:


![image](https://github.com/user-attachments/assets/40cdd57a-295d-4889-ab97-4618613eaf0f)
2025-03-01 13:21:16 -08:00
Matias Fontanini
dfe0e8160e feat: introduce palette.classes and use span.class to reference it 2025-03-01 13:16:08 -08:00
Matias Fontanini
4ceb07c6de
feat: allow escaped braces in footer (#467)
After #442 you could no longer use braces unless it was to refer to a
variable. This PR softens that restriction by letting you use double
braces as an "escaped brace" to let you use them anywhere.

e.g. this footer "{title} {{potato}}" will render as "<title>
{{potato}}"
2025-02-28 06:15:22 -08:00
Matias Fontanini
161110e763 feat: allow escaped braces in footer 2025-02-28 06:05:25 -08:00
Matias Fontanini
3a3c7e031e
feat: allow footer to be styled via markdown (#466)
This allows styling the template footer using markdown, including colors
from the theme palette. If the front matter contains the `title` entry
and that contains markdown, it is also rendered as expected.

Example:

~~~markdown
---
theme:
    override:
        footer:
            style: template
left: "_how_ to <span style=\"color: red\">chop</span> **onions**"
---
~~~


![image](https://github.com/user-attachments/assets/0e8807c5-6488-4ba0-956d-1837ec32ae63)
2025-02-23 14:53:36 -08:00
Matias Fontanini
350f692ed9 feat: allow footer to be styled via markdown 2025-02-23 14:45:53 -08:00
Matias Fontanini
2ef27f4313
chore: remove Result return type from builder fns that don't need it (#465) 2025-02-23 13:09:46 -08:00
Matias Fontanini
33619c3255 chore: remove Result return type from builder fns that don't need it 2025-02-23 13:04:10 -08:00
Matias Fontanini
2e198d2dbc
feat: allow markdown syntax in presentation title (#464)
This allows using markdown syntax in the presentation's title, meaning
you can use bold, italics, color, etc. This currently looks wrong if you
use the `{title}` formatted in the footer but this will be fixed in a
follow up PR that will allow footers to be styled the same way as well.

Example presentation:

~~~markdown
---
title: "**Hi** _mom_ <span style=\"color: red\">how are you</span>?"
---
~~~


![image](https://github.com/user-attachments/assets/c26cd48d-1fa8-4f25-b4ee-fea5324a08d7)
2025-02-23 12:55:32 -08:00
Matias Fontanini
b6459701f3 feat: allow markdown syntax in presentation title 2025-02-23 12:50:15 -08:00
Matias Fontanini
83e33d7709
chore: refactor theme code (#463)
This cleans up the way themes are handled and splits them into 2 sets of
types: types that are meant to define the .yaml themes and another one
that's used during rendering. This:

* Prevents us from dealing with `Option` and using `unwrap_or/_default`
all over the place since we run defaults once during the .yaml >
"runtime" theme types conversion.
* Allows fallible code where there shouldn't be: there's cases like
footer images that forced error checking in weird spots, like when
creating the footer render just to ensure images use a valid path.
* Allows us to not deal with color palette lookup errors since we can
resolve those once.
* Allows us to not have to be extra careful to resolve all palette
colors or face runtime errors and instead enforce this in compile time.
e.g. now a new `RawColor` type has a `Palette` variant, but the real
`Color` one doesn't. Markdown parsing deals with `RawColor` but we are
forced to resolve them into `Color` because we can't print `RawColor`s.
2025-02-22 14:16:12 -08:00
Matias Fontanini
3e11cbe6fd chore: make Margin and Alignment Copy 2025-02-22 14:13:12 -08:00
Matias Fontanini
e7dd8f7e86 chore: add PaddingRect clean theme type 2025-02-22 14:00:44 -08:00
Matias Fontanini
7f1e2cbdb4 chore: remove serde::* derives from Color 2025-02-22 13:38:00 -08:00
Matias Fontanini
fb4ca37746 chore: create RawColor to deal with unresolved palette colors 2025-02-21 06:35:00 -08:00
Matias Fontanini
fa4d862834 chore: use re-exports for clean theme types 2025-02-20 16:15:34 -08:00
Matias Fontanini
8a806d76a1 chore: restrict raw theme visibility as much as possible 2025-02-20 16:13:29 -08:00
Matias Fontanini
60eee62e84 chore: use a separate theme model for config / runtime 2025-02-20 06:43:30 -08:00
Matias Fontanini
0f6a8ec73f
fix: use right script name for kotlin files when executing (#462)
Fixes #461
2025-02-19 05:13:31 -08:00
Matias Fontanini
5a9c2d7a45 fix: use right script name for kotlin files when executing 2025-02-19 05:09:20 -08:00
Matias Fontanini
dc75f43ab3 chore: split up themes module 2025-02-18 16:54:56 -08:00
Matias Fontanini
430872846b
feat: set font size on code execution components (#460)
After #458 most components were allowed to have a font size except a
couple. This expands that list to include code execution components (the
separator bar and the output).

This code has turned quite gore after all these changes and will be
refactored soon. Markdown tables still don't respect the font size but
that should hopefully be easier after the refactor.
2025-02-18 16:42:56 -08:00
Matias Fontanini
fda4eeb108 feat: set font size on code execution components 2025-02-18 16:36:32 -08:00
Matias Fontanini
61cc8125ea
fix: respect lists that start at non 1 indexes (#459)
This makes ordered lists that don't start at 1 to respect the number
they're prefixed with. e.g.

```markdown
5. hi
6. mom
```

Will be now rendered as expected, whereas before it would show indexes
1. and 2. for each item.

Fixes #457
2025-02-16 14:39:14 -08:00
Matias Fontanini
49ab5690dd fix: respect lists that start at non 1 indexes 2025-02-16 14:35:01 -08:00
Matias Fontanini
7437422a0b
feat: allow specifying font size in comment command (#458)
This extends the font size capabilities introduced in #438 by allowing
the user to specify the font size in the form of a comment command
anywhere in the presentation. This command changes the size of the font
for the remainder of the slide.

Currently this doesn't work in cases like executable snippets, but I'll
fix that when I have more time.

Example presentation:

~~~markdown
test

> this is
> small

<!-- font_size: 2 -->

test

> this is
> large

```rust
fn greet() -> &'static str {
    "hi mom"
}
```
~~~


![image](https://github.com/user-attachments/assets/91b88b57-edd7-47b6-8b47-b5fa6ea3575e)
2025-02-16 14:24:37 -08:00
Matias Fontanini
d5c56d2523 feat: allow specifying font size in comment command 2025-02-16 14:18:08 -08:00
Matias Fontanini
0f80362558 docs: mention tmux passthrough in docs 2025-02-15 14:08:00 -08:00
Matias Fontanini
1aea867700
fix: don't get stuck if tmux doesn't passthrough (#456)
This fixes an issue where we're inside tmux but `allow-passthrough` is
disabled, which manifests in presenterm getting stuck on startup because
tmux never forwards the escape codes sent to query for terminal
capabilities to the terminal, hence we never get an answer. The fix here
is to spawn a thread that will wait a second and if after that time we
still haven't received an answer it will make a device status report
query without the passthrough prefix, meaning tmux (if under tmux) will
answer it, and will wake the main thread up.

Fixes #455
2025-02-15 14:03:20 -08:00
Matias Fontanini
73429b98bd fix: don't get stuck if tmux doesn't passthrough 2025-02-15 13:57:17 -08:00
Matias Fontanini
0c00558cd0 chore: add release notes for 0.10.1 2025-02-14 16:25:14 -08:00
Matias Fontanini
8e0bc18791
fix: don't err if auto_render_languages isn't set (#454)
Fixes #453
2025-02-14 16:20:41 -08:00
Matias Fontanini
b6e393cde1 fix: don't err if auto_render_languages isn't set 2025-02-14 16:18:47 -08:00
Matias Fontanini
5507ea4dfd
chore: bump sixel-rs version (#452)
Includes https://github.com/orhun/sixel-rs/pull/6 to fix build on
aarch64 and riscv64.

If possible, please make a new release so downstream maintainers like
Arch Linux RISC-V won't need to maintain this patch for an extended
period. :P
2025-02-13 17:43:03 -08:00
Xeonacid
54d4c0db74 chore: bump sixel-rs version
Includes https://github.com/orhun/sixel-rs/pull/6 to fix build on aarch64 and riscv64.
2025-02-14 02:10:07 +01:00
Matias Fontanini
46d283743f
Update introduction.md (#451)
Fix broken link to installation guide  in introduction.md
2025-02-13 17:02:51 -08:00
Ivan G.
8935e1d110
Update introduction.md
Fix broken link to installation guide  in introduction.md
2025-02-13 19:24:30 +00:00
Matias Fontanini
793073a373
feat: support images in footer (#450)
This allows using images in a footer template. For now they can only be
used in the left and center templates by using this syntax:

```yaml
footer:
  style: template
  left:
    image: path-to-image
  # the height of the footer. defaults to 3
  height: 3
```

Images will preserve aspect ratios and will expand to take up as much of
the `height` attribute as possible. Therefore, to scale images, simply
bump that number up/down.

Images are looked up:
* Relative to the presentation's file, just like any other image.
* Inside the themes directory (~/.config/presenterm/themes).

Example using random logo:


![image](https://github.com/user-attachments/assets/f04dc955-8103-4c13-b617-307267a617ed)

Fixes #437
2025-02-13 05:52:29 -08:00
Matias Fontanini
bf6a15dce5 feat: support images in footer 2025-02-13 05:38:55 -08:00
Matias Fontanini
75116ba29c
feat: add support for wikilinks (#448)
This adds wikilinks support via the `[[url]]` syntax.

Relates to #447
2025-02-12 05:12:48 -08:00
Matias Fontanini
17476f2c0c feat: add support for wikilinks 2025-02-12 04:58:03 -08:00
Matias Fontanini
f58cc80820
fix: don't squash image if font is not 2:1 (#446)
Images looked a little squashed vertically if the font being used in the
terminal didn't have a 2:1 ratio between between columns and rows. This
also adds some much needed tests around this code, and fixes some
rounding issues when the image is small enough to only need a handful of
columns/rows to be displayed.

Fixes #445
2025-02-11 16:16:33 -08:00
Matias Fontanini
828ef016ec fix: don't squash image if font is not 2:1 2025-02-11 16:09:31 -08:00
Matias Fontanini
fc01bc57df
chore: restructure Terminal code and add test for margins/layouts (#443)
This refactors the `Terminal` code a bit to be able to test it, and add
tests to layout handling since the implementation for #437 will require
creating new rect to print the images in.
2025-02-09 14:33:57 -08:00
Matias Fontanini
af8c7d6f0d chore: add tests for margins/columns handling in engine 2025-02-09 14:29:12 -08:00
Matias Fontanini
6771c2f8a2 chore: create trait out of Terminal 2025-02-09 13:16:38 -08:00
Matias Fontanini
fc5062eb7a
feat!: sanitize theme footer's templates (#442)
This sanitizes the theme footer's template so that we have sure that
it's not malformed and doesn't reference any variables that can't be
used (e.g. `{bar}`). This now also avoids allocation a bunch of strings
when rendering the footer, since every variable was being replaced via
`str::replace`.

This is a breaking change for anyone who already has a malformed string
in their string templates. If someone does use a string like `{lord}
George` in their footer please bring it up and we can see what to do.
2025-02-07 06:23:31 -08:00
Matias Fontanini
80c6df34aa feat!: sanitize theme footer 2025-02-07 06:11:24 -08:00
Matias Fontanini
56923ab97a
chore: use fastrand instead of rand (#441)
This switches to from `rand` to `fastrand`. `rand` was also being pulled
in via `image/rayon` so I disabled it. There's no use case here that
demands parsing images at crazy fast speeds so whatever rayon is doing
is likely not needed; we can revisit if someone finds perf issues.
2025-02-06 06:50:30 -08:00
Matias Fontanini
d2c0379465 chore: remove rayon feature in image 2025-02-06 06:32:02 -08:00
Matias Fontanini
99b5212af9 chore: don't pull in getrandom via tempfile 2025-02-06 06:30:55 -08:00
Matias Fontanini
dbd4f9c1ea chore: use fastrand instead of rand 2025-02-06 06:22:31 -08:00
Matias Fontanini
1e3b3ff26d
perf: avoid cloning strings when styling them (#440) 2025-02-06 06:16:32 -08:00
Matias Fontanini
7abfb5a7bc perf: avoid cloning strings when styling them 2025-02-06 06:13:03 -08:00
Matias Fontanini
fb0223bb83
feat: add support for kitty's font size protocol (#438)
This adds support for kitty's font size protocol
(https://github.com/kovidgoyal/kitty/issues/8226) which allows printing
characters that take up more than one cell. This feature will be
available in kitty >= 0.40.0 and is currently only available in nightly
builds.

This for now is only supported in a subset of the theme components,
namely:

* The introduction slide's presentation title
(`intro_slide.title.font_size`).
* The slide titles (`slide_title.font_size`).
* The headings (`headings.h*.font_size`).

Font sizes are only used if the terminal emulator supports it so this
doesn't change anything for emulators other than kitty (or other
implementors of the protocol). If you find this somehow breaks
something, please create an issue.

For now all built in themes set `intro_slide.title.font_size=2` and
`slide_title.font_size=2`. I think this looks a lot better this way but
please do comment here if you don't think built in themes should come
with these values set.

These are now the first 2 slides in the `demo.md` example:


https://github.com/user-attachments/assets/8d761d86-8855-498a-9766-5294cdae3b57
2025-02-05 16:20:13 -08:00
Matias Fontanini
8093875aea docs: move notes around external code highlighting via bat 2025-02-05 15:58:05 -08:00
Matias Fontanini
2935eb617f docs: add redirects from old routes 2025-02-05 15:56:18 -08:00
Matias Fontanini
1235a26f75 feat: add support for kitty's font size spec 2025-02-05 06:29:00 -08:00
Matias Fontanini
33c7c9705c docs; fix latex image link 2025-02-02 16:57:32 -08:00
Matias Fontanini
dacb291de2 docs: link speaker notes example 2025-02-02 14:49:52 -08:00
Matias Fontanini
5a909259c8
docs: restructure docs mdbook (#436)
This splits the docs a bit so pages are not yuge.
2025-02-02 14:39:02 -08:00
Matias Fontanini
3379d7a9cb docs: restructure docs mdbook 2025-02-02 14:33:39 -08:00
Matias Fontanini
f8e9ec6728 docs: fix code to image conversion example 2025-02-02 13:11:34 -08:00
Matias Fontanini
64b52334d7 chore: fix changelog formatting 2025-02-02 13:02:21 -08:00
Matias Fontanini
5eb2391d62
chore: 0.10.0 changes (#435) 2025-02-02 13:00:07 -08:00
Matias Fontanini
c51ce7dbf9 chore: bump version to 0.10.0 2025-02-02 12:56:58 -08:00
Matias Fontanini
7bbd1ff9db chore: update sample config files to include newest options 2025-02-02 12:56:58 -08:00
Matias Fontanini
a02b8c4d86 chore: link speaker notes guide in README 2025-02-02 12:56:58 -08:00
Matias Fontanini
3911f15cbc chore: update demo presentation to use new features 2025-02-02 08:41:33 -08:00
Matias Fontanini
fd45d96e1c docs: add docs on 0.10.0 features 2025-02-02 08:41:33 -08:00
Matias Fontanini
09e9f003ed chore: add 0.10.0 changes to changelog 2025-02-02 08:39:19 -08:00
Matias Fontanini
55dcee47c1
chore: rename alerts symbol to icon (#434) 2025-02-02 06:11:16 -08:00
Matias Fontanini
4a294b9fe7 chore: rename alerts symbol to icon 2025-02-02 06:08:40 -08:00
Matias Fontanini
68b10c1807
fix: alert styling for built in themes (#433) 2025-02-01 15:16:05 -08:00
Matias Fontanini
622453b96c fix: use some default colors for alerts if not set 2025-02-01 15:13:18 -08:00
Matias Fontanini
272ba07abe fix: alert styling for built in themes 2025-02-01 14:56:26 -08:00
Matias Fontanini
96b020ce6d
fix: re-set original colors after printing colored text (#432) 2025-02-01 14:47:35 -08:00
Matias Fontanini
6f305ef5b1 fix: re-set original colors after printing colored text 2025-02-01 14:41:07 -08:00
Matias Fontanini
55639a4523
docs: enable mdbook-alerts preprocessor (#431)
This adds the mdbook-alerts preprocessor, and removes all the catppuccin
theme related files since they created issues when bumping mdbook and
I'd rather not have to deal with that.
2025-02-01 14:00:03 -08:00
Matias Fontanini
808a1209ff docs: enable mdbook-alerts preprocessor 2025-02-01 13:56:54 -08:00
Matias Fontanini
f6daf38c46
feat: add symbols to alerts (#430)
This is a follow up on #423 that adds symbols before the title of alert
type elements. The structure of the theme changed a bit to be able to
pack the styles for each alert type into a single type.

This causes the following presentation:

```markdown
> [!note]
> this is a note

> [!tip]
> this is a tip

> [!important]
> this is important

> [!warning]
> this is warning!

> [!caution]
> this advises caution!

>>> [!note] other title
ez
multiline
>>>
```

To render like this:


![image](https://github.com/user-attachments/assets/ce90a4e0-7543-4100-83b2-5f8d86f4cbe2)
2025-02-01 06:11:22 -08:00
Matias Fontanini
7643b8f988 ci: use stable toolchain to run clippy 2025-02-01 06:08:06 -08:00
Matias Fontanini
3a389c1c7e feat: add symbols to alerts 2025-01-31 06:43:24 -08:00
Matias Fontanini
22af0665c0
feat: add +image to code blocks to consume their output as an image (#429)
This adds a new `+image` attribute to code blocks. This inherits
`+exec_replace` (meaning it has the same semantics and requires being
enabled with the same parameters) but it assumes the output of the
executed block is an image and renders it as such.

This means a presentation like the following one:

~~~markdown
hi
----

```bash +image
curl -s -L -o - 'https://github.com/mfontanini/presenterm/blob/master/examples/doge.png?raw=true'
```
~~~

Renders like this 

![image](https://github.com/user-attachments/assets/e7bd7a97-5dd4-457b-ac24-1a6592ae6d24)

For this to work, **the entire output of the code snippet must be an
image written to stdout**.

An application of this feature could be to have text to image
conversions to create titles on the fly. Hopefully this opens up the
door for more creative ideas users will definitely have.
2025-01-28 17:07:10 -08:00
Matias Fontanini
5968289562 feat: add +image to code blocks to consume their output as an image 2025-01-28 16:53:28 -08:00
Matias Fontanini
e4b2b388b7
fixup: support colors in block quotes (#428)
This is a followup on #427 which didn't consider styled text inside a
block quote.
2025-01-27 05:39:08 -08:00
Matias Fontanini
7f73d00f19 fixup: support colors in block quotes 2025-01-27 05:34:02 -08:00
Matias Fontanini
7d9115c3ee
feat: add color palette in theme to allow reusing colors (#427)
This allows defining a color palette in themes, which allows reusing
colors across the theme and in html span tags. In particular in theme
files there's always the same set of colors used all over the place, and
it's annoying to have to always address them using a RGB hex spec. This
instead allows defining a set of named colors and then those colors can
be referenced where a color would be used (e.g. in a theme or in a span
tag) by using the `palette:<name>` or `p:<name>` syntax.

For the color name this uses a stack allocated string of at most 16
characters. This is not an optimization but a workaround since `Color`,
`Colors` and `TextStyle` are currently `Copy` and using a `String` would
make them non copy which would involve changing lots of code. This
constraints the length of the palette colors but I don't feel like
changing this all over the codebase, plus "16 characters ought to be
enough for everybody".
2025-01-26 15:12:26 -08:00
Matias Fontanini
2292146b20 chore: use FixedStr in palette 2025-01-26 14:41:59 -08:00
Matias Fontanini
3fae9aea7a chore: expose theme name when invalid palette color is found 2025-01-26 14:32:07 -08:00
Matias Fontanini
165a0bfe6e chore: migrate dark.yaml to use a color palette 2025-01-26 14:28:47 -08:00
Matias Fontanini
b73ec4db45 chore: introduce BuildResult type alias 2025-01-26 14:28:31 -08:00
Matias Fontanini
9e7e5ad58c feat: add color palette in theme to allow reusing colors 2025-01-26 14:26:25 -08:00
Matias Fontanini
16b024ce33
chore: cleanup main.rs (#426) 2025-01-25 06:36:44 -08:00
Matias Fontanini
4f4b3684fe chore: cleanup main.rs 2025-01-25 06:33:27 -08:00
Matias Fontanini
efc833543f
fix: bind/publish to different addresses in non linux (#425)
This makes:

* Linux bind to 127.0.0.1 and publish on 127.255.255.255 which allows
multiple listeners on the same port as expected.
* Windows bind and publish on 127.0.0.1, which also allows multiple
listeners.
* Mac's lo interface doesn't have `BROADCAST` enabled, so binding
multiple listeners is not supported since while we could allow binding
multiple listeners, only one of them would get the packets so instead we
err. Maybe there's a better way of doing this but in practice nobody's
going to be running multiple presentations at once so making this more
complicated provides no gain.
2025-01-24 16:27:19 -08:00
Matias Fontanini
5506a7bb1e fix: bind/publish to different addresses in non linux 2025-01-24 16:15:16 -08:00
Matias Fontanini
5f81ef3155
feat: allow multiline speaker notes (#424)
This allows multiline speaker notes, but also multiline comment commands
in general. The conditions here are changed so that we first try to
parse a comment command and if that fails we then check if we should
ignore it. This allows essentially using any comment command in a
multiline fashion, such as:

```markdown
<!-- 
speaker_note: |
  something
  something else
-->
```

and

```markdown
<!--
pause
-->
```
2025-01-23 16:20:14 -08:00
Matias Fontanini
ee06739b9c feat: allow multiline speaker notes 2025-01-23 16:15:17 -08:00
Matias Fontanini
f6df7aa110
feat: add markdown alert support (#423)
This adds supports for markdown alerts (github/gitlab style) as support
for these was recently added to comrak. This for now looks close a block
quote except it also contains a title colored the same color as the
vertical bar prefix that shows up on the left of the block quote.

See https://github.com/kivikakk/comrak/pull/519 and
https://github.com/kivikakk/comrak/pull/521 for syntax but basically
this:

```markdown
> [!note]
> this is a note

> [!tip]
> this is a tip

> [!important]
> this is important

> [!warning]
> this is warning!

> [!caution]
> this advises caution!

>>> [!note] other title
ez
multiline
>>>
```

Renders like this:


![image](https://github.com/user-attachments/assets/219024ae-b635-4bf2-87ba-e252b64ebd67)
2025-01-22 18:05:10 -08:00
Matias Fontanini
388932f2b4 feat: add markdown alert support 2025-01-22 17:56:07 -08:00
Matias Fontanini
c8e413c6bc
chore: bump dependencies (#422)
This bumps dependencies and luckily comrak fixed the source position bug
where the front matter's lines weren't being taken into account so the
workarounds here go away.
2025-01-22 16:34:59 -08:00
Matias Fontanini
d66fe47e2b chore: bump dependencies 2025-01-22 16:32:06 -08:00
Matias Fontanini
6a494c63cd
fix: udp socket bind issues in windows/mac (#421)
It looks like mac and windows can't bind to 127.255.255.255, whereas
this works in linux. For now those 2 will bind to 127.0.0.1, which means
you won't be able to run more than one presentation using speaker notes
at the same time, which is anyway something that should never happen in
practice.
2025-01-22 16:19:27 -08:00
Matias Fontanini
51f8b73180 fix: udp socket bind issues in windows/mac 2025-01-22 16:12:57 -08:00
Matias Fontanini
dcdf5613d3
fix: check for TERM_PROGRAM before TERM to determine emulator (#420)
I noticed somehow this was thinking I was in xterm when using ghostty.
This moves the detection of emulators based on `TERM_PROGRAM` up before
anything else.
2025-01-21 17:05:08 -08:00
Matias Fontanini
897d496035 fix: check for TERM_PROGRAM before TERM to determine emulator 2025-01-21 17:01:37 -08:00
Matias Fontanini
4bb9705b07
feat: use UDP to publish/listen for speaker notes (#419)
This is a followup to #389 that uses UDP sockets and loopback broadcast
addresses to publish and listen for speaker notes. The previous solution
using `iceoryx2` was using shared memory to communicate between
different `presenterm` instances. This is fine but was causing a couple
of issues:

* When trying to compile presenterm in a clean system I found I needed
libclang (?) to build it. I'm not sure what transitive dependency needs
this but I don't really want to deal with having builds that require
having external libraries installed for it to work. e.g. this means we'd
have to do something about the CI as it will likely fail to build the
release artifacts as it is.
* This was pulling in roughly ~60 extra dependencies. I like to be
conservative with dependencies and while I'd be okay with pulling this
many if it really solved a problem that would be annoying to solve
otherwise, I think this is not the case.
* Not an issue right now but the events sent were limited to statically
allocated pieces of memory (e.g. no `Vec`s) which could cause future
evolutions of this solution to require hacky workarounds.

This new solution instead uses JSON over UDP sockets. Note that nothing
internally in how speaker notes are parsed/managed has changed, it's
just the communication protocol that did. This works like this:

* The publisher binds a UDP port to a random port and sends events to a
broadcast address on the loopback interface (e.g. `127.255.255.255:<some
port>`).
* Listeners will bind to that same broadcast address using a non
blocking socket and will listen for events as usual. Listeners use
`SO_REUSEADDR` to allow multiple listeners on the same port as otherwise
each packet goes to a single listener.

Events sent contain the full presentation path so this allows having
multiple publishers and listeners using different presentations but
listening on the same port. This is the equivalent of the service name
we had before.

As part of this I also changed the CLI parameters to be
`--publish-speaker-notes` and `--listen-speaker-notes` as they're a bit
shorter. Also I added a `speaker_notes.always_publish` configuration
parameter that causes the tool to always publish speaker notes unless
you're running in listen mode.
2025-01-21 16:53:28 -08:00
Matias Fontanini
98f1c388f2 ci: bump rust-toolchain to 1.78.0 2025-01-21 16:49:11 -08:00
Matias Fontanini
e2d8313132 feat: use UDP to publish/listen for speaker notes 2025-01-21 16:31:05 -08:00
Matias Fontanini
954f112bfd
feat: allow auto rendering code snippets (#418)
This adds a new `auto_render_languages`
[option](https://mfontanini.github.io/presenterm/guides/configuration.html#options)
(meaning it can be configured in the config file or in the presentation
itself) that specifies a list of languages for which the `+render`
attribute is implied and are therefore automatically rendered. This
means for example this presentation would have its mermaid diagram
automatically rendered as an image:

~~~markdown
---
options:
    auto_render_languages:
        - mermaid
---

```mermaid
sequenceDiagram
    bob ->> mike: oh hi
```
~~~

Fixes #416
2025-01-20 13:36:57 -08:00
Matias Fontanini
1f5acd8a79 feat: allow auto rendering code snippets 2025-01-20 13:25:35 -08:00
Matias Fontanini
fd9a1284b2
feat: allow capping max columns on presentation (#417)
This adds a `defaults.max_columns` property in the config file that
allows specifying the maximum number of columns you want the
presentation to take up. This means if `columns > max_columns`, the
presentation will be capped to `max_columns` and the entire presentation
will be centered with the remainder of the size being spread equally on
both sides of the presentation.

Fixes #410
2025-01-20 12:57:54 -08:00
Matias Fontanini
338acbfb12 feat: allow capping max columns on presentation 2025-01-20 12:48:34 -08:00
Matias Fontanini
607c38212b
chore: restructure file tree (#415)
This makes major changes in how the code is structured and tries to
group files that belong together under the same modules, getting rid of
unnecessary files in the process. There's still more work to do to clean
the codebase up but this at least should make finding the right files in
the source tree easier. I've had the problem myself that I have trouble
finding the file that I need so I can't imagine how bad it is for an
outsider.
2025-01-19 14:51:04 -08:00
Matias Fontanini
9b3e9249e1 chore: cleanup TerminalDrawer 2025-01-19 14:40:09 -08:00
Matias Fontanini
b7df61d36c chore: move style to markdown 2025-01-19 14:40:09 -08:00
Matias Fontanini
e2f23476e7 chore: rename custom to config 2025-01-19 14:40:09 -08:00
Matias Fontanini
19d47a36cb chore: move presentation related code together 2025-01-19 14:40:09 -08:00
Matias Fontanini
9a8faf4c71 chore: move ansi splitting code into terminal module 2025-01-19 14:40:09 -08:00
Matias Fontanini
e428f9cc25 chore: move all ui components code into ui module 2025-01-19 14:40:09 -08:00
Matias Fontanini
4cf9c93180 chore: move all code/snippet related code together 2025-01-19 14:40:09 -08:00
Matias Fontanini
abce141f5e chore: move Terminal to terminal module 2025-01-19 14:40:09 -08:00
Matias Fontanini
99b49c364f chore: reorganize image printing modules 2025-01-19 14:40:08 -08:00
Matias Fontanini
5bd18f9dcd chore: cleanup input handling modules 2025-01-19 05:19:27 -08:00
Matias Fontanini
a5bd8b9986 chore: remove lib.rs 2025-01-19 05:02:45 -08:00
Matias Fontanini
22c4e28899
Added Haskell executor (#414) 2025-01-18 18:33:50 -08:00
Deepak Sharma
778144160f Added Haskell executor 2025-01-18 20:21:55 -05:00
Matias Fontanini
e096a7b991
fix: ignore clippy false positive on template variables (#413) 2025-01-18 17:04:58 -08:00
Matias Fontanini
a8e78d2304 fix: ignore clippy false positive on template variables 2025-01-18 16:55:50 -08:00
Matias Fontanini
5ae691b48a
feat: support speaker notes (#389)
Closes https://github.com/mfontanini/presenterm/issues/112.

- Adds support for speaker notes via via inter-process communication
(IPC) using [iceoryx2](https://github.com/eclipse-iceoryx/iceoryx2):
- Bumps CI `rust` version to 1.75.0 (for compatibility with `iceoryx2`).
- Adds new speaker notes example presentation.


## Speaker notes implementation:
- Speaker notes can be added to a presentation markdown file in the form
of HTML comments: `<!-- speaker_note: Your speaker note here. -->`.
- `--speaker-notes-mode=publisher` CLI option is used to present slides.
In this mode, speaker note comments are ignored and not rendered, and
every time the slide changes, an IPC message is published.
- `--speaker-notes-mode=receiver` is used to view speaker notes. In this
mode, only the title of each slide and speaker note comments are
rendered. When retrieving the next command, we first check for an IPC
message to change slides.
- If `--speaker-notes-mode` is not specified, no IPC structures are
created, no messages are published/listened for, and speaker notes are
ignored and not rendered.

## Demo:

https://github.com/user-attachments/assets/ee75452b-5ea5-4d02-a599-3d7e8d9f69c0
2025-01-18 16:45:57 -08:00
Matias Fontanini
5e651f6360
feat: use kitty image protocol in ghostty (#405)
This detects ghostty and uses the kitty graphics protocol on it by
default. I don't have access to ghostty yet so I can't test this but
based on [this
article](https://fredrikaverpil.github.io/blog/2024/12/04/ghostty-on-macos/)
`TERM_PROGRAM=ghostty` is the way to detect this. If it implements the
protocol correctly, this should work out of the box.

@fredrikaverpil could I ask for your help here? :)
2024-12-26 20:26:44 -03:00
Matias Fontanini
37d71a9ed4 feat: use kitty image protocol in ghostty 2024-12-26 19:55:36 -03:00
dmackdev
cd76a97544 restore version of home dep 2024-12-24 17:37:42 +00:00
dmackdev
34bf39eb90 Merge remote-tracking branch 'origin/master' into speaker-notes 2024-12-24 17:11:58 +00:00
dmackdev
28d9c9f72e update iceoryx2 to 0.5.0, and remove now uneeded set log level 2024-12-24 17:02:12 +00:00
dmackdev
664caac3ca Merge remote-tracking branch 'origin/master' into speaker-notes 2024-12-24 16:54:56 +00:00
Matias Fontanini
1809efb536
feat: detect kitty through tmux (#406)
This PR adds support for kitty protocol detection when you're using
tmux. This means now when you're running tmux inside kitty you no longer
need to explicitly configure the image protocol to be kitty-local/remote
and it will be instead figured out automatically.

This is done by making two kitty graphics protocol queries (and for
local and one for remote support) and another widely supported query
just in case the emulator doesn't support the kitty protocol and ignores
our queries (this is what's suggested
[here](https://sw.kovidgoyal.net/kitty/graphics-protocol/#querying-support-and-available-transmission-mediums)).
The [third query](https://vt100.net/docs/vt510-rm/DA1.html) being done
gets us the terminal capabilities; we figure out whether we support
sixel by parsing its output. This means now we always make these queries
once regardless of the protocol being used unless you explicitly
configure a protocol other than `auto` (the default).

The caveat here is that terminal emulators may not support [unicode
placeholders](https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders)
which is a requirement for the kitty protocol to work while inside tmux.
At least wezterm [does not currently support
them](https://github.com/wez/wezterm/issues/986) so images printed while
inside tmux in it look like crap. The workaround for now is to guess
that we're inside wezterm + tmux by checking an env var that wezterm
sets. This won't work if you start tmux in another terminal (since it
inherits _those_ env vars) but it's the best we can do. The outcome of
this is if there's another terminal that does support the kitty protocol
but does not have unicode placeholders implemented, images will look bad
and users will be forced to [specify the image
protocol](https://mfontanini.github.io/presenterm/guides/configuration.html#preferred-image-protocol)
to be used by hand.
2024-12-19 18:23:31 -08:00
Matias Fontanini
7ea9cb1e74 feat: detect kitty through tmux 2024-12-19 18:15:26 -08:00
Matias Fontanini
ec192d1481
chore: bump dependencies (#404) 2024-12-07 13:06:07 -08:00
Matias Fontanini
dd2737f9d9 chore: bump dependencies 2024-12-07 13:00:14 -08:00
Matias Fontanini
a324bc9218
docs: document tmux active session bug (#402) 2024-12-04 05:42:46 -08:00
Matias Fontanini
b82a7e365d docs: document tmux active session bug 2024-12-04 05:38:38 -08:00
dmackdev
792452416b add better error messages for iceoryx2 service open/create errors 2024-12-04 09:36:43 +00:00
dmackdev
64dcc88d7d set iceoryx2 log level to error to hide misleading "missing config" warning 2024-12-04 09:33:00 +00:00
Matias Fontanini
2a469f39d3
feat: force color output in rust, c, and c++ compiler executions (#401)
This forces color output when running rust, c, and c++ code snippets and
a compilation error is found. e.g.


![image](https://github.com/user-attachments/assets/89328c32-4a88-4287-adf4-0d4d971717a4)

Fixes #400
2024-11-28 13:18:49 -08:00
Matias Fontanini
2cbfa09e8b feat: force color output in rust, c, and c++ compiler executions 2024-11-28 13:12:53 -08:00
Matias Fontanini
254b55c9c0 fix: handle missing ansi codes 2024-11-28 13:11:42 -08:00
Matias Fontanini
c5beb82a67
Add C# to code executors (#399)
Closes #398.

To test:

```csharp +exec
// ignore
System.Console.WriteLine("Hello from C#");
```
2024-11-25 18:10:40 -08:00
Giovanni Bassi
5ed8d48f5b
Add C# to code executors
Closes #398.
2024-11-25 14:22:18 -03:00
dmackdev
209cd10da8 propagate error instead of expect 2024-11-21 17:33:52 +00:00
dmackdev
2964ac9397 use string interpolation for ipc service name 2024-11-21 17:05:28 +00:00
dmackdev
13e2d4655f send new SpeakerNotesCommand::Exit ipc message to exit the speaker notes presentation 2024-11-21 17:04:11 +00:00
dmackdev
52d3f04009 propagate iceoryx2 receiver error 2024-11-19 19:13:34 +00:00
dmackdev
3114df9de4 propagate iceoryx2 publisher errors 2024-11-19 19:01:44 +00:00
dmackdev
ddbaf41977 split process comment command for speaker notes mode 2024-11-19 18:55:18 +00:00
dmackdev
43b44c5b7f incorporate presentation file name in ipc service name 2024-11-17 15:41:36 +00:00
Matias Fontanini
0d9e9ffc9a
docs: add notes on running bat directly (#397) 2024-11-16 13:36:45 -08:00
Matias Fontanini
792ed7c1b4 docs: add notes on running bat directly 2024-11-16 13:36:11 -08:00
Matias Fontanini
7479395fe3
fix: allow jumping back to column in column layout (#396)
Before this change you could create a column layout, jump to column X,
then Y, then go back to X and it worked but it would start over at the
very top row of column X. This meant it would overwrite content you
wrote in that column the last time you entered it.

This changes that behavior so it behaves as you'd expect: re-entering a
column will cause the cursor to continue at the last row you modified in
that column. This, in combination with pauses, allows you to create
presentations where content shows up in multiple columns progressively,
e.g. pause, show content on column 0, show content on column 1, pause,
show more content, etc.

As an example, the following presentation:

~~~markdown
# Text before

<!-- column_layout: [1, 1] -->

<!-- column: 0 -->
# hi

<!-- column: 1 -->
hi

<!-- column: 0 -->
bye mom

this is text

<!-- column: 1 -->
hi

hi

hi

hi

<!-- reset_layout -->

# Text after
~~~

Renders like this:


![image](https://github.com/user-attachments/assets/8f071bfd-5793-434f-9254-032d9aaa697f)

Fixes #346
2024-11-16 13:26:40 -08:00
Matias Fontanini
c2899e91d2 fix: allow jumping back to column in column layout 2024-11-16 13:17:34 -08:00
dmackdev
27a6151d9a use pubsub ipc messaging pattern instead of event 2024-11-16 12:07:04 +00:00
dmackdev
dc0e03927f fix formatting 2024-11-16 10:52:19 +00:00
Matias Fontanini
e2e7e5a222
fix: ignore comments that start with vim: prefix (#395)
Fixes #392
2024-11-14 15:52:23 -08:00
Matias Fontanini
f06cf33ac8 fix: ignore comments that start with vim: prefix 2024-11-14 15:50:26 -08:00
Matias Fontanini
61cd49f2b2
Add R to executors (#393)
Thanks for this awesome project! If it's welcome, I added
[R](https://www.r-project.org/) support to the executors. I've only
tested this locally but it seems to work nicely.

![Screen Recording 2024-11-13 at 1 17 32 PM
(1)](https://github.com/user-attachments/assets/f82b7860-3988-4973-8265-d5fe7adb5c16)


[Rscript](https://search.r-project.org/R/refmans/utils/html/Rscript.html)
is a command-line tool that's installed along with R, and should behave
in a way that's compatible with your approach.
2024-11-12 19:49:30 -08:00
Jonathan Carroll
56c17b4651
add R to executors 2024-11-13 13:12:20 +10:30
Matias Fontanini
149d954069
refactor: misc cleanups (#391)
This cleans up a few things around constructing styles, line numbers
when displaying errors, etc.
2024-11-11 13:25:34 -08:00
Matias Fontanini
f089a10522 chore: clean up line numbers in errors 2024-11-11 13:18:44 -08:00
dmackdev
0f83b5a595 split process_element for speaker notes and presentation modes 2024-11-11 19:29:54 +00:00
dmackdev
0fd812e7a5 Merge remote-tracking branch 'origin/master' into speaker-notes 2024-11-11 18:32:43 +00:00
dmackdev
5f781aca34 bump CI rust version to 1.75.0 2024-11-11 18:05:20 +00:00
dmackdev
4416af81c4 remove SpeakerNoteChannel enum
store IPC publisher in Presenter
store IPC receiver in CommandSource
2024-11-11 18:04:38 +00:00
Matias Fontanini
e9b61ea1e5 refactor: simplify pushing multiple line breaks 2024-11-11 09:59:31 -08:00
Matias Fontanini
91bd6a0645 refactor: rename text block to text line 2024-11-11 09:54:37 -08:00
Matias Fontanini
12be307b93
feat: parse inline span tags and css color attributes in them (#390)
This PR adds support to parse inline span html tags and the `color` and
`background-color` CSS attributes in the `style` span tag attribute.

The `color` and `background-color` can have any `#RRGGBB` formatted
color + any color like `red`, `dark_red`, etc, as used in themes.

For now this only supports "strict" mode in which any deviation from
what's supported will raise an error. In another PR I will add a way to
configure this flag so if you don't feel like erroring, you can disable
it.

After this change, the following:

~~~markdown
# <span style="color: black; background-color: yellow"> _hi_ </span> mom

Paragraphs can <span style="color: #ffff00">**mix** colors <span
style="background-color: white">in any way</span>


* <span style="color: red">this is red</span>
* this has <span style="background-color: blue">blue background</span>
~~~

Renders as:


![image](https://github.com/user-attachments/assets/fbd938da-4b78-46a0-ae90-cd8926cf4041)

Fixes #88
2024-11-10 15:23:11 -08:00
Matias Fontanini
fb1ffcc996 feat: parse inline span tags and css color attributes in them 2024-11-10 14:01:32 -08:00
dmackdev
81cfbbcc2e remove ipc event service duplication 2024-11-08 19:22:38 +00:00
dmackdev
df051d3980 show titles in speaker notes mode
support implicit slide ends in speaker notes mode
2024-11-08 18:24:51 +00:00
dmackdev
4c7726ef92 add speaker notes example presentation 2024-11-08 17:50:45 +00:00
dmackdev
22d70f7940 restore original examples/code.md 2024-11-08 17:50:28 +00:00
dmackdev
93a0dcba3e move SpeakerNotesChannel to Presenter from PresentationStateInner
always notify channel of current slide index after command is applied
pass bool flag to builder for rendering speaker notes only
2024-11-08 17:18:21 +00:00
dmackdev
cbadf07bc0 fix layout on speaker note slides 2024-11-06 21:39:39 +00:00
dmackdev
3151fbdb7c process only speaker notes and end slide comments when in speaker note receiver mode 2024-11-05 09:31:17 +00:00
dmackdev
fd6d57bf52 use ipc to navigate to specfic slides in speaker notes presentation when the "publisher" presentation changes slides 2024-11-05 09:18:21 +00:00
dmackdev
de42655389 add speaker notes mode cli option
add speaker note CommentCommand
push text when processing speaker note comment
2024-11-04 21:02:56 +00:00
Matias Fontanini
67b71bbef8
feat: add tcl code highlighting (#387)
Added TCL code highlighting.
2024-11-02 11:25:35 -07:00
Jesús Lázaro
a3dd66c7c7 fix: Correct verilog identifier 2024-10-31 15:21:37 +01:00
Matias Fontanini
8f54421a18
feat: add graphql code highlighting (#385)
Adding GraphQL code highlighting.

`bat` is so awesome 😮
2024-10-31 06:26:03 -07:00
Jesús Lázaro
14d4bee671 add verilog code highlighting 2024-10-30 15:41:34 +01:00
Jesús Lázaro
0488bb5135 feat: add tcl code highlighting 2024-10-30 15:19:32 +01:00
Graham Vasquez
aee4c2e2d8
style: formatting 2024-10-28 12:59:32 -04:00
Graham Vasquez
43e53b74ea
feat: add graphql code highlighting 2024-10-28 03:45:14 -04:00
Matias Fontanini
6cf25e5eb9
chore: bump dependencies (#384) 2024-10-26 13:42:57 -07:00
Matias Fontanini
b549bd7dcc chore: bump dependencies 2024-10-26 13:32:55 -07:00
Matias Fontanini
d5e7c9f4bc
fix: respect +no_background on a +exec_replace block (#383)
Fixes #381
2024-10-12 13:19:58 -07:00
Matias Fontanini
77343e5edc fix: respect +no_background on a +exec_replace block 2024-10-12 13:15:45 -07:00
Matias Fontanini
1fd0bca2e4
chore: set correct date on 0.9.0 release in changelog 2024-10-06 12:40:25 -07:00
Matias Fontanini
657155eafe
chore: prepare v0.9.0 changes (#377) 2024-10-06 12:30:00 -07:00
Matias Fontanini
a8d44e158c chore: fix new clippy lints 2024-10-06 10:53:21 -07:00
Matias Fontanini
a3eacf7277 chore: update demo.gif 2024-10-06 10:48:36 -07:00
Matias Fontanini
e1a05307ac chore: bump version to 0.9.0 2024-10-06 10:48:36 -07:00
Matias Fontanini
dec21d51cf chore: update docs with 0.9.0 changes 2024-10-06 10:48:35 -07:00
Matias Fontanini
e5868f4b62 chore: update changelog with 0.9.0 entries 2024-10-06 10:46:52 -07:00
Matias Fontanini
b1906bdb10
feat: run acquire-terminal snippets a single time (#376)
This changes `+acquire_terminal` snippets so they can only be run a
single time and adds the bar below them like in normal snippets. This
lets you know they're executable and whether they've been run or not.

PS: this whole code execution displaying thing needs some refactoring

Fixes #374
2024-10-03 16:18:29 -07:00
Matias Fontanini
11c2649e70 feat: run acquire-terminal snippets a single time 2024-10-01 18:30:01 -07:00
Matias Fontanini
b6b4ce724a
chore: use debug mode in rust-script snippets (#375)
The default seems to be release mode which will slow down compilation
unnecessarily and is inconsistent with `rust` which uses `cargo run`
which uses debug mode by default.
2024-09-29 13:38:30 -07:00
Matias Fontanini
a5ad829a4e chore: use debug mode in rust-script 2024-09-29 09:12:46 -07:00
Matias Fontanini
43bc5ffaca
feat: watch file code snippet files (#372)
This makes it so that code snippets that use `file` and point to an
external file are watched for modifications.
2024-09-21 14:11:29 -07:00
Matias Fontanini
74205dbd34 chore: fmt with latest nightly 2024-09-21 14:07:47 -07:00
Matias Fontanini
e927f8b812 feat: watch file code snippet files 2024-09-21 14:04:49 -07:00
Matias Fontanini
0056105008
fix: pass along -X to presenterm-export (#371)
Closes https://github.com/mfontanini/presenterm-export/issues/11
2024-09-17 16:31:08 -07:00
Matias Fontanini
eaf2ff91f8 fix: pass along -X to presenterm-export 2024-09-17 16:28:49 -07:00
Matias Fontanini
c08d33bf37
Add Racket syntax highlighting (#367)
Add code highlighting for the Racket programming languages.
2024-09-14 12:28:14 -07:00
Matias Fontanini
41db0f6e2b
feat: add no_background property (#368)
This renames the `no_margin` attribute added in #363 to `no_background`
and makes it do everything `no_margin` did but also prevents presenterm
from adding a background to the snippet. This currently doesn't play
well with `exec_replace` or `exec` meaning the snippet execution output
still gets background. I will address this separately as there's other
things I want to get to but I want to close this issue.

The following

~~~markdown
```text +no_background
.__    .__
|  |__ |__|   _____   ____   _____
|  |  \|  |  /     \ /  _ \ /     \
|   Y  \  | |  Y Y  (  <_> )  Y Y  \
|___|  /__| |__|_|  /\____/|__|_|  /
     \/           \/             \/
```
~~~

Currently renders like this after this change:


![image](https://github.com/user-attachments/assets/4b883337-f238-4d85-9420-50dc42002cbf)

Fixes #349
2024-09-12 16:23:45 -07:00
Matias Fontanini
a065300649 feat: add no_background snippet attribute 2024-09-12 16:13:21 -07:00
Matias Fontanini
2f6fb6e849
feat: allow acquiring terminal when running snippets (#366)
This introduces a new `+acquire_terminal` snippet attribute which causes
the snippet to take ownership of the terminal while the contents of it
are being executed. This means the output won't be displayed inside the
presentation but instead the presentation will pause, invoke the code
snippet, and resume after the snippet is done. This is useful when
running TUIs as otherwise those won't run given the terminal is taken by
presenterm already.

Fixes #365
2024-09-12 15:49:33 -07:00
Phi
fc9b25c154 Add Racket syntax highlighting 2024-09-11 17:16:35 +02:00
Matias Fontanini
d22910c912 feat: allow acquiring terminal when running snippets 2024-09-09 20:00:50 -07:00
Matias Fontanini
1d6c34ac5b
feat: add +no_margin to remove margin in code blocks (#363)
This adds a `+no_margin` attribute to code blocks which removes extra
margins. For themes that specify a vertical/horizontal padding (like all
built in themes), the padding is still present even when using this.
This only removes the margin that typically extends a bit right if your
code snippet's max line length is too short.

This is especially useful when using `+exec_replace` so you don't get
any extra margin and instead get the executed program's output as-is.
For example, the following now looks like this:

~~~markdown
```bash +exec_replace +no_margin
echo "hello world" | qrencode -t utf8
```
~~~


![image](https://github.com/user-attachments/assets/78478b81-0393-4ad8-afa8-ec3e6a59920f)

Fixes #349
2024-09-09 09:10:30 -07:00
Matias Fontanini
b71ceb458b feat: add +no_margin to remove margin in code blocks 2024-09-09 09:03:19 -07:00
Matias Fontanini
bf19b497b2
Add TOML highlighting. Resolves #360. (#361) 2024-09-02 07:20:30 -07:00
witchard
5c634c2aed Add TOML highlighting. Resolves #360. 2024-09-02 13:49:42 +01:00
Matias Fontanini
a2616833ab
fix: allow list-themes/acknowledgements to run without path (#359)
This was broken on a recent change
2024-08-21 05:28:20 -07:00
Matias Fontanini
b4435545a6 fix: allow list-themes/acknowledgements to run without path 2024-08-21 05:24:10 -07:00
Matias Fontanini
d21adcd5e0
feat!: use "template" footer in built-in themes (#358)
This makes the footer in built in themes be a template with
`{current_slide} / {total_slides}` in the right. The progress bar was
"cool" but it's not very professional so I don't think it should be the
default.

Relates to #339 #357
2024-08-21 05:18:15 -07:00
Matias Fontanini
32d5046303 feat!: use "template" footer in built-in themes 2024-08-21 05:12:13 -07:00
Matias Fontanini
778f03efe6
fix: translate tabs in snippet to 4 spaces (#356)
This replaces `\t` in code snippets with 4 spaces. If you'd prefer a
number of spaces different than 4 please stop using this cursed
character.

Fixes #355
2024-08-20 17:14:58 -07:00
Matias Fontanini
d8b91363c6 fix: translate tabs in snippet to 4 spaces 2024-08-20 17:09:52 -07:00
Matias Fontanini
9e87136dfd
fix: add padding to right of code block wrapped lines (#354)
This fixes a styling issue leftover from #320 where there was no padding
being applied to the right of wrapped code block lines.
2024-08-20 06:36:49 -07:00
Matias Fontanini
dfa370ee89 fix: add padding to right of code block wrapped lines 2024-08-20 06:31:08 -07:00
Matias Fontanini
2076386eb5
fix: don't wrap separator line (#353)
Fixes #343
2024-08-20 05:57:18 -07:00
Matias Fontanini
9e8ce891ed fix: don't wrap separator line 2024-08-20 05:52:28 -07:00
Matias Fontanini
a823cf5fc2
fix: show block quote prefix when wrapping (#352)
Fixes #340
2024-08-20 05:45:17 -07:00
Matias Fontanini
cc02c82469 fix: show block quote prefix when wrapping 2024-08-20 05:41:09 -07:00
Matias Fontanini
145530012d
fix: handle inline code in block quotes (#351)
Follow up on #350
2024-08-20 05:28:50 -07:00
Matias Fontanini
7e4bc50eb0 fix: handle inline code in block quotes 2024-08-20 05:24:26 -07:00
Matias Fontanini
f3dc245756
docs: fix link to mermaid docs in README 2024-08-19 15:47:23 -07:00
Matias Fontanini
96cd1d4751
feat: style markdown inside block quotes properly (#350)
This adds proper styling (bold, italics, etc) to block quotes.

Fixes #341
2024-08-19 15:39:08 -07:00
Matias Fontanini
672a5bd56b feat: style markdown inside block quotes properly 2024-08-18 19:06:08 -07:00
Matias Fontanini
cd35bd752f chore: remove ParagraphElement and use text blocks directly 2024-08-18 13:00:49 -07:00
Matias Fontanini
916ab0cab5
docs: add example on how column layouts and pauses interact (#348)
Relates to #346
2024-08-18 12:41:35 -07:00
Matias Fontanini
895dc11c96 docs: add example on how column layouts and pauses interact 2024-08-18 12:38:58 -07:00
Matias Fontanini
9a00d0f756
fix: don't crash on code block with only hidden-line-prefixed lines (#347)
Fixes #344
2024-08-18 12:31:34 -07:00
Matias Fontanini
50d615245c
feat: add template variables (#338)
This PR adds some more variables that can be used in the footer template
(basically everything available in the slide metadata):
- `{title}`
- `{sub_title}`
- `{location}`
- `{event}`
- `{date}`
2024-08-18 12:27:37 -07:00
Matias Fontanini
a19d435edf fix: don't crash on code block with only hidden-line-prefixed lines 2024-08-18 12:25:40 -07:00
Matias Fontanini
3cffa04077
docs: rename jump_to_vertical_center -> jump_to_middle (#342) 2024-08-18 12:20:45 -07:00
Eugene Oliveros
459b4aba40 docs: rename jump_to_vertical_center -> jump_to_middle 2024-08-18 13:52:53 +08:00
Caleb White
a2301d81c6
feat: add template variables 2024-08-17 17:43:07 -05:00
Matias Fontanini
e1d3a2761f
feat: move hidden line prefix into executors file (#337)
This moves the hidden line prefix into the executors file. This allows
making this more explicit and harder to miss, and also allows custom
executors in config files to define this without having to touch the
code.

Fixes #313
2024-08-14 05:35:32 -07:00
Matias Fontanini
56a43c2d59 feat: move hidden line prefix into executors file 2024-08-14 05:31:14 -07:00
Matias Fontanini
7ed3ed42ee
docs: document all keyword (#335)
Just adding some docs for this ability, thanks!
2024-08-13 07:06:09 -07:00
Caleb White
4c6ffebb5b
docs: document all keyword 2024-08-13 09:00:07 -05:00
Matias Fontanini
66aa3a311d
feat: show link label and title (#334)
Closes #325 

Hello!

This PR renders the various link attributes (if they exist) to prevent
lose of context/information. It's also nice to automatically style the
relevant info so the user doesn't have to manually do it when copying
markdown links from other locations.

It turns out there's two other link attributes besides the url:
- **label** this is the portion between the brackets (a Text child of
the Link node)
- **title** this is the portion between the double quotes inside the
link (this is most often shown as a tooltip when hovering over the
link/label)

```markdown
* Links look like this [](https://example.com/)
* Links with titles look like this [](https://example.com/ "Example")
* Links with labels look like this [example](https://example.com/)
* Links with titles and labels look like this [example](https://example.com/ "Example")
```


![image](https://github.com/user-attachments/assets/b1ba7e46-b24b-4304-b3de-5aec772596dc)

I'm open to formatting suggestions, let me know what you think! We might
could display this in a more markdown style (I kinda like this better):


![image](https://github.com/user-attachments/assets/e7800ca2-4f1b-4cf2-9276-f77961a47139)


Thanks!
2024-08-13 06:14:37 -07:00
Caleb White
9aa1ef4d74
feat: show link label and title 2024-08-12 11:34:04 -05:00
Matias Fontanini
a31f6e4e96
feat: add php executor (#332)
Hello!

This adds the ability to execute PHP code.

Thanks!
2024-08-11 12:29:11 -07:00
Matias Fontanini
b8a546f610
fix: canonicalize resources path (#333)
Hopefully fixes #331
2024-08-11 12:28:17 -07:00
Matias Fontanini
ceac5af26f fix: canonicalize resources path 2024-08-11 09:04:46 -07:00
Caleb White
d1852c7971
feat: add php executor 2024-08-10 16:29:21 -05:00
Matias Fontanini
cdc8cbde99
feat: allow snippets to be executed and replaced with output (#330)
This adds the `+exec_replace` attribute that is basically an `+exec`
except the output of the snippet's execution replaces the snippet
itself. This allows using tools that emit ascii art, colorize outputs,
etc.

I added a new set of configs to enable this (`-X` as a cli parameter and
`snippet.exec_replace.enable` in the config file) as this adds a whole
other level of danger when running someone else's presentation so I
don't think the configs for `exec` are enough to protect users.

Fixes #292
2024-08-10 09:56:02 -07:00
Matias Fontanini
dbc897a710 feat: allow snippets to be executed and replaced with output 2024-08-04 13:02:33 -07:00
Matias Fontanini
02ba5e8f87
feat: always show snippet execution bar (#329)
This makes it so that executable snippets always have the status bar
shown, which serves as an indication that a code snippet is executable.

Fixes #278
2024-08-04 12:26:31 -07:00
Matias Fontanini
9bda297b56 feat: always show snippet execution bar 2024-08-04 12:22:03 -07:00
Matias Fontanini
322fad6c48
feat: allow including external code snippets (#328)
This allows adding snippets that pull in an external source file.
Attributes still work like you'd expect. Examples:

A rust file that allows execution:
~~~
```file +exec +line_numbers
path: snippet.rs
language: rust
```
~~~

A mermaid script that is rendered like you'd expect
~~~
```file +render
path: snippet.mmd
language: mermaid
```
~~~

Relates to #307
2024-08-04 06:05:30 -07:00
Matias Fontanini
39782d072e feat: allow including external code snippets 2024-08-03 15:09:55 -07:00
Matias Fontanini
9075aa4fc2 chore: move code snippet parsing logic into builder 2024-08-03 14:36:10 -07:00
Matias Fontanini
d221b0a835
fix: execute script relative to current working directory (#323)
Intends to fix #322

I mostly just quickly wrote something so it would work for my use-case,
so I didn't test all languages.
2024-08-03 14:14:10 -07:00
Peter Leconte
bb2ace9579 chore: fix formatting 2024-08-03 22:06:07 +02:00
Matias Fontanini
bffe10458d
chore: add default diffable_content (#324)
Now diffing to figure out if something dynamic changed is always handled
by `Differ` so this can have a default impl.
2024-08-03 12:26:10 -07:00
Peter Leconte
87687d6373 feat: take slide path for cwd in executors 2024-08-02 20:04:32 +02:00
Peter Leconte
a9bf2c3264 fix: add pwd to c/c++ executors 2024-08-02 19:31:40 +02:00
Matias Fontanini
4047edb298
fix: support mmdc on windows (#319)
I am not sure if Windows platform support is of any importance but I
encountered an issue where `mmdc` cannot be found on Windows. This is
due to this
[issue](https://doc.rust-lang.org/std/process/struct.Command.html#implementations)
where `Command::new()` on Windows works in this way: "if the file has a
different extension, a filename including the extension needs to be
provided, otherwise the file won’t be found.".

Since mmdc on Windows is an alias for the `mmdc.cmd` script and not an
`exe`, this updates the name used to find the mmdc tool.
2024-08-02 06:35:21 -07:00
Matias Fontanini
7c1002f208 chore: add default diffable_content 2024-08-02 06:33:50 -07:00
Matias Fontanini
fb53aa00a4
feat: handle code overflow (#320)
This makes it so that code blocks don't overflow to the right of the
code block but instead how you'd expect them to: by creating a new line
within the code block rect, properly colored and stuff.


![image](https://github.com/user-attachments/assets/529b71e0-a267-4b20-b745-cbb190f310cb)

Fixes #289
2024-08-02 06:06:51 -07:00
Matias Fontanini
e45f21a255 feat: handle code overflow 2024-08-02 06:02:39 -07:00
Peter Leconte
f54f5875ff fix: execute script relative to current working directory 2024-08-01 20:44:11 +02:00
Matias Fontanini
d61375c37c
feat: handle suspend signal (SIGTSTP) (#318)
Hello!

Closes #237 

This PR allows `presenterm` to be suspended / resumed with `<c-z>` and
`fg`.

Thanks!
2024-07-31 20:55:37 -07:00
Caleb White
d7093d5b61
feat: handle suspend signal 2024-07-31 22:23:46 -05:00
brendan
b52dbb9179 fixup: cargo fmt on tools.rs 2024-07-31 23:57:55 +08:00
Matias Fontanini
27e3472bf5
feat: allow closing with q (#321)
Hello!

This PR adds `q` to the list of exit commands---`q` is a common key used
by many programs to exit such as:
- less
- man pages
- vim/neovim
- top/htop/btop
- lookatme
- lazygit
- etc.

This allows both `<c-c>` (the SIGINT signal) and `q` (the common exit
key) to exit `presenterm`.

Thanks!
2024-07-31 07:28:41 -07:00
Caleb White
6fa85350f1
feat: allow closing with q 2024-07-31 09:05:25 -05:00
Matias Fontanini
7dba250b6e
feat: add event, location, and date frontmatter (#317)
Hello!

Closes #312 


This PR adds fields for the presentation `event`, `location`, and
`date`---these are optional fields but are common frontmatter elements,
particularly if the presentation is for a larger conference / event.

Example:
~~~markdown
---
title: How to Think Like a Designer
sub_title: It's easier than you think
event: Laracon US 2024
location: Dallas, TX
date: 2024-08-28
author: Jack McDade
---
~~~
2024-07-31 05:54:44 -07:00
Brendan
f81ff58c54 fix: support mmdc on windows 2024-07-31 14:03:35 +08:00
Caleb White
8fc9f0ccec
feat: add event, location, and date frontmatter 2024-07-30 22:19:49 -05:00
Matias Fontanini
05d0603ae6
feat: process colored output from execution output (#316)
This processes execution output's ansi escape codes and turns them into
`WeightedTexBlock`. This can then be rendered so that we:
* Get colored output automatically.
* Handle lines that are too long to fit on the screen automatically.

This needs more testing, I just tested a couple of commands and it works
fine but I didn't thoroughly test yet.

This is based on the initial work done on #296.

cc @DrunkenToast @calebdw I would appreciate if you could give this PR a
test. It's only handling a very tiny subset of escape codes so I
wouldn't be surprised if something failed. At least I tried basic things
like `ls --color=always` and piping stuff to `lolcat` and they both seem
to work fine to me.
2024-07-30 18:25:25 -07:00
Matias Fontanini
c240bf182f feat: process colored output from execution output 2024-07-30 18:19:56 -07:00
Matias Fontanini
fd2a9d9518
feat: add mermaid docs and sensible defaults (#314)
Hello!

Changes:
- add mermaid theme docs
- change default mermaid bg to `transparent` (this seems to be how
Github renders mermaid charts)
- add sensible mermaid default based on theme

I noticed that the `mermaid` theme parameters didn't make it into the
documentation so I decided to add it.

I also tried to add sensible mermaid theme defaults to better match the
supported themes instead of always having to override the theme. For
instance, on the `dark` theme the original mermaid defaults (bg: white,
theme: default) are quite jarring:



![image](https://github.com/user-attachments/assets/044e4996-9163-4b97-bf75-030c2ce33a9e)

But updating to (bg: transparent, theme: dark) looks much nicer:


![image](https://github.com/user-attachments/assets/31a2fca0-e40b-41a9-8125-6e1b12cefd9f)

Thanks!
2024-07-29 21:40:10 -07:00
Caleb White
28521fa263
feat: add sensible defaults for mermaid themes 2024-07-29 23:04:30 -05:00
Caleb White
1fc64cbcfa
feat: change default mermaid background color to transparent 2024-07-29 13:27:43 -05:00
Caleb White
09b13f396b
docs: add mermaid theme documentation 2024-07-29 13:13:32 -05:00
Matias Fontanini
2db26706b9 chore: use --locked in installation docs 2024-07-29 06:42:11 -07:00
Matias Fontanini
a79f88dcf4 chore: add docs on typst paths and extending themes 2024-07-29 06:41:08 -07:00
150 changed files with 16513 additions and 8728 deletions

1
.github/FUNDING.yml vendored Normal file
View File

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

View File

@ -16,11 +16,12 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Install mdBook
- name: Install cargo-binstall
uses: cargo-bins/cargo-binstall@v1.10.22
- name: Install mdbook
run: |
curl -sSL -o /tmp/mdbook.tar.gz https://github.com/rust-lang/mdBook/releases/download/v0.4.36/mdbook-v0.4.36-x86_64-unknown-linux-gnu.tar.gz
tar -xf /tmp/mdbook.tar.gz -C /usr/local/bin
mdbook --version
cargo binstall -y mdbook@0.4.44 mdbook-alerts@0.7.0
- name: Build the book
run: |

View File

@ -8,49 +8,55 @@ name: Merge checks
jobs:
check:
name: Check
name: Checks
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@1.74.0
uses: dtolnay/rust-toolchain@1.78.0
with:
components: clippy
- name: Run cargo check
run: cargo check --features sixel
test:
name: Tests
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@1.74.0
- name: Run cargo test
run: cargo test
lints:
name: Lints
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt, clippy
- name: Run cargo fmt
run: cargo fmt --all -- --check
- name: Run cargo clippy
run: cargo clippy -- -D warnings
- name: Install nightly toolchain
uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt
- name: Run cargo fmt
run: cargo +nightly fmt --all -- --check
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Install weasyprint
run: |
uv venv
source ./.venv/bin/activate
uv pip install weasyprint
- name: Export demo presentation as PDF and HTML
run: |
cat >/tmp/config.yaml <<EOL
export:
dimensions:
rows: 35
columns: 135
EOL
source ./.venv/bin/activate
cargo run -- --export-pdf -c /tmp/config.yaml examples/demo.md
cargo run -- --export-html -c /tmp/config.yaml examples/demo.md
nix-flake:
name: Validate nix flake
runs-on: ubuntu-latest

102
.github/workflows/nightly.yaml vendored Normal file
View File

@ -0,0 +1,102 @@
name: Nightly build
on:
schedule:
- cron: "0 0 * * *"
env:
RELEASE_VERSION: nightly
jobs:
vars:
name: Set release variables
runs-on: ubuntu-latest
outputs:
timestamp: ${{ steps.set.outputs.timestamp }}
git_hash: ${{ steps.set.outputs.git_hash }}
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Set variables
id: set
shell: bash
run: |
set -euo pipefail
timestamp=$(date -u)
git_hash=$(git rev-parse HEAD)
echo "timestamp=$timestamp" >> "$GITHUB_OUTPUT"
echo "git_hash=$git_hash" >> "$GITHUB_OUTPUT"
publish-github:
name: Publish on GitHub
runs-on: ${{ matrix.config.OS }}
needs: vars
strategy:
fail-fast: false
matrix:
config:
- { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-gnu" }
- { OS: ubuntu-latest, TARGET: "x86_64-unknown-linux-musl" }
- { OS: ubuntu-latest, TARGET: "i686-unknown-linux-gnu" }
- { OS: ubuntu-latest, TARGET: "i686-unknown-linux-musl" }
- { OS: ubuntu-latest, TARGET: "armv5te-unknown-linux-gnueabi" }
- { OS: ubuntu-latest, TARGET: "armv7-unknown-linux-gnueabihf" }
- { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-gnu" }
- { OS: ubuntu-latest, TARGET: "aarch64-unknown-linux-musl" }
- { OS: macos-latest, TARGET: "x86_64-apple-darwin" }
- { OS: macos-latest, TARGET: "aarch64-apple-darwin" }
- { OS: windows-latest, TARGET: "x86_64-pc-windows-msvc" }
- { OS: windows-latest, TARGET: "i686-pc-windows-msvc" }
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: ${{ matrix.config.TARGET }}
override: true
- name: Build
uses: actions-rs/cargo@v1
with:
use-cross: true
command: build
args: --release --locked --target ${{ matrix.config.TARGET }}
- name: Prepare release assets
shell: bash
run: |
mkdir release/
cp {LICENSE,README.md} release/
cp target/${{ matrix.config.TARGET }}/release/presenterm release/
mv release/ presenterm-${{ env.RELEASE_VERSION }}/
- name: Create release artifacts
shell: bash
run: |
if [ "${{ matrix.config.OS }}" = "windows-latest" ]; then
7z a -tzip "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \
presenterm-${{ env.RELEASE_VERSION }}
sha512sum "presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip" \
> presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.zip.sha512
else
tar -czvf presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \
presenterm-${{ env.RELEASE_VERSION }}/
shasum -a 512 presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz \
> presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.tar.gz.sha512
fi
- name: Upload the release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: nightly
file: presenterm-${{ env.RELEASE_VERSION }}-${{ matrix.config.TARGET }}.*
file_glob: true
overwrite: true
prerelease: true
release_name: Nightly
body: |
This is a nightly build based on ref [${{ needs.vars.outputs.git_hash }}](https://github.com/mfontanini/presenterm/commit/${{ needs.vars.outputs.git_hash }})
Generated on `${{ needs.vars.outputs.timestamp }}`

View File

@ -1,3 +1,232 @@
# v0.13.0 - 2025-04-25
## Breaking changes
* The CLI parameter to generate the JSON schema for the config file (`--generate-config-file-schema`) is now hidden behind a `json-schema` feature flag. The JSON schema file for the latest version is already publicly available at `https://github.com/mfontanini/presenterm/blob/${VERSION}/config-file-schema.json`, so anyone can use it without having to generate it by hand. This allows cutting down the number of dependencies in this project quite a bit ([#563](https://github.com/mfontanini/presenterm/issues/563)).
## New features
* Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitions.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)):
* Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)).
* Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)).
* Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)).
* Add `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)) - thanks @marianozunino.
* Allow specifying [start/end lines](https://mfontanini.github.io/presenterm/features/code/highlighting.html#including-external-code-snippets) in file snippet type ([#565](https://github.com/mfontanini/presenterm/issues/565)).
* Allow letting [pauses become new slides](https://mfontanini.github.io/presenterm/configuration/settings.html#pause-behavior) when exporting ([#557](https://github.com/mfontanini/presenterm/issues/557)).
* Allow [using images on right in footer](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) ([#554](https://github.com/mfontanini/presenterm/issues/554)).
* Add [`max_rows` configuration](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-height) to cap vertical size ([#531](https://github.com/mfontanini/presenterm/issues/531)).
* Add julia language highlighting and execution support ([#561](https://github.com/mfontanini/presenterm/issues/561)).
## Fixes
* Center overflow lines when using centered text ([#546](https://github.com/mfontanini/presenterm/issues/546)).
* Don't add extra space before heading if prefix in theme is empty ([#542](https://github.com/mfontanini/presenterm/issues/542)).
* Use no typst background in terminal-* built in themes ([#535](https://github.com/mfontanini/presenterm/issues/535)).
* Use `std::env::temp_dir` in the `external_snippet` test ([#533](https://github.com/mfontanini/presenterm/issues/533)) - thanks @Medovi.
* Respect `extends` in a theme set via `path` in front matter ([#532](https://github.com/mfontanini/presenterm/issues/532)).
## Misc
* Refactor async renders (e.g. mermaid/typst/latex `+render` blocks, `+exec` blocks, etc) to work truly asynchronously. This causes the output to be polled faster, and causes jumping to a slide that contains an async render to take a likely negligible (but maybe noticeable) amount of time to be jumped to. This was needed for slide transitions to work seemlessly ([#556](https://github.com/mfontanini/presenterm/issues/556)).
* Get rid of `textproperties` ([#529](https://github.com/mfontanini/presenterm/issues/529)).
* Add links to presentations using presenterm ([#544](https://github.com/mfontanini/presenterm/issues/544)) - thanks @orhun.
## Performance improvements
* A few performance improvements had to be done for slide transitions to work seemlessly:
* Pre-scale ASCII images when transitions are enabled ([#550](https://github.com/mfontanini/presenterm/issues/550)).
* Pre-scale generated images ([#553](https://github.com/mfontanini/presenterm/issues/553)).
* Cache resized ASCII images ([#547](https://github.com/mfontanini/presenterm/issues/547)).
## ❤️ Sponsors
Thanks to the following users who supported _presenterm_ via a [github sponsorship](https://github.com/sponsors/mfontanini) in this release:
* [@0atman](https://github.com/0atman)
* [@orhun](https://github.com/orhun)
* [@fipoac](https://github.com/fipoac)
# v0.12.0 - 2025-03-24
## Breaking changes
* Using incremental lists now adds an extra pause before and after a list. Use the `defaults.incremental_lists` [configuration parameter](https://mfontanini.github.io/presenterm/features/commands.html#incremental-lists-behavior) to go back to the previous behavior ([#487](https://github.com/mfontanini/presenterm/issues/487)) ([#498](https://github.com/mfontanini/presenterm/issues/498)).
## New features
* [PDF exports](https://mfontanini.github.io/presenterm/features/pdf-export.html) are now generated by invoking [weasyprint](https://pypi.org/project/weasyprint/) rather than by using the now deprecated _presenterm-export_. This gets rid of the need for _tmux_ and opens up the door for other export formats ([#509](https://github.com/mfontanini/presenterm/issues/509)) ([#517](https://github.com/mfontanini/presenterm/issues/517)).
* PDF export dimensions can now also be [specified in the config file](https://mfontanini.github.io/presenterm/configuration/settings.html#pdf-export-size) rather than always having them inferred by the terminal size ([#511](https://github.com/mfontanini/presenterm/issues/511)).
* Allow specifying path for temporary files generated during presentation export ([#518](https://github.com/mfontanini/presenterm/issues/518)).
* Respect font sizes in generated PDF ([#510](https://github.com/mfontanini/presenterm/issues/510)).
* Add [`skip_slide` comment command](https://mfontanini.github.io/presenterm/features/commands.html#skip-slide) to avoid including a slide in the final presentation ([#505](https://github.com/mfontanini/presenterm/issues/505)).
* Add [`alignment` comment](https://mfontanini.github.io/presenterm/features/commands.html#text-alignment) command to specify text alignment for the remainder of a slide ([#493](https://github.com/mfontanini/presenterm/issues/493)) ([#522](https://github.com/mfontanini/presenterm/issues/522)).
* Add `--current-theme` CLI parameter to display the theme being used ([#489](https://github.com/mfontanini/presenterm/issues/489)).
* Add gruvbox dark theme ([#483](https://github.com/mfontanini/presenterm/issues/483)) - thanks @ret2src.
## Fixes
* Fix broken ANSI escape code parsing which would cause command output to sometimes be incorrectly parsed and therefore led to its colors/attributes not being respected ([#500](https://github.com/mfontanini/presenterm/issues/500)).
* Center lists correctly ([#512](https://github.com/mfontanini/presenterm/issues/512)) ([#520](https://github.com/mfontanini/presenterm/issues/520)).
* Respect end slide shorthand in speaker notes mode ([#494](https://github.com/mfontanini/presenterm/issues/494)).
* Use more visible colors in snippet execution output in terminal-light/dark themes ([#485](https://github.com/mfontanini/presenterm/issues/485)).
* Show error if sixel mode is selected but disabled ([#525](https://github.com/mfontanini/presenterm/issues/525)).
## CI
* Add nightly build job ([#496](https://github.com/mfontanini/presenterm/issues/496)).
## Docs
* Fix typo in README.md ([#490](https://github.com/mfontanini/presenterm/issues/490)) - thanks @eltociear.
* Correctly include layout pic ([#495](https://github.com/mfontanini/presenterm/issues/495)) - thanks @Tuxified.
## Misc
* Cleanup text attributes ([#519](https://github.com/mfontanini/presenterm/issues/519)).
* Refactor snippet processing ([#484](https://github.com/mfontanini/presenterm/issues/484)).
## Sponsors
It is now possible to sponsor this project via [github sponsors](https://github.com/sponsors/mfontanini).
Thanks to [@0atman](https://github.com/0atman) for being the first project sponsor!
# v0.11.0 - 2025-03-08
## Breaking changes
* Footer templates are now sanitized, and any variables surrounded in braces that aren't supported (e.g. `{potato}`) will now cause _presenterm_ to display an error. If you'd like to use braces in contexts where you're not trying to reference a variable you can use double braces, e.g. `live at {{PotatoConf}}` ([#442](https://github.com/mfontanini/presenterm/issues/442)) ([#467](https://github.com/mfontanini/presenterm/issues/467)) ([#469](https://github.com/mfontanini/presenterm/issues/469)) ([#471](https://github.com/mfontanini/presenterm/issues/471)).
## New features
* [Add support for kitty's font size protocol](https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes). This is now used by default in built in themes in a few components such as the intro slide's title and slide titles. See the [example presentation gif](https://github.com/mfontanini/presenterm/blob/master/docs/src/assets/demo.gif) to check out how this looks like. Terminal suport for this feature is detected on startup and will be ignored if unsupported. This requires _kitty_ >= 0.40.0 ([#438](https://github.com/mfontanini/presenterm/issues/438)) ([#460](https://github.com/mfontanini/presenterm/issues/460)) ([#470](https://github.com/mfontanini/presenterm/issues/470)).
* [Allow specifying font size in a comment command](https://mfontanini.github.io/presenterm/features/commands.html#font-size), which causes any subsequent text in a slide to use the specified font size. Just like the above, only supported in _kitty_ >= 0.40.0 for now ([#458](https://github.com/mfontanini/presenterm/issues/458)).
* [Footers can now contain images](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) in the left and center components. This allows including some form of branding/company logo to your presentations ([#450](https://github.com/mfontanini/presenterm/issues/450)) ([#476](https://github.com/mfontanini/presenterm/issues/476)).
* [Footers can now contain inline markdown](https://mfontanini.github.io/presenterm/features/themes/definition.html#template-footers), which allows using bold, italics, `<span>` tags for colors, etc ([#466](https://github.com/mfontanini/presenterm/issues/466)).
* [Presentation titles can now contain inline markdown](https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide) ([#464](https://github.com/mfontanini/presenterm/issues/464)).
* [Introduce palette.classes in themes](https://mfontanini.github.io/presenterm/features/themes/definition.html#color-palette) to allow specifying combinations of foreground/background colors that can be referenced via the `class` attribute in `<span>` tags ([#468](https://github.com/mfontanini/presenterm/issues/468)).
* It's now possible to [configure the alignment](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-width) to use when `max_columns` is configured and the terminal width is larger than it ([#475](https://github.com/mfontanini/presenterm/issues/475)).
* Add support for wikilinks ([#448](https://github.com/mfontanini/presenterm/issues/448)).
## Fixes
* Don't get stuck if tmux doesn't passthrough ([#456](https://github.com/mfontanini/presenterm/issues/456)).
* Don't squash image if terminal's font aspect ratio is not 2:1 ([#446](https://github.com/mfontanini/presenterm/issues/446)).
* Fail if `--config-file` points to non existent file ([#474](https://github.com/mfontanini/presenterm/issues/474)).
* Use right script name for kotlin files when executing ([#462](https://github.com/mfontanini/presenterm/issues/462)).
* Respect lists that start at non 1 indexes ([#459](https://github.com/mfontanini/presenterm/issues/459)).
* Jump to right slide on code attribute change ([#478](https://github.com/mfontanini/presenterm/issues/478)).
## Improvements
* Remove `result` return type from builder fns that don't need it ([#465](https://github.com/mfontanini/presenterm/issues/465)).
* Refactor theme code ([#463](https://github.com/mfontanini/presenterm/issues/463)).
* Restructure `terminal` code and add test for margins/layouts ([#443](https://github.com/mfontanini/presenterm/issues/443)).
* Use `fastrand` instead of `rand` ([#441](https://github.com/mfontanini/presenterm/issues/441)).
* Avoid cloning strings when styling them ([#440](https://github.com/mfontanini/presenterm/issues/440)).
# v0.10.1 - 2025-02-14
## Fixes
* Don't error out if `options` in front matter doesn't include `auto_render_languages` ([#454](https://github.com/mfontanini/presenterm/pull/454)).
* Bump sixel-rs to 0.4.1 to fix build in aarch64 and riscv64 ([#452](https://github.com/mfontanini/presenterm/pull/452)) - thanks @Xeonacid.
# v0.10.0 - 2025-02-02
## New features
* Support for presentation speaker notes ([#389](https://github.com/mfontanini/presenterm/issues/389)) ([#419](https://github.com/mfontanini/presenterm/issues/419)) ([#421](https://github.com/mfontanini/presenterm/issues/421)) ([#425](https://github.com/mfontanini/presenterm/issues/425)) - thanks @dmackdev.
* Add support for colored text via inline `span` HTML tags ([#390](https://github.com/mfontanini/presenterm/issues/390)).
* Add a color palette in themes to allow reusing colors across the theme and using predefined colors inside `span` tags ([#427](https://github.com/mfontanini/presenterm/issues/427)).
* Add support for github/gitlab style markdown alerts ([#423](https://github.com/mfontanini/presenterm/issues/423)) ([#430](https://github.com/mfontanini/presenterm/issues/430)).
* Allow using `+image` on code blocks to consume their output as an image ([#429](https://github.com/mfontanini/presenterm/issues/429)).
* Allow multiline comment commands ([#424](https://github.com/mfontanini/presenterm/issues/424)).
* Allow auto rendering mermaid/typst/latex code blocks ([#418](https://github.com/mfontanini/presenterm/issues/418)).
* Allow capping max columns on presentation ([#417](https://github.com/mfontanini/presenterm/issues/417)).
* Automatically detect kitty support, including when running inside tmux ([#406](https://github.com/mfontanini/presenterm/issues/406)).
* Use kitty image protocol in ghostty ([#405](https://github.com/mfontanini/presenterm/issues/405)).
* Force color output in rust, c, and c++ compiler executions ([#401](https://github.com/mfontanini/presenterm/issues/401)).
* Add graphql code highlighting ([#385](https://github.com/mfontanini/presenterm/issues/385)) - thanks @GV14982.
* Add tcl code highlighting ([#387](https://github.com/mfontanini/presenterm/issues/387)) - thanks @jtplaarj.
* Add Haskell executor ([#414](https://github.com/mfontanini/presenterm/issues/414)) - thanks @feature-not-a-bug.
* Add C# to code executors ([#399](https://github.com/mfontanini/presenterm/issues/399)) - thanks @giggio.
* Add R to executors ([#393](https://github.com/mfontanini/presenterm/issues/393)) - thanks @jonocarroll.
## Fixes
* Check for `term_program` before `term` to determine emulator ([#420](https://github.com/mfontanini/presenterm/issues/420)).
* Allow jumping back to column in column layout ([#396](https://github.com/mfontanini/presenterm/issues/396)).
* Ignore comments that start with `vim:` prefix ([#395](https://github.com/mfontanini/presenterm/issues/395)).
* Respect `+no_background` on a `+exec_replace` block ([#383](https://github.com/mfontanini/presenterm/issues/383)).
## Docs
* Document tmux active session bug ([#402](https://github.com/mfontanini/presenterm/issues/402)).
* Add notes on running `bat` directly ([#397](https://github.com/mfontanini/presenterm/issues/397)).
# v0.9.0 - 2024-10-06
## Breaking changes
* Default themes now no longer use a progress bar based footer. Instead they use indicator of the current page number
and the total number of pages. If you'd like to preserve the old behavior, you can override the theme by using
`footer.style = progress_bar` in [your
theme](https://mfontanini.github.io/presenterm/guides/themes.html#setting-themes).
* Links that include a title (e.g. `[my title](http://example.com)`) now have their title rendered as well. Removing a
link's title will make it look the same as they used to.
## New features
* Use "template" footer in built-in themes ([#358](https://github.com/mfontanini/presenterm/issues/358)).
* Allow including external code snippets ([#328](https://github.com/mfontanini/presenterm/issues/328))
([#372](https://github.com/mfontanini/presenterm/issues/372)).
* Add `+no_background` property to remove background from code blocks
([#363](https://github.com/mfontanini/presenterm/issues/363))
([#368](https://github.com/mfontanini/presenterm/issues/368)).
* Show colored output from snippet execution output ([#316](https://github.com/mfontanini/presenterm/issues/316)).
* Style markdown inside block quotes ([#350](https://github.com/mfontanini/presenterm/issues/350))
([#351](https://github.com/mfontanini/presenterm/issues/351)).
* Allow using all intro slide variables in footer template
([#338](https://github.com/mfontanini/presenterm/issues/338)).
* Include hidden line prefix in executors file ([#337](https://github.com/mfontanini/presenterm/issues/337)).
* Show link labels and titles ([#334](https://github.com/mfontanini/presenterm/issues/334)).
* Add `+exec_replace` which executes snippets and replaces them with their output
([#330](https://github.com/mfontanini/presenterm/issues/330))
([#371](https://github.com/mfontanini/presenterm/issues/371)).
* Always show snippet execution bar ([#329](https://github.com/mfontanini/presenterm/issues/329)).
* Handle suspend signal (SIGTSTP) ([#318](https://github.com/mfontanini/presenterm/issues/318)).
* Allow closing with `q` ([#321](https://github.com/mfontanini/presenterm/issues/321)).
* Add event, location, and date labels in intro slide ([#317](https://github.com/mfontanini/presenterm/issues/317)).
* Use transparent background in mermaid charts ([#314](https://github.com/mfontanini/presenterm/issues/314)).
* Add `+acquire_terminal` to acquire the terminal when running snippets
([#366](https://github.com/mfontanini/presenterm/issues/366))
([#376](https://github.com/mfontanini/presenterm/pull/376)).
* Add PHP executor ([#332](https://github.com/mfontanini/presenterm/issues/332)).
* Add Racket syntax highlighting ([#367](https://github.com/mfontanini/presenterm/issues/367)).
* Add TOML highlighting ([#361](https://github.com/mfontanini/presenterm/issues/361)).
## Fixes
* Wrap code snippets if they don't fit in terminal ([#320](https://github.com/mfontanini/presenterm/issues/320)).
* Allow list-themes/acknowledgements to run without path ([#359](https://github.com/mfontanini/presenterm/issues/359)).
* Translate tabs in code snippets to 4 spaces ([#356](https://github.com/mfontanini/presenterm/issues/356)).
* Add padding to right of code block wrapped lines ([#354](https://github.com/mfontanini/presenterm/issues/354)).
* Don't wrap code snippet separator line ([#353](https://github.com/mfontanini/presenterm/issues/353)).
* Show block quote prefix when wrapping ([#352](https://github.com/mfontanini/presenterm/issues/352)).
* Don't crash on code block with only hidden-line-prefixed lines
([#347](https://github.com/mfontanini/presenterm/issues/347)).
* Canonicalize resources path ([#333](https://github.com/mfontanini/presenterm/issues/333)).
* Execute script relative to current working directory ([#323](https://github.com/mfontanini/presenterm/issues/323)).
* Support rendering mermaid charts on windows ([#319](https://github.com/mfontanini/presenterm/issues/319)).
## Improvements
* Add example on how column layouts and pauses interact ([#348](https://github.com/mfontanini/presenterm/issues/348)).
* Rename `jump_to_vertical_center` -> `jump_to_middle` in docs
([#342](https://github.com/mfontanini/presenterm/issues/342)).
* Document `all` snippet highlighting keyword ([#335](https://github.com/mfontanini/presenterm/issues/335)).
# v0.8.0 - 2024-07-29
## Breaking changes

880
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -16,8 +16,9 @@ presenterm
[scoop-package]: https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a
_presenterm_ lets you create presentations in markdown format and run them from your terminal, with support for image
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and
plenty of other features. This is how the [demo presentation](/examples/demo.md) looks like:
and animated gifs, highly customizable themes, code highlighting, exporting presentations into PDF format, and plenty of
other features. This is how the [demo presentation](/examples/demo.md) looks like when running in the [kitty
terminal](https://sw.kovidgoyal.net/kitty/):
![](/docs/src/assets/demo.gif)
@ -25,51 +26,68 @@ Check the rest of the example presentations in the [examples directory](/example
# Documentation
Visit the [documentation][guide-introduction] to get started.
Visit the [documentation][docs-introduction] to get started.
# Features
* Define your presentation in a single markdown file.
* [Images and animated gifs][guide-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
* [Customizeable themes][guide-themes] including colors, margins, layout (left/center aligned content), footer for every
slide, etc. Several [built-in themes][guide-builtin-themes] can give your presentation the look you want without
* [Images and animated gifs][docs-images] on terminals like _kitty_, _iterm2_, and _wezterm_.
* [Customizable themes][docs-themes] including colors, margins, layout (left/center aligned content), footer for every
slide, etc. Several [built-in themes][docs-builtin-themes] can give your presentation the look you want without
having to define your own.
* Code highlighting for a [wide list of programming languages][guide-code-highlight].
* [Selective/dynamic][guide-selective-highlight] code highlighting that only highlights portions of code at a time.
* [Column layouts][guide-layout].
* [mermaid graph rendering](guide-mermaid).
* [_LaTeX_ and _typst_ formula rendering][guide-latex].
* [Introduction slide][guide-intro-slide] that displays the presentation title and your name.
* [Slide titles][guide-slide-titles].
* [Snippet execution][guide-code-execute] for various programming languages.
* [Export presentations to PDF][guide-pdf-export].
* [Pause][guide-pauses] portions of your slides.
* [Custom key bindings][guide-key-bindings].
* [Automatically reload your presentation][guide-hot-reload] every time it changes for a fast development loop.
* Code highlighting for a [wide list of programming languages][docs-code-highlight].
* [Font sizes][docs-font-sizes] for terminals that support them.
* [Selective/dynamic][docs-selective-highlight] code highlighting that only highlights portions of code at a time.
* [Column layouts][docs-layout].
* [mermaid graph rendering][docs-mermaid].
* [_LaTeX_ and _typst_ formula rendering][docs-latex].
* [Introduction slide][docs-intro-slide] that displays the presentation title and your name.
* [Slide titles][docs-slide-titles].
* [Snippet execution][docs-code-execute] for various programming languages.
* [Export presentations to PDF][docs-pdf-export].
* [Slide transitions][docs-slide-transitions].
* [Pause][docs-pauses] portions of your slides.
* [Custom key bindings][docs-key-bindings].
* [Automatically reload your presentation][docs-hot-reload] every time it changes for a fast development loop.
* [Define speaker notes][docs-speaker-notes] to aid you during presentations.
See the [introduction page][guide-introduction] to learn more.
See the [introduction page][docs-introduction] to learn more.
# Presenterm in action
Here are some talks and demos that feature _presenterm_:
- [Bringing Terminal Aesthetics to the Web With Rust][bringing-terminal-aesthetics] by [Orhun Parmaksız][orhun-github]
- [7 Rust Terminal Tools That You Should Use][rust-terminal-tools] by [Orhun Parmaksız][orhun-github]
- [Renaissance of Terminal User Interfaces with Rust][renaissance-tui] by [Orhun Parmaksız][orhun-github]
Gave a talk using _presenterm_? We would love to feature it here! Open a PR or issue to get it added.
<!-- links -->
[guide-introduction]: https://mfontanini.github.io/presenterm/
[guide-installation]: https://mfontanini.github.io/presenterm/guides/installation.html
[guide-basics]: https://mfontanini.github.io/presenterm/guides/basics.html
[guide-intro-slide]: https://mfontanini.github.io/presenterm/guides/basics.html#introduction-slide
[guide-slide-titles]: https://mfontanini.github.io/presenterm/guides/basics.html#slide-titles
[guide-pauses]: https://mfontanini.github.io/presenterm/guides/basics.html#pauses
[guide-images]: https://mfontanini.github.io/presenterm/guides/basics.html#images
[guide-themes]: https://mfontanini.github.io/presenterm/guides/themes.html
[guide-builtin-themes]: https://mfontanini.github.io/presenterm/guides/themes.html#built-in-themes
[guide-code-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html
[guide-code-execute]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#executing-code
[guide-selective-highlight]: https://mfontanini.github.io/presenterm/guides/code-highlight.html#selective-highlighting
[guide-layout]: https://mfontanini.github.io/presenterm/guides/layout.html
[guide-mermaid]: https://mfontanini.github.io/presenterm/guides/mermaid.html
[guide-latex]: https://mfontanini.github.io/presenterm/guides/latex.html
[guide-pdf-export]: https://mfontanini.github.io/presenterm/guides/pdf-export.html
[guide-key-bindings]: https://mfontanini.github.io/presenterm/guides/configuration.html#key-bindings
[guide-hot-reload]: https://mfontanini.github.io/presenterm/guides/basics.html#hot-reload
[docs-introduction]: https://mfontanini.github.io/presenterm/
[docs-basics]: https://mfontanini.github.io/presenterm/features/introduction.html
[docs-intro-slide]: https://mfontanini.github.io/presenterm/features/introduction.html#introduction-slide
[docs-slide-titles]: https://mfontanini.github.io/presenterm/features/introduction.html#slide-titles
[docs-font-sizes]: https://mfontanini.github.io/presenterm/features/introduction.html#font-sizes
[docs-pauses]: https://mfontanini.github.io/presenterm/features/commands.html#pauses
[docs-images]: https://mfontanini.github.io/presenterm/features/images.html
[docs-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html
[docs-builtin-themes]: https://mfontanini.github.io/presenterm/features/themes/introduction.html#built-in-themes
[docs-code-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html
[docs-code-execute]: https://mfontanini.github.io/presenterm/features/code/execution.html
[docs-selective-highlight]: https://mfontanini.github.io/presenterm/features/code/highlighting.html#selective-highlighting
[docs-slide-transitions]: https://mfontanini.github.io/presenterm/features/slide-transitons.html
[docs-layout]: https://mfontanini.github.io/presenterm/features/layout.html
[docs-mermaid]: https://mfontanini.github.io/presenterm/features/code/mermaid.html
[docs-latex]: https://mfontanini.github.io/presenterm/features/code/latex.html
[docs-pdf-export]: https://mfontanini.github.io/presenterm/features/pdf-export.html
[docs-key-bindings]: https://mfontanini.github.io/presenterm/configuration/settings.html#key-bindings
[docs-hot-reload]: https://mfontanini.github.io/presenterm/features/introduction.html#hot-reload
[docs-speaker-notes]: https://mfontanini.github.io/presenterm/features/speaker-notes.html
[bat]: https://github.com/sharkdp/bat
[syntect]: https://github.com/trishume/syntect
[bringing-terminal-aesthetics]: https://www.youtube.com/watch?v=iepbyYrF_YQ
[rust-terminal-tools]: https://www.youtube.com/watch?v=ATiKwUiqnAU
[renaissance-tui]: https://www.youtube.com/watch?v=hWG51Mc1DlM
[orhun-github]: https://github.com/orhun

View File

@ -14,6 +14,9 @@
}
]
},
"export": {
"$ref": "#/definitions/ExportConfig"
},
"mermaid": {
"$ref": "#/definitions/MermaidConfig"
},
@ -23,6 +26,19 @@
"snippet": {
"$ref": "#/definitions/SnippetConfig"
},
"speaker_notes": {
"$ref": "#/definitions/SpeakerNotesConfig"
},
"transition": {
"anyOf": [
{
"$ref": "#/definitions/SlideTransitionConfig"
},
{
"type": "null"
}
]
},
"typst": {
"$ref": "#/definitions/TypstConfig"
}
@ -40,6 +56,44 @@
}
]
},
"incremental_lists": {
"description": "The configuration for lists when incremental lists are enabled.",
"allOf": [
{
"$ref": "#/definitions/IncrementalListsConfig"
}
]
},
"max_columns": {
"description": "A max width in columns that the presentation must always be capped to.",
"default": 65535,
"type": "integer",
"format": "uint16",
"minimum": 0.0
},
"max_columns_alignment": {
"description": "The alignment the presentation should have if `max_columns` is set and the terminal is larger than that.",
"allOf": [
{
"$ref": "#/definitions/MaxColumnsAlignment"
}
]
},
"max_rows": {
"description": "A max height in rows that the presentation must always be capped to.",
"default": 65535,
"type": "integer",
"format": "uint16",
"minimum": 0.0
},
"max_rows_alignment": {
"description": "The alignment the presentation should have if `max_rows` is set and the terminal is larger than that.",
"allOf": [
{
"$ref": "#/definitions/MaxRowsAlignment"
}
]
},
"terminal_font_size": {
"description": "Override the terminal font size when in windows or when using sixel.",
"default": 16,
@ -65,6 +119,55 @@
},
"additionalProperties": false
},
"ExportConfig": {
"description": "The export configuration.",
"type": "object",
"properties": {
"dimensions": {
"description": "The dimensions to use for presentation exports.",
"anyOf": [
{
"$ref": "#/definitions/ExportDimensionsConfig"
},
{
"type": "null"
}
]
},
"pauses": {
"description": "Whether pauses should create new slides.",
"allOf": [
{
"$ref": "#/definitions/PauseExportPolicy"
}
]
}
},
"additionalProperties": false
},
"ExportDimensionsConfig": {
"description": "The dimensions to use for presentation exports.",
"type": "object",
"required": [
"columns",
"rows"
],
"properties": {
"columns": {
"description": "The number of columns.",
"type": "integer",
"format": "uint16",
"minimum": 0.0
},
"rows": {
"description": "The number of rows.",
"type": "integer",
"format": "uint16",
"minimum": 0.0
}
},
"additionalProperties": false
},
"ImageProtocol": {
"oneOf": [
{
@ -111,6 +214,29 @@
}
]
},
"IncrementalListsConfig": {
"description": "The configuration for lists when incremental lists are enabled.",
"type": "object",
"properties": {
"pause_after": {
"description": "Whether to pause after a list ends.",
"default": null,
"type": [
"boolean",
"null"
]
},
"pause_before": {
"description": "Whether to pause before a list begins.",
"default": null,
"type": [
"boolean",
"null"
]
}
},
"additionalProperties": false
},
"KeyBinding": {
"type": "string"
},
@ -194,6 +320,13 @@
"$ref": "#/definitions/KeyBinding"
}
},
"suspend": {
"description": "The key binding to suspend the application.",
"type": "array",
"items": {
"$ref": "#/definitions/KeyBinding"
}
},
"toggle_bindings": {
"description": "The key binding to toggle the key bindings modal.",
"type": "array",
@ -240,9 +373,68 @@
"filename": {
"description": "The filename to use for the snippet input file.",
"type": "string"
},
"hidden_line_prefix": {
"description": "The prefix to use to hide lines visually but still execute them.",
"type": [
"string",
"null"
]
}
}
},
"MaxColumnsAlignment": {
"description": "The alignment to use when `defaults.max_columns` is set.",
"oneOf": [
{
"description": "Align the presentation to the left.",
"type": "string",
"enum": [
"left"
]
},
{
"description": "Align the presentation on the center.",
"type": "string",
"enum": [
"center"
]
},
{
"description": "Align the presentation to the right.",
"type": "string",
"enum": [
"right"
]
}
]
},
"MaxRowsAlignment": {
"description": "The alignment to use when `defaults.max_rows` is set.",
"oneOf": [
{
"description": "Align the presentation to the top.",
"type": "string",
"enum": [
"top"
]
},
{
"description": "Align the presentation on the center.",
"type": "string",
"enum": [
"center"
]
},
{
"description": "Align the presentation to the bottom.",
"type": "string",
"enum": [
"bottom"
]
}
]
},
"MermaidConfig": {
"type": "object",
"properties": {
@ -259,6 +451,13 @@
"OptionsConfig": {
"type": "object",
"properties": {
"auto_render_languages": {
"description": "Assume snippets for these languages contain `+render` and render them automatically.",
"type": "array",
"items": {
"$ref": "#/definitions/SnippetLanguage"
}
},
"command_prefix": {
"description": "The prefix to use for commands.",
"type": [
@ -304,6 +503,108 @@
},
"additionalProperties": false
},
"PauseExportPolicy": {
"description": "The policy for pauses when exporting.",
"oneOf": [
{
"description": "Whether to ignore pauses.",
"type": "string",
"enum": [
"ignore"
]
},
{
"description": "Create a new slide when a pause is found.",
"type": "string",
"enum": [
"new_slide"
]
}
]
},
"SlideTransitionConfig": {
"type": "object",
"required": [
"animation"
],
"properties": {
"animation": {
"description": "The slide transition style.",
"allOf": [
{
"$ref": "#/definitions/SlideTransitionStyleConfig"
}
]
},
"duration_millis": {
"description": "The amount of time to take to perform the transition.",
"default": 1000,
"type": "integer",
"format": "uint16",
"minimum": 0.0
},
"frames": {
"description": "The number of frames in a transition.",
"default": 30,
"type": "integer",
"format": "uint",
"minimum": 0.0
}
},
"additionalProperties": false
},
"SlideTransitionStyleConfig": {
"oneOf": [
{
"description": "Slide horizontally.",
"type": "object",
"required": [
"style"
],
"properties": {
"style": {
"type": "string",
"enum": [
"slide_horizontal"
]
}
},
"additionalProperties": false
},
{
"description": "Fade the new slide into the previous one.",
"type": "object",
"required": [
"style"
],
"properties": {
"style": {
"type": "string",
"enum": [
"fade"
]
}
},
"additionalProperties": false
},
{
"description": "Collapse the current slide into the center of the screen.",
"type": "object",
"required": [
"style"
],
"properties": {
"style": {
"type": "string",
"enum": [
"collapse_horizontal"
]
}
},
"additionalProperties": false
}
]
},
"SnippetConfig": {
"type": "object",
"properties": {
@ -315,6 +616,14 @@
}
]
},
"exec_replace": {
"description": "The properties for snippet execution.",
"allOf": [
{
"$ref": "#/definitions/SnippetExecReplaceConfig"
}
]
},
"render": {
"description": "The properties for snippet auto rendering.",
"allOf": [
@ -346,6 +655,105 @@
},
"additionalProperties": false
},
"SnippetExecReplaceConfig": {
"type": "object",
"required": [
"enable"
],
"properties": {
"enable": {
"description": "Whether to enable snippet replace-executions, which automatically run code snippets without the user's intervention.",
"type": "boolean"
}
},
"additionalProperties": false
},
"SnippetLanguage": {
"description": "The language of a code snippet.",
"oneOf": [
{
"type": "string",
"enum": [
"Ada",
"Asp",
"Awk",
"Bash",
"BatchFile",
"C",
"CMake",
"Crontab",
"CSharp",
"Clojure",
"Cpp",
"Css",
"DLang",
"Diff",
"Docker",
"Dotenv",
"Elixir",
"Elm",
"Erlang",
"File",
"Fish",
"Go",
"GraphQL",
"Haskell",
"Html",
"Java",
"JavaScript",
"Json",
"Julia",
"Kotlin",
"Latex",
"Lua",
"Makefile",
"Mermaid",
"Markdown",
"Nix",
"Nushell",
"OCaml",
"Perl",
"Php",
"Protobuf",
"Puppet",
"Python",
"R",
"Racket",
"Ruby",
"Rust",
"RustScript",
"Scala",
"Shell",
"Sql",
"Swift",
"Svelte",
"Tcl",
"Terraform",
"Toml",
"TypeScript",
"Typst",
"Xml",
"Yaml",
"Verilog",
"Vue",
"Zig",
"Zsh"
]
},
{
"type": "object",
"required": [
"Unknown"
],
"properties": {
"Unknown": {
"type": "string"
}
},
"additionalProperties": false
}
]
},
"SnippetRenderConfig": {
"type": "object",
"properties": {
@ -359,6 +767,27 @@
},
"additionalProperties": false
},
"SpeakerNotesConfig": {
"type": "object",
"properties": {
"always_publish": {
"description": "Whether to always publish speaker notes.",
"default": false,
"type": "boolean"
},
"listen_address": {
"description": "The address in which to listen for speaker note events.",
"default": "127.255.255.255:59418",
"type": "string"
},
"publish_address": {
"description": "The address in which to publish speaker notes events.",
"default": "127.255.255.255:59418",
"type": "string"
}
},
"additionalProperties": false
},
"TypstConfig": {
"type": "object",
"properties": {

View File

@ -1,3 +1,4 @@
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/mfontanini/presenterm/master/config-file-schema.json
defaults:
# override the terminal font size when in windows or when using sixel.
@ -40,10 +41,24 @@ snippet:
# enable code snippet execution. Use at your own risk!
enable: true
exec_replace:
# enable code snippet automatic execution + replacing the snippet with its output. Use at your own risk!
enable: true
render:
# the number of threads to use when rendering `+render` code snippets.
threads: 2
speaker_notes:
# The endpoint to listen for speaker note events.
listen_address: "127.0.0.1:59418"
# The endpoint to publish speaker note events.
publish_address: "127.0.0.1:59418"
# Whether to always publish speaker notes even when `--publish-speaker-notes` is not set.
always_publish: false
bindings:
# the keys that cause the presentation to move forwards.
next: ["l", "j", "<right>", "<page_down>", "<down>", " "]
@ -82,4 +97,7 @@ bindings:
close_modal: ["<esc>"]
# the key binding to close the application.
exit: ["<c-c>"]
exit: ["<c-c>", "q"]
# the key binding to suspend the application.
suspend: ["<c-z>"]

View File

@ -7,12 +7,18 @@ title = "presenterm documentation"
[preprocessor]
[preprocessor.catppuccin]
assets_version = "2.1.0"
[preprocessor.alerts]
[output]
[output.html]
additional-css = ["./theme/catppuccin.css", "./theme/catppuccin-admonish.css"]
git-repository-url = "https://github.com/mfontanini/presenterm"
default-theme = "machiatto"
default-theme = "navy"
# Redirects for broken links after 02/02/2025 restructuring.
[output.html.redirect]
"/guides/basics.html" = "../features/introduction.html"
"/guides/installation.html" = "../install.html"
"/guides/code-highlight.html" = "../features/code/highlighting.html"
"/guides/mermaid.html" = "../features/code/mermaid.html"

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

View File

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

View File

@ -0,0 +1,305 @@
# Settings
As opposed to options, the rest of these settings **can only be configured via the configuration file**.
## Default theme
The default theme can be configured only via the config file. When this is set, every presentation that doesn't set a
theme explicitly will use this one:
```yaml
defaults:
theme: light
```
## Terminal font size
This is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are
in Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the
window is and resize images properly. Some terminals on other platforms may also have this issue, but that should not be
as common.
If you are on Windows or you notice images show up larger/smaller than they should, you can adjust this setting in your
config file:
```yaml
defaults:
terminal_font_size: 16
```
## Preferred image protocol
By default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In case
detection for some reason fails in your setup or you'd like to force a different protocol to be used, you can explicitly
set this via the `--image-protocol` parameter or the configuration key `defaults.image_protocol`:
```yaml
defaults:
image_protocol: kitty-local
```
Possible values are:
* `auto`: try to detect it automatically (default).
* `kitty-local`: use the kitty protocol in "local" mode, meaning both _presenterm_ and the terminal run in the same host
and can share the filesystem to communicate.
* `kitty-remote`: use the kitty protocol in "remote" mode, meaning _presenterm_ and the terminal run in different hosts
and therefore can only communicate via terminal escape codes.
* `iterm2`: use the iterm2 protocol.
* `sixel`: use the sixel protocol. Note that this requires compiling _presenterm_ using the `--features sixel` flag.
## Maximum presentation width
The `max_columns` property can be set to specify the maximum number of columns that the presentation will stretch to. If
your terminal is larger than that, the presentation will stick to that size and will be centered, preventing it from
looking too stretched.
```yaml
defaults:
max_columns: 100
```
If you would like your presentation to be left or right aligned instead of centered when the terminal is too wide, you
can use the `max_columns_alignment` key:
```yaml
defaults:
max_columns: 100
# Valid values: left, center, right
max_columns_alignment: left
```
## Maximum presentation height
The `max_rows` and `max_rows_alignment` properties are analogous to `max_columns*` to allow capping the maximum number
of rows:
```yaml
defaults:
max_rows: 100
# Valid values: top, center, bottom
max_rows_alignment: left
```
## Incremental lists behavior
By default, [incremental lists](../features/commands.md) will pause before and after a list. If you would like to change
this behavior, use the `defaults.incremental_lists` key:
```yaml
defaults:
incremental_lists:
# The defaults, change to false if desired.
pause_before: true
pause_after: true
```
# Slide transitions
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. The
configuration for slide transitions is the following:
```yaml
transition:
# how long the transition should last.
duration_millis: 750
# how many frames should be rendered during the transition
frames: 45
# the animation to use
animation:
style: <style_name>
```
See the [slide transitions page](../features/slide-transitions.md) for more information on which animation styles are
supported.
# Key bindings
Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following
is the default configuration:
```yaml
bindings:
# the keys that cause the presentation to move forwards.
next: ["l", "j", "<right>", "<page_down>", "<down>", " "]
# the keys that cause the presentation to move backwards.
previous: ["h", "k", "<left>", "<page_up>", "<up>"]
# the keys that cause the presentation to move "fast" to the next slide. this will ignore:
#
# * Pauses.
# * Dynamic code highlights.
# * Slide transitions, if enabled.
next_fast: ["n"]
# same as `next_fast` but jumps fast to the previous slide.
previous_fast: ["p"]
# the key binding to jump to the first slide.
first_slide: ["gg"]
# the key binding to jump to the last slide.
last_slide: ["G"]
# the key binding to jump to a specific slide.
go_to_slide: ["<number>G"]
# the key binding to execute a piece of shell code.
execute_code: ["<c-e>"]
# the key binding to reload the presentation.
reload: ["<c-r>"]
# the key binding to toggle the slide index modal.
toggle_slide_index: ["<c-p>"]
# the key binding to toggle the key bindings modal.
toggle_bindings: ["?"]
# the key binding to close the currently open modal.
close_modal: ["<esc>"]
# the key binding to close the application.
exit: ["<c-c>", "q"]
# the key binding to suspend the application.
suspend: ["<c-z>"]
```
You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the
default won't apply anymore and only what you've defined will be used.
# Snippet configurations
The configurations that affect code snippets in presentations.
## Snippet execution
[Snippet execution](../features/code/execution.md#executing-code-blocks) is disabled by default for security reasons.
Besides passing in the `-x` command line parameter every time you run _presenterm_, you can also configure this globally
for all presentations by setting:
```yaml
snippet:
exec:
enable: true
```
**Use this at your own risk**, especially if you're running someone else's presentations!
## Snippet execution + replace
[Snippet execution + replace](../features/code/execution.md#executing-and-replacing) is disabled by default for security
reasons. Similar to `+exec`, this can be enabled by passing in the `-X` command line parameter or configuring it
globally by setting:
```yaml
snippet:
exec_replace:
enable: true
```
**Use this at your own risk**. This will cause _presenterm_ to execute code without user intervention so don't blindly
enable this and open a presentation unless you trust its origin!
## Custom snippet executors
If _presenterm_ doesn't support executing code snippets for your language of choice, please [create an
issue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by
setting:
```yaml
snippet:
exec:
custom:
# The keys should be the language identifier you'd use in a code block.
c++:
# The name of the file that will be created with your snippet's contents.
filename: "snippet.cpp"
# A list of environment variables that should be set before building/running your code.
environment:
MY_FAVORITE_ENVIRONMENT_VAR: foo
# A prefix that indicates a line that starts with it should not be visible but should be executed if the
# snippet is marked with `+exec`.
hidden_line_prefix: "/// "
# A list of commands that will be ran one by one in the same directory as the snippet is in.
commands:
# Compile if first
- ["g++", "-std=c++20", "snippet.cpp", "-o", "snippet"]
# Now run it
- ["./snippet"]
```
The output of all commands will be included in the code snippet execution output so if a command (like the `g++`
invocation) was to emit any output, make sure to use whatever flags are needed to mute its output.
Also note that you can override built-in executors in case you want to run them differently (e.g. use `c++23` in the
example above).
See more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file
which defines all of the built-in executors.
## Snippet rendering threads
Because some `+render` code blocks can take some time to be rendered into an image, especially if you're using
[mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which
defaults to 2, can be configured by setting:
```yaml
snippet:
render:
threads: 2
```
## Mermaid scaling
[mermaid](https://mermaid.js.org/) graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like
to change this use:
```yaml
mermaid:
scale: 2
```
## Enabling speaker note publishing
If you don't want to run _presenterm_ with `--publish-speaker-notes` every time you want to publish speaker notes, you
can set the `speaker_notes.always_publish` attribute to `true`.
```yaml
speaker_notes:
always_publish: true
```
# Presentation exports
The configurations that affect PDF exports.
## PDF export size
The size of exported PDFs can be configured via the `export.dimensions` key:
```yaml
export:
dimensions:
columns: 80
rows: 30
```
See [the PDF export page](../features/pdf-export.md) for more information.
## Pause behavior
By default pauses will be ignored in generated PDF files. If instead you'd like every pause to generate a new page in
the export, set the `export.pauses` attribute:
```yaml
export:
pauses: new_slide
```

View File

@ -0,0 +1,125 @@
# Snippet execution
## Executing code blocks
Annotating a code block with a `+exec` attribute will make it executable. Pressing `control+e` when viewing a slide that
contains an executable block, the code in the snippet will be executed and the output of the execution will be displayed
on a box below it. The code execution is stateful so if you switch to another slide and then go back, you will still see
the output.
~~~markdown
```bash +exec
echo hello world
```
~~~
Code execution **must be explicitly enabled** by using either:
* The `-x` command line parameter when running _presenterm_.
* Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config
file](../../configuration/settings.md#snippet-execution).
Refer to [the table in the highlighting page](highlighting.md#code-highlighting) for the list of languages for which
code execution is supported.
---
[![asciicast](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr.svg)](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr)
> [!warning]
> Run code in presentations at your own risk! Especially if you're running someone else's presentation. Don't blindly
> enable snippet execution!
## Executing and replacing
Similar to `+exec`, `+exec_replace` causes a snippet to be executable but:
* Execution happens automatically without user intervention.
* The snippet will be automatically replaced with its execution output.
This can be useful to run programs that generate some form of ASCII art that you'd like to generate dynamically.
[![asciicast](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD.svg)](https://asciinema.org/a/hklQARZKb5sP5mavL4cGgbYXD)
Because of the risk involved in `+exec_replace`, where code gets automatically executed when running a presentation,
this requires users to explicitly opt in to it. This can be done by either passing in the `-X` command line parameter
or setting the `snippet.exec_replace.enable` flag in your configuration file to `true`.
## Code to image conversions
The `+image` attribute behaves like `+exec_replace` but also assumes the output of the executed snippet will be an
image, and it will render it as such. For this to work, the code **must only emit an image in jpg/png formats** and
nothing else.
For example, this would render the demo presentation's image:
~~~markdown
```bash +image
cat examples/doge.png
```
~~~
This attribute carries the same risks as `+exec_replace` and therefore needs to be enabled via the same flags.
## Executing snippets that need a TTY
If you're trying to execute a program like `top` that needs to run on a TTY as it renders text, clears the screen, etc,
you can use the `+acquire_terminal` modifier on a code already marked as executable with `+exec`. Executing snippets
tagged with these two attributes will cause _presenterm_ to suspend execution, the snippet will be invoked giving it the
raw terminal to do whatever it needs, and upon its completion _presenterm_ will resume its execution.
[![asciicast](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT.svg)](https://asciinema.org/a/AHfuJorCNRR8ZEnfwQSDR5vPT)
## Styled execution output
Snippets that generate output which contains escape codes that change the colors or styling of the text will be parsed
and displayed respecting those styles. Do note that you may need to force certain tools to use colored output as they
will likely not use it by default.
For example, to get colored output when invoking `ls` you can use:
~~~markdown
```bash +exec
ls /tmp --color=always
```
~~~
The parameter or way to enable this will depend on the tool being invoked.
## Hiding code lines
When you mark a code snippet as executable via the `+exec` flag, you may not be interested in showing _all the lines_ to
your audience, as some of them may not be necessary to convey your point. For example, you may want to hide imports,
non-essential functions, initialization of certain variables, etc. For this purpose, _presenterm_ supports a prefix
under certain programming languages that let you indicate a line should be executed when running the code but should not
be displayed in the presentation.
For example, in the following code snippet only the print statement will be displayed but the entire snippet will be
ran:
~~~markdown
```rust
# fn main() {
println!("Hello world!");
# }
```
~~~
Rather than blindly relying on a prefix that may have a meaning in a language, prefixes are chosen on a per language
basis. The languages that are supported and their prefix is:
* rust: `# `.
* python/bash/fish/shell/zsh/kotlin/java/javascript/typescript/c/c++/go: `/// `.
This means that any line in a rust code snippet that starts with `# ` will be hidden, whereas all lines in, say, a
golang code snippet that starts with a `/// ` will be hidden.
## Pre-rendering
Some languages support pre-rendering. This means the code block is transformed into something else when the presentation
is loaded. The languages that currently support this are _mermaid_, _LaTeX_, and _typst_ where the contents of the code
block is transformed into an image, allowing you to define formulas as text in your presentation. This can be done by
using the `+render` attribute on a code block.
See the [LaTeX and typst](latex.md) and [mermaid](mermaid.md) docs for more information.

View File

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

View File

@ -1,26 +1,40 @@
## LaTeX and typst
# LaTeX and typst
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](code-highlight.html)) to
have them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than
having to define them somewhere else, transform them into an image, and them embed it.
`latex` and `typst` code blocks can be marked with the `+render` attribute (see [highlighting](highlighting.md)) to have
them rendered into images when the presentation is loaded. This allows you to define formulas in text rather than having
to define them somewhere else, transform them into an image, and them embed it.
### Dependencies
For example, the following presentation:
#### typst
~~~
# Formulas
```latex +render
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
```
~~~
Would be rendered like this:
![](../../assets/formula.png)
## Dependencies
### typst
The engine used to render both of these languages is [typst](https://github.com/typst/typst). _typst_ is easy to
install, lightweight, and boilerplate free as compared to _LaTeX_.
#### pandoc
### pandoc
For _LaTeX_ code rendering both _typst_ and [pandoc](https://github.com/jgm/pandoc) are required. How this works is the
_LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_. This lets us:
For _LaTeX_ code rendering, besides _typst_ you will need to install [pandoc](https://github.com/jgm/pandoc). How this
works is the _LaTeX_ code you write gets transformed into _typst_ code via _pandoc_ and then rendered by using _typst_.
This lets us:
* Have the same look/feel on generated formulas for both languages.
* Avoid having to write lots of boilerplate _LaTeX_ to make rendering for that language work.
* Have the same logic to render formulas for both languages, except with a small preparation step for _LaTeX_.
### Controlling PPI
## Controlling PPI
_presenterm_ lets you define how many Pixels Per Inch (PPI) you want in the generated images. This is important because
as opposed to images that you manually include in your presentation, where you control the exact dimensions, the images
@ -28,7 +42,7 @@ generated on the fly will have a fixed size. Configuring the PPI used during the
higher the PPI, the larger the generated images will be.
Because as opposed to most configurations this is a very environment-specific config, the PPI parameter is not part of
the theme definition but is instead has to be set in [_presenterm_'s config file](configuration.html):
the theme definition but is instead has to be set in _presenterm_'s [config file](../../configuration/introduction.md):
```yaml
typst:
@ -37,7 +51,17 @@ typst:
The default is 300 so adjust it and see what works for you.
### Controlling the image size
## Image paths
If you're including an image inside a _typst_ snippet, you must:
* Use absolute paths, e.g. `#image("/image1.png")`.
* Place the image in the same or a sub path of the path where the presentation is. That is, if your presentation file is
at `/tmp/foo/presentation.md`, you can place images in `/tmp/foo`, and `/tmp/foo/bar` but not under `/tmp/bar`. This is
because of the absolute path rule above: the path will be considered to be relative to the presentation file's
directory.
## Controlling the image size
You can also set the generated image's size on a per code snippet basis by using the `+width` modifier which specifies
the width of the image as a percentage of the terminal size.
@ -48,7 +72,7 @@ $f(x) = x + 1$
```
~~~
### Customizations
## Customizations
The colors and margin of the generated images can be defined in your theme:
@ -62,19 +86,3 @@ typst:
horizontal_margin: 2
vertical_margin: 2
```
## Example
The following example:
~~~
# Formulas
```latex +render
\[ \sum_{n=1}^{\infty} 2^{-n} = 1 \]
```
~~~
Is rendered like this:
![](../assets/formula.png)

View File

@ -1,4 +1,4 @@
# Mermaid
## Mermaid
[mermaid](https://mermaid.js.org/) snippets can be converted into images automatically in any code snippet tagged with
the `mermaid` language and a `+render` tag:
@ -15,11 +15,11 @@ sequenceDiagram
Note that because the mermaid CLI will spin up a browser under the hood, this may not work in all environments and can
also be a bit slow (e.g. ~2 seconds to generate every image). Mermaid graphs are rendered asynchronously by a number of
threads that can be configured in the [configuration file](configuration.html#snippet-rendering-threads). This
configuration value currently defaults to 2.
threads that can be configured in the [configuration file](../../configuration/settings.md#snippet-rendering-threads).
This configuration value currently defaults to 2.
The size of the rendered image can be configured by changing:
* The `mermaid.scale` [configuration parameter](configuration.html#mermaid-scaling).
* The `mermaid.scale` [configuration parameter](../../configuration/settings.md#mermaid-scaling).
* Using the `+width:<number>%` attribute in the code snippet.
For example, this diagram will take up 50% of the width of the window and will preserve its aspect ratio:
@ -35,3 +35,16 @@ sequenceDiagram
It is recommended to change the `mermaid.scale` parameter until images look big enough and then adjust on an image by
image case if necessary using the `+width` attribute. Otherwise, using a small scale and then scaling via `+width` may
cause the image to become blurry.
## Theme
The theme of the rendered mermaid diagrams can be changed through the following
[theme](../themes/introduction.md#mermaid) parameters:
* `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`).
* `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use.
## Always render diagrams
If you don't want to use `+render` every time, you can configure which languages get this automatically via the [config
file](../../configuration/settings.md#auto_render_languages).

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
## Layouts
# Layouts
_presenterm_ currently supports a column layout that lets you split parts of your slides into column. This allows you to
put text on one side, and code/images on the other, or really organize markdown into columns in any way you want.
@ -6,7 +6,7 @@ put text on one side, and code/images on the other, or really organize markdown
This is done by using commands, just like `pause` and `end_slide`, in the form of HTML comments. This section describes
how to use those.
### Wait, why not HTML?
## Wait, why not HTML?
While markdown _can_ contain HTML tags (beyond comments!) and we _could_ represent this using divs with alignment, I
don't really want to:
@ -17,12 +17,12 @@ don't really want to:
Because of this, _presenterm_ doesn't let you use HTML and instead has a custom way of specifying column layouts.
### Column layout
## Column layout
The way to specify column layouts is by first creating a layout, and then telling _presenterm_ you want to enter each of
the column in it as you write your presentation.
#### Defining layouts
### Defining layouts
Defining a layout is done via the `column_layout` command, in the form of an HTML comment:
@ -39,7 +39,7 @@ This defines a layout with 2 columns where:
You can use any number of columns and with as many units you want on each of them. This lets you decide how to structure
the presentation in a fairly straightforward way.
#### Using columns
### Using columns
Once a layout is defined, you just need to specify that you want to enter a column before writing any text to it by
using the `column` command:
@ -54,7 +54,7 @@ Now all the markdown you write will be placed on the first column until you eith
* The slide ends.
* You jump into another column by using the `column` command again.
### Example
## Example
The following example puts all of this together by defining 2 columns, one with some code and bullet points, another one
with an image, and some extra text at the bottom that's not tied to any columns.
@ -95,7 +95,7 @@ This would render the following way:
![](../assets/layouts.png)
### Other uses
## Other uses
Besides organizing your slides into columns, you can use column layouts to center a piece of your slide. For example, if
you want a certain portion of your slide to be centered, you could define a column layout like `[1, 3, 1]` and then only

View File

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

View File

@ -0,0 +1,24 @@
# Slide transitions
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. See the
[configuration page](../configuration/settings.md) to learn how to configure transitions.
The following animations are supported:
## `fade`
Fade the current slide into the next one.
[![asciicast](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw.svg)](https://asciinema.org/a/RvxLw0FHOopjdF4ixWbCkWuSw)
## `slide_horizontal`
Slide horizontally to the next/previous slide.
[![asciicast](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ.svg)](https://asciinema.org/a/T43ttxPWZ8TsM2auTqNZSWrmZ)
## `collapse_horizontal`
Collapse the current slide into the center of the screen horizontally.
[![asciicast](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW.svg)](https://asciinema.org/a/VB8i3kGMvbkbiYYPpaZJUl2dW)

View File

@ -0,0 +1,72 @@
## Speaker notes
Starting on version 0.10.0, _presenterm_ allows presentations to define speaker notes. The way this works is:
* You start an instance of _presenterm_ using the `--publish-speaker-notes` parameter. This will be the main instance in
which you will present like you usually do.
* Another instance should be started using the `--listen-speaker-notes` parameter. This instance will only display
speaker notes in the presentation and will automatically change slides whenever the main instance does so.
For example:
```bash
# Start the main instance
presenterm demo.md --publish-speaker-notes
# In another shell: start the speaker notes instance
presenterm demo.md --listen-speaker-notes
```
[![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J)
See the [speaker notes example](https://github.com/mfontanini/presenterm/blob/master/examples/speaker-notes.md) for more
information.
### Defining speaker notes
In order to define speaker notes you can use the `speaker_notes` comment command:
```markdown
Normal text
<!-- speaker_note: this is a speaker note -->
More text
```
When running this two instance setup, the main one will show "normal text" and "more text", whereas the second one will
only show "this is a speaker note" on that slide.
### Multiline speaker notes
You can use multiline speaker notes by using the appropriate YAML syntax:
```yaml
<!--
speaker_note: |
something
something else
-->
```
### Multiple instances
On Linux and Windows, you can run multiple instances in publish mode and multiple instances in listen mode at the same
time. Each instance will only listen to events for the presentation it was started on.
On Mac this is not supported and only a single listener can be used at a time.
### Enabling publishing by default
You can use the `speaker_notes.always_publish` key in your config file to always publish speaker notes. This means you
will only ever need to use `--listen-speaker-notes` and you will never need to use `--publish-speaker-notes`:
```yaml
speaker_notes:
always_publish: true
```
### Internals
This uses UDP sockets on localhost to communicate between instances. The main instance sends events every time a slide
is shown and the listener instances listen to them and displays the speaker notes for that specific slide.

View File

@ -1,108 +1,9 @@
## Themes
# Theme definition
Themes are defined in the form of yaml files. A few built-in themes are defined in the [themes][builtin-themes]
directory, but others can be created and referenced directly in every presentation.
This section goes through the structure of the theme files. Have a look at some of the [existing
themes](https://github.com/mfontanini/presenterm/tree/master/themes) to have an idea of how to structure themes.
### Setting themes
There's various ways of setting the theme you want in your presentation:
#### CLI
Passing in the `--theme` parameter when running _presenterm_ to select one of the built-in themes.
#### Within the presentation
The presentation's markdown file can contain a front matter that specifies the theme to use. This comes in 3 flavors:
##### By name
Using a built-in theme name makes your presentation use that one regardless of what the default or what the `--theme`
option specifies:
```yaml
---
theme:
name: dark
---
```
##### By path
You can define a theme file in yaml format somewhere in your filesystem and reference it within the presentation:
```yaml
---
theme:
path: /home/me/Documents/epic-theme.yaml
---
```
##### Overrides
You can partially/completely override the theme in use from within the presentation:
```yaml
---
theme:
override:
default:
colors:
foreground: "beeeff"
---
```
This lets you:
1. Create a unique style for your presentation without having to go through the process of taking an existing theme,
copying somewhere, and changing it when you only expect to use it for that one presentation.
2. Iterate quickly on styles given overrides are reloaded whenever you save your presentation file.
## Built-in themes
A few built-in themes are bundled with the application binary, meaning you don't need to have any external files
available to use them. These are packed as part of the [build process][build-rs] as a binary blob and are decoded on
demand only when used.
Currently, the following themes are supported:
* `dark`: A dark theme.
* `light`: A light theme.
* `tokyonight-storm`: A theme inspired by the colors used in [toyonight](https://github.com/folke/tokyonight.nvim).
* A set of themes based on the [catppuccin](https://github.com/catppuccin/catppuccin) color palette:
* `catppuccin-latte`
* `catppuccin-frappe`
* `catppuccin-macchiato`
* `catppuccin-mocha`
* `terminal-dark`: A theme that uses your terminals color and looks best if your terminal uses a dark color scheme. This
means if your terminal background is e.g. transparent, or uses an image, the presentation will inherit that.
* `terminal-light`: The same as `terminal-dark` but works best if your terminal uses a light color scheme.
### Trying out built-in themes
All built-in themes can be tested by using the `--list-themes` parameter:
```shell
presenterm --list-themes
```
This will run a presentation where the same content is rendered using a different theme in each slide:
[![asciicast](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle.svg)](https://asciinema.org/a/zeV1QloyrLkfBp6rNltvX7Lle)
## Loading custom themes
On startup, _presenterm_ will look into the `themes` directory under the [configuration directory](configuration.html)
(e.g. `~/.config/presenterm/themes` in Linux) and will load any `.yaml` file as a theme and make it available as if it
was a built-in theme. This means you can use it as an argument to the `--theme` parameter, use it in the `theme.name`
property in a presentation's front matter, etc.
## Theme definition
This section goes through the structure of the theme files. Have a look at some of the [existing themes][builtin-themes]
to have an idea of how to structure themes.
### Root elements
## Root elements
The root attributes on the theme yaml files specify either:
@ -110,7 +11,7 @@ The root attributes on the theme yaml files specify either:
etc.
* A default to be applied as a fallback if no specific style is specified for a particular element.
### Alignment
## Alignment
_presenterm_ uses the notion of alignment, just like you would have in a GUI editor, to align text to the left, center,
or right. You probably want most elements to be aligned left, _some_ to be aligned on the center, and probably none to
@ -122,14 +23,14 @@ The following elements support alignment:
* The title, subtitle, and author elements in the intro slide.
* Tables.
#### Left/right alignment
### Left/right alignment
Left and right alignments take a margin property which specifies the number of columns to keep between the text and the
left/right terminal screen borders.
The margin can be specified in two ways:
##### Fixed
#### Fixed
A specific number of characters regardless of the terminal size.
@ -139,7 +40,7 @@ margin:
fixed: 5
```
##### Percent
#### Percent
A percentage over the total number of columns in the terminal.
@ -152,7 +53,7 @@ margin:
Percent alignment tends to look a bit nicer as it won't change the presentation's look as much when the terminal size
changes.
#### Center alignment
### Center alignment
Center alignment has 2 properties:
* `minimum_size` which specifies the minimum size you want that element to have. This is normally useful for code blocks
@ -161,7 +62,7 @@ Center alignment has 2 properties:
alignment. This doesn't play very well with `minimum_size` but in isolation it specifies the minimum number of columns
you want to the left and right of your text.
### Colors
## Colors
Every element can have its own background/foreground color using hex notation:
@ -172,7 +73,7 @@ default:
background: "00ff00"
```
### Default style
## Default style
The default style specifies:
@ -188,7 +89,7 @@ default:
background: "040312"
```
### Intro slide
## Intro slide
The introductory slide will be rendered if you specify a title, subtitle, or author in the presentation's front matter.
This lets you have a less markdown-looking introductory slide that stands out so that it doesn't end up looking too
@ -221,20 +122,79 @@ intro_slide:
positioning: below_title
```
### Footer
## Footer
The footer currently comes in 3 flavors:
#### None
### Template footers
No footer at all!
A template footer lets you put text on the left, center and/or right of the screen. The template strings
can reference `{current_slide}` and `{total_slides}` which will be replaced with the current and total number of slides.
Besides those special variables, any of the attributes defined in the front matter can also be used:
* `title`.
* `sub_title`.
* `event`.
* `location`.
* `date`.
* `author`.
Strings used in template footers can contain arbitrary markdown, including `span` tags that let you use colored text. A
`height` attribute allows specifying how tall, in terminal rows, the footer is. The text in the footer will always be
placed at the center of the footer area. The default footer height is 2.
```yaml
footer:
style: empty
style: template
left: "My **name** is {author}"
center: "_@myhandle_"
right: "{current_slide} / {total_slides}"
height: 3
```
#### Progress bar
Do note that:
* Only existing attributes in the front matter can be referenced. That is, if you use `{date}` but the `date` isn't set,
an error will be shown.
* Similarly, referencing unsupported variables (e.g. `{potato}`) will cause an error to be displayed. If you'd like the
`{}` characters to be used in contexts where you don't want to reference a variable, you will need to escape them by
using another brace. e.g. `{{potato}} farms` will be displayed as `{potato} farms`.
#### Footer images
Besides text, images can also be used in the left/center/right positions. This can be done by specifying an `image` key
under each of those attributes:
```yaml
footer:
style: template
left:
image: potato.png
center:
image: banana.png
right:
image: apple.png
# The height of the footer to adjust image sizes
height: 5
```
Images will be looked up:
* First, relative to the presentation file just like any other image.
* If the image is not found, it will be looked up relative to the themes directory. e.g. `~/.config/presenterm/themes`.
This allows you to define a custom theme in your themes directory that points to a local image within that same
location.
Images will preserve their aspect ratio and expand vertically to take up as many terminal rows as `footer.height`
specifies. This parameter should be adjusted accordingly if taller-than-wider images are used in a footer.
See the [footer example](https://github.com/mfontanini/presenterm/blob/master/examples/footer.md) as a showcase of how a
footer can contain images and colored text.
![](../../assets/example-footer.png)
### Progress bar footers
A progress bar that will advance as you move in your presentation. This will by default use a block-looking character to
draw the progress bar but you can customize it:
@ -247,21 +207,17 @@ footer:
character: 🚀
```
#### Template
### None
A template footer that lets you put something on the left, center and/or right of the screen. The template strings have
access to `{author}` as specified in the front matter, `{current_slide}` and `{total_slides}` which will point to the
current and total number of slides:
No footer at all!
```yaml
footer:
style: template
left: "My name is {author}"
center: @myhandle
right: "{current_slide} / {total_slides}"
style: empty
```
### Slide title
## Slide title
Slide titles, as specified by using a setext header, has the following properties:
* `padding_top` which specifies the number of rows you want as padding before the text.
@ -275,7 +231,7 @@ slide_title:
separator: true
```
### Headings
## Headings
Every header type (h1 through h6) can have its own style composed of:
* The prefix you want to use.
@ -293,7 +249,7 @@ headings:
foreground: "rgb_(168,223,142)"
```
### Code blocks
## Code blocks
The syntax highlighting for code blocks is done via the [syntect](https://github.com/trishume/syntect) crate. The list
of all the supported built-in _syntect_ themes is the following:
@ -324,10 +280,10 @@ code:
#### Custom highlighting themes
Besides the built-in highlighting themes, you can drop any `.tmTheme` theme in the `themes/highlighting` directory under
your [configuration directory](configuration.html) (e.g. `~/.config/presenterm/themes/highlighting` in Linux) and they
will be loaded automatically when _presenterm_ starts.
your [configuration directory](../../configuration/introduction.md) (e.g. `~/.config/presenterm/themes/highlighting` in
Linux) and they will be loaded automatically when _presenterm_ starts.
### Block quotes
## Block quotes
For block quotes you can specify a string to use as a prefix in every line of quoted text:
@ -336,6 +292,103 @@ block_quote:
prefix: "▍ "
```
<!-- links -->
[builtin-themes]: https://github.com/mfontanini/presenterm/tree/master/themes
[build-rs]: https://github.com/mfontanini/presenterm/blob/master/build.rs
## Mermaid
The [mermaid](https://mermaid.js.org/) graphs can be customized using the following parameters:
* `mermaid.background` the background color passed to the CLI (e.g., `transparent`, `red`, `#F0F0F0`).
* `mermaid.theme` the [mermaid theme](https://mermaid.js.org/config/theming.html#available-themes) to use.
```yaml
mermaid:
background: transparent
theme: dark
```
## Alerts
GitHub style markdown alerts can be styled by setting the `alert` key:
```yaml
alert:
# the base colors used in all text in an alert
base_colors:
foreground: red
background: black
# the prefix used in every line in the alert
prefix: "▍ "
# the style for each alert type
styles:
note:
color: blue
title: Note
icon: I
tip:
color: green
title: Tip
icon: T
important:
color: cyan
title: Important
icon: I
warning:
color: orange
title: Warning
icon: W
caution:
color: red
title: Caution
icon: C
```
## Extending themes
Custom themes can extend other custom or built in themes. This means it will inherit all the properties of the theme
being extended by default.
For example:
```yaml
extends: dark
default:
colors:
background: "000000"
```
This theme extends the built in _dark_ theme and overrides the background color. This is useful if you find yourself
_almost_ liking a built in theme but there's only some properties you don't like.
## Color palette
Every theme can define a color palette, which includes a list of pre-defined colors and a list of background/foreground
pairs called "classes". Colors and classes can be used when styling text via `<span>` HTML tags, whereas colors can also
be used inside themes to avoid duplicating the same colors all over the theme definition.
A palette can de defined as follows:
```yaml
palette:
colors:
red: "f78ca2"
purple: "986ee2"
classes:
foo:
foreground: "ff0000"
background: "00ff00"
```
Any palette color can be referenced using either `palette:<name>` or `p:<name>`. This means now any part of the theme
can use `p:red` and `p:purple` where a color is required.
Similarly, these colors can be used in `span` tags like:
```html
<span style="color: palette:red">this is red</span>
<span class="foo">this is foo-colored</span>
```
These colors can used anywhere in your presentation as well as in other places such as in
[template footers](#template-footers) and [introduction slides](../introduction.md#introduction-slide).

View File

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

View File

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

View File

@ -1,198 +0,0 @@
## Code highlighting
Code highlighting is supported for the following languages:
* ada
* asp
* awk
* bash
* batchfile
* C
* cmake
* crontab
* C#
* clojure
* C++
* CSS
* D
* diff
* docker
* dotenv
* elixir
* elm
* erlang
* go
* haskell
* HTML
* java
* javascript
* json
* kotlin
* latex
* lua
* makefile
* markdown
* nix
* ocaml
* perl
* php
* protobuf
* puppet
* python
* R
* ruby
* rust
* scala
* shell
* sql
* swift
* svelte
* terraform
* typescript
* xml
* yaml
* vue
* zig
### Enabling line numbers
If you would like line numbers to be shown on the left of a code block use the `+line_numbers` switch after specifying
the language in a code block:
~~~markdown
```rust +line_numbers
fn hello_world() {
println!("Hello world");
}
```
~~~
### Selective highlighting
By default, the entire code block will be syntax-highlighted. If instead you only wanted a subset of it to be
highlighted, you can use braces and a list of either individual lines, or line ranges that you'd want to highlight.
~~~markdown
```rust {1,3,5-7}
fn potato() -> u32 { // 1: highlighted
// 2: not highlighted
println!("Hello world"); // 3: highlighted
let mut q = 42; // 4: not highlighted
q = q * 1337; // 5: highlighted
q // 6: highlighted
} // 7: highlighted
```
~~~
### Dynamic highlighting
Similar to the syntax used for selective highlighting, dynamic highlighting will change which lines of the code in a
code block are highlighted every time you move to the next/previous slide.
This is achieved by using the separator `|` to indicate what sections of the code will be highlighted at a given time.
~~~markdown
```rust {1,3|5-7}
fn potato() -> u32 {
println!("Hello world");
let mut q = 42;
q = q * 1337;
q
}
```
~~~
In this example, lines 1 and 3 will be highlighted initially. Then once you press a key to move to the next slide, lines
1 and 3 will no longer be highlighted and instead lines 5 through 7 will. This allows you to create more dynamic
presentations where you can display sections of the code to explain something specific about each of them.
See this real example of how this looks like.
[![asciicast](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI.svg)](https://asciinema.org/a/iCf4f6how1Ux3H8GNzksFUczI)
### Executing code blocks
Annotating a code block with a `+exec` attribute will make it executable. Once you're in a slide that contains an
executable block, press `control+e` to execute it. The output of the execution will be displayed on a box below the
code. The code execution is stateful so if you switch to another slide and then go back, you will still see the output.
~~~markdown
```bash +exec
echo hello world
```
~~~
Code execution **must be explicitly enabled** by using either:
* The `-x` command line parameter when running _presenterm_.
* Setting the `snippet.exec.enable` property to `true` in your [_presenterm_ config
file](configuration.html#snippet-execution).
---
The list of languages that support execution are:
* bash
* c++
* c
* fish
* go
* java
* js
* kotlin
* lua
* nushell
* perl
* python
* ruby
* rust-script
* rust
* sh
* zsh
If there's a language that is not in this list and you would like it to be supported, please [create an
issue](https://github.com/mfontanini/presenterm/issues/new) providing details on how to compile (if necessary) and run
snippets for that language. You can also configure how to run code snippet for a language locally in your [config
file](configuration.html#custom-snippet-executors).
[![asciicast](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr.svg)](https://asciinema.org/a/BbAY817esxagCgPtnKUwgYnHr)
> **Note**: because this is spawning a process and executing code, you should use this at your own risk.
### Hiding code lines
When you mark a code snippet as executable via the `+exec` flag, you may not be interested in showing _all the lines_ to
your audience, as some of them may not be necessary to convey your point. For example, you may want to hide imports,
non-essential functions, initialization of certain variables, etc. For this purpose, _presenterm_ supports a prefix
under certain programming languages that let you indicate a line should be executed when running the code but should not
be displayed in the presentation.
For example, in the following code snippet only the print statement will be displayed but the entire snippet will be
ran:
~~~markdown
```rust
# fn main() {
println!("Hello world!");
# }
```
~~~
Rather than blindly relying on a prefix that may have a meaning in a language, prefixes are chosen on a per language
basis. The languages that are supported and their prefix is:
* rust: `# `.
* python/bash/fish/shell/zsh/kotlin/java/javascript/typescript/c/c++/go: `/// `.
This means that any line in a rust code snippet that starts with `# ` will be hidden, whereas all lines in, say, a
golang code snippet that starts with a `/// ` will be hidden.
### Pre-rendering
Some languages support pre-rendering. This means the code block is transformed into something else when the presentation
is loaded. The languages that currently support this are _LaTeX_ and _typst_ where the contents of the code block is
transformed into an image, allowing you to define formulas as text in your presentation. This can be done by using the
`+render` attribute on a code block.
See the [LaTeX and typst](latex.html) and [mermaid](mermaid.html) docs for more information.

View File

@ -1,335 +0,0 @@
## Configuration
_presenterm_ allows you to customize its behavior via a configuration file. This file is stored, along with all of your
custom themes, in the following directories:
* `$XDG_CONFIG_HOME/presenterm/` if that environment variable is defined, otherwise:
* `~/.config/presenterm/` in Linux.
* `~/Library/Application Support/presenterm/` in macOS.
* `~/AppData/Roaming/presenterm/config/` in Windows.
The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on
Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file
when running _presenterm_ via the `--config-path` parameter.
A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in
the repository that you can use as a base.
## Options
Options are special configuration parameters that can be set either in the configuration file under the `options` key,
or in a presentation's front matter under the same key. This last one allows you to customize a single presentation so
that it acts in a particular way. This can also be useful if you'd like to share the source files for your presentation
with other people.
The supported configuration options are currently the following:
### implicit_slide_ends
This option removes the need to use `<!-- end_slide -->` in between slides and instead assumes that if you use a slide
title, then you're implying that the previous slide ended. For example, the following presentation:
```
---
options:
implicit_slide_ends: true
---
Tasty vegetables
================
* Potato
Awful vegetables
================
* Lettuce
```
Is equivalent to this "vanilla" one that doesn't use implicit slide ends.
```markdown
Tasty vegetables
================
* Potato
<!-- end_slide -->
Awful vegetables
================
* Lettuce
```
### end_slide_shorthand
This option allows using thematic breaks (`---`) as a delimiter between slides. When enabling this option, you can still
use `<!-- end_slide -->` but any thematic break will also be considered a slide terminator.
```
---
options:
end_slide_shorthand: true
---
this is a slide
---------------------
this is another slide
```
### command_prefix
Because _presenterm_ uses HTML comments to represent commands, it is necessary to make some assumptions on _what_ is a
command and what isn't. The current heuristic is:
* If an HTML comment is laid out on a single line, it is assumed to be a command. This means if you want to use a real
HTML comment like `<!-- remember to say "potato" here -->`, this will raise an error.
* If an HTML comment is multi-line, then it is assumed to be a comment and it can have anything inside it. This means
you can't have a multi-line comment that contains a command like `pause` inside.
Depending on how you use HTML comments personally, this may be limiting to you: you cannot use any single line comments
that are not commands. To get around this, the `command_prefix` option lets you configure a prefix that must be set in
all commands for them to be configured as such. Any single line comment that doesn't start with this prefix will not be
considered a command.
For example:
```
---
options:
command_prefix: "cmd:"
---
<!-- remember to say "potato here" -->
Tasty vegetables
================
* Potato
<!-- cmd:pause -->
**That's it!**
```
In the example above, the first comment is ignored because it doesn't start with "cmd:" and the second one is processed
because it does.
### incremental_lists
If you'd like all bullet points in all lists to show up with pauses in between you can enable the `incremental_lists`
option:
```
---
options:
incremental_lists: true
---
* pauses
* in
* between
```
Keep in mind if you only want specific bullet points to show up with pauses in between, you can use the
[`incremental_lists` comment command](basics.html#incremental-lists).
### strict_front_matter_parsing
This option tells _presenterm_ you don't care about extra parameters in presentation's front matter. This can be useful
if you're trying to load a presentation made for another tool. The following presentation would only be successfully
loaded if you set `strict_front_matter_parsing` to `false` in your configuration file:
```markdown
---
potato: 42
---
# Hi
```
### image_attributes_prefix
The [image size](basics.html#image-size) prefix (by default `image:`) can be configured to be anything you would want in
case you don't like the default one. For example, if you'd like to set the image size by simply doing
`![width:50%](path.png)` you would need to set:
```
---
options:
image_attributes_prefix: ""
---
![width:50%](path.png)
```
## Defaults
Defaults **can only be configured via the configuration file**.
### Default theme
The default theme can be configured only via the config file. When this is set, every presentation that doesn't set a
theme explicitly will use this one:
```yaml
defaults:
theme: light
```
### Terminal font size
This is a parameter that lets you explicitly set the terminal font size in use. This should not be used unless you are
in Windows, given there's no (easy) way to get the terminal window size so we use this to figure out how large the
window is and resize images properly. Some terminals on other platforms may also have this issue, but that should not be
as common.
If you are on Windows or you notice images show up larger/smaller than they should, you can adjust this setting in your
config file:
```yaml
defaults:
terminal_font_size: 16
```
### Preferred image protocol
By default _presenterm_ will try to detect which image protocol to use based on the terminal you are using. In some
cases this may fail, for example when using `tmux`. In those cases, you can explicitly set this via the
`--image-protocol` parameter or the configuration key `defaults.image_protocol`:
```yaml
defaults:
image_protocol: kitty-local
```
Possible values are:
* `auto`: try to detect it automatically (default).
* `kitty-local`: use the kitty protocol in "local" mode, meaning both _presenterm_ and the terminal run in the same host
and can share the filesystem to communicate.
* `kitty-remote`: use the kitty protocol in "remote" mode, meaning _presenterm_ and the terminal run in different hosts
and therefore can only communicate via terminal escape codes.
* `iterm2`: use the iterm2 protocol.
* `sixel`: use the sixel protocol. Note that this requires compiling _presenterm_ using the `--features sixel` flag.
## Key bindings
Key bindings that _presenterm_ uses can be manually configured in the config file via the `bindings` key. The following
is the default configuration:
```yaml
bindings:
# the keys that cause the presentation to move forwards.
next: ["l", "j", "<right>", "<page_down>", "<down>", " "]
# the keys that cause the presentation to move backwards.
previous: ["h", "k", "<left>", "<page_up>", "<up>"]
# the key binding to jump to the first slide.
first_slide: ["gg"]
# the key binding to jump to the last slide.
last_slide: ["G"]
# the key binding to jump to a specific slide.
go_to_slide: ["<number>G"]
# the key binding to execute a piece of shell code.
execute_code: ["<c-e>"]
# the key binding to reload the presentation.
reload: ["<c-r>"]
# the key binding to toggle the slide index modal.
toggle_slide_index: ["<c-p>"]
# the key binding to toggle the key bindings modal.
toggle_bindings: ["?"]
# the key binding to close the currently open modal.
close_modal: ["<esc>"]
# the key binding to close the application.
exit: ["<c-c>"]
```
You can choose to override any of them. Keep in mind these are overrides so if for example you change `next`, the
default won't apply anymore and only what you've defined will be used.
## Snippet configurations
### Snippet execution
Snippet execution is disabled by default for security reasons. Besides passing in the `-x` command line parameter every
time you run _presenterm_, you can also configure this globally for all presentations by setting:
```yaml
snippet:
exec:
enable: true
```
**Use this at your own risk**, especially if you're running someone else's presentations!
### Custom snippet executors
If _presenterm_ doesn't support executing code snippets for your language of choice, please [create an
issue](https://github.com/mfontanini/presenterm/issues/new)! Alternatively, you can configure this locally yourself by
setting:
```yaml
snippet:
exec:
custom:
# The keys should be the language identifier you'd use in a code block.
c++:
# The name of the file that will be created with your snippet's contents.
filename: "snippet.cpp"
# A list of environment variables that should be set before building/running your code.
environment:
MY_FAVORITE_ENVIRONMENT_VAR: foo
# A list of commands that will be ran one by one in the same directory as the snippet is in.
commands:
# Compile if first
- ["g++", "-std=c++20", "snippet.cpp", "-o", "snippet"]
# Now run it
- ["./snippet"]
```
The output of all commands will be included in the code snippet execution output so if a command (like the `g++`
invocation) was to emit any output, make sure to use whatever flags are needed to mute its output.
Also note that you can override built-in executors in case you want to run them differently (e.g. use `c++23` in the
example above).
See more examples in the [executors.yaml](https://github.com/mfontanini/presenterm/blob/master/executors.yaml) file
which defines all of the built-in executors.
### Snippet rendering threads
Because some `+render` code blocks can take some time to be rendered into an image, especially if you're using
[mermaid](https://mermaid.js.org/) charts, this is run asychronously. The number of threads used to render these, which
defaults to 2, can be configured by setting:
```yaml
snippet:
render:
threads: 2
```
### Mermaid scaling
[mermaid](https://mermaid.js.org/) graphs will use a default scaling of `2` when invoking the mermaid CLI. If you'd like
to change this use:
```yaml
mermaid:
scale: 2
```

View File

@ -1,31 +0,0 @@
## PDF export
Presentations can be converted into PDF by using a [helper tool](https://github.com/mfontanini/presenterm-export). You
can install it by running:
```shell
pip install presenterm-export
```
> **Note**: make sure that `presenterm-export` works by running `presenterm-export --version` before attempting to
> generate a PDF file. If you get errors related to _weasyprint_, follow their [installation
> instructions](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html) to ensure you meet all of their
> dependencies. This has otherwise caused issues in macOS.
The only external dependency you'll need is [tmux](https://github.com/tmux/tmux/). After you've installed both of these,
simply run _presenterm_ with the `--export-pdf` parameter to generate the output PDF:
```shell
presenterm --export-pdf examples/demo.md
```
The output PDF will be placed in `examples/demo.pdf`. The size of each page will depend on the size of your terminal so
make sure to adjust accordingly before running the command above.
> Note: if you're using a separate virtual env to install _presenterm-export_ just make sure you activate it before
> running _presenterm_ with the `--export-pdf` parameter.
### How it works
The conversion into PDF format is pretty convoluted. If you'd like to learn more visit
[presenterm-export](https://github.com/mfontanini/presenterm-export)'s repo.

View File

@ -1,41 +1,41 @@
## Installation
# Installing _presenterm_
_presenterm_ works on Linux, macOS, and Windows and can be installed in different ways:
### Pre-built binaries (recommended)
## Pre-built binaries (recommended)
The recommended way to install _presenterm_ is to download the latest pre-built version for
your system from the [releases](https://github.com/mfontanini/presenterm/releases) page.
### Install via cargo
## Install via cargo
Alternatively, download [rust](https://www.rust-lang.org/) and run:
```shell
cargo install presenterm
```bash
cargo install --locked presenterm
```
### Latest unreleased version
## Latest unreleased version
To install from the latest source code run:
```shell
```bash
cargo install --git https://github.com/mfontanini/presenterm
```
### macOS
## macOS
Install the latest version in macOS via [brew](https://formulae.brew.sh/formula/presenterm) by running:
```shell
```bash
brew install presenterm
```
### Nix
## Nix
To install _presenterm_ using the Nix package manager run:
```shell
```bash
nix-env -iA nixos.presenterm # for nixos
nix-env -iA nixpkgs.presenterm # for non-nixos
```
@ -56,13 +56,13 @@ nix run github:mfontanini/presenterm # to run from github repo
```
For more information see
[nixpkgs](https://search.nixos.org/packages?channel=unstable&show=presenterm&from=0&size=50&sort=relevance&type=packages&query=presenterm)
[nixpkgs](https://search.nixos.org/packages?channel=unstable&show=presenterm&from=0&size=50&sort=relevance&type=packages&query=presenterm).
### Arch Linux
## Arch Linux
_presenterm_ is available in the [official repositories](https://archlinux.org/packages/extra/x86_64/presenterm/). You can use [pacman](https://wiki.archlinux.org/title/pacman) to install as follows:
```shell
```bash
pacman -S presenterm
```
@ -70,17 +70,17 @@ pacman -S presenterm
Alternatively, you can use any AUR helper to install the upstream binaries:
```shell
```bash
paru/yay -S presenterm-bin
```
#### Building from git
```shell
```bash
paru/yay -S presenterm-git
```
### Windows
## Windows
Install the latest version in Scoop via [Scoop](https://scoop.sh/#/apps?q=presenterm&id=a462290f824b50f180afbaa6d8c7c1e6e0952e3a) by running:

View File

@ -1,10 +1,10 @@
## presenterm
# presenterm
[presenterm][github] lets you create presentations in markdown format and run them from your terminal, with support for
image and animated gif support, highly customizable themes, code highlighting, exporting presentations into PDF format,
and plenty of other features.
### Demo
## Demo
This is how the [demo presentation][demo-source] looks like:

View File

@ -1,363 +0,0 @@
.mocha :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
border-color: #f9e2af;
}
.mocha :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(249, 226, 175, 0.2);
}
.mocha :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f9e2af;
}
.mocha :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
border-color: #f2cdcd;
}
.mocha :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(242, 205, 205, 0.2);
}
.mocha :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f2cdcd;
}
.mocha :is(.admonition):is(.admonish-example) {
border-color: #cba6f7;
}
.mocha :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(203, 166, 247, 0.2);
}
.mocha :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #cba6f7;
}
.mocha :is(.admonition):is(.admonish-info, .admonish-todo) {
border-color: #89dceb;
}
.mocha :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(137, 220, 235, 0.2);
}
.mocha :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #89dceb;
}
.mocha :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
border-color: #a6e3a1;
}
.mocha :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(166, 227, 161, 0.2);
}
.mocha :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #a6e3a1;
}
.mocha :is(.admonition):is(.admonish-note) {
border-color: #89b4fa;
}
.mocha :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(137, 180, 250, 0.2);
}
.mocha :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #89b4fa;
}
.mocha :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
border-color: #fab387;
}
.mocha :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(250, 179, 135, 0.2);
}
.mocha :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #fab387;
}
.mocha :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
border-color: #94e2d5;
}
.mocha :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(148, 226, 213, 0.2);
}
.mocha :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #94e2d5;
}
.mocha :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
border-color: #f38ba8;
}
.mocha :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(243, 139, 168, 0.2);
}
.mocha :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f38ba8;
}
.mocha :is(.admonition):is(.admonish-cite, .admonish-quote) {
border-color: #f5c2e7;
}
.mocha :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(245, 194, 231, 0.2);
}
.mocha :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f5c2e7;
}
.macchiato :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
border-color: #eed49f;
}
.macchiato :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(238, 212, 159, 0.2);
}
.macchiato :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #eed49f;
}
.macchiato :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
border-color: #f0c6c6;
}
.macchiato :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(240, 198, 198, 0.2);
}
.macchiato :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f0c6c6;
}
.macchiato :is(.admonition):is(.admonish-example) {
border-color: #c6a0f6;
}
.macchiato :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(198, 160, 246, 0.2);
}
.macchiato :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #c6a0f6;
}
.macchiato :is(.admonition):is(.admonish-info, .admonish-todo) {
border-color: #91d7e3;
}
.macchiato :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(145, 215, 227, 0.2);
}
.macchiato :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #91d7e3;
}
.macchiato :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
border-color: #a6da95;
}
.macchiato :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(166, 218, 149, 0.2);
}
.macchiato :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #a6da95;
}
.macchiato :is(.admonition):is(.admonish-note) {
border-color: #8aadf4;
}
.macchiato :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(138, 173, 244, 0.2);
}
.macchiato :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #8aadf4;
}
.macchiato :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
border-color: #f5a97f;
}
.macchiato :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(245, 169, 127, 0.2);
}
.macchiato :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f5a97f;
}
.macchiato :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
border-color: #8bd5ca;
}
.macchiato :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(139, 213, 202, 0.2);
}
.macchiato :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #8bd5ca;
}
.macchiato :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
border-color: #ed8796;
}
.macchiato :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(237, 135, 150, 0.2);
}
.macchiato :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #ed8796;
}
.macchiato :is(.admonition):is(.admonish-cite, .admonish-quote) {
border-color: #f5bde6;
}
.macchiato :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(245, 189, 230, 0.2);
}
.macchiato :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f5bde6;
}
.frappe :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
border-color: #e5c890;
}
.frappe :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(229, 200, 144, 0.2);
}
.frappe :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #e5c890;
}
.frappe :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
border-color: #eebebe;
}
.frappe :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(238, 190, 190, 0.2);
}
.frappe :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #eebebe;
}
.frappe :is(.admonition):is(.admonish-example) {
border-color: #ca9ee6;
}
.frappe :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(202, 158, 230, 0.2);
}
.frappe :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #ca9ee6;
}
.frappe :is(.admonition):is(.admonish-info, .admonish-todo) {
border-color: #99d1db;
}
.frappe :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(153, 209, 219, 0.2);
}
.frappe :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #99d1db;
}
.frappe :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
border-color: #a6d189;
}
.frappe :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(166, 209, 137, 0.2);
}
.frappe :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #a6d189;
}
.frappe :is(.admonition):is(.admonish-note) {
border-color: #8caaee;
}
.frappe :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(140, 170, 238, 0.2);
}
.frappe :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #8caaee;
}
.frappe :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
border-color: #ef9f76;
}
.frappe :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(239, 159, 118, 0.2);
}
.frappe :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #ef9f76;
}
.frappe :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
border-color: #81c8be;
}
.frappe :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(129, 200, 190, 0.2);
}
.frappe :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #81c8be;
}
.frappe :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
border-color: #e78284;
}
.frappe :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(231, 130, 132, 0.2);
}
.frappe :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #e78284;
}
.frappe :is(.admonition):is(.admonish-cite, .admonish-quote) {
border-color: #f4b8e4;
}
.frappe :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(244, 184, 228, 0.2);
}
.frappe :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #f4b8e4;
}
.latte :is(.admonition):is(.admonish-hint, .admonish-important, .admonish-tip) {
border-color: #df8e1d;
}
.latte :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(223, 142, 29, 0.2);
}
.latte :is(.admonish-hint, .admonish-important, .admonish-tip) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #df8e1d;
}
.latte :is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) {
border-color: #dd7878;
}
.latte :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(221, 120, 120, 0.2);
}
.latte :is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #dd7878;
}
.latte :is(.admonition):is(.admonish-example) {
border-color: #8839ef;
}
.latte :is(.admonish-example) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(136, 57, 239, 0.2);
}
.latte :is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #8839ef;
}
.latte :is(.admonition):is(.admonish-info, .admonish-todo) {
border-color: #04a5e5;
}
.latte :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(4, 165, 229, 0.2);
}
.latte :is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #04a5e5;
}
.latte :is(.admonition):is(.admonish-check, .admonish-done, .admonish-success) {
border-color: #40a02b;
}
.latte :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(64, 160, 43, 0.2);
}
.latte :is(.admonish-check, .admonish-done, .admonish-success) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #40a02b;
}
.latte :is(.admonition):is(.admonish-note) {
border-color: #1e66f5;
}
.latte :is(.admonish-note) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(30, 102, 245, 0.2);
}
.latte :is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #1e66f5;
}
.latte :is(.admonition):is(.admonish-attention, .admonish-caution, .admonish-warning) {
border-color: #fe640b;
}
.latte :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(254, 100, 11, 0.2);
}
.latte :is(.admonish-attention, .admonish-caution, .admonish-warning) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #fe640b;
}
.latte :is(.admonition):is(.admonish-faq, .admonish-help, .admonish-question) {
border-color: #179299;
}
.latte :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(23, 146, 153, 0.2);
}
.latte :is(.admonish-faq, .admonish-help, .admonish-question) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #179299;
}
.latte :is(.admonition):is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) {
border-color: #d20f39;
}
.latte :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(210, 15, 57, 0.2);
}
.latte :is(.admonish-bug, .admonish-danger, .admonish-error, .admonish-fail, .admonish-failure, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #d20f39;
}
.latte :is(.admonition):is(.admonish-cite, .admonish-quote) {
border-color: #ea76cb;
}
.latte :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title) {
background-color: rgba(234, 118, 203, 0.2);
}
.latte :is(.admonish-cite, .admonish-quote) > :is(.admonition-title, summary.admonition-title)::before {
background-color: #ea76cb;
}

View File

@ -1,787 +0,0 @@
.mocha.hljs {
color: #cdd6f4;
background: #1e1e2e;
}
.mocha .hljs-keyword {
color: #cba6f7;
}
.mocha .hljs-built_in {
color: #f38ba8;
}
.mocha .hljs-type {
color: #f9e2af;
}
.mocha .hljs-literal {
color: #fab387;
}
.mocha .hljs-number {
color: #fab387;
}
.mocha .hljs-operator {
color: #94e2d5;
}
.mocha .hljs-punctuation {
color: #bac2de;
}
.mocha .hljs-property {
color: #94e2d5;
}
.mocha .hljs-regexp {
color: #f5c2e7;
}
.mocha .hljs-string {
color: #a6e3a1;
}
.mocha .hljs-char.escape_ {
color: #a6e3a1;
}
.mocha .hljs-subst {
color: #a6adc8;
}
.mocha .hljs-symbol {
color: #f2cdcd;
}
.mocha .hljs-variable {
color: #cba6f7;
}
.mocha .hljs-variable.language_ {
color: #cba6f7;
}
.mocha .hljs-variable.constant_ {
color: #fab387;
}
.mocha .hljs-title {
color: #89b4fa;
}
.mocha .hljs-title.class_ {
color: #f9e2af;
}
.mocha .hljs-title.function_ {
color: #89b4fa;
}
.mocha .hljs-params {
color: #cdd6f4;
}
.mocha .hljs-comment {
color: #585b70;
}
.mocha .hljs-doctag {
color: #f38ba8;
}
.mocha .hljs-meta {
color: #fab387;
}
.mocha .hljs-section {
color: #89b4fa;
}
.mocha .hljs-tag {
color: #a6adc8;
}
.mocha .hljs-name {
color: #cba6f7;
}
.mocha .hljs-attr {
color: #89b4fa;
}
.mocha .hljs-attribute {
color: #a6e3a1;
}
.mocha .hljs-bullet {
color: #94e2d5;
}
.mocha .hljs-code {
color: #a6e3a1;
}
.mocha .hljs-emphasis {
color: #f38ba8;
font-style: italic;
}
.mocha .hljs-strong {
color: #f38ba8;
font-weight: bold;
}
.mocha .hljs-formula {
color: #94e2d5;
}
.mocha .hljs-link {
color: #74c7ec;
font-style: italic;
}
.mocha .hljs-quote {
color: #a6e3a1;
font-style: italic;
}
.mocha .hljs-selector-tag {
color: #f9e2af;
}
.mocha .hljs-selector-id {
color: #89b4fa;
}
.mocha .hljs-selector-class {
color: #94e2d5;
}
.mocha .hljs-selector-attr {
color: #cba6f7;
}
.mocha .hljs-selector-pseudo {
color: #94e2d5;
}
.mocha .hljs-template-tag {
color: #f2cdcd;
}
.mocha .hljs-template-variable {
color: #f2cdcd;
}
.mocha .hljs-addition {
color: #a6e3a1;
background: rgba(166, 227, 161, 0.15);
}
.mocha .hljs-deletion {
color: #f38ba8;
background: rgba(243, 139, 168, 0.15);
}
.mocha code {
color: #cdd6f4;
background: #181825;
}
.mocha blockquote blockquote {
border-top: 0.1em solid #585b70;
border-bottom: 0.1em solid #585b70;
}
.mocha hr {
color: #585b70;
}
.mocha del {
color: #9399b2;
}
.mocha .ace_gutter {
color: #7f849c;
background: #181825;
}
.mocha .ace_gutter-active-line.ace_gutter-cell {
color: #f5c2e7;
background: #181825;
}
.macchiato.hljs {
color: #cad3f5;
background: #24273a;
}
.macchiato .hljs-keyword {
color: #c6a0f6;
}
.macchiato .hljs-built_in {
color: #ed8796;
}
.macchiato .hljs-type {
color: #eed49f;
}
.macchiato .hljs-literal {
color: #f5a97f;
}
.macchiato .hljs-number {
color: #f5a97f;
}
.macchiato .hljs-operator {
color: #8bd5ca;
}
.macchiato .hljs-punctuation {
color: #b8c0e0;
}
.macchiato .hljs-property {
color: #8bd5ca;
}
.macchiato .hljs-regexp {
color: #f5bde6;
}
.macchiato .hljs-string {
color: #a6da95;
}
.macchiato .hljs-char.escape_ {
color: #a6da95;
}
.macchiato .hljs-subst {
color: #a5adcb;
}
.macchiato .hljs-symbol {
color: #f0c6c6;
}
.macchiato .hljs-variable {
color: #c6a0f6;
}
.macchiato .hljs-variable.language_ {
color: #c6a0f6;
}
.macchiato .hljs-variable.constant_ {
color: #f5a97f;
}
.macchiato .hljs-title {
color: #8aadf4;
}
.macchiato .hljs-title.class_ {
color: #eed49f;
}
.macchiato .hljs-title.function_ {
color: #8aadf4;
}
.macchiato .hljs-params {
color: #cad3f5;
}
.macchiato .hljs-comment {
color: #5b6078;
}
.macchiato .hljs-doctag {
color: #ed8796;
}
.macchiato .hljs-meta {
color: #f5a97f;
}
.macchiato .hljs-section {
color: #8aadf4;
}
.macchiato .hljs-tag {
color: #a5adcb;
}
.macchiato .hljs-name {
color: #c6a0f6;
}
.macchiato .hljs-attr {
color: #8aadf4;
}
.macchiato .hljs-attribute {
color: #a6da95;
}
.macchiato .hljs-bullet {
color: #8bd5ca;
}
.macchiato .hljs-code {
color: #a6da95;
}
.macchiato .hljs-emphasis {
color: #ed8796;
font-style: italic;
}
.macchiato .hljs-strong {
color: #ed8796;
font-weight: bold;
}
.macchiato .hljs-formula {
color: #8bd5ca;
}
.macchiato .hljs-link {
color: #7dc4e4;
font-style: italic;
}
.macchiato .hljs-quote {
color: #a6da95;
font-style: italic;
}
.macchiato .hljs-selector-tag {
color: #eed49f;
}
.macchiato .hljs-selector-id {
color: #8aadf4;
}
.macchiato .hljs-selector-class {
color: #8bd5ca;
}
.macchiato .hljs-selector-attr {
color: #c6a0f6;
}
.macchiato .hljs-selector-pseudo {
color: #8bd5ca;
}
.macchiato .hljs-template-tag {
color: #f0c6c6;
}
.macchiato .hljs-template-variable {
color: #f0c6c6;
}
.macchiato .hljs-addition {
color: #a6da95;
background: rgba(166, 218, 149, 0.15);
}
.macchiato .hljs-deletion {
color: #ed8796;
background: rgba(237, 135, 150, 0.15);
}
.macchiato code {
color: #cad3f5;
background: #1e2030;
}
.macchiato blockquote blockquote {
border-top: 0.1em solid #5b6078;
border-bottom: 0.1em solid #5b6078;
}
.macchiato hr {
color: #5b6078;
}
.macchiato del {
color: #939ab7;
}
.macchiato .ace_gutter {
color: #8087a2;
background: #1e2030;
}
.macchiato .ace_gutter-active-line.ace_gutter-cell {
color: #f5bde6;
background: #1e2030;
}
.frappe.hljs {
color: #c6d0f5;
background: #303446;
}
.frappe .hljs-keyword {
color: #ca9ee6;
}
.frappe .hljs-built_in {
color: #e78284;
}
.frappe .hljs-type {
color: #e5c890;
}
.frappe .hljs-literal {
color: #ef9f76;
}
.frappe .hljs-number {
color: #ef9f76;
}
.frappe .hljs-operator {
color: #81c8be;
}
.frappe .hljs-punctuation {
color: #b5bfe2;
}
.frappe .hljs-property {
color: #81c8be;
}
.frappe .hljs-regexp {
color: #f4b8e4;
}
.frappe .hljs-string {
color: #a6d189;
}
.frappe .hljs-char.escape_ {
color: #a6d189;
}
.frappe .hljs-subst {
color: #a5adce;
}
.frappe .hljs-symbol {
color: #eebebe;
}
.frappe .hljs-variable {
color: #ca9ee6;
}
.frappe .hljs-variable.language_ {
color: #ca9ee6;
}
.frappe .hljs-variable.constant_ {
color: #ef9f76;
}
.frappe .hljs-title {
color: #8caaee;
}
.frappe .hljs-title.class_ {
color: #e5c890;
}
.frappe .hljs-title.function_ {
color: #8caaee;
}
.frappe .hljs-params {
color: #c6d0f5;
}
.frappe .hljs-comment {
color: #626880;
}
.frappe .hljs-doctag {
color: #e78284;
}
.frappe .hljs-meta {
color: #ef9f76;
}
.frappe .hljs-section {
color: #8caaee;
}
.frappe .hljs-tag {
color: #a5adce;
}
.frappe .hljs-name {
color: #ca9ee6;
}
.frappe .hljs-attr {
color: #8caaee;
}
.frappe .hljs-attribute {
color: #a6d189;
}
.frappe .hljs-bullet {
color: #81c8be;
}
.frappe .hljs-code {
color: #a6d189;
}
.frappe .hljs-emphasis {
color: #e78284;
font-style: italic;
}
.frappe .hljs-strong {
color: #e78284;
font-weight: bold;
}
.frappe .hljs-formula {
color: #81c8be;
}
.frappe .hljs-link {
color: #85c1dc;
font-style: italic;
}
.frappe .hljs-quote {
color: #a6d189;
font-style: italic;
}
.frappe .hljs-selector-tag {
color: #e5c890;
}
.frappe .hljs-selector-id {
color: #8caaee;
}
.frappe .hljs-selector-class {
color: #81c8be;
}
.frappe .hljs-selector-attr {
color: #ca9ee6;
}
.frappe .hljs-selector-pseudo {
color: #81c8be;
}
.frappe .hljs-template-tag {
color: #eebebe;
}
.frappe .hljs-template-variable {
color: #eebebe;
}
.frappe .hljs-addition {
color: #a6d189;
background: rgba(166, 209, 137, 0.15);
}
.frappe .hljs-deletion {
color: #e78284;
background: rgba(231, 130, 132, 0.15);
}
.frappe code {
color: #c6d0f5;
background: #292c3c;
}
.frappe blockquote blockquote {
border-top: 0.1em solid #626880;
border-bottom: 0.1em solid #626880;
}
.frappe hr {
color: #626880;
}
.frappe del {
color: #949cbb;
}
.frappe .ace_gutter {
color: #838ba7;
background: #292c3c;
}
.frappe .ace_gutter-active-line.ace_gutter-cell {
color: #f4b8e4;
background: #292c3c;
}
.latte.hljs {
color: #4c4f69;
background: #eff1f5;
}
.latte .hljs-keyword {
color: #8839ef;
}
.latte .hljs-built_in {
color: #d20f39;
}
.latte .hljs-type {
color: #df8e1d;
}
.latte .hljs-literal {
color: #fe640b;
}
.latte .hljs-number {
color: #fe640b;
}
.latte .hljs-operator {
color: #179299;
}
.latte .hljs-punctuation {
color: #5c5f77;
}
.latte .hljs-property {
color: #179299;
}
.latte .hljs-regexp {
color: #ea76cb;
}
.latte .hljs-string {
color: #40a02b;
}
.latte .hljs-char.escape_ {
color: #40a02b;
}
.latte .hljs-subst {
color: #6c6f85;
}
.latte .hljs-symbol {
color: #dd7878;
}
.latte .hljs-variable {
color: #8839ef;
}
.latte .hljs-variable.language_ {
color: #8839ef;
}
.latte .hljs-variable.constant_ {
color: #fe640b;
}
.latte .hljs-title {
color: #1e66f5;
}
.latte .hljs-title.class_ {
color: #df8e1d;
}
.latte .hljs-title.function_ {
color: #1e66f5;
}
.latte .hljs-params {
color: #4c4f69;
}
.latte .hljs-comment {
color: #acb0be;
}
.latte .hljs-doctag {
color: #d20f39;
}
.latte .hljs-meta {
color: #fe640b;
}
.latte .hljs-section {
color: #1e66f5;
}
.latte .hljs-tag {
color: #6c6f85;
}
.latte .hljs-name {
color: #8839ef;
}
.latte .hljs-attr {
color: #1e66f5;
}
.latte .hljs-attribute {
color: #40a02b;
}
.latte .hljs-bullet {
color: #179299;
}
.latte .hljs-code {
color: #40a02b;
}
.latte .hljs-emphasis {
color: #d20f39;
font-style: italic;
}
.latte .hljs-strong {
color: #d20f39;
font-weight: bold;
}
.latte .hljs-formula {
color: #179299;
}
.latte .hljs-link {
color: #209fb5;
font-style: italic;
}
.latte .hljs-quote {
color: #40a02b;
font-style: italic;
}
.latte .hljs-selector-tag {
color: #df8e1d;
}
.latte .hljs-selector-id {
color: #1e66f5;
}
.latte .hljs-selector-class {
color: #179299;
}
.latte .hljs-selector-attr {
color: #8839ef;
}
.latte .hljs-selector-pseudo {
color: #179299;
}
.latte .hljs-template-tag {
color: #dd7878;
}
.latte .hljs-template-variable {
color: #dd7878;
}
.latte .hljs-addition {
color: #40a02b;
background: rgba(64, 160, 43, 0.15);
}
.latte .hljs-deletion {
color: #d20f39;
background: rgba(210, 15, 57, 0.15);
}
.latte code {
color: #4c4f69;
background: #e6e9ef;
}
.latte blockquote blockquote {
border-top: 0.1em solid #acb0be;
border-bottom: 0.1em solid #acb0be;
}
.latte hr {
color: #acb0be;
}
.latte del {
color: #7c7f93;
}
.latte .ace_gutter {
color: #8c8fa1;
background: #e6e9ef;
}
.latte .ace_gutter-active-line.ace_gutter-cell {
color: #ea76cb;
background: #e6e9ef;
}
.mocha {
--bg: #1e1e2e;
--fg: #cdd6f4;
--sidebar-bg: #181825;
--sidebar-fg: #cdd6f4;
--sidebar-non-existant: #6c7086;
--sidebar-active: #89b4fa;
--sidebar-spacer: #6c7086;
--scrollbar: #6c7086;
--icons: #6c7086;
--icons-hover: #7f849c;
--links: #89b4fa;
--inline-code-color: #fab387;
--theme-popup-bg: #181825;
--theme-popup-border: #6c7086;
--theme-hover: #6c7086;
--quote-bg: #181825;
--quote-border: #11111b;
--table-border-color: #11111b;
--table-header-bg: #181825;
--table-alternate-bg: #181825;
--searchbar-border-color: #11111b;
--searchbar-bg: #181825;
--searchbar-fg: #cdd6f4;
--searchbar-shadow-color: #11111b;
--searchresults-header-fg: #cdd6f4;
--searchresults-border-color: #11111b;
--searchresults-li-bg: #1e1e2e;
--search-mark-bg: #fab387;
--warning-border: #fab387;
}
.macchiato {
--bg: #24273a;
--fg: #cad3f5;
--sidebar-bg: #1e2030;
--sidebar-fg: #cad3f5;
--sidebar-non-existant: #6e738d;
--sidebar-active: #8aadf4;
--sidebar-spacer: #6e738d;
--scrollbar: #6e738d;
--icons: #6e738d;
--icons-hover: #8087a2;
--links: #8aadf4;
--inline-code-color: #f5a97f;
--theme-popup-bg: #1e2030;
--theme-popup-border: #6e738d;
--theme-hover: #6e738d;
--quote-bg: #1e2030;
--quote-border: #181926;
--table-border-color: #181926;
--table-header-bg: #1e2030;
--table-alternate-bg: #1e2030;
--searchbar-border-color: #181926;
--searchbar-bg: #1e2030;
--searchbar-fg: #cad3f5;
--searchbar-shadow-color: #181926;
--searchresults-header-fg: #cad3f5;
--searchresults-border-color: #181926;
--searchresults-li-bg: #24273a;
--search-mark-bg: #f5a97f;
--warning-border: #f5a97f;
}
.frappe {
--bg: #303446;
--fg: #c6d0f5;
--sidebar-bg: #292c3c;
--sidebar-fg: #c6d0f5;
--sidebar-non-existant: #737994;
--sidebar-active: #8caaee;
--sidebar-spacer: #737994;
--scrollbar: #737994;
--icons: #737994;
--icons-hover: #838ba7;
--links: #8caaee;
--inline-code-color: #ef9f76;
--theme-popup-bg: #292c3c;
--theme-popup-border: #737994;
--theme-hover: #737994;
--quote-bg: #292c3c;
--quote-border: #232634;
--table-border-color: #232634;
--table-header-bg: #292c3c;
--table-alternate-bg: #292c3c;
--searchbar-border-color: #232634;
--searchbar-bg: #292c3c;
--searchbar-fg: #c6d0f5;
--searchbar-shadow-color: #232634;
--searchresults-header-fg: #c6d0f5;
--searchresults-border-color: #232634;
--searchresults-li-bg: #303446;
--search-mark-bg: #ef9f76;
--warning-border: #ef9f76;
}
.latte {
--bg: #eff1f5;
--fg: #4c4f69;
--sidebar-bg: #e6e9ef;
--sidebar-fg: #4c4f69;
--sidebar-non-existant: #9ca0b0;
--sidebar-active: #1e66f5;
--sidebar-spacer: #9ca0b0;
--scrollbar: #9ca0b0;
--icons: #9ca0b0;
--icons-hover: #8c8fa1;
--links: #1e66f5;
--inline-code-color: #fe640b;
--theme-popup-bg: #e6e9ef;
--theme-popup-border: #9ca0b0;
--theme-hover: #9ca0b0;
--quote-bg: #e6e9ef;
--quote-border: #dce0e8;
--table-border-color: #dce0e8;
--table-header-bg: #e6e9ef;
--table-alternate-bg: #e6e9ef;
--searchbar-border-color: #dce0e8;
--searchbar-bg: #e6e9ef;
--searchbar-fg: #4c4f69;
--searchbar-shadow-color: #dce0e8;
--searchresults-header-fg: #4c4f69;
--searchresults-border-color: #dce0e8;
--searchresults-li-bg: #eff1f5;
--search-mark-bg: #fe640b;
--warning-border: #fe640b;
}

349
docs/theme/index.hbs vendored
View File

@ -1,349 +0,0 @@
<!DOCTYPE HTML>
<html lang="{{ language }}" class="{{ default_theme }}" dir="{{ text_direction }}">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>{{ title }}</title>
{{#if is_print }}
<meta name="robots" content="noindex">
{{/if}}
{{#if base_url}}
<base href="{{ base_url }}">
{{/if}}
<!-- Custom HTML head -->
{{> head}}
<meta name="description" content="{{ description }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
{{#if favicon_svg}}
<link rel="icon" href="{{ path_to_root }}favicon.svg">
{{/if}}
{{#if favicon_png}}
<link rel="shortcut icon" href="{{ path_to_root }}favicon.png">
{{/if}}
<link rel="stylesheet" href="{{ path_to_root }}css/variables.css">
<link rel="stylesheet" href="{{ path_to_root }}css/general.css">
<link rel="stylesheet" href="{{ path_to_root }}css/chrome.css">
{{#if print_enable}}
<link rel="stylesheet" href="{{ path_to_root }}css/print.css" media="print">
{{/if}}
<!-- Fonts -->
<link rel="stylesheet" href="{{ path_to_root }}FontAwesome/css/font-awesome.css">
{{#if copy_fonts}}
<link rel="stylesheet" href="{{ path_to_root }}fonts/fonts.css">
{{/if}}
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" href="{{ path_to_root }}highlight.css">
<link rel="stylesheet" href="{{ path_to_root }}tomorrow-night.css">
<link rel="stylesheet" href="{{ path_to_root }}ayu-highlight.css">
<!-- Custom theme stylesheets -->
{{#each additional_css}}
<link rel="stylesheet" href="{{ ../path_to_root }}{{ this }}">
{{/each}}
{{#if mathjax_support}}
<!-- MathJax -->
<script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
{{/if}}
</head>
<body class="sidebar-visible no-js">
<div id="body-container">
<!-- Provide site root to javascript -->
<script>
var path_to_root = "{{ path_to_root }}";
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}";
</script>
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
var theme = localStorage.getItem('mdbook-theme');
var sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
var theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
var html = document.querySelector('html');
html.classList.remove('{{ default_theme }}')
html.classList.add(theme);
var body = document.querySelector('body');
body.classList.remove('no-js')
body.classList.add('js');
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
var body = document.querySelector('body');
var sidebar = null;
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
}
sidebar_toggle.checked = sidebar === 'visible';
body.classList.remove('sidebar-visible');
body.classList.add("sidebar-" + sidebar);
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<div class="sidebar-scrollbox">
{{#toc}}{{/toc}}
</div>
<div id="sidebar-resize-handle" class="sidebar-resize-handle"></div>
</nav>
<!-- Track and set sidebar scroll position -->
<script>
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
sidebarScrollbox.addEventListener('click', function(e) {
if (e.target.tagName === 'A') {
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
}
}, { passive: true });
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
sessionStorage.removeItem('sidebar-scroll');
if (sidebarScrollTop) {
// preserve sidebar scroll position when navigating via links within sidebar
sidebarScrollbox.scrollTop = sidebarScrollTop;
} else {
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
var activeSection = document.querySelector('#sidebar .active');
if (activeSection) {
activeSection.scrollIntoView({ block: 'center' });
}
}
</script>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
{{> header}}
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="mocha">Mocha</button></li>
<li role="none"><button role="menuitem" class="theme" id="macchiato">Macchiato</button></li>
<li role="none"><button role="menuitem" class="theme" id="frappe">Frappé</button></li>
<li role="none"><button role="menuitem" class="theme" id="latte">Latte</button></li>
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
{{#if search_enabled}}
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
{{/if}}
</div>
<h1 class="menu-title">{{ book_title }}</h1>
<div class="right-buttons">
{{#if print_enable}}
<a href="{{ path_to_root }}print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
{{/if}}
{{#if git_repository_url}}
<a href="{{git_repository_url}}" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa {{git_repository_icon}}"></i>
</a>
{{/if}}
{{#if git_repository_edit_url}}
<a href="{{git_repository_edit_url}}" title="Suggest an edit" aria-label="Suggest an edit">
<i id="git-edit-button" class="fa fa-edit"></i>
</a>
{{/if}}
</div>
</div>
{{#if search_enabled}}
<div id="search-wrapper" class="hidden">
<form id="searchbar-outer" class="searchbar-outer">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
</form>
<div id="searchresults-outer" class="searchresults-outer hidden">
<div id="searchresults-header" class="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
</div>
{{/if}}
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="content" class="content">
<main>
{{{ content }}}
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
{{#previous}}
<a rel="prev" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
{{/previous}}
{{#next}}
<a rel="next prefetch" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
{{/next}}
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
{{#previous}}
<a rel="prev" href="{{ path_to_root }}{{link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
{{/previous}}
{{#next}}
<a rel="next prefetch" href="{{ path_to_root }}{{link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
{{/next}}
</nav>
</div>
{{#if live_reload_endpoint}}
<!-- Livereload script (if served using the cli tool) -->
<script>
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsAddress = wsProtocol + "//" + location.host + "/" + "{{{live_reload_endpoint}}}";
const socket = new WebSocket(wsAddress);
socket.onmessage = function (event) {
if (event.data === "reload") {
socket.close();
location.reload();
}
};
window.onbeforeunload = function() {
socket.close();
}
</script>
{{/if}}
{{#if google_analytics}}
<!-- Google Analytics Tag -->
<script>
var localAddrs = ["localhost", "127.0.0.1", ""];
// make sure we don't activate google analytics if the developer is
// inspecting the book locally...
if (localAddrs.indexOf(document.location.hostname) === -1) {
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', '{{google_analytics}}', 'auto');
ga('send', 'pageview');
}
</script>
{{/if}}
{{#if playground_line_numbers}}
<script>
window.playground_line_numbers = true;
</script>
{{/if}}
{{#if playground_copyable}}
<script>
window.playground_copyable = true;
</script>
{{/if}}
{{#if playground_js}}
<script src="{{ path_to_root }}ace.js"></script>
<script src="{{ path_to_root }}editor.js"></script>
<script src="{{ path_to_root }}mode-rust.js"></script>
<script src="{{ path_to_root }}theme-dawn.js"></script>
<script src="{{ path_to_root }}theme-tomorrow_night.js"></script>
{{/if}}
{{#if search_js}}
<script src="{{ path_to_root }}elasticlunr.min.js"></script>
<script src="{{ path_to_root }}mark.min.js"></script>
<script src="{{ path_to_root }}searcher.js"></script>
{{/if}}
<script src="{{ path_to_root }}clipboard.min.js"></script>
<script src="{{ path_to_root }}highlight.js"></script>
<script src="{{ path_to_root }}book.js"></script>
<!-- Custom JS scripts -->
{{#each additional_js}}
<script src="{{ ../path_to_root }}{{this}}"></script>
{{/each}}
{{#if is_print}}
{{#if mathjax_support}}
<script>
window.addEventListener('load', function() {
MathJax.Hub.Register.StartupHook('End', function() {
window.setTimeout(window.print, 100);
});
});
</script>
{{else}}
<script>
window.addEventListener('load', function() {
window.setTimeout(window.print, 100);
});
</script>
{{/if}}
{{/if}}
</div>
</body>
</html>

View File

@ -39,4 +39,21 @@ This example uses a template-style footer, which lets you place some text on the
A few template variables, such as `current_slide` and `total_slides` can be used to reference properties of the
presentation.
[![asciicast](https://asciinema.org/a/DLpBDpCbEp5pSrNZ2Vh4mmIY1.svg)](https://asciinema.org/a/DLpBDpCbEp5pSrNZ2Vh4mmIY1)
![](../docs/src/assets/example-footer.png)
# Columns
[Source](/examples/columns.md)
This example shows how column layouts and pauses interact with each other. Note that the image shows up as pixels
because asciinema doesn't support these and it will otherwise look like a normal image if your terminal supports images.
[![asciicast](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp.svg)](https://asciinema.org/a/x2tTDt0BIesvOXeal3UpdzMHp)
# Speaker notes
[Source](/examples/speaker-notes.md)
This example shows how to use speaker notes.
[![asciicast](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J.svg)](https://asciinema.org/a/ETusvlmHuHrcLKzwa0CMQRX2J)

25
examples/columns.md Normal file
View File

@ -0,0 +1,25 @@
# Columns and pauses
Columns and pauses can interact with each other in useful ways:
<!-- pause -->
<!-- column_layout: [1, 1] -->
<!-- column: 1 -->
![](../examples/doge.png)
After this pause, the text on the left will show up
<!-- pause -->
<!-- column: 0 -->
This is useful for various things:
<!-- incremental_lists: true -->
* Lorem.
* Ipsum.
* Etcetera.

View File

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

View File

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

43
examples/speaker-notes.md Normal file
View File

@ -0,0 +1,43 @@
Speaker Notes
===
`presenterm` supports speaker notes.
You can use the following HTML comment throughout your presentation markdown file:
```markdown
<!-- speaker_note: Your speaker note goes here. -->
```
<!-- speaker_note: This is a speaker note from slide 1. -->
And you can run a separate instance of `presenterm` to view them.
<!-- speaker_note: You can use multiple speaker notes within each slide and interleave them with other markdown. -->
<!-- end_slide -->
Usage
===
Run the following two commands in separate terminals.
<!-- speaker_note: This is a speaker note from slide 2. -->
The `--publish-speaker-notes` argument will render your actual presentation as normal, without speaker notes:
```
presenterm --publish-speaker-notes examples/speaker-notes.md
```
The `--listen-speaker-notes` argument will render only the speaker notes for the current slide being shown in the actual
presentation:
```
presenterm --listen-speaker-notes examples/speaker-notes.md
```
<!-- speaker_note: Demonstrate changing slides in the actual presentation. -->
As you change slides in your actual presentation, the speaker notes presentation slide will automatically navigate to the correct slide.
<!-- speaker_note: Isn't that cool? -->

View File

@ -1,73 +1,110 @@
---
bash:
filename: script.sh
commands:
- ["bash", "script.sh"]
- ["bash", "$pwd/script.sh"]
hidden_line_prefix: "/// "
c++:
filename: snippet.cpp
commands:
- ["g++", "-std=c++20", "snippet.cpp", "-o", "$pwd/snippet"]
- ["g++", "-std=c++20", "-fdiagnostics-color=always", "$pwd/snippet.cpp", "-o", "$pwd/snippet"]
- ["$pwd/snippet"]
hidden_line_prefix: "/// "
c:
filename: snippet.c
commands:
- ["gcc", "snippet.c", "-o", "$pwd/snippet"]
- ["gcc", "$pwd/snippet.c", "-fdiagnostics-color=always", "-o", "$pwd/snippet"]
- ["$pwd/snippet"]
hidden_line_prefix: "/// "
fish:
filename: script.fish
commands:
- ["fish", "script.fish"]
- ["fish", "$pwd/script.fish"]
hidden_line_prefix: "/// "
go:
filename: snippet.go
environment:
GO11MODULE: off
commands:
- ["go", "run", "snippet.go"]
- ["go", "run", "$pwd/snippet.go"]
hidden_line_prefix: "/// "
haskell:
filename: snippet.hs
commands:
- ["runhaskell", "-w", "$pwd/snippet.hs"]
java:
filename: Snippet.java
commands:
- ["java", "Snippet.java"]
- ["java", "$pwd/Snippet.java"]
hidden_line_prefix: "/// "
js:
filename: snippet.js
commands:
- ["node", "snippet.js"]
- ["node", "$pwd/snippet.js"]
hidden_line_prefix: "/// "
julia:
filename: snippet.jl
commands:
- ["julia", "$pwd/snippet.jl"]
hidden_line_prefix: "/// "
kotlin:
filename: snippet.kts
commands:
- ["kotlinc", "-script", "script.kts"]
- ["kotlinc", "-script", "$pwd/snippet.kts"]
hidden_line_prefix: "/// "
lua:
filename: snippet.lua
commands:
- ["lua", "snippet.lua"]
- ["lua", "$pwd/snippet.lua"]
nushell:
filename: snippet.nu
commands:
- ["nu", "snippet.nu"]
- ["nu", "$pwd/snippet.nu"]
perl:
filename: snippet.pl
commands:
- ["perl", "snippet.pl"]
- ["perl", "$pwd/snippet.pl"]
php:
filename: snippet.php
commands:
- ["php", "-f", "$pwd/snippet.php"]
hidden_line_prefix: "/// "
python:
filename: snippet.py
commands:
- ["python", "-u", "snippet.py"]
- ["python", "-u", "$pwd/snippet.py"]
hidden_line_prefix: "/// "
r:
filename: snippet.R
commands:
- ["Rscript", "$pwd/snippet.R"]
ruby:
filename: snippet.rb
commands:
- ["ruby", "snippet.rb"]
- ["ruby", "$pwd/snippet.rb"]
rust-script:
filename: snippet.rs
commands:
- ["rust-script", "snippet.rs"]
- ["rust-script", "--debug", "$pwd/snippet.rs"]
hidden_line_prefix: "# "
rust:
filename: snippet.rs
commands:
- ["rustc", "--crate-name", "presenterm_snippet", "snippet.rs", "-o", "$pwd/snippet"]
- ["rustc", "--crate-name", "presenterm_snippet", "$pwd/snippet.rs", "-o", "$pwd/snippet", "--color", "always"]
- ["$pwd/snippet"]
hidden_line_prefix: "# "
sh:
filename: script.sh
commands:
- ["sh", "script.sh"]
- ["sh", "$pwd/script.sh"]
hidden_line_prefix: "/// "
zsh:
filename: script.sh
commands:
- ["zsh", "script.sh"]
- ["zsh", "$pwd/script.sh"]
hidden_line_prefix: "/// "
csharp:
filename: snippet.cs
commands:
- ["dotnet-script", "$pwd/snippet.cs"]
hidden_line_prefix: "/// "

View File

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

View File

@ -1,15 +1,18 @@
//! Code execution.
use super::snippet::{SnippetExec, SnippetRepr};
use crate::{
custom::LanguageSnippetExecutionConfig,
markdown::elements::{Snippet, SnippetLanguage},
code::snippet::{Snippet, SnippetLanguage},
config::LanguageSnippetExecutionConfig,
};
use once_cell::sync::Lazy;
use os_pipe::PipeReader;
use std::{
collections::{BTreeMap, HashMap},
fmt::{self, Debug},
fs::File,
io::{self, BufRead, BufReader, Write},
io::{self, BufRead, BufReader, Read, Write},
path::{Path, PathBuf},
process::{self, Child, Stdio},
sync::{Arc, Mutex},
thread,
@ -17,17 +20,18 @@ use std::{
use tempfile::TempDir;
static EXECUTORS: Lazy<BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>> =
Lazy::new(|| serde_yaml::from_slice(include_bytes!("../executors.yaml")).expect("executors.yaml is broken"));
Lazy::new(|| serde_yaml::from_slice(include_bytes!("../../executors.yaml")).expect("executors.yaml is broken"));
/// Allows executing code.
#[derive(Debug)]
pub struct SnippetExecutor {
executors: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
cwd: PathBuf,
}
impl SnippetExecutor {
pub fn new(
custom_executors: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
cwd: PathBuf,
) -> Result<Self, InvalidSnippetConfig> {
let mut executors = EXECUTORS.clone();
executors.extend(custom_executors);
@ -44,44 +48,95 @@ impl SnippetExecutor {
}
}
}
Ok(Self { executors })
Ok(Self { executors, cwd })
}
pub(crate) fn is_execution_supported(&self, language: &SnippetLanguage) -> bool {
self.executors.contains_key(language)
}
/// Execute a piece of code.
pub(crate) fn execute(&self, code: &Snippet) -> Result<ExecutionHandle, CodeExecuteError> {
if !code.attributes.execute {
return Err(CodeExecuteError::NotExecutableCode);
}
let Some(config) = self.executors.get(&code.language) else {
return Err(CodeExecuteError::UnsupportedExecution);
/// Execute a piece of code asynchronously.
pub(crate) fn execute_async(&self, snippet: &Snippet) -> Result<ExecutionHandle, CodeExecuteError> {
let config = self.language_config(snippet)?;
let script_dir = Self::write_snippet(snippet, config)?;
let state: Arc<Mutex<ExecutionState>> = Default::default();
let output_type = match snippet.attributes.representation {
SnippetRepr::Image => OutputType::Binary,
_ => OutputType::Lines,
};
Self::execute_lang(config, code.executable_contents().as_bytes())
let reader_handle = CommandsRunner::spawn(
state.clone(),
script_dir,
config.commands.clone(),
config.environment.clone(),
self.cwd.to_path_buf(),
output_type,
);
let handle = ExecutionHandle { state, reader_handle };
Ok(handle)
}
fn execute_lang(config: &LanguageSnippetExecutionConfig, code: &[u8]) -> Result<ExecutionHandle, CodeExecuteError> {
/// Executes a piece of code synchronously.
pub(crate) fn execute_sync(&self, snippet: &Snippet) -> Result<(), CodeExecuteError> {
let config = self.language_config(snippet)?;
let script_dir = Self::write_snippet(snippet, config)?;
let script_dir_path = script_dir.path().to_string_lossy();
for mut commands in config.commands.clone() {
for command in &mut commands {
*command = command.replace("$pwd", &script_dir_path);
}
let (command, args) = commands.split_first().expect("no commands");
let child = process::Command::new(command)
.args(args)
.envs(&config.environment)
.current_dir(&self.cwd)
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CodeExecuteError::SpawnProcess(command.clone(), e))?;
let output = child.wait_with_output().map_err(CodeExecuteError::Waiting)?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr).to_string();
return Err(CodeExecuteError::Running(error));
}
}
Ok(())
}
pub(crate) fn hidden_line_prefix(&self, language: &SnippetLanguage) -> Option<&str> {
self.executors.get(language).and_then(|lang| lang.hidden_line_prefix.as_deref())
}
fn language_config(&self, snippet: &Snippet) -> Result<&LanguageSnippetExecutionConfig, CodeExecuteError> {
let is_executable = !matches!(snippet.attributes.execution, SnippetExec::None);
let is_exec_replace = matches!(snippet.attributes.representation, SnippetRepr::ExecReplace);
if !is_executable && !is_exec_replace {
return Err(CodeExecuteError::NotExecutableCode);
}
self.executors.get(&snippet.language).ok_or(CodeExecuteError::UnsupportedExecution)
}
fn write_snippet(snippet: &Snippet, config: &LanguageSnippetExecutionConfig) -> Result<TempDir, CodeExecuteError> {
let hide_prefix = config.hidden_line_prefix.as_deref();
let code = snippet.executable_contents(hide_prefix);
let script_dir =
tempfile::Builder::default().prefix(".presenterm").tempdir().map_err(CodeExecuteError::TempDir)?;
let snippet_path = script_dir.path().join(&config.filename);
{
let mut snippet_file = File::create(snippet_path).map_err(CodeExecuteError::TempDir)?;
snippet_file.write_all(code).map_err(CodeExecuteError::TempDir)?;
}
let state: Arc<Mutex<ExecutionState>> = Default::default();
let reader_handle =
CommandsRunner::spawn(state.clone(), script_dir, config.commands.clone(), config.environment.clone());
let handle = ExecutionHandle { state, reader_handle };
Ok(handle)
let mut snippet_file = File::create(snippet_path).map_err(CodeExecuteError::TempDir)?;
snippet_file.write_all(code.as_bytes()).map_err(CodeExecuteError::TempDir)?;
Ok(script_dir)
}
}
impl Default for SnippetExecutor {
fn default() -> Self {
Self::new(Default::default()).expect("initialization failed")
Self::new(Default::default(), PathBuf::from("./")).expect("initialization failed")
}
}
impl Debug for SnippetExecutor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SnippetExecutor {{ .. }}")
}
}
@ -107,6 +162,12 @@ pub(crate) enum CodeExecuteError {
#[error("error creating pipe: {0}")]
Pipe(io::Error),
#[error("error waiting for process to run: {0}")]
Waiting(io::Error),
#[error("error running process: {0}")]
Running(String),
}
/// A handle for the execution of a piece of code.
@ -129,15 +190,17 @@ impl CommandsRunner {
script_directory: TempDir,
commands: Vec<Vec<String>>,
env: HashMap<String, String>,
cwd: PathBuf,
output_type: OutputType,
) -> thread::JoinHandle<()> {
let reader = Self { state, script_directory };
thread::spawn(|| reader.run(commands, env))
thread::spawn(move || reader.run(commands, env, cwd, output_type))
}
fn run(self, commands: Vec<Vec<String>>, env: HashMap<String, String>) {
fn run(self, commands: Vec<Vec<String>>, env: HashMap<String, String>, cwd: PathBuf, output_type: OutputType) {
let mut last_result = true;
for command in commands {
last_result = self.run_command(command, &env);
last_result = self.run_command(command, &env, &cwd, output_type);
if !last_result {
break;
}
@ -149,17 +212,23 @@ impl CommandsRunner {
self.state.lock().unwrap().status = status;
}
fn run_command(&self, command: Vec<String>, env: &HashMap<String, String>) -> bool {
let (mut child, reader) = match self.launch_process(command, env) {
fn run_command(
&self,
command: Vec<String>,
env: &HashMap<String, String>,
cwd: &Path,
output_type: OutputType,
) -> bool {
let (mut child, reader) = match self.launch_process(command, env, cwd) {
Ok(inner) => inner,
Err(e) => {
let mut state = self.state.lock().unwrap();
state.status = ProcessStatus::Failure;
state.output.push(e.to_string());
state.output.extend(e.to_string().into_bytes());
return false;
}
};
let _ = Self::process_output(self.state.clone(), reader);
let _ = Self::process_output(self.state.clone(), reader, output_type);
match child.wait() {
Ok(code) => code.success(),
@ -171,17 +240,19 @@ impl CommandsRunner {
&self,
mut commands: Vec<String>,
env: &HashMap<String, String>,
cwd: &Path,
) -> Result<(Child, PipeReader), CodeExecuteError> {
let (reader, writer) = os_pipe::pipe().map_err(CodeExecuteError::Pipe)?;
let writer_clone = writer.try_clone().map_err(CodeExecuteError::Pipe)?;
let script_dir = self.script_directory.path().to_string_lossy();
for command in &mut commands {
*command = command.replace("$pwd", &self.script_directory.path().to_string_lossy());
*command = command.replace("$pwd", &script_dir);
}
let (command, args) = commands.split_first().expect("no commands");
let child = process::Command::new(command)
.args(args)
.envs(env)
.current_dir(self.script_directory.path())
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(writer)
.stderr(writer_clone)
@ -190,24 +261,41 @@ impl CommandsRunner {
Ok((child, reader))
}
fn process_output(state: Arc<Mutex<ExecutionState>>, reader: os_pipe::PipeReader) -> io::Result<()> {
let reader = BufReader::new(reader);
for line in reader.lines() {
let mut line = line?;
if line.contains('\t') {
line = line.replace('\t', " ");
fn process_output(
state: Arc<Mutex<ExecutionState>>,
mut reader: os_pipe::PipeReader,
output_type: OutputType,
) -> io::Result<()> {
match output_type {
OutputType::Lines => {
let reader = BufReader::new(reader);
for line in reader.lines() {
let mut state = state.lock().unwrap();
state.output.extend(line?.into_bytes());
state.output.push(b'\n');
}
Ok(())
}
OutputType::Binary => {
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
state.lock().unwrap().output.extend(buffer);
Ok(())
}
// TODO: consider not locking per line...
state.lock().unwrap().output.push(line);
}
Ok(())
}
}
#[derive(Clone, Copy)]
enum OutputType {
Lines,
Binary,
}
/// The state of the execution of a process.
#[derive(Clone, Default, Debug)]
pub(crate) struct ExecutionState {
pub(crate) output: Vec<String>,
pub(crate) output: Vec<u8>,
pub(crate) status: ProcessStatus,
}
@ -230,7 +318,7 @@ impl ProcessStatus {
#[cfg(test)]
mod test {
use super::*;
use crate::markdown::elements::SnippetAttributes;
use crate::code::snippet::SnippetAttributes;
#[test]
fn shell_code_execution() {
@ -241,9 +329,9 @@ echo 'bye'"
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execute: true, ..Default::default() },
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
};
let handle = SnippetExecutor::default().execute(&code).expect("execution failed");
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
let state = loop {
let state = handle.state.lock().unwrap();
if state.status.is_finished() {
@ -251,8 +339,8 @@ echo 'bye'"
}
};
let expected_lines = vec!["hello world", "bye"];
assert_eq!(state.output, expected_lines);
let expected = b"hello world\nbye\n";
assert_eq!(state.output, expected);
}
#[test]
@ -261,9 +349,9 @@ echo 'bye'"
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execute: false, ..Default::default() },
attributes: SnippetAttributes { execution: SnippetExec::None, ..Default::default() },
};
let result = SnippetExecutor::default().execute(&code);
let result = SnippetExecutor::default().execute_async(&code);
assert!(result.is_err());
}
@ -277,9 +365,9 @@ echo 'hello world'
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execute: true, ..Default::default() },
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
};
let handle = SnippetExecutor::default().execute(&code).expect("execution failed");
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
let state = loop {
let state = handle.state.lock().unwrap();
if state.status.is_finished() {
@ -287,8 +375,8 @@ echo 'hello world'
}
};
let expected_lines = vec!["This message redirects to stderr", "hello world"];
assert_eq!(state.output, expected_lines);
let expected = b"This message redirects to stderr\nhello world\n";
assert_eq!(state.output, expected);
}
#[test]
@ -302,9 +390,9 @@ echo 'hello world'
let code = Snippet {
contents,
language: SnippetLanguage::Shell,
attributes: SnippetAttributes { execute: true, ..Default::default() },
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
};
let handle = SnippetExecutor::default().execute(&code).expect("execution failed");
let handle = SnippetExecutor::default().execute_async(&code).expect("execution failed");
let state = loop {
let state = handle.state.lock().unwrap();
if state.status.is_finished() {
@ -312,13 +400,12 @@ echo 'hello world'
}
};
let expected_lines =
vec!["this line was hidden", "this line was hidden and contains another prefix /// ", "hello world"];
assert_eq!(state.output, expected_lines);
let expected = b"this line was hidden\nthis line was hidden and contains another prefix /// \nhello world\n";
assert_eq!(state.output, expected);
}
#[test]
fn built_in_executors() {
SnippetExecutor::new(Default::default()).expect("invalid default executors");
SnippetExecutor::new(Default::default(), PathBuf::from("./")).expect("invalid default executors");
}
}

View File

@ -1,24 +1,20 @@
use crate::{markdown::elements::SnippetLanguage, theme::CodeBlockStyle};
use crossterm::{
style::{SetBackgroundColor, SetForegroundColor},
QueueableCommand,
use crate::{
code::snippet::SnippetLanguage,
markdown::{
elements::{Line, Text},
text_style::{Color, TextStyle},
},
theme::CodeBlockStyle,
};
use flate2::read::ZlibDecoder;
use once_cell::sync::Lazy;
use serde::Deserialize;
use std::{
cell::RefCell,
collections::BTreeMap,
fs,
io::{self, Write},
path::Path,
rc::Rc,
};
use std::{cell::RefCell, collections::BTreeMap, fs, path::Path, rc::Rc};
use syntect::{
LoadingError,
easy::HighlightLines,
highlighting::{Style, Theme, ThemeSet},
parsing::SyntaxSet,
LoadingError,
};
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(|| {
@ -44,16 +40,16 @@ pub struct HighlightThemeSet {
impl HighlightThemeSet {
/// Construct a new highlighter using the given [syntect] theme name.
pub fn load_by_name(&self, name: &str) -> Option<CodeHighlighter> {
pub fn load_by_name(&self, name: &str) -> Option<SnippetHighlighter> {
let mut themes = self.themes.borrow_mut();
// Check if we already loaded this one.
if let Some(theme) = themes.get(name).cloned() {
Some(CodeHighlighter { theme })
Some(SnippetHighlighter { theme })
}
// Otherwise try to deserialize it from bat's themes
else if let Some(theme) = self.deserialize_bat_theme(name) {
themes.insert(name.into(), theme.clone());
Some(CodeHighlighter { theme })
Some(SnippetHighlighter { theme })
} else {
None
}
@ -89,13 +85,13 @@ impl Default for HighlightThemeSet {
}
}
/// A code highlighter.
/// A snippet highlighter.
#[derive(Clone)]
pub struct CodeHighlighter {
pub(crate) struct SnippetHighlighter {
theme: Rc<Theme>,
}
impl CodeHighlighter {
impl SnippetHighlighter {
/// Create a highlighter for a specific language.
pub(crate) fn language_highlighter(&self, language: &SnippetLanguage) -> LanguageHighlighter {
let extension = Self::language_extension(language);
@ -126,13 +122,16 @@ impl CodeHighlighter {
Elixir => "ex",
Elm => "elm",
Erlang => "erl",
File => "txt",
Fish => "fish",
Go => "go",
GraphQL => "graphql",
Haskell => "hs",
Html => "html",
Java => "java",
JavaScript => "js",
Json => "json",
Julia => "jl",
Kotlin => "kt",
Latex => "tex",
Lua => "lua",
@ -148,6 +147,7 @@ impl CodeHighlighter {
Puppet => "pp",
Python => "py",
R => "r",
Racket => "rkt",
Ruby => "rb",
Rust => "rs",
RustScript => "rs",
@ -156,11 +156,14 @@ impl CodeHighlighter {
Sql => "sql",
Swift => "swift",
Svelte => "svelte",
Tcl => "tcl",
Terraform => "tf",
Toml => "toml",
TypeScript => "ts",
Typst => "txt",
// default to plain text so we get the same look&feel
Unknown(_) => "txt",
Verilog => "v",
Vue => "vue",
Xml => "xml",
Yaml => "yaml",
@ -170,7 +173,7 @@ impl CodeHighlighter {
}
}
impl Default for CodeHighlighter {
impl Default for SnippetHighlighter {
fn default() -> Self {
let themes = HighlightThemeSet::default();
themes.load_by_name("base16-eighties.dark").expect("default theme not found")
@ -181,51 +184,42 @@ pub(crate) struct LanguageHighlighter<'a> {
highlighter: HighlightLines<'a>,
}
impl<'a> LanguageHighlighter<'a> {
pub(crate) fn highlight_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> String {
self.style_line(line).map(|s| s.apply_style(block_style)).collect()
impl LanguageHighlighter<'_> {
pub(crate) fn highlight_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line {
self.style_line(line, block_style)
}
pub(crate) fn style_line<'b>(&mut self, line: &'b str) -> impl Iterator<Item = StyledTokens<'b>> {
self.highlighter
pub(crate) fn style_line(&mut self, line: &str, block_style: &CodeBlockStyle) -> Line {
let texts: Vec<_> = self
.highlighter
.highlight_line(line, &SYNTAX_SET)
.unwrap()
.into_iter()
.map(|(style, tokens)| StyledTokens { style, tokens })
.map(|(style, tokens)| StyledTokens::new(style, tokens, block_style).apply_style())
.collect();
Line(texts)
}
}
pub(crate) struct StyledTokens<'a> {
pub(crate) style: Style,
pub(crate) style: TextStyle,
pub(crate) tokens: &'a str,
}
impl<'a> StyledTokens<'a> {
pub(crate) fn apply_style(&self, block_style: &CodeBlockStyle) -> String {
let has_background = block_style.background.unwrap_or(true);
let background = has_background.then_some(to_ansi_color(self.style.background)).flatten();
let foreground = to_ansi_color(self.style.foreground);
pub(crate) fn new(style: Style, tokens: &'a str, block_style: &CodeBlockStyle) -> Self {
let has_background = block_style.background;
let background = has_background.then_some(parse_color(style.background)).flatten();
let foreground = parse_color(style.foreground);
let mut style = TextStyle::default();
style.colors.background = background;
style.colors.foreground = foreground;
Self { style, tokens }
}
// We do this conversion manually as crossterm will reset the color after styling, and we
// want to "keep it open" so that padding also uses this background color.
//
// Note: these unwraps shouldn't happen as this is an in-memory writer so there's no
// fallible IO here.
let mut cursor = io::BufWriter::new(Vec::new());
if let Some(color) = background {
cursor.queue(SetBackgroundColor(color)).unwrap();
}
if let Some(color) = foreground {
cursor.queue(SetForegroundColor(color)).unwrap();
}
// syntect likes its input to contain \n but we don't want them as we pad text with extra
// " " at the end so we get rid of them here.
for chunk in self.tokens.split('\n') {
cursor.write_all(chunk.as_bytes()).unwrap();
}
cursor.flush().unwrap();
String::from_utf8(cursor.into_inner().unwrap()).unwrap()
pub(crate) fn apply_style(&self) -> Text {
let text: String = self.tokens.split('\n').collect();
Text::new(text, self.style)
}
}
@ -235,8 +229,7 @@ impl<'a> StyledTokens<'a> {
pub struct ThemeNotFound;
// This code has been adapted from bat's: https://github.com/sharkdp/bat
fn to_ansi_color(color: syntect::highlighting::Color) -> Option<crossterm::style::Color> {
use crossterm::style::Color;
fn parse_color(color: syntect::highlighting::Color) -> Option<Color> {
if color.a == 0 {
Some(match color.r {
0x00 => Color::Black,
@ -247,12 +240,12 @@ fn to_ansi_color(color: syntect::highlighting::Color) -> Option<crossterm::style
0x05 => Color::DarkMagenta,
0x06 => Color::DarkCyan,
0x07 => Color::Grey,
n => Color::AnsiValue(n),
n => Color::from_ansi(n)?,
})
} else if color.a == 1 {
None
} else {
Some(Color::Rgb { r: color.r, g: color.g, b: color.b })
Some(Color::new(color.r, color.g, color.b))
}
}
@ -265,7 +258,7 @@ mod test {
#[test]
fn language_extensions_exist() {
for language in SnippetLanguage::iter() {
let extension = CodeHighlighter::language_extension(&language);
let extension = SnippetHighlighter::language_extension(&language);
let syntax = SYNTAX_SET.find_syntax_by_extension(extension);
assert!(syntax.is_some(), "extension {extension} for {language:?} not found");
}
@ -273,7 +266,7 @@ mod test {
#[test]
fn default_highlighter() {
CodeHighlighter::default();
SnippetHighlighter::default();
}
#[test]

4
src/code/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub(crate) mod execute;
pub(crate) mod highlighting;
pub(crate) mod padding;
pub(crate) mod snippet;

View File

@ -6,7 +6,7 @@ pub(crate) struct NumberPadder {
impl NumberPadder {
pub(crate) fn new(upper_bound: usize) -> Self {
let width = upper_bound.ilog10() as usize + 1;
let width = upper_bound.checked_ilog10().map(|log| log as usize + 1).unwrap_or_default();
Self { width }
}
@ -37,4 +37,9 @@ mod test {
let rendered: Vec<_> = numbers.iter().map(|n| padder.pad_right(*n)).collect();
assert_eq!(rendered, expected);
}
#[test]
fn zero_count() {
NumberPadder::new(0);
}
}

856
src/code/snippet.rs Normal file
View File

@ -0,0 +1,856 @@
use super::{
highlighting::{LanguageHighlighter, StyledTokens},
padding::NumberPadder,
};
use crate::{
markdown::{
elements::{Percent, PercentParseError},
text::{WeightedLine, WeightedText},
text_style::{Color, TextStyle},
},
presentation::ChunkMutator,
render::{
operation::{AsRenderOperations, BlockLine, RenderOperation},
properties::WindowSize,
},
theme::{Alignment, CodeBlockStyle},
};
use serde::Deserialize;
use std::{cell::RefCell, convert::Infallible, fmt::Write, ops::Range, path::PathBuf, rc::Rc, str::FromStr};
use strum::{EnumDiscriminants, EnumIter};
use unicode_width::UnicodeWidthStr;
pub(crate) struct SnippetSplitter<'a> {
style: &'a CodeBlockStyle,
hidden_line_prefix: Option<&'a str>,
}
impl<'a> SnippetSplitter<'a> {
pub(crate) fn new(style: &'a CodeBlockStyle, hidden_line_prefix: Option<&'a str>) -> Self {
Self { style, hidden_line_prefix }
}
pub(crate) fn split(&self, code: &Snippet) -> Vec<SnippetLine> {
let mut lines = Vec::new();
let horizontal_padding = self.style.padding.horizontal;
let vertical_padding = self.style.padding.vertical;
if vertical_padding > 0 {
lines.push(SnippetLine::empty());
}
self.push_lines(code, horizontal_padding, &mut lines);
if vertical_padding > 0 {
lines.push(SnippetLine::empty());
}
lines
}
fn push_lines(&self, code: &Snippet, horizontal_padding: u8, lines: &mut Vec<SnippetLine>) {
if code.contents.is_empty() {
return;
}
let padding = " ".repeat(horizontal_padding as usize);
let padder = NumberPadder::new(code.visible_lines(self.hidden_line_prefix).count());
for (index, line) in code.visible_lines(self.hidden_line_prefix).enumerate() {
let mut line = line.replace('\t', " ");
let mut prefix = padding.clone();
if code.attributes.line_numbers {
let line_number = index + 1;
prefix.push_str(&padder.pad_right(line_number));
prefix.push(' ');
}
line.push('\n');
let line_number = Some(index as u16 + 1);
lines.push(SnippetLine { prefix, code: line, right_padding_length: padding.len() as u16, line_number });
}
}
}
pub(crate) struct SnippetLine {
pub(crate) prefix: String,
pub(crate) code: String,
pub(crate) right_padding_length: u16,
pub(crate) line_number: Option<u16>,
}
impl SnippetLine {
pub(crate) fn empty() -> Self {
Self { prefix: String::new(), code: "\n".into(), right_padding_length: 0, line_number: None }
}
pub(crate) fn width(&self) -> usize {
self.prefix.width() + self.code.width() + self.right_padding_length as usize
}
pub(crate) fn highlight(
&self,
code_highlighter: &mut LanguageHighlighter,
block_style: &CodeBlockStyle,
font_size: u8,
) -> WeightedLine {
let mut line = code_highlighter.highlight_line(&self.code, block_style);
line.apply_style(&TextStyle::default().size(font_size));
line.into()
}
pub(crate) fn dim(&self, dim_style: &TextStyle) -> WeightedLine {
let output = vec![StyledTokens { style: *dim_style, tokens: &self.code }.apply_style()];
output.into()
}
pub(crate) fn dim_prefix(&self, dim_style: &TextStyle) -> WeightedText {
let text = StyledTokens { style: *dim_style, tokens: &self.prefix }.apply_style();
text.into()
}
}
#[derive(Debug)]
pub(crate) struct HighlightContext {
pub(crate) groups: Vec<HighlightGroup>,
pub(crate) current: usize,
pub(crate) block_length: u16,
pub(crate) alignment: Alignment,
}
#[derive(Debug)]
pub(crate) struct HighlightedLine {
pub(crate) prefix: WeightedText,
pub(crate) right_padding_length: u16,
pub(crate) highlighted: WeightedLine,
pub(crate) not_highlighted: WeightedLine,
pub(crate) line_number: Option<u16>,
pub(crate) context: Rc<RefCell<HighlightContext>>,
pub(crate) block_color: Option<Color>,
}
impl AsRenderOperations for HighlightedLine {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
let context = self.context.borrow();
let group = &context.groups[context.current];
let needs_highlight = self.line_number.map(|number| group.contains(number)).unwrap_or_default();
// TODO: Cow<str>?
let text = match needs_highlight {
true => self.highlighted.clone(),
false => self.not_highlighted.clone(),
};
vec![
RenderOperation::RenderBlockLine(BlockLine {
prefix: self.prefix.clone(),
right_padding_length: self.right_padding_length,
repeat_prefix_on_wrap: false,
text,
block_length: context.block_length,
alignment: context.alignment,
block_color: self.block_color,
}),
RenderOperation::RenderLineBreak,
]
}
}
#[derive(Debug)]
pub(crate) struct HighlightMutator {
context: Rc<RefCell<HighlightContext>>,
}
impl HighlightMutator {
pub(crate) fn new(context: Rc<RefCell<HighlightContext>>) -> Self {
Self { context }
}
}
impl ChunkMutator for HighlightMutator {
fn mutate_next(&self) -> bool {
let mut context = self.context.borrow_mut();
if context.current == context.groups.len() - 1 {
false
} else {
context.current += 1;
true
}
}
fn mutate_previous(&self) -> bool {
let mut context = self.context.borrow_mut();
if context.current == 0 {
false
} else {
context.current -= 1;
true
}
}
fn reset_mutations(&self) {
self.context.borrow_mut().current = 0;
}
fn apply_all_mutations(&self) {
let mut context = self.context.borrow_mut();
context.current = context.groups.len() - 1;
}
fn mutations(&self) -> (usize, usize) {
let context = self.context.borrow();
(context.current, context.groups.len())
}
}
pub(crate) type ParseResult<T> = Result<T, SnippetBlockParseError>;
pub(crate) struct SnippetParser;
impl SnippetParser {
pub(crate) fn parse(info: String, code: String) -> ParseResult<Snippet> {
let (language, attributes) = Self::parse_block_info(&info)?;
let code = Snippet { contents: code, language, attributes };
Ok(code)
}
fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> {
let (language, input) = Self::parse_language(input);
let attributes = Self::parse_attributes(input)?;
if attributes.width.is_some() && !matches!(attributes.representation, SnippetRepr::Render) {
return Err(SnippetBlockParseError::NotRenderSnippet("width"));
}
Ok((language, attributes))
}
fn parse_language(input: &str) -> (SnippetLanguage, &str) {
let token = Self::next_identifier(input);
// this always returns `Ok` given we fall back to `Unknown` if we don't know the language.
let language = token.parse().expect("language parsing");
let rest = &input[token.len()..];
(language, rest)
}
fn parse_attributes(mut input: &str) -> ParseResult<SnippetAttributes> {
let mut attributes = SnippetAttributes::default();
let mut processed_attributes = Vec::new();
while let (Some(attribute), rest) = Self::parse_attribute(input)? {
let discriminant = SnippetAttributeDiscriminants::from(&attribute);
if processed_attributes.contains(&discriminant) {
return Err(SnippetBlockParseError::DuplicateAttribute("duplicate attribute"));
}
use SnippetAttribute::*;
match attribute {
ExecReplace | Image | Render if attributes.representation != SnippetRepr::Snippet => {
return Err(SnippetBlockParseError::MultipleRepresentation);
}
LineNumbers => attributes.line_numbers = true,
Exec => {
if attributes.execution != SnippetExec::AcquireTerminal {
attributes.execution = SnippetExec::Exec;
}
}
ExecReplace => {
attributes.representation = SnippetRepr::ExecReplace;
attributes.execution = SnippetExec::Exec;
}
Image => {
attributes.representation = SnippetRepr::Image;
attributes.execution = SnippetExec::Exec;
}
Render => attributes.representation = SnippetRepr::Render,
AcquireTerminal => attributes.execution = SnippetExec::AcquireTerminal,
NoBackground => attributes.no_background = true,
HighlightedLines(lines) => attributes.highlight_groups = lines,
Width(width) => attributes.width = Some(width),
};
processed_attributes.push(discriminant);
input = rest;
}
if attributes.highlight_groups.is_empty() {
attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All]));
}
Ok(attributes)
}
fn parse_attribute(input: &str) -> ParseResult<(Option<SnippetAttribute>, &str)> {
let input = Self::skip_whitespace(input);
let (attribute, input) = match input.chars().next() {
Some('+') => {
let token = Self::next_identifier(&input[1..]);
let attribute = match token {
"line_numbers" => SnippetAttribute::LineNumbers,
"exec" => SnippetAttribute::Exec,
"exec_replace" => SnippetAttribute::ExecReplace,
"image" => SnippetAttribute::Image,
"render" => SnippetAttribute::Render,
"no_background" => SnippetAttribute::NoBackground,
"acquire_terminal" => SnippetAttribute::AcquireTerminal,
token if token.starts_with("width:") => {
let value = input.split_once("+width:").unwrap().1;
let (width, input) = Self::parse_width(value)?;
return Ok((Some(SnippetAttribute::Width(width)), input));
}
_ => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())),
};
(Some(attribute), &input[token.len() + 1..])
}
Some('{') => {
let (lines, input) = Self::parse_highlight_groups(&input[1..])?;
(Some(SnippetAttribute::HighlightedLines(lines)), input)
}
Some(_) => return Err(SnippetBlockParseError::InvalidToken(Self::next_identifier(input).into())),
None => (None, input),
};
Ok((attribute, input))
}
fn parse_highlight_groups(input: &str) -> ParseResult<(Vec<HighlightGroup>, &str)> {
use SnippetBlockParseError::InvalidHighlightedLines;
let Some((head, tail)) = input.split_once('}') else {
return Err(InvalidHighlightedLines("no enclosing '}'".into()));
};
let head = head.trim();
if head.is_empty() {
return Ok((Vec::new(), tail));
}
let mut highlight_groups = Vec::new();
for group in head.split('|') {
let group = Self::parse_highlight_group(group)?;
highlight_groups.push(group);
}
Ok((highlight_groups, tail))
}
fn parse_highlight_group(input: &str) -> ParseResult<HighlightGroup> {
let mut highlights = Vec::new();
for piece in input.split(',') {
let piece = piece.trim();
if piece == "all" {
highlights.push(Highlight::All);
continue;
}
match piece.split_once('-') {
Some((left, right)) => {
let left = Self::parse_number(left)?;
let right = Self::parse_number(right)?;
let right = right.checked_add(1).ok_or_else(|| {
SnippetBlockParseError::InvalidHighlightedLines(format!("{right} is too large"))
})?;
highlights.push(Highlight::Range(left..right));
}
None => {
let number = Self::parse_number(piece)?;
highlights.push(Highlight::Single(number));
}
}
}
Ok(HighlightGroup::new(highlights))
}
fn parse_number(input: &str) -> ParseResult<u16> {
input
.trim()
.parse()
.map_err(|_| SnippetBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'")))
}
fn parse_width(input: &str) -> ParseResult<(Percent, &str)> {
let end_index = input.find(' ').unwrap_or(input.len());
let value = input[0..end_index].parse().map_err(SnippetBlockParseError::InvalidWidth)?;
Ok((value, &input[end_index..]))
}
fn skip_whitespace(input: &str) -> &str {
input.trim_start_matches(' ')
}
fn next_identifier(input: &str) -> &str {
match input.split_once(' ') {
Some((token, _)) => token,
None => input,
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum SnippetBlockParseError {
#[error("invalid code attribute: {0}")]
InvalidToken(String),
#[error("invalid highlighted lines: {0}")]
InvalidHighlightedLines(String),
#[error("invalid width: {0}")]
InvalidWidth(PercentParseError),
#[error("duplicate attribute: {0}")]
DuplicateAttribute(&'static str),
#[error("+exec_replace +image and +render can't be used together ")]
MultipleRepresentation,
#[error("attribute {0} can only be set in +render blocks")]
NotRenderSnippet(&'static str),
}
#[derive(EnumDiscriminants)]
enum SnippetAttribute {
LineNumbers,
Exec,
ExecReplace,
Image,
Render,
HighlightedLines(Vec<HighlightGroup>),
Width(Percent),
NoBackground,
AcquireTerminal,
}
/// A code snippet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Snippet {
/// The snippet itself.
pub(crate) contents: String,
/// The programming language this snippet is written in.
pub(crate) language: SnippetLanguage,
/// The attributes used for snippet.
pub(crate) attributes: SnippetAttributes,
}
impl Snippet {
pub(crate) fn visible_lines<'a, 'b>(
&'a self,
hidden_line_prefix: Option<&'b str>,
) -> impl Iterator<Item = &'a str> + 'b
where
'a: 'b,
{
self.contents.lines().filter(move |line| !hidden_line_prefix.is_some_and(|prefix| line.starts_with(prefix)))
}
pub(crate) fn executable_contents(&self, hidden_line_prefix: Option<&str>) -> String {
if let Some(prefix) = hidden_line_prefix {
self.contents.lines().fold(String::new(), |mut output, line| {
let line = line.strip_prefix(prefix).unwrap_or(line);
let _ = writeln!(output, "{line}");
output
})
} else {
self.contents.to_owned()
}
}
}
/// The language of a code snippet.
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub enum SnippetLanguage {
Ada,
Asp,
Awk,
Bash,
BatchFile,
C,
CMake,
Crontab,
CSharp,
Clojure,
Cpp,
Css,
DLang,
Diff,
Docker,
Dotenv,
Elixir,
Elm,
Erlang,
File,
Fish,
Go,
GraphQL,
Haskell,
Html,
Java,
JavaScript,
Json,
Julia,
Kotlin,
Latex,
Lua,
Makefile,
Mermaid,
Markdown,
Nix,
Nushell,
OCaml,
Perl,
Php,
Protobuf,
Puppet,
Python,
R,
Racket,
Ruby,
Rust,
RustScript,
Scala,
Shell,
Sql,
Swift,
Svelte,
Tcl,
Terraform,
Toml,
TypeScript,
Typst,
Unknown(String),
Xml,
Yaml,
Verilog,
Vue,
Zig,
Zsh,
}
crate::utils::impl_deserialize_from_str!(SnippetLanguage);
impl FromStr for SnippetLanguage {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
use SnippetLanguage::*;
let language = match s {
"ada" => Ada,
"asp" => Asp,
"awk" => Awk,
"bash" => Bash,
"c" => C,
"cmake" => CMake,
"crontab" => Crontab,
"csharp" => CSharp,
"clojure" => Clojure,
"cpp" | "c++" => Cpp,
"css" => Css,
"d" => DLang,
"diff" => Diff,
"docker" => Docker,
"dotenv" => Dotenv,
"elixir" => Elixir,
"elm" => Elm,
"erlang" => Erlang,
"file" => File,
"fish" => Fish,
"go" => Go,
"graphql" => GraphQL,
"haskell" => Haskell,
"html" => Html,
"java" => Java,
"javascript" | "js" => JavaScript,
"json" => Json,
"julia" => Julia,
"kotlin" => Kotlin,
"latex" => Latex,
"lua" => Lua,
"make" => Makefile,
"markdown" => Markdown,
"mermaid" => Mermaid,
"nix" => Nix,
"nushell" | "nu" => Nushell,
"ocaml" => OCaml,
"perl" => Perl,
"php" => Php,
"protobuf" => Protobuf,
"puppet" => Puppet,
"python" => Python,
"r" => R,
"racket" => Racket,
"ruby" => Ruby,
"rust" => Rust,
"rust-script" => RustScript,
"scala" => Scala,
"shell" | "sh" => Shell,
"sql" => Sql,
"svelte" => Svelte,
"swift" => Swift,
"tcl" => Tcl,
"terraform" => Terraform,
"toml" => Toml,
"typescript" | "ts" => TypeScript,
"typst" => Typst,
"xml" => Xml,
"yaml" => Yaml,
"verilog" => Verilog,
"vue" => Vue,
"zig" => Zig,
"zsh" => Zsh,
other => Unknown(other.to_string()),
};
Ok(language)
}
}
/// Attributes for code snippets.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SnippetAttributes {
/// The way the snippet should be represented.
pub(crate) representation: SnippetRepr,
/// The way the snippet should be executed.
pub(crate) execution: SnippetExec,
/// Whether the snippet should show line numbers.
pub(crate) line_numbers: bool,
/// The groups of lines to highlight.
pub(crate) highlight_groups: Vec<HighlightGroup>,
/// The width of the generated image.
///
/// Only valid for +render snippets.
pub(crate) width: Option<Percent>,
/// Whether to add no background to a snippet.
pub(crate) no_background: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum SnippetRepr {
#[default]
Snippet,
Image,
Render,
ExecReplace,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) enum SnippetExec {
#[default]
None,
Exec,
AcquireTerminal,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct HighlightGroup(Vec<Highlight>);
impl HighlightGroup {
pub(crate) fn new(highlights: Vec<Highlight>) -> Self {
Self(highlights)
}
pub(crate) fn contains(&self, line_number: u16) -> bool {
for higlight in &self.0 {
match higlight {
Highlight::All => return true,
Highlight::Single(number) if number == &line_number => return true,
Highlight::Range(range) if range.contains(&line_number) => return true,
_ => continue,
};
}
false
}
}
/// A highlighted set of lines
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Highlight {
All,
Single(u16),
Range(Range<u16>),
}
#[derive(Debug, Deserialize)]
pub(crate) struct ExternalFile {
pub(crate) path: PathBuf,
pub(crate) language: SnippetLanguage,
pub(crate) start_line: Option<usize>,
pub(crate) end_line: Option<usize>,
}
#[cfg(test)]
mod test {
use super::*;
use Highlight::*;
use rstest::rstest;
fn parse_language(input: &str) -> SnippetLanguage {
let (language, _) = SnippetParser::parse_block_info(input).expect("parse failed");
language
}
fn try_parse_attributes(input: &str) -> Result<SnippetAttributes, SnippetBlockParseError> {
let (_, attributes) = SnippetParser::parse_block_info(input)?;
Ok(attributes)
}
fn parse_attributes(input: &str) -> SnippetAttributes {
try_parse_attributes(input).expect("parse failed")
}
#[test]
fn code_with_line_numbers() {
let total_lines = 11;
let input_lines = "hi\n".repeat(total_lines);
let code = Snippet {
contents: input_lines,
language: SnippetLanguage::Unknown("".to_string()),
attributes: SnippetAttributes { line_numbers: true, ..Default::default() },
};
let lines = SnippetSplitter::new(&Default::default(), None).split(&code);
assert_eq!(lines.len(), total_lines);
let mut lines = lines.into_iter().enumerate();
// 0..=9
for (index, line) in lines.by_ref().take(9) {
let line_number = index + 1;
assert_eq!(&line.prefix, &format!(" {line_number} "));
}
// 10..
for (index, line) in lines {
let line_number = index + 1;
assert_eq!(&line.prefix, &format!("{line_number} "));
}
}
#[test]
fn unknown_language() {
assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string()));
}
#[test]
fn no_attributes() {
assert_eq!(parse_language("rust"), SnippetLanguage::Rust);
}
#[test]
fn one_attribute() {
let attributes = parse_attributes("bash +exec");
assert_eq!(attributes.execution, SnippetExec::Exec);
assert!(!attributes.line_numbers);
}
#[test]
fn two_attributes() {
let attributes = parse_attributes("bash +exec +line_numbers");
assert_eq!(attributes.execution, SnippetExec::Exec);
assert!(attributes.line_numbers);
}
#[test]
fn acquire_terminal() {
let attributes = parse_attributes("bash +acquire_terminal +exec");
assert_eq!(attributes.execution, SnippetExec::AcquireTerminal);
assert_eq!(attributes.representation, SnippetRepr::Snippet);
assert!(!attributes.line_numbers);
}
#[test]
fn image() {
let attributes = parse_attributes("bash +image +exec");
assert_eq!(attributes.execution, SnippetExec::Exec);
assert_eq!(attributes.representation, SnippetRepr::Image);
assert!(!attributes.line_numbers);
}
#[test]
fn invalid_attributes() {
SnippetParser::parse_block_info("bash +potato").unwrap_err();
SnippetParser::parse_block_info("bash potato").unwrap_err();
}
#[rstest]
#[case::no_end("{")]
#[case::number_no_end("{42")]
#[case::comma_nothing("{42,")]
#[case::brace_comma("{,}")]
#[case::range_no_end("{42-")]
#[case::range_end("{42-}")]
#[case::too_many_ranges("{42-3-5}")]
#[case::range_comma("{42-,")]
#[case::too_large("{65536}")]
#[case::too_large_end("{1-65536}")]
fn invalid_line_highlights(#[case] input: &str) {
let input = format!("bash {input}");
SnippetParser::parse_block_info(&input).expect_err("parsed successfully");
}
#[test]
fn highlight_none() {
let attributes = parse_attributes("bash {}");
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]);
}
#[test]
fn highlight_specific_lines() {
let attributes = parse_attributes("bash { 1, 2 , 3 }");
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]);
}
#[test]
fn highlight_line_range() {
let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }");
assert_eq!(
attributes.highlight_groups,
&[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])]
);
}
#[test]
fn multiple_groups() {
let attributes = parse_attributes("bash {1-3,5 |6-9}");
assert_eq!(attributes.highlight_groups.len(), 2);
assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)]));
assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)]));
}
#[test]
fn parse_width() {
let attributes = parse_attributes("mermaid +width:50% +render");
assert_eq!(attributes.representation, SnippetRepr::Render);
assert_eq!(attributes.width, Some(Percent(50)));
}
#[test]
fn invalid_width() {
try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded");
try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded");
try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded");
}
#[test]
fn code_visible_lines() {
let contents = r##"# fn main() {
println!("Hello world");
# // The prefix is # .
# }
"##
.to_string();
let expected = vec!["println!(\"Hello world\");"];
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
assert_eq!(expected, code.visible_lines(Some("# ")).collect::<Vec<_>>());
}
#[test]
fn code_executable_contents() {
let contents = r##"# fn main() {
println!("Hello world");
# // The prefix is # .
# }
"##
.to_string();
let expected = r##"fn main() {
println!("Hello world");
// The prefix is # .
}
"##
.to_string();
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
assert_eq!(expected, code.executable_contents(Some("# ")));
}
#[test]
fn tabs_in_snippet() {
let snippet = Snippet { contents: "\thi".into(), language: SnippetLanguage::C, attributes: Default::default() };
let lines = SnippetSplitter::new(&Default::default(), None).split(&snippet);
assert_eq!(lines[0].code, " hi\n");
}
}

View File

@ -1,17 +1,15 @@
use super::source::{Command, CommandDiscriminants};
use crate::custom::KeyBindingsConfig;
use crossterm::event::{poll, read, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use schemars::JsonSchema;
use serde_with::DeserializeFromStr;
use super::listener::{Command, CommandDiscriminants};
use crate::config::KeyBindingsConfig;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use std::{fmt, io, iter, mem, str::FromStr, time::Duration};
/// A user input handler.
pub struct UserInput {
/// A keyboard command listener.
pub struct KeyboardListener {
bindings: CommandKeyBindings,
events: Vec<KeyEvent>,
}
impl UserInput {
impl KeyboardListener {
pub fn new(bindings: CommandKeyBindings) -> Self {
Self { bindings, events: Vec::new() }
}
@ -90,6 +88,7 @@ impl CommandKeyBindings {
}
RenderAsyncOperations => Command::RenderAsyncOperations,
Exit => Command::Exit,
Suspend => Command::Suspend,
Reload => Command::Reload,
HardReload => Command::HardReload,
ToggleSlideIndex => Command::ToggleSlideIndex,
@ -133,6 +132,7 @@ impl TryFrom<KeyBindingsConfig> for CommandKeyBindings {
.chain(zip(CommandDiscriminants::LastSlide, config.last_slide))
.chain(zip(CommandDiscriminants::GoToSlide, config.go_to_slide))
.chain(zip(CommandDiscriminants::Exit, config.exit))
.chain(zip(CommandDiscriminants::Suspend, config.suspend))
.chain(zip(CommandDiscriminants::HardReload, config.reload))
.chain(zip(CommandDiscriminants::ToggleSlideIndex, config.toggle_slide_index))
.chain(zip(CommandDiscriminants::ToggleKeyBindingsConfig, config.toggle_bindings))
@ -160,8 +160,11 @@ enum BindingMatch {
None,
}
#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr, JsonSchema)]
pub struct KeyBinding(#[schemars(with = "String")] Vec<KeyMatcher>);
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct KeyBinding(#[cfg_attr(feature = "json-schema", schemars(with = "String"))] Vec<KeyMatcher>);
crate::utils::impl_deserialize_from_str!(KeyBinding);
impl KeyBinding {
fn match_events(&self, mut events: &[KeyEvent]) -> BindingMatch {

View File

@ -1,40 +1,45 @@
use super::{
fs::PresentationFileWatcher,
user::{CommandKeyBindings, KeyBindingsValidationError, UserInput},
keyboard::{CommandKeyBindings, KeyBindingsValidationError, KeyboardListener},
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventListener},
};
use crate::custom::KeyBindingsConfig;
use crate::{config::KeyBindingsConfig, presenter::PresentationError};
use serde::Deserialize;
use std::{io, path::PathBuf, time::Duration};
use std::time::Duration;
use strum::EnumDiscriminants;
/// The source of commands.
///
/// This expects user commands as well as watches over the presentation file to reload if it that
/// happens.
pub struct CommandSource {
watcher: PresentationFileWatcher,
user_input: UserInput,
/// A command listener that allows polling all command sources in a single place.
pub struct CommandListener {
keyboard: KeyboardListener,
speaker_notes_event_listener: Option<SpeakerNotesEventListener>,
}
impl CommandSource {
impl CommandListener {
/// Create a new command source over the given presentation path.
pub fn new<P: Into<PathBuf>>(
presentation_path: P,
pub fn new(
config: KeyBindingsConfig,
speaker_notes_event_listener: Option<SpeakerNotesEventListener>,
) -> Result<Self, KeyBindingsValidationError> {
let watcher = PresentationFileWatcher::new(presentation_path);
let bindings = CommandKeyBindings::try_from(config)?;
Ok(Self { watcher, user_input: UserInput::new(bindings) })
Ok(Self { keyboard: KeyboardListener::new(bindings), speaker_notes_event_listener })
}
/// Try to get the next command.
///
/// This attempts to get a command and returns `Ok(None)` on timeout.
pub(crate) fn try_next_command(&mut self) -> io::Result<Option<Command>> {
if let Some(command) = self.user_input.poll_next_command(Duration::from_millis(250))? {
return Ok(Some(command));
};
if self.watcher.has_modifications()? { Ok(Some(Command::Reload)) } else { Ok(None) }
pub(crate) fn try_next_command(&mut self) -> Result<Option<Command>, PresentationError> {
if let Some(receiver) = &self.speaker_notes_event_listener {
if let Some(msg) = receiver.try_recv()? {
let command = match msg {
SpeakerNotesEvent::GoToSlide { slide } => Command::GoToSlide(slide),
SpeakerNotesEvent::Exit => Command::Exit,
};
return Ok(Some(command));
}
}
match self.keyboard.poll_next_command(Duration::from_millis(100))? {
Some(command) => Ok(Some(command)),
None => Ok(None),
}
}
}
@ -74,6 +79,9 @@ pub(crate) enum Command {
/// Exit the presentation.
Exit,
/// Suspend the presentation.
Suspend,
/// The presentation has changed and needs to be reloaded.
Reload,

3
src/commands/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub(crate) mod keyboard;
pub(crate) mod listener;
pub(crate) mod speaker_notes;

View File

@ -0,0 +1,115 @@
use serde::{Deserialize, Serialize};
use socket2::{Domain, Protocol, Socket, Type};
use std::{
io,
net::{SocketAddr, UdpSocket},
path::PathBuf,
};
pub struct SpeakerNotesEventPublisher {
socket: UdpSocket,
presentation_path: PathBuf,
}
impl SpeakerNotesEventPublisher {
pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {
let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.set_broadcast(true)?;
socket.connect(address)?;
Ok(Self { socket, presentation_path })
}
pub(crate) fn send(&self, event: SpeakerNotesEvent) -> io::Result<()> {
// Wrap this event in an envelope that contains the presentation path so listeners can
// ignore unrelated events.
let envelope = SpeakerNotesEventEnvelope { event, presentation_path: self.presentation_path.clone() };
let data = serde_json::to_string(&envelope).expect("serialization failed");
match self.socket.send(data.as_bytes()) {
Ok(_) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => Ok(()),
Err(e) => Err(e),
}
}
}
pub struct SpeakerNotesEventListener {
socket: UdpSocket,
presentation_path: PathBuf,
}
impl SpeakerNotesEventListener {
pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {
let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
// Use SO_REUSEADDR so we can have multiple listeners on the same port.
#[cfg(not(target_os = "macos"))]
s.set_reuse_address(true)?;
// Don't block so we can listen to the keyboard and this socket at the same time.
s.set_nonblocking(true)?;
s.bind(&address.into())?;
Ok(Self { socket: s.into(), presentation_path })
}
pub(crate) fn try_recv(&self) -> io::Result<Option<SpeakerNotesEvent>> {
let mut buffer = [0; 1024];
let bytes_read = match self.socket.recv(&mut buffer) {
Ok(bytes_read) => bytes_read,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(None),
Err(e) => return Err(e),
};
// Ignore garbage. Odds are this is someone else sending garbage rather than presenterm
// itself.
let Ok(envelope) = serde_json::from_slice::<SpeakerNotesEventEnvelope>(&buffer[0..bytes_read]) else {
return Ok(None);
};
if envelope.presentation_path == self.presentation_path { Ok(Some(envelope.event)) } else { Ok(None) }
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "command")]
pub(crate) enum SpeakerNotesEvent {
GoToSlide { slide: u32 },
Exit,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct SpeakerNotesEventEnvelope {
presentation_path: PathBuf,
event: SpeakerNotesEvent,
}
#[cfg(not(target_os = "macos"))]
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{default_speaker_notes_listen_address, default_speaker_notes_publish_address};
use std::{thread::sleep, time::Duration};
fn make_listener(path: PathBuf) -> SpeakerNotesEventListener {
SpeakerNotesEventListener::new(default_speaker_notes_listen_address(), path).expect("building listener")
}
fn make_publisher(path: PathBuf) -> SpeakerNotesEventPublisher {
SpeakerNotesEventPublisher::new(default_speaker_notes_publish_address(), path).expect("building publisher")
}
#[test]
fn bind_multiple() {
let _l1 = make_listener("".into());
let _l2 = make_listener("".into());
}
#[test]
fn multicast() {
let path = PathBuf::from("/tmp/test.md");
let l1 = make_listener(path.clone());
let l2 = make_listener(path.clone());
let publisher = make_publisher(path);
let event = SpeakerNotesEvent::Exit;
publisher.send(event.clone()).expect("send failed");
sleep(Duration::from_millis(100));
assert_eq!(l1.try_recv().expect("recv first failed"), Some(event.clone()));
assert_eq!(l2.try_recv().expect("recv second failed"), Some(event));
}
}

View File

@ -1,23 +1,26 @@
use crate::{
input::user::KeyBinding,
markdown::elements::SnippetLanguage,
media::{emulator::TerminalEmulator, kitty::KittyMode},
GraphicsMode,
code::snippet::SnippetLanguage,
commands::keyboard::KeyBinding,
terminal::{
GraphicsMode, capabilities::TerminalCapabilities, emulator::TerminalEmulator,
image::protocols::kitty::KittyMode,
},
};
use clap::ValueEnum;
use schemars::JsonSchema;
use serde::Deserialize;
use std::{
collections::{BTreeMap, HashMap},
fs, io,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::Path,
};
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct Config {
/// The default configuration for the presentation.
#[serde(default)]
#[doc = "The default configuration for the presentation."]
pub defaults: DefaultsConfig,
#[serde(default)]
@ -34,6 +37,15 @@ pub struct Config {
#[serde(default)]
pub snippet: SnippetConfig,
#[serde(default)]
pub speaker_notes: SpeakerNotesConfig,
#[serde(default)]
pub export: ExportConfig,
#[serde(default)]
pub transition: Option<SlideTransitionConfig>,
}
impl Config {
@ -41,7 +53,7 @@ impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound),
Err(e) => return Err(e.into()),
};
let config = serde_yaml::from_str(&contents)?;
@ -54,19 +66,23 @@ pub enum ConfigLoadError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("config file not found")]
NotFound,
#[error("invalid configuration: {0}")]
Invalid(#[from] serde_yaml::Error),
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct DefaultsConfig {
/// The theme to use by default in every presentation unless overridden.
pub theme: Option<String>,
/// Override the terminal font size when in windows or when using sixel.
#[serde(default = "default_font_size")]
#[validate(range(min = 1))]
#[serde(default = "default_terminal_font_size")]
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
pub terminal_font_size: u8,
/// The image protocol to use.
@ -76,24 +92,98 @@ pub struct DefaultsConfig {
/// Validate that the presentation does not overflow the terminal screen.
#[serde(default)]
pub validate_overflows: ValidateOverflows,
/// A max width in columns that the presentation must always be capped to.
#[serde(default = "default_u16_max")]
pub max_columns: u16,
/// The alignment the presentation should have if `max_columns` is set and the terminal is
/// larger than that.
#[serde(default)]
pub max_columns_alignment: MaxColumnsAlignment,
/// A max height in rows that the presentation must always be capped to.
#[serde(default = "default_u16_max")]
pub max_rows: u16,
/// The alignment the presentation should have if `max_rows` is set and the terminal is
/// larger than that.
#[serde(default)]
pub max_rows_alignment: MaxRowsAlignment,
/// The configuration for lists when incremental lists are enabled.
#[serde(default)]
pub incremental_lists: IncrementalListsConfig,
}
impl Default for DefaultsConfig {
fn default() -> Self {
Self {
theme: Default::default(),
terminal_font_size: default_font_size(),
terminal_font_size: default_terminal_font_size(),
image_protocol: Default::default(),
validate_overflows: Default::default(),
max_columns: default_u16_max(),
max_columns_alignment: Default::default(),
max_rows: default_u16_max(),
max_rows_alignment: Default::default(),
incremental_lists: Default::default(),
}
}
}
fn default_font_size() -> u8 {
/// The configuration for lists when incremental lists are enabled.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct IncrementalListsConfig {
/// Whether to pause before a list begins.
#[serde(default)]
pub pause_before: Option<bool>,
/// Whether to pause after a list ends.
#[serde(default)]
pub pause_after: Option<bool>,
}
fn default_terminal_font_size() -> u8 {
16
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
/// The alignment to use when `defaults.max_columns` is set.
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MaxColumnsAlignment {
/// Align the presentation to the left.
Left,
/// Align the presentation on the center.
#[default]
Center,
/// Align the presentation to the right.
Right,
}
/// The alignment to use when `defaults.max_rows` is set.
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MaxRowsAlignment {
/// Align the presentation to the top.
Top,
/// Align the presentation on the center.
#[default]
Center,
/// Align the presentation to the bottom.
Bottom,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ValidateOverflows {
#[default]
@ -103,7 +193,8 @@ pub enum ValidateOverflows {
WhenDeveloping,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct OptionsConfig {
/// Whether slides are automatically terminated when a slide title is found.
@ -123,21 +214,31 @@ pub struct OptionsConfig {
/// Whether to be strict about parsing the presentation's front matter.
pub strict_front_matter_parsing: Option<bool>,
/// Assume snippets for these languages contain `+render` and render them automatically.
#[serde(default)]
pub auto_render_languages: Vec<SnippetLanguage>,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetConfig {
/// The properties for snippet execution.
#[serde(default)]
pub exec: SnippetExecConfig,
/// The properties for snippet execution.
#[serde(default)]
pub exec_replace: SnippetExecReplaceConfig,
/// The properties for snippet auto rendering.
#[serde(default)]
pub render: SnippetRenderConfig,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetExecConfig {
/// Whether to enable snippet execution.
@ -148,7 +249,17 @@ pub struct SnippetExecConfig {
pub custom: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetExecReplaceConfig {
/// Whether to enable snippet replace-executions, which automatically run code snippets without
/// the user's intervention.
pub enable: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetRenderConfig {
/// The number of threads to use when rendering.
@ -166,7 +277,8 @@ pub(crate) fn default_snippet_render_threads() -> usize {
2
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TypstConfig {
/// The pixels per inch when rendering latex/typst formulas.
@ -184,7 +296,8 @@ pub(crate) fn default_typst_ppi() -> u32 {
300
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct MermaidConfig {
/// The scaling parameter to be used in the mermaid CLI.
@ -202,8 +315,13 @@ pub(crate) fn default_mermaid_scale() -> u32 {
2
}
pub(crate) fn default_u16_max() -> u16 {
u16::MAX
}
/// The snippet execution configuration for a specific programming language.
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct LanguageSnippetExecutionConfig {
/// The filename to use for the snippet input file.
pub filename: String,
@ -214,9 +332,13 @@ pub struct LanguageSnippetExecutionConfig {
/// The commands to be run when executing snippets for this programming language.
pub commands: Vec<Vec<String>>,
/// The prefix to use to hide lines visually but still execute them.
pub hidden_line_prefix: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, ValueEnum, JsonSchema)]
#[derive(Clone, Debug, Default, Deserialize, ValueEnum)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ImageProtocol {
/// Automatically detect the best image protocol to use.
@ -255,10 +377,10 @@ impl TryFrom<&ImageProtocol> for GraphicsMode {
}
ImageProtocol::Iterm2 => GraphicsMode::Iterm2,
ImageProtocol::KittyLocal => {
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: TerminalEmulator::is_inside_tmux() }
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: TerminalCapabilities::is_inside_tmux() }
}
ImageProtocol::KittyRemote => {
GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux: TerminalEmulator::is_inside_tmux() }
GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux: TerminalCapabilities::is_inside_tmux() }
}
ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks,
#[cfg(feature = "sixel")]
@ -270,7 +392,8 @@ impl TryFrom<&ImageProtocol> for GraphicsMode {
}
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct KeyBindingsConfig {
/// The keys that cause the presentation to move forwards.
@ -330,6 +453,10 @@ pub struct KeyBindingsConfig {
/// The key binding to close the application.
#[serde(default = "default_exit_bindings")]
pub(crate) exit: Vec<KeyBinding>,
/// The key binding to suspend the application.
#[serde(default = "default_suspend_bindings")]
pub(crate) suspend: Vec<KeyBinding>,
}
impl Default for KeyBindingsConfig {
@ -348,10 +475,108 @@ impl Default for KeyBindingsConfig {
toggle_bindings: default_toggle_bindings_modal_bindings(),
close_modal: default_close_modal_bindings(),
exit: default_exit_bindings(),
suspend: default_suspend_bindings(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SpeakerNotesConfig {
/// The address in which to listen for speaker note events.
#[serde(default = "default_speaker_notes_listen_address")]
pub listen_address: SocketAddr,
/// The address in which to publish speaker notes events.
#[serde(default = "default_speaker_notes_publish_address")]
pub publish_address: SocketAddr,
/// Whether to always publish speaker notes.
#[serde(default)]
pub always_publish: bool,
}
impl Default for SpeakerNotesConfig {
fn default() -> Self {
Self {
listen_address: default_speaker_notes_listen_address(),
publish_address: default_speaker_notes_publish_address(),
always_publish: false,
}
}
}
/// The export configuration.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ExportConfig {
/// The dimensions to use for presentation exports.
pub dimensions: Option<ExportDimensionsConfig>,
/// Whether pauses should create new slides.
#[serde(default)]
pub pauses: PauseExportPolicy,
}
/// The policy for pauses when exporting.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum PauseExportPolicy {
/// Whether to ignore pauses.
#[default]
Ignore,
/// Create a new slide when a pause is found.
NewSlide,
}
/// The dimensions to use for presentation exports.
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ExportDimensionsConfig {
/// The number of rows.
pub rows: u16,
/// The number of columns.
pub columns: u16,
}
// The slide transition configuration.
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "style", deny_unknown_fields)]
pub struct SlideTransitionConfig {
/// The amount of time to take to perform the transition.
#[serde(default = "default_transition_duration_millis")]
pub duration_millis: u16,
/// The number of frames in a transition.
#[serde(default = "default_transition_frames")]
pub frames: usize,
/// The slide transition style.
pub animation: SlideTransitionStyleConfig,
}
// The slide transition style configuration.
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "style", rename_all = "snake_case", deny_unknown_fields)]
pub enum SlideTransitionStyleConfig {
/// Slide horizontally.
SlideHorizontal,
/// Fade the new slide into the previous one.
Fade,
/// Collapse the current slide into the center of the screen.
CollapseHorizontal,
}
fn make_keybindings<const N: usize>(raw_bindings: [&str; N]) -> Vec<KeyBinding> {
let mut bindings = Vec::new();
for binding in raw_bindings {
@ -409,17 +634,54 @@ fn default_close_modal_bindings() -> Vec<KeyBinding> {
}
fn default_exit_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-c>"])
make_keybindings(["<c-c>", "q"])
}
fn default_suspend_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-z>"])
}
fn default_transition_duration_millis() -> u16 {
1000
}
fn default_transition_frames() -> usize {
30
}
#[cfg(target_os = "linux")]
pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)
}
#[cfg(target_os = "macos")]
pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)
}
#[cfg(test)]
mod test {
use super::*;
use crate::input::user::CommandKeyBindings;
use crate::commands::keyboard::CommandKeyBindings;
#[test]
fn default_bindings() {
let config = KeyBindingsConfig::default();
CommandKeyBindings::try_from(config).expect("construction failed");
}
#[test]
fn default_options_serde() {
serde_yaml::from_str::<'_, OptionsConfig>("implicit_slide_ends: true").expect("failed to parse");
}
}

View File

@ -1,16 +1,20 @@
use crate::{
execute::SnippetExecutor,
input::{
source::Command,
user::{CommandKeyBindings, UserInput},
ImageRegistry, MarkdownParser, PresentationBuilderOptions, Resources, ThemeOptions, Themes, ThirdPartyRender,
code::execute::SnippetExecutor,
commands::{
keyboard::{CommandKeyBindings, KeyboardListener},
listener::Command,
},
markdown::elements::MarkdownElement,
presentation::Presentation,
processing::builder::{BuildError, PresentationBuilder},
render::{draw::TerminalDrawer, terminal::TerminalWrite},
ImageRegistry, MarkdownParser, PresentationBuilderOptions, PresentationTheme, Resources, Themes, ThirdPartyRender,
presentation::{
Presentation,
builder::{BuildError, PresentationBuilder},
},
render::TerminalDrawer,
terminal::emulator::TerminalEmulator,
theme::raw::PresentationTheme,
};
use std::{io, rc::Rc};
use std::{io, sync::Arc};
const PRESENTATION: &str = r#"
# Header 1
@ -37,16 +41,16 @@ fn greet(name: &str) -> String {
<!-- end_slide -->
"#;
pub struct ThemesDemo<W: TerminalWrite> {
pub struct ThemesDemo {
themes: Themes,
input: UserInput,
drawer: TerminalDrawer<W>,
input: KeyboardListener,
drawer: TerminalDrawer,
}
impl<W: TerminalWrite> ThemesDemo<W> {
pub fn new(themes: Themes, bindings: CommandKeyBindings, writer: W) -> io::Result<Self> {
let input = UserInput::new(bindings);
let drawer = TerminalDrawer::new(writer, Default::default(), 1)?;
impl ThemesDemo {
pub fn new(themes: Themes, bindings: CommandKeyBindings) -> io::Result<Self> {
let input = KeyboardListener::new(bindings);
let drawer = TerminalDrawer::new(Default::default(), Default::default())?;
Ok(Self { themes, input, drawer })
}
@ -62,7 +66,7 @@ impl<W: TerminalWrite> ThemesDemo<W> {
}
let mut current = 0;
loop {
self.drawer.render_slide(&presentations[current])?;
self.drawer.render_operations(presentations[current].current_slide().iter_visible_operations())?;
let command = self.next_command()?;
match command {
@ -99,21 +103,24 @@ impl<W: TerminalWrite> ThemesDemo<W> {
theme: &PresentationTheme,
) -> Result<Presentation, BuildError> {
let image_registry = ImageRegistry::default();
let mut resources = Resources::new("non_existent", image_registry.clone());
let resources = Resources::new("non_existent", "non_existent", image_registry.clone());
let mut third_party = ThirdPartyRender::default();
let options = PresentationBuilderOptions::default();
let executer = Rc::new(SnippetExecutor::default());
let options = PresentationBuilderOptions {
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
..Default::default()
};
let executer = Arc::new(SnippetExecutor::default());
let bindings_config = Default::default();
let builder = PresentationBuilder::new(
theme,
&mut resources,
resources,
&mut third_party,
executer,
&self.themes,
image_registry,
bindings_config,
options,
);
)?;
let mut elements = vec![MarkdownElement::SetexHeading { text: format!("theme: {theme_name}").into() }];
elements.extend(base_elements.iter().cloned());
builder.build(elements)

View File

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

313
src/export/exporter.rs Normal file
View File

@ -0,0 +1,313 @@
use crate::{
MarkdownParser, Resources,
code::execute::SnippetExecutor,
config::{KeyBindingsConfig, PauseExportPolicy},
export::output::{ExportRenderer, OutputFormat},
markdown::{parse::ParseError, text_style::Color},
presentation::{
Presentation,
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
poller::{Poller, PollerCommand},
},
render::{
RenderError,
operation::{AsRenderOperations, PollableState, RenderOperation},
properties::WindowSize,
},
theme::{ProcessingThemeError, raw::PresentationTheme},
third_party::ThirdPartyRender,
tools::{ExecutionError, ThirdPartyTools},
};
use crossterm::{
cursor::{MoveToColumn, MoveToNextLine, MoveUp},
execute,
style::{Print, PrintStyledContent, Stylize},
terminal::{Clear, ClearType},
};
use image::ImageError;
use std::{
fs, io,
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use tempfile::TempDir;
pub enum OutputDirectory {
Temporary(TempDir),
External(PathBuf),
}
impl OutputDirectory {
pub fn temporary() -> io::Result<Self> {
let dir = TempDir::with_suffix("presenterm")?;
Ok(Self::Temporary(dir))
}
pub fn external(path: PathBuf) -> io::Result<Self> {
fs::create_dir_all(&path)?;
Ok(Self::External(path))
}
pub(crate) fn path(&self) -> &Path {
match self {
Self::Temporary(temp) => temp.path(),
Self::External(path) => path,
}
}
}
/// Allows exporting presentations into PDF.
pub struct Exporter<'a> {
parser: MarkdownParser<'a>,
default_theme: &'a PresentationTheme,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
themes: Themes,
dimensions: WindowSize,
options: PresentationBuilderOptions,
}
impl<'a> Exporter<'a> {
/// Construct a new exporter.
#[allow(clippy::too_many_arguments)]
pub fn new(
parser: MarkdownParser<'a>,
default_theme: &'a PresentationTheme,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
themes: Themes,
mut options: PresentationBuilderOptions,
mut dimensions: WindowSize,
pause_policy: PauseExportPolicy,
) -> Self {
// We don't want dynamically highlighted code blocks.
options.allow_mutations = false;
options.theme_options.font_size_supported = true;
options.pause_create_new_slide = match pause_policy {
PauseExportPolicy::Ignore => false,
PauseExportPolicy::NewSlide => true,
};
// Make sure we have a 1:2 aspect ratio.
let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64);
dimensions.width = width as u16;
Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions }
}
fn build_renderer(
&mut self,
presentation_path: &Path,
output_directory: OutputDirectory,
renderer: OutputFormat,
) -> Result<ExportRenderer, ExportError> {
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
let elements = self.parser.parse(&content)?;
let mut presentation = PresentationBuilder::new(
self.default_theme,
self.resources.clone(),
&mut self.third_party,
self.code_executor.clone(),
&self.themes,
Default::default(),
KeyBindingsConfig::default(),
self.options.clone(),
)?
.build(elements)?;
Self::validate_theme_colors(&presentation)?;
let mut render = ExportRenderer::new(self.dimensions.clone(), output_directory, renderer);
Self::log("waiting for images to be generated and code to be executed, if any...")?;
Self::render_async_images(&mut presentation);
for (index, slide) in presentation.into_slides().into_iter().enumerate() {
let index = index + 1;
Self::log(&format!("processing slide {index}..."))?;
render.process_slide(slide)?;
}
Self::log("invoking weasyprint...")?;
Ok(render)
}
/// Export the given presentation into PDF.
pub fn export_pdf(
mut self,
presentation_path: &Path,
output_directory: OutputDirectory,
output_path: Option<&Path>,
) -> Result<(), ExportError> {
println!(
"exporting using rows={}, columns={}, width={}, height={}",
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
);
println!("checking for weasyprint...");
Self::validate_weasyprint_exists()?;
Self::log("weasyprint installation found")?;
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Pdf)?;
let pdf_path = match output_path {
Some(path) => path.to_path_buf(),
None => presentation_path.with_extension("pdf"),
};
render.generate(&pdf_path)?;
execute!(
io::stdout(),
PrintStyledContent(
format!("output file is at {}\n", pdf_path.display()).stylize().with(Color::Green.into())
)
)?;
Ok(())
}
/// Export the given presentation into HTML.
pub fn export_html(
mut self,
presentation_path: &Path,
output_directory: OutputDirectory,
output_path: Option<&Path>,
) -> Result<(), ExportError> {
println!(
"exporting using rows={}, columns={}, width={}, height={}",
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
);
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Html)?;
let output_path = match output_path {
Some(path) => path.to_path_buf(),
None => presentation_path.with_extension("html"),
};
render.generate(&output_path)?;
execute!(
io::stdout(),
PrintStyledContent(
format!("output file is at {}\n", output_path.display()).stylize().with(Color::Green.into())
)
)?;
Ok(())
}
fn render_async_images(presentation: &mut Presentation) {
let poller = Poller::launch();
let mut pollables = Vec::new();
for (index, slide) in presentation.iter_slides().enumerate() {
for op in slide.iter_operations() {
if let RenderOperation::RenderAsync(inner) = op {
// Send a pollable to the poller and keep one for ourselves.
poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index });
pollables.push(inner.pollable())
}
}
}
// Poll until they're all done
for mut pollable in pollables {
while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {}
}
// Replace render asyncs with new operations that contains the replaced image
// and any other unmodified operations.
for slide in presentation.iter_slides_mut() {
for op in slide.iter_operations_mut() {
if let RenderOperation::RenderAsync(inner) = op {
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
let new_operations = inner.as_render_operations(&window_size);
*op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
}
}
}
}
fn validate_weasyprint_exists() -> Result<(), ExportError> {
let result = ThirdPartyTools::weasyprint(&["--version"]).run_and_capture_stdout();
match result {
Ok(_) => Ok(()),
Err(ExecutionError::Execution { .. }) => Err(ExportError::WeasyprintMissing),
Err(e) => Err(e.into()),
}
}
fn validate_theme_colors(presentation: &Presentation) -> Result<(), ExportError> {
for slide in presentation.iter_slides() {
for operation in slide.iter_visible_operations() {
let RenderOperation::SetColors(colors) = operation else {
continue;
};
// The PDF requires a specific theme to be set, as "no background" means "what the
// browser uses" which is likely white and it will probably look terrible. It's
// better to err early and let you choose a theme that contains _some_ color.
if colors.background.is_none() {
return Err(ExportError::UnsupportedColor("background"));
}
if colors.foreground.is_none() {
return Err(ExportError::UnsupportedColor("foreground"));
}
}
}
Ok(())
}
fn log(text: &str) -> io::Result<()> {
execute!(
io::stdout(),
MoveUp(1),
Clear(ClearType::CurrentLine),
MoveToColumn(0),
Print(text),
MoveToNextLine(1)
)
}
}
#[derive(thiserror::Error, Debug)]
pub enum ExportError {
#[error("failed to read presentation: {0}")]
ReadPresentation(io::Error),
#[error("failed to parse presentation: {0}")]
ParsePresentation(#[from] ParseError),
#[error("failed to build presentation: {0}")]
BuildPresentation(#[from] BuildError),
#[error("unsupported {0} color in theme")]
UnsupportedColor(&'static str),
#[error("generating images: {0}")]
GeneratingImages(#[from] ImageError),
#[error(transparent)]
Execution(#[from] ExecutionError),
#[error("weasyprint not found")]
WeasyprintMissing,
#[error("processing theme: {0}")]
ProcessingTheme(#[from] ProcessingThemeError),
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("render: {0}")]
Render(#[from] RenderError),
}
#[derive(Debug)]
struct RenderMany(Vec<RenderOperation>);
impl AsRenderOperations for RenderMany {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
self.0.clone()
}
}

122
src/export/html.rs Normal file
View File

@ -0,0 +1,122 @@
use crate::markdown::text_style::{Color, TextAttribute, TextStyle};
use std::{borrow::Cow, fmt};
pub(crate) enum HtmlText {
Plain(String),
Styled { text: String, style: String },
}
impl HtmlText {
pub(crate) fn new(text: &str, style: &TextStyle, font_size: FontSize) -> Self {
if style == &TextStyle::default() {
return Self::Plain(text.to_string());
}
let mut css_styles = Vec::new();
let mut text_decorations = Vec::new();
for attr in style.iter_attributes() {
match attr {
TextAttribute::Bold => css_styles.push(Cow::Borrowed("font-weight: bold")),
TextAttribute::Italics => css_styles.push(Cow::Borrowed("font-style: italic")),
TextAttribute::Strikethrough => text_decorations.push(Cow::Borrowed("line-through")),
TextAttribute::Underlined => text_decorations.push(Cow::Borrowed("underline")),
TextAttribute::ForegroundColor(color) => {
let color = color_to_html(&color);
css_styles.push(format!("color: {color}").into());
}
TextAttribute::BackgroundColor(color) => {
let color = color_to_html(&color);
css_styles.push(format!("background-color: {color}").into());
}
};
}
if !text_decorations.is_empty() {
let text_decoration = text_decorations.join(" ");
css_styles.push(format!("text-decoration: {text_decoration}").into());
}
if style.size > 1 {
let font_size = font_size.scale(style.size);
css_styles.push(format!("font-size: {font_size}").into());
}
let css_style = css_styles.join("; ");
Self::Styled { text: text.to_string(), style: css_style }
}
}
impl fmt::Display for HtmlText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Plain(text) => write!(f, "{text}"),
Self::Styled { text, style } => write!(f, "<span style=\"{style}\">{text}</span>"),
}
}
}
pub(crate) enum FontSize {
Pixels(u16),
}
impl FontSize {
fn scale(&self, size: u8) -> String {
match self {
Self::Pixels(scale) => format!("{}px", scale * size as u16),
}
}
}
pub(crate) fn color_to_html(color: &Color) -> String {
match color {
Color::Black => "#000000".into(),
Color::DarkGrey => "#5a5a5a".into(),
Color::Red => "#ff0000".into(),
Color::DarkRed => "#8b0000".into(),
Color::Green => "#00ff00".into(),
Color::DarkGreen => "#006400".into(),
Color::Yellow => "#ffff00".into(),
Color::DarkYellow => "#8b8000".into(),
Color::Blue => "#0000ff".into(),
Color::DarkBlue => "#00008b".into(),
Color::Magenta => "#ff00ff".into(),
Color::DarkMagenta => "#8b008b".into(),
Color::Cyan => "#00ffff".into(),
Color::DarkCyan => "#008b8b".into(),
Color::White => "#ffffff".into(),
Color::Grey => "#808080".into(),
Color::Rgb { r, g, b } => format!("#{r:02x}{g:02x}{b:02x}"),
}
}
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
#[rstest]
#[case::none(TextStyle::default(), "")]
#[case::bold(TextStyle::default().bold(), "font-weight: bold")]
#[case::italics(TextStyle::default().italics(), "font-style: italic")]
#[case::bold_italics(TextStyle::default().bold().italics(), "font-weight: bold; font-style: italic")]
#[case::strikethrough(TextStyle::default().strikethrough(), "text-decoration: line-through")]
#[case::underlined(TextStyle::default().underlined(), "text-decoration: underline")]
#[case::strikethrough_underlined(
TextStyle::default().strikethrough().underlined(),
"text-decoration: line-through underline"
)]
#[case::foreground_color(TextStyle::default().fg_color(Color::new(1,2,3)), "color: #010203")]
#[case::background_color(TextStyle::default().bg_color(Color::new(1,2,3)), "background-color: #010203")]
#[case::font_size(TextStyle::default().size(3), "font-size: 6px")]
fn html_text(#[case] style: TextStyle, #[case] expected_style: &str) {
let html_text = HtmlText::new("", &style, FontSize::Pixels(2));
let style = match &html_text {
HtmlText::Plain(_) => "",
HtmlText::Styled { style, .. } => &style,
};
assert_eq!(style, expected_style);
}
#[test]
fn render_span() {
let html_text = HtmlText::new("hi", &TextStyle::default().bold(), FontSize::Pixels(1));
let rendered = html_text.to_string();
assert_eq!(rendered, "<span style=\"font-weight: bold\">hi</span>");
}
}

3
src/export/mod.rs Normal file
View File

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

260
src/export/output.rs Normal file
View File

@ -0,0 +1,260 @@
use super::{
exporter::{ExportError, OutputDirectory},
html::{FontSize, color_to_html},
};
use crate::{
export::html::HtmlText,
markdown::text_style::TextStyle,
presentation::Slide,
render::{engine::RenderEngine, properties::WindowSize},
terminal::{
image::printer::TerminalImage,
virt::{TerminalGrid, VirtualTerminal},
},
tools::ThirdPartyTools,
};
use std::{
fs, io,
path::{Path, PathBuf},
};
// A magical multiplier that converts a font size in pixels to a font width.
//
// There's probably something somewhere that specifies what the relationship
// really is but I found this by trial and error an I'm okay with that.
const FONT_SIZE_WIDTH: f64 = 0.605;
const FONT_SIZE: u16 = 10;
const LINE_HEIGHT: u16 = 12;
struct HtmlSlide {
rows: Vec<String>,
background_color: Option<String>,
}
impl HtmlSlide {
fn new(grid: TerminalGrid) -> Result<Self, ExportError> {
let mut rows = Vec::new();
rows.push(String::from("<div class=\"container\">"));
for (y, row) in grid.rows.into_iter().enumerate() {
let mut finalized_row = "<div class=\"content-line\"><pre>".to_string();
let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
let mut current_string = String::new();
let mut x = 0;
while x < row.len() {
let c = row[x];
if c.style != current_style {
finalized_row.push_str(&Self::finalize_string(&current_string, &current_style));
current_string = String::new();
current_style = c.style;
}
match c.character {
'<' => current_string.push_str("&lt;"),
'>' => current_string.push_str("&gt;"),
other => current_string.push(other),
}
if let Some(image) = grid.images.get(&(y as u16, x as u16)) {
let TerminalImage::Raw(raw_image) = image.image.image() else { panic!("not in raw image mode") };
let image_contents = raw_image.to_inline_html();
let width_pixels = (image.width_columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
let image_tag = format!(
"<img width=\"{width_pixels}\" src=\"{image_contents}\" style=\"position: absolute\" />"
);
current_string.push_str(&image_tag);
}
x += c.style.size as usize;
}
if !current_string.is_empty() {
finalized_row.push_str(&Self::finalize_string(&current_string, &current_style));
}
finalized_row.push_str("</pre></div>");
rows.push(finalized_row);
}
rows.push(String::from("</div>"));
Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) })
}
fn finalize_string(s: &str, style: &TextStyle) -> String {
HtmlText::new(s, style, FontSize::Pixels(FONT_SIZE)).to_string()
}
}
pub(crate) struct ContentManager {
output_directory: OutputDirectory,
}
impl ContentManager {
pub(crate) fn new(output_directory: OutputDirectory) -> Self {
Self { output_directory }
}
fn persist_file(&self, name: &str, data: &[u8]) -> io::Result<PathBuf> {
let path = self.output_directory.path().join(name);
fs::write(&path, data)?;
Ok(path)
}
}
pub(crate) enum OutputFormat {
Pdf,
Html,
}
pub(crate) struct ExportRenderer {
content_manager: ContentManager,
output_format: OutputFormat,
dimensions: WindowSize,
html_body: String,
background_color: Option<String>,
}
impl ExportRenderer {
pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory, output_type: OutputFormat) -> Self {
let image_manager = ContentManager::new(output_directory);
Self {
content_manager: image_manager,
dimensions,
html_body: "".to_string(),
background_color: None,
output_format: output_type,
}
}
pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> {
let mut terminal = VirtualTerminal::new(self.dimensions.clone(), Default::default());
let engine = RenderEngine::new(&mut terminal, self.dimensions.clone(), Default::default());
engine.render(slide.iter_operations())?;
let grid = terminal.into_contents();
let slide = HtmlSlide::new(grid)?;
if self.background_color.is_none() {
self.background_color.clone_from(&slide.background_color);
}
for row in slide.rows {
self.html_body.push_str(&row);
self.html_body.push('\n');
}
Ok(())
}
pub(crate) fn generate(self, output_path: &Path) -> Result<(), ExportError> {
let html_body = &self.html_body;
let script = include_str!("script.js");
let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
let height = self.dimensions.rows * LINE_HEIGHT;
let background_color = self.background_color.unwrap_or_else(|| "black".into());
let container = match self.output_format {
OutputFormat::Pdf => String::from("display: contents;"),
OutputFormat::Html => String::from(
"
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
",
),
};
let css = format!(
r"
pre {{
margin: 0;
padding: 0;
}}
span {{
display: inline-block;
}}
body {{
margin: 0;
font-size: {FONT_SIZE}px;
line-height: {LINE_HEIGHT}px;
width: {width}px;
height: {height}px;
transform-origin: top left;
background-color: {background_color};
}}
.container {{
{container}
}}
.content-line {{
line-height: {LINE_HEIGHT}px;
height: {LINE_HEIGHT}px;
margin: 0px;
width: {width}px;
}}
.hidden {{
display: none;
}}
@page {{
margin: 0;
height: {height}px;
width: {width}px;
}}"
);
let html_script = match self.output_format {
OutputFormat::Pdf => String::new(),
OutputFormat::Html => {
format!(
"
<script>
let originalWidth = {width};
let originalHeight = {height};
{script}
</script>"
)
}
};
let style = match self.output_format {
OutputFormat::Pdf => String::new(),
OutputFormat::Html => format!(
"
<head>
<style>
{css}
</style>
</head>
"
),
};
let html = format!(
r"
<html>
{style}
<body>
{html_body}
{html_script}
</body>
</html>"
);
let html_path = self.content_manager.persist_file("index.html", html.as_bytes())?;
let css_path = self.content_manager.persist_file("styles.css", css.as_bytes())?;
match self.output_format {
OutputFormat::Pdf => {
ThirdPartyTools::weasyprint(&[
"-s",
css_path.to_string_lossy().as_ref(),
"--presentational-hints",
"-e",
"utf8",
html_path.to_string_lossy().as_ref(),
output_path.to_string_lossy().as_ref(),
])
.run()?;
}
OutputFormat::Html => {
fs::write(output_path, html.as_bytes())?;
}
}
Ok(())
}
}

45
src/export/script.js Normal file
View File

@ -0,0 +1,45 @@
document.addEventListener('DOMContentLoaded', function() {
const allLines = document.querySelectorAll('body > div');
const pageBreakMarkers = document.querySelectorAll('.container');
let currentPageIndex = 0;
function showCurrentPage() {
allLines.forEach((line) => {
line.classList.add('hidden');
});
allLines[currentPageIndex].classList.remove('hidden');
}
function scaler() {
var w = document.documentElement.clientWidth;
var h = document.documentElement.clientHeight;
let widthScaledAmount= w/originalWidth;
let heightScaledAmount= h/originalHeight;
let scaledAmount = Math.min(widthScaledAmount, heightScaledAmount);
document.querySelector("body").style.transform = `scale(${scaledAmount})`;
}
function handleKeyPress(event) {
if (event.key === 'ArrowLeft') {
if (currentPageIndex > 0) {
currentPageIndex--;
showCurrentPage();
}
} else if (event.key === 'ArrowRight') {
if (currentPageIndex < pageBreakMarkers.length - 1) {
currentPageIndex++;
showCurrentPage();
}
}
}
document.addEventListener('keydown', handleKeyPress);
window.addEventListener("resize", scaler);
scaler();
showCurrentPage();
});

View File

@ -1,35 +0,0 @@
use std::{fs, io, path::PathBuf, time::SystemTime};
/// Watchers the presentation's file.
///
/// This uses polling rather than something fancier like `inotify`. The latter turned out to make
/// code too complex for little added gain. This instead keeps the last modified time for the given
/// path and uses that to determine if it's changed.
pub(crate) struct PresentationFileWatcher {
path: PathBuf,
last_modification: SystemTime,
}
impl PresentationFileWatcher {
/// Create a watcher over the given file path.
pub(crate) fn new<P: Into<PathBuf>>(path: P) -> Self {
let path = path.into();
let last_modification = fs::metadata(&path).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
Self { path, last_modification }
}
/// Checker whether this file has modifications.
pub(crate) fn has_modifications(&mut self) -> io::Result<bool> {
let Ok(metadata) = fs::metadata(&self.path) else {
// If the file no longer exists, it's technically changed since last time.
return Ok(true);
};
let modified_time = metadata.modified()?;
if modified_time > self.last_modification {
self.last_modification = modified_time;
Ok(true)
} else {
Ok(false)
}
}
}

View File

@ -1,3 +0,0 @@
pub(crate) mod fs;
pub(crate) mod source;
pub(crate) mod user;

View File

@ -1,37 +0,0 @@
//! Presenterm: a terminal slideshow presentation tool.
//!
//! This is not meant to be used as a crate!
pub(crate) mod custom;
pub(crate) mod demo;
pub(crate) mod diff;
pub(crate) mod execute;
pub(crate) mod export;
pub(crate) mod input;
pub(crate) mod markdown;
pub(crate) mod media;
pub(crate) mod presentation;
pub(crate) mod presenter;
pub(crate) mod processing;
pub(crate) mod render;
pub(crate) mod resource;
pub(crate) mod style;
pub(crate) mod theme;
pub(crate) mod third_party;
pub(crate) mod tools;
pub use crate::{
custom::{Config, ImageProtocol, ValidateOverflows},
demo::ThemesDemo,
execute::SnippetExecutor,
export::{ExportError, Exporter},
input::source::CommandSource,
markdown::parse::MarkdownParser,
media::{graphics::GraphicsMode, printer::ImagePrinter, register::ImageRegistry},
presenter::{PresentMode, Presenter, PresenterOptions},
processing::builder::{PresentationBuilderOptions, Themes},
render::highlighting::{CodeHighlighter, HighlightThemeSet},
resource::Resources,
theme::{LoadThemeError, PresentationTheme, PresentationThemeSet},
third_party::{ThirdPartyConfigs, ThirdPartyRender},
};

View File

@ -1,20 +1,62 @@
use clap::{error::ErrorKind, CommandFactory, Parser};
use comrak::Arena;
use directories::ProjectDirs;
use presenterm::{
CommandSource, Config, Exporter, GraphicsMode, HighlightThemeSet, ImagePrinter, ImageProtocol, ImageRegistry,
MarkdownParser, PresentMode, PresentationBuilderOptions, PresentationTheme, PresentationThemeSet, Presenter,
PresenterOptions, Resources, SnippetExecutor, Themes, ThemesDemo, ThirdPartyConfigs, ThirdPartyRender,
ValidateOverflows,
use crate::{
code::{execute::SnippetExecutor, highlighting::HighlightThemeSet},
commands::listener::CommandListener,
config::{Config, ImageProtocol, ValidateOverflows},
demo::ThemesDemo,
export::exporter::Exporter,
markdown::parse::MarkdownParser,
presentation::builder::{PresentationBuilderOptions, Themes},
presenter::{PresentMode, Presenter, PresenterOptions},
resource::Resources,
terminal::{
GraphicsMode,
image::printer::{ImagePrinter, ImageRegistry},
},
theme::{raw::PresentationTheme, registry::PresentationThemeRegistry},
third_party::{ThirdPartyConfigs, ThirdPartyRender},
};
use anyhow::anyhow;
use clap::{CommandFactory, Parser, error::ErrorKind};
use commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher};
use comrak::Arena;
use config::ConfigLoadError;
use crossterm::{
execute,
style::{PrintStyledContent, Stylize},
};
use directories::ProjectDirs;
use export::exporter::OutputDirectory;
use render::{engine::MaxSize, properties::WindowSize};
use std::{
env, io,
env::{self, current_dir},
io,
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use terminal::emulator::TerminalEmulator;
use theme::ThemeOptions;
mod code;
mod commands;
mod config;
mod demo;
mod export;
mod markdown;
mod presentation;
mod presenter;
mod render;
mod resource;
mod terminal;
mod theme;
mod third_party;
mod tools;
mod transitions;
mod ui;
mod utils;
const DEFAULT_THEME: &str = "dark";
const DEFAULT_EXPORT_PIXELS_PER_COLUMN: u16 = 20;
const DEFAULT_EXPORT_PIXELS_PER_ROW: u16 = DEFAULT_EXPORT_PIXELS_PER_COLUMN * 2;
/// Run slideshows from your terminal.
#[derive(Parser)]
@ -26,21 +68,26 @@ struct Cli {
path: Option<PathBuf>,
/// Export the presentation as a PDF rather than displaying it.
#[clap(short, long)]
#[clap(short, long, group = "export")]
export_pdf: bool,
/// Generate the PDF metadata without generating the PDF itself.
#[clap(long, hide = true)]
generate_pdf_metadata: bool,
/// Export the presentation as a HTML rather than displaying it.
#[clap(long, group = "export")]
export_html: bool,
/// The path in which to store temporary files used when exporting.
#[clap(long, requires = "export")]
export_temporary_path: Option<PathBuf>,
/// The output path for the exported PDF.
#[clap(short = 'o', long = "output", requires = "export")]
export_output: Option<PathBuf>,
/// Generate a JSON schema for the configuration file.
#[clap(long)]
#[cfg(feature = "json-schema")]
generate_config_file_schema: bool,
/// Run in export mode.
#[clap(long, hide = true)]
enable_export_mode: bool,
/// Use presentation mode.
#[clap(short, long, default_value_t = false)]
present: bool,
@ -50,9 +97,13 @@ struct Cli {
theme: Option<String>,
/// List all supported themes.
#[clap(long)]
#[clap(long, group = "target")]
list_themes: bool,
/// Print the theme in use.
#[clap(long, group = "target")]
current_theme: bool,
/// Display acknowledgements.
#[clap(long, group = "target")]
acknowledgements: bool,
@ -69,9 +120,21 @@ struct Cli {
#[clap(short = 'x', long)]
enable_snippet_execution: bool,
/// Enable code snippet auto execution via `+exec_replace` blocks.
#[clap(short = 'X', long)]
enable_snippet_execution_replace: bool,
/// The path to the configuration file.
#[clap(short, long)]
config_file: Option<String>,
/// Whether to publish speaker notes to local listeners.
#[clap(short = 'P', long, group = "speaker-notes")]
publish_speaker_notes: bool,
/// Whether to listen for speaker notes.
#[clap(short, long, group = "speaker-notes")]
listen_speaker_notes: bool,
}
fn create_splash() -> String {
@ -92,85 +155,195 @@ fn create_splash() -> String {
struct Customizations {
config: Config,
themes: Themes,
themes_path: Option<PathBuf>,
code_executor: SnippetExecutor,
}
fn load_customizations(config_file_path: Option<PathBuf>) -> Result<Customizations, Box<dyn std::error::Error>> {
let configs_path: PathBuf = match env::var("XDG_CONFIG_HOME") {
Ok(path) => Path::new(&path).join("presenterm"),
Err(_) => {
let Some(project_dirs) = ProjectDirs::from("", "", "presenterm") else {
return Ok(Default::default());
};
project_dirs.config_dir().into()
}
};
let themes = load_themes(&configs_path)?;
let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml"));
let config = Config::load(&config_file_path)?;
let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone())?;
Ok(Customizations { config, themes, code_executor })
}
impl Customizations {
fn load(config_file_path: Option<PathBuf>, cwd: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let configs_path: PathBuf = match env::var("XDG_CONFIG_HOME") {
Ok(path) => Path::new(&path).join("presenterm"),
Err(_) => {
let Some(project_dirs) = ProjectDirs::from("", "", "presenterm") else {
return Ok(Default::default());
};
project_dirs.config_dir().into()
}
};
let themes_path = configs_path.join("themes");
let themes = Self::load_themes(&themes_path)?;
let require_config_file = config_file_path.is_some();
let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml"));
let config = match Config::load(&config_file_path) {
Ok(config) => config,
Err(ConfigLoadError::NotFound) if !require_config_file => Default::default(),
Err(e) => return Err(e.into()),
};
let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone(), cwd.to_path_buf())?;
Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor })
}
fn load_themes(config_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
let themes_path = config_path.join("themes");
fn load_themes(themes_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
let mut highlight_themes = HighlightThemeSet::default();
highlight_themes.register_from_directory(themes_path.join("highlighting"))?;
let mut highlight_themes = HighlightThemeSet::default();
highlight_themes.register_from_directory(themes_path.join("highlighting"))?;
let mut presentation_themes = PresentationThemeRegistry::default();
presentation_themes.register_from_directory(themes_path)?;
let mut presentation_themes = PresentationThemeSet::default();
presentation_themes.register_from_directory(&themes_path)?;
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
Ok(themes)
}
fn display_acknowledgements() {
let acknowledgements = include_bytes!("../bat/acknowledgements.txt");
println!("{}", String::from_utf8_lossy(acknowledgements));
}
fn make_builder_options(config: &Config, mode: &PresentMode, force_default_theme: bool) -> PresentationBuilderOptions {
PresentationBuilderOptions {
allow_mutations: !matches!(mode, PresentMode::Export),
implicit_slide_ends: config.options.implicit_slide_ends.unwrap_or_default(),
command_prefix: config.options.command_prefix.clone().unwrap_or_default(),
image_attribute_prefix: config.options.image_attributes_prefix.clone().unwrap_or_else(|| "image:".to_string()),
incremental_lists: config.options.incremental_lists.unwrap_or_default(),
force_default_theme,
end_slide_shorthand: config.options.end_slide_shorthand.unwrap_or_default(),
print_modal_background: false,
strict_front_matter_parsing: config.options.strict_front_matter_parsing.unwrap_or(true),
enable_snippet_execution: config.snippet.exec.enable,
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
Ok(themes)
}
}
fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme {
let default_theme_name =
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
let Some(default_theme) = themes.presentation.load_by_name(default_theme_name) else {
let valid_themes = themes.presentation.theme_names().join(", ");
let error_message = format!("invalid theme name, valid themes are: {valid_themes}");
Cli::command().error(ErrorKind::InvalidValue, error_message).exit();
};
default_theme
struct CoreComponents {
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
resources: Resources,
printer: Arc<ImagePrinter>,
builder_options: PresentationBuilderOptions,
themes: Themes,
default_theme: PresentationTheme,
config: Config,
present_mode: PresentMode,
graphics_mode: GraphicsMode,
}
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
if cli.enable_export_mode || cli.export_pdf || cli.generate_pdf_metadata {
GraphicsMode::AsciiBlocks
} else {
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol);
match GraphicsMode::try_from(protocol) {
Ok(mode) => mode,
Err(_) => {
Cli::command().error(ErrorKind::InvalidValue, "sixel support was not enabled during compilation").exit()
impl CoreComponents {
fn new(cli: &Cli, path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let mut resources_path = path.parent().unwrap_or(Path::new("./")).to_path_buf();
if resources_path == Path::new("") {
resources_path = "./".into();
}
let resources_path = resources_path.canonicalize().unwrap_or(resources_path);
let Customizations { config, themes, code_executor, themes_path } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &resources_path)?;
let default_theme = Self::load_default_theme(&config, &themes, cli);
let force_default_theme = cli.theme.is_some();
let present_mode = match (cli.present, cli.export_pdf) {
(true, _) | (_, true) => PresentMode::Presentation,
(false, false) => PresentMode::Development,
};
let mut builder_options = Self::make_builder_options(&config, force_default_theme, cli.listen_speaker_notes);
if cli.enable_snippet_execution {
builder_options.enable_snippet_execution = true;
}
if cli.enable_snippet_execution_replace {
builder_options.enable_snippet_execution_replace = true;
}
let graphics_mode = Self::select_graphics_mode(cli, &config);
let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);
let registry = ImageRegistry::new(printer.clone());
let resources = Resources::new(
resources_path.clone(),
themes_path.unwrap_or_else(|| resources_path.clone()),
registry.clone(),
);
let third_party_config = ThirdPartyConfigs {
typst_ppi: config.typst.ppi.to_string(),
mermaid_scale: config.mermaid.scale.to_string(),
threads: config.snippet.render.threads,
};
let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path);
let code_executor = Arc::new(code_executor);
Ok(Self {
third_party,
code_executor,
resources,
printer,
builder_options,
themes,
default_theme,
config,
present_mode,
graphics_mode,
})
}
fn make_builder_options(
config: &Config,
force_default_theme: bool,
render_speaker_notes_only: bool,
) -> PresentationBuilderOptions {
PresentationBuilderOptions {
allow_mutations: true,
implicit_slide_ends: config.options.implicit_slide_ends.unwrap_or_default(),
command_prefix: config.options.command_prefix.clone().unwrap_or_default(),
image_attribute_prefix: config
.options
.image_attributes_prefix
.clone()
.unwrap_or_else(|| "image:".to_string()),
incremental_lists: config.options.incremental_lists.unwrap_or_default(),
force_default_theme,
end_slide_shorthand: config.options.end_slide_shorthand.unwrap_or_default(),
print_modal_background: false,
strict_front_matter_parsing: config.options.strict_front_matter_parsing.unwrap_or(true),
enable_snippet_execution: config.snippet.exec.enable,
enable_snippet_execution_replace: config.snippet.exec_replace.enable,
render_speaker_notes_only,
auto_render_languages: config.options.auto_render_languages.clone(),
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
pause_before_incremental_lists: config.defaults.incremental_lists.pause_before.unwrap_or(true),
pause_after_incremental_lists: config.defaults.incremental_lists.pause_after.unwrap_or(true),
pause_create_new_slide: false,
}
}
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
if cli.export_pdf | cli.export_html {
GraphicsMode::Raw
} else {
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol);
match GraphicsMode::try_from(protocol) {
Ok(mode) => mode,
Err(_) => Cli::command()
.error(ErrorKind::InvalidValue, "sixel support was not enabled during compilation")
.exit(),
}
}
}
fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme {
let default_theme_name =
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
let Some(default_theme) = themes.presentation.load_by_name(default_theme_name) else {
let valid_themes = themes.presentation.theme_names().join(", ");
let error_message = format!("invalid theme name, valid themes are: {valid_themes}");
Cli::command().error(ErrorKind::InvalidValue, error_message).exit();
};
default_theme
}
}
fn overflow_validation(mode: &PresentMode, config: &ValidateOverflows) -> bool {
struct SpeakerNotesComponents {
events_listener: Option<SpeakerNotesEventListener>,
events_publisher: Option<SpeakerNotesEventPublisher>,
}
impl SpeakerNotesComponents {
fn new(cli: &Cli, config: &Config, path: &Path) -> anyhow::Result<Self> {
let full_presentation_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let publish_speaker_notes =
cli.publish_speaker_notes || (config.speaker_notes.always_publish && !cli.listen_speaker_notes);
let events_publisher = publish_speaker_notes
.then(|| {
SpeakerNotesEventPublisher::new(config.speaker_notes.publish_address, full_presentation_path.clone())
})
.transpose()
.map_err(|e| anyhow!("failed to create speaker notes publisher: {e}"))?;
let events_listener = cli
.listen_speaker_notes
.then(|| SpeakerNotesEventListener::new(config.speaker_notes.listen_address, full_presentation_path))
.transpose()
.map_err(|e| anyhow!("failed to create speaker notes listener: {e}"))?;
Ok(Self { events_listener, events_publisher })
}
}
fn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -> bool {
match (config, mode) {
(ValidateOverflows::Always, _) => true,
(ValidateOverflows::Never, _) => false,
@ -180,88 +353,111 @@ fn overflow_validation(mode: &PresentMode, config: &ValidateOverflows) -> bool {
}
}
fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "json-schema")]
if cli.generate_config_file_schema {
let schema = schemars::schema_for!(Config);
serde_json::to_writer_pretty(io::stdout(), &schema).map_err(|e| format!("failed to write schema: {e}"))?;
return Ok(());
}
let Customizations { config, themes, code_executor } =
load_customizations(cli.config_file.clone().map(PathBuf::from))?;
let default_theme = load_default_theme(&config, &themes, &cli);
let force_default_theme = cli.theme.is_some();
let mode = match (cli.present, cli.enable_export_mode) {
(true, _) => PresentMode::Presentation,
(false, true) => PresentMode::Export,
(false, false) => PresentMode::Development,
};
let arena = Arena::new();
let parser = MarkdownParser::new(&arena);
if cli.acknowledgements {
display_acknowledgements();
let acknowledgements = include_bytes!("../bat/acknowledgements.txt");
println!("{}", String::from_utf8_lossy(acknowledgements));
return Ok(());
} else if cli.list_themes {
// Load this ahead of time so we don't do it when we're already in raw mode.
TerminalEmulator::capabilities();
let Customizations { config, themes, .. } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &current_dir()?)?;
let bindings = config.bindings.try_into()?;
let demo = ThemesDemo::new(themes, bindings, io::stdout())?;
let demo = ThemesDemo::new(themes, bindings)?;
demo.run()?;
return Ok(());
} else if cli.current_theme {
let Customizations { config, .. } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &current_dir()?)?;
let theme_name =
cli.theme.as_ref().or(config.defaults.theme.as_ref()).map(|s| s.as_str()).unwrap_or(DEFAULT_THEME);
println!("{theme_name}");
return Ok(());
}
// Disable this so we don't mess things up when generating PDFs
if cli.export_pdf {
TerminalEmulator::disable_capability_detection();
}
let path = cli.path.take().unwrap_or_else(|| {
let Some(path) = cli.path.clone() else {
Cli::command().error(ErrorKind::MissingRequiredArgument, "no path specified").exit();
});
let validate_overflows = overflow_validation(&mode, &config.defaults.validate_overflows) || cli.validate_overflows;
let resources_path = path.parent().unwrap_or(Path::new("/"));
let mut options = make_builder_options(&config, &mode, force_default_theme);
if cli.enable_snippet_execution {
options.enable_snippet_execution = true;
}
let graphics_mode = select_graphics_mode(&cli, &config);
let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);
let registry = ImageRegistry(printer.clone());
let resources = Resources::new(resources_path, registry.clone());
let third_party_config = ThirdPartyConfigs {
typst_ppi: config.typst.ppi.to_string(),
mermaid_scale: config.mermaid.scale.to_string(),
threads: config.snippet.render.threads,
};
let third_party = ThirdPartyRender::new(third_party_config, registry, resources_path);
let code_executor = Rc::new(code_executor);
if cli.export_pdf || cli.generate_pdf_metadata {
let mut exporter =
Exporter::new(parser, &default_theme, resources, third_party, code_executor, themes, options);
let mut args = Vec::new();
if let Some(theme) = cli.theme.as_ref() {
args.extend(["--theme", theme]);
}
if let Some(path) = cli.config_file.as_ref() {
args.extend(["--config-file", path]);
}
if cli.enable_snippet_execution {
args.push("-x");
}
let CoreComponents {
third_party,
code_executor,
resources,
printer,
mut builder_options,
themes,
default_theme,
config,
present_mode,
graphics_mode,
} = CoreComponents::new(&cli, &path)?;
let arena = Arena::new();
let parser = MarkdownParser::new(&arena);
let validate_overflows =
overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows;
if cli.export_pdf || cli.export_html {
let dimensions = match config.export.dimensions {
Some(dimensions) => WindowSize {
rows: dimensions.rows,
columns: dimensions.columns,
height: dimensions.rows * DEFAULT_EXPORT_PIXELS_PER_ROW,
width: dimensions.columns * DEFAULT_EXPORT_PIXELS_PER_COLUMN,
},
None => WindowSize::current(config.defaults.terminal_font_size)?,
};
let exporter = Exporter::new(
parser,
&default_theme,
resources,
third_party,
code_executor,
themes,
builder_options,
dimensions,
config.export.pauses,
);
let output_directory = match cli.export_temporary_path {
Some(path) => OutputDirectory::external(path),
None => OutputDirectory::temporary(),
}?;
if cli.export_pdf {
exporter.export_pdf(&path, &args)?;
exporter.export_pdf(&path, output_directory, cli.export_output.as_deref())?;
} else {
let meta = exporter.generate_metadata(&path)?;
println!("{}", serde_json::to_string_pretty(&meta)?);
exporter.export_html(&path, output_directory, cli.export_output.as_deref())?;
}
} else {
let commands = CommandSource::new(&path, config.bindings.clone())?;
options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. });
let SpeakerNotesComponents { events_listener, events_publisher } =
SpeakerNotesComponents::new(&cli, &config, &path)?;
let command_listener = CommandListener::new(config.bindings.clone(), events_listener)?;
builder_options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. });
let options = PresenterOptions {
builder_options: options,
mode,
builder_options,
mode: present_mode,
font_size_fallback: config.defaults.terminal_font_size,
bindings: config.bindings,
validate_overflows,
max_size: MaxSize {
max_columns: config.defaults.max_columns,
max_columns_alignment: config.defaults.max_columns_alignment,
max_rows: config.defaults.max_rows,
max_rows_alignment: config.defaults.max_rows_alignment,
},
transition: config.transition,
};
let presenter = Presenter::new(
&default_theme,
commands,
command_listener,
parser,
resources,
third_party,
@ -269,6 +465,7 @@ fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
themes,
printer,
options,
events_publisher,
);
presenter.present(&path)?;
}
@ -278,7 +475,8 @@ fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("{e}");
let _ =
execute!(io::stdout(), PrintStyledContent(format!("{e}\n").stylize().with(crossterm::style::Color::Red)));
std::process::exit(1);
}
}

View File

@ -1,297 +0,0 @@
use super::elements::{
Highlight, HighlightGroup, Percent, PercentParseError, Snippet, SnippetAttributes, SnippetLanguage,
};
use comrak::nodes::NodeCodeBlock;
use strum::EnumDiscriminants;
pub(crate) type ParseResult<T> = Result<T, CodeBlockParseError>;
pub(crate) struct CodeBlockParser;
impl CodeBlockParser {
pub(crate) fn parse(code_block: &NodeCodeBlock) -> ParseResult<Snippet> {
let (language, attributes) = Self::parse_block_info(&code_block.info)?;
let code = Snippet { contents: code_block.literal.clone(), language, attributes };
Ok(code)
}
fn parse_block_info(input: &str) -> ParseResult<(SnippetLanguage, SnippetAttributes)> {
let (language, input) = Self::parse_language(input);
let attributes = Self::parse_attributes(input)?;
if attributes.auto_render && !language.supports_auto_render() {
return Err(CodeBlockParseError::UnsupportedAttribute(language, "rendering"));
}
if attributes.width.is_some() && !attributes.auto_render {
return Err(CodeBlockParseError::NotRenderSnippet("width"));
}
Ok((language, attributes))
}
fn parse_language(input: &str) -> (SnippetLanguage, &str) {
let token = Self::next_identifier(input);
// this always returns `Ok` given we fall back to `Unknown` if we don't know the language.
let language = token.parse().expect("language parsing");
let rest = &input[token.len()..];
(language, rest)
}
fn parse_attributes(mut input: &str) -> ParseResult<SnippetAttributes> {
let mut attributes = SnippetAttributes::default();
let mut processed_attributes = Vec::new();
while let (Some(attribute), rest) = Self::parse_attribute(input)? {
let discriminant = AttributeDiscriminants::from(&attribute);
if processed_attributes.contains(&discriminant) {
return Err(CodeBlockParseError::DuplicateAttribute("duplicate attribute"));
}
match attribute {
Attribute::LineNumbers => attributes.line_numbers = true,
Attribute::Exec => attributes.execute = true,
Attribute::AutoRender => attributes.auto_render = true,
Attribute::HighlightedLines(lines) => attributes.highlight_groups = lines,
Attribute::Width(width) => attributes.width = Some(width),
};
processed_attributes.push(discriminant);
input = rest;
}
if attributes.highlight_groups.is_empty() {
attributes.highlight_groups.push(HighlightGroup::new(vec![Highlight::All]));
}
Ok(attributes)
}
fn parse_attribute(input: &str) -> ParseResult<(Option<Attribute>, &str)> {
let input = Self::skip_whitespace(input);
let (attribute, input) = match input.chars().next() {
Some('+') => {
let token = Self::next_identifier(&input[1..]);
let attribute = match token {
"line_numbers" => Attribute::LineNumbers,
"exec" => Attribute::Exec,
"render" => Attribute::AutoRender,
token if token.starts_with("width:") => {
let value = input.split_once("+width:").unwrap().1;
let (width, input) = Self::parse_width(value)?;
return Ok((Some(Attribute::Width(width)), input));
}
_ => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())),
};
(Some(attribute), &input[token.len() + 1..])
}
Some('{') => {
let (lines, input) = Self::parse_highlight_groups(&input[1..])?;
(Some(Attribute::HighlightedLines(lines)), input)
}
Some(_) => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())),
None => (None, input),
};
Ok((attribute, input))
}
fn parse_highlight_groups(input: &str) -> ParseResult<(Vec<HighlightGroup>, &str)> {
use CodeBlockParseError::InvalidHighlightedLines;
let Some((head, tail)) = input.split_once('}') else {
return Err(InvalidHighlightedLines("no enclosing '}'".into()));
};
let head = head.trim();
if head.is_empty() {
return Ok((Vec::new(), tail));
}
let mut highlight_groups = Vec::new();
for group in head.split('|') {
let group = Self::parse_highlight_group(group)?;
highlight_groups.push(group);
}
Ok((highlight_groups, tail))
}
fn parse_highlight_group(input: &str) -> ParseResult<HighlightGroup> {
let mut highlights = Vec::new();
for piece in input.split(',') {
let piece = piece.trim();
if piece == "all" {
highlights.push(Highlight::All);
continue;
}
match piece.split_once('-') {
Some((left, right)) => {
let left = Self::parse_number(left)?;
let right = Self::parse_number(right)?;
let right = right
.checked_add(1)
.ok_or_else(|| CodeBlockParseError::InvalidHighlightedLines(format!("{right} is too large")))?;
highlights.push(Highlight::Range(left..right));
}
None => {
let number = Self::parse_number(piece)?;
highlights.push(Highlight::Single(number));
}
}
}
Ok(HighlightGroup::new(highlights))
}
fn parse_number(input: &str) -> ParseResult<u16> {
input
.trim()
.parse()
.map_err(|_| CodeBlockParseError::InvalidHighlightedLines(format!("not a number: '{input}'")))
}
fn parse_width(input: &str) -> ParseResult<(Percent, &str)> {
let end_index = input.find(' ').unwrap_or(input.len());
let value = input[0..end_index].parse().map_err(CodeBlockParseError::InvalidWidth)?;
Ok((value, &input[end_index..]))
}
fn skip_whitespace(input: &str) -> &str {
input.trim_start_matches(' ')
}
fn next_identifier(input: &str) -> &str {
match input.split_once(' ') {
Some((token, _)) => token,
None => input,
}
}
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum CodeBlockParseError {
#[error("invalid code attribute: {0}")]
InvalidToken(String),
#[error("invalid highlighted lines: {0}")]
InvalidHighlightedLines(String),
#[error("invalid width: {0}")]
InvalidWidth(PercentParseError),
#[error("duplicate attribute: {0}")]
DuplicateAttribute(&'static str),
#[error("language {0:?} does not support {1}")]
UnsupportedAttribute(SnippetLanguage, &'static str),
#[error("attribute {0} can only be set in +render blocks")]
NotRenderSnippet(&'static str),
}
#[derive(EnumDiscriminants)]
enum Attribute {
LineNumbers,
Exec,
AutoRender,
HighlightedLines(Vec<HighlightGroup>),
Width(Percent),
}
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
use Highlight::*;
fn parse_language(input: &str) -> SnippetLanguage {
let (language, _) = CodeBlockParser::parse_block_info(input).expect("parse failed");
language
}
fn try_parse_attributes(input: &str) -> Result<SnippetAttributes, CodeBlockParseError> {
let (_, attributes) = CodeBlockParser::parse_block_info(input)?;
Ok(attributes)
}
fn parse_attributes(input: &str) -> SnippetAttributes {
try_parse_attributes(input).expect("parse failed")
}
#[test]
fn unknown_language() {
assert_eq!(parse_language("potato"), SnippetLanguage::Unknown("potato".to_string()));
}
#[test]
fn no_attributes() {
assert_eq!(parse_language("rust"), SnippetLanguage::Rust);
}
#[test]
fn one_attribute() {
let attributes = parse_attributes("bash +exec");
assert!(attributes.execute);
assert!(!attributes.line_numbers);
}
#[test]
fn two_attributes() {
let attributes = parse_attributes("bash +exec +line_numbers");
assert!(attributes.execute);
assert!(attributes.line_numbers);
}
#[test]
fn invalid_attributes() {
CodeBlockParser::parse_block_info("bash +potato").unwrap_err();
CodeBlockParser::parse_block_info("bash potato").unwrap_err();
}
#[rstest]
#[case::no_end("{")]
#[case::number_no_end("{42")]
#[case::comma_nothing("{42,")]
#[case::brace_comma("{,}")]
#[case::range_no_end("{42-")]
#[case::range_end("{42-}")]
#[case::too_many_ranges("{42-3-5}")]
#[case::range_comma("{42-,")]
#[case::too_large("{65536}")]
#[case::too_large_end("{1-65536}")]
fn invalid_line_highlights(#[case] input: &str) {
let input = format!("bash {input}");
CodeBlockParser::parse_block_info(&input).expect_err("parsed successfully");
}
#[test]
fn highlight_none() {
let attributes = parse_attributes("bash {}");
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Highlight::All])]);
}
#[test]
fn highlight_specific_lines() {
let attributes = parse_attributes("bash { 1, 2 , 3 }");
assert_eq!(attributes.highlight_groups, &[HighlightGroup::new(vec![Single(1), Single(2), Single(3)])]);
}
#[test]
fn highlight_line_range() {
let attributes = parse_attributes("bash { 1, 2-4,6 , all , 10 - 12 }");
assert_eq!(
attributes.highlight_groups,
&[HighlightGroup::new(vec![Single(1), Range(2..5), Single(6), All, Range(10..13)])]
);
}
#[test]
fn multiple_groups() {
let attributes = parse_attributes("bash {1-3,5 |6-9}");
assert_eq!(attributes.highlight_groups.len(), 2);
assert_eq!(attributes.highlight_groups[0], HighlightGroup::new(vec![Range(1..4), Single(5)]));
assert_eq!(attributes.highlight_groups[1], HighlightGroup::new(vec![Range(6..10)]));
}
#[test]
fn parse_width() {
let attributes = parse_attributes("mermaid +width:50% +render");
assert!(attributes.auto_render);
assert_eq!(attributes.width, Some(Percent(50)));
}
#[test]
fn invalid_width() {
try_parse_attributes("mermaid +width:50%% +render").expect_err("parse succeeded");
try_parse_attributes("mermaid +width: +render").expect_err("parse succeeded");
try_parse_attributes("mermaid +width:50%").expect_err("parse succeeded");
}
}

View File

@ -1,7 +1,7 @@
use crate::style::TextStyle;
use serde_with::DeserializeFromStr;
use std::{convert::Infallible, fmt::Write, iter, ops::Range, path::PathBuf, str::FromStr};
use strum::EnumIter;
use super::text_style::{Color, TextStyle, UndefinedPaletteColorError};
use crate::theme::{ColorPalette, raw::RawColor};
use comrak::nodes::AlertType;
use std::{fmt, iter, path::PathBuf, str::FromStr};
use unicode_width::UnicodeWidthStr;
/// A markdown element.
@ -14,13 +14,13 @@ pub(crate) enum MarkdownElement {
FrontMatter(String),
/// A setex heading.
SetexHeading { text: TextBlock },
SetexHeading { text: Line<RawColor> },
/// A normal heading.
Heading { level: u8, text: TextBlock },
Heading { level: u8, text: Line<RawColor> },
/// A paragraph, composed of text and line breaks.
Paragraph(Vec<ParagraphElement>),
/// A paragraph composed by a list of lines.
Paragraph(Vec<Line<RawColor>>),
/// An image.
Image { path: PathBuf, title: String, source_position: SourcePosition },
@ -31,7 +31,16 @@ pub(crate) enum MarkdownElement {
List(Vec<ListItem>),
/// A code snippet.
Snippet(Snippet),
Snippet {
/// The information line that specifies this code's language, attributes, etc.
info: String,
/// The code in this snippet.
code: String,
/// The position in the source file this snippet came from.
source_position: SourcePosition,
},
/// A table.
Table(Table),
@ -42,20 +51,30 @@ pub(crate) enum MarkdownElement {
/// An HTML comment.
Comment { comment: String, source_position: SourcePosition },
/// A quote.
BlockQuote(Vec<String>),
/// A block quote containing a list of lines.
BlockQuote(Vec<Line<RawColor>>),
/// An alert.
Alert {
/// The alert's type.
alert_type: AlertType,
/// The optional title.
title: Option<String>,
/// The content lines in this alert.
lines: Vec<Line<RawColor>>,
},
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SourcePosition {
#[derive(Clone, Copy, Debug, Default)]
pub struct SourcePosition {
pub(crate) start: LineColumn,
}
impl SourcePosition {
pub(crate) fn offset_lines(&self, offset: usize) -> SourcePosition {
let mut output = self.clone();
output.start.line += offset;
output
impl fmt::Display for SourcePosition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.start.line, self.start.column)
}
}
@ -65,7 +84,7 @@ impl From<comrak::nodes::Sourcepos> for SourcePosition {
}
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct LineColumn {
pub(crate) line: usize,
pub(crate) column: usize,
@ -77,32 +96,26 @@ impl From<comrak::nodes::LineColumn> for LineColumn {
}
}
/// The components that make up a paragraph.
///
/// This does not map one-to-one with the commonmark spec and only handles text (including its
/// style) and line breaks. Any other inlines that could show up on a paragraph, such as images,
/// are a [MarkdownElement] on their own.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ParagraphElement {
/// A block of text.
Text(TextBlock),
/// A line break.
LineBreak,
}
/// A block of text.
/// A text line.
///
/// Text is represented as a series of chunks, each with their own formatting.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub(crate) struct TextBlock(pub(crate) Vec<Text>);
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Line<C = Color>(pub(crate) Vec<Text<C>>);
impl TextBlock {
impl<C> Default for Line<C> {
fn default() -> Self {
Self(vec![])
}
}
impl<C> Line<C> {
/// Get the total width for this text.
pub(crate) fn width(&self) -> usize {
self.0.iter().map(|text| text.content.width()).sum()
}
}
impl Line<Color> {
/// Applies the given style to this text.
pub(crate) fn apply_style(&mut self, style: &TextStyle) {
for text in &mut self.0 {
@ -111,7 +124,19 @@ impl TextBlock {
}
}
impl<T: Into<Text>> From<T> for TextBlock {
impl Line<RawColor> {
/// Resolve the colors in this line.
pub(crate) fn resolve(self, palette: &ColorPalette) -> Result<Line<Color>, UndefinedPaletteColorError> {
let mut output = Vec::with_capacity(self.0.len());
for text in self.0 {
let style = text.style.resolve(palette)?;
output.push(Text::new(text.content, style));
}
Ok(Line(output))
}
}
impl<C, T: Into<Text<C>>> From<T> for Line<C> {
fn from(text: T) -> Self {
Self(vec![text.into()])
}
@ -121,25 +146,36 @@ impl<T: Into<Text>> From<T> for TextBlock {
///
/// This is the most granular text representation: a `String` and a style.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Text {
pub(crate) struct Text<C = Color> {
pub(crate) content: String,
pub(crate) style: TextStyle,
pub(crate) style: TextStyle<C>,
}
impl Text {
/// Construct a new styled text.
pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle) -> Self {
Self { content: content.into(), style }
impl<C> Default for Text<C> {
fn default() -> Self {
Self { content: Default::default(), style: TextStyle::default() }
}
}
impl From<String> for Text {
impl<C> Text<C> {
/// Construct a new styled text.
pub(crate) fn new<S: Into<String>>(content: S, style: TextStyle<C>) -> Self {
Self { content: content.into(), style }
}
/// Get the width of this text.
pub(crate) fn width(&self) -> usize {
self.content.width()
}
}
impl<C> From<String> for Text<C> {
fn from(text: String) -> Self {
Self { content: text, style: TextStyle::default() }
}
}
impl From<&str> for Text {
impl<C> From<&str> for Text<C> {
fn from(text: &str) -> Self {
Self { content: text.into(), style: TextStyle::default() }
}
@ -154,7 +190,7 @@ pub(crate) struct ListItem {
pub(crate) depth: u8,
/// The contents of this list item.
pub(crate) contents: TextBlock,
pub(crate) contents: Line<RawColor>,
/// The type of list item.
pub(crate) item_type: ListItemType,
@ -167,241 +203,10 @@ pub(crate) enum ListItemType {
Unordered,
/// A list item for an ordered list that uses parenthesis after the list item number.
OrderedParens,
OrderedParens(usize),
/// A list item for an ordered list that uses a period after the list item number.
OrderedPeriod,
}
/// A code snippet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Snippet {
/// The snippet itself.
pub(crate) contents: String,
/// The programming language this snippet is written in.
pub(crate) language: SnippetLanguage,
/// The attributes used for snippet.
pub(crate) attributes: SnippetAttributes,
}
impl Snippet {
pub(crate) fn visible_lines(&self) -> impl Iterator<Item = &str> {
let prefix = self.language.hidden_line_prefix();
self.contents.lines().filter(move |line| !prefix.is_some_and(|prefix| line.starts_with(prefix)))
}
pub(crate) fn executable_contents(&self) -> String {
if let Some(prefix) = self.language.hidden_line_prefix() {
self.contents.lines().fold(String::new(), |mut output, line| {
let line = line.strip_prefix(prefix).unwrap_or(line);
let _ = writeln!(output, "{line}");
output
})
} else {
self.contents.to_owned()
}
}
}
/// The language of a code snippet.
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, PartialOrd, Ord, DeserializeFromStr)]
pub enum SnippetLanguage {
Ada,
Asp,
Awk,
Bash,
BatchFile,
C,
CMake,
Crontab,
CSharp,
Clojure,
Cpp,
Css,
DLang,
Diff,
Docker,
Dotenv,
Elixir,
Elm,
Erlang,
Fish,
Go,
Haskell,
Html,
Java,
JavaScript,
Json,
Kotlin,
Latex,
Lua,
Makefile,
Mermaid,
Markdown,
Nix,
Nushell,
OCaml,
Perl,
Php,
Protobuf,
Puppet,
Python,
R,
Ruby,
Rust,
RustScript,
Scala,
Shell,
Sql,
Swift,
Svelte,
Terraform,
TypeScript,
Typst,
Unknown(String),
Xml,
Yaml,
Vue,
Zig,
Zsh,
}
impl SnippetLanguage {
pub(crate) fn supports_auto_render(&self) -> bool {
matches!(self, Self::Latex | Self::Typst | Self::Mermaid)
}
pub(crate) fn hidden_line_prefix(&self) -> Option<&'static str> {
use SnippetLanguage::*;
match self {
Rust => Some("# "),
Python | Bash | Fish | Shell | Zsh | Kotlin | Java | JavaScript | TypeScript | C | Cpp | Go => Some("/// "),
_ => None,
}
}
}
impl FromStr for SnippetLanguage {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
use SnippetLanguage::*;
let language = match s {
"ada" => Ada,
"asp" => Asp,
"awk" => Awk,
"bash" => Bash,
"c" => C,
"cmake" => CMake,
"crontab" => Crontab,
"csharp" => CSharp,
"clojure" => Clojure,
"cpp" | "c++" => Cpp,
"css" => Css,
"d" => DLang,
"diff" => Diff,
"docker" => Docker,
"dotenv" => Dotenv,
"elixir" => Elixir,
"elm" => Elm,
"erlang" => Erlang,
"fish" => Fish,
"go" => Go,
"haskell" => Haskell,
"html" => Html,
"java" => Java,
"javascript" | "js" => JavaScript,
"json" => Json,
"kotlin" => Kotlin,
"latex" => Latex,
"lua" => Lua,
"make" => Makefile,
"markdown" => Markdown,
"mermaid" => Mermaid,
"nix" => Nix,
"nushell" | "nu" => Nushell,
"ocaml" => OCaml,
"perl" => Perl,
"php" => Php,
"protobuf" => Protobuf,
"puppet" => Puppet,
"python" => Python,
"r" => R,
"ruby" => Ruby,
"rust" => Rust,
"rust-script" => RustScript,
"scala" => Scala,
"shell" | "sh" => Shell,
"sql" => Sql,
"svelte" => Svelte,
"swift" => Swift,
"terraform" => Terraform,
"typescript" | "ts" => TypeScript,
"typst" => Typst,
"xml" => Xml,
"yaml" => Yaml,
"vue" => Vue,
"zig" => Zig,
"zsh" => Zsh,
other => Unknown(other.to_string()),
};
Ok(language)
}
}
/// Attributes for code snippets.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SnippetAttributes {
/// Whether the snippet is marked as executable.
pub(crate) execute: bool,
/// Whether a snippet is marked to be auto rendered.
///
/// An auto rendered snippet is transformed during parsing, leading to some visual
/// representation of it being shown rather than the original code.
pub(crate) auto_render: bool,
/// Whether the snippet should show line numbers.
pub(crate) line_numbers: bool,
/// The groups of lines to highlight.
pub(crate) highlight_groups: Vec<HighlightGroup>,
/// The width of the generated image.
///
/// Only valid for +render snippets.
pub(crate) width: Option<Percent>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct HighlightGroup(Vec<Highlight>);
impl HighlightGroup {
pub(crate) fn new(highlights: Vec<Highlight>) -> Self {
Self(highlights)
}
pub(crate) fn contains(&self, line_number: u16) -> bool {
for higlight in &self.0 {
match higlight {
Highlight::All => return true,
Highlight::Single(number) if number == &line_number => return true,
Highlight::Range(range) if range.contains(&line_number) => return true,
_ => continue,
};
}
false
}
}
/// A highlighted set of lines
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Highlight {
All,
Single(u16),
Range(Range<u16>),
OrderedPeriod(usize),
}
/// A table.
@ -423,7 +228,7 @@ impl Table {
/// Iterates all the text entries in a column.
///
/// This includes the header.
pub(crate) fn iter_column(&self, column: usize) -> impl Iterator<Item = &TextBlock> {
pub(crate) fn iter_column(&self, column: usize) -> impl Iterator<Item = &Line<RawColor>> {
let header_element = &self.header.0[column];
let row_elements = self.rows.iter().map(move |row| &row.0[column]);
iter::once(header_element).chain(row_elements)
@ -432,7 +237,7 @@ impl Table {
/// A table row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TableRow(pub(crate) Vec<TextBlock>);
pub(crate) struct TableRow(pub(crate) Vec<Line<RawColor>>);
/// A percentage.
#[derive(Clone, Debug, PartialEq, Eq)]
@ -471,82 +276,3 @@ pub enum PercentParseError {
#[error("unexpected: '{0}'")]
Trailer(String),
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn code_visible_lines_bash() {
let contents = r"echo 'hello world'
/// echo 'this was hidden'
echo '/// is the prefix'
/// echo 'the prefix is /// '
echo 'hello again'
"
.to_string();
let expected = vec!["echo 'hello world'", "", "echo '/// is the prefix'", "echo 'hello again'"];
let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() };
assert_eq!(expected, code.visible_lines().collect::<Vec<_>>());
}
#[test]
fn code_visible_lines_rust() {
let contents = r##"# fn main() {
println!("Hello world");
# // The prefix is # .
# }
"##
.to_string();
let expected = vec!["println!(\"Hello world\");"];
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
assert_eq!(expected, code.visible_lines().collect::<Vec<_>>());
}
#[test]
fn code_executable_contents_bash() {
let contents = r"echo 'hello world'
/// echo 'this was hidden'
echo '/// is the prefix'
/// echo 'the prefix is /// '
echo 'hello again'
"
.to_string();
let expected = r"echo 'hello world'
echo 'this was hidden'
echo '/// is the prefix'
echo 'the prefix is /// '
echo 'hello again'
"
.to_string();
let code = Snippet { contents, language: SnippetLanguage::Bash, attributes: Default::default() };
assert_eq!(expected, code.executable_contents());
}
#[test]
fn code_executable_contents_rust() {
let contents = r##"# fn main() {
println!("Hello world");
# // The prefix is # .
# }
"##
.to_string();
let expected = r##"fn main() {
println!("Hello world");
// The prefix is # .
}
"##
.to_string();
let code = Snippet { contents, language: SnippetLanguage::Rust, attributes: Default::default() };
assert_eq!(expected, code.executable_contents());
}
}

195
src/markdown/html.rs Normal file
View File

@ -0,0 +1,195 @@
use super::text_style::{Color, TextStyle};
use crate::theme::raw::{ParseColorError, RawColor};
use std::{borrow::Cow, str, str::Utf8Error};
use tl::Attributes;
pub(crate) struct HtmlParseOptions {
pub(crate) strict: bool,
}
impl Default for HtmlParseOptions {
fn default() -> Self {
Self { strict: true }
}
}
#[derive(Default)]
pub(crate) struct HtmlParser {
options: HtmlParseOptions,
}
impl HtmlParser {
pub(crate) fn parse(self, input: &str) -> Result<HtmlInline, ParseHtmlError> {
if input.starts_with("</") {
if input.starts_with("</span") {
return Ok(HtmlInline::CloseSpan);
} else {
return Err(ParseHtmlError::UnsupportedClosingTag(input.to_string()));
}
}
let dom = tl::parse(input, Default::default())?;
let top = dom.children().iter().next().ok_or(ParseHtmlError::NoTags)?;
let node = top.get(dom.parser()).expect("failed to get");
let tag = node.as_tag().ok_or(ParseHtmlError::NoTags)?;
if tag.name().as_bytes() != b"span" {
return Err(ParseHtmlError::UnsupportedHtml);
}
let style = self.parse_attributes(tag.attributes())?;
Ok(HtmlInline::OpenSpan { style })
}
fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle<RawColor>, ParseHtmlError> {
let mut style = TextStyle::default();
for (name, value) in attributes.iter() {
let value = value.unwrap_or(Cow::Borrowed(""));
match name.as_ref() {
"style" => self.parse_css_attribute(&value, &mut style)?,
"class" => {
style = style.fg_color(RawColor::ForegroundClass(value.to_string()));
style = style.bg_color(RawColor::BackgroundClass(value.to_string()));
}
_ => {
if self.options.strict {
return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string()));
}
}
}
}
Ok(style)
}
fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle<RawColor>) -> Result<(), ParseHtmlError> {
for attribute in attribute.split(';') {
let attribute = attribute.trim();
if attribute.is_empty() {
continue;
}
let (key, value) = attribute.split_once(':').ok_or(ParseHtmlError::NoColonInAttribute)?;
let key = key.trim();
let value = value.trim();
match key {
"color" => style.colors.foreground = Some(Self::parse_color(value)?),
"background-color" => style.colors.background = Some(Self::parse_color(value)?),
_ => {
if self.options.strict {
return Err(ParseHtmlError::UnsupportedCssAttribute(key.into()));
}
}
}
}
Ok(())
}
fn parse_color(input: &str) -> Result<RawColor, ParseHtmlError> {
if input.starts_with('#') {
let color = input.strip_prefix('#').unwrap().parse()?;
if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) }
} else {
let color = input.parse::<RawColor>()?;
if matches!(color, RawColor::Color(Color::Rgb { .. })) {
Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into()))
} else {
Ok(color)
}
}
}
}
#[derive(Debug)]
pub(crate) enum HtmlInline {
OpenSpan { style: TextStyle<RawColor> },
CloseSpan,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseHtmlError {
#[error("parsing html failed: {0}")]
ParsingHtml(#[from] tl::ParseError),
#[error("no html tags found")]
NoTags,
#[error("non utf8 content: {0}")]
NotUtf8(#[from] Utf8Error),
#[error("attribute has no ':'")]
NoColonInAttribute,
#[error("invalid color: {0}")]
InvalidColor(String),
#[error("invalid css attribute: {0}")]
UnsupportedCssAttribute(String),
#[error("HTML can only contain span tags")]
UnsupportedHtml,
#[error("unsupported tag attribute: {0}")]
UnsupportedTagAttribute(String),
#[error("unsupported closing tag: {0}")]
UnsupportedClosingTag(String),
}
impl From<ParseColorError> for ParseHtmlError {
fn from(e: ParseColorError) -> Self {
Self::InvalidColor(e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn parse_style() {
let tag =
HtmlParser::default().parse(r#"<span style="color: red; background-color: black">"#).expect("parse failed");
let HtmlInline::OpenSpan { style } = tag else { panic!("not an open tag") };
assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red));
}
#[test]
fn parse_class() {
let tag = HtmlParser::default().parse(r#"<span class="foo">"#).expect("parse failed");
let HtmlInline::OpenSpan { style } = tag else { panic!("not an open tag") };
assert_eq!(
style,
TextStyle::default()
.bg_color(RawColor::BackgroundClass("foo".into()))
.fg_color(RawColor::ForegroundClass("foo".into()))
);
}
#[test]
fn parse_end_tag() {
let tag = HtmlParser::default().parse("</span>").expect("parse failed");
assert!(matches!(tag, HtmlInline::CloseSpan));
}
#[rstest]
#[case::invalid_start_tag("<div>")]
#[case::invalid_end_tag("</div>")]
#[case::invalid_attribute("<span foo=\"bar\">")]
#[case::invalid_attribute("<span style=\"bleh: 42\"")]
#[case::invalid_color("<span style=\"color: 42\"")]
fn parse_invalid_html(#[case] input: &str) {
HtmlParser::default().parse(input).expect_err("parse succeeded");
}
#[rstest]
#[case::rgb("#ff0000", Color::Rgb{r: 255, g: 0, b: 0})]
#[case::red("red", Color::Red)]
fn parse_color(#[case] input: &str, #[case] expected: Color) {
let color = HtmlParser::parse_color(input).expect("parse failed");
assert_eq!(color, expected.into());
}
#[rstest]
#[case::rgb("ff0000")]
#[case::red("#red")]
fn parse_invalid_color(#[case] input: &str) {
HtmlParser::parse_color(input).expect_err("parse succeeded");
}
}

View File

@ -1,4 +1,5 @@
pub(crate) mod code;
pub(crate) mod elements;
pub(crate) mod html;
pub(crate) mod parse;
pub(crate) mod text;
pub(crate) mod text_style;

View File

@ -1,31 +1,29 @@
use super::{code::CodeBlockParseError, elements::SourcePosition};
use crate::{
markdown::{
code::CodeBlockParser,
elements::{ListItem, ListItemType, MarkdownElement, ParagraphElement, Table, TableRow, Text, TextBlock},
},
style::TextStyle,
use super::{
elements::{Line, ListItem, ListItemType, MarkdownElement, SourcePosition, Table, TableRow, Text},
html::{HtmlInline, HtmlParser, ParseHtmlError},
text_style::TextStyle,
};
use crate::theme::raw::RawColor;
use comrak::{
Arena, ComrakOptions,
arena_tree::Node,
format_commonmark,
nodes::{
Ast, AstNode, ListDelimType, ListType, NodeCodeBlock, NodeHeading, NodeHtmlBlock, NodeList, NodeValue,
Sourcepos,
Ast, AstNode, ListDelimType, ListType, NodeAlert, NodeCodeBlock, NodeHeading, NodeHtmlBlock, NodeList,
NodeValue, Sourcepos,
},
parse_document, Arena, ComrakOptions, ListStyleType,
parse_document,
};
use std::{
cell::RefCell,
fmt::{self, Debug, Display},
io::BufWriter,
mem,
};
/// The result of parsing a markdown file.
pub(crate) type ParseResult<T> = Result<T, ParseError>;
struct ParserOptions(ComrakOptions<'static>);
struct ParserOptions(comrak::Options<'static>);
impl Default for ParserOptions {
fn default() -> Self {
@ -34,6 +32,8 @@ impl Default for ParserOptions {
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.multiline_block_quotes = true;
options.extension.alerts = true;
options.extension.wikilinks_title_before_pipe = true;
Self(options)
}
}
@ -43,7 +43,7 @@ impl Default for ParserOptions {
/// This takes the contents of a markdown file and parses it into a list of [MarkdownElement].
pub struct MarkdownParser<'a> {
arena: &'a Arena<AstNode<'a>>,
options: ComrakOptions<'static>,
options: comrak::Options<'static>,
}
impl<'a> MarkdownParser<'a> {
@ -56,37 +56,40 @@ impl<'a> MarkdownParser<'a> {
pub(crate) fn parse(&self, contents: &str) -> ParseResult<Vec<MarkdownElement>> {
let node = parse_document(self.arena, contents, &self.options);
let mut elements = Vec::new();
let mut lines_offset = 0;
for node in node.children() {
let mut parsed_elements =
self.parse_node(node).map_err(|e| ParseError::new(e.kind, e.sourcepos.offset_lines(lines_offset)))?;
if let Some(MarkdownElement::FrontMatter(contents)) = parsed_elements.first() {
lines_offset += contents.lines().count() + 2;
}
// comrak ignores the lines in the front matter so we need to offset this ourselves.
Self::adjust_source_positions(parsed_elements.iter_mut(), lines_offset);
let parsed_elements = self.parse_node(node).map_err(|e| ParseError::new(e.kind, e.sourcepos))?;
elements.extend(parsed_elements);
}
Ok(elements)
}
fn adjust_source_positions<'b>(elements: impl Iterator<Item = &'b mut MarkdownElement>, lines_offset: usize) {
for element in elements {
let position = match element {
MarkdownElement::FrontMatter(_)
| MarkdownElement::SetexHeading { .. }
| MarkdownElement::Heading { .. }
| MarkdownElement::Paragraph(_)
| MarkdownElement::Image { .. }
| MarkdownElement::List(_)
| MarkdownElement::Snippet(_)
| MarkdownElement::Table(_)
| MarkdownElement::ThematicBreak
| MarkdownElement::BlockQuote(_) => continue,
MarkdownElement::Comment { source_position, .. } => source_position,
};
*position = position.offset_lines(lines_offset);
/// Parse inlines in a markdown input.
pub(crate) fn parse_inlines(&self, line: &str) -> Result<Line<RawColor>, ParseInlinesError> {
let node = parse_document(self.arena, line, &self.options);
if node.children().count() == 0 {
return Ok(Default::default());
}
if node.children().count() > 1 {
return Err(ParseInlinesError("inline must be simple text".into()));
}
let node = node.first_child().expect("must have one child");
let data = node.data.borrow();
let NodeValue::Paragraph = &data.value else {
return Err(ParseInlinesError("inline must be simple text".into()));
};
let parser = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No);
let inlines = parser.parse(node).map_err(|e| ParseInlinesError(e.to_string()))?;
let mut output = Line::default();
for inline in inlines {
match inline {
Inline::Text(line) => {
output.0.extend(line.0);
}
Inline::Image { .. } => return Err(ParseInlinesError("images not supported".into())),
Inline::LineBreak => return Err(ParseInlinesError("line breaks not supported".into())),
};
}
Ok(output)
}
fn parse_node(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
@ -103,15 +106,16 @@ impl<'a> MarkdownParser<'a> {
NodeValue::Table(_) => self.parse_table(node)?,
NodeValue::CodeBlock(block) => Self::parse_code_block(block, data.sourcepos)?,
NodeValue::ThematicBreak => MarkdownElement::ThematicBreak,
NodeValue::HtmlBlock(block) => Self::parse_html_block(block, data.sourcepos)?,
NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => Self::parse_block_quote(node)?,
NodeValue::HtmlBlock(block) => self.parse_html_block(block, data.sourcepos)?,
NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => self.parse_block_quote(node)?,
NodeValue::Alert(alert) => self.parse_alert(alert, node)?,
other => return Err(ParseErrorKind::UnsupportedElement(other.identifier()).with_sourcepos(data.sourcepos)),
};
Ok(vec![element])
}
fn parse_front_matter(contents: &str) -> ParseResult<MarkdownElement> {
// Remote leading and trailing delimiters before parsing. This is quite poopy but hey, it
// Remove leading and trailing delimiters before parsing. This is quite poopy but hey, it
// works.
let contents = contents.strip_prefix("---\n").unwrap_or(contents);
let contents = contents.strip_prefix("---\r\n").unwrap_or(contents);
@ -122,7 +126,7 @@ impl<'a> MarkdownParser<'a> {
Ok(MarkdownElement::FrontMatter(contents.into()))
}
fn parse_html_block(block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult<MarkdownElement> {
fn parse_html_block(&self, block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult<MarkdownElement> {
let block = block.literal.trim();
let start_tag = "<!--";
let end_tag = "-->";
@ -134,31 +138,18 @@ impl<'a> MarkdownParser<'a> {
Ok(MarkdownElement::Comment { comment: block.into(), source_position: sourcepos.into() })
}
fn parse_block_quote(node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
// This renders the contents of this block quote AST as commonmark, given we otherwise
// would need to either do this outselves or pull the raw block contents off of the
// original raw string and that also isn't great.
let mut buffer = BufWriter::new(Vec::new());
let mut options = ParserOptions::default().0;
options.render.list_style = ListStyleType::Star;
format_commonmark(node, &options, &mut buffer)
.map_err(|e| ParseErrorKind::Internal(e.to_string()).with_sourcepos(node.data.borrow().sourcepos))?;
let buffer = buffer.into_inner().expect("unwrapping writer failed");
fn parse_block_quote(&self, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
let mut lines = Vec::new();
for line in String::from_utf8_lossy(&buffer).lines() {
let line = match line.find('>') {
Some(index) => line[index + 1..].trim(),
None => line,
};
let mut line = line.to_string();
// `format_commonmark` escapes these symbols so we un-escape them.
for escape in &["\\*", "\\!", "\\[", "\\]", "\\#", "\\`", "\\<", "\\>"] {
if line.contains(escape) {
line = line.replace(escape, &escape[1..]);
}
let inlines = InlinesParser::new(self.arena, SoftBreak::Newline, StringifyImages::Yes).parse(node)?;
for inline in inlines {
match inline {
Inline::Text(text) => lines.push(text),
Inline::LineBreak => lines.push(Line::from("")),
Inline::Image { .. } => {}
}
lines.push(line);
}
if lines.last() == Some(&Line::<RawColor>::from("")) {
lines.pop();
}
Ok(MarkdownElement::BlockQuote(lines))
}
@ -167,9 +158,16 @@ impl<'a> MarkdownParser<'a> {
if !block.fenced {
return Err(ParseErrorKind::UnfencedCodeBlock.with_sourcepos(sourcepos));
}
let code =
CodeBlockParser::parse(block).map_err(|e| ParseErrorKind::InvalidCodeBlock(e).with_sourcepos(sourcepos))?;
Ok(MarkdownElement::Snippet(code))
Ok(MarkdownElement::Snippet {
info: block.info.clone(),
code: block.literal.clone(),
source_position: sourcepos.into(),
})
}
fn parse_alert(&self, alert: &NodeAlert, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
let MarkdownElement::BlockQuote(lines) = self.parse_block_quote(node)? else { panic!("not a block quote") };
Ok(MarkdownElement::Alert { alert_type: alert.alert_type, title: alert.title.clone(), lines })
}
fn parse_heading(&self, heading: &NodeHeading, node: &'a AstNode<'a>) -> ParseResult<MarkdownElement> {
@ -183,12 +181,12 @@ impl<'a> MarkdownParser<'a> {
fn parse_paragraph(&self, node: &'a AstNode<'a>) -> ParseResult<Vec<MarkdownElement>> {
let mut elements = Vec::new();
let inlines = InlinesParser::new(self.arena).parse(node)?;
let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;
let mut paragraph_elements = Vec::new();
for inline in inlines {
match inline {
Inline::Text(text) => paragraph_elements.push(ParagraphElement::Text(text)),
Inline::LineBreak => paragraph_elements.push(ParagraphElement::LineBreak),
Inline::Text(text) => paragraph_elements.push(text),
Inline::LineBreak => (),
Inline::Image { path, title } => {
if !paragraph_elements.is_empty() {
elements.push(MarkdownElement::Paragraph(mem::take(&mut paragraph_elements)));
@ -207,8 +205,8 @@ impl<'a> MarkdownParser<'a> {
Ok(elements)
}
fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<TextBlock> {
let inlines = InlinesParser::new(self.arena).parse(node)?;
fn parse_text(&self, node: &'a AstNode<'a>) -> ParseResult<Line<RawColor>> {
let inlines = InlinesParser::new(self.arena, SoftBreak::Space, StringifyImages::No).parse(node)?;
let mut chunks = Vec::new();
for inline in inlines {
match inline {
@ -219,7 +217,7 @@ impl<'a> MarkdownParser<'a> {
}
};
}
Ok(TextBlock(chunks))
Ok(Line(chunks))
}
fn parse_list(&self, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
@ -245,8 +243,8 @@ impl<'a> MarkdownParser<'a> {
fn parse_list_item(&self, item: &NodeList, root: &'a AstNode<'a>, depth: u8) -> ParseResult<Vec<ListItem>> {
let item_type = match (item.list_type, item.delimiter) {
(ListType::Bullet, _) => ListItemType::Unordered,
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens,
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod,
(ListType::Ordered, ListDelimType::Paren) => ListItemType::OrderedParens(item.start),
(ListType::Ordered, ListDelimType::Period) => ListItemType::OrderedPeriod(item.start),
};
let mut elements = Vec::new();
for node in root.children() {
@ -311,15 +309,27 @@ impl<'a> MarkdownParser<'a> {
}
}
enum SoftBreak {
Newline,
Space,
}
enum StringifyImages {
Yes,
No,
}
struct InlinesParser<'a> {
inlines: Vec<Inline>,
pending_text: Vec<Text>,
pending_text: Vec<Text<RawColor>>,
arena: &'a Arena<AstNode<'a>>,
soft_break: SoftBreak,
stringify_images: StringifyImages,
}
impl<'a> InlinesParser<'a> {
fn new(arena: &'a Arena<AstNode<'a>>) -> Self {
Self { inlines: Vec::new(), pending_text: Vec::new(), arena }
fn new(arena: &'a Arena<AstNode<'a>>, soft_break: SoftBreak, stringify_images: StringifyImages) -> Self {
Self { inlines: Vec::new(), pending_text: Vec::new(), arena, soft_break, stringify_images }
}
fn parse(mut self, node: &'a AstNode<'a>) -> ParseResult<Vec<Inline>> {
@ -331,29 +341,63 @@ impl<'a> InlinesParser<'a> {
fn store_pending_text(&mut self) {
let chunks = mem::take(&mut self.pending_text);
if !chunks.is_empty() {
self.inlines.push(Inline::Text(TextBlock(chunks)));
self.inlines.push(Inline::Text(Line(chunks)));
}
}
fn process_node(&mut self, node: &'a AstNode<'a>, style: TextStyle) -> ParseResult<()> {
fn process_node(
&mut self,
node: &'a AstNode<'a>,
parent: &'a AstNode<'a>,
style: TextStyle<RawColor>,
) -> ParseResult<Option<HtmlStyle>> {
let data = node.data.borrow();
match &data.value {
NodeValue::Text(text) => {
self.pending_text.push(Text::new(text.clone(), style.clone()));
self.pending_text.push(Text::new(text.clone(), style));
}
NodeValue::Code(code) => {
self.pending_text.push(Text::new(code.literal.clone(), TextStyle::default().code()));
}
NodeValue::Strong => self.process_children(node, style.clone().bold())?,
NodeValue::Emph => self.process_children(node, style.clone().italics())?,
NodeValue::Strikethrough => self.process_children(node, style.clone().strikethrough())?,
NodeValue::SoftBreak => self.pending_text.push(Text::from(" ")),
NodeValue::Link(link) => self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link())),
NodeValue::Strong => self.process_children(node, style.bold())?,
NodeValue::Emph => self.process_children(node, style.italics())?,
NodeValue::Strikethrough => self.process_children(node, style.strikethrough())?,
NodeValue::SoftBreak => {
match self.soft_break {
SoftBreak::Newline => {
self.store_pending_text();
}
SoftBreak::Space => self.pending_text.push(Text::new(" ", style)),
};
}
NodeValue::Link(link) => {
let has_label = node.first_child().is_some();
if has_label {
self.process_children(node, TextStyle::default().link_label())?;
self.pending_text.push(Text::from(" ("));
}
self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url()));
if !link.title.is_empty() {
self.pending_text.push(Text::from(" \""));
self.pending_text.push(Text::new(link.title.clone(), TextStyle::default().link_title()));
self.pending_text.push(Text::from("\""));
}
if has_label {
self.pending_text.push(Text::from(")"));
}
}
NodeValue::WikiLink(link) => {
self.pending_text.push(Text::new(link.url.clone(), TextStyle::default().link_url()));
}
NodeValue::LineBreak => {
self.store_pending_text();
self.inlines.push(Inline::LineBreak);
}
NodeValue::Image(link) => {
if matches!(self.stringify_images, StringifyImages::Yes) {
self.pending_text.push(Text::from(format!("![{}]({})", link.title, link.url)));
return Ok(None);
}
self.store_pending_text();
// The image "title" contains inlines so we create a dummy paragraph node that
@ -371,24 +415,75 @@ impl<'a> InlinesParser<'a> {
let title = String::from_utf8_lossy(&buffer).trim_end().to_string();
self.inlines.push(Inline::Image { path: link.url.clone(), title });
}
NodeValue::Paragraph => {
self.process_children(node, style)?;
self.store_pending_text();
if matches!(parent.data.borrow().value, NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_)) {
self.inlines.push(Inline::LineBreak);
}
}
NodeValue::List(_) => {
self.process_children(node, style)?;
self.store_pending_text();
self.inlines.push(Inline::LineBreak);
}
NodeValue::Item(item) => {
match (item.list_type, item.delimiter) {
(ListType::Bullet, _) => self.pending_text.push(Text::from("* ")),
(ListType::Ordered, ListDelimType::Period) => {
self.pending_text.push(Text::from(format!("{}. ", item.start)))
}
(ListType::Ordered, ListDelimType::Paren) => {
self.pending_text.push(Text::from(format!("{}) ", item.start)))
}
};
self.process_children(node, style)?;
}
NodeValue::HtmlInline(html) => {
let html_inline = HtmlParser::default()
.parse(html)
.map_err(|e| ParseErrorKind::InvalidHtml(e).with_sourcepos(data.sourcepos))?;
match html_inline {
HtmlInline::OpenSpan { style } => return Ok(Some(HtmlStyle::Add(style))),
HtmlInline::CloseSpan => return Ok(Some(HtmlStyle::Remove)),
};
}
other => {
return Err(ParseErrorKind::UnsupportedStructure { container: "text", element: other.identifier() }
.with_sourcepos(data.sourcepos));
}
};
Ok(())
Ok(None)
}
fn process_children(&mut self, node: &'a AstNode<'a>, style: TextStyle) -> ParseResult<()> {
for node in node.children() {
self.process_node(node, style.clone())?;
fn process_children(&mut self, root: &'a AstNode<'a>, base_style: TextStyle<RawColor>) -> ParseResult<()> {
let mut html_styles = Vec::new();
let mut style = base_style.clone();
for node in root.children() {
if let Some(html_style) = self.process_node(node, root, style.clone())? {
match html_style {
HtmlStyle::Add(style) => html_styles.push(style),
HtmlStyle::Remove => {
html_styles.pop();
}
};
style = base_style.clone();
for html_style in html_styles.iter().rev() {
style.merge(html_style);
}
}
}
Ok(())
}
}
enum HtmlStyle {
Add(TextStyle<RawColor>),
Remove,
}
enum Inline {
Text(TextBlock),
Text(Line<RawColor>),
Image { path: String, title: String },
LineBreak,
}
@ -415,7 +510,7 @@ pub struct ParseError {
impl Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "parse error at {}:{}: {}", self.sourcepos.start.line, self.sourcepos.start.column, self.kind)
write!(f, "parse error at {}: {}", self.sourcepos, self.kind)
}
}
@ -437,8 +532,8 @@ pub(crate) enum ParseErrorKind {
/// We don't support unfenced code blocks.
UnfencedCodeBlock,
/// A code block contains invalid attributes.
InvalidCodeBlock(CodeBlockParseError),
/// Invalid HTML was found.
InvalidHtml(ParseHtmlError),
/// An internal parsing error.
Internal(String),
@ -452,7 +547,7 @@ impl Display for ParseErrorKind {
write!(f, "unsupported structure in {container}: {element}")
}
Self::UnfencedCodeBlock => write!(f, "only fenced code blocks are supported"),
Self::InvalidCodeBlock(error) => write!(f, "invalid code block: {error}"),
Self::InvalidHtml(inner) => write!(f, "invalid HTML: {inner}"),
Self::Internal(message) => write!(f, "internal error: {message}"),
}
}
@ -509,14 +604,22 @@ impl Identifier for NodeValue {
NodeValue::Underline => "underline",
NodeValue::SpoileredText => "spoilered text",
NodeValue::EscapedTag(_) => "escaped tag",
NodeValue::Subscript => "subscript",
NodeValue::Raw(_) => "raw",
NodeValue::Alert(_) => "alert",
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid markdown line: {0}")]
pub(crate) struct ParseInlinesError(String);
#[cfg(test)]
mod test {
use crate::markdown::text_style::Color;
use super::*;
use crate::markdown::elements::SnippetLanguage;
use rstest::rstest;
use std::path::Path;
@ -566,17 +669,96 @@ boop
Text::new("strikethrough", TextStyle::default().strikethrough()),
];
let expected_elements = &[ParagraphElement::Text(TextBlock(expected_chunks))];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
#[test]
fn link() {
fn html_inlines() {
let parsed = parse_single(
"hi<span style=\"color: red\">red<span style=\"background-color: blue\">blue<span style=\"color: yellow\">yellow</span></span></span>",
);
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
let expected_chunks = vec![
Text::from("hi"),
Text::new("red", TextStyle::default().fg_color(Color::Red)),
Text::new("blue", TextStyle::default().fg_color(Color::Red).bg_color(Color::Blue)),
Text::new("yellow", TextStyle::default().fg_color(Color::Yellow).bg_color(Color::Blue)),
];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
#[test]
fn link_wo_label_wo_title() {
let parsed = parse_single("my [](https://example.com)");
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
let expected_chunks =
vec![Text::from("my "), Text::new("https://example.com", TextStyle::default().link_url())];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
#[test]
fn link_w_label_wo_title() {
let parsed = parse_single("my [website](https://example.com)");
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
let expected_chunks = vec![Text::from("my "), Text::new("https://example.com", TextStyle::default().link())];
let expected_chunks = vec![
Text::from("my "),
Text::new("website", TextStyle::default().link_label()),
Text::from(" ("),
Text::new("https://example.com", TextStyle::default().link_url()),
Text::from(")"),
];
let expected_elements = &[ParagraphElement::Text(TextBlock(expected_chunks))];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
#[test]
fn link_wo_label_w_title() {
let parsed = parse_single("my [](https://example.com \"Example\")");
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
let expected_chunks = vec![
Text::from("my "),
Text::new("https://example.com", TextStyle::default().link_url()),
Text::from(" \""),
Text::new("Example", TextStyle::default().link_title()),
Text::from("\""),
];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
#[test]
fn link_w_label_w_title() {
let parsed = parse_single("my [website](https://example.com \"Example\")");
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
let expected_chunks = vec![
Text::from("my "),
Text::new("website", TextStyle::default().link_label()),
Text::from(" ("),
Text::new("https://example.com", TextStyle::default().link_url()),
Text::from(" \""),
Text::new("Example", TextStyle::default().link_title()),
Text::from("\""),
Text::from(")"),
];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
#[test]
fn wikilink_wo_title() {
let parsed = parse_single("[[https://example.com]]");
let MarkdownElement::Paragraph(elements) = parsed else { panic!("not a paragraph: {parsed:?}") };
let expected_chunks = vec![Text::new("https://example.com", TextStyle::default().link_url())];
let expected_elements = &[Line(expected_chunks)];
assert_eq!(elements, expected_elements);
}
@ -640,6 +822,26 @@ Title
assert_eq!(next().depth, 0);
}
#[test]
fn ordered_list_starting_non_one() {
let parsed = parse_single(
r"
4. One
1. Sub1
2. Sub2
5. Two
6. Three",
);
let MarkdownElement::List(items) = parsed else { panic!("not a list: {parsed:?}") };
let mut items = items.into_iter();
let mut next = || items.next().expect("list ended prematurely");
assert_eq!(next().item_type, ListItemType::OrderedPeriod(4));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(1));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(2));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(5));
assert_eq!(next().item_type, ListItemType::OrderedPeriod(6));
}
#[test]
fn line_breaks() {
let parsed = parse_all(
@ -654,42 +856,25 @@ another",
assert_eq!(parsed.len(), 2);
let MarkdownElement::Paragraph(elements) = &parsed[0] else { panic!("not a line break: {parsed:?}") };
assert_eq!(elements.len(), 3);
assert_eq!(elements.len(), 2);
let expected_chunks = &[Text::from("some text"), Text::from(" "), Text::from("with line breaks")];
let ParagraphElement::Text(text) = &elements[0] else { panic!("non-text in paragraph") };
let text = &elements[0];
assert_eq!(text.0, expected_chunks);
assert!(matches!(&elements[1], ParagraphElement::LineBreak));
assert!(matches!(&elements[2], ParagraphElement::Text(_)));
}
#[test]
fn code_block() {
let parsed = parse_single(
r"
```rust
```rust +exec
let q = 42;
````
",
);
let MarkdownElement::Snippet(code) = parsed else { panic!("not a code block: {parsed:?}") };
assert_eq!(code.language, SnippetLanguage::Rust);
assert_eq!(code.contents, "let q = 42;\n");
assert!(!code.attributes.execute);
}
#[test]
fn executable_code_block() {
let parsed = parse_single(
r"
```bash +exec
echo hi mom
````
",
);
let MarkdownElement::Snippet(code) = parsed else { panic!("not a code block: {parsed:?}") };
assert_eq!(code.language, SnippetLanguage::Bash);
assert!(code.attributes.execute);
let MarkdownElement::Snippet { info, code, .. } = parsed else { panic!("not a code block: {parsed:?}") };
assert_eq!(info, "rust +exec");
assert_eq!(code, "let q = 42;\n");
}
#[test]
@ -699,7 +884,7 @@ echo hi mom
let expected_chunks = &[Text::from("some "), Text::new("inline code", TextStyle::default().code())];
assert_eq!(elements.len(), 1);
let ParagraphElement::Text(text) = &elements[0] else { panic!("non-text in paragraph") };
let text = &elements[0];
assert_eq!(text.0, expected_chunks);
}
@ -749,20 +934,38 @@ echo hi mom
fn block_quote() {
let parsed = parse_single(
r#"
> bar!@#$%^&*()[]'"{}-=`~,.<>/?
> foo
> foo **is not** bar
> ![](hehe.png) test ![](potato.png)
>
> * a
> * b
>
> 1. a
> 2. b
>
> 1) a
> 2) b
"#,
);
let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") };
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], "bar!@#$%^&*()[]'\"{}-=`~,.<>/?");
assert_eq!(lines[1], "foo");
assert_eq!(lines[2], "");
assert_eq!(lines[3], "* a");
assert_eq!(lines[4], "* b");
assert_eq!(lines.len(), 11);
assert_eq!(
lines[0],
Line(vec![Text::from("foo "), Text::new("is not", TextStyle::default().bold()), Text::from(" bar")])
);
assert_eq!(
lines[1],
Line(vec![Text::from("![](hehe.png)"), Text::from(" test "), Text::from("![](potato.png)")])
);
assert_eq!(lines[2], Line::from(""));
assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")]));
assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")]));
assert_eq!(lines[5], Line::from(""));
assert_eq!(lines[6], Line(vec![Text::from("1. "), Text::from("a")]));
assert_eq!(lines[7], Line(vec![Text::from("2. "), Text::from("b")]));
assert_eq!(lines[8], Line::from(""));
assert_eq!(lines[9], Line(vec![Text::from("1) "), Text::from("a")]));
assert_eq!(lines[10], Line(vec![Text::from("2) "), Text::from("b")]));
}
#[test]
@ -779,11 +982,11 @@ foo
);
let MarkdownElement::BlockQuote(lines) = parsed else { panic!("not a block quote: {parsed:?}") };
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], "bar");
assert_eq!(lines[1], "foo");
assert_eq!(lines[2], "");
assert_eq!(lines[3], "* a");
assert_eq!(lines[4], "* b");
assert_eq!(lines[0], Line::from("bar"));
assert_eq!(lines[1], Line::from("foo"));
assert_eq!(lines[2], Line::from(""));
assert_eq!(lines[3], Line(vec![Text::from("* "), Text::from("a")]));
assert_eq!(lines[4], Line(vec![Text::from("* "), Text::from("b")]));
}
#[test]
@ -815,7 +1018,7 @@ mom
let Err(e) = result else {
panic!("parsing didn't fail");
};
assert_eq!(e.sourcepos.start.line, 5);
assert_eq!(e.sourcepos.start.line, 6);
assert_eq!(e.sourcepos.start.column, 3);
}
@ -831,7 +1034,7 @@ mom
",
);
let MarkdownElement::Comment { source_position, .. } = &parsed[1] else { panic!("not a comment") };
assert_eq!(source_position.start.line, 5);
assert_eq!(source_position.start.line, 6);
assert_eq!(source_position.start.column, 1);
}
@ -846,4 +1049,32 @@ mom
let expected = format!("hi{nl}mom{nl}");
assert_eq!(contents, &expected);
}
#[test]
fn parse_alert() {
let input = r"
> [!note]
> hi mom
> bye **mom**
";
let MarkdownElement::Alert { lines, .. } = parse_single(&input) else {
panic!("not an alert");
};
assert_eq!(lines.len(), 2);
}
#[test]
fn parse_inlines() {
let arena = Arena::new();
let input = "hello **mom** how _are you_?";
let parsed = MarkdownParser::new(&arena).parse_inlines(input).expect("parse failed");
let expected = &[
"hello ".into(),
Text::new("mom", TextStyle::default().bold()),
" how ".into(),
Text::new("are you", TextStyle::default().italics()),
"?".into(),
];
assert_eq!(parsed.0, expected);
}
}

View File

@ -1,42 +1,55 @@
use super::elements::{Text, TextBlock};
use crate::style::TextStyle;
use super::{
elements::{Line, Text},
text_style::TextStyle,
};
use std::mem;
use unicode_width::UnicodeWidthChar;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// A weighted block of text.
/// A weighted line of text.
///
/// The weight of a character is its given by its width in unicode.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct WeightedTextBlock(Vec<WeightedText>);
pub(crate) struct WeightedLine {
text: Vec<WeightedText>,
width: usize,
font_size: u8,
}
impl WeightedTextBlock {
impl WeightedLine {
/// Split this line into chunks of at most `max_length` width.
pub(crate) fn split(&self, max_length: usize) -> SplitTextIter {
SplitTextIter::new(&self.0, max_length)
SplitTextIter::new(&self.text, max_length)
}
/// The total width of this line.
pub(crate) fn width(&self) -> usize {
self.0.iter().map(|text| text.width()).sum()
self.width
}
/// The height of this line.
pub(crate) fn font_size(&self) -> u8 {
self.font_size
}
/// Get an iterator to the underlying text chunks.
#[cfg(test)]
pub(crate) fn iter_texts(&self) -> impl Iterator<Item = &WeightedText> {
self.0.iter()
self.text.iter()
}
}
impl From<TextBlock> for WeightedTextBlock {
fn from(block: TextBlock) -> Self {
impl From<Line> for WeightedLine {
fn from(block: Line) -> Self {
block.0.into()
}
}
impl From<Vec<Text>> for WeightedTextBlock {
impl From<Vec<Text>> for WeightedLine {
fn from(mut texts: Vec<Text>) -> Self {
let mut output = Vec::new();
let mut index = 0;
let mut width = 0;
let mut font_size = 1;
// Compact chunks so any consecutive chunk with the same style is merged into the same block.
while index < texts.len() {
let mut target = mem::replace(&mut texts[index], Text::from(""));
@ -46,17 +59,27 @@ impl From<Vec<Text>> for WeightedTextBlock {
target.content.push_str(&current_content);
current += 1;
}
let size = target.style.size.max(1);
width += target.content.width() * size as usize;
output.push(target.into());
index = current;
font_size = font_size.max(size);
}
Self(output)
Self { text: output, width, font_size }
}
}
impl From<String> for WeightedTextBlock {
impl From<String> for WeightedLine {
fn from(text: String) -> Self {
let texts = vec![WeightedText::from(text)];
Self(texts)
let width = text.width();
let text = vec![WeightedText::from(text)];
Self { text, width, font_size: 1 }
}
}
impl From<&str> for WeightedLine {
fn from(text: &str) -> Self {
Self::from(text.to_string())
}
}
@ -75,14 +98,13 @@ pub(crate) struct WeightedText {
impl WeightedText {
fn to_ref(&self) -> WeightedTextRef {
WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style.clone() }
WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style }
}
pub(crate) fn width(&self) -> usize {
self.accumulators.last().map(|a| a.width).unwrap_or(0)
self.to_ref().width()
}
#[cfg(test)]
pub(crate) fn text(&self) -> &Text {
&self.text
}
@ -179,6 +201,7 @@ impl<'a> WeightedTextRef<'a> {
return (self.make_ref(0, self.text.len()), self.make_ref(0, 0));
}
let max_length = (max_length / self.style.size as usize).max(1);
let target_chunk = self.substr(max_length + 1);
let output_chunk = match target_chunk.rsplit_once(' ') {
Some((before, _)) => before,
@ -197,7 +220,7 @@ impl<'a> WeightedTextRef<'a> {
let leading_char_count = self.text[0..from].chars().count();
let output_char_count = text.chars().count();
let character_lengths = &self.accumulators[leading_char_count..leading_char_count + output_char_count + 1];
WeightedTextRef { text, accumulators: character_lengths, style: self.style.clone() }
WeightedTextRef { text, accumulators: character_lengths, style: self.style }
}
fn trim_start(self) -> Self {
@ -207,10 +230,10 @@ impl<'a> WeightedTextRef<'a> {
Self { text, accumulators, style: self.style }
}
fn width(&self) -> usize {
pub(crate) fn width(&self) -> usize {
let last_width = self.accumulators.last().map(|a| a.width).unwrap_or(0);
let first_width = self.accumulators.first().map(|a| a.width).unwrap_or(0);
last_width - first_width
(last_width - first_width) * self.style.size as usize
}
fn bytes_until(&self, index: usize) -> usize {
@ -281,6 +304,15 @@ mod test {
assert_eq!(rest.width(), 3);
}
#[test]
fn font_size_split() {
let text = WeightedText::from(Text::new("█████", TextStyle::default().size(2)));
let text_ref = text.to_ref();
let (head, rest) = text_ref.word_split_at_length(3);
assert_eq!(head.width(), 2);
assert_eq!(rest.width(), 8);
}
#[test]
fn make_ref() {
let text = WeightedText::from("hello world");
@ -304,7 +336,7 @@ mod test {
#[test]
fn split_at_full_length() {
let text = WeightedTextBlock(vec![WeightedText::from("hello world")]);
let text = WeightedLine::from("hello world");
let lines = join_lines(text.split(11));
let expected = vec!["hello world"];
assert_eq!(lines, expected);
@ -312,7 +344,11 @@ mod test {
#[test]
fn no_split_necessary() {
let text = WeightedTextBlock(vec![WeightedText::from("short"), WeightedText::from("text")]);
let text = WeightedLine {
text: vec![WeightedText::from("short"), WeightedText::from("text")],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(50));
let expected = vec!["short text"];
assert_eq!(lines, expected);
@ -320,7 +356,8 @@ mod test {
#[test]
fn split_lines_single() {
let text = WeightedTextBlock(vec![WeightedText::from("this is a slightly long line")]);
let text =
WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0, font_size: 1 };
let lines = join_lines(text.split(6));
let expected = vec!["this", "is a", "slight", "ly", "long", "line"];
assert_eq!(lines, expected);
@ -328,11 +365,15 @@ mod test {
#[test]
fn split_lines_multi() {
let text = WeightedTextBlock(vec![
WeightedText::from("this is a slightly long line"),
WeightedText::from("another chunk"),
WeightedText::from("yet some other piece"),
]);
let text = WeightedLine {
text: vec![
WeightedText::from("this is a slightly long line"),
WeightedText::from("another chunk"),
WeightedText::from("yet some other piece"),
],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(10));
let expected = vec!["this is a", "slightly", "long line", "another", "chunk yet", "some other", "piece"];
assert_eq!(lines, expected);
@ -340,11 +381,15 @@ mod test {
#[test]
fn long_splits() {
let text = WeightedTextBlock(vec![
WeightedText::from("this is a slightly long line"),
WeightedText::from("another chunk"),
WeightedText::from("yet some other piece"),
]);
let text = WeightedLine {
text: vec![
WeightedText::from("this is a slightly long line"),
WeightedText::from("another chunk"),
WeightedText::from("yet some other piece"),
],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(50));
let expected = vec!["this is a slightly long line another chunk yet some", "other piece"];
assert_eq!(lines, expected);
@ -352,7 +397,7 @@ mod test {
#[test]
fn prefixed_by_whitespace() {
let text = WeightedTextBlock(vec![WeightedText::from(" * bullet")]);
let text = WeightedLine::from(" * bullet");
let lines = join_lines(text.split(50));
let expected = vec![" * bullet"];
assert_eq!(lines, expected);
@ -360,7 +405,7 @@ mod test {
#[test]
fn utf8_character() {
let text = WeightedTextBlock(vec![WeightedText::from("• A")]);
let text = WeightedLine::from("• A");
let lines = join_lines(text.split(50));
let expected = vec!["• A"];
assert_eq!(lines, expected);
@ -369,7 +414,7 @@ mod test {
#[test]
fn many_utf8_characters() {
let content = "█████ ██";
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
let text = WeightedLine::from(content);
let lines = join_lines(text.split(3));
let expected = vec!["███", "██", "██"];
assert_eq!(lines, expected);
@ -378,7 +423,7 @@ mod test {
#[test]
fn no_whitespaces_ascii() {
let content = "X".repeat(10);
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
let text = WeightedLine::from(content);
let lines = join_lines(text.split(3));
let expected = vec!["XXX", "XXX", "XXX", "X"];
assert_eq!(lines, expected);
@ -387,7 +432,7 @@ mod test {
#[test]
fn no_whitespaces_utf8() {
let content = "".repeat(10);
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
let text = WeightedLine::from(content);
let lines = join_lines(text.split(3));
let expected = vec!["───", "───", "───", ""];
assert_eq!(lines, expected);
@ -396,7 +441,7 @@ mod test {
#[test]
fn wide_characters() {
let content = " ";
let text = WeightedTextBlock(vec![WeightedText::from(content)]);
let text = WeightedLine::from(content);
let lines = join_lines(text.split(10));
// Each word is 10 characters long
let expected = vec!["", ""];
@ -410,7 +455,7 @@ mod test {
#[case::split(&["hello".into(), Text::new(" ", TextStyle::default().bold()), "world".into()], 3)]
#[case::split_merged(&["hello".into(), Text::new(" ", TextStyle::default().bold()), Text::new("w", TextStyle::default().bold()), "orld".into()], 3)]
fn compaction(#[case] texts: &[Text], #[case] expected: usize) {
let block = WeightedTextBlock::from(texts.to_vec());
assert_eq!(block.0.len(), expected);
let block = WeightedLine::from(texts.to_vec());
assert_eq!(block.text.len(), expected);
}
}

390
src/markdown/text_style.rs Normal file
View File

@ -0,0 +1,390 @@
use crate::theme::{ColorPalette, raw::RawColor};
use crossterm::style::{StyledContent, Stylize};
use hex::FromHexError;
use serde::{Deserialize, Serialize};
use std::fmt::{self, Display};
/// The style of a piece of text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TextStyle<C = Color> {
flags: u8,
pub(crate) colors: Colors<C>,
pub(crate) size: u8,
}
impl<C> Default for TextStyle<C> {
fn default() -> Self {
Self { flags: Default::default(), colors: Default::default(), size: 1 }
}
}
impl<C> TextStyle<C>
where
C: Clone,
{
pub(crate) fn colored(colors: Colors<C>) -> Self {
Self { colors, ..Default::default() }
}
pub(crate) fn size(mut self, size: u8) -> Self {
self.size = size.min(16);
self
}
/// Add bold to this style.
pub(crate) fn bold(self) -> Self {
self.add_flag(TextFormatFlags::Bold)
}
/// Add italics to this style.
pub(crate) fn italics(self) -> Self {
self.add_flag(TextFormatFlags::Italics)
}
/// Indicate this text is a piece of inline code.
pub(crate) fn code(self) -> Self {
self.add_flag(TextFormatFlags::Code)
}
/// Add strikethrough to this style.
pub(crate) fn strikethrough(self) -> Self {
self.add_flag(TextFormatFlags::Strikethrough)
}
/// Add underline to this style.
pub(crate) fn underlined(self) -> Self {
self.add_flag(TextFormatFlags::Underlined)
}
/// Indicate this is a link label.
pub(crate) fn link_label(self) -> Self {
self.bold()
}
/// Indicate this is a link title.
pub(crate) fn link_title(self) -> Self {
self.italics()
}
/// Indicate this is a link url.
pub(crate) fn link_url(self) -> Self {
self.italics().underlined()
}
/// Set the background color for this text style.
pub(crate) fn bg_color<U: Into<C>>(mut self, color: U) -> Self {
self.colors.background = Some(color.into());
self
}
/// Set the foreground color for this text style.
pub(crate) fn fg_color<U: Into<C>>(mut self, color: U) -> Self {
self.colors.foreground = Some(color.into());
self
}
/// Set the colors on this style.
pub(crate) fn colors(mut self, colors: Colors<C>) -> Self {
self.colors = colors;
self
}
/// Check whether this text is code.
pub(crate) fn is_code(&self) -> bool {
self.has_flag(TextFormatFlags::Code)
}
/// Merge this style with another one.
pub(crate) fn merge(&mut self, other: &TextStyle<C>) {
self.flags |= other.flags;
self.size = self.size.max(other.size);
self.colors.background = self.colors.background.clone().or(other.colors.background.clone());
self.colors.foreground = self.colors.foreground.clone().or(other.colors.foreground.clone());
}
/// Return a new style merged with the one passed in.
pub(crate) fn merged(mut self, other: &TextStyle<C>) -> Self {
self.merge(other);
self
}
fn add_flag(mut self, flag: TextFormatFlags) -> Self {
self.flags |= flag as u8;
self
}
fn has_flag(&self, flag: TextFormatFlags) -> bool {
self.flags & flag as u8 != 0
}
}
impl TextStyle<Color> {
/// Apply this style to a piece of text.
pub(crate) fn apply<'a>(&self, text: &'a str) -> StyledContent<impl Display + Clone + 'a> {
let text = FontSizedStr { contents: text, font_size: self.size };
let mut styled = StyledContent::new(Default::default(), text);
for attr in self.iter_attributes() {
styled = match attr {
TextAttribute::Bold => styled.bold(),
TextAttribute::Italics => styled.italic(),
TextAttribute::Strikethrough => styled.crossed_out(),
TextAttribute::Underlined => styled.underlined(),
TextAttribute::ForegroundColor(color) => styled.with(color.into()),
TextAttribute::BackgroundColor(color) => styled.on(color.into()),
}
}
styled
}
pub(crate) fn into_raw(self) -> TextStyle<RawColor> {
let colors = Colors {
background: self.colors.background.map(Into::into),
foreground: self.colors.foreground.map(Into::into),
};
TextStyle { flags: self.flags, colors, size: self.size }
}
/// Iterate all attributes in this style.
pub(crate) fn iter_attributes(&self) -> AttributeIterator {
AttributeIterator {
flags: self.flags,
next_mask: Some(TextFormatFlags::Bold),
background_color: self.colors.background,
foreground_color: self.colors.foreground,
}
}
}
impl TextStyle<RawColor> {
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<TextStyle, UndefinedPaletteColorError> {
let colors = self.colors.resolve(palette)?;
Ok(TextStyle { flags: self.flags, colors, size: self.size })
}
}
pub(crate) struct AttributeIterator {
flags: u8,
next_mask: Option<TextFormatFlags>,
background_color: Option<Color>,
foreground_color: Option<Color>,
}
impl Iterator for AttributeIterator {
type Item = TextAttribute;
fn next(&mut self) -> Option<Self::Item> {
if let Some(c) = self.background_color.take() {
return Some(TextAttribute::BackgroundColor(c));
}
if let Some(c) = self.foreground_color.take() {
return Some(TextAttribute::ForegroundColor(c));
}
use TextFormatFlags::*;
loop {
let next_mask = self.next_mask?;
self.next_mask = match next_mask {
Bold => Some(Italics),
Italics => Some(Strikethrough),
Code => Some(Strikethrough),
Strikethrough => Some(Underlined),
Underlined => None,
};
if self.flags & next_mask as u8 != 0 {
let attr = match next_mask {
Bold => TextAttribute::Bold,
Italics => TextAttribute::Italics,
Code => panic!("code shouldn't reach here"),
Strikethrough => TextAttribute::Strikethrough,
Underlined => TextAttribute::Underlined,
};
return Some(attr);
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TextAttribute {
Bold,
Italics,
Strikethrough,
Underlined,
ForegroundColor(Color),
BackgroundColor(Color),
}
#[derive(Clone)]
struct FontSizedStr<'a> {
contents: &'a str,
font_size: u8,
}
impl fmt::Display for FontSizedStr<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let contents = &self.contents;
match self.font_size {
0 | 1 => write!(f, "{contents}"),
size => write!(f, "\x1b]66;s={size};{contents}\x1b\\"),
}
}
}
#[derive(Clone, Copy, Debug)]
enum TextFormatFlags {
Bold = 1,
Italics = 2,
Code = 4,
Strikethrough = 8,
Underlined = 16,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(crate) enum Color {
Black,
DarkGrey,
Red,
DarkRed,
Green,
DarkGreen,
Yellow,
DarkYellow,
Blue,
DarkBlue,
Magenta,
DarkMagenta,
Cyan,
DarkCyan,
White,
Grey,
Rgb { r: u8, g: u8, b: u8 },
}
impl Color {
pub(crate) fn new(r: u8, g: u8, b: u8) -> Self {
Self::Rgb { r, g, b }
}
pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> {
match self {
Self::Rgb { r, g, b } => Some((*r, *g, *b)),
_ => None,
}
}
pub(crate) fn from_ansi(color: u8) -> Option<Self> {
let color = match color {
30 | 40 => Color::Black,
31 | 41 => Color::Red,
32 | 42 => Color::Green,
33 | 43 => Color::Yellow,
34 | 44 => Color::Blue,
35 | 45 => Color::Magenta,
36 | 46 => Color::Cyan,
37 | 47 => Color::White,
_ => return None,
};
Some(color)
}
}
impl From<Color> for crossterm::style::Color {
fn from(value: Color) -> Self {
use crossterm::style::Color as C;
match value {
Color::Black => C::Black,
Color::DarkGrey => C::DarkGrey,
Color::Red => C::Red,
Color::DarkRed => C::DarkRed,
Color::Green => C::Green,
Color::DarkGreen => C::DarkGreen,
Color::Yellow => C::Yellow,
Color::DarkYellow => C::DarkYellow,
Color::Blue => C::Blue,
Color::DarkBlue => C::DarkBlue,
Color::Magenta => C::Magenta,
Color::DarkMagenta => C::DarkMagenta,
Color::Cyan => C::Cyan,
Color::DarkCyan => C::DarkCyan,
Color::White => C::White,
Color::Grey => C::Grey,
Color::Rgb { r, g, b } => C::Rgb { r, g, b },
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("unresolved palette color: {0}")]
pub(crate) struct PaletteColorError(String);
#[derive(Debug, thiserror::Error)]
#[error("undefined palette color: {0}")]
pub(crate) struct UndefinedPaletteColorError(pub(crate) String);
/// Text colors.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub(crate) struct Colors<C = Color> {
/// The background color.
pub(crate) background: Option<C>,
/// The foreground color.
pub(crate) foreground: Option<C>,
}
impl<C> Default for Colors<C> {
fn default() -> Self {
Self { background: None, foreground: None }
}
}
impl Colors<RawColor> {
pub(crate) fn resolve(&self, palette: &ColorPalette) -> Result<Colors<Color>, UndefinedPaletteColorError> {
let background = self.background.clone().map(|c| c.resolve(palette)).transpose()?.flatten();
let foreground = self.foreground.clone().map(|c| c.resolve(palette)).transpose()?.flatten();
Ok(Colors { foreground, background })
}
}
impl From<Colors> for crossterm::style::Colors {
fn from(value: Colors) -> Self {
let foreground = value.foreground.map(Color::into);
let background = value.background.map(Color::into);
Self { foreground, background }
}
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum ParseColorError {
#[error("invalid hex color: {0}")]
Hex(#[from] FromHexError),
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::default(TextStyle::default(), &[])]
#[case::code(TextStyle::default().code(), &[])]
#[case::bold(TextStyle::default().bold(), &[TextAttribute::Bold])]
#[case::italics(TextStyle::default().italics(), &[TextAttribute::Italics])]
#[case::strikethrough(TextStyle::default().strikethrough(), &[TextAttribute::Strikethrough])]
#[case::underlined(TextStyle::default().underlined(), &[TextAttribute::Underlined])]
#[case::bg_color(TextStyle::default().bg_color(Color::Red), &[TextAttribute::BackgroundColor(Color::Red)])]
#[case::bg_color(TextStyle::default().fg_color(Color::Red), &[TextAttribute::ForegroundColor(Color::Red)])]
#[case::all(
TextStyle::default().bold().code().italics().strikethrough().underlined().bg_color(Color::Black).fg_color(Color::Red),
&[
TextAttribute::BackgroundColor(Color::Black),
TextAttribute::ForegroundColor(Color::Red),
TextAttribute::Bold,
TextAttribute::Italics,
TextAttribute::Strikethrough,
TextAttribute::Underlined,
]
)]
fn iterate_attributes(#[case] style: TextStyle, #[case] expected: &[TextAttribute]) {
let attrs: Vec<_> = style.iter_attributes().collect();
assert_eq!(attrs, expected);
}
}

View File

@ -1,136 +0,0 @@
use super::printer::{PrintImage, PrintImageError, PrintOptions, RegisterImageError, ResourceProperties};
use crossterm::{
cursor::{MoveRight, MoveToColumn},
style::{Color, Stylize},
QueueableCommand,
};
use image::{imageops::FilterType, DynamicImage, GenericImageView, Pixel, Rgba};
use itertools::Itertools;
use std::{fs, ops::Deref};
const TOP_CHAR: char = '▀';
const BOTTOM_CHAR: char = '▄';
pub(crate) struct AsciiResource(DynamicImage);
impl ResourceProperties for AsciiResource {
fn dimensions(&self) -> (u32, u32) {
self.0.dimensions()
}
}
impl From<DynamicImage> for AsciiResource {
fn from(image: DynamicImage) -> Self {
let image = image.into_rgba8();
Self(image.into())
}
}
impl Deref for AsciiResource {
type Target = DynamicImage;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Default)]
pub struct AsciiPrinter;
impl AsciiPrinter {
fn pixel_color(pixel: &Rgba<u8>, background: Option<Color>) -> Option<Color> {
let [r, g, b, alpha] = pixel.0;
if alpha == 0 {
None
} else if alpha < 255 {
// For alpha > 0 && < 255, we blend it with the background color (if any). This helps
// smooth the image's borders.
let mut pixel = *pixel;
match background {
Some(Color::Rgb { r, g, b }) => {
pixel.blend(&Rgba([r, g, b, 255 - alpha]));
Some(Color::Rgb { r: pixel[0], g: pixel[1], b: pixel[2] })
}
// For transparent backgrounds, we can't really know whether we should blend it
// towards light or dark.
None | Some(_) => Some(Color::Rgb { r, g, b }),
}
} else {
Some(Color::Rgb { r, g, b })
}
}
}
impl PrintImage for AsciiPrinter {
type Resource = AsciiResource;
fn register_image(&self, image: image::DynamicImage) -> Result<Self::Resource, RegisterImageError> {
Ok(AsciiResource(image))
}
fn register_resource<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError> {
let contents = fs::read(path)?;
let image = image::load_from_memory(&contents)?;
Ok(AsciiResource(image))
}
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
where
W: std::io::Write,
{
// The strategy here is taken from viuer: use half vertical ascii blocks in combination
// with foreground/background colors to fit 2 vertical pixels per cell. That is, cell (x, y)
// will contain the pixels at (x, y) and (x, y + 1) combined.
let image = image.0.resize_exact(options.columns as u32, 2 * options.rows as u32, FilterType::Triangle);
let image = image.into_rgba8();
let default_background = options.background_color.map(Color::from);
// Iterate pixel rows in pairs to be able to merge both pixels in a single iteration.
// Note that may not have a second row if there's an odd number of them.
for mut rows in &image.rows().chunks(2) {
writer.queue(MoveToColumn(options.cursor_position.column))?;
let top_row = rows.next().unwrap();
let mut bottom_row = rows.next();
for top_pixel in top_row {
let bottom_pixel = bottom_row.as_mut().and_then(|pixels| pixels.next());
// Get pixel colors for both of these. At this point the special case for the odd
// number of rows disappears as we treat a transparent pixel and a non-existent
// one the same: they're simply transparent.
let background = options.background_color.map(Color::from);
let top = Self::pixel_color(top_pixel, background);
let bottom = bottom_pixel.and_then(|c| Self::pixel_color(c, background));
match (top, bottom) {
(Some(top), Some(bottom)) => {
write!(writer, "{}", TOP_CHAR.with(top).on(bottom))?;
}
(Some(top), None) => {
write!(writer, "{}", TOP_CHAR.with(top).maybe_on(default_background))?;
}
(None, Some(bottom)) => {
write!(writer, "{}", BOTTOM_CHAR.with(bottom).maybe_on(default_background))?;
}
(None, None) => {
writer.queue(MoveRight(1))?;
}
};
}
writeln!(writer)?;
}
Ok(())
}
}
trait StylizeExt: Stylize {
fn maybe_on(self, color: Option<Color>) -> Self::Styled;
}
impl<T: Stylize> StylizeExt for T {
fn maybe_on(self, color: Option<Color>) -> Self::Styled {
match color {
Some(background) => self.on(background),
None => self.stylize(),
}
}
}

View File

@ -1,13 +0,0 @@
use super::kitty::KittyMode;
#[derive(Clone, Debug)]
pub enum GraphicsMode {
Iterm2,
Kitty {
mode: KittyMode,
inside_tmux: bool,
},
AsciiBlocks,
#[cfg(feature = "sixel")]
Sixel,
}

View File

@ -1,45 +0,0 @@
use crate::media::printer::{ImageResource, ResourceProperties};
use std::{fmt::Debug, ops::Deref, path::PathBuf, sync::Arc};
/// An image.
///
/// This stores the image in an [std::sync::Arc] so it's cheap to clone.
#[derive(Clone)]
pub(crate) struct Image {
pub(crate) resource: Arc<ImageResource>,
pub(crate) source: ImageSource,
}
impl PartialEq for Image {
fn eq(&self, other: &Self) -> bool {
self.source == other.source
}
}
impl Debug for Image {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (width, height) = self.resource.dimensions();
write!(f, "Image<{width}x{height}>")
}
}
impl Image {
/// Constructs a new image.
pub(crate) fn new(resource: ImageResource, source: ImageSource) -> Self {
Self { resource: Arc::new(resource), source }
}
}
impl Deref for Image {
type Target = ImageResource;
fn deref(&self) -> &Self::Target {
&self.resource
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ImageSource {
Filesystem(PathBuf),
Generated,
}

View File

@ -1,80 +0,0 @@
use super::printer::{PrintImage, PrintImageError, PrintOptions, RegisterImageError, ResourceProperties};
use base64::{engine::general_purpose::STANDARD, Engine};
use image::{codecs::png::PngEncoder, GenericImageView, ImageEncoder};
use std::{env, fs, path::Path};
pub(crate) struct ItermResource {
dimensions: (u32, u32),
raw_length: usize,
base64_contents: String,
}
impl ItermResource {
fn new(contents: Vec<u8>, dimensions: (u32, u32)) -> Self {
let raw_length = contents.len();
let base64_contents = STANDARD.encode(&contents);
Self { dimensions, raw_length, base64_contents }
}
}
impl ResourceProperties for ItermResource {
fn dimensions(&self) -> (u32, u32) {
self.dimensions
}
}
pub struct ItermPrinter {
// Whether this is iterm2. Otherwise it can be a terminal that _supports_ the iterm2 protocol.
is_iterm: bool,
}
impl Default for ItermPrinter {
fn default() -> Self {
for key in ["TERM_PROGRAM", "LC_TERMINAL"] {
if let Ok(value) = env::var(key) {
if value.contains("iTerm") {
return Self { is_iterm: true };
}
}
}
Self { is_iterm: false }
}
}
impl PrintImage for ItermPrinter {
type Resource = ItermResource;
fn register_image(&self, image: image::DynamicImage) -> Result<Self::Resource, RegisterImageError> {
let dimensions = image.dimensions();
let mut contents = Vec::new();
let encoder = PngEncoder::new(&mut contents);
encoder.write_image(image.as_bytes(), dimensions.0, dimensions.1, image.color().into())?;
Ok(ItermResource::new(contents, dimensions))
}
fn register_resource<P: AsRef<Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError> {
let contents = fs::read(path)?;
let image = image::load_from_memory(&contents)?;
Ok(ItermResource::new(contents, image.dimensions()))
}
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
where
W: std::io::Write,
{
let size = image.raw_length;
let columns = options.columns;
let rows = options.rows;
let contents = &image.base64_contents;
write!(
writer,
"\x1b]1337;File=size={size};width={columns};height={rows};inline=1;preserveAspectRatio=0:{contents}\x07"
)?;
// iterm2 really respects what we say and leaves no space, whereas wezterm does leave an
// extra line here.
if self.is_iterm {
writeln!(writer)?;
}
Ok(())
}
}

View File

@ -1,11 +0,0 @@
mod ascii;
pub(crate) mod emulator;
pub(crate) mod graphics;
pub(crate) mod image;
mod iterm;
pub(crate) mod kitty;
pub(crate) mod printer;
pub(crate) mod register;
pub(crate) mod scale;
#[cfg(feature = "sixel")]
pub(crate) mod sixel;

View File

@ -1,194 +0,0 @@
use super::{
ascii::{AsciiPrinter, AsciiResource},
graphics::GraphicsMode,
iterm::{ItermPrinter, ItermResource},
kitty::{KittyMode, KittyPrinter, KittyResource},
};
use crate::{render::properties::CursorPosition, style::Color};
use image::{DynamicImage, ImageError};
use std::{borrow::Cow, io, path::Path};
pub(crate) trait PrintImage {
type Resource: ResourceProperties;
/// Register an image.
fn register_image(&self, image: DynamicImage) -> Result<Self::Resource, RegisterImageError>;
/// Load and register a resource from the given path.
fn register_resource<P: AsRef<Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError>;
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
where
W: io::Write;
}
pub(crate) trait ResourceProperties {
fn dimensions(&self) -> (u32, u32);
}
#[derive(Debug)]
pub(crate) struct PrintOptions {
pub(crate) columns: u16,
pub(crate) rows: u16,
pub(crate) cursor_position: CursorPosition,
pub(crate) z_index: i32,
pub(crate) background_color: Option<Color>,
// Width/height in pixels.
#[allow(dead_code)]
pub(crate) column_width: u16,
#[allow(dead_code)]
pub(crate) row_height: u16,
}
pub(crate) enum ImageResource {
Kitty(KittyResource),
Iterm(ItermResource),
Ascii(AsciiResource),
#[cfg(feature = "sixel")]
Sixel(super::sixel::SixelResource),
}
impl ResourceProperties for ImageResource {
fn dimensions(&self) -> (u32, u32) {
match self {
Self::Kitty(resource) => resource.dimensions(),
Self::Iterm(resource) => resource.dimensions(),
Self::Ascii(resource) => resource.dimensions(),
#[cfg(feature = "sixel")]
Self::Sixel(resource) => resource.dimensions(),
}
}
}
pub enum ImagePrinter {
Kitty(KittyPrinter),
Iterm(ItermPrinter),
Ascii(AsciiPrinter),
Null,
#[cfg(feature = "sixel")]
Sixel(super::sixel::SixelPrinter),
}
impl Default for ImagePrinter {
fn default() -> Self {
Self::new_ascii()
}
}
impl ImagePrinter {
pub fn new(mode: GraphicsMode) -> Result<Self, CreatePrinterError> {
let printer = match mode {
GraphicsMode::Kitty { mode, inside_tmux } => Self::new_kitty(mode, inside_tmux)?,
GraphicsMode::Iterm2 => Self::new_iterm(),
GraphicsMode::AsciiBlocks => Self::new_ascii(),
#[cfg(feature = "sixel")]
GraphicsMode::Sixel => Self::new_sixel()?,
};
Ok(printer)
}
fn new_kitty(mode: KittyMode, inside_tmux: bool) -> io::Result<Self> {
Ok(Self::Kitty(KittyPrinter::new(mode, inside_tmux)?))
}
fn new_iterm() -> Self {
Self::Iterm(ItermPrinter::default())
}
fn new_ascii() -> Self {
Self::Ascii(AsciiPrinter)
}
#[cfg(feature = "sixel")]
fn new_sixel() -> Result<Self, CreatePrinterError> {
Ok(Self::Sixel(super::sixel::SixelPrinter::new()?))
}
}
impl PrintImage for ImagePrinter {
type Resource = ImageResource;
fn register_image(&self, image: DynamicImage) -> Result<Self::Resource, RegisterImageError> {
let resource = match self {
Self::Kitty(printer) => ImageResource::Kitty(printer.register_image(image)?),
Self::Iterm(printer) => ImageResource::Iterm(printer.register_image(image)?),
Self::Ascii(printer) => ImageResource::Ascii(printer.register_image(image)?),
Self::Null => return Err(RegisterImageError::Unsupported),
#[cfg(feature = "sixel")]
Self::Sixel(printer) => ImageResource::Sixel(printer.register_image(image)?),
};
Ok(resource)
}
fn register_resource<P: AsRef<Path>>(&self, path: P) -> Result<Self::Resource, RegisterImageError> {
let resource = match self {
Self::Kitty(printer) => ImageResource::Kitty(printer.register_resource(path)?),
Self::Iterm(printer) => ImageResource::Iterm(printer.register_resource(path)?),
Self::Ascii(printer) => ImageResource::Ascii(printer.register_resource(path)?),
Self::Null => return Err(RegisterImageError::Unsupported),
#[cfg(feature = "sixel")]
Self::Sixel(printer) => ImageResource::Sixel(printer.register_resource(path)?),
};
Ok(resource)
}
fn print<W>(&self, image: &Self::Resource, options: &PrintOptions, writer: &mut W) -> Result<(), PrintImageError>
where
W: io::Write,
{
match (self, image) {
(Self::Kitty(printer), ImageResource::Kitty(image)) => printer.print(image, options, writer),
(Self::Iterm(printer), ImageResource::Iterm(image)) => printer.print(image, options, writer),
(Self::Ascii(printer), ImageResource::Ascii(image)) => printer.print(image, options, writer),
(Self::Null, _) => Ok(()),
#[cfg(feature = "sixel")]
(Self::Sixel(printer), ImageResource::Sixel(image)) => printer.print(image, options, writer),
_ => Err(PrintImageError::Unsupported),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum CreatePrinterError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("unexpected: {0}")]
Other(String),
}
#[derive(Debug, thiserror::Error)]
pub enum PrintImageError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("unsupported image type")]
Unsupported,
#[error("image decoding: {0}")]
Image(#[from] ImageError),
#[error("other: {0}")]
Other(Cow<'static, str>),
}
#[derive(Debug, thiserror::Error)]
pub enum RegisterImageError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("image decoding: {0}")]
Image(#[from] ImageError),
#[error("printer can't register resources")]
Unsupported,
}
impl PrintImageError {
pub(crate) fn other<S>(message: S) -> Self
where
S: Into<Cow<'static, str>>,
{
Self::Other(message.into())
}
}

View File

@ -1,24 +0,0 @@
use super::{
image::{Image, ImageSource},
printer::{PrintImage, RegisterImageError},
};
use crate::ImagePrinter;
use image::DynamicImage;
use std::{path::PathBuf, sync::Arc};
#[derive(Clone, Default)]
pub struct ImageRegistry(pub Arc<ImagePrinter>);
impl ImageRegistry {
pub(crate) fn register_image(&self, image: DynamicImage) -> Result<Image, RegisterImageError> {
let resource = self.0.register_image(image)?;
let image = Image::new(resource, ImageSource::Generated);
Ok(image)
}
pub(crate) fn register_resource(&self, path: PathBuf) -> Result<Image, RegisterImageError> {
let resource = self.0.register_resource(&path)?;
let image = Image::new(resource, ImageSource::Filesystem(path));
Ok(image)
}
}

View File

@ -1,64 +0,0 @@
use crate::render::properties::{CursorPosition, WindowSize};
/// Scale an image to a specific size.
pub(crate) fn scale_image(
scale_size: &WindowSize,
window_dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect {
let aspect_ratio = image_height as f64 / image_width as f64;
let column_in_pixels = scale_size.pixels_per_column();
let width_in_columns = scale_size.columns;
let image_width = width_in_columns as f64 * column_in_pixels;
let image_height = image_width * aspect_ratio;
fit_image_to_window(window_dimensions, image_width as u32, image_height as u32, position)
}
/// Shrink an image so it fits the dimensions of the layout it's being displayed in.
pub(crate) fn fit_image_to_window(
dimensions: &WindowSize,
image_width: u32,
image_height: u32,
position: &CursorPosition,
) -> TerminalRect {
let aspect_ratio = image_height as f64 / image_width as f64;
// Compute the image's width in columns by translating pixels -> columns.
let column_in_pixels = dimensions.pixels_per_column();
let column_margin = (dimensions.columns as f64 * 0.95) as u32;
let mut width_in_columns = (image_width as f64 / column_in_pixels) as u32;
// Do the same for its height.
let row_in_pixels = dimensions.pixels_per_row();
let height_in_rows = (image_height as f64 / row_in_pixels) as u32;
// If the image doesn't fit vertically, shrink it.
let available_height = dimensions.rows.saturating_sub(position.row) as u32;
if height_in_rows > available_height {
// Because we only use the width to draw, here we scale the width based on how much we
// need to shrink the height.
let shrink_ratio = available_height as f64 / height_in_rows as f64;
width_in_columns = (width_in_columns as f64 * shrink_ratio).ceil() as u32;
}
// Don't go too far wide.
let width_in_columns = width_in_columns.min(column_margin);
let height_in_rows = (width_in_columns as f64 * aspect_ratio / 2.0) as u16;
let width_in_columns = width_in_columns.max(1);
let height_in_rows = height_in_rows.max(1);
// Draw it in the middle
let start_column = dimensions.columns / 2 - (width_in_columns / 2) as u16;
let start_column = start_column + position.column;
TerminalRect { start_column, columns: width_in_columns as u16, rows: height_in_rows }
}
#[derive(Debug)]
pub(crate) struct TerminalRect {
pub(crate) start_column: u16,
pub(crate) columns: u16,
pub(crate) rows: u16,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,390 @@
use super::{BuildError, BuildResult, ExecutionMode, PresentationBuilderOptions};
use crate::{
ImageRegistry,
code::{
execute::SnippetExecutor,
highlighting::SnippetHighlighter,
snippet::{
ExternalFile, Highlight, HighlightContext, HighlightGroup, HighlightMutator, HighlightedLine, Snippet,
SnippetExec, SnippetLanguage, SnippetLine, SnippetParser, SnippetRepr, SnippetSplitter,
},
},
markdown::elements::SourcePosition,
presentation::ChunkMutator,
render::{
operation::{AsRenderOperations, RenderAsyncStartPolicy, RenderOperation},
properties::WindowSize,
},
resource::Resources,
theme::{Alignment, CodeBlockStyle, PresentationTheme},
third_party::{ThirdPartyRender, ThirdPartyRenderRequest},
ui::execution::{
RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation,
disabled::ExecutionType, snippet::DisplaySeparator,
},
};
use itertools::Itertools;
use std::{cell::RefCell, rc::Rc, sync::Arc};
pub(crate) struct SnippetProcessorState<'a> {
pub(crate) resources: &'a Resources,
pub(crate) image_registry: &'a ImageRegistry,
pub(crate) snippet_executor: Arc<SnippetExecutor>,
pub(crate) theme: &'a PresentationTheme,
pub(crate) third_party: &'a ThirdPartyRender,
pub(crate) highlighter: &'a SnippetHighlighter,
pub(crate) options: &'a PresentationBuilderOptions,
pub(crate) font_size: u8,
}
pub(crate) struct SnippetProcessor<'a> {
operations: Vec<RenderOperation>,
mutators: Vec<Box<dyn ChunkMutator>>,
resources: &'a Resources,
image_registry: &'a ImageRegistry,
snippet_executor: Arc<SnippetExecutor>,
theme: &'a PresentationTheme,
third_party: &'a ThirdPartyRender,
highlighter: &'a SnippetHighlighter,
options: &'a PresentationBuilderOptions,
font_size: u8,
}
impl<'a> SnippetProcessor<'a> {
pub(crate) fn new(state: SnippetProcessorState<'a>) -> Self {
let SnippetProcessorState {
resources,
image_registry,
snippet_executor,
theme,
third_party,
highlighter,
options,
font_size,
} = state;
Self {
operations: Vec::new(),
mutators: Vec::new(),
resources,
image_registry,
snippet_executor,
theme,
third_party,
highlighter,
options,
font_size,
}
}
pub(crate) fn process_code(
mut self,
info: String,
code: String,
source_position: SourcePosition,
) -> Result<SnippetOperations, BuildError> {
self.do_process_code(info, code, source_position)?;
let Self { operations, mutators, .. } = self;
Ok(SnippetOperations { operations, mutators })
}
fn do_process_code(&mut self, info: String, code: String, source_position: SourcePosition) -> BuildResult {
let mut snippet = SnippetParser::parse(info, code)
.map_err(|e| BuildError::InvalidSnippet { source_position, error: e.to_string() })?;
if matches!(snippet.language, SnippetLanguage::File) {
snippet = self.load_external_snippet(snippet, source_position)?;
}
if self.options.auto_render_languages.contains(&snippet.language) {
snippet.attributes.representation = SnippetRepr::Render;
}
self.push_differ(snippet.contents.clone());
// Redraw slide if attributes change
self.push_differ(format!("{:?}", snippet.attributes));
let execution_allowed = self.is_execution_allowed(&snippet);
match snippet.attributes.representation {
SnippetRepr::Render => return self.push_rendered_code(snippet, source_position),
SnippetRepr::Image => {
if execution_allowed {
return self.push_code_as_image(snippet);
}
}
SnippetRepr::ExecReplace => {
if execution_allowed {
return self.push_code_execution(snippet, 0, ExecutionMode::ReplaceSnippet);
}
}
SnippetRepr::Snippet => (),
};
let block_length = self.push_code_lines(&snippet);
match snippet.attributes.execution {
SnippetExec::None => Ok(()),
SnippetExec::Exec | SnippetExec::AcquireTerminal if !execution_allowed => {
let exec_type = match snippet.attributes.representation {
SnippetRepr::Image => ExecutionType::Image,
SnippetRepr::ExecReplace => ExecutionType::ExecReplace,
SnippetRepr::Render | SnippetRepr::Snippet => ExecutionType::Execute,
};
self.push_execution_disabled_operation(exec_type);
Ok(())
}
SnippetExec::Exec => self.push_code_execution(snippet, block_length, ExecutionMode::AlongSnippet),
SnippetExec::AcquireTerminal => self.push_acquire_terminal_execution(snippet, block_length),
}
}
fn is_execution_allowed(&self, snippet: &Snippet) -> bool {
match snippet.attributes.representation {
SnippetRepr::Snippet => self.options.enable_snippet_execution,
SnippetRepr::Image | SnippetRepr::ExecReplace => self.options.enable_snippet_execution_replace,
SnippetRepr::Render => true,
}
}
fn push_code_lines(&mut self, snippet: &Snippet) -> u16 {
let lines = SnippetSplitter::new(&self.theme.code, self.snippet_executor.hidden_line_prefix(&snippet.language))
.split(snippet);
let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0) * self.font_size as usize;
let block_length = block_length as u16;
let (lines, context) = self.highlight_lines(snippet, lines, block_length);
for line in lines {
self.operations.push(RenderOperation::RenderDynamic(Rc::new(line)));
}
self.operations.push(RenderOperation::SetColors(self.theme.default_style.style.colors));
if self.options.allow_mutations && context.borrow().groups.len() > 1 {
self.mutators.push(Box::new(HighlightMutator::new(context)));
}
block_length
}
fn load_external_snippet(
&mut self,
mut code: Snippet,
source_position: SourcePosition,
) -> Result<Snippet, BuildError> {
let file: ExternalFile = serde_yaml::from_str(&code.contents)
.map_err(|e| BuildError::InvalidSnippet { source_position, error: e.to_string() })?;
let path = file.path;
let path_display = path.display();
let contents = self.resources.external_snippet(&path).map_err(|e| BuildError::InvalidSnippet {
source_position,
error: format!("failed to load {path_display}: {e}"),
})?;
code.language = file.language;
code.contents = Self::filter_lines(contents, file.start_line, file.end_line);
Ok(code)
}
fn filter_lines(code: String, start: Option<usize>, end: Option<usize>) -> String {
let start = start.map(|s| s.saturating_sub(1));
match (start, end) {
(None, None) => code,
(None, Some(end)) => code.lines().take(end).join("\n"),
(Some(start), None) => code.lines().skip(start).join("\n"),
(Some(start), Some(end)) => code.lines().skip(start).take(end.saturating_sub(start)).join("\n"),
}
}
fn push_rendered_code(&mut self, code: Snippet, source_position: SourcePosition) -> BuildResult {
let Snippet { contents, language, attributes } = code;
let request = match language {
SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()),
SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()),
SnippetLanguage::Mermaid => ThirdPartyRenderRequest::Mermaid(contents, self.theme.mermaid.clone()),
_ => {
return Err(BuildError::InvalidSnippet {
source_position,
error: format!("language {language:?} doesn't support rendering"),
})?;
}
};
let operation = self.third_party.render(request, self.theme, attributes.width)?;
self.operations.push(operation);
Ok(())
}
fn highlight_lines(
&self,
code: &Snippet,
lines: Vec<SnippetLine>,
block_length: u16,
) -> (Vec<HighlightedLine>, Rc<RefCell<HighlightContext>>) {
let mut code_highlighter = self.highlighter.language_highlighter(&code.language);
let style = self.code_style(code);
let block_length = self.theme.code.alignment.adjust_size(block_length);
let font_size = self.font_size;
let dim_style = {
let mut highlighter = self.highlighter.language_highlighter(&SnippetLanguage::Rust);
highlighter.style_line("//", &style).0.first().expect("no styles").style.size(font_size)
};
let groups = match self.options.allow_mutations {
true => code.attributes.highlight_groups.clone(),
false => vec![HighlightGroup::new(vec![Highlight::All])],
};
let context =
Rc::new(RefCell::new(HighlightContext { groups, current: 0, block_length, alignment: style.alignment }));
let mut output = Vec::new();
for line in lines.into_iter() {
let prefix = line.dim_prefix(&dim_style);
let highlighted = line.highlight(&mut code_highlighter, &style, font_size);
let not_highlighted = line.dim(&dim_style);
let line_number = line.line_number;
let context = context.clone();
output.push(HighlightedLine {
prefix,
right_padding_length: line.right_padding_length * self.font_size as u16,
highlighted,
not_highlighted,
line_number,
context,
block_color: dim_style.colors.background,
});
}
(output, context)
}
fn code_style(&self, snippet: &Snippet) -> CodeBlockStyle {
let mut style = self.theme.code.clone();
if snippet.attributes.no_background {
style.background = false;
}
style
}
fn push_execution_disabled_operation(&mut self, exec_type: ExecutionType) {
let policy = match exec_type {
ExecutionType::ExecReplace | ExecutionType::Image => RenderAsyncStartPolicy::Automatic,
ExecutionType::Execute => RenderAsyncStartPolicy::OnDemand,
};
let operation = SnippetExecutionDisabledOperation::new(
self.theme.execution_output.status.failure_style,
self.theme.code.alignment,
policy,
exec_type,
);
self.operations.push(RenderOperation::RenderAsync(Rc::new(operation)));
}
fn push_code_as_image(&mut self, snippet: Snippet) -> BuildResult {
if !self.snippet_executor.is_execution_supported(&snippet.language) {
return Err(BuildError::UnsupportedExecution(snippet.language));
}
let operation = RunImageSnippet::new(
snippet,
self.snippet_executor.clone(),
self.image_registry.clone(),
self.theme.execution_output.status.clone(),
);
let operation = RenderOperation::RenderAsync(Rc::new(operation));
self.operations.push(operation);
Ok(())
}
fn push_acquire_terminal_execution(&mut self, snippet: Snippet, block_length: u16) -> BuildResult {
if !self.snippet_executor.is_execution_supported(&snippet.language) {
return Err(BuildError::UnsupportedExecution(snippet.language));
}
let block_length = self.theme.code.alignment.adjust_size(block_length);
let operation = RunAcquireTerminalSnippet::new(
snippet,
self.snippet_executor.clone(),
self.theme.execution_output.status.clone(),
block_length,
self.font_size,
);
let operation = RenderOperation::RenderAsync(Rc::new(operation));
self.operations.push(operation);
Ok(())
}
fn push_code_execution(&mut self, snippet: Snippet, block_length: u16, mode: ExecutionMode) -> BuildResult {
if !self.snippet_executor.is_execution_supported(&snippet.language) {
return Err(BuildError::UnsupportedExecution(snippet.language));
}
let separator = match mode {
ExecutionMode::AlongSnippet => DisplaySeparator::On,
ExecutionMode::ReplaceSnippet => DisplaySeparator::Off,
};
let default_alignment = self.code_style(&snippet).alignment;
// If we're replacing the snippet output and we have center alignment, use center alignment but
// without any margins and minimum sizes so we truly center the output.
let alignment = match (&mode, default_alignment) {
(ExecutionMode::ReplaceSnippet, Alignment::Center { .. }) => {
Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 }
}
(_, alignment) => alignment,
};
let default_colors = self.theme.default_style.style.colors;
let mut execution_output_style = self.theme.execution_output.clone();
if snippet.attributes.no_background {
execution_output_style.style.colors.background = None;
}
let policy = match mode {
ExecutionMode::AlongSnippet => RenderAsyncStartPolicy::OnDemand,
ExecutionMode::ReplaceSnippet => RenderAsyncStartPolicy::Automatic,
};
let operation = RunSnippetOperation::new(
snippet,
self.snippet_executor.clone(),
default_colors,
execution_output_style,
block_length,
separator,
alignment,
self.font_size,
policy,
);
let operation = RenderOperation::RenderAsync(Rc::new(operation));
self.operations.push(operation);
Ok(())
}
fn push_differ(&mut self, text: String) {
self.operations.push(RenderOperation::RenderDynamic(Rc::new(Differ(text))));
}
}
pub(crate) struct SnippetOperations {
pub(crate) operations: Vec<RenderOperation>,
pub(crate) mutators: Vec<Box<dyn ChunkMutator>>,
}
#[derive(Debug)]
struct Differ(String);
impl AsRenderOperations for Differ {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
Vec::new()
}
fn diffable_content(&self) -> Option<&str> {
Some(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::no_filters(None, None, &["a", "b", "c", "d", "e"])]
#[case::start_from_first(Some(1), None, &["a", "b", "c", "d", "e"])]
#[case::start_from_second(Some(2), None, &["b", "c", "d", "e"])]
#[case::start_from_end(Some(5), None, &["e"])]
#[case::start_from_past_end(Some(6), None, &[])]
#[case::end_last(None, Some(5), &["a", "b", "c", "d", "e"])]
#[case::end_one_before_last(None, Some(4), &["a", "b", "c", "d"])]
#[case::end_at_first(None, Some(1), &["a"])]
#[case::end_at_zero(None, Some(0), &[])]
#[case::start_and_end(Some(2), Some(3), &["b", "c"])]
#[case::crossed(Some(2), Some(1), &[])]
fn filter_lines(#[case] start: Option<usize>, #[case] end: Option<usize>, #[case] expected: &[&str]) {
let code = ["a", "b", "c", "d", "e"].join("\n");
let output = SnippetProcessor::filter_lines(code, start, end);
let expected = expected.join("\n");
assert_eq!(output, expected);
}
}

View File

@ -114,11 +114,15 @@ where
mod test {
use super::*;
use crate::{
presentation::{
AsRenderOperations, BlockLine, BlockLineText, RenderAsync, RenderAsyncState, Slide, SlideBuilder,
markdown::{
text::WeightedLine,
text_style::{Color, Colors},
},
presentation::{Slide, SlideBuilder},
render::{
operation::{AsRenderOperations, BlockLine, Pollable, RenderAsync, ToggleState},
properties::WindowSize,
},
render::properties::WindowSize,
style::{Color, Colors},
theme::{Alignment, Margin},
};
use rstest::rstest;
@ -131,19 +135,12 @@ mod test {
fn as_render_operations(&self, _dimensions: &WindowSize) -> Vec<RenderOperation> {
Vec::new()
}
fn diffable_content(&self) -> Option<&str> {
None
}
}
impl RenderAsync for Dynamic {
fn start_render(&self) -> bool {
false
}
fn poll_state(&self) -> RenderAsyncState {
RenderAsyncState::Rendered
fn pollable(&self) -> Box<dyn Pollable> {
// Use some random one, we don't care
Box::new(ToggleState::new(Default::default()))
}
}
@ -156,10 +153,13 @@ mod test {
#[case(RenderOperation::RenderText{line: String::from("asd").into(), alignment: Default::default()})]
#[case(RenderOperation::RenderBlockLine(
BlockLine{
text: BlockLineText::Preformatted("".into()),
prefix: "".into(),
right_padding_length: 0,
repeat_prefix_on_wrap: false,
text: WeightedLine::from("".to_string()),
alignment: Default::default(),
block_length: 42,
unformatted_length: 1337
block_color: None,
}
))]
#[case(RenderOperation::RenderDynamic(Rc::new(Dynamic)))]

View File

@ -1,21 +1,17 @@
use crate::{
custom::OptionsConfig,
markdown::text::WeightedTextBlock,
media::image::Image,
render::properties::WindowSize,
style::{Color, Colors},
theme::{Alignment, Margin, PresentationTheme},
};
use crate::{config::OptionsConfig, render::operation::RenderOperation};
use serde::Deserialize;
use std::{
cell::RefCell,
collections::HashSet,
fmt::Debug,
ops::Deref,
rc::Rc,
sync::{Arc, Mutex},
};
pub(crate) mod builder;
pub(crate) mod diff;
pub(crate) mod poller;
#[derive(Debug)]
pub(crate) struct Modals {
pub(crate) slide_index: Vec<RenderOperation>,
@ -41,6 +37,11 @@ impl Presentation {
self.slides.iter()
}
/// Iterate the slides in this presentation.
pub(crate) fn iter_slides_mut(&mut self) -> impl Iterator<Item = &mut Slide> {
self.slides.iter_mut()
}
/// Iterate the operations that render the slide index.
pub(crate) fn iter_slide_index_operations(&self) -> impl Iterator<Item = &RenderOperation> {
self.modals.slide_index.iter()
@ -52,7 +53,6 @@ impl Presentation {
}
/// Consume this presentation and return its slides.
#[cfg(test)]
pub(crate) fn into_slides(self) -> Vec<Slide> {
self.slides
}
@ -140,92 +140,7 @@ impl Presentation {
self.current_slide().current_chunk_index()
}
/// Trigger async render operations in all slides.
pub(crate) fn trigger_all_async_renders(&mut self) -> HashSet<usize> {
let mut triggered_slides = HashSet::new();
for (index, slide) in self.slides.iter_mut().enumerate() {
for operation in slide.iter_operations_mut() {
if let RenderOperation::RenderAsync(operation) = operation {
if operation.start_render() {
triggered_slides.insert(index);
}
}
}
}
triggered_slides
}
/// Trigger async render operations in this slide.
pub(crate) fn trigger_slide_async_renders(&mut self) -> bool {
let slide = self.current_slide_mut();
let mut any_rendered = false;
for operation in slide.iter_visible_operations_mut() {
if let RenderOperation::RenderAsync(operation) = operation {
let is_rendered = operation.start_render();
any_rendered = any_rendered || is_rendered;
}
}
any_rendered
}
// Get all slides that contain async render operations.
pub(crate) fn slides_with_async_renders(&self) -> HashSet<usize> {
let mut indexes = HashSet::new();
for (index, slide) in self.slides.iter().enumerate() {
for operation in slide.iter_operations() {
if let RenderOperation::RenderAsync(operation) = operation {
if matches!(operation.poll_state(), RenderAsyncState::Rendering { .. }) {
indexes.insert(index);
break;
}
}
}
}
indexes
}
/// Poll every async render operation in the current slide and check whether they're completed.
pub(crate) fn poll_slide_async_renders(&mut self) -> RenderAsyncState {
let slide = self.current_slide_mut();
let mut slide_state = RenderAsyncState::Rendered;
for operation in slide.iter_operations_mut() {
if let RenderOperation::RenderAsync(operation) = operation {
let state = operation.poll_state();
slide_state = match (&slide_state, &state) {
// If one finished rendering and another one still is rendering, claim that we
// are still rendering and there's modifications.
(RenderAsyncState::JustFinishedRendering, RenderAsyncState::Rendering { .. })
| (RenderAsyncState::Rendering { .. }, RenderAsyncState::JustFinishedRendering) => {
RenderAsyncState::Rendering { modified: true }
}
// Render + modified overrides anything, rendering overrides only "rendered".
(_, RenderAsyncState::Rendering { modified: true })
| (RenderAsyncState::Rendered, RenderAsyncState::Rendering { .. })
| (_, RenderAsyncState::JustFinishedRendering) => state,
_ => slide_state,
};
}
}
slide_state
}
/// Run a callback through every operation and let it mutate it in place.
///
/// This should be used with care!
pub(crate) fn mutate_operations<F>(&mut self, mut callback: F)
where
F: FnMut(&mut RenderOperation),
{
for slide in &mut self.slides {
for chunk in &mut slide.chunks {
for operation in &mut chunk.operations {
callback(operation);
}
}
}
}
fn current_slide_mut(&mut self) -> &mut Slide {
pub(crate) fn current_slide_mut(&mut self) -> &mut Slide {
let index = self.current_slide_index();
&mut self.slides[index]
}
@ -383,7 +298,7 @@ impl Slide {
self.current_chunk().reset_mutations();
}
fn show_all_chunks(&mut self) {
pub(crate) fn show_all_chunks(&mut self) {
self.visible_chunks = self.chunks.len();
for chunk in &self.chunks {
chunk.apply_all_mutations();
@ -493,6 +408,18 @@ pub(crate) struct PresentationMetadata {
#[serde(default)]
pub(crate) sub_title: Option<String>,
/// The presentation event.
#[serde(default)]
pub(crate) event: Option<String>,
/// The presentation location.
#[serde(default)]
pub(crate) location: Option<String>,
/// The presentation date.
#[serde(default)]
pub(crate) date: Option<String>,
/// The presentation author.
#[serde(default)]
pub(crate) author: Option<String>,
@ -510,6 +437,19 @@ pub(crate) struct PresentationMetadata {
pub(crate) options: Option<OptionsConfig>,
}
impl PresentationMetadata {
/// Check if this presentation has frontmatter.
pub(crate) fn has_frontmatter(&self) -> bool {
self.title.is_some()
|| self.sub_title.is_some()
|| self.event.is_some()
|| self.location.is_some()
|| self.date.is_some()
|| self.author.is_some()
|| !self.authors.is_empty()
}
}
/// A presentation's theme metadata.
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct PresentationThemeMetadata {
@ -523,153 +463,7 @@ pub(crate) struct PresentationThemeMetadata {
/// Any specific overrides for the presentation's theme.
#[serde(default, rename = "override")]
pub(crate) overrides: Option<PresentationTheme>,
}
/// A line of preformatted text to be rendered.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct BlockLine {
pub(crate) text: BlockLineText,
pub(crate) unformatted_length: u16,
pub(crate) block_length: u16,
pub(crate) alignment: Alignment,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum BlockLineText {
Preformatted(String),
Weighted(WeightedTextBlock),
}
/// A render operation.
///
/// Render operations are primitives that allow the input markdown file to be decoupled with what
/// we draw on the screen.
#[derive(Clone, Debug)]
pub(crate) enum RenderOperation {
/// Clear the entire screen.
ClearScreen,
/// Set the colors to be used for any subsequent operations.
SetColors(Colors),
/// Jump the draw cursor into the vertical center, that is, at `screen_height / 2`.
JumpToVerticalCenter,
/// Jumps to the N-th row in the current layout.
///
/// The index is zero based where 0 represents the top row.
JumpToRow { index: u16 },
/// Jumps to the N-th to last row in the current layout.
///
/// The index is zero based where 0 represents the bottom row.
JumpToBottomRow { index: u16 },
/// Render text.
RenderText { line: WeightedTextBlock, alignment: Alignment },
/// Render a line break.
RenderLineBreak,
/// Render an image.
RenderImage(Image, ImageProperties),
/// Render a line.
RenderBlockLine(BlockLine),
/// Render a dynamically generated sequence of render operations.
///
/// This allows drawing something on the screen that requires knowing dynamic properties of the
/// screen, like window size, without coupling the transformation of markdown into
/// [RenderOperation] with the screen itself.
RenderDynamic(Rc<dyn AsRenderOperations>),
/// An operation that is rendered asynchronously.
RenderAsync(Rc<dyn RenderAsync>),
/// Initialize a column layout.
///
/// The value for each column is the width of the column in column-unit units, where the entire
/// screen contains `columns.sum()` column-units.
InitColumnLayout { columns: Vec<u8> },
/// Enter a column in a column layout.
///
/// The index is 0-index based and will be tied to a previous `InitColumnLayout` operation.
EnterColumn { column: usize },
/// Exit the current layout and go back to the default one.
ExitLayout,
/// Apply a margin to every following operation.
ApplyMargin(MarginProperties),
/// Pop an `ApplyMargin` operation.
PopMargin,
}
/// The properties of an image being rendered.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ImageProperties {
pub(crate) z_index: i32,
pub(crate) size: ImageSize,
pub(crate) restore_cursor: bool,
pub(crate) background_color: Option<Color>,
}
/// The size used when printing an image.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) enum ImageSize {
#[default]
ShrinkIfNeeded,
Specific(u16, u16),
WidthScaled {
ratio: f64,
},
}
/// Slide properties, set on initialization.
#[derive(Clone, Debug, Default)]
pub(crate) struct MarginProperties {
/// The horizontal margin.
pub(crate) horizontal_margin: Margin,
/// The margin at the bottom of the slide.
pub(crate) bottom_slide_margin: u16,
}
/// A type that can generate render operations.
pub(crate) trait AsRenderOperations: Debug + 'static {
/// Generate render operations.
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation>;
/// Get the content in this type to diff it against another `AsRenderOperations`.
fn diffable_content(&self) -> Option<&str>;
}
/// An operation that can be rendered asynchronously.
pub(crate) trait RenderAsync: AsRenderOperations {
/// Start the render for this operation.
///
/// Should return true if the invocation triggered the rendering (aka if rendering wasn't
/// already started before).
fn start_render(&self) -> bool;
/// Update the internal state and return the updated state.
fn poll_state(&self) -> RenderAsyncState;
}
/// The state of a [RenderAsync].
#[derive(Clone, Debug, Default)]
pub(crate) enum RenderAsyncState {
#[default]
NotStarted,
Rendering {
modified: bool,
},
Rendered,
JustFinishedRendering,
pub(crate) overrides: Option<crate::theme::raw::PresentationTheme>,
}
#[cfg(test)]

118
src/presentation/poller.rs Normal file
View File

@ -0,0 +1,118 @@
use crate::render::operation::{Pollable, PollableState};
use std::{
sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel},
thread,
time::Duration,
};
const POLL_INTERVAL: Duration = Duration::from_millis(25);
pub(crate) struct Poller {
sender: Sender<PollerCommand>,
receiver: Receiver<PollableEffect>,
}
impl Poller {
pub(crate) fn launch() -> Self {
let (command_sender, command_receiver) = channel();
let (effect_sender, effect_receiver) = channel();
let worker = PollerWorker::new(command_receiver, effect_sender);
thread::spawn(move || {
worker.run();
});
Self { sender: command_sender, receiver: effect_receiver }
}
pub(crate) fn send(&self, command: PollerCommand) {
let _ = self.sender.send(command);
}
pub(crate) fn next_effect(&mut self) -> Option<PollableEffect> {
self.receiver.try_recv().ok()
}
}
/// An effect caused by a pollable.
#[derive(Clone)]
pub(crate) enum PollableEffect {
/// Refresh the given slide.
RefreshSlide(usize),
/// Display an error for the given slide.
DisplayError { slide: usize, error: String },
}
/// A poller command.
pub(crate) enum PollerCommand {
/// Start polling a pollable that's positioned in the given slide.
Poll { pollable: Box<dyn Pollable>, slide: usize },
/// Reset all pollables.
Reset,
}
struct PollerWorker {
receiver: Receiver<PollerCommand>,
sender: Sender<PollableEffect>,
pollables: Vec<(Box<dyn Pollable>, usize)>,
}
impl PollerWorker {
fn new(receiver: Receiver<PollerCommand>, sender: Sender<PollableEffect>) -> Self {
Self { receiver, sender, pollables: Default::default() }
}
fn run(mut self) {
loop {
match self.receiver.recv_timeout(POLL_INTERVAL) {
Ok(command) => self.process_command(command),
// TODO don't loop forever.
Err(RecvTimeoutError::Timeout) => self.poll(),
Err(RecvTimeoutError::Disconnected) => break,
};
}
}
fn process_command(&mut self, command: PollerCommand) {
match command {
PollerCommand::Poll { mut pollable, slide } => {
// Poll and only insert if it's still running.
match pollable.poll() {
PollableState::Unmodified | PollableState::Modified => {
self.pollables.push((pollable, slide));
}
PollableState::Done => {
let _ = self.sender.send(PollableEffect::RefreshSlide(slide));
}
PollableState::Failed { error } => {
let _ = self.sender.send(PollableEffect::DisplayError { slide, error });
}
};
}
PollerCommand::Reset => self.pollables.clear(),
}
}
fn poll(&mut self) {
let mut removables = Vec::new();
for (index, (pollable, slide)) in self.pollables.iter_mut().enumerate() {
let slide = *slide;
let (effect, remove) = match pollable.poll() {
PollableState::Unmodified => (None, false),
PollableState::Modified => (Some(PollableEffect::RefreshSlide(slide)), false),
PollableState::Done => (Some(PollableEffect::RefreshSlide(slide)), true),
PollableState::Failed { error } => (Some(PollableEffect::DisplayError { slide, error }), true),
};
if let Some(effect) = effect {
let _ = self.sender.send(effect);
}
if remove {
removables.push(index);
}
}
// Walk back and swap remove to avoid invalidating indexes.
for index in removables.iter().rev() {
self.pollables.swap_remove(*index);
}
}
}

View File

@ -1,32 +1,48 @@
use crate::{
custom::KeyBindingsConfig,
diff::PresentationDiffer,
execute::SnippetExecutor,
export::ImageReplacer,
input::source::{Command, CommandSource},
code::execute::SnippetExecutor,
commands::{
listener::{Command, CommandListener},
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventPublisher},
},
config::{KeyBindingsConfig, SlideTransitionConfig, SlideTransitionStyleConfig},
markdown::parse::{MarkdownParser, ParseError},
media::{printer::ImagePrinter, register::ImageRegistry},
presentation::{Presentation, RenderAsyncState},
processing::builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
presentation::{
Presentation, Slide,
builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes},
diff::PresentationDiffer,
poller::{PollableEffect, Poller, PollerCommand},
},
render::{
draw::{ErrorSource, RenderError, RenderResult, TerminalDrawer},
ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions,
ascii_scaler::AsciiScaler,
engine::{MaxSize, RenderEngine, RenderEngineOptions},
operation::{Pollable, RenderAsyncStartPolicy, RenderOperation},
properties::WindowSize,
validate::OverflowValidator,
},
resource::Resources,
theme::PresentationTheme,
terminal::{
image::printer::{ImagePrinter, ImageRegistry},
printer::{TerminalCommand, TerminalIo},
virt::{ImageBehavior, TerminalGrid, VirtualTerminal},
},
theme::{ProcessingThemeError, raw::PresentationTheme},
third_party::ThirdPartyRender,
transitions::{
AnimateTransition, AnimationFrame, LinesFrame, TransitionDirection,
collapse_horizontal::CollapseHorizontalAnimation, fade::FadeAnimation,
slide_horizontal::SlideHorizontalAnimation,
},
};
use std::{
collections::HashSet,
fmt::Display,
fs,
io::{self, Stdout},
io::{self},
mem,
ops::Deref,
path::Path,
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
pub struct PresenterOptions {
@ -35,6 +51,8 @@ pub struct PresenterOptions {
pub font_size_fallback: u8,
pub bindings: KeyBindingsConfig,
pub validate_overflows: bool,
pub max_size: MaxSize,
pub transition: Option<SlideTransitionConfig>,
}
/// A slideshow presenter.
@ -42,16 +60,17 @@ pub struct PresenterOptions {
/// This type puts everything else together.
pub struct Presenter<'a> {
default_theme: &'a PresentationTheme,
commands: CommandSource,
listener: CommandListener,
parser: MarkdownParser<'a>,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Rc<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
state: PresenterState,
slides_with_pending_async_renders: HashSet<usize>,
image_printer: Arc<ImagePrinter>,
themes: Themes,
options: PresenterOptions,
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
poller: Poller,
}
impl<'a> Presenter<'a> {
@ -59,72 +78,137 @@ impl<'a> Presenter<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
default_theme: &'a PresentationTheme,
commands: CommandSource,
listener: CommandListener,
parser: MarkdownParser<'a>,
resources: Resources,
third_party: ThirdPartyRender,
code_executor: Rc<SnippetExecutor>,
code_executor: Arc<SnippetExecutor>,
themes: Themes,
image_printer: Arc<ImagePrinter>,
options: PresenterOptions,
speaker_notes_event_publisher: Option<SpeakerNotesEventPublisher>,
) -> Self {
Self {
default_theme,
commands,
listener,
parser,
resources,
third_party,
code_executor,
state: PresenterState::Empty,
slides_with_pending_async_renders: HashSet::new(),
image_printer,
themes,
options,
speaker_notes_event_publisher,
poller: Poller::launch(),
}
}
/// Run a presentation.
pub fn present(mut self, path: &Path) -> Result<(), PresentationError> {
if matches!(self.options.mode, PresentMode::Development) {
self.resources.watch_presentation_file(path.to_path_buf());
}
self.state = PresenterState::Presenting(Presentation::from(vec![]));
self.try_reload(path, true);
self.try_reload(path, true)?;
let mut drawer =
TerminalDrawer::new(io::stdout(), self.image_printer.clone(), self.options.font_size_fallback)?;
let drawer_options = TerminalDrawerOptions {
font_size_fallback: self.options.font_size_fallback,
max_size: self.options.max_size.clone(),
};
let mut drawer = TerminalDrawer::new(self.image_printer.clone(), drawer_options)?;
loop {
if matches!(self.options.mode, PresentMode::Export) {
if let PresenterState::Failure { error, .. } = &self.state {
return Err(PresentationError::Fatal(format!("failed to run presentation: {error}")));
}
}
// Poll async renders once before we draw just in case.
self.poll_async_renders()?;
self.render(&mut drawer)?;
loop {
if self.poll_async_renders()? {
if self.process_poller_effects()? {
self.render(&mut drawer)?;
}
let Some(command) = self.commands.try_next_command()? else {
if self.check_async_error() {
break;
}
continue;
let command = match self.listener.try_next_command()? {
Some(command) => command,
_ => match self.resources.resources_modified() {
true => Command::Reload,
false => {
if self.check_async_error() {
break;
}
continue;
}
},
};
match self.apply_command(command) {
CommandSideEffect::Exit => return Ok(()),
CommandSideEffect::Exit => {
self.publish_event(SpeakerNotesEvent::Exit)?;
return Ok(());
}
CommandSideEffect::Suspend => {
self.suspend(&mut drawer);
break;
}
CommandSideEffect::Reload => {
self.try_reload(path, false);
self.try_reload(path, false)?;
break;
}
CommandSideEffect::Redraw => {
self.try_scale_transition_images()?;
break;
}
CommandSideEffect::AnimateNextSlide => {
self.animate_next_slide(&mut drawer)?;
break;
}
CommandSideEffect::AnimatePreviousSlide => {
self.animate_previous_slide(&mut drawer)?;
break;
}
CommandSideEffect::None => (),
};
}
self.publish_event(SpeakerNotesEvent::GoToSlide {
slide: self.state.presentation().current_slide_index() as u32 + 1,
})?;
}
}
fn process_poller_effects(&mut self) -> Result<bool, PresentationError> {
let current_slide = match &self.state {
PresenterState::Presenting(presentation)
| PresenterState::SlideIndex(presentation)
| PresenterState::KeyBindings(presentation)
| PresenterState::Failure { presentation, .. } => presentation.current_slide_index(),
PresenterState::Empty => usize::MAX,
};
let mut refreshed = false;
let mut needs_render = false;
while let Some(effect) = self.poller.next_effect() {
match effect {
PollableEffect::RefreshSlide(index) => {
needs_render = needs_render || index == current_slide;
refreshed = true;
}
PollableEffect::DisplayError { slide, error } => {
let presentation = mem::take(&mut self.state).into_presentation();
self.state =
PresenterState::failure(error, presentation, ErrorSource::Slide(slide + 1), FailureMode::Other);
needs_render = true;
}
}
}
if refreshed {
self.try_scale_transition_images()?;
}
Ok(needs_render)
}
fn publish_event(&self, event: SpeakerNotesEvent) -> io::Result<()> {
if let Some(publisher) = &self.speaker_notes_event_publisher {
publisher.send(event)?;
}
Ok(())
}
fn check_async_error(&mut self) -> bool {
let error_holder = self.state.presentation().state.async_error_holder();
let error_holder = error_holder.lock().unwrap();
@ -143,37 +227,18 @@ impl<'a> Presenter<'a> {
}
}
fn poll_async_renders(&mut self) -> Result<bool, RenderError> {
if matches!(self.state, PresenterState::Failure { .. }) {
return Ok(false);
}
let current_index = self.state.presentation().current_slide_index();
if self.slides_with_pending_async_renders.contains(&current_index) {
let state = self.state.presentation_mut().poll_slide_async_renders();
match state {
RenderAsyncState::NotStarted | RenderAsyncState::Rendering { modified: false } => (),
RenderAsyncState::Rendering { modified: true } => {
return Ok(true);
}
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => {
self.slides_with_pending_async_renders.remove(&current_index);
return Ok(true);
}
};
}
Ok(false)
}
fn render(&mut self, drawer: &mut TerminalDrawer<Stdout>) -> RenderResult {
fn render(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
let result = match &self.state {
PresenterState::Presenting(presentation) => drawer.render_slide(presentation),
PresenterState::Presenting(presentation) => {
drawer.render_operations(presentation.current_slide().iter_visible_operations())
}
PresenterState::SlideIndex(presentation) => {
drawer.render_slide(presentation)?;
drawer.render_slide_index(presentation)
drawer.render_operations(presentation.current_slide().iter_visible_operations())?;
drawer.render_operations(presentation.iter_slide_index_operations())
}
PresenterState::KeyBindings(presentation) => {
drawer.render_slide(presentation)?;
drawer.render_key_bindings(presentation)
drawer.render_operations(presentation.current_slide().iter_visible_operations())?;
drawer.render_operations(presentation.iter_bindings_operations())
}
PresenterState::Failure { error, source, .. } => drawer.render_error(error, source),
PresenterState::Empty => panic!("cannot render without state"),
@ -196,6 +261,7 @@ impl<'a> Presenter<'a> {
return CommandSideEffect::Reload;
}
Command::Exit => return CommandSideEffect::Exit,
Command::Suspend => return CommandSideEffect::Suspend,
_ => (),
};
if matches!(command, Command::Redraw) {
@ -216,16 +282,37 @@ impl<'a> Presenter<'a> {
}
};
let needs_redraw = match command {
Command::Next => presentation.jump_next(),
Command::Next => {
let current_slide = presentation.current_slide_index();
if !presentation.jump_next() {
false
} else if presentation.current_slide_index() != current_slide {
return CommandSideEffect::AnimateNextSlide;
} else {
true
}
}
Command::NextFast => presentation.jump_next_fast(),
Command::Previous => presentation.jump_previous(),
Command::Previous => {
let current_slide = presentation.current_slide_index();
if !presentation.jump_previous() {
false
} else if presentation.current_slide_index() != current_slide {
return CommandSideEffect::AnimatePreviousSlide;
} else {
true
}
}
Command::PreviousFast => presentation.jump_previous_fast(),
Command::FirstSlide => presentation.jump_first_slide(),
Command::LastSlide => presentation.jump_last_slide(),
Command::GoToSlide(number) => presentation.go_to_slide(number.saturating_sub(1) as usize),
Command::RenderAsyncOperations => {
if presentation.trigger_slide_async_renders() {
self.slides_with_pending_async_renders.insert(self.state.presentation().current_slide_index());
let pollables = Self::trigger_slide_async_renders(presentation);
if !pollables.is_empty() {
for pollable in pollables {
self.poller.send(PollerCommand::Poll { pollable, slide: presentation.current_slide_index() });
}
return CommandSideEffect::Redraw;
} else {
return CommandSideEffect::None;
@ -245,18 +332,19 @@ impl<'a> Presenter<'a> {
true
}
// These are handled above as they don't require the presentation
Command::Reload | Command::HardReload | Command::Exit | Command::Redraw => {
Command::Reload | Command::HardReload | Command::Exit | Command::Suspend | Command::Redraw => {
panic!("unreachable commands")
}
};
if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None }
}
fn try_reload(&mut self, path: &Path, force: bool) {
fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult {
if matches!(self.options.mode, PresentMode::Presentation) && !force {
return;
return Ok(());
}
self.slides_with_pending_async_renders.clear();
self.poller.send(PollerCommand::Reset);
self.resources.clear_watches();
match self.load_presentation(path) {
Ok(mut presentation) => {
let current = self.state.presentation();
@ -267,21 +355,40 @@ impl<'a> Presenter<'a> {
presentation.go_to_slide(current.current_slide_index());
presentation.jump_chunk(current.current_chunk());
}
self.slides_with_pending_async_renders = match self.options.mode {
PresentMode::Development | PresentMode::Presentation => {
presentation.slides_with_async_renders().into_iter().collect()
}
// Trigger all async renders so we get snippet execution output in the PDF
// file.
PresentMode::Export => presentation.trigger_all_async_renders(),
};
self.start_automatic_async_renders(&mut presentation);
self.state = self.validate_overflows(presentation);
self.try_scale_transition_images()?;
}
Err(e) => {
let presentation = mem::take(&mut self.state).into_presentation();
self.state = PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other);
}
};
Ok(())
}
fn try_scale_transition_images(&self) -> RenderResult {
if self.options.transition.is_none() {
return Ok(());
}
let options = RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() };
let scaler = AsciiScaler::new(options);
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
scaler.process(self.state.presentation(), &dimensions)?;
Ok(())
}
fn trigger_slide_async_renders(presentation: &mut Presentation) -> Vec<Box<dyn Pollable>> {
let slide = presentation.current_slide_mut();
let mut pollables = Vec::new();
for operation in slide.iter_visible_operations_mut() {
if let RenderOperation::RenderAsync(operation) = operation {
if let RenderAsyncStartPolicy::OnDemand = operation.start_policy() {
pollables.push(operation.pollable());
}
}
}
pollables
}
fn is_displaying_other_error(&self) -> bool {
@ -308,22 +415,17 @@ impl<'a> Presenter<'a> {
fn load_presentation(&mut self, path: &Path) -> Result<Presentation, LoadPresentationError> {
let content = fs::read_to_string(path).map_err(LoadPresentationError::Reading)?;
let elements = self.parser.parse(&content)?;
let export_mode = matches!(self.options.mode, PresentMode::Export);
let mut presentation = PresentationBuilder::new(
let presentation = PresentationBuilder::new(
self.default_theme,
&mut self.resources,
self.resources.clone(),
&mut self.third_party,
self.code_executor.clone(),
&self.themes,
ImageRegistry(self.image_printer.clone()),
ImageRegistry::new(self.image_printer.clone()),
self.options.bindings.clone(),
self.options.builder_options.clone(),
)
)?
.build(elements)?;
if export_mode {
ImageReplacer::default().replace_presentation_images(&mut presentation);
}
Ok(presentation)
}
@ -348,12 +450,158 @@ impl<'a> Presenter<'a> {
other => self.state = other,
}
}
fn suspend(&self, drawer: &mut TerminalDrawer) {
#[cfg(unix)]
unsafe {
drawer.terminal.suspend();
libc::raise(libc::SIGTSTP);
drawer.terminal.resume();
}
}
fn animate_next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
let Some(config) = self.options.transition.clone() else {
return Ok(());
};
let options = drawer.render_engine_options();
let presentation = self.state.presentation_mut();
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
presentation.jump_previous();
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
presentation.jump_next();
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
let direction = TransitionDirection::Next;
self.animate_transition(drawer, left, right, direction, dimensions, config)
}
fn animate_previous_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
let Some(config) = self.options.transition.clone() else {
return Ok(());
};
let options = drawer.render_engine_options();
let presentation = self.state.presentation_mut();
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
presentation.jump_next();
// Re-borrow to avoid calling fns above while mutably borrowing
let presentation = self.state.presentation_mut();
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
presentation.jump_previous();
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
let direction = TransitionDirection::Previous;
self.animate_transition(drawer, left, right, direction, dimensions, config)
}
fn animate_transition(
&mut self,
drawer: &mut TerminalDrawer,
left: TerminalGrid,
right: TerminalGrid,
direction: TransitionDirection,
dimensions: WindowSize,
config: SlideTransitionConfig,
) -> RenderResult {
let first = match &direction {
TransitionDirection::Next => left.clone(),
TransitionDirection::Previous => right.clone(),
};
match &config.animation {
SlideTransitionStyleConfig::SlideHorizontal => self.run_animation(
drawer,
first,
SlideHorizontalAnimation::new(left, right, dimensions, direction),
config,
),
SlideTransitionStyleConfig::Fade => {
self.run_animation(drawer, first, FadeAnimation::new(left, right, direction), config)
}
SlideTransitionStyleConfig::CollapseHorizontal => {
self.run_animation(drawer, first, CollapseHorizontalAnimation::new(left, right, direction), config)
}
}
}
fn run_animation<T>(
&mut self,
drawer: &mut TerminalDrawer,
first: TerminalGrid,
animation: T,
config: SlideTransitionConfig,
) -> RenderResult
where
T: AnimateTransition,
{
let total_time = Duration::from_millis(config.duration_millis as u64);
let frames: usize = config.frames;
let total_frames = animation.total_frames();
let step = total_time / (frames as u32 * 2);
let mut last_frame_index = 0;
let mut frame_index = 1;
// Render the first frame as text to have images as ascii
Self::render_frame(&LinesFrame::from(&first).build_commands(), drawer)?;
while frame_index < total_frames {
let start = Instant::now();
let frame = animation.build_frame(frame_index, last_frame_index);
let commands = frame.build_commands();
Self::render_frame(&commands, drawer)?;
let elapsed = start.elapsed();
let sleep_needed = step.saturating_sub(elapsed);
if sleep_needed.as_millis() > 0 {
std::thread::sleep(step);
}
last_frame_index = frame_index;
frame_index += total_frames.div_ceil(frames);
}
Ok(())
}
fn render_frame(commands: &[TerminalCommand<'_>], drawer: &mut TerminalDrawer) -> RenderResult {
drawer.terminal.execute(&TerminalCommand::BeginUpdate)?;
for command in commands {
drawer.terminal.execute(command)?;
}
drawer.terminal.execute(&TerminalCommand::EndUpdate)?;
drawer.terminal.execute(&TerminalCommand::Flush)?;
Ok(())
}
fn virtual_render(
slide: &Slide,
dimensions: WindowSize,
options: &RenderEngineOptions,
) -> Result<TerminalGrid, RenderError> {
let mut term = VirtualTerminal::new(dimensions.clone(), ImageBehavior::PrintAscii);
let engine = RenderEngine::new(&mut term, dimensions.clone(), options.clone());
engine.render(slide.iter_visible_operations())?;
Ok(term.into_contents())
}
fn start_automatic_async_renders(&self, presentation: &mut Presentation) {
for (index, slide) in presentation.iter_slides_mut().enumerate() {
for operation in slide.iter_operations_mut() {
if let RenderOperation::RenderAsync(operation) = operation {
if let RenderAsyncStartPolicy::Automatic = operation.start_policy() {
let pollable = operation.pollable();
self.poller.send(PollerCommand::Poll { pollable, slide: index });
}
}
}
}
}
}
enum CommandSideEffect {
Exit,
Suspend,
Redraw,
Reload,
AnimateNextSlide,
AnimatePreviousSlide,
None,
}
@ -425,9 +673,6 @@ pub enum PresentMode {
/// This is a live presentation so we don't want hot reloading.
Presentation,
/// We are running a presentation that's being consumed by `presenterm-export`.
Export,
}
/// An error when loading a presentation.
@ -441,6 +686,9 @@ pub enum LoadPresentationError {
#[error(transparent)]
Processing(#[from] BuildError),
#[error("processing theme: {0}")]
ProcessingTheme(#[from] ProcessingThemeError),
}
/// An error during the presentation.
@ -451,7 +699,4 @@ pub enum PresentationError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("fatal error: {0}")]
Fatal(String),
}

View File

@ -1,218 +0,0 @@
use super::padding::NumberPadder;
use crate::{
markdown::elements::{HighlightGroup, Snippet},
presentation::{AsRenderOperations, BlockLine, BlockLineText, ChunkMutator, RenderOperation},
render::{
highlighting::{LanguageHighlighter, StyledTokens},
properties::WindowSize,
},
theme::{Alignment, CodeBlockStyle},
PresentationTheme,
};
use std::{cell::RefCell, rc::Rc};
use syntect::highlighting::Style;
use unicode_width::UnicodeWidthStr;
pub(crate) struct CodePreparer<'a> {
theme: &'a PresentationTheme,
}
impl<'a> CodePreparer<'a> {
pub(crate) fn new(theme: &'a PresentationTheme) -> Self {
Self { theme }
}
pub(crate) fn prepare(&self, code: &Snippet) -> Vec<CodeLine> {
let mut lines = Vec::new();
let horizontal_padding = self.theme.code.padding.horizontal.unwrap_or(0);
let vertical_padding = self.theme.code.padding.vertical.unwrap_or(0);
if vertical_padding > 0 {
lines.push(CodeLine::empty());
}
self.push_lines(code, horizontal_padding, &mut lines);
if vertical_padding > 0 {
lines.push(CodeLine::empty());
}
lines
}
fn push_lines(&self, code: &Snippet, horizontal_padding: u8, lines: &mut Vec<CodeLine>) {
if code.contents.is_empty() {
return;
}
let padding = " ".repeat(horizontal_padding as usize);
let padder = NumberPadder::new(code.visible_lines().count());
for (index, line) in code.visible_lines().enumerate() {
let mut line = line.to_string();
let mut prefix = padding.clone();
if code.attributes.line_numbers {
let line_number = index + 1;
prefix.push_str(&padder.pad_right(line_number));
prefix.push(' ');
}
line.push('\n');
let line_number = Some(index as u16 + 1);
lines.push(CodeLine { prefix, code: line, suffix: padding.clone(), line_number });
}
}
}
pub(crate) struct CodeLine {
pub(crate) prefix: String,
pub(crate) code: String,
pub(crate) suffix: String,
pub(crate) line_number: Option<u16>,
}
impl CodeLine {
pub(crate) fn empty() -> Self {
Self { prefix: String::new(), code: "\n".into(), suffix: String::new(), line_number: None }
}
pub(crate) fn width(&self) -> usize {
self.prefix.width() + self.code.width() + self.suffix.width()
}
pub(crate) fn highlight(
&self,
dim_style: &Style,
code_highlighter: &mut LanguageHighlighter,
block_style: &CodeBlockStyle,
) -> String {
let mut output = StyledTokens { style: *dim_style, tokens: &self.prefix }.apply_style(block_style);
output.push_str(&code_highlighter.highlight_line(&self.code, block_style));
output.push_str(&StyledTokens { style: *dim_style, tokens: &self.suffix }.apply_style(block_style));
output
}
pub(crate) fn dim(&self, padding_style: &Style, block_style: &CodeBlockStyle) -> String {
let mut output = String::new();
for chunk in [&self.prefix, &self.code, &self.suffix] {
output.push_str(&StyledTokens { style: *padding_style, tokens: chunk }.apply_style(block_style));
}
output
}
}
#[derive(Debug)]
pub(crate) struct HighlightContext {
pub(crate) groups: Vec<HighlightGroup>,
pub(crate) current: usize,
pub(crate) block_length: usize,
pub(crate) alignment: Alignment,
}
#[derive(Debug)]
pub(crate) struct HighlightedLine {
pub(crate) highlighted: String,
pub(crate) not_highlighted: String,
pub(crate) line_number: Option<u16>,
pub(crate) width: usize,
pub(crate) context: Rc<RefCell<HighlightContext>>,
}
impl AsRenderOperations for HighlightedLine {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
let context = self.context.borrow();
let group = &context.groups[context.current];
let needs_highlight = self.line_number.map(|number| group.contains(number)).unwrap_or_default();
// TODO: Cow<str>?
let text = match needs_highlight {
true => self.highlighted.clone(),
false => self.not_highlighted.clone(),
};
vec![
RenderOperation::RenderBlockLine(BlockLine {
text: BlockLineText::Preformatted(text),
unformatted_length: self.width as u16,
block_length: context.block_length as u16,
alignment: context.alignment.clone(),
}),
RenderOperation::RenderLineBreak,
]
}
fn diffable_content(&self) -> Option<&str> {
Some(&self.highlighted)
}
}
#[derive(Debug)]
pub(crate) struct HighlightMutator {
context: Rc<RefCell<HighlightContext>>,
}
impl HighlightMutator {
pub(crate) fn new(context: Rc<RefCell<HighlightContext>>) -> Self {
Self { context }
}
}
impl ChunkMutator for HighlightMutator {
fn mutate_next(&self) -> bool {
let mut context = self.context.borrow_mut();
if context.current == context.groups.len() - 1 {
false
} else {
context.current += 1;
true
}
}
fn mutate_previous(&self) -> bool {
let mut context = self.context.borrow_mut();
if context.current == 0 {
false
} else {
context.current -= 1;
true
}
}
fn reset_mutations(&self) {
self.context.borrow_mut().current = 0;
}
fn apply_all_mutations(&self) {
let mut context = self.context.borrow_mut();
context.current = context.groups.len() - 1;
}
fn mutations(&self) -> (usize, usize) {
let context = self.context.borrow();
(context.current, context.groups.len())
}
}
#[cfg(test)]
mod test {
use crate::markdown::elements::{SnippetAttributes, SnippetLanguage};
use super::*;
#[test]
fn code_with_line_numbers() {
let total_lines = 11;
let input_lines = "hi\n".repeat(total_lines);
let code = Snippet {
contents: input_lines,
language: SnippetLanguage::Unknown("".to_string()),
attributes: SnippetAttributes { line_numbers: true, ..Default::default() },
};
let lines = CodePreparer { theme: &Default::default() }.prepare(&code);
assert_eq!(lines.len(), total_lines);
let mut lines = lines.into_iter().enumerate();
// 0..=9
for (index, line) in lines.by_ref().take(9) {
let line_number = index + 1;
assert_eq!(&line.prefix, &format!(" {line_number} "));
}
// 10..
for (index, line) in lines {
let line_number = index + 1;
assert_eq!(&line.prefix, &format!("{line_number} "));
}
}
}

View File

@ -1,225 +0,0 @@
use super::separator::{RenderSeparator, SeparatorWidth};
use crate::{
execute::{ExecutionHandle, ExecutionState, ProcessStatus, SnippetExecutor},
markdown::elements::{Snippet, Text, TextBlock},
presentation::{AsRenderOperations, BlockLine, BlockLineText, RenderAsync, RenderAsyncState, RenderOperation},
render::properties::WindowSize,
style::{Colors, TextStyle},
theme::{Alignment, ExecutionStatusBlockStyle},
PresentationTheme,
};
use itertools::Itertools;
use std::{cell::RefCell, mem, rc::Rc};
use unicode_width::UnicodeWidthStr;
#[derive(Debug)]
struct RunSnippetOperationInner {
handle: Option<ExecutionHandle>,
output_lines: Vec<String>,
state: RenderAsyncState,
max_line_length: u16,
}
#[derive(Debug)]
pub(crate) struct RunSnippetOperation {
code: Snippet,
executor: Rc<SnippetExecutor>,
default_colors: Colors,
block_colors: Colors,
status_colors: ExecutionStatusBlockStyle,
block_length: u16,
alignment: Alignment,
inner: Rc<RefCell<RunSnippetOperationInner>>,
state_description: RefCell<Text>,
}
impl RunSnippetOperation {
pub(crate) fn new(
code: Snippet,
executor: Rc<SnippetExecutor>,
theme: &PresentationTheme,
block_length: u16,
) -> Self {
let default_colors = theme.default_style.colors.clone();
let block_colors = theme.execution_output.colors.clone();
let status_colors = theme.execution_output.status.clone();
let running_colors = status_colors.running.clone();
let alignment = theme.code.alignment.clone().unwrap_or_default();
let block_length = match &alignment {
Alignment::Left { .. } | Alignment::Right { .. } => block_length,
Alignment::Center { minimum_size, .. } => block_length.max(*minimum_size),
};
let inner = RunSnippetOperationInner {
handle: None,
output_lines: Vec::new(),
state: RenderAsyncState::default(),
max_line_length: 0,
};
Self {
code,
executor,
default_colors,
block_colors,
status_colors,
block_length,
alignment,
inner: Rc::new(RefCell::new(inner)),
state_description: Text::new("running", TextStyle::default().colors(running_colors)).into(),
}
}
fn render_line(&self, line: String, block_length: u16) -> RenderOperation {
let line_len = line.width() as u16;
RenderOperation::RenderBlockLine(BlockLine {
text: BlockLineText::Preformatted(line),
unformatted_length: line_len,
block_length,
alignment: self.alignment.clone(),
})
}
}
impl AsRenderOperations for RunSnippetOperation {
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
let inner = self.inner.borrow();
if matches!(inner.state, RenderAsyncState::NotStarted) {
return Vec::new();
}
let description = self.state_description.borrow();
let heading = TextBlock(vec![" [".into(), description.clone(), "] ".into()]);
let separator_width = match &self.alignment {
Alignment::Left { .. } | Alignment::Right { .. } => SeparatorWidth::FitToWindow,
Alignment::Center { .. } => SeparatorWidth::Fixed(self.block_length),
};
let separator = RenderSeparator::new(heading, separator_width);
let mut operations = vec![
RenderOperation::RenderLineBreak,
RenderOperation::RenderDynamic(Rc::new(separator)),
RenderOperation::RenderLineBreak,
RenderOperation::RenderLineBreak,
RenderOperation::SetColors(self.block_colors.clone()),
];
let block_length = self.block_length.max(inner.max_line_length.saturating_add(1));
for line in &inner.output_lines {
let chunks = line.chars().chunks(dimensions.columns as usize);
for chunk in &chunks {
operations.push(self.render_line(chunk.collect(), block_length));
operations.push(RenderOperation::RenderLineBreak);
}
}
operations.push(RenderOperation::SetColors(self.default_colors.clone()));
operations
}
fn diffable_content(&self) -> Option<&str> {
None
}
}
impl RenderAsync for RunSnippetOperation {
fn poll_state(&self) -> RenderAsyncState {
let mut inner = self.inner.borrow_mut();
if let Some(handle) = inner.handle.as_mut() {
let mut state = handle.state.lock().unwrap();
let ExecutionState { output, status } = &mut *state;
*self.state_description.borrow_mut() = match status {
ProcessStatus::Running => {
Text::new("running", TextStyle::default().colors(self.status_colors.running.clone()))
}
ProcessStatus::Success => {
Text::new("finished", TextStyle::default().colors(self.status_colors.success.clone()))
}
ProcessStatus::Failure => {
Text::new("finished with error", TextStyle::default().colors(self.status_colors.failure.clone()))
}
};
let new_lines = mem::take(output);
let modified = !new_lines.is_empty();
let is_finished = status.is_finished();
drop(state);
let mut max_line_length = 0;
for line in &new_lines {
let width = u16::try_from(line.width()).unwrap_or(u16::MAX);
max_line_length = max_line_length.max(width);
}
if is_finished {
inner.handle.take();
inner.state = RenderAsyncState::JustFinishedRendering;
} else {
inner.state = RenderAsyncState::Rendering { modified };
}
inner.output_lines.extend(new_lines);
inner.max_line_length = inner.max_line_length.max(max_line_length);
}
inner.state.clone()
}
fn start_render(&self) -> bool {
let mut inner = self.inner.borrow_mut();
if !matches!(inner.state, RenderAsyncState::NotStarted) {
return false;
}
match self.executor.execute(&self.code) {
Ok(handle) => {
inner.handle = Some(handle);
inner.state = RenderAsyncState::Rendering { modified: false };
true
}
Err(e) => {
inner.output_lines = vec![e.to_string()];
inner.state = RenderAsyncState::Rendered;
true
}
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct SnippetExecutionDisabledOperation {
colors: Colors,
alignment: Alignment,
started: RefCell<bool>,
}
impl SnippetExecutionDisabledOperation {
pub(crate) fn new(colors: Colors, alignment: Alignment) -> Self {
Self { colors, alignment, started: Default::default() }
}
}
impl AsRenderOperations for SnippetExecutionDisabledOperation {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
if !*self.started.borrow() {
return Vec::new();
}
vec![
RenderOperation::RenderLineBreak,
RenderOperation::RenderText {
line: vec![Text::new(
"snippet execution is disabled",
TextStyle::default().colors(self.colors.clone()),
)]
.into(),
alignment: self.alignment.clone(),
},
RenderOperation::RenderLineBreak,
]
}
fn diffable_content(&self) -> Option<&str> {
None
}
}
impl RenderAsync for SnippetExecutionDisabledOperation {
fn start_render(&self) -> bool {
let was_started = mem::replace(&mut *self.started.borrow_mut(), true);
!was_started
}
fn poll_state(&self) -> RenderAsyncState {
RenderAsyncState::Rendered
}
}

View File

@ -1,90 +0,0 @@
use crate::{
markdown::elements::Text,
presentation::{AsRenderOperations, RenderOperation},
render::properties::WindowSize,
style::{Colors, TextStyle},
theme::{Alignment, FooterStyle, Margin},
};
use std::{cell::RefCell, rc::Rc};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Default)]
pub(crate) struct FooterContext {
pub(crate) total_slides: usize,
pub(crate) author: String,
}
#[derive(Debug)]
pub(crate) struct FooterGenerator {
pub(crate) current_slide: usize,
pub(crate) context: Rc<RefCell<FooterContext>>,
pub(crate) style: FooterStyle,
}
impl FooterGenerator {
fn render_template(
template: &str,
current_slide: &str,
context: &FooterContext,
colors: Colors,
alignment: Alignment,
) -> RenderOperation {
let contents = template
.replace("{current_slide}", current_slide)
.replace("{total_slides}", &context.total_slides.to_string())
.replace("{author}", &context.author);
let text = Text::new(contents, TextStyle::default().colors(colors));
RenderOperation::RenderText { line: vec![text].into(), alignment }
}
}
impl AsRenderOperations for FooterGenerator {
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
let context = self.context.borrow();
match &self.style {
FooterStyle::Template { left, center, right, colors } => {
let current_slide = (self.current_slide + 1).to_string();
// We print this one row below the bottom so there's one row of padding.
let mut operations = vec![RenderOperation::JumpToBottomRow { index: 1 }];
let margin = Margin::Fixed(1);
let alignments = [
Alignment::Left { margin: margin.clone() },
Alignment::Center { minimum_size: 0, minimum_margin: margin.clone() },
Alignment::Right { margin: margin.clone() },
];
for (text, alignment) in [left, center, right].iter().zip(alignments) {
if let Some(text) = text {
operations.push(Self::render_template(
text,
&current_slide,
&context,
colors.clone(),
alignment,
));
}
}
operations
}
FooterStyle::ProgressBar { character, colors } => {
let character = character.unwrap_or('█').to_string();
let total_columns = dimensions.columns as usize / character.width();
let progress_ratio = (self.current_slide + 1) as f64 / context.total_slides as f64;
let columns_ratio = (total_columns as f64 * progress_ratio).ceil();
let bar = character.repeat(columns_ratio as usize);
let bar = Text::new(bar, TextStyle::default().colors(colors.clone()));
vec![
RenderOperation::JumpToBottomRow { index: 0 },
RenderOperation::RenderText {
line: vec![bar].into(),
alignment: Alignment::Left { margin: Margin::Fixed(0) },
},
]
}
FooterStyle::Empty => vec![],
}
}
fn diffable_content(&self) -> Option<&str> {
None
}
}

102
src/render/ascii_scaler.rs Normal file
View File

@ -0,0 +1,102 @@
use super::{
RenderError,
engine::{RenderEngine, RenderEngineOptions},
};
use crate::{
WindowSize,
presentation::Presentation,
terminal::{
image::Image,
printer::{TerminalCommand, TerminalError, TerminalIo},
},
};
use std::thread;
use unicode_width::UnicodeWidthStr;
pub(crate) struct AsciiScaler {
options: RenderEngineOptions,
}
impl AsciiScaler {
pub(crate) fn new(options: RenderEngineOptions) -> Self {
Self { options }
}
pub(crate) fn process(self, presentation: &Presentation, dimensions: &WindowSize) -> Result<(), RenderError> {
let mut collector = ImageCollector::default();
for slide in presentation.iter_slides() {
let engine = RenderEngine::new(&mut collector, dimensions.clone(), self.options.clone());
engine.render(slide.iter_operations())?;
}
thread::spawn(move || Self::scale(collector.images));
Ok(())
}
fn scale(images: Vec<ScalableImage>) {
for image in images {
let ascii_image = image.image.to_ascii();
ascii_image.cache_scaling(image.columns, image.rows);
}
}
}
struct ScalableImage {
image: Image,
rows: u16,
columns: u16,
}
struct ImageCollector {
current_column: u16,
current_row: u16,
current_row_height: u16,
images: Vec<ScalableImage>,
}
impl Default for ImageCollector {
fn default() -> Self {
Self { current_row: 0, current_column: 0, current_row_height: 1, images: Default::default() }
}
}
impl TerminalIo for ImageCollector {
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
use TerminalCommand::*;
match command {
MoveTo { column, row } => {
self.current_column = *column;
self.current_row = *row;
}
MoveToRow(row) => self.current_row = *row,
MoveToColumn(column) => self.current_column = *column,
MoveDown(amount) => self.current_row = self.current_row.saturating_add(*amount),
MoveRight(amount) => self.current_column = self.current_column.saturating_add(*amount),
MoveLeft(amount) => self.current_column = self.current_column.saturating_sub(*amount),
MoveToNextLine => {
self.current_row = self.current_row.saturating_add(1);
self.current_column = 0;
self.current_row_height = 1;
}
PrintText { content, style } => {
self.current_column = self.current_column.saturating_add(content.width() as u16);
self.current_row_height = self.current_row_height.max(style.size as u16);
}
PrintImage { image, options } => {
// we can only really cache filesystem images for now
let image = ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns };
self.images.push(image);
}
ClearScreen => {
self.current_column = 0;
self.current_row = 0;
self.current_row_height = 1;
}
BeginUpdate | EndUpdate | Flush | SetColors(_) | SetBackgroundColor(_) => (),
};
Ok(())
}
fn cursor_row(&self) -> u16 {
self.current_row
}
}

View File

@ -1,132 +0,0 @@
use super::{
engine::RenderEngine,
terminal::{Terminal, TerminalWrite},
};
use crate::{
markdown::{elements::Text, text::WeightedTextBlock},
media::printer::{ImagePrinter, PrintImageError},
presentation::{Presentation, RenderOperation},
render::properties::WindowSize,
style::{Color, Colors, TextStyle},
theme::{Alignment, Margin},
};
use std::{io, sync::Arc};
/// The result of a render operation.
pub(crate) type RenderResult = Result<(), RenderError>;
/// Allows drawing elements in the terminal.
pub(crate) struct TerminalDrawer<W: TerminalWrite> {
terminal: Terminal<W>,
font_size_fallback: u8,
}
impl<W> TerminalDrawer<W>
where
W: TerminalWrite,
{
/// Construct a drawer over a [std::io::Write].
pub(crate) fn new(handle: W, image_printer: Arc<ImagePrinter>, font_size_fallback: u8) -> io::Result<Self> {
let terminal = Terminal::new(handle, image_printer)?;
Ok(Self { terminal, font_size_fallback })
}
/// Render a slide.
pub(crate) fn render_slide(&mut self, presentation: &Presentation) -> RenderResult {
let dimensions = WindowSize::current(self.font_size_fallback)?;
let slide = presentation.current_slide();
let engine = self.create_engine(dimensions);
engine.render(slide.iter_visible_operations())?;
Ok(())
}
/// Render an error.
pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult {
let dimensions = WindowSize::current(self.font_size_fallback)?;
let heading_text = match source {
ErrorSource::Presentation => "Error loading presentation".to_string(),
ErrorSource::Slide(slide) => {
format!("Error in slide {slide}")
}
};
let heading = vec![Text::new(heading_text, TextStyle::default().bold()), Text::from(": ")];
let total_lines = message.lines().count();
let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);
let alignment = Alignment::Left { margin: Margin::Percent(25) };
let mut operations = vec![
RenderOperation::SetColors(Colors {
foreground: Some(Color::new(255, 0, 0)),
background: Some(Color::new(0, 0, 0)),
}),
RenderOperation::ClearScreen,
RenderOperation::JumpToRow { index: starting_row },
RenderOperation::RenderText { line: WeightedTextBlock::from(heading), alignment: alignment.clone() },
RenderOperation::RenderLineBreak,
RenderOperation::RenderLineBreak,
];
for line in message.lines() {
let error = vec![Text::from(line)];
let op = RenderOperation::RenderText { line: WeightedTextBlock::from(error), alignment: alignment.clone() };
operations.extend([op, RenderOperation::RenderLineBreak]);
}
let engine = self.create_engine(dimensions);
engine.render(operations.iter())?;
Ok(())
}
pub(crate) fn render_slide_index(&mut self, presentation: &Presentation) -> RenderResult {
let dimensions = WindowSize::current(self.font_size_fallback)?;
let engine = self.create_engine(dimensions);
engine.render(presentation.iter_slide_index_operations())?;
Ok(())
}
pub(crate) fn render_key_bindings(&mut self, presentation: &Presentation) -> RenderResult {
let dimensions = WindowSize::current(self.font_size_fallback)?;
let engine = self.create_engine(dimensions);
engine.render(presentation.iter_bindings_operations())?;
Ok(())
}
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<W> {
let options = Default::default();
RenderEngine::new(&mut self.terminal, dimensions, options)
}
}
/// A rendering error.
#[derive(thiserror::Error, Debug)]
pub enum RenderError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("unsupported structure: {0}")]
UnsupportedStructure(&'static str),
#[error("screen is too small")]
TerminalTooSmall,
#[error("tried to move to non existent layout location")]
InvalidLayoutEnter,
#[error("tried to pop default screen")]
PopDefaultScreen,
#[error("printing image: {0}")]
PrintImage(#[from] PrintImageError),
#[error("horizontal overflow")]
HorizontalOverflow,
#[error("vertical overflow")]
VerticalOverflow,
#[error(transparent)]
Other(Box<dyn std::error::Error>),
}
pub(crate) enum ErrorSource {
Presentation,
Slide(usize),
}

View File

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

View File

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

View File

@ -1,8 +1,161 @@
pub(crate) mod draw;
pub(crate) mod ascii_scaler;
pub(crate) mod engine;
pub(crate) mod highlighting;
pub(crate) mod layout;
pub(crate) mod operation;
pub(crate) mod properties;
pub(crate) mod terminal;
pub(crate) mod text;
pub(crate) mod validate;
use crate::{
markdown::{
elements::Text,
text::WeightedLine,
text_style::{Color, Colors, PaletteColorError, TextStyle},
},
render::{operation::RenderOperation, properties::WindowSize},
terminal::{
Terminal,
image::printer::{ImagePrinter, PrintImageError},
printer::TerminalError,
},
theme::{Alignment, Margin},
};
use engine::{MaxSize, RenderEngine, RenderEngineOptions};
use operation::AsRenderOperations;
use std::{
io::{self, Stdout},
iter,
rc::Rc,
sync::Arc,
};
/// The result of a render operation.
pub(crate) type RenderResult = Result<(), RenderError>;
pub(crate) struct TerminalDrawerOptions {
pub(crate) font_size_fallback: u8,
pub(crate) max_size: MaxSize,
}
impl Default for TerminalDrawerOptions {
fn default() -> Self {
Self { font_size_fallback: 1, max_size: Default::default() }
}
}
/// Allows drawing on the terminal.
pub(crate) struct TerminalDrawer {
pub(crate) terminal: Terminal<Stdout>,
options: TerminalDrawerOptions,
}
impl TerminalDrawer {
pub(crate) fn new(image_printer: Arc<ImagePrinter>, options: TerminalDrawerOptions) -> io::Result<Self> {
let terminal = Terminal::new(io::stdout(), image_printer)?;
Ok(Self { terminal, options })
}
pub(crate) fn render_operations<'a>(
&mut self,
operations: impl Iterator<Item = &'a RenderOperation>,
) -> RenderResult {
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
let engine = self.create_engine(dimensions);
engine.render(operations)?;
Ok(())
}
pub(crate) fn render_error(&mut self, message: &str, source: &ErrorSource) -> RenderResult {
let operation = RenderErrorOperation { message: message.into(), source: source.clone() };
let operation = RenderOperation::RenderDynamic(Rc::new(operation));
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
let engine = self.create_engine(dimensions);
engine.render(iter::once(&operation))?;
Ok(())
}
pub(crate) fn render_engine_options(&self) -> RenderEngineOptions {
RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() }
}
fn create_engine(&mut self, dimensions: WindowSize) -> RenderEngine<Terminal<Stdout>> {
let options = self.render_engine_options();
RenderEngine::new(&mut self.terminal, dimensions, options)
}
}
/// A rendering error.
#[derive(thiserror::Error, Debug)]
pub(crate) enum RenderError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("terminal: {0}")]
Terminal(#[from] TerminalError),
#[error("screen is too small")]
TerminalTooSmall,
#[error("tried to move to non existent layout location")]
InvalidLayoutEnter,
#[error("tried to pop default screen")]
PopDefaultScreen,
#[error("printing image: {0}")]
PrintImage(#[from] PrintImageError),
#[error("horizontal overflow")]
HorizontalOverflow,
#[error("vertical overflow")]
VerticalOverflow,
#[error(transparent)]
PaletteColor(#[from] PaletteColorError),
}
#[derive(Clone, Debug)]
pub(crate) enum ErrorSource {
Presentation,
Slide(usize),
}
#[derive(Debug)]
struct RenderErrorOperation {
message: String,
source: ErrorSource,
}
impl AsRenderOperations for RenderErrorOperation {
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation> {
let heading_text = match self.source {
ErrorSource::Presentation => "Error loading presentation".to_string(),
ErrorSource::Slide(slide) => {
format!("Error in slide {slide}")
}
};
let heading = vec![Text::new(heading_text, TextStyle::default().bold()), Text::from(": ")];
let total_lines = self.message.lines().count();
let starting_row = (dimensions.rows / 2).saturating_sub(total_lines as u16 / 2 + 3);
let alignment = Alignment::Left { margin: Margin::Percent(25) };
let mut operations = vec![
RenderOperation::SetColors(Colors {
foreground: Some(Color::new(255, 0, 0)),
background: Some(Color::new(0, 0, 0)),
}),
RenderOperation::ClearScreen,
RenderOperation::JumpToRow { index: starting_row },
RenderOperation::RenderText { line: WeightedLine::from(heading), alignment },
RenderOperation::RenderLineBreak,
RenderOperation::RenderLineBreak,
];
for line in self.message.lines() {
let error = vec![Text::from(line)];
let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment };
operations.extend([op, RenderOperation::RenderLineBreak]);
}
operations
}
}

220
src/render/operation.rs Normal file
View File

@ -0,0 +1,220 @@
use super::properties::WindowSize;
use crate::{
markdown::{
text::{WeightedLine, WeightedText},
text_style::{Color, Colors},
},
terminal::image::Image,
theme::{Alignment, Margin},
};
use std::{
fmt::Debug,
rc::Rc,
sync::{Arc, Mutex},
};
const DEFAULT_IMAGE_Z_INDEX: i32 = -2;
/// A line of preformatted text to be rendered.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct BlockLine {
pub(crate) prefix: WeightedText,
pub(crate) right_padding_length: u16,
pub(crate) repeat_prefix_on_wrap: bool,
pub(crate) text: WeightedLine,
pub(crate) block_length: u16,
pub(crate) block_color: Option<Color>,
pub(crate) alignment: Alignment,
}
/// A render operation.
///
/// Render operations are primitives that allow the input markdown file to be decoupled with what
/// we draw on the screen.
#[derive(Clone, Debug)]
pub(crate) enum RenderOperation {
/// Clear the entire screen.
ClearScreen,
/// Set the colors to be used for any subsequent operations.
SetColors(Colors),
/// Jump the draw cursor into the vertical center, that is, at `screen_height / 2`.
JumpToVerticalCenter,
/// Jumps to the N-th row in the current layout.
///
/// The index is zero based where 0 represents the top row.
JumpToRow { index: u16 },
/// Jumps to the N-th to last row in the current layout.
///
/// The index is zero based where 0 represents the bottom row.
JumpToBottomRow { index: u16 },
/// Jump to the N-th column in the current layout.
JumpToColumn { index: u16 },
/// Render text.
RenderText { line: WeightedLine, alignment: Alignment },
/// Render a line break.
RenderLineBreak,
/// Render an image.
RenderImage(Image, ImageRenderProperties),
/// Render a line.
RenderBlockLine(BlockLine),
/// Render a dynamically generated sequence of render operations.
///
/// This allows drawing something on the screen that requires knowing dynamic properties of the
/// screen, like window size, without coupling the transformation of markdown into
/// [RenderOperation] with the screen itself.
RenderDynamic(Rc<dyn AsRenderOperations>),
/// An operation that is rendered asynchronously.
RenderAsync(Rc<dyn RenderAsync>),
/// Initialize a column layout.
///
/// The value for each column is the width of the column in column-unit units, where the entire
/// screen contains `columns.sum()` column-units.
InitColumnLayout { columns: Vec<u8> },
/// Enter a column in a column layout.
///
/// The index is 0-index based and will be tied to a previous `InitColumnLayout` operation.
EnterColumn { column: usize },
/// Exit the current layout and go back to the default one.
ExitLayout,
/// Apply a margin to every following operation.
ApplyMargin(MarginProperties),
/// Pop an `ApplyMargin` operation.
PopMargin,
}
/// The properties of an image being rendered.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ImageRenderProperties {
pub(crate) z_index: i32,
pub(crate) size: ImageSize,
pub(crate) restore_cursor: bool,
pub(crate) background_color: Option<Color>,
pub(crate) position: ImagePosition,
}
impl Default for ImageRenderProperties {
fn default() -> Self {
Self {
z_index: DEFAULT_IMAGE_Z_INDEX,
size: Default::default(),
restore_cursor: false,
background_color: None,
position: ImagePosition::Center,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ImagePosition {
Cursor,
Center,
Right,
}
/// The size used when printing an image.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) enum ImageSize {
#[default]
ShrinkIfNeeded,
Specific(u16, u16),
WidthScaled {
ratio: f64,
},
}
/// Slide properties, set on initialization.
#[derive(Clone, Debug, Default)]
pub(crate) struct MarginProperties {
/// The horizontal margin.
pub(crate) horizontal: Margin,
/// The margin at the top.
pub(crate) top: u16,
/// The margin at the bottom.
pub(crate) bottom: u16,
}
/// A type that can generate render operations.
pub(crate) trait AsRenderOperations: Debug + 'static {
/// Generate render operations.
fn as_render_operations(&self, dimensions: &WindowSize) -> Vec<RenderOperation>;
/// Get the content in this type to diff it against another `AsRenderOperations`.
fn diffable_content(&self) -> Option<&str> {
None
}
}
/// An operation that can be rendered asynchronously.
pub(crate) trait RenderAsync: AsRenderOperations {
/// Create a pollable for this render async.
///
/// The pollable will be used to poll this by a separate thread, so all state that will
/// be loaded asynchronously should be shared between this operation and any pollables
/// generated from it.
fn pollable(&self) -> Box<dyn Pollable>;
/// Get the start policy for this render.
fn start_policy(&self) -> RenderAsyncStartPolicy {
RenderAsyncStartPolicy::OnDemand
}
}
/// The start policy for an async render.
#[derive(Copy, Clone, Debug)]
pub(crate) enum RenderAsyncStartPolicy {
/// Start automatically.
Automatic,
/// Start on demand.
OnDemand,
}
/// A pollable that can be used to pull and update the state of an operation asynchronously.
pub(crate) trait Pollable: Send + 'static {
/// Update the internal state and return the updated state.
fn poll(&mut self) -> PollableState;
}
/// The state of a [Pollable].
#[derive(Clone, Debug)]
pub(crate) enum PollableState {
Unmodified,
Modified,
Done,
Failed { error: String },
}
pub(crate) struct ToggleState {
toggled: Arc<Mutex<bool>>,
}
impl ToggleState {
pub(crate) fn new(toggled: Arc<Mutex<bool>>) -> Self {
Self { toggled }
}
}
impl Pollable for ToggleState {
fn poll(&mut self) -> PollableState {
*self.toggled.lock().unwrap() = true;
PollableState::Done
}
}

View File

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

View File

@ -1,161 +0,0 @@
use crate::{
media::{
image::Image,
printer::{ImagePrinter, PrintImage, PrintImageError, PrintOptions},
},
style::Colors,
};
use crossterm::{
cursor,
style::{self, StyledContent},
terminal::{self},
QueueableCommand,
};
use std::{
io::{self, Write},
sync::Arc,
};
/// A wrapper over the terminal write handle.
pub(crate) struct Terminal<W>
where
W: TerminalWrite,
{
writer: W,
image_printer: Arc<ImagePrinter>,
pub(crate) cursor_row: u16,
}
impl<W: TerminalWrite> Terminal<W> {
pub(crate) fn new(mut writer: W, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {
writer.init()?;
Ok(Self { writer, image_printer, cursor_row: 0 })
}
pub(crate) fn begin_update(&mut self) -> io::Result<()> {
self.writer.queue(terminal::BeginSynchronizedUpdate)?;
Ok(())
}
pub(crate) fn end_update(&mut self) -> io::Result<()> {
self.writer.queue(terminal::EndSynchronizedUpdate)?;
Ok(())
}
pub(crate) fn move_to(&mut self, column: u16, row: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveTo(column, row))?;
self.cursor_row = row;
Ok(())
}
pub(crate) fn move_to_row(&mut self, row: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveToRow(row))?;
self.cursor_row = row;
Ok(())
}
pub(crate) fn move_to_column(&mut self, column: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveToColumn(column))?;
Ok(())
}
pub(crate) fn move_down(&mut self, amount: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveDown(amount))?;
self.cursor_row += amount;
Ok(())
}
pub(crate) fn move_to_next_line(&mut self, amount: u16) -> io::Result<()> {
self.writer.queue(cursor::MoveToNextLine(amount))?;
self.cursor_row += amount;
Ok(())
}
pub(crate) fn print_line(&mut self, text: &str) -> io::Result<()> {
self.writer.queue(style::Print(text))?;
Ok(())
}
pub(crate) fn print_styled_line(&mut self, content: StyledContent<String>) -> io::Result<()> {
self.writer.queue(style::PrintStyledContent(content))?;
Ok(())
}
pub(crate) fn clear_screen(&mut self) -> io::Result<()> {
self.writer.queue(terminal::Clear(terminal::ClearType::All))?;
self.cursor_row = 0;
Ok(())
}
pub(crate) fn set_colors(&mut self, colors: Colors) -> io::Result<()> {
self.writer.queue(style::ResetColor)?;
self.writer.queue(style::SetColors(colors.into()))?;
Ok(())
}
pub(crate) fn flush(&mut self) -> io::Result<()> {
self.writer.flush()?;
Ok(())
}
pub(crate) fn sync_cursor_row(&mut self, position: u16) -> io::Result<()> {
self.cursor_row = position;
self.writer.queue(cursor::MoveToRow(position))?;
Ok(())
}
pub(crate) fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
self.move_to_column(options.cursor_position.column)?;
self.image_printer.print(&image.resource, options, &mut self.writer)?;
self.cursor_row += options.rows;
Ok(())
}
}
impl<W> Drop for Terminal<W>
where
W: TerminalWrite,
{
fn drop(&mut self) {
self.writer.deinit();
}
}
fn should_hide_cursor() -> bool {
// WezTerm on Windows fails to display images if we've hidden the cursor so we **always** hide it
// unless we're on WezTerm on Windows.
let term = std::env::var("TERM_PROGRAM");
let is_wezterm = term.as_ref().map(|s| s.as_str()) == Ok("WezTerm");
!(is_windows_based_os() && is_wezterm)
}
fn is_windows_based_os() -> bool {
let is_windows = std::env::consts::OS == "windows";
let is_wsl = std::env::var("WSL_DISTRO_NAME").is_ok();
is_windows || is_wsl
}
pub trait TerminalWrite: io::Write {
fn init(&mut self) -> io::Result<()>;
fn deinit(&mut self);
}
impl TerminalWrite for io::Stdout {
fn init(&mut self) -> io::Result<()> {
terminal::enable_raw_mode()?;
if should_hide_cursor() {
self.queue(cursor::Hide)?;
}
self.queue(terminal::EnterAlternateScreen)?;
Ok(())
}
fn deinit(&mut self) {
let _ = self.queue(terminal::LeaveAlternateScreen);
if should_hide_cursor() {
let _ = self.queue(cursor::Show);
}
let _ = self.flush();
let _ = terminal::disable_raw_mode();
}
}

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