diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6ded3b5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# This raw-code fixture intentionally contains tab-indented Scala. +tests/number-placement/4-longcode.typ whitespace=-space-before-tab diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3564866..4bea39d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,23 @@ on: branches: [ main ] jobs: + formatting: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Typstyle + uses: baptiste0928/cargo-install@v3.0.0 + with: + crate: typstyle + version: '0.15.1' + + - name: Check Typst formatting + run: >- + typstyle --check --line-width 100 --indent-width 2 + --no-reorder-import-items codly.typ src tests + tests: runs-on: ubuntu-latest steps: @@ -17,20 +34,20 @@ jobs: - name: Probe runner package cache uses: awalsh128/cache-apt-pkgs-action@latest with: - packages: cargo + packages: cargo poppler-utils version: 1.0 - - name: Install tytanic from github + - name: Install Tytanic uses: baptiste0928/cargo-install@v3.0.0 with: crate: tytanic - version: '0.2.1' + version: '0.4.1' - name: Setup typst uses: typst-community/setup-typst@v4 with: - version: 'v0.13.1' + version: 'v0.15.1' - name: Run test suite - run: tt run --no-fail-fast --font-path ./docs/fonts + run: python3 tests/run.py - name: Archive diffs uses: actions/upload-artifact@v4 if: always() diff --git a/Justfile b/Justfile index 4b251a7..b6405c6 100644 --- a/Justfile +++ b/Justfile @@ -9,25 +9,25 @@ export TYPST_ROOT := root default: @just --list --unsorted -# generate manual -doc *args: - typst compile docs/docs.typ docs.pdf --root . --font-path ./docs/fonts --ignore-system-fonts {{ args }} - -# watch the manual -doc-watch *args: - typst watch docs/docs.typ docs.pdf --root . --font-path ./docs/fonts --ignore-system-fonts {{ args }} - # generate the codly function signature in codly.typ signature: python3 ./scripts/gen-signature.py # run test suite test *args: - tt run --no-fail-fast --font-path ./docs/fonts {{ args }} + tt run --no-fail-fast --font-path ./fonts {{ args }} # update test cases update *args: - tt update --font-path ./docs/fonts {{ args }} + tt update --font-path ./fonts {{ args }} + +# format Typst library, tests, and documentation sources +fmt: + typstyle --inplace --line-width 100 --indent-width 2 --no-reorder-import-items codly.typ src tests + +# verify Typst formatting without changing files +fmt-check: + typstyle --check --line-width 100 --indent-width 2 --no-reorder-import-items codly.typ src tests # package the library into the specified destination folder package target: @@ -55,4 +55,4 @@ uninstall: (remove "@local") uninstall-preview: (remove "@preview") # run ci suite -ci: test doc +ci: fmt-check test diff --git a/README.md b/README.md index cc8dc52..4eca878 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,19 @@ Codly offers a wide range of features for referencing code blocks, lines, highli ![highlight-ref](./assets/highlight-ref.png) +### Querying a code block (development API) + +References remain native Typst references and do not require a custom `ref` show rule. For queried information, `codly.info` reads a small metadata record: + +```typ +#import "codly.typ" as codly +#codly.new(raw("first\nsecond", block: true)) + +#context [This block has #codly.info().lines source lines.] +``` + +`lines` counts source lines before ranges and skips. `last-number` is the last displayed line number, including offsets, or `none` for an empty block. `offset-from` uses the same record. Pass the label of the code block or its containing figure. + ### Setting an offset If you wish to add an offset to your code block, but without selecting a subset of lines, you can use the `codly-offset` function: diff --git a/bench/indent-guides.py b/bench/indent-guides.py new file mode 100644 index 0000000..4bf1c35 --- /dev/null +++ b/bench/indent-guides.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Compare indentation guides with the disabled path and an optional old checkout.""" +import argparse +import json +from pathlib import Path +import statistics +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +SOURCES = { + "typical": "\n".join( + f"fn f{i}() {{\n if true {{\n call({i});\n }}\n}}" + for i in range(200) + ), + "deep": "\n".join(" " * (i % 16) + "call(value);" for i in range(1000)), + "blank-lines": "\n".join("root\n child\n\n leaf\n\n child\nroot" for _ in range(140)), + "no-indent": "\n".join(f"let value{i} = {i};" for i in range(1000)), + "wrapped": "\n".join(" " * (i % 4) + "call(" + "long_argument, " * 12 + ");" for i in range(300)), +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", type=Path, help="An old checkout containing codly.typ") + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--case", choices=SOURCES, action="append", dest="cases") + args = parser.parse_args() + if args.repeats < 1: + parser.error("--repeats must be positive") + variants = [("disabled", ROOT, ""), ("monochrome", ROOT, ", indent-guides: true"), + ("rainbow-guides", ROOT, ", indent-guides: (rainbow: true)"), + ("both", ROOT, ", indent-guides: true, rainbow: true")] + if args.baseline: + variants.insert(0, ("baseline", args.baseline.resolve(), "")) + with tempfile.TemporaryDirectory(prefix="codly-indent-bench-") as directory: + temporary = Path(directory) + for case in args.cases or SOURCES: + paths = {} + for name, root, options in variants: + path = temporary / f"{name}.typ" + path.write_text( + f'#import {json.dumps(str(root / "codly.typ"), ensure_ascii=False)} as codly\n' + '#set page(width: 400pt, height: 600pt, margin: 10pt)\n' + f'#codly.new(raw({json.dumps(SOURCES[case])}, lang: "rs", block: true)' + + options + ")\n" + ) + paths[name] = path + times = {name: [] for name in paths} + for _ in range(args.repeats): + for name, path in paths.items(): + start = time.perf_counter() + subprocess.run( + ["typst", "compile", "--root", "/", str(path), str(temporary / "out.pdf")], + check=True, stdout=subprocess.DEVNULL, + ) + times[name].append(time.perf_counter() - start) + print(json.dumps({ + "case": case, + "median_seconds": {name: round(statistics.median(runs), 3) for name, runs in times.items()}, + "runs_seconds": {name: [round(t, 3) for t in runs] for name, runs in times.items()}, + }), flush=True) + + +if __name__ == "__main__": + main() diff --git a/bench/rainbow.py b/bench/rainbow.py new file mode 100644 index 0000000..4f0cdc7 --- /dev/null +++ b/bench/rainbow.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Compare rainbow coloring with the disabled path and an optional old checkout.""" +import argparse +import json +from pathlib import Path +import statistics +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +SOURCES = { + "typical": "\n".join( + f'fn f{i}() {{\n let value = {i};\n println!("value", value);\n}}' + for i in range(250) + ), + "dense": "\n".join( + f'fn f{i}() {{ let x = [1, 2]; println!("{{}}", x[0]); }}' + for i in range(1000) + ), + "no-delimiters": "\n".join(f"let value{i} = {i};" for i in range(1000)), +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", type=Path, help="An old checkout containing codly.typ") + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--case", choices=SOURCES, action="append", dest="cases") + args = parser.parse_args() + if args.repeats < 1: + parser.error("--repeats must be positive") + variants = [("disabled", ROOT, False), ("enabled", ROOT, True)] + if args.baseline: + variants.insert(0, ("baseline", args.baseline.resolve(), False)) + with tempfile.TemporaryDirectory(prefix="codly-rainbow-bench-") as directory: + temporary = Path(directory) + for case in args.cases or SOURCES: + paths = {} + for name, root, enabled in variants: + path = temporary / f"{name}.typ" + path.write_text( + f'#import {json.dumps(str(root / "codly.typ"), ensure_ascii=False)} as codly\n' + '#set page(width: 400pt, height: 600pt, margin: 10pt)\n' + f'#codly.new(raw({json.dumps(SOURCES[case])}, lang: "rs", block: true)' + + (", rainbow: true" if enabled else "") + ")\n" + ) + paths[name] = path + times = {name: [] for name in paths} + for _ in range(args.repeats): + for name, path in paths.items(): + start = time.perf_counter() + subprocess.run( + ["typst", "compile", "--root", "/", str(path), str(temporary / "out.pdf")], + check=True, stdout=subprocess.DEVNULL, + ) + times[name].append(time.perf_counter() - start) + print(json.dumps({ + "case": case, + "median_seconds": {name: round(statistics.median(runs), 3) for name, runs in times.items()}, + "runs_seconds": {name: [round(t, 3) for t in runs] for name, runs in times.items()}, + }), flush=True) + + +if __name__ == "__main__": + main() diff --git a/bench/wrap-marker.py b/bench/wrap-marker.py new file mode 100644 index 0000000..eda9037 --- /dev/null +++ b/bench/wrap-marker.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Compare soft-wrap markers with the disabled path and an optional old checkout.""" +import argparse +import json +from pathlib import Path +import statistics +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +SOURCES = { + "short": "\n".join(f" call({i});" for i in range(1000)), + "wrapped": "\n".join(f" call({i}, " + "long_argument, " * 12 + ");" for i in range(300)), + "unicode": "\n".join(" " + "中文内容" * 24 for _ in range(200)), +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", type=Path, help="An old checkout containing codly.typ") + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--case", choices=SOURCES, action="append", dest="cases") + args = parser.parse_args() + if args.repeats < 1: + parser.error("--repeats must be positive") + variants = [("disabled", ROOT, ""), + ("marker", ROOT, ", wrap-marker: [↪]"), + ("smart-off", ROOT, ", wrap-marker: [↪], smart-indent: false"), + ("combined", ROOT, ", wrap-marker: [↪], indent-guides: true, rainbow: true")] + if args.baseline: + variants.insert(0, ("baseline", args.baseline.resolve(), "")) + with tempfile.TemporaryDirectory(prefix="codly-wrap-bench-") as directory: + temporary = Path(directory) + for case in args.cases or SOURCES: + paths = {} + for name, root, options in variants: + path = temporary / f"{name}.typ" + path.write_text( + f'#import {json.dumps(str(root / "codly.typ"), ensure_ascii=False)} as codly\n' + '#set page(width: 400pt, height: 600pt, margin: 10pt)\n' + f'#codly.new(raw({json.dumps(SOURCES[case])}, lang: "rs", block: true)' + + options + ")\n" + ) + paths[name] = path + times = {name: [] for name in paths} + for _ in range(args.repeats): + for name, path in paths.items(): + start = time.perf_counter() + subprocess.run( + ["typst", "compile", "--root", "/", str(path), str(temporary / "out.pdf")], + check=True, stdout=subprocess.DEVNULL, + ) + times[name].append(time.perf_counter() - start) + print(json.dumps({ + "case": case, + "median_seconds": {name: round(statistics.median(runs), 3) for name, runs in times.items()}, + "runs_seconds": {name: [round(t, 3) for t in runs] for name, runs in times.items()}, + }), flush=True) + + +if __name__ == "__main__": + main() diff --git a/codly.typ b/codly.typ index 39c18ab..3267a47 100644 --- a/codly.typ +++ b/codly.typ @@ -1,607 +1,1913 @@ -#import "src/lib.typ": ( - codly-init, - codly-reset, - no-codly, - yes-codly, - codly-enable, - codly-disable, - codly-range, - codly-offset, - codly-skip, - typst-icon, -) - -#let __codly-default = context [ Codly Default ] - -/// See the full documentation: https://raw.githubusercontent.com/Dherse/codly/main/docs.pdf -/// -/// - enabled (bool, function): enabled -/// - header (content, none, function): header -/// - header-repeat (bool, function): header-repeat -/// - header-cell-args (array, dictionary, arguments, function): header-cell-args -/// - header-transform (function): header-transform -/// - footer (content, none, function): footer -/// - footer-repeat (bool, function): footer-repeat -/// - footer-cell-args (array, dictionary, arguments, function): footer-cell-args -/// - footer-transform (function): footer-transform -/// - offset (int, function): offset -/// - offset-from (none, label, function): offset-from -/// - range (none, array, function): range -/// - ranges (none, array, function): ranges -/// - smart-skip (bool, dictionary, function): smart-skip -/// - languages (dictionary, function): languages -/// - default-color (color, gradient, tiling, function): default-color -/// - radius (length, function): radius -/// - inset (length, dictionary, function): inset -/// - fill (none, color, gradient, tiling, function): fill -/// - zebra-fill (none, color, gradient, tiling, function): zebra-fill -/// - stroke (none, stroke, function): stroke -/// - lang-inset (length, dictionary, function): lang-inset -/// - lang-outset (dictionary, function): lang-outset -/// - lang-radius (length, function): lang-radius -/// - lang-stroke (none, stroke, function): lang-stroke -/// - lang-fill (none, color, gradient, tiling, function): lang-fill -/// - lang-format (auto, none, function): lang-format -/// - display-name (bool, function): display-name -/// - display-icon (bool, function): display-icon -/// - filename (str, content, none, function): filename -/// - filename-separator (str, content, function): filename-separator -/// - number-format (function, none): number-format -/// - number-align (alignment, function): number-align -/// - number-placement (str): number-placement -/// - smart-indent (bool): smart-indent -/// - skip-last-empty (bool, function): skip-last-empty -/// - breakable (bool): breakable -/// - skips (array, none, function): skips -/// - skip-line (content, none, function): skip-line -/// - skip-number (content, none, function): skip-number -/// - annotations (array, none, function): annotations -/// - annotation-format (none, function): annotation-format -/// - highlighted-lines (array, none, function): highlighted-lines -/// - highlighted-default-color (color, tiling, gradient, function): highlighted-default-color -/// - highlights (array, none, function): highlights -/// - highlight-radius (length, function): highlight-radius -/// - highlight-fill (function): highlight-fill -/// - highlight-stroke (stroke, function): highlight-stroke -/// - highlight-inset (length, dictionary, function): highlight-inset -/// - highlight-outset (length, dictionary, function): highlight-outset -/// - highlight-clip (bool, function): highlight-clip -/// - reference-by (str, function): reference-by -/// - reference-sep (str, content, function): reference-sep -/// - reference-number-format (function): reference-number-format -/// - aliases (dictionary): aliases -/// -> content -#let codly( - enabled: __codly-default, - header: __codly-default, - header-repeat: __codly-default, - header-cell-args: __codly-default, - header-transform: __codly-default, - footer: __codly-default, - footer-repeat: __codly-default, - footer-cell-args: __codly-default, - footer-transform: __codly-default, - offset: __codly-default, - offset-from: __codly-default, - range: __codly-default, - ranges: __codly-default, - smart-skip: __codly-default, - languages: __codly-default, - default-color: __codly-default, - radius: __codly-default, - inset: __codly-default, - fill: __codly-default, - zebra-fill: __codly-default, - stroke: __codly-default, - lang-inset: __codly-default, - lang-outset: __codly-default, - lang-radius: __codly-default, - lang-stroke: __codly-default, - lang-fill: __codly-default, - lang-format: __codly-default, - display-name: __codly-default, - display-icon: __codly-default, - filename: __codly-default, - filename-separator: __codly-default, - number-format: __codly-default, - number-align: __codly-default, - number-placement: __codly-default, - smart-indent: __codly-default, - skip-last-empty: __codly-default, - breakable: __codly-default, - skips: __codly-default, - skip-line: __codly-default, - skip-number: __codly-default, - annotations: __codly-default, - annotation-format: __codly-default, - highlighted-lines: __codly-default, - highlighted-default-color: __codly-default, - highlights: __codly-default, - highlight-radius: __codly-default, - highlight-fill: __codly-default, - highlight-stroke: __codly-default, - highlight-inset: __codly-default, - highlight-outset: __codly-default, - highlight-clip: __codly-default, - reference-by: __codly-default, - reference-sep: __codly-default, - reference-number-format: __codly-default, - aliases: __codly-default, -) = { - import "src/lib.typ": __codly-inner - let out = (:) - if enabled != __codly-default { - out.insert("enabled", enabled) - } - if header != __codly-default { - out.insert("header", header) - } - if header-repeat != __codly-default { - out.insert("header-repeat", header-repeat) - } - if header-cell-args != __codly-default { - out.insert("header-cell-args", header-cell-args) - } - if header-transform != __codly-default { - out.insert("header-transform", header-transform) - } - if footer != __codly-default { - out.insert("footer", footer) - } - if footer-repeat != __codly-default { - out.insert("footer-repeat", footer-repeat) - } - if footer-cell-args != __codly-default { - out.insert("footer-cell-args", footer-cell-args) - } - if footer-transform != __codly-default { - out.insert("footer-transform", footer-transform) - } - if offset != __codly-default { - out.insert("offset", offset) - } - if offset-from != __codly-default { - out.insert("offset-from", offset-from) - } - if range != __codly-default { - out.insert("range", range) - } - if ranges != __codly-default { - out.insert("ranges", ranges) - } - if smart-skip != __codly-default { - out.insert("smart-skip", smart-skip) - } - if languages != __codly-default { - out.insert("languages", languages) - } - if default-color != __codly-default { - out.insert("default-color", default-color) - } - if radius != __codly-default { - out.insert("radius", radius) - } - if inset != __codly-default { - out.insert("inset", inset) - } - if fill != __codly-default { - out.insert("fill", fill) - } - if zebra-fill != __codly-default { - out.insert("zebra-fill", zebra-fill) - } - if stroke != __codly-default { - out.insert("stroke", stroke) - } - if lang-inset != __codly-default { - out.insert("lang-inset", lang-inset) - } - if lang-outset != __codly-default { - out.insert("lang-outset", lang-outset) - } - if lang-radius != __codly-default { - out.insert("lang-radius", lang-radius) - } - if lang-stroke != __codly-default { - out.insert("lang-stroke", lang-stroke) - } - if lang-fill != __codly-default { - out.insert("lang-fill", lang-fill) - } - if lang-format != __codly-default { - out.insert("lang-format", lang-format) - } - if display-name != __codly-default { - out.insert("display-name", display-name) - } - if display-icon != __codly-default { - out.insert("display-icon", display-icon) - } - if filename != __codly-default { - out.insert("filename", filename) - } - if filename-separator != __codly-default { - out.insert("filename-separator", filename-separator) - } - if number-format != __codly-default { - out.insert("number-format", number-format) - } - if number-align != __codly-default { - out.insert("number-align", number-align) - } - if number-placement != __codly-default { - out.insert("number-placement", number-placement) - } - if smart-indent != __codly-default { - out.insert("smart-indent", smart-indent) - } - if skip-last-empty != __codly-default { - out.insert("skip-last-empty", skip-last-empty) - } - if breakable != __codly-default { - out.insert("breakable", breakable) - } - if skips != __codly-default { - out.insert("skips", skips) - } - if skip-line != __codly-default { - out.insert("skip-line", skip-line) - } - if skip-number != __codly-default { - out.insert("skip-number", skip-number) - } - if annotations != __codly-default { - out.insert("annotations", annotations) - } - if annotation-format != __codly-default { - out.insert("annotation-format", annotation-format) - } - if highlighted-lines != __codly-default { - out.insert("highlighted-lines", highlighted-lines) - } - if highlighted-default-color != __codly-default { - out.insert("highlighted-default-color", highlighted-default-color) - } - if highlights != __codly-default { - out.insert("highlights", highlights) - } - if highlight-radius != __codly-default { - out.insert("highlight-radius", highlight-radius) - } - if highlight-fill != __codly-default { - out.insert("highlight-fill", highlight-fill) - } - if highlight-stroke != __codly-default { - out.insert("highlight-stroke", highlight-stroke) - } - if highlight-inset != __codly-default { - out.insert("highlight-inset", highlight-inset) - } - if highlight-outset != __codly-default { - out.insert("highlight-outset", highlight-outset) - } - if highlight-clip != __codly-default { - out.insert("highlight-clip", highlight-clip) - } - if reference-by != __codly-default { - out.insert("reference-by", reference-by) - } - if reference-sep != __codly-default { - out.insert("reference-sep", reference-sep) - } - if reference-number-format != __codly-default { - out.insert("reference-number-format", reference-number-format) - } - if aliases != __codly-default { - out.insert("aliases", aliases) - } +/// A language definition, see the `languages` field. +/// +/// Additional, unknown fields are preserved and override the corresponding +/// `codly-lang` arguments for that language, e.g. `(py: (radius: 5pt))` +/// overrides `radius` for the Python language badge. +#let language = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix - __codly-inner(..out) + e.types.declare( + "language", + prefix: __codly-prefix, + doc: "A language definition used for language block formatting.", + allow-unknown-fields: true, + fields: ( + e.field( + "name", + e.types.union(str, content), + doc: "The \"pretty\" name of the language, as a showable value.", + required: true, + named: true, + ), + e.field( + "color", + e.types.option(e.types.paint), + doc: "The color of the language, if omitted uses the default color.", + default: none, + ), + e.field( + "icon", + e.types.option(e.types.union(str, content)), + doc: "The icon of the language, if omitted no icon is shown.", + default: none, + ), + ), + casts: ( + (from: dictionary), + (from: str, with: constructor => value => constructor(name: value)), + ), + ) } -/// Allows setting codly setting locally. -/// Anything that happens inside the block will have the settings applied only to it. -/// The pre-existing settings will be restored after the block. This is useful -/// if you want to apply settings to a specific block only. -/// -/// *Special:* -/// #local(default-color: red)[ -/// ``` -/// Hello, world! -/// ``` -/// ] -/// -/// *Normal:* -/// ``` -/// Hello, world! -/// ``` -/// -/// See the full documentation: https://raw.githubusercontent.com/Dherse/codly/main/docs.pdf -/// -/// - body (content): the content to be locally styled -/// - nested (bool): whether to enable nested local states -/// - enabled (bool, function): enabled -/// - header (content, none, function): header -/// - header-repeat (bool, function): header-repeat -/// - header-cell-args (array, dictionary, arguments, function): header-cell-args -/// - header-transform (function): header-transform -/// - footer (content, none, function): footer -/// - footer-repeat (bool, function): footer-repeat -/// - footer-cell-args (array, dictionary, arguments, function): footer-cell-args -/// - footer-transform (function): footer-transform -/// - offset (int, function): offset -/// - offset-from (none, label, function): offset-from -/// - range (none, array, function): range -/// - ranges (none, array, function): ranges -/// - smart-skip (bool, dictionary, function): smart-skip -/// - languages (dictionary, function): languages -/// - default-color (color, gradient, tiling, function): default-color -/// - radius (length, function): radius -/// - inset (length, dictionary, function): inset -/// - fill (none, color, gradient, tiling, function): fill -/// - zebra-fill (none, color, gradient, tiling, function): zebra-fill -/// - stroke (none, stroke, function): stroke -/// - lang-inset (length, dictionary, function): lang-inset -/// - lang-outset (dictionary, function): lang-outset -/// - lang-radius (length, function): lang-radius -/// - lang-stroke (none, stroke, function): lang-stroke -/// - lang-fill (none, color, gradient, tiling, function): lang-fill -/// - lang-format (auto, none, function): lang-format -/// - display-name (bool, function): display-name -/// - display-icon (bool, function): display-icon -/// - filename (str, content, none, function): filename -/// - filename-separator (str, content, function): filename-separator -/// - number-format (function, none): number-format -/// - number-align (alignment, function): number-align -/// - number-placement (str): number-placement -/// - smart-indent (bool): smart-indent -/// - skip-last-empty (bool, function): skip-last-empty -/// - breakable (bool): breakable -/// - skips (array, none, function): skips -/// - skip-line (content, none, function): skip-line -/// - skip-number (content, none, function): skip-number -/// - annotations (array, none, function): annotations -/// - annotation-format (none, function): annotation-format -/// - highlighted-lines (array, none, function): highlighted-lines -/// - highlighted-default-color (color, tiling, gradient, function): highlighted-default-color -/// - highlights (array, none, function): highlights -/// - highlight-radius (length, function): highlight-radius -/// - highlight-fill (function): highlight-fill -/// - highlight-stroke (stroke, function): highlight-stroke -/// - highlight-inset (length, dictionary, function): highlight-inset -/// - highlight-outset (length, dictionary, function): highlight-outset -/// - highlight-clip (bool, function): highlight-clip -/// - reference-by (str, function): reference-by -/// - reference-sep (str, content, function): reference-sep -/// - reference-number-format (function): reference-number-format -/// - aliases (dictionary): aliases -/// -> content -#let local( - body, - nested: false, - enabled: __codly-default, - header: __codly-default, - header-repeat: __codly-default, - header-cell-args: __codly-default, - header-transform: __codly-default, - footer: __codly-default, - footer-repeat: __codly-default, - footer-cell-args: __codly-default, - footer-transform: __codly-default, - offset: __codly-default, - offset-from: __codly-default, - range: __codly-default, - ranges: __codly-default, - smart-skip: __codly-default, - languages: __codly-default, - default-color: __codly-default, - radius: __codly-default, - inset: __codly-default, - fill: __codly-default, - zebra-fill: __codly-default, - stroke: __codly-default, - lang-inset: __codly-default, - lang-outset: __codly-default, - lang-radius: __codly-default, - lang-stroke: __codly-default, - lang-fill: __codly-default, - lang-format: __codly-default, - display-name: __codly-default, - display-icon: __codly-default, - filename: __codly-default, - filename-separator: __codly-default, - number-format: __codly-default, - number-align: __codly-default, - number-placement: __codly-default, - smart-indent: __codly-default, - skip-last-empty: __codly-default, - breakable: __codly-default, - skips: __codly-default, - skip-line: __codly-default, - skip-number: __codly-default, - annotations: __codly-default, - annotation-format: __codly-default, - highlighted-lines: __codly-default, - highlighted-default-color: __codly-default, - highlights: __codly-default, - highlight-radius: __codly-default, - highlight-fill: __codly-default, - highlight-stroke: __codly-default, - highlight-inset: __codly-default, - highlight-outset: __codly-default, - highlight-clip: __codly-default, - reference-by: __codly-default, - reference-sep: __codly-default, - reference-number-format: __codly-default, - aliases: __codly-default, -) = { - import "src/lib.typ": __local-inner - let out = (:) - if enabled != __codly-default { - out.insert("enabled", enabled) - } - if header != __codly-default { - out.insert("header", header) - } - if header-repeat != __codly-default { - out.insert("header-repeat", header-repeat) - } - if header-cell-args != __codly-default { - out.insert("header-cell-args", header-cell-args) - } - if header-transform != __codly-default { - out.insert("header-transform", header-transform) - } - if footer != __codly-default { - out.insert("footer", footer) - } - if footer-repeat != __codly-default { - out.insert("footer-repeat", footer-repeat) - } - if footer-cell-args != __codly-default { - out.insert("footer-cell-args", footer-cell-args) - } - if footer-transform != __codly-default { - out.insert("footer-transform", footer-transform) - } - if offset != __codly-default { - out.insert("offset", offset) - } - if offset-from != __codly-default { - out.insert("offset-from", offset-from) - } - if range != __codly-default { - out.insert("range", range) - } - if ranges != __codly-default { - out.insert("ranges", ranges) - } - if smart-skip != __codly-default { - out.insert("smart-skip", smart-skip) - } - if languages != __codly-default { - out.insert("languages", languages) - } - if default-color != __codly-default { - out.insert("default-color", default-color) - } - if radius != __codly-default { - out.insert("radius", radius) - } - if inset != __codly-default { - out.insert("inset", inset) - } - if fill != __codly-default { - out.insert("fill", fill) - } - if zebra-fill != __codly-default { - out.insert("zebra-fill", zebra-fill) - } - if stroke != __codly-default { - out.insert("stroke", stroke) - } - if lang-inset != __codly-default { - out.insert("lang-inset", lang-inset) - } - if lang-outset != __codly-default { - out.insert("lang-outset", lang-outset) - } - if lang-radius != __codly-default { - out.insert("lang-radius", lang-radius) - } - if lang-stroke != __codly-default { - out.insert("lang-stroke", lang-stroke) - } - if lang-fill != __codly-default { - out.insert("lang-fill", lang-fill) - } - if lang-format != __codly-default { - out.insert("lang-format", lang-format) - } - if display-name != __codly-default { - out.insert("display-name", display-name) - } - if display-icon != __codly-default { - out.insert("display-icon", display-icon) - } - if filename != __codly-default { - out.insert("filename", filename) - } - if filename-separator != __codly-default { - out.insert("filename-separator", filename-separator) - } - if number-format != __codly-default { - out.insert("number-format", number-format) - } - if number-align != __codly-default { - out.insert("number-align", number-align) - } - if number-placement != __codly-default { - out.insert("number-placement", number-placement) - } - if smart-indent != __codly-default { - out.insert("smart-indent", smart-indent) - } - if skip-last-empty != __codly-default { - out.insert("skip-last-empty", skip-last-empty) - } - if breakable != __codly-default { - out.insert("breakable", breakable) - } - if skips != __codly-default { - out.insert("skips", skips) - } - if skip-line != __codly-default { - out.insert("skip-line", skip-line) - } - if skip-number != __codly-default { - out.insert("skip-number", skip-number) - } - if annotations != __codly-default { - out.insert("annotations", annotations) - } - if annotation-format != __codly-default { - out.insert("annotation-format", annotation-format) - } - if highlighted-lines != __codly-default { - out.insert("highlighted-lines", highlighted-lines) - } - if highlighted-default-color != __codly-default { - out.insert("highlighted-default-color", highlighted-default-color) +/// A range of lines that should use a different syntax-highlighting language. +#let __sublang-normalize(value) = { + let start = value.at("start") + assert(start > 0, message: "codly: sublang `start` must be greater than 0") + + let end = value.at("end") + assert(end >= start, message: "codly: sublang `end` must be at least `start`") + + value +} + +#let __sublang-parser = { + (default-parser, fields: (:), typecheck: true) => { + (args, include-required: true) => { + let result = default-parser(args, include-required: include-required) + if result.at(0) { + result.at(1) = __sublang-normalize(result.at(1)) + } + result + } } - if highlights != __codly-default { - out.insert("highlights", highlights) +} + +/// A syntax-highlighting language applied to an inclusive range of lines. +#let sublang = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "sublang", + prefix: __codly-prefix, + doc: "A syntax-highlighting language applied to a range of lines.", + fields: ( + e.field( + "start", + int, + doc: "The first line of the range (one-indexed).", + required: true, + named: true, + ), + e.field( + "end", + int, + doc: "The last line of the range (inclusive).", + required: true, + named: true, + ), + e.field( + "lang", + str, + doc: "The syntax-highlighting language key.", + required: true, + named: true, + ), + ), + parse-args: __sublang-parser, + casts: ( + (from: dictionary), + ), + ) +} + +/// Optional grammar-aware delimiter coloring. +#let rainbow = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + import "src/rainbow.typ" as r + + let parser(default-parser, fields: (:), typecheck: true) = { + (args, include-required: true) => { + let result = default-parser(args, include-required: include-required) + if result.at(0) { + let value = result.at(1) + assert( + value.at("palette", default: (black,)).len() > 0, + message: "codly: rainbow palette must not be empty", + ) + assert( + value.at("depth-offset", default: 0) >= 0, + message: "codly: rainbow depth-offset must be nonnegative", + ) + } + result + } } - if highlight-radius != __codly-default { - out.insert("highlight-radius", highlight-radius) + + e.types.declare( + "rainbow", + prefix: __codly-prefix, + doc: "Color matching code delimiters by nesting depth, excluding strings and comments.", + parse-args: parser, + fields: ( + e.field("enabled", bool, default: true, doc: "Whether to color delimiters."), + e.field( + "palette", + e.types.array(color), + default: r.palette, + folds: false, + doc: "Colors repeated at successive nesting depths; must not be empty.", + ), + e.field( + "pairs", + e.types.array(e.types.union("()", "[]", "{}")), + default: ("()", "[]", "{}"), + folds: false, + doc: "Delimiter pairs that contribute to nesting.", + ), + e.field("depth-offset", int, default: 0, doc: "Nonnegative offset into the palette."), + e.field( + "unmatched", + e.types.option(color), + default: none, + doc: "Color for unmatched delimiters, or none to preserve their syntax style.", + ), + e.field( + "code-scopes", + str, + default: r.code-scopes, + doc: "Syntax scope selectors identifying code in the private classification theme.", + ), + e.field( + "ignore-scopes", + str, + default: r.ignore-scopes, + doc: "Syntax scope selectors excluding literals and comments.", + ), + ), + casts: ( + (from: dictionary), + (from: bool, with: constructor => value => constructor(enabled: value)), + ), + ) +} + +/// Optional indentation guides, independent of syntax delimiter nesting. +#let indent-guides = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + let parser(default-parser, fields: (:), typecheck: true) = { + (args, include-required: true) => { + let result = default-parser(args, include-required: include-required) + if result.at(0) { + let value = result.at(1) + let width = value.at("width", default: auto) + let palette = value.at("palette", default: auto) + let offset = value.at("depth-offset", default: auto) + assert(width == auto or width > 0, message: "codly: indent-guides width must be positive") + assert( + palette == auto or palette.len() > 0, + message: "codly: indent-guides palette must not be empty", + ) + assert( + offset == auto or offset >= 0, + message: "codly: indent-guides depth-offset must be nonnegative", + ) + let thickness = value.at("thickness", default: 0.5pt) + assert( + thickness.abs >= 0pt and thickness.em >= 0 and thickness != 0pt, + message: "codly: indent-guides thickness must be positive", + ) + } + result + } } - if highlight-fill != __codly-default { - out.insert("highlight-fill", highlight-fill) + + e.types.declare( + "indent-guides", + prefix: __codly-prefix, + doc: "Draw optional vertical guides at complete indentation levels.", + parse-args: parser, + fields: ( + e.field("enabled", bool, default: true, doc: "Whether to draw indentation guides."), + e.field( + "width", + e.types.union(auto, int), + default: auto, + doc: "Spaces per level; auto votes on observed indentation changes.", + ), + e.field( + "blank-lines", + bool, + default: true, + doc: "Continue shared levels across interior blank lines.", + ), + e.field( + "rainbow", + e.types.union(auto, bool), + default: auto, + doc: "Color guides by level; auto follows delimiter rainbow enablement.", + ), + e.field( + "palette", + e.types.union(auto, e.types.array(color)), + default: auto, + folds: false, + doc: "Nonempty guide palette; auto shares the delimiter palette.", + ), + e.field( + "depth-offset", + e.types.union(auto, int), + default: auto, + doc: "Nonnegative palette offset; auto shares the delimiter offset.", + ), + e.field( + "color", + color, + default: luma(70%), + doc: "Guide color when rainbow guides are disabled.", + ), + e.field("thickness", length, default: 0.5pt, doc: "Positive guide stroke thickness."), + e.field( + "x-offset", + length, + default: 0pt, + doc: "Horizontal shift of every guide; positive moves right, negative moves left. Supports em lengths.", + ), + ), + casts: ( + (from: dictionary), + (from: bool, with: constructor => value => constructor(enabled: value)), + ), + ) +} + +/// Configuration for smart skips, see the `smart-skip` field. +#let smart-skip = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "smart-skip", + prefix: __codly-prefix, + doc: "Configuration for automatically inserting skips between displayed ranges.", + fields: ( + e.field( + "first", + e.types.option(bool), + doc: "Whether to include a skip if the start of the block is outside of the ranges.", + default: none, + ), + e.field( + "last", + e.types.option(bool), + doc: "Whether to include a skip if the end of the code block is outside of the ranges.", + default: none, + ), + e.field( + "rest", + e.types.option(bool), + doc: "Whether to include a skip for unspecified values and/or in the middle of the code block.", + default: none, + ), + ), + casts: ( + ( + from: dictionary, + with: constructor => value => { + let rest = value.at("rest", default: false) + let first = value.at("first", default: rest) + let last = value.at("last", default: rest) + constructor(first: first, last: last, rest: rest) + }, + ), + ( + from: bool, + with: constructor => value => constructor(first: value, last: value, rest: value), + ), + ), + ) +} + +/// A single highlight, see the `highlights` field. +#let __highlight-normalize(value) = { + let line = value.at("line") + assert(line > 0, message: "codly: highlight `line` must be greater than 0") + + let start = value.at("start", default: none) + if start == none { + value.insert("start", 0) + } else { + assert(start >= 0, message: "codly: highlight `start` must be at least 0") } - if highlight-stroke != __codly-default { - out.insert("highlight-stroke", highlight-stroke) + + let end = value.at("end", default: none) + if end == none { + value.insert("end", 999999999) + } else { + assert(end >= 0, message: "codly: highlight `end` must be at least 0") } - if highlight-inset != __codly-default { - out.insert("highlight-inset", highlight-inset) + + value +} + +#let __highlight-parser = { + (default-parser, fields: (:), typecheck: true) => { + (args, include-required: true) => { + let result = default-parser(args, include-required: include-required) + if result.at(0) { + result.at(1) = __highlight-normalize(result.at(1)) + } + result + } } - if highlight-outset != __codly-default { - out.insert("highlight-outset", highlight-outset) +} + +#let highlight = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "highlight", + prefix: __codly-prefix, + doc: "A highlight over part of a line of the code block.", + fields: ( + e.field( + "line", + int, + doc: "The line number to start highlighting (one-indexed).", + required: true, + ), + e.field( + "start", + e.types.option(int), + doc: "The character position to start highlighting, zero if omitted or `none` (zero-indexed).", + default: none, + ), + e.field( + "end", + e.types.option(int), + doc: "The character position to end highlighting, the end of the line if omitted or `none` (zero-indexed).", + default: none, + ), + e.field( + "fill", + e.types.option(e.types.union(e.types.paint, function)), + doc: "The fill of the highlight, defaults to the default color.", + default: none, + ), + e.field( + "tag", + e.types.option(e.types.union(str, content)), + doc: "An optional tag to be displayed alongside the highlight.", + default: none, + ), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary)), + doc: "Overrides `codly-highlight`'s `inset`.", + default: none, + ), + e.field( + "baseline", + e.types.option(e.types.union(length, auto)), + doc: "Overrides the highlight box baseline; `auto` preserves the content's baseline.", + default: none, + ), + e.field( + "clip", + e.types.option(bool), + doc: "Overrides `codly-highlight`'s `clip`.", + default: none, + ), + e.field( + "outset", + e.types.option(e.types.union(length, dictionary)), + doc: "Overrides `codly-highlight`'s `outset`.", + default: none, + ), + e.field( + "radius", + e.types.option(length), + doc: "Overrides `codly-highlight`'s `radius`.", + default: none, + ), + e.field( + "label", + e.types.option(label), + doc: "If and only if the code block is in a `figure`, sets the label by which the highlight can be referenced.", + default: none, + ), + e.field( + "stroke", + e.types.option(e.types.union(stroke, function)), + doc: "Overrides `codly-highlight`'s `stroke`.", + default: none, + ), + e.field( + "depth", + e.types.option(int), + doc: "The depth of the highlight, used to determine which highlight is on top when multiple highlights overlap. Higher depth means on top.", + ), + ), + parse-args: __highlight-parser, + casts: ( + ( + from: dictionary, + with: constructor => value => { + let line = value.remove("line") + constructor(line, ..value) + }, + ), + ), + ) +} + +/// A single annotation, see the `annotations` field. +#let __annotation-normalize(value) = { + let start = value.at("start") + assert(start > 0, message: "codly: annotation `start` must be greater than 0") + + let end = value.at("end", default: none) + if end == none { + end = start + value.insert("end", end) + } else { + assert(end > 0, message: "codly: annotation `end` must be greater than 0") } - if highlight-clip != __codly-default { - out.insert("highlight-clip", highlight-clip) + + assert(end >= start, message: "codly: annotation `end` must be at least `start`") + + if "content" not in value { + value.insert("content", none) } - if reference-by != __codly-default { - out.insert("reference-by", reference-by) + + value +} + +#let __annotation-parser = { + (default-parser, fields: (:), typecheck: true) => { + (args, include-required: true) => { + let result = default-parser(args, include-required: include-required) + if result.at(0) { + result.at(1) = __annotation-normalize(result.at(1)) + } + result + } } - if reference-sep != __codly-default { - out.insert("reference-sep", reference-sep) +} + +#let annotation = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "annotation", + prefix: __codly-prefix, + doc: "An annotation displayed on the right side of the code block.", + fields: ( + e.field( + "start", + int, + doc: "The line number to start the annotation (one-indexed).", + required: true, + ), + e.field( + "end", + e.types.option(int), + doc: "The line number to end the annotation, if missing or `none` the annotation will only contain the start line.", + default: none, + ), + e.field( + "content", + e.types.option(content), + doc: "The content of the annotation as a showable value, if missing or `none` the annotation will only contain the number.", + default: none, + ), + e.field( + "label", + e.types.option(label), + doc: "If and only if the code block is in a `figure`, sets the label by which the annotation can be referenced.", + default: none, + ), + e.field( + "numbering", + e.types.option(function), + doc: "The format of the annotation number, defaults to `(1)`.", + default: numbering.with("(1)"), + ), + ), + parse-args: __annotation-parser, + casts: ( + ( + from: dictionary, + with: constructor => value => { + let start = value.remove("start") + constructor(start, ..value) + }, + ), + ), + ) +} + +/// A callout attached to a source line, see the `callouts` field. +#let __callout-normalize(value) = { + let line = value.at("line") + assert(line > 0, message: "codly: callout `line` must be greater than 0") + value +} + +#let __callout-parser = { + (default-parser, fields: (:), typecheck: true) => { + (args, include-required: true) => { + let result = default-parser(args, include-required: include-required) + if result.at(0) { + result.at(1) = __callout-normalize(result.at(1)) + } + result + } } - if reference-number-format != __codly-default { - out.insert("reference-number-format", reference-number-format) +} + +#let callout = { + import "src/callout.typ" as callout-impl + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "callout", + prefix: __codly-prefix, + doc: "A callout displayed above or below a source line.", + fields: ( + e.field( + "line", + int, + doc: "The source line to which the callout is attached (one-indexed).", + required: true, + named: true, + ), + e.field( + "body", + content, + doc: "The content to display in the callout row.", + required: true, + named: true, + ), + e.field( + "align", + e.types.option(e.types.union(alignment, auto)), + doc: "The callout cell alignment.", + default: auto, + ), + e.field( + "breakable", + e.types.option(e.types.union(bool, auto)), + doc: "Whether the callout cell may break across pages.", + default: auto, + ), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary, auto)), + doc: "The callout cell inset.", + default: auto, + ), + e.field( + "fill", + e.types.option(e.types.union(e.types.paint, auto)), + doc: "The callout cell fill.", + default: auto, + ), + e.field( + "stroke", + e.types.option(e.types.union(stroke, auto)), + doc: "The callout cell stroke.", + default: auto, + ), + ..callout-impl.fields(entry: true), + ), + parse-args: __callout-parser, + casts: ( + (from: dictionary, with: constructor => value => constructor(..value)), + ), + ) +} + +/// A custom argument parser for the pair-like types below (range, skip, +/// highlighted-line). It allows specifying the pair's fields either +/// positionally, e.g. `range(2, 4)`, or by name, e.g. `range(start: 2, end: 4)`, +/// unlike the default parser which only accepts positional arguments for +/// required fields. +#let __pair-parser(first-field) = { + (default-parser, fields: (:), typecheck: true) => { + let names = fields.user-fields.keys() + (args, include-required: true) => { + let positional = args.pos() + let named = args.named() + if include-required and positional.len() == 0 and first-field in named { + // All fields given by name: convert to positional order based on field + // declaration order. + let ordered = () + for name in names { + if name in named { + ordered.push(named.remove(name)) + } + } + // Any remaining named fields are unknown and will error in the default parser + if named.len() > 0 { + return default-parser(args, include-required: include-required) + } + default-parser(arguments(..ordered), include-required: include-required) + } else { + default-parser(args, include-required: include-required) + } + } } - if aliases != __codly-default { - out.insert("aliases", aliases) +} + +/// A cast from a dictionary for the pair-like types, converting the dict to +/// positional arguments based on field order. +#let __pair-dict-cast(field-names) = { + constructor => value => { + let ordered = () + for name in field-names { + if name in value { + ordered.push(value.remove(name)) + } + } + if value.len() > 0 { + assert(false, message: "elembic: unknown e.field(s) " + value.keys().join(", ")) + } + constructor(..ordered) } +} + +/// A single range of line numbers, see the `range` and `ranges` fields. +/// Can be constructed as `range(start, end)`, `range(start: .., end: ..)`, +/// or cast from the array `(start, end)`. +#let range = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "range", + prefix: __codly-prefix, + doc: "A range of line numbers to display (one-indexed, inclusive).", + fields: ( + e.field("start", int, doc: "The first line of the range (one-indexed).", required: true), + e.field( + "end", + e.types.option(int), + doc: "The last line of the range (inclusive), `none` for the rest of the block.", + default: none, + named: false, + ), + ), + parse-args: __pair-parser("start"), + casts: ( + ( + from: dictionary, + with: __pair-dict-cast(("start", "end")), + ), + ( + from: array, + check: value => value.len() in (1, 2), + with: constructor => value => constructor(..value), + ), + ), + ) +} + +/// A single skip, see the `skips` field. +/// Can be constructed as `skip(position, length)`, `skip(position: .., length: ..)`, +/// or cast from the array `(position, length)`. +#let skip = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "skip", + prefix: __codly-prefix, + doc: "A skip of a number of lines at a position in the code block.", + fields: ( + e.field( + "position", + int, + doc: "The line where the skip is inserted (zero-indexed).", + required: true, + ), + e.field("length", int, doc: "The number of lines of the skip.", default: 1, named: false), + ), + parse-args: __pair-parser("position"), + casts: ( + ( + from: dictionary, + with: __pair-dict-cast(("position", "length")), + ), + ( + from: array, + check: value => value.len() in (1, 2), + with: constructor => value => constructor(..value), + ), + ), + ) +} + +/// A single highlighted line, see the `highlighted-lines` field. +/// Can be constructed as `highlighted-line(line)`, `highlighted-line(line, color)`, +/// or cast from an integer or an array of the form `(line, color)`. +#let highlighted-line = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "highlighted-line", + prefix: __codly-prefix, + doc: "A line to be highlighted, with an optional custom highlight color.", + fields: ( + e.field( + "line", + int, + doc: "The line number to highlight (one-indexed, as shown in the document).", + required: true, + ), + e.field( + "color", + e.types.option(e.types.paint), + doc: "The highlight color of the line, defaults to `codly-highlight`'s `fill`.", + default: none, + named: false, + ), + ), + parse-args: __pair-parser("line"), + casts: ( + ( + from: dictionary, + with: __pair-dict-cast(("line", "color")), + ), + ( + from: int, + with: constructor => value => constructor(value), + ), + ( + from: array, + check: value => value.len() in (1, 2), + with: constructor => value => constructor(..value), + ), + ), + ) +} + +/// A source line excluded from line numbering, see the `unnumbered-lines` field. +/// Can be constructed as `unnumbered-line(line)`, `unnumbered-line(line, fill)`, +/// or cast from an integer or an array of the form `(line, fill)`. +#let unnumbered-line = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.types.declare( + "unnumbered-line", + prefix: __codly-prefix, + doc: "A source line excluded from numbering, with optional number-column content.", + fields: ( + e.field( + "line", + int, + doc: "The source line to leave unnumbered (one-indexed).", + required: true, + ), + e.field( + "fill", + content, + doc: "Content to display in place of the line number.", + default: [], + named: false, + ), + ), + parse-args: __pair-parser("line"), + casts: ( + ( + from: dictionary, + with: __pair-dict-cast(("line", "fill")), + ), + ( + from: int, + with: constructor => value => constructor(value), + ), + ( + from: array, + check: value => value.len() in (1, 2), + with: constructor => value => constructor(..value), + ), + ), + ) +} + +/// Shared formatting settings read by the line, highlight, and annotation +/// reference renderers. Generated references currently use figure numbering. +#let codly-ref = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default + + e.element.declare( + "codly-ref", + prefix: __codly-prefix, + doc: "A reference to a highlight or annotation of a codly code block.", + display: it => it.body, + fields: ( + e.field( + "body", + e.types.option(content), + doc: "The content of the reference.", + required: true, + ), + e.field( + "by", + e.types.option(e.types.union("line", "item")), + doc: __doc("reference-by"), + default: __default("reference-by"), + ), + e.field( + "sep", + e.types.option(e.types.union(str, content)), + doc: __doc("reference-sep"), + default: __default("reference-sep"), + ), + e.field( + "numbering", + e.types.option(function), + doc: __doc("reference-number-format"), + default: __default("reference-number-format"), + ), + ), + ) +} + +/// A native reference to a displayed line of a codly code block. +#let codly-line-ref = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __codly-line-ref-show + + e.element.declare( + "codly-line-ref", + prefix: __codly-prefix, + doc: "A native reference to a displayed line of a codly code block.", + display: __codly-line-ref-show.with(codly-ref), + labelable: false, + fields: ( + e.field("body", e.types.option(content), doc: "Internal reference body.", required: true), + e.field("block", label, doc: "The containing code block label.", required: true, named: true), + e.field("number", int, doc: "The displayed line number.", required: true, named: true), + e.field( + "suffix", + e.types.option(content), + doc: "An optional displayed-number suffix.", + default: none, + ), + e.field( + "separator", + e.types.option(e.types.union(auto, str, content)), + doc: "Text between the code block and line number; `auto` uses `codly-ref` settings.", + default: auto, + ), + e.field( + "numbering", + e.types.option(e.types.union(auto, function)), + doc: "Line-number formatter; `auto` uses `codly-ref` settings.", + default: auto, + ), + ), + ) +} + +/// A native reference to a labelled highlight in a codly code block. +#let codly-highlight-ref = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __codly-highlight-ref-show + + e.element.declare( + "codly-highlight-ref", + prefix: __codly-prefix, + doc: "A native reference to a labelled highlight in a codly code block.", + display: __codly-highlight-ref-show.with(codly-ref), + labelable: false, + fields: ( + e.field("body", e.types.option(content), doc: "Internal reference body.", required: true), + e.field("block", label, doc: "The containing code block label.", required: true, named: true), + e.field("line", int, doc: "The displayed line number.", required: true, named: true), + e.field( + "item", + e.types.option(content), + doc: "The highlight tag for item references.", + default: none, + ), + e.field( + "by", + e.types.union("line", "item"), + doc: "Whether the reference shows a line or item.", + required: true, + named: true, + ), + e.field( + "separator", + e.types.option(e.types.union(auto, str, content)), + doc: "Text between the code block and reference; `auto` uses `codly-ref` settings.", + default: auto, + ), + e.field( + "numbering", + e.types.option(e.types.union(auto, function)), + doc: "Line-number formatter; `auto` uses `codly-ref` settings.", + default: auto, + ), + ), + ) +} + +/// A native reference to a labelled annotation in a codly code block. +#let codly-annotation-ref = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-annotation-ref-show, __codly-prefix + + e.element.declare( + "codly-annotation-ref", + prefix: __codly-prefix, + doc: "A native reference to a labelled annotation in a codly code block.", + display: __codly-annotation-ref-show.with(codly-ref), + labelable: false, + fields: ( + e.field("body", e.types.option(content), doc: "Internal reference body.", required: true), + e.field("block", label, doc: "The containing code block label.", required: true, named: true), + e.field("line", int, doc: "The displayed line number.", required: true, named: true), + e.field( + "item", + content, + doc: "The annotation item for item references.", + required: true, + named: true, + ), + e.field( + "suffix", + e.types.option(content), + doc: "An optional displayed-number suffix.", + default: none, + ), + e.field( + "by", + e.types.union("line", "item"), + doc: "Whether the reference shows a line or item.", + required: true, + named: true, + ), + e.field( + "separator", + e.types.option(e.types.union(auto, str, content)), + doc: "Text between the code block and reference; `auto` uses `codly-ref` settings.", + default: auto, + ), + e.field( + "numbering", + e.types.option(e.types.union(auto, function)), + doc: "Line-number formatter; `auto` uses `codly-ref` settings.", + default: auto, + ), + ), + ) +} + +/// A single highlight within a codly code block. +#let codly-highlight = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default, __codly-highlight-show + + e.element.declare( + "codly-highlight", + prefix: __codly-prefix, + doc: "A highlight over part of a line of a codly code block.", + display: __codly-highlight-show.with(codly-ref, codly-highlight-ref), + fields: ( + e.field("body", e.types.option(content), doc: "The highlighted content.", required: true), + e.field( + "highlight", + e.types.option(highlight), + doc: "The highlight metadata for this content.", + default: none, + ), + e.field( + "__continuation-indent", + e.types.option(length), + default: none, + doc: "Internal smart indentation inside a wrapping highlight.", + ), + e.field( + "color", + e.types.paint, + doc: __doc("default-color"), + default: __default("default-color"), + ), + e.field( + "radius", + e.types.option(length), + doc: __doc("highlight-radius"), + default: __default("highlight-radius"), + ), + e.field( + "fill", + e.types.option(function), + doc: __doc("highlight-fill"), + default: __default("highlight-fill"), + ), + e.field( + "baseline", + e.types.option(e.types.union(length, auto)), + doc: "The highlight box baseline shift; `auto` uses Typst's content baseline.", + default: 0pt, + ), + e.field( + "stroke", + e.types.option(e.types.union(stroke, function)), + doc: __doc("highlight-stroke"), + default: __default("highlight-stroke"), + ), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary)), + doc: __doc("highlight-inset"), + default: __default("highlight-inset"), + ), + e.field( + "outset", + e.types.option(e.types.union(length, dictionary)), + doc: __doc("highlight-outset"), + default: __default("highlight-outset"), + ), + e.field( + "clip", + e.types.option(bool), + doc: __doc("highlight-clip"), + default: __default("highlight-clip"), + ), + ), + ) +} + +/// A single line of a codly code block. Takes over the line-level styling +/// arguments of `codly` (`radius`, `inset`, `fill`, `stroke`). +#let codly-line = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default, __codly-line-show + + e.element.declare( + "codly-line", + prefix: __codly-prefix, + doc: "A single line of a codly code block.", + display: __codly-line-show.with(codly-highlight, codly-ref, codly-line-ref), + fields: ( + e.field("body", e.types.option(content), doc: "The content of the line.", required: true), + e.field("radius", e.types.option(length), doc: __doc("radius"), default: __default("radius")), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary)), + doc: __doc("inset"), + default: __default("inset"), + ), + e.field( + "fill", + e.types.option(e.types.union( + auto, + e.types.paint, + e.types.array(e.types.option(e.types.paint)), + function, + )), + doc: __doc("fill"), + default: __default("fill"), + folds: false, + ), + e.field("stroke", e.types.option(stroke), doc: __doc("stroke"), default: __default("stroke")), + e.field( + "highlights", + e.types.option(e.types.array(highlight)), + doc: __doc("highlights"), + default: __default("highlights"), + folds: false, + ), + e.field("smart-indent", bool, doc: __doc("smart-indent"), default: __default("smart-indent")), + e.field( + "__wrap", + e.types.option(dictionary), + default: none, + doc: "Internal block-owned continuation marker settings.", + ), + e.field( + "__callout", + e.types.option(dictionary), + default: none, + doc: "Internal character anchors.", + ), + e.field( + "block-label", + e.types.option(label), + doc: "The label of the containing code block.", + default: none, + ), + e.field( + "reference", + e.types.option(dictionary), + doc: "Internal reference details for an unnumbered source line.", + default: none, + ), + ), + ) +} + +/// The header of a codly code block. Takes over the `header-` prefixed +/// arguments of `codly`. +#let codly-header = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default - __local-inner(body, nested: nested, ..out) + e.element.declare( + "codly-header", + prefix: __codly-prefix, + doc: "The header of a codly code block.", + display: it => it.body, + fields: ( + e.field("body", e.types.option(content), doc: __doc("header"), required: true), + e.field( + "repeat", + e.types.option(bool), + doc: __doc("header-repeat"), + default: __default("header-repeat"), + ), + + // Replaces the old cell-args + e.field("align", e.types.option(alignment), doc: "todo", default: center + horizon), + e.field("breakable", e.types.option(e.types.union(bool, auto)), doc: "todo", default: auto), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary, auto)), + doc: "todo", + default: auto, + ), + e.field( + "fill", + e.types.option(e.types.union(e.types.paint, auto)), + doc: "The header cell background. `none` leaves it unfilled; `auto` uses the header's default. Independent of line and number fills.", + default: luma(240), + ), + e.field("stroke", e.types.option(e.types.union(stroke, auto)), doc: "todo", default: auto), + ), + ) +} + +/// The footer of a codly code block. Takes over the `footer-` prefixed +/// arguments of `codly`. +#let codly-footer = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default + + e.element.declare( + "codly-footer", + prefix: __codly-prefix, + doc: "The footer of a codly code block.", + display: it => it.body, + fields: ( + e.field("body", e.types.option(content), doc: __doc("footer"), required: true), + e.field( + "repeat", + e.types.option(bool), + doc: __doc("footer-repeat"), + default: __default("footer-repeat"), + ), + + // Replaces the old cell-args + e.field("align", e.types.option(alignment), doc: "todo", default: center + horizon), + e.field("breakable", e.types.option(e.types.union(bool, auto)), doc: "todo", default: auto), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary, auto)), + doc: "todo", + default: auto, + ), + e.field( + "fill", + e.types.option(e.types.union(e.types.paint, auto)), + doc: "The footer cell background. `none` leaves it unfilled; `auto` uses the footer's default. Independent of line and number fills.", + default: none, + ), + e.field("stroke", e.types.option(e.types.union(stroke, auto)), doc: "todo", default: auto), + ), + ) +} + +/// The language badge of a codly code block. Takes over the `lang-` prefixed +/// arguments of `codly`, as well as `display-name` and `display-icon`. +#let codly-lang = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default, __codly-lang-show + + e.element.declare( + "codly-lang", + prefix: __codly-prefix, + doc: "The language badge of a codly code block.", + display: __codly-lang-show, + fields: ( + e.field("body", str, doc: "The language key, e.g. \"py\".", required: true), + e.field( + "languages", + e.types.option(e.types.dict(language)), + doc: __doc("languages"), + default: __default("languages"), + ), + e.field( + "default-color", + e.types.option(e.types.paint), + doc: __doc("default-color"), + default: __default("default-color"), + ), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary)), + doc: __doc("lang-inset"), + default: __default("lang-inset"), + ), + e.field( + "outset", + e.types.option(dictionary), + doc: __doc("lang-outset"), + default: __default("lang-outset"), + ), + e.field( + "radius", + e.types.option(e.types.union(length, dictionary)), + doc: __doc("lang-radius"), + default: __default("lang-radius"), + ), + e.field( + "stroke", + e.types.option(e.types.union(stroke, function)), + doc: __doc("lang-stroke"), + default: __default("lang-stroke"), + ), + e.field( + "fill", + e.types.option(e.types.union(e.types.paint, function)), + doc: __doc("lang-fill"), + default: __default("lang-fill"), + ), + e.field( + "display-name", + e.types.option(bool), + doc: __doc("display-name"), + default: __default("display-name"), + ), + e.field( + "display-icon", + e.types.option(bool), + doc: __doc("display-icon"), + default: __default("display-icon"), + ), + e.field("align", e.types.option(alignment), doc: "todo", default: right + horizon), + ), + ) +} + +/// A filename badge, independently styleable from the language badge. +#let codly-file = { + import "src/file.typ" as file-impl + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + e.element.declare( + "codly-file", + prefix: __codly-prefix, + doc: "The filename badge in a code block header or footer.", + display: file-impl.badge, + fields: ( + e.field("body", e.types.union(str, content), required: true, doc: "Displayed filename."), + e.field("fill", e.types.option(e.types.paint), default: luma(240), doc: "Badge fill."), + e.field("stroke", e.types.option(stroke), default: 0.5pt + luma(160), doc: "Badge stroke."), + e.field( + "inset", + e.types.union(length, dictionary), + default: (x: 0.4em, y: 0.2em), + doc: "Badge inset.", + ), + e.field("radius", e.types.union(length, dictionary), default: 2pt, doc: "Badge radius."), + ), + ) +} + +/// A single annotation of a codly code block. Takes over the `annotation-` +/// prefixed arguments of `codly`. +#let codly-annotation = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default, __codly-annotation-show + + e.element.declare( + "codly-annotation", + prefix: __codly-prefix, + doc: "An annotation displayed on the right side of a codly code block.", + display: __codly-annotation-show, + labelable: false, + fields: ( + e.field( + "body", + e.types.option(content), + doc: "The content of the annotation.", + required: true, + ), + e.field("label", e.types.option(content), doc: "todo", default: "todo", required: true), + e.field("height", e.types.option(length), doc: "todo", default: 0.0pt), + e.field("num", e.types.option(int), doc: "todo", default: 0), + e.field("numbering", e.types.option(function), doc: "todo", default: numbering.with("(1)")), + ), + ) +} + +/// The painted bubble inside a callout. Owns independent styling defaults. +#let codly-bubble = { + import "src/callout.typ" as callout-impl + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.element.declare( + "codly-bubble", + prefix: __codly-prefix, + doc: "A bubble generated by a pointed callout. Set rules provide its styling defaults.", + display: callout-impl.bubble-display, + fields: ( + e.field("body", content, doc: "The bubble content.", required: true), + ..callout-impl.fields(bubble: true), + e.field("line", int, doc: "The attached source line.", required: true, named: true), + e.field("pointer", int, doc: "The source character anchor.", required: true, named: true), + e.field( + "placement", + e.types.union("above", "below"), + default: "below", + doc: "Side of the source line.", + ), + e.field( + "__layout", + e.types.option(dictionary), + default: none, + doc: "Internal packed geometry.", + ), + e.field( + "__anchor", + e.types.option(dictionary), + default: none, + doc: "Internal source anchor.", + ), + ), + ) +} + +/// A callout row. Its cell properties can be configured with set rules. +#let codly-callout = { + import "src/callout.typ" as callout-impl + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix + + e.element.declare( + "codly-callout", + prefix: __codly-prefix, + doc: "A callout displayed above or below a source line.", + display: callout-impl.display.with(codly-bubble), + fields: ( + e.field("body", content, doc: "The callout content.", required: true), + e.field("line", int, doc: "The attached source line.", required: true, named: true), + e.field( + "align", + e.types.option(e.types.union(alignment, auto)), + doc: "The callout cell alignment.", + default: auto, + ), + e.field( + "breakable", + e.types.option(e.types.union(bool, auto)), + doc: "Whether the callout cell may break across pages.", + default: auto, + ), + e.field( + "inset", + e.types.option(e.types.union(length, dictionary, auto)), + doc: "The callout cell inset.", + default: auto, + ), + e.field( + "fill", + e.types.option(e.types.union(e.types.paint, auto)), + doc: "The callout cell fill.", + default: auto, + ), + e.field( + "stroke", + e.types.option(e.types.union(stroke, auto)), + doc: "The callout cell stroke.", + default: auto, + ), + ..callout-impl.fields(), + e.field( + "__layout", + e.types.option(dictionary), + default: none, + doc: "Internal packed bubble geometry.", + ), + e.field( + "__anchor", + e.types.option(dictionary), + default: none, + doc: "Internal source anchor.", + ), + ), + ) +} + +/// A line number of a codly code block. Takes over the `number-` prefixed +/// arguments of `codly`. +#let codly-number = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default + + e.element.declare( + "codly-number", + prefix: __codly-prefix, + doc: "A line number of a codly code block.", + display: it => if type(it.number) == content { + it.number + } else if type(it.number) == array { + (it.numbering)(..it.number) + } else { + (it.numbering)(it.number) + }, + fields: ( + e.field( + "number", + e.types.option(e.types.union(content, int, e.types.array(int))), + doc: "The line number.", + required: true, + ), + e.field( + "parent", + e.types.any, + doc: "The parent content of the line.", + required: true, + ), + e.field( + "numbering", + e.types.union(str, function), + doc: "The numbering style of the line.", + default: numbering.with("1"), + ), + e.field( + "align", + e.types.option(alignment), + doc: __doc("number-align"), + default: __default("number-align"), + ), + e.field( + "placement", + e.types.option(e.types.union("inside", "outside")), + doc: __doc("number-placement"), + default: __default("number-placement"), + ), + e.field( + "fill", + e.types.option(e.types.union(auto, e.types.paint)), + doc: "The background fill of the number column. `auto` follows each code row's fill.", + default: auto, + ), + ), + ) +} + +#let sublang-block = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __doc, __default + + e.element.declare( + "sublang-block", + prefix: __codly-prefix, + doc: "A sublanguage block within a codly code block.", + display: it => { + show raw.where(block: true): raw => { + let idx = str(it.idx) + + for (i, line) in raw.lines.enumerate() { + let line-label = label("__codly_sublang_line_" + idx + "_" + str(i)) + [ + #metadata(line) #line-label + ] + } + } + + it.body + }, + fields: ( + e.field("body", content, doc: "The content of the sublanguage block.", required: true), + e.field( + "idx", + int, + doc: "The index of the sublanguage block within the code block.", + required: true, + ), + ), + ) +} + +#let codly = { + import "@preview/elembic:1.1.1" as e + import "src/lib.typ": __codly-prefix, __default, __doc, __codly-show + + let codly-show = __codly-show.with( + codly-line, + codly-highlight, + codly-lang, + codly-file, + codly-header, + codly-footer, + codly-number, + codly-annotation, + codly-callout, + codly-bubble, + codly-annotation-ref, + codly-ref, + sublang-block, + ) + + e.element.declare( + "codly", + prefix: __codly-prefix, + doc: "Codly is a library that enhances the way you write code blocks in Typst.", + display: it => { + import "src/file.typ" as file-impl + let body = it.remove("body") + let prepared = file-impl.prepare(body, file: it.file, lang: it.lang) + body = prepared.body + it.file = prepared.file + if prepared.source and it.header == none { it.header = [] } + if prepared.source and it.at("lang-position") == auto { it.at("lang-position") = top + right } + let data = it.remove("__elembic_stored_element_data") + let constructor = if it.alias == none and it.aliases != none and it.aliases.len() > 0 { + data.default-constructor + } + let alias-style = if constructor != none { + (size: text.size, theme: raw.theme, syntaxes: raw.syntaxes) + } + show raw.where(block: true): codly-show.with( + constructor, + it, + alias-style, + ) + body + }, + fields: ( + e.field( + "body", + e.types.option(e.types.union(str, path, content)), + doc: "Raw content, literal source text, or a caller-resolved file path.", + required: true, + ), + e.field( + "file", + e.types.union(auto, none, str, path, content), + default: auto, + doc: "Filename badge: auto uses the input path basename; none hides it.", + ), + e.field( + "lang", + e.types.union(auto, none, str), + default: auto, + doc: "Language for source/path inputs; auto infers the file extension.", + ), + e.field( + "lang-position", + e.types.union(auto, none, alignment), + default: auto, + doc: "Language badge placement: top/bottom selects header/footer, left/right selects side. auto retains ordinary raw-block placement.", + ), + e.field( + "file-position", + e.types.option(alignment), + default: top + left, + doc: "Filename badge placement: top/bottom selects header/footer, left/right selects side; none hides the badge.", + ), + e.field( + "block-label", + e.types.option(label), + doc: "The label of the containing figure.", + default: none, + ), + e.field( + "alias", + e.types.option(str), + doc: "Whether this is an already aliased block", + required: false, + default: none, + ), + e.field("number-enabled", e.types.option(bool), doc: "todo", default: true), + e.field("offset", e.types.option(int), doc: __doc("offset"), default: __default("offset")), + e.field( + "offset-from", + e.types.option(label), + doc: __doc("offset-from"), + default: __default("offset-from"), + ), + e.field( + "range", + e.types.option(range), + doc: __doc("range"), + default: __default("range"), + folds: false, + ), + e.field( + "ranges", + e.types.option(e.types.array(range)), + doc: __doc("ranges"), + default: __default("ranges"), + folds: false, + ), + e.field( + "smart-skip", + e.types.option(smart-skip), + doc: __doc("smart-skip"), + default: __default("smart-skip"), + folds: false, + ), + e.field( + "aliases", + e.types.option(dictionary), + doc: __doc("aliases"), + default: __default("aliases"), + ), + e.field( + "smart-indent", + e.types.option(bool), + doc: __doc("smart-indent"), + default: __default("smart-indent"), + ), + e.field( + "wrap-marker", + e.types.option(content), + default: none, + doc: "Optional symbol at each wrapped continuation when smart-indent is enabled.", + ), + e.field( + "skip-last-empty", + e.types.option(bool), + doc: __doc("skip-last-empty"), + default: __default("skip-last-empty"), + ), + e.field( + "breakable", + e.types.option(bool), + doc: __doc("breakable"), + default: __default("breakable"), + ), + e.field( + "skips", + e.types.option(e.types.array(skip)), + doc: __doc("skips"), + default: __default("skips"), + folds: false, + ), + e.field( + "skip-line", + e.types.option(e.types.union(content, e.types.array(e.types.option(content)))), + doc: __doc("skip-line"), + default: __default("skip-line"), + folds: false, + ), + e.field( + "skip-number", + e.types.option(e.types.union(content, e.types.array(e.types.option(content)))), + doc: __doc("skip-number"), + default: __default("skip-number"), + folds: false, + ), + e.field( + "unnumbered", + e.types.option(e.types.array(unnumbered-line)), + doc: __doc("unnumbered-lines"), + default: __default("unnumbered-lines"), + folds: false, + ), + e.field( + "annotations", + e.types.option(e.types.array(annotation)), + doc: __doc("annotations"), + default: __default("annotations"), + folds: false, + ), + e.field( + "callouts", + e.types.option(e.types.array(callout)), + doc: __doc("callouts"), + default: __default("callouts"), + folds: false, + ), + e.field( + "highlighted", + e.types.option(e.types.array(highlighted-line)), + doc: __doc("highlighted-lines"), + default: __default("highlighted-lines"), + folds: false, + ), + e.field( + "highlights", + e.types.option(e.types.array(highlight)), + doc: __doc("highlights"), + default: __default("highlights"), + folds: false, + ), + e.field( + "header", + e.types.option(content), + doc: __doc("header"), + default: __default("header"), + ), + e.field( + "footer", + e.types.option(content), + doc: __doc("footer"), + default: __default("footer"), + ), + e.field("radius", e.types.option(length), doc: __doc("radius"), default: __default("radius")), + e.field( + "leading", + e.types.option(length), + doc: __doc("leading"), + default: __default("leading"), + ), + e.field("gutter", e.types.option(length), doc: __doc("gutter"), default: __default("gutter")), + e.field( + "column-gutter", + e.types.option(length), + doc: __doc("column-gutter"), + default: __default("column-gutter"), + ), + e.field( + "row-gutter", + e.types.option(length), + doc: __doc("row-gutter"), + default: __default("row-gutter"), + ), + e.field("sublangs", e.types.option(e.types.array(sublang)), doc: "todo", default: none), + e.field( + "rainbow", + e.types.option(rainbow), + doc: "Opt-in syntax-aware delimiter colors; true or a rainbow configuration.", + default: none, + ), + e.field( + "indent-guides", + e.types.option(indent-guides), + doc: "Opt-in indentation guides; true or an indent-guides configuration.", + default: none, + ), + e.field( + "width", + e.types.union(length, ratio, auto), + doc: "todo", + default: 100%, + folds: false, + ), + ), + ) } + + +#let new = codly +#let set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly, ..args) +} +#let show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly, it, ..args) +} +#let selector(..args) = { + import "@preview/elembic:1.1.1" as e + e.selector(codly, ..args) +} +#let lang-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-lang, ..args) +} +#let lang-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-lang, it, ..args) +} +#let file-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-file, ..args) +} +#let file-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-file, it, ..args) +} +#let header-set(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-header, ..args) +} +#let line-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-line, ..args) +} +#let line-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-line, it, ..args) +} +#let highlight-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-highlight, ..args) +} +#let highlight-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-highlight, it, ..args) +} +#let annotation-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-annotation, ..args) +} +#let annotation-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-annotation, it, ..args) +} +#let callout-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-callout, ..args) +} +#let callout-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-callout, it, ..args) +} +#let bubble-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-bubble, ..args) +} +#let bubble-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-bubble, it, ..args) +} +#let ref-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-ref, ..args) +} +#let line-ref-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-line-ref, ..args) +} +#let line-ref-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-line-ref, it, ..args) +} +#let highlight-ref-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-highlight-ref, ..args) +} +#let highlight-ref-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-highlight-ref, it, ..args) +} +#let annotation-ref-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-annotation-ref, ..args) +} +#let annotation-ref-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-annotation-ref, it, ..args) +} +#let number-set_(..args) = { + import "@preview/elembic:1.1.1" as e + e.set_(codly-number, ..args) +} +#let number-show_(it, ..args) = { + import "@preview/elembic:1.1.1" as e + e.show_(codly-number, it, ..args) +} + +/// In context, read source line count and the last displayed number of a block. +#import "src/lib.typ": __codly-block-info as info diff --git a/docs/codly.png b/docs/codly.png deleted file mode 100644 index 1488246..0000000 Binary files a/docs/codly.png and /dev/null differ diff --git a/docs/docs.typ b/docs/docs.typ deleted file mode 100644 index b9ce358..0000000 --- a/docs/docs.typ +++ /dev/null @@ -1,823 +0,0 @@ -#import "@preview/gentle-clues:1.2.0": * -#import "@preview/codly-languages:0.1.8": * -#import "orly.typ": orly -#import "../src/args.typ": * -#import "../codly.typ": ( - codly, codly-init, codly-reset, no-codly, yes-codly, codly-enable, - codly-disable, codly-range, codly-offset, local, codly-skip, - typst-icon -) - -// Set this to the relevant version -#let current_version = "1.3.1" - -#show: codly-init - -#show ref: it => { - if type(it.target) == label and str(it.target).starts-with("arg-") { - let label = str(it.target).replace("arg-", "") - return link(it.target, raw(lang: "typc", label)) - } else { - return it - } -} - -#show raw.where(block: false): set raw(lang: "typc") -#show raw.where(block: false): box.with( - fill: luma(240), - inset: (x: 3pt, y: 0pt), - outset: (y: 3pt), - radius: 2pt, -) - -#let code-icon(icon, ..args) = text( - font: "tabler-icons", - fallback: false, - weight: "regular", - size: 1em, - icon, - ..args, -) -#let icon-ty = code-icon("\u{ea77}", fill: rgb("#B300B3")) -#let icon-val = code-icon("\u{f312}", fill: rgb("#007ACC")) -#let icon-con = code-icon("\u{eb20}", fill: rgb("#FFC72C")) -#let icon-rst = code-icon("\u{fafd}", fill: rgb("#A3A300")) -#let icon-new = code-icon("\u{fd12}", fill: rgb("#49C39E")) -#let icon-yes = code-icon("\u{ea5e}", fill: green, baseline: 0.15em) -#let icon-no = code-icon("\u{eb55}", fill: red, baseline: 0.15em) -#let paint = luma(0) - -#let ty_map(ty) = { - if ty == "type(none)" { - "none" - } else { - ty - } -} -#let bool_yes_no(b) = { - if b { - icon-yes + [ yes] - } else { - icon-no + [ no] - } -} - -#let link-map(ty) = { - if ty == "array" { - "https://typst.app/docs/reference/foundations/array/" - } else if ty == "string" or ty == "str" { - "https://typst.app/docs/reference/foundations/str/" - } else if ty == "dictionary" { - "https://typst.app/docs/reference/foundations/dictionary/" - } else if ty == "type(none)" or ty == "none" { - "https://typst.app/docs/reference/foundations/none/" - } else if ty == "type(auto)" { - "https://typst.app/docs/reference/foundations/auto/" - } else if ty == "content" { - "https://typst.app/docs/reference/foundations/content/" - } else if ty == "bool" { - "https://typst.app/docs/reference/foundations/bool/" - } else if ty == "arguments" { - "https://typst.app/docs/reference/foundations/arguments/" - } else if ty == "function" { - "https://typst.app/docs/reference/foundations/function/" - } else if ty == "color" { - "https://typst.app/docs/reference/visualize/color/" - } else if ty == "gradient" { - "https://typst.app/docs/reference/visualize/gradient/" - } else if ty == "tiling" { - "https://typst.app/docs/reference/visualize/tiling/" - } else if ty == "length" { - "https://typst.app/docs/reference/layout/length/" - } else if ty == "stroke" { - "https://typst.app/docs/reference/visualize/stroke/" - } else if ty == "alignment" { - "https://typst.app/docs/reference/layout/alignment/" - } else if ty == "int" { - "https://typst.app/docs/reference/foundations/int/" - } else if ty == "label" { - "https://typst.app/docs/reference/foundations/label/" - } else { - panic("unknown type: " + ty) - } -} -#let format-default(value) = { - let e = eval(value) - raw(lang: "typc", value) - - if type(e) == color { - h(0.32em) - box(width: 1.32em, height: 0.9em, fill: e, baseline: 0.1em) - } else if type(e) == gradient { - h(0.32em) - box(width: 12em, height: 0.9em, fill: e, baseline: 0.1em) - } else if type(e) == stroke { - h(0.32em) - box(width: 1.32em, height: 0.9em, stroke: e, baseline: 0.1em) - } -} - -// Start with a cover page -#orly( - color: rgb("#85144b"), - title: "Codly " + current_version + " manual", - top-text: "Always start a new Typst project by importing codly", - subtitle: "Your code blocks on steroids", - pic: "/docs/codly.png", - signature: "Dherse" -) - -#let args = json("../src/args.json") - -#let codly-args = arguments( - header-cell-args: (align: center, ), - header-transform: strong, - enabled: true, - languages: typst-icon, -) -#codly(..codly-args) - -#let one-indexed = info[ Line numbers are one-indexed, meaning that to reference the fourth line, you use the number `4`. ] -#let zero-indexed = info[ Character positions are zero-indexed, meaning that to reference the fourth character, you use the number `3`. ] - -#let example(raw, actual: none, pre: none, scale-factor: 100%) = context { - codly( - header: [ Example code ], - ) - grid( - columns: (1fr, )* 2, - column-gutter: 0.32em, - box( - radius: 0.32em, - inset: 0.32em, - stroke: 1pt + luma(120), - { - pre - raw - } - ), - box( - radius: 0.32em, - width: 1fr, - stroke: 1pt + luma(120), - grid( - columns: (1fr, ), - inset: 0.32em, - grid.header( - grid.cell( - inset: 0pt, - align: center, box( - fill: luma(240), - stroke: 1pt + luma(120), - inset: 0.32em, - radius: (top-left: 0.32em, top-right: 0.32em), - width: 1fr, - strong[Rendered output] - ) - ) - ), - { - codly-reset() - eval( - if actual != none { - actual.text - } else { - raw.text - }, - scope: (codly: codly, local: local, no-codly: no-codly, yes-codly: yes-codly, codly-enable: codly-enable, codly-disable: codly-disable, codly-range: codly-range, codly-offset: codly-offset, codly-skip: codly-skip, codly-reset: codly-reset, typst-icon: typst-icon, one-indexed: one-indexed, zero-indexed: zero-indexed), - mode: "markup" - ) - codly-reset() - codly(..codly-args) - }, - ) - ), - ) -} - -#set text(font: ("Source Sans 3", "Noto Color Emoji"), size: 10pt) -#set par(justify: true, spacing: 1.5em) -#counter(page).update(1) -#set heading(numbering: "1.1.") -#show link: underline -#show link: set text(fill: blue) -#set page( - margin: (top: 0.5in, bottom: 0.4in, left: 0.75in, right: 0.75in), - numbering: "1", -) - -#codly(lang-format: none) - -#outline(depth: 2, indent: auto) - -#pagebreak() - -= Codly - -Codly is a library that enhances the way you write code blocks in Typst. It provides a set of tools to help you manage your code blocks, highlights them, skip parts of them, and more. This manual will guide you through the different features of Codly, how to use them, and how to integrate them into your Typst projects. - -#notify[If you find any issues with Codly, please report them on the GitHub repository: https://github.com/Dherse/codly.] - -== Initialization - -To start using Codly, you must first import it into your Typst project. - -#example( - ```typ - #import "@preview/codly:1.3.1": * - - #show: codly-init - ```, - actual: ```typ - #import "../codly.typ": * - - #show: codly-init - ```, -) - -As you can see, this does nothing but initialize codly. You can also import it with a specific version, as shown in the example above. For the latest version, always refer to the #link("https://typst.app/universe/package/codly")[Typst Universe page]. - -From this point on, any code block that is included in your Typst project will be enhanced by Codly. - -#example( - ````typ - ``` - Hello, world! - ``` - ```` -) - - -== Enabling and disabling codly - -By default Codly will be enabled after initialization. However, disabling codly can be done using the #link()[`codly-disable`] function, the #link()[`enabled`] argument of the `codly` function, or the #link()[`no-codly`] and #link()[`yes-codly`] functions. To enable Codly again, use the #link()[`codly-enable`] function or by setting the #link()[`enabled`] parameter again. - -== Short guide to this manual - -Codly can take a lot of different argument to configure your code blocks. Some of these arguments can have pretty complex behaviour, so this manual is here to help you understand how to use them. Each argument is acompagnied by a card that gives you important information about its usage. The card contains the following information: -- the type(s) of values accepted ; -- the default value that is pre-set ; -- whether the argument can take a contextual function: if it can, you can pass it a function that will be called within a typst #link("https://typst.app/docs/reference/context/")[`context`] block to customize the value of the argument yourself #footnote[This is mostly intended for libraries that wish to extend Codly, or for users that wish to have dynamic control over argument values using their own logic.] ; -- whether the argument is automatically reset after the code block is rendered ; -- whether the argument is already released in the current version of Codly or will be released in the next version. - -#block(stroke: 1pt + paint, radius: 0.32em, table( - columns: (auto, 1fr), - stroke: none, - align: (left + horizon, left + horizon), - strong[ #icon-ty Type ], - [ #link(link-map("bool"), raw(lang: "typc", "bool")) or #link(link-map("type(auto)"), raw(lang: "typc", "auto")) ], - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-val Default value], - format-default("true"), - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-con Contextual function ], - bool_yes_no(true), - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-rst Automatically reset ], - bool_yes_no(true), - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-new Already released in #current_version ], - bool_yes_no(false), -)) - -Additionally to the card, most arguments are accompanied by an example that shows how to use the argument in a code block. The example is followed by a rendered output of the code block, which shows how the argument affects the code block. - -#pagebreak() - -= A primer on Codly's show-rule like system -Codly uses a function called `codly` to create a kind of show-rule which you can use to configure how your code blocks are displayed. The `codly` function takes a set of arguments that define how the code block should be displayed. Here is the equivalent definition of the `codly` function: - -```typc -let codly( - enabled: true, - offset: 0, - offset-from: none, - range: none, - ranges: (), - languages: (:), - display-name: true, - display-icon: true, - default-color: rgb("#283593"), - radius: 0.32em, - inset: 0.32em, - fill: none, - zebra-fill: luma(240), - stroke: 1pt + luma(240), - lang-inset: 0.32em, - lang-outset: (x: 0.32em, y: 0pt), - lang-radius: 0.32em, - lang-stroke: (lang) => lang.color + 0.5pt, - lang-fill: (lang) => lang.color.lighten(80%), - lang-format: codly.default-language-block, - number-format: (number) => [ #number ], - number-align: left + horizon, - number-placement: "outside", - smart-indent: false, - annotations: none, - annotation-format: numbering.with("(1)"), - highlights: none, - highlight-radius: 0.32em, - highlight-fill: (color) => color.lighten(80%), - highlight-stroke: (color) => 0.5pt + color, - highlight-inset: 0.32em, - highlight-outset: 0pt, - highlight-clip: true, - reference-by: line, - reference-sep: "-", - reference-number-format: numbering.with("1"), - header: none, - header-repeat: false, - header-transform: (x) => x, - header-cell-args: (), - footer: none, - footer-repeat: false, - footer-transform: (x) => x, - footer-cell-args: (), - breakable: false, -) = {} -``` - -The codly functions acts like a set-rule, this means that calling it will set the configuration for all code blocks that follow it, with the exception of a few arguments that are explicitly set for each code block. To perform changes locally, you can use the #link()[`local`] function, or set the arguments before the code block and reset them after to their previous values. - -#warning[ - Unlike regular set-rules in native Typst, there are two considerations: - - The `codly` function uses states to store the configuration, this means that it is dependent on layout order for the order in which settings are applied. - - The `codly` function is not local, it sets the configuration for all code blocks that follow it in layout order, unless overriden by another `codly` call. This means that you cannot use it to set the configuration for a specific code block. To perform this, use the #link()[`local`] function to set the configuration for a specific "section". -] - -#for (i, (key, arg)) in args.pairs().enumerate() { - let header = heading(level: 2)[ - #arg.title (#raw(key)) - ] - let label = label("arg-" + key) - - let tys = if type(arg.ty) == array { - let long = arg.ty.len() > 2 - arg.ty.map(ty_map) - .map((x) => (x, raw(lang: "typc", x))) - .map(((key, value)) => link(link-map(key), value)) - .join(", ", last: if long { ", or " } else { " or "}) - } else { - link(link-map(arg.ty), raw(lang: "typc", ty_map(arg.ty))) - } - - let card = block(stroke: 1pt + paint, radius: 0.32em, table( - columns: (auto, 1fr), - stroke: none, - align: (left + horizon, left + horizon), - strong[ #icon-ty Type ], - tys, - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-val Default value], - format-default(arg.default), - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-con Contextual function ], - bool_yes_no(arg.function), - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-rst Automatically reset ], - if "reset" in arg { bool_yes_no(arg.reset) } else { bool_yes_no(false) }, - table.hline(stroke: (thickness: 1pt, dash: "dashed", paint: paint)), - strong[ #icon-new Already released in #current_version ], - if "upcoming" in arg { bool_yes_no(not arg.upcoming) } else { bool_yes_no(true) }, - )) - - block(breakable: true, { - [ - #header #label - #card - #eval(arg.description, mode: "markup", scope: (experiment: experiment, info: info, warning: warning, one-indexed: one-indexed, zero-indexed: zero-indexed)) - - #if "experimental" in arg and arg.experimental { - experiment[ - This feature should be considered experimental. Please report any issues you encounter on GitHub: https://github.com/Dherse/codly. - ] - } - - #if "example" in arg { - [=== Example] - example( - raw(block: true, lang: "typ", arg.example) - ) - } - - #if "examples" in arg { - for ex in arg.examples { - [=== Example: #ex.title] - if "desc" in ex { - ex.desc - } - example( - raw(block: true, lang: "typ", ex.code) - ) - } - } - - #if "post" in arg { - eval(arg.post, mode: "markup", scope: (experiment: experiment, info: info, example: example)) - } - ] - }) -} - -#pagebreak(weak: true) -= Referencing code blocks, highlights, and annotations - -This section of the documentation will detail how you can use codly to reference: lines, highlights, and annotations in your code blocks. To do this, here are the requirements that must be met *for each code block*: -#list(marker: sym.square)[ -Numbering of figures must be turned on: `set figure(numbering: ...)`. -][ -The code block must be contained within a raw figure: `figure(kind: raw)`. -][ -The figure must have a label of its own: `figure(...)[...] `. -] - -== Shorthand line references -You can reference lines directly, if you have set a label correctly, using the shorthand syntax `@my-label:2` to reference the second line (one-indexed) of the code block with the label ``. It will always use the #link()[`reference-number-format`] argument of the `codly` function to format the line number. - -#one-indexed - -#experiment[ - Due to upcoming changes to v0.13 of Typst, this entire feature has been re-done to be more robust, but that means that the styling might have changed slightly and it might have introduced new issues. If you find any issues, report them on https://github.com/Dherse/codly. -] - -#example( - ````typ - #figure( - caption: "A code block with a label" - )[ - ```typ - = Example - *Hello, world!* - ``` - ] - I can reference my code block: @my-label. Or a specific line: @my-label:2. - ````, - pre: codly( - ranges: ((1, 3), (8, 10)), - skips: ((4, 0), ) - ) -) - -== Highlight references - -You can also highlight by reference, to do this, you need to set a label for your highlight in the #link()[`highlights`] argument of the `codly` function. You can then reference the highlight using the shorthand syntax `@my-highlight` to reference the highlight with the label ``. There are two supported #link()[`reference-by`] modes: -- `"line"`: references the line of the highlight -- `"item"`: references the tag of the highlight, this requires that the `tag` be set *for each tagged highlight*. - -#example( - ````typ - #codly( - highlights: ((line: 2, start: 2, end: 7, label: ), - )) - #figure( - caption: "A code block with a label" - )[ - ```typ - = Example - *Hello, world!* - ``` - ] - - Reference a highlight by its label: @hl-1. - ````, - pre: codly( - ranges: ((1, 3), (13, 13)), - skips: ((4, 0), ) - ) -) - -And using #link()[`"item"`] mode: -#example( - ````typ - #codly( - highlights: ((line: 2, start: 2, end: 7, label: , tag: [ Highlight ]), ), - reference-by: "item", - ) - #figure( - caption: "A code block with a label" - )[ - ```typ - = Example - *Hello, world!* - ``` - ] - - Reference a highlight by its label: @hl-2. - ````, - pre: codly( - ranges: ((1, 4), (14, 14)), - skips: ((5, 0), ) - ) -) - -#pagebreak(weak: true) -= Getting nice icons - -This is a short, non-exhaustive guide on how to get nicer icons for the languages of your code blocks. In the documentation, codly makes use of tabler-icons to display the language icons. But a more general approach is the following: - -+ Chose a font that contains icons, such as: - - #link("https://tabler.io/icons")[Tabler Icons] - - #link("https://fontawesome.com/")[Font Awesome] - - #link("https://material.io/resources/icons/")[Material Icons] - - Look on #link("https://fonts.google.com/")[Google Fonts] for more options -+ Download the font and put it in your project (if using the _CLI_, you need to set the `--font` argument) -+ Using your font selector, select the icon you wish to use - - For example, the language icon in Tabler Icons is `ebbe` (the unicode value of the icon, which you can find in the documentation of the font) - - Use the #link("https://typst.app/docs/reference/text/text/")[`text`] function to display the icon in your document by setting the font, size, and the unicode value of the icon: - #codly(highlights: ((line: 1, start: 12, end: 25, tag: [ Font name ]), (line: 1, start: 43, end: 46, fill: green, tag: [ UTF-8 icon code]))) - ```typc - text(font: "tabler-icons", size: 1em, "\u{ebbe}") - ``` -+ You can store it the `languages` argument of the `codly` function to use it for all code blocks in your document: #example(````typ -#let icon = text(font: "tabler-icons", size: 1em, "\u{ebbe}") -#codly(languages: (text: (icon: icon, name: "Text"))) -```text -Hello, world! -``` -````) -+ Congrats, you now have fancy icons! -+ ... -+ But you can notice that the baseline of the icon is wrong, I find that this is generally the case with tabler, you can set the baseline to `0.1em` in the icon to fix it: #example(````typ -#let icon = text(font: "tabler-icons", size: 1em, "\u{ebbe}", baseline: 0.1em) -#codly(languages: (text: (icon: icon, name: "Text"))) -```text -Hello, world! -``` -````) - -== Typst language icon (`typst-icon`) - -Additionally, codly ships with language definitions for the Typst language. You can use the `typst-icon` function to get the Typst icon for your code blocks. This function takes no arguments and returns the proper settings for codly to use the Typst icon. - -#info[You can use the `..` spread operator to spread it into your own `languages` dictionary.] - -#example(````typ -#codly(languages: typst-icon) -```typ -= Here's a title -Hello, world! -``` -````) - -#pagebreak(weak: true) -= Other functions - -== Skip (`codly-skip`) -Convenience function for setting the skips, see the #link()[`skips`] argument of the `codly` function. - -== Range (`codly-range`) -Convenience function for setting the range, see the #link()[`range`] argument of the `codly` function. If you provide more than one range, as a list of arguments, it will set the #link()[`ranges`] argument instead. - -With a single range: -#example(````typ -#codly-range(2, end: 2) -```py -def fib(n): - if n <= 1: - return n - return fib(n - 1) + fib(n - 2) -fib(25) -``` -````) - -With more than one range: -#example(````typ -#codly-range(2, end: 2, (4, 5)) -```py -def fib(n): - if n <= 1: - return n - return fib(n - 1) + fib(n - 2) -fib(25) -``` -````) - -== Offset (`codly-offset`) -Convenience function for setting the offset, see the #link()[`offset`] argument of the `codly` function. - -#pagebreak(weak: true) -== Local (`local`) - -Codly provides a convenience function called `local` that allows you to locally override the global settings for a specific code block. This is useful when you want to apply a specific style to a code block without affecting the rest of the code blocks in your document. It works by overriding the default codly show rule locally with an override of the arguments by those you provide. It does not rely on states (much) and should no longer add layout passes to the rendering which could cause documents to not converge. - -#warning[When using `nested: false` on your local states, the outermost local state will be overriden by the inner local state(s). This means that the inner local state will be the only one that is applied to the code block. And that any previous local states (in the same hierarchy) will be ignored for subsequent code blocks.] - -#info[Once custom elements become available in Typst, and codly moves to using those and set rules, this limitation will be lifted and you will be able to use nested local states without performance impact.] - -#example(````typ -*Global state with red color* -#codly(fill: red) -```typ -= Example -*Hello, World!* -``` -*Locally set it to gray* -#local( - fill: luma(240), - ```typ - = Example - *Hello, World!* - ``` -) -* It's back to being red* -```typ -= Example -*Hello, World!* -``` -````) - -#pagebreak(weak: true) -=== Local state for per-language configuration - -Additionally, local settings can be used to set per-language configuration using a show rule on your #link("https://typst.app/docs/reference/text/raw/")[`raw`] blocks. This can be done in one of two ways: by using a show rule on `raw.where(block: true, lang: "")` and calling the #link()[`local`] function, or by using the `codly` function. The main differentiators are that the `local` function is faster and does not rely on states, while the `codly` function is more flexible, but slower and *will also style all following blocks*, you must therefore manually reset the changes. - -#experiment[ - This should work in most cases, but this feature should be considered experimental. Please report any issues you encounter on GitHub: https://github.com/Dherse/codly. -] - -#info[ - Note that you only want to do show rules on `raw` blocks where `block: true`, otherwise this will make your document slow. -] - -#warning[ - If you use the `local` function in a show rule, nested `local` states *will not work* with the settings you have set! Use the `codly` method instead. If using the `codly` method, and you *must* manually reset the changed settings in the show rule! -] - -#example(````typ -#show raw.where(block: true, lang: "rust"): local.with( - number-format: numbering.with("I") -) - -#show raw.where(block: true, lang: "py"): it => { - codly(number-format: numbering.with("①")) - it - codly(number-format: numbering.with("1")) -} - -*Numbered with Roman numerals* -```rust -fn main() { - println!("Rust code has Roman numbers"); -} - -``` -*Numbered with circled numbers* -```py -print("Python code has circled numbers") -``` - -*Override with local state* -#local( - fill: blue.lighten(80%), - ```py - print("Python code is green") - ``` -) -````) - -#pagebreak(weak: true) -=== Nested local state -Codly does support nested local state, the innermost local state will override the outermost local state. This allows you to have different styles for different parts of your code block. This function takes the same arguments as the `codly` function, but only the arguments that are different from the global settings need to be provided. - -#warning[Nested local states can slow down documents significantly if over-used (explicitly set `nested: true`). Use them sparingly and only when necessary. Another solution is to use the normal `codly` function before and after your code block. You can also use the the argument `nested: false` on `local` to prevent nested local states, which significantly reduces the performance impact.] - -#example(````typ -*Global state with red color* -#codly(fill: red) -```typ -= Example -*Hello, World!* -``` -*Locally set it to blue* -#local( - nested: true, - fill: blue, -)[ - ```typ - = Example - *Hello, World!* - ``` - *Now it's green:* - #local(nested: true, fill: green)[ - ```typ - = Example - *Hello, World!* - ``` - ] - *Now its zebras are also blue:* - #local(nested: true, zebra-fill: blue)[ - ```typ - = Example - *Hello, World!* - ``` - ] - - *Back to blue:* - ```typ - = Example - *Hello, World!* - ``` -] -*Back to red:* -```typ -= Example -*Hello, World!* -``` -````) - -#pagebreak(weak: true) -== No Codly (`no-codly`) -This is a convenience function equivalent to `local(enabled: false, body)`. - -#example(````typ -*Enabled codly* -```typ -= Example -*Hello, World!* -``` - -*Disabled codly* -#no-codly[ - ```typ - = Example - *Hello, World!* - ``` -] -````) -== Yes Codly (`yes-codly`) -This is a convenience function equivalent to `local(enabled: false, body)`. - -#example(````typ -*Disabled codly* -#codly(enabled: false) -```typ -= Example -*Hello, World!* -``` - -*Enabled codly* -#yes-codly[ - ```typ - = Example - *Hello, World!* - ``` -] -````) - -== Enable (`codly-enable`) -Enables codly globally, equivalent to `codly(enabled: true)`. -#example(````typ -*Disabled codly* -#codly-disable() -```typ -= Example -*Hello, World!* -``` -#codly-enable() -*Enabled codly* -```typ -= Example -*Hello, World!* -``` -````) - -== Disable (`codly-disable`) -Disables codly globally, equivalent to `codly(enabled: false)`. -#example(````typ -*Enabled codly* -```typ -= Example -*Hello, World!* -``` - -*Disabled codly* -#codly-disable() -```typ -= Example -*Hello, World!* -``` -````) - -#pagebreak(weak: true) - -== Reset (`codly-reset`) -Resets all codly settings to their default values. This is useful when you want to reset the settings of a code block to the default values after applying local settings. - -#example(````typ -*Global state with red color* -#codly(fill: red) -```typ -= Example -*Hello, World!* -``` -*Reset it* -#codly-reset() -```typ -= Example -*Hello, World!* -``` -````) - -#pagebreak(weak: true) -= Codly performance diff --git a/docs/orly.typ b/docs/orly.typ deleted file mode 100644 index dbc6780..0000000 --- a/docs/orly.typ +++ /dev/null @@ -1,94 +0,0 @@ -// Clone of fauxreilly: https://typst.app/universe/package/fauxreilly -// There is a bug in it regarding the `pic` argument, see issue: -#let orly( - font: "", - color: blue, - top-text: "", - pic: "", - title: "", - title-align: left, - subtitle: "", - publisher: "", - publisher-font: ("Noto Sans", "Arial Rounded MT"), - signature: "", - margin: (top: 0in) -) = { - page( - margin: margin, - [ - /************** - * VARIABLES - ***************/ - - // Layout - #let top-bar-height = 0.33em // how tall to make the colored bar at the top of the page - - // Title block - #let title-text-color = white - #let title-text-leading = 0.5em - #let title-block-height = 12em - - // Subtitle - #let subtitle-margin = 0.5em // space between title block and subtitle text - #let subtitle-text-size = 1.4em - - // "Publisher" / signature - #let publisher-text-size = 2em - #let signature-text-size = 0.9em - - // ********************************************************* - - #set text(font: font) if font != "" - - #grid( - rows: ( - top-bar-height, - 1em, // top text - 1fr, // pre-image spacing - auto, // image - 1fr, // spacing between image and title block - title-block-height, - subtitle-margin, // spacing between title and subtitle - subtitle-text-size, // subtitle - 1fr, // spacing between subtitle and "publisher" - publisher-text-size - ), - rect(width: 100%, height: 100%, fill: color), // color bar at top - align(center + bottom)[#emph[#top-text]], // top text - [], // pre-image spacing - image(pic, width: 100%, fit: "contain"), // image - [], // spacing between image and title block - block(width: 100%, height: title-block-height, inset: (x: 2em), fill: color)[ // title block - #set text(fill: title-text-color, size: 3em) - #set par(leading: title-text-leading) - #set align(title-align + horizon) - #title - ], - [], // spacing between title block and subtitle - align(right)[ - #set text(size: subtitle-text-size) - #emph[#subtitle] - ], - [], - [ - #text(weight: "bold", size: publisher-text-size)[ - #if publisher == "" { - [O RLY#text(fill: color)[#super[?]]] - } else { - publisher - } - ] - #if signature != "" { - box(width: 1fr, height: 100%)[ - #set align(right + bottom) - #set text(size: signature-text-size) - #emph[#signature] - ] - } - ] - ) - ] - ) -} - - diff --git a/docs/fonts/Noto Color Emoji/LICENSE b/fonts/Noto Color Emoji/LICENSE similarity index 100% rename from docs/fonts/Noto Color Emoji/LICENSE rename to fonts/Noto Color Emoji/LICENSE diff --git a/docs/fonts/Noto Color Emoji/NotoColorEmoji-Regular.ttf b/fonts/Noto Color Emoji/NotoColorEmoji-Regular.ttf similarity index 100% rename from docs/fonts/Noto Color Emoji/NotoColorEmoji-Regular.ttf rename to fonts/Noto Color Emoji/NotoColorEmoji-Regular.ttf diff --git a/docs/fonts/Source Sans 3/LICENSE b/fonts/Source Sans 3/LICENSE similarity index 100% rename from docs/fonts/Source Sans 3/LICENSE rename to fonts/Source Sans 3/LICENSE diff --git a/docs/fonts/Source Sans 3/SourceSans3-Bold.ttf b/fonts/Source Sans 3/SourceSans3-Bold.ttf similarity index 100% rename from docs/fonts/Source Sans 3/SourceSans3-Bold.ttf rename to fonts/Source Sans 3/SourceSans3-Bold.ttf diff --git a/docs/fonts/Source Sans 3/SourceSans3-Light.ttf b/fonts/Source Sans 3/SourceSans3-Light.ttf similarity index 100% rename from docs/fonts/Source Sans 3/SourceSans3-Light.ttf rename to fonts/Source Sans 3/SourceSans3-Light.ttf diff --git a/docs/fonts/Source Sans 3/SourceSans3-Medium.ttf b/fonts/Source Sans 3/SourceSans3-Medium.ttf similarity index 100% rename from docs/fonts/Source Sans 3/SourceSans3-Medium.ttf rename to fonts/Source Sans 3/SourceSans3-Medium.ttf diff --git a/docs/fonts/Source Sans 3/SourceSans3-Regular.ttf b/fonts/Source Sans 3/SourceSans3-Regular.ttf similarity index 100% rename from docs/fonts/Source Sans 3/SourceSans3-Regular.ttf rename to fonts/Source Sans 3/SourceSans3-Regular.ttf diff --git a/docs/fonts/Tabler Icons/LICENSE b/fonts/Tabler Icons/LICENSE similarity index 100% rename from docs/fonts/Tabler Icons/LICENSE rename to fonts/Tabler Icons/LICENSE diff --git a/docs/fonts/Tabler Icons/tabler-icons.ttf b/fonts/Tabler Icons/tabler-icons.ttf similarity index 100% rename from docs/fonts/Tabler Icons/tabler-icons.ttf rename to fonts/Tabler Icons/tabler-icons.ttf diff --git a/src/args.json b/src/args.json index 7ed868c..8602686 100644 --- a/src/args.json +++ b/src/args.json @@ -165,21 +165,45 @@ } ] }, - "fill": { - "title": "Fill", - "description": "The fill of the code block when not zebra-striped.", + "leading": { + "title": "Leading", + "description": "Vertical padding for each code row. When set, this overrides the vertical component of the line inset while preserving its horizontal component. It increases or decreases the apparent spacing between code lines and automatically keeps the language badge inside the block border.", "default": "none", - "ty": [ "type(none)", "color", "gradient", "tiling" ], + "ty": [ "type(none)", "length" ], "function": true, - "example": "*Default fill:*\n```py\nprint('Hello, world!')\nprint('Hello, world!')\n```\n*Overriden fill:*\n#codly(fill: gradient.linear(..color.map.flare))\n```py\nprint('Hello, world!')\nprint('Hello, world!')\n```\n*No fill:*\n#codly(fill: none)\n```py\nprint('Hello, world!')\nprint('Hello, world!')\n```" + "example": "*Tighter rows:*\n#codly(leading: 0.2em)\n```py\nprint('Hello, world!')\nprint('Goodbye, world!')\n```\n\n*Looser rows:*\n#codly(leading: 0.6em)\n```py\nprint('Hello, world!')\nprint('Goodbye, world!')\n```" }, - "zebra-fill": { - "title": "Zebra fill", - "description": "Background color of the code lines when zebra-stripped. If set to `none`, no zebra-striping is applied.", - "default": "luma(240)", - "ty": [ "type(none)", "color", "gradient", "tiling" ], + "gutter": { + "title": "Grid gutter", + "description": "Default spacing between rows and columns of Codly's primary grid. Use `column-gutter` or `row-gutter` to override one direction.", + "default": "none", + "ty": [ "type(none)", "length" ], + "function": true, + "example": "#codly(gutter: 0.2em)\n```py\nprint('Hello, world!')\n```" + }, + "column-gutter": { + "title": "Column gutter", + "description": "Horizontal spacing between columns of Codly's primary grid, such as the line-number and code columns. Overrides `gutter` horizontally when set.", + "default": "none", + "ty": [ "type(none)", "length" ], + "function": true, + "example": "#codly(column-gutter: 0.4em)\n```py\nprint('Hello, world!')\n```" + }, + "row-gutter": { + "title": "Row gutter", + "description": "Vertical spacing between rows of Codly's primary grid. Overrides `gutter` vertically when set.", + "default": "none", + "ty": [ "type(none)", "length" ], + "function": true, + "example": "#codly(row-gutter: 0.2em)\n```py\nprint('Hello, world!')\n```" + }, + "fill": { + "title": "Fill", + "description": "The background fill of displayed body rows. Header and footer backgrounds are set by the `fill` field on `codly-header` and `codly-footer` independently. A paint or `none` applies to every body row. An array cycles through its entries, and a function receives a row dictionary with `index` (zero-indexed displayed row), `kind` (`code`, `skip`, or `callout`), `source-line` (or `none` for generated rows), and `number` (the displayed line number, or `none`). The default palette is `(luma(240), none)`; `auto` restores that palette. Header rows count toward `index` but are not passed to the callback. Whole-line highlights override this fill, and a number column with `fill: auto` follows the resolved row fill.", + "default": "(luma(240), none)", + "ty": [ "type(auto)", "type(none)", "color", "gradient", "tiling", "array", "function" ], "function": true, - "example": "*Default zebra:*\n```py\nprint('Hello, world!')\nprint('Hello, world!')\n```\n*No zebra:*\n#codly(zebra-fill: none)\n```py\nprint('Hello, world!')\nprint('Hello, world!')\n```\n*Overriden zebra:*\n#codly(zebra-fill: gradient.linear(..color.map.flare))\n```py\nprint('Hello, world!')\n```" + "example": "*Cyclic palette:*\n#show: codly.line-set_(fill: (luma(240), none, luma(248)))\n```py\nprint('Hello, world!')\nprint('Hello, world!')\nprint('Again!')\n```\n\n*Dynamic fill:*\n#show: codly.line-set_(fill: row => if row.kind == \"code\" and row.number % 5 == 0 { yellow.lighten(80%) } else { none })" }, "stroke": { "title": "Stroke", @@ -209,7 +233,7 @@ "title": "Language box radius", "description": "The radius of the border of the language block.", "default": "0.32em", - "ty": [ "length" ], + "ty": [ "length", "dictionary" ], "function": true, "example": "#codly(lang-radius: 10pt)\n```py\nprint('Hello, world!')\nprint('Goodbye, world!')\n```" }, @@ -278,9 +302,18 @@ "example": "*Line number outside of the block:*\n#codly(number-placement: \"outside\")\n```py\nprint('Hello, world!')\nprint('Goodbye, world!')\n```\n*Line number inside of the block:*\n#codly(number-placement: \"inside\")\n```py\nprint('Hello, world!')\nprint('Goodbye, world!')\n```", "upcoming": false }, + "unnumbered-lines": { + "title": "Unnumbered lines", + "description": "Source lines to omit from line numbering. Each entry can be a line number or `(line, fill)`, where `fill` replaces the empty number cell. Line numbers are one-indexed. An unnumbered line after displayed line 5 can be referenced as `@block:l5p1`; consecutive unnumbered lines increase the final number.", + "default": "()", + "ty": [ "array" ], + "function": true, + "reset": true, + "example": "#codly(unnumbered: ((1, emoji.face.happy), 4))\n```asm\nstart:\n mov w0, 0\n ret\ndone:\n```" + }, "smart-indent": { "title": "Smart indentation", - "description": "Whether to use smart indentation, which will check for indentation on a line and use a bigger left side inset instead of spaces. This allows for linebreaks to continue at the same level of indentation. This is on by default, but disabling it can improve performance.", + "description": "Whether to continue wrapped lines at their original indentation. Leading spaces are read from the normalized raw line text, independently of syntax styling. Wrapping highlights also retain the indentation remaining inside their span. This is on by default, but disabling it can improve performance.", "default": "true", "ty": [ "bool" ], "function": false, @@ -313,17 +346,17 @@ }, "skip-line": { "title": "Skip line", - "description": "Sets the content with which the line code is filled when a skip is encountered.", + "description": "Sets the content displayed for a skip. An array supplies one value per explicit skip in display order, ignoring duplicate skip records. Further skips reuse the final value. Smart skips use the final value without consuming an entry. An empty array uses the default; `none` entries display nothing.", "default": "align(center)[ ... ]", - "ty": [ "content", "type(none)" ], + "ty": [ "content", "type(none)", "array" ], "function": true, "example": "#codly(\n skips: ((4, 32), ),\n skip-line: align(center, emoji.face.shock)\n)\n```py\ndef fib(n):\n if n <= 1:\n return n\n return fib(n - 1) + fib(n - 2)\nfib(25)\n```" }, "skip-number": { "title": "Skip number", - "description": "Sets the content with which the line number columns is filled when a skip is encountered. If line numbers are disabled, this has no effect.", + "description": "Sets the number column content for a skip. Like @arg-skip-line, this accepts a single value or an array indexed by explicit skips. Further skips and smart skips reuse the final value; an empty array uses the default. `none` entries display nothing. If line numbers are disabled, this has no effect.", "default": "[ ... ]", - "ty": [ "content", "type(none)" ], + "ty": [ "content", "type(none)", "array" ], "function": true, "example": "#codly(\n skips: ((4, 32), ),\n skip-number: align(center, emoji.face.shock)\n)\n```py\ndef fib(n):\n if n <= 1:\n return n\n return fib(n - 1) + fib(n - 2)\nfib(25)\n```" }, @@ -337,6 +370,14 @@ "experimental": true, "example": "#codly(\n annotations: (\n (\n start: 2, end: 5,\n content: block(\n width: 2em,\n rotate(-90deg, reflow: true, \n align(center)[Function body])\n )\n ),\n ),\n)\n```py\ndef fib(n):\n if n <= 1:\n return n\n else:\n return fib(n - 1) + fib(n - 2)\nfib(25)\n```" }, + "callouts": { + "title": "Callout rows", + "description": "Content rows attached to selected source lines. Each entry has `line` (one-indexed source line) and `body`; `placement` selects `\"below\"` (default) or `\"above\"`. Optional `align`, `breakable`, `fill`, `inset`, and `stroke` style the grid cell. Set `pointer` to a character position in the attached source line (one-based, like highlights; 0 is line start) to render a pointed bubble. The arrow follows the rendered character, including native source wrapping, and faces the source line. `pointer-align` is `\"center\"` by default, or `\"start\"` / `\"end\"`; the bubble shifts to fit the available row. Consecutive bubbles on the same side with matching cell styles share a row when their anchored outlines fit; collisions start another lane in declaration order. Plain rows or different cell styles start a separate grid row. `bubble-gap` (default `0.5em`) sets the minimum separation: a length applies to both axes, or use `(x: 12pt, y: 4pt)` independently (omitted axes default to `0.5em`). The larger gap wins between neighbors; vertical gaps apply only to adjacent lanes. `bubble-align` aligns the body text and defaults to `center`, independently of the grid cell alignment. Independent bubble fields are `bubble-fill`, `bubble-stroke`, `bubble-inset`, `bubble-outset`, `bubble-radius`, `bubble-width`, and `pointer-size`. The latter defaults the arrow height and half-base width; `pointer-height` and `pointer-width` independently override its height and full base width. `pointer-offset` shifts the tip horizontally from the source character (positive right, negative left, default `0pt`), clamped to the bubble; it does not move the body. The arrow and body share a single outline and paint, including gradients and tilings. Bubble text wraps natively at the available width. A grouped set of bubbles stays together on one page; their outsets and strokes are reserved in the row. Styling defaults live on `codly-bubble`: use `bubble-set_(fill: ..., inset: ..., align: ...)`, with unprefixed bubble fields and the same `pointer-*` fields. Precedence is entry fields, then explicit `codly-callout` settings, then `codly-bubble` settings. Unspecified or `auto` callout styling inherits; explicit `none` still disables a fill or stroke. Generated bubbles support `bubble-show_`. Placement and source pointer remain callout properties; `pointer: none` disables an inherited bubble. Callouts span the number and code columns plus any inactive annotation column; an active brace retains the last column and spans the callouts attached to its source lines.", + "default": "none", + "ty": [ "array", "type(none)" ], + "function": true, + "example": "#codly.new(\n raw(\"if n <= 1:\\n return n\", block: true, lang: \"py\"),\n callouts: ((\n line: 2,\n pointer: 5,\n body: [This branch handles the base case.],\n bubble-fill: gradient.linear(aqua.lighten(70%), purple.lighten(80%)),\n bubble-inset: 4pt,\n ),),\n)" + }, "annotation-format": { "title": "Annotation formatter", "description": "The format of the annotation number. Can be `none` or a function that formats the annotation number.", @@ -365,7 +406,7 @@ }, "highlights": { "title": "Highlights", - "description": "You can apply highlights to the code block using the `highlights` argument. It consists of a list of dictionaries, each with the following keys:\n- `line`: the line number to start highlighting\n- `start`: the character position to start highlighting, zero if omitted or `none`\n- `end`: the character position to end highlighting, the end of the line if omitted or `none`\n- `fill`: the fill of the highlight, defaults to the default color\n- `tag`: an optional tag to be displayed alongside the highlight.\n- `inset`: overrides the global @arg-highlight-inset.\n- `baseline`: overrides the baseline which is set by default to the `bottom` component of @arg-highlight-inset.\n- `clip`: overrides the global @arg-highlight-clip.\n- `outset`: overrides the global @arg-highlight-outset.\n- `radius`: overrides the global @arg-highlight-radius.\n- `label`: *if and only if* the code block is in a `figure`, sets the label by which the highlight can be referenced.\n\nAs with other code block settings, annotations are reset after each code block. #one-indexed #zero-indexed", + "description": "You can apply highlights to the code block using the `highlights` argument. It consists of a list of dictionaries, each with the following keys:\n- `line`: the line number to start highlighting\n- `start`: the character position to start highlighting, zero if omitted or `none`\n- `end`: the character position to end highlighting, the end of the line if omitted or `none`\n- `fill`: the fill of the highlight, defaults to the default color\n- `tag`: an optional tag to be displayed alongside the highlight.\n- `inset`: overrides the global @arg-highlight-inset.\n- `baseline`: overrides the highlight box baseline. The default is `0pt` (no shift). Typst 0.15 and newer also accept `auto` to use the content baseline.\n- `clip`: overrides the global @arg-highlight-clip.\n- `outset`: overrides the global @arg-highlight-outset.\n- `radius`: overrides the global @arg-highlight-radius.\n- `label`: *if and only if* the code block is in a `figure`, sets the label by which the highlight can be referenced.\n\nAs with other code block settings, annotations are reset after each code block. #one-indexed #zero-indexed", "default": "none", "ty": [ "array", "type(none)" ], "function": true, @@ -450,7 +491,7 @@ }, "aliases": { "title": "Language name aliases", - "description": "Creates aliases between languages, allowing them to have separate `languages` but the same syntax highlighting. An example of that could be `cuda` and `c++` having the same highlighting but different icons.", + "description": "Creates aliases between languages, allowing them to have separate `languages` but the same syntax highlighting. An example of that could be `cuda` and `c++` having the same highlighting but different icons. Relative theme and syntax paths inherited through `set raw(...)` retain their origin. When passing these resources directly to an aliased `raw(...)`, use `path(...)` (Typst 0.15+) or `read(..., encoding: none)` instead of plain strings.", "default": "(:)", "ty": [ "dictionary" ], "function": false, diff --git a/src/args.typ b/src/args.typ deleted file mode 100644 index a2e5b52..0000000 --- a/src/args.typ +++ /dev/null @@ -1,129 +0,0 @@ -#let type_check( - tys, - function, -) = { - assert( - type(tys) == array, - message: "codly: the type of a codly argument must be an array" - ) - - (value_ty, use) => { - value_ty in tys or (function and (not use) and value_ty == function) - } -} - -#let type_check_str( - tys, - function, -) = { - assert( - type(tys) == array, - message: "codly: the type of a codly argument must be an array" - ) - - let out = "either a " + tys.map(str).join(", a ", last: ", or a ") - let out_function = if function { - "either a " + tys.map(str).join(", a ") + ", or a function that returns one of the previous types" - } else { - out - } - - (use) => { - if function and (not use) { - out_function - } else { - out - } - } -} - -#let state_with_type( - name, - type_check, - type_check_str, - default, - allow-fn, -) = { - let state = state("codly-" + name, default) - - ( - set-raw: (value) => { - state.update((_) => value) - }, - update: (value) => { - assert( - type_check(type(value), false), - message: "codly: `" + name + "` must be " + type_check_str(false) + ", found: " + str(type(value)), - ) - state.update((_) => value) - }, - type_check: (value) => { - if allow-fn and type(value) == function { - value = value() - } - - assert( - type_check(type(value), false), - message: "codly: `" + name + "` must be " + type_check_str(false) + ", found: " + str(type(value)), - ) - - value - }, - reset: () => { - state.update((_) => default) - }, - default: default, - ) -} - -#let __codly-args = { - let out = (:) - let args = json("args.json") - for (key, arg) in args { - let tys = arg.ty.map(t => { - let t = eval(t, mode: "code"); - if t == none { - type(none) - } else { - t - } - }) - if arg.function and function in tys { - panic("codly: `function` is not a valid type for an argument") - } - - out.insert( - key, - state_with_type( - key, - type_check(tys, arg.function), - type_check_str(tys, arg.function), - eval(arg.default, mode: "code"), - arg.function, - ) - ) - } - - out -} - -#let __codly-defaults = { - let out = (:) - __codly-args.pairs().map(((key, value)) =>(key, value.default)).to-dict() -} - -// Must be called in a context! -#let __codly-save() = { - let out = (:) - for (key, value) in __codly-args { - out.insert(key, (value.get-raw)()) - } - return out -} - -// Must be called after `__codly_save`! -#let __codly-load(stored) = { - for (key, value) in __codly-args { - (value.set-raw)(stored.at(key)) - } -} \ No newline at end of file diff --git a/src/callout.typ b/src/callout.typ new file mode 100644 index 0000000..b58c803 --- /dev/null +++ b/src/callout.typ @@ -0,0 +1,487 @@ +// Pointed callouts: source anchors, native text layout, and one painted outline. +#import "@preview/elembic:1.1.1" as e + +#let bubble-name(name) = if name.starts-with("bubble-") { name.slice(7) } else { name } +#let style-names = ( + "bubble-gap", + "pointer-align", + "pointer-size", + "pointer-height", + "pointer-width", + "pointer-offset", + "bubble-align", + "bubble-fill", + "bubble-stroke", + "bubble-inset", + "bubble-outset", + "bubble-radius", + "bubble-width", +) + +// One schema owns both the bubble's defaults and callout-level overrides. +#let fields(entry: false, bubble: false) = { + let field(name, ty, default, doc) = { + let styling = name in style-names + if bubble and not styling { return none } + let inherit = entry or (not bubble and styling) + e.field( + if bubble { bubble-name(name) } else { name }, + if inherit { e.types.union(ty, auto) } else { ty }, + default: if inherit { auto } else { default }, + doc: doc, + folds: false, + ) + } + ( + field( + "placement", + e.types.union("above", "below"), + "below", + "Side of the source line on which to place the callout.", + ), + field( + "bubble-gap", + e.types.union(length, dictionary), + 0.5em, + "Minimum separation: a length for both axes, or (x: length, y: length). Omitted axes default to 0.5em.", + ), + field( + "pointer", + e.types.option(int), + none, + "Character in the attached source line (one-based, like highlights; 0 means line start). `none` disables the bubble.", + ), + field( + "pointer-align", + e.types.union("start", "center", "end"), + "center", + "Preferred bubble alignment about the source character, clamped to the available row.", + ), + field("pointer-size", length, 0.4em, "Height and half-base width of the arrow."), + field( + "pointer-height", + e.types.union(length, auto), + auto, + "Arrow height; auto uses pointer-size.", + ), + field( + "pointer-width", + e.types.union(length, auto), + auto, + "Full arrow base width; auto uses twice pointer-size.", + ), + field( + "pointer-offset", + length, + 0pt, + "Horizontal shift from the source character; positive points right, negative left. Clamped to the bubble.", + ), + field( + "bubble-align", + alignment, + center, + "Alignment of the content inside the bubble, independent of cell alignment.", + ), + field( + "bubble-fill", + e.types.option(e.types.paint), + luma(245), + "Fill of the entire bubble and arrow.", + ), + field( + "bubble-stroke", + e.types.option(stroke), + 0.6pt + luma(80), + "Stroke of the single bubble outline.", + ), + field( + "bubble-inset", + e.types.union(length, dictionary), + (x: 0.5em, y: 0.3em), + "Padding inside the bubble.", + ), + field( + "bubble-outset", + e.types.union(length, dictionary), + 0pt, + "Extra paint extent around the padded body; reserved in the row.", + ), + field("bubble-radius", length, 2pt, "Corner radius of the bubble."), + field( + "bubble-width", + e.types.union(length, ratio, auto), + auto, + "Preferred padded body width; auto fits the content, bounded by the row.", + ), + ).filter(f => f != none) +} + +#let resolve(value, defaults, bubble-defaults: none) = { + let result = (:) + for name in ("placement", "pointer", ..style-names) { + let local = value.at(name) + let chosen = if local == auto { defaults.at(name) } else { local } + if chosen == auto and bubble-defaults != none and name in style-names { + chosen = bubble-defaults.at(bubble-name(name)) + } + result.insert(name, chosen) + } + result +} + +// Only mark requested characters. The markers occupy no width and survive +// syntax styles, highlights, native line breaks, and smart indentation. +#let annotate(body, data) = { + let visit(body, offset) = { + if body.has("label") and body.label == { return (body, offset) } + if body.has("text") { + let result = () + let part = "" + for c in body.text.clusters() { + let before = offset + offset += c.len() + part += c + let targets = data.pointers.filter(p => before < p and p <= offset) + if targets.len() > 0 { + result.push(text(part)) + part = "" + for pointer in targets { + result.push(context [#metadata(( + owner: data.owner, + row: data.row, + pointer: pointer, + advance: measure(text(c)).width / 2, + ))<__codly-callout-anchor>]) + } + } + } + result.push(text(part)) + (result.join(), offset) + } else if body.has("children") { + let result = () + for child in body.children { + let (child, end) = visit(child, offset) + result.push(child) + offset = end + } + (result.join(), offset) + } else if body.has("child") and body.has("styles") { + let (child, end) = visit(body.child, offset) + ((body.func())(child, body.styles), end) + } else if body.has("body") { + let args = body.fields() + let (child, end) = visit(args.body, offset) + args.body = child + ((body.func())(..args), end) + } else { (body, offset) } + } + let start = if 0 in data.pointers { + [#metadata(( + owner: data.owner, + row: data.row, + pointer: 0, + advance: 0pt, + ))<__codly-callout-anchor>] + } else { [] } + start + visit(body, 0).first() +} + +#let sides(value) = { + let d = if type(value) == dictionary { value } else { (rest: value) } + let rest = d.at("rest", default: 0pt) + let side(name, axis) = d.at(name, default: d.at(axis, default: rest)).to-absolute() + ( + left: side("left", "x"), + right: side("right", "x"), + top: side("top", "y"), + bottom: side("bottom", "y"), + ) +} + +// A single closed path avoids seams and shares one gradient/tiling coordinate +// system across the arrow and rounded rectangle. +#let outline(w, h, arrow, tip, radius, fill, stroke, arrow-width: auto, above: false) = { + let r = calc.min(radius, w / 2, (h - arrow) / 2) + let k = r * 0.5522847498 + let half = calc.min(if arrow-width == auto { arrow } else { arrow-width / 2 }, (w - 2 * r) / 2) + let base = calc.min(w - r - half, calc.max(r + half, tip)) + let point(x, y) = (x, if above { h - y } else { y }) + curve( + fill: fill, + stroke: stroke, + curve.move(point(r, arrow)), + curve.line(point(base - half, arrow)), + curve.line(point(tip, 0pt)), + curve.line(point(base + half, arrow)), + curve.line(point(w - r, arrow)), + curve.cubic(point(w - r + k, arrow), point(w, arrow + r - k), point(w, arrow + r)), + curve.line(point(w, h - r)), + curve.cubic(point(w, h - r + k), point(w - r + k, h), point(w - r, h)), + curve.line(point(r, h)), + curve.cubic(point(r - k, h), point(0pt, h - r + k), point(0pt, h - r)), + curve.line(point(0pt, arrow + r)), + curve.cubic(point(0pt, arrow + r - k), point(r - k, arrow), point(r, arrow)), + curve.close(), + ) +} + +// Measure once at the full available row width, letting Typst wrap the body. +#let geometry(it, row-width, origin) = { + assert( + it.__anchor != none, + message: "codly: pointed callouts need a source line; use the block's `callouts` field", + ) + let anchor = query(<__codly-callout-anchor>).find(m => ( + m.value.owner == it.__anchor.owner and m.value.row == it.line and m.value.pointer == it.pointer + )) + let target = if anchor == none { 0pt } else { + anchor.location().position().x - anchor.value.advance - origin.x + } + let inset = sides(it.at("bubble-inset")) + let outset = sides(it.at("bubble-outset")) + assert( + (..inset.values(), ..outset.values()).all(v => v >= 0pt), + message: "codly: bubble inset and outset must be non-negative", + ) + let size = it.at("pointer-size").to-absolute() + let arrow = it.at("pointer-height") + let arrow = if arrow == auto { size } else { arrow.to-absolute() } + let arrow-width = it.at("pointer-width") + let arrow-width = if arrow-width == auto { 2 * size } else { arrow-width.to-absolute() } + let radius = it.at("bubble-radius").to-absolute() + assert( + size >= 0pt and radius >= 0pt, + message: "codly: pointer size and bubble radius must be non-negative", + ) + assert( + arrow >= 0pt and arrow-width >= 0pt, + message: "codly: pointer height and width must be non-negative", + ) + let stroke = it.at("bubble-stroke") + let edge = if stroke == none { 0pt } else if stroke.thickness == auto { 0.5pt } else { + stroke.thickness.to-absolute() / 2 + } + let available = calc.max(0pt, row-width - 2 * edge) + // Fit even deliberately oversized decorations into a narrow row. + let decorations = inset.left + inset.right + outset.left + outset.right + if decorations > available and decorations > 0pt { + let scale = available / decorations + inset.left *= scale + inset.right *= scale + outset.left *= scale + outset.right *= scale + } + let horizontal = inset.left + inset.right + let outer = outset.left + outset.right + let natural = measure(it.body).width + horizontal + let requested = it.at("bubble-width") + let requested = if requested == auto { natural } else if type(requested) == ratio { + row-width * requested + } else { requested.to-absolute() } + assert(requested >= 0pt, message: "codly: bubble width must be non-negative") + let width = calc.min(calc.max(horizontal, requested), calc.max(0pt, available - outer)) + let body = block(width: width, inset: inset, above: 0pt, below: 0pt, breakable: false, align( + it.at("bubble-align"), + it.body, + )) + let body-height = measure(body).height + let w = width + outer + let h = body-height + outset.top + outset.bottom + arrow + let desired = ( + target + - if it.at("pointer-align") == "start" { 0pt } else if it.at("pointer-align") == "end" { + w + } else { w / 2 } + ) + let x = calc.min(calc.max(edge, desired), calc.max(edge, row-width - edge - w)) + let tip = calc.min(w, calc.max(0pt, target + it.at("pointer-offset").to-absolute() - x)) + ( + x: x, + w: w, + h: h, + edge: edge, + arrow: arrow, + arrow-width: arrow-width, + tip: tip, + radius: radius, + outset: outset, + body: body, + ) +} + +#let paint(it, g) = { + let above = it.placement == "above" + block( + width: g.w + 2 * g.edge, + height: g.h + 2 * g.edge, + above: 0pt, + below: 0pt, + breakable: false, + )[ + #place(top + left, dx: g.edge, dy: g.edge, outline( + g.w, + g.h, + g.arrow, + g.tip, + g.radius, + it.at("bubble-fill"), + it.at("bubble-stroke"), + arrow-width: g.arrow-width, + above: above, + )) + #place( + top + left, + dx: g.edge + g.outset.left, + dy: g.edge + g.outset.top + if above { 0pt } else { g.arrow }, + g.body, + ) + ] +} + +#let render(it) = { + if it.pointer == none { return it.body } + if it.__layout != none { return paint(it, it.__layout) } + block(width: 100%, above: 0pt, below: 0pt, breakable: false, layout(size => { + let g = geometry(it, size.width, here().position()) + block(width: size.width, height: g.h + 2 * g.edge, above: 0pt, below: 0pt, breakable: false)[ + #place(top + left, dx: g.x - g.edge, paint(it, g)) + ] + })) +} + +// The bubble is a real sub-element, so show rules can observe each painted +// instance. Callout cell styles never leak into its fill, padding or alignment. +#let bubble-display(it) = { + let row = ( + body: it.body, + line: it.line, + pointer: it.pointer, + placement: it.placement, + __anchor: it.__anchor, + __layout: it.__layout, + ) + for name in style-names { row.insert(name, it.at(bubble-name(name))) } + render(row) +} + +#let display(bubble, it) = { + if it.pointer == none { return it.body } + e.get(get => { + let settings = resolve(it, it, bubble-defaults: get(bubble)) + let args = (:) + for name in style-names { args.insert(bubble-name(name), settings.at(name)) } + bubble( + it.body, + line: it.line, + pointer: it.pointer, + placement: it.placement, + __anchor: it.__anchor, + __layout: it.__layout, + ..args, + ) + }) +} + +#let pack(rows, constructor) = block( + width: 100%, + above: 0pt, + below: 0pt, + breakable: false, + layout(size => { + let origin = here().position() + let lanes = () + for row in rows { + let g = geometry(row, size.width, origin) + let gap = row.at("bubble-gap") + let gap = if type(gap) == dictionary { gap } else { (x: gap, y: gap) } + assert( + gap.keys().all(k => k in ("x", "y")), + message: "codly: bubble gap accepts only x and y", + ) + let spacing = gap.at("x", default: 0.5em) + let vertical = gap.at("y", default: 0.5em) + assert( + type(spacing) == length and type(vertical) == length, + message: "codly: bubble gap axes must be lengths", + ) + let spacing = spacing.to-absolute() + let vertical = vertical.to-absolute() + assert(spacing >= 0pt and vertical >= 0pt, message: "codly: bubble gap must be non-negative") + let a = g.x - g.edge + let b = g.x + g.w + g.edge + let overlap = ( + lanes.len() == 0 + or lanes + .last() + .items + .any(item => ( + a < item.right + calc.max(spacing, item.gap) + and b + calc.max(spacing, item.gap) > item.left + )) + ) + if overlap { lanes.push((items: (), height: 0pt, gap: 0pt)) } + lanes.last().items.push((row: row, geometry: g, left: a, right: b, gap: spacing)) + lanes.last().height = calc.max(lanes.last().height, g.h + 2 * g.edge) + lanes.last().gap = calc.max(lanes.last().gap, vertical) + } + let gaps = lanes.zip(lanes.slice(1)).map(((a, b)) => calc.max(a.gap, b.gap)) + let height = lanes.map(l => l.height).sum() + gaps.fold(0pt, (a, b) => a + b) + block(width: size.width, height: height, above: 0pt, below: 0pt, breakable: false, { + let y = 0pt + for (index, lane) in lanes.enumerate() { + if index > 0 { y += gaps.at(index - 1) } + for item in lane.items { + let row = item.row + let body = row.remove("body") + let dy = if row.placement == "above" { + lane.height - item.geometry.h - 2 * item.geometry.edge + } else { 0pt } + place(top + left, dx: item.left, dy: y + dy, constructor( + body, + ..row, + __layout: item.geometry, + )) + } + y += lane.height + } + }) + }), +) + +// Consecutive bubbles with identical cell styling can share a grid cell. +// Plain rows or different cell styles form a boundary, preserving their fills +// and spacing. Within a group, collision lanes retain declaration order. +#let row-cells(entries, constructor, defaults, owner, source-line, colspan) = { + let groups = () + for entry in entries { + let settings = resolve(entry, defaults) + for name in ("align", "breakable", "fill", "inset", "stroke") { + let value = entry.at(name) + settings.insert(name, if value == auto { defaults.at(name) } else { value }) + } + let cell = (:) + for name in ("align", "breakable", "fill", "inset", "stroke") { + if settings.at(name) != auto { cell.insert(name, settings.at(name)) } + } + if settings.pointer != none { cell.insert("breakable", false) } + let row = (body: entry.body, line: source-line, __anchor: (owner: owner), ..settings) + if ( + settings.pointer != none + and groups.len() > 0 + and groups.last().bubbles + and groups.last().cell == cell + ) { + groups.last().rows.push(row) + } else { + groups.push((rows: (row,), bubbles: settings.pointer != none, cell: cell)) + } + } + groups.map(group => { + let body = if not group.bubbles { + let row = group.rows.first() + let body = row.remove("body") + constructor(body, ..row) + } else { pack(group.rows, constructor) } + grid.cell(body, colspan: colspan, ..group.cell) + }) +} diff --git a/src/file.typ b/src/file.typ new file mode 100644 index 0000000..387fea7 --- /dev/null +++ b/src/file.typ @@ -0,0 +1,71 @@ +// File/source convenience input and reserved header/footer badge rows. +#let filename(value) = { + if type(value) != path { return value } + // Typst 0.15 exposes no path components. Its repr is path(), + // including for package roots. Decode only that compiler-generated string; + // always read the original path object, never reconstruct its root from repr. + let shown = repr(value) + assert( + shown.starts-with("path(\"") and shown.ends-with("\")"), + message: "codly: unsupported path representation; specify both file and lang explicitly", + ) + let name = eval(shown.slice(5, -1), mode: "code") + name.split("/").last() +} + +#let extension(name) = { + if type(name) != str { return none } + let name = name.split("/").last() + let parts = name.split(".") + if parts.len() < 2 or parts.last() == "" or (parts.len() == 2 and parts.first() == "") { + return none + } + lower(parts.last()) +} + +#let prepare(body, file: auto, lang: auto) = { + let from-file = type(body) == path + let source = from-file or type(body) == str + let original-name = if from-file and (file == auto or lang == auto) { filename(body) } + let file = if file == auto { original-name } else { filename(file) } + if source { + let code = if from-file { read(body) } else { body } + let language = if lang == auto { + extension(if from-file { original-name } else { file }) + } else { lang } + body = if lang == auto and language == none { raw(code, block: true) } else { + raw(code, block: true, lang: language) + } + } + (body: body, file: file, source: source) +} + +#let badge(it) = box(fill: it.fill, stroke: it.stroke, inset: it.inset, radius: it.radius, it.body) + +// place does not reserve space by itself: wrap every badge in its measured +// box before putting the left/right groups into the header/footer grid. +#let reserved(body) = { + let size = measure(body) + box(width: size.width, height: size.height, place(top + left, body)) +} + +#let band(body, file: none, language: none, file-side: left, side: right) = { + let lhs = () + let rhs = () + if file != none { + if file-side == left { lhs.push(reserved(file)) } else { rhs.push(reserved(file)) } + } + if language != none { + if side == left { lhs.push(reserved(language)) } else { rhs.push(reserved(language)) } + } + let group(items) = if items.len() == 0 { [] } else { + grid(columns: (auto,) * items.len(), column-gutter: 0.5em, align: left + top, ..items) + } + let row = grid( + columns: (auto, 1fr, auto), + column-gutter: 0.5em, + align: left + top, + group(lhs), [], group(rhs), + ) + if body == none or body == [] { row } else { stack(spacing: 0.5em, row, body) } +} diff --git a/src/geometry.typ b/src/geometry.typ new file mode 100644 index 0000000..aa4c87b --- /dev/null +++ b/src/geometry.typ @@ -0,0 +1,350 @@ +#import "wrap.typ" as wrap + +// Record cell bounds without measuring or duplicating their contents. +#let cell(body, origin, role: "body") = { + let fields = if body.func() == grid.cell { body.fields() } else { (body: body) } + let body = fields.remove("body") + let fill = fields.remove("fill", default: auto) + [#grid.cell(fill: none, ..fields, [#metadata(( + fill: fill, + role: role, + origin: origin, + ))#body])<__codly-painted-cell>] +} + +#let mark(it, fill, origin, code-column: 1, outside: true, guides: none) = { + let first = if it.body.func() == metadata { it.body } else { it.body.children.first() } + if first.value.origin != origin { return it } + let paint = if first.value.fill == auto { fill(it.x, it.y - 1) } else { first.value.fill } + if not outside or it.x != 0 or it.colspan > 1 or paint != none { + let marks = () + if guides != none and it.x == code-column and first.value.role == "body" { + let depth = guides.depths.at(it.y - 1, default: 0) + for level in range(depth) { + marks.push(( + x: (guides.inset + guides.x-offset).to-absolute() + guides.step * level, + color: guides.palette.at(calc.rem(level + guides.depth-offset, guides.palette.len())), + thickness: guides.thickness, + max-height: guides.max-height, + )) + } + } + place(top + left)[#metadata(( + kind: "cell-start", + origin: origin, + x: it.x, + y: it.y, + rowspan: it.rowspan, + fill: paint, + role: first.value.role, + guides: marks, + ))<__codly-geometry>] + place(bottom + right)[#metadata((kind: "cell-end", origin: origin))<__codly-geometry>] + } + it +} + +#let region-key(pos) = repr((pos.page, pos.x, pos.y)) + +#let pages(origin, code-column: 1, outside: true, wraps: none) = { + let end = query(metadata.where(value: (kind: "paint-end", origin: origin))) + if end.len() == 0 { return (:) } + let starts = () + let ends = () + let tops = () + let bottoms = () + // Location equality also separates nested blocks at identical page positions. + for mark in query(selector(<__codly-geometry>).after(origin).before(end.first().location())) { + if mark.value.origin != origin { continue } + if mark.value.kind == "cell-start" { starts.push(mark) } else if mark.value.kind == "cell-end" { + ends.push(mark) + } else if mark.value.kind == "region-start" { tops.push(mark) } else { bottoms.push(mark) } + } + if tops.len() != bottoms.len() { return (:) } + let regions = () + let by-page = (:) + for (i, top) in tops.enumerate() { + let at = top.location().position() + let bottom = bottoms.at(i).location().position().y + let page = str(at.page) + if page not in by-page { by-page.insert(page, ()) } + by-page.at(page).push(i) + regions.push(( + at: at, + bottom: bottom, + width: top.value.width, + body-top: at.y, + body-bottom: bottom, + cells: (), + numbers: (), + )) + } + let spans = () + let rows = (:) + let gutter = 0pt + for (i, start) in starts.enumerate() { + let a = start.location().position() + let b = ends.at(i).location().position() + let region = none + for index in by-page.at(str(a.page), default: ()) { + let r = regions.at(index) + if a.x >= r.at.x and a.x < r.at.x + r.width and a.y >= r.at.y and a.y <= r.bottom { + region = index + break + } + } + if region == none { continue } + let value = start.value + if value.role == "body" { + spans.push((value: value, a: a, b: b, region: region)) + if value.x == code-column { + rows.insert(str(value.y), (a: a, region: region)) + if outside { gutter = a.x - regions.at(region).at.x } + } + } else { + regions.at(region).cells.push((a: a, b: b, fill: value.fill, guides: ())) + if value.role == "header" { + regions.at(region).body-top = calc.max(regions.at(region).body-top, b.y) + } + if value.role == "footer" { + regions.at(region).body-bottom = calc.min(regions.at(region).body-bottom, a.y) + } + } + } + // A cell owns its fill until the next logical row, including continuation regions. + for span in spans { + let next = rows.at(str(span.value.y + span.value.rowspan), default: none) + let last = if next == none { regions.len() - 1 } else { next.region } + let index = span.region + while index <= last { + let r = regions.at(index) + let top = if index == span.region { span.a.y } else { r.body-top } + let bottom = if next != none and index == last { next.a.y } else { r.body-bottom } + let dx = r.at.x - regions.at(span.region).at.x + if bottom > top { + let a = (x: span.a.x + dx, y: top) + let b = (x: span.b.x + dx, y: bottom) + if outside and span.value.x == 0 { + regions.at(index).numbers.push((a: a, b: b, fill: span.value.fill)) + } else { + let guides = if index == span.region { span.value.guides } else { + span.value.guides.filter(g => g.max-height == none) + } + regions + .at(index) + .cells + .push(( + a: a, + b: b, + fill: span.value.fill, + guides: guides, + )) + } + } + index += 1 + } + } + let pages = (:) + let markers = if wraps == none { () } else { + wrap.marks(wraps.owner, end.first().location(), regions) + } + for r in regions { + let local-markers = () + for marker in markers { + let p = marker.at + if ( + p.page != r.at.page + or p.x < r.at.x + or p.x > r.at.x + r.width + or p.y < r.body-top + or p.y >= r.body-bottom + ) { continue } + local-markers.push(marker) + } + pages.insert(region-key(r.at), ( + origin: r.at, + left: r.at.x + gutter, + top: r.at.y, + bottom: r.bottom, + cells: r.cells, + numbers: r.numbers, + wraps: local-markers, + )) + } + pages +} + +#let background( + origin, + width, + radius, + stroke, + code-column: 1, + outside: true, + wraps: none, +) = context { + let at = here().position() + let page = pages(origin, code-column: code-column, outside: outside, wraps: wraps).at( + region-key(at), + default: none, + ) + if page != none { + for cell in page.numbers { + if cell.fill != none { + place(top + left, dx: cell.a.x - at.x, dy: cell.a.y - page.top, rect( + width: cell.b.x - cell.a.x, + height: cell.b.y - cell.a.y, + fill: cell.fill, + stroke: none, + )) + } + } + let code-left = if page.left == none { at.x } else { page.left } + place(top + left, dx: code-left - at.x, dy: page.top - at.y, block( + width: at.x + width - code-left, + height: page.bottom - page.top, + radius: radius, + stroke: stroke, + clip: true, + { + for cell in page.cells { + if cell.fill != none { + let x = calc.max(code-left, cell.a.x) + place(top + left, dx: x - code-left, dy: cell.a.y - page.top, rect( + width: cell.b.x - x, + height: cell.b.y - cell.a.y, + fill: cell.fill, + stroke: none, + )) + } + for guide in cell.guides { + let x = cell.a.x + guide.x + if cell.a.x <= x and x < cell.b.x { + place(top + left, dx: x - code-left, dy: cell.a.y - page.top, pdf.artifact(line( + end: ( + 0pt, + if guide.max-height == none { cell.b.y - cell.a.y } else { + calc.min(guide.max-height, cell.b.y - cell.a.y) + }, + ), + stroke: guide.color + guide.thickness, + ))) + } + } + } + }, + )) + } +} + +#let wrap-foreground(origin, wraps) = context { + let at = here().position() + let start = none + for record in query(selector(<__codly-geometry>).after(origin).before(here())) { + if record.value.origin != origin or record.value.kind != "region-start" { continue } + let position = record.location().position() + if position.page == at.page and position.x == at.x { start = record } + } + if start == none { return } + let position = start.location().position() + let region = ( + page: at.page, + x: position.x, + top: position.y, + bottom: at.y, + width: start.value.width, + ) + for marker in wrap.marks-region(wraps.owner, here(), region) { + place(top + left, dx: marker.at.x - at.x, dy: marker.at.y - at.y, pdf.artifact(text( + top-edge: "baseline", + bottom-edge: "baseline", + marker.body, + ))) + } +} + +#let outside( + columns, + inset, + align, + fill, + stroke, + radius, + header, + items, + footer, + code-column: 1, + outside: true, + guides: none, + wraps: none, + gutter: none, + column-gutter: none, + row-gutter: none, +) = context { + let origin = here() + let cells = () + for item in items { cells.push(cell(item, origin)) } + let headers = () + if header.len() > 0 { + let h = header.first() + let fields = h.children.first().fields() + fields.y = 1 + let body = fields.remove("body") + headers.push(grid.header(repeat: h.repeat, level: 2, cell( + grid.cell(..fields, body), + origin, + role: "header", + ))) + } + let footers = () + let end = grid.cell(colspan: columns.len(), inset: 0pt, [ + #metadata((kind: "region-end", origin: origin))<__codly-geometry> + #if wraps != none { wrap-foreground(origin, wraps) } + ]) + if footer.len() > 0 { + let f = footer.first() + let content = cell(f.children.first(), origin, role: "footer") + if f.repeat { + footers.push(grid.footer(repeat: true, content, end)) + } else { + cells.push(content) + footers.push(grid.footer(repeat: true, end)) + } + } else { + footers.push(grid.footer(repeat: true, end)) + } + show <__codly-painted-cell>: it => mark( + it, + fill, + origin, + code-column: code-column, + outside: outside, + guides: guides, + ) + let gutters = (:) + if gutter != none { gutters.insert("gutter", gutter) } + if column-gutter != none { gutters.insert("column-gutter", column-gutter) } + if row-gutter != none { gutters.insert("row-gutter", row-gutter) } + grid( + columns: columns, inset: inset, align: align, stroke: none, fill: none, + ..gutters, + grid.header(repeat: true, level: 1, grid.cell( + colspan: columns.len(), + inset: 0pt, + layout(size => { + [#metadata((kind: "region-start", origin: origin, width: size.width))<__codly-geometry>] + background( + origin, + size.width, + radius, + stroke, + code-column: code-column, + outside: outside, + wraps: none, + ) + }), + )), + ..headers, ..cells, ..footers, + ) + metadata((kind: "paint-end", origin: origin)) +} diff --git a/src/indent.typ b/src/indent.typ new file mode 100644 index 0000000..358dc6a --- /dev/null +++ b/src/indent.typ @@ -0,0 +1,78 @@ +// Work from raw.line.text: Typst has already normalized tabs and line endings. +// Syntax highlighting can split or wrap the leading whitespace arbitrarily. +#let leading-spaces = regex("^ *") + +// Vote on observed changes, rather than taking a GCD that collapses to one +// after a single alignment outlier. Ties favor the smaller observed step. +#let detect(rows) = { + let votes = (:) + let previous = 0 + for row in rows { + if row.blank { continue } + let delta = calc.abs(row.columns - previous) + if delta > 0 { + let key = str(delta) + votes.insert(key, votes.at(key, default: 0) + 1) + } + previous = row.columns + } + let width = 4 + let best = 0 + for (key, count) in votes { + let candidate = int(key) + if count > best or (count == best and candidate < width) { + width = candidate + best = count + } + } + width +} + +#let scan(lines, width: auto, blank-lines: true) = { + let rows = lines.map(value => { + let spaces = value.match(leading-spaces).text + (columns: spaces.len(), blank: spaces.len() == value.len()) + }) + if width == auto { width = detect(rows) } + let depths = () + let previous = 0 + let pending = 0 + for row in rows { + if row.blank { + pending += 1 + continue + } + let depth = int(calc.floor(row.columns / width)) + // Only continue levels shared by the surrounding nonblank lines. Leading + // and trailing blank lines have no inferred guides, regardless of spaces. + if pending > 0 { + depths += (if blank-lines { calc.min(previous, depth) } else { 0 },) * pending + pending = 0 + } + depths.push(depth) + previous = depth + } + depths += (0,) * pending + (width: width, depths: depths) +} + +#let settings(value, rainbow) = { + let colored = if value.rainbow == auto { rainbow != none and rainbow.enabled } else { + value.rainbow + } + let colors = if value.palette != auto { value.palette } else if rainbow != none { + rainbow.palette + } else { + import "rainbow.typ": palette + palette + } + let offset = if value.depth-offset != auto { value.depth-offset } else if rainbow != none { + rainbow.depth-offset + } else { 0 } + ( + palette: if colored { colors } else { (value.color,) }, + depth-offset: offset, + thickness: value.thickness, + x-offset: value.x-offset, + ) +} diff --git a/src/lib.typ b/src/lib.typ index 30f072e..63b15b9 100644 --- a/src/lib.typ +++ b/src/lib.typ @@ -1,28 +1,43 @@ -#import "args.typ": __codly-args, __codly-save, __codly-load - -#let __codly-trim(body) = { - if type(body) == str { - return body.trim() +#import "@preview/elembic:1.1.1" as e +#import "geometry.typ" as geometry +#import "rainbow.typ" as rainbow +#import "indent.typ" as indent +#import "wrap.typ" as wrap +#import "callout.typ" as callout-impl + +/// The prefix identifying codly's custom elements and types. +#let __codly-prefix = "@preview/codly:v2.0.0" +#let __codly-whitespace = regex("\\s") + +/// Metadata for each codly argument, read from `src/args.json`. +/// The `doc` of each field below is the argument's `title` in that file. +#let __codly-args = json("args.json") + +/// Returns the documentation title of the argument with the given name. +#let __doc(name) = { + if name not in __codly-args { + panic("codly: missing argument definition for: " + name) } + __codly-args.at(name).title +} - if body.has("children") { - let out = () - let start = true - for child in body.children { - if start and child.has("text") and child.text.trim().len() == 0 { - continue - } else if start and child == [ ] { - continue - } +/// Returns the default value of the argument with the given name, evaluated +/// from its string representation in `src/args.json`. +#let __default(name) = { + if name not in __codly-args { + panic("codly: missing argument definition for: " + name) + } - start = false - out.push(child) - } + eval(__codly-args.at(name).default, mode: "code") +} - (body.func())(out) - } else { - body - } +/// Read the compact block record without querying reference figures. +#let __codly-block-info(target) = { + let origin = query(target) + assert(origin.len() == 1, message: "codly: expected a unique code block label: " + str(target)) + let blocks = query(selector(<__codly-block>).after(origin.first().location())) + assert(blocks.len() > 0, message: "codly: no code block found after " + str(target)) + blocks.first().value } #let __codly-inset(inset) = { @@ -39,2123 +54,1683 @@ } } -/// Lets you set a line number offset. -/// -/// #codly-offset(offset: 25) -/// ```py -/// def fib(n): -/// if n <= 1: -/// return n -/// return fib(n - 1) + fib(n - 2) -/// fib(25) -/// ``` -/// -/// - offset (int): the offset to apply to the code block -/// -/// -> content -#let codly-offset(offset: 0) = { - (__codly-args.offset.update)(offset) +#let __codly-cell-args(align, breakable, fill, inset, stroke) = { + let args = (:) + if align != auto { args.insert("align", align) } + if breakable != auto { args.insert("breakable", breakable) } + if fill != auto { args.insert("fill", fill) } + if inset != auto { args.insert("inset", inset) } + if stroke != auto { args.insert("stroke", stroke) } + args } -/// Lets you set a range of line numbers to highlight. -/// Similar to `codly(range: (start, end))`. -/// -/// #codly-range(start: 2) -/// ```py -/// def fib(n): -/// if n <= 1: -/// return n -/// return fib(n - 1) + fib(n - 2) -/// fib(25) -/// ``` -/// -/// - start (int): the start of the displayed range -/// - end (none, int): the end of the displayed range, none for the rest of the block -/// -/// -> content -#let codly-range( - start, - end: none, - ..rest, -) = { - let pos = rest.pos(); - if pos.len() > 0 { - (__codly-args.ranges.update)(((start, end), ..pos)) - } else { - (__codly-args.range.update)((start, end)) +/// Sort and merge ranges once, then advance through them alongside the lines. +#let __codly-ranges(ranges) = { + let ordered = () + for r in ranges { ordered.push((r.start, if r.end == none { calc.inf } else { r.end })) } + let merged = () + for (start, end) in ordered.sorted() { + if merged.len() > 0 and start <= merged.last().last() + 1 { + merged.last().at(1) = calc.max(merged.last().last(), end) + } else { + merged.push((start, end)) + } } + merged } -/// Disables codly. -/// -/// *Enabled:* -/// ``` -/// Hello, world! -/// ``` -/// -/// #codly-disable() -/// *Disabled:* -/// ``` -/// Hello, world! -/// ``` -/// -/// -> content -#let codly-disable() = { - (__codly-args.enabled.update)(false) +/// Resolve a code-row fill once from the public scalar, palette, or callback +/// form. The caller attaches an `index` to the row before calling this helper. +#let __codly-row-fill(fill, row) = { + if type(fill) == array { + assert(fill.len() > 0, message: "codly: `fill` palettes must not be empty") + return fill.at(calc.rem(row.index, fill.len())) + } + if type(fill) == function { return fill(row) } + fill } -/// Enables codly. -/// -/// *Enabled:* -/// ``` -/// Hello, world! -/// ``` -/// #codly-disable() -/// *Disabled:* -/// ``` -/// Hello, world! -/// ``` -/// -/// #codly-enable() -/// *Enabled:* -/// ``` -/// Hello, world! -/// ``` -/// -/// -> content -#let codly-enable() = { - (__codly-args.enabled.update)(true) -} +#let __codly-lang-show( + it, +) = { + // The body is the language key as a string, e.g. "py". + let lang-key = it.body -/// Disabled codly locally. -/// -/// ```` -/// *Enabled:* -/// ``` -/// Hello, world! -/// ``` -/// -/// *Disabled:* -/// #no-codly(``` -/// Hello, world! -/// ```) -/// -/// -> content -#let no-codly(body) = { - (__codly-args.enabled.update)(false) - body - (__codly-args.enabled.update)(true) -} + // Look up the language definition, if there is one. + let lang-def = if it.languages != none { + it.languages.at(lang-key, default: none) + } else { + none + } -/// Enable codly locally. -/// -/// ```` -/// *Disabled:* -/// #codly(enabled: false) -/// ``` -/// Hello, world! -/// ``` -/// -/// *Enabled:* -/// #yes-codly(``` -/// Hello, world! -/// ```) -/// -/// -> content -#let yes-codly(body) = { - (__codly-args.enabled.update)(true) - body - (__codly-args.enabled.update)(false) -} + let style = (:) + for name in ("display-name", "display-icon", "fill", "stroke", "inset", "radius") { + style.insert(name, if lang-def != none and name in lang-def { lang-def.at(name) } else { + it.at(name) + }) + } -/// Appends a skip to the list of skips. -/// -/// #codly-skip(4, 32) -/// ``` -/// Hello, world! -/// Goodbye, world! -/// ``` -/// -/// - position (int): the line at which to insert the skip -/// - length (int): the number of lines the skip is long -/// -/// -> content -#let codly-skip( - position, - length, -) = { - state("codly-skips", ()).update(skips => { - if skips == none { - skips = () - } + let name = if not style.display-name { + [] + } else if lang-def != none and lang-def.name != none { + lang-def.name + } else if lang-def != none { + [] + } else { + lang-key + } + let icon = if style.display-icon and lang-def != none and lang-def.icon != none { + lang-def.icon + } else { + [] + } + let color = if lang-def != none and lang-def.color != none { + lang-def.color + } else { + it.default-color + } - skips.push((position, length)) - skips - }) -} + let lang = (name: name, icon: icon, color: color) + let fill = style.fill + let fill = if type(fill) == function { + fill(lang) + } else if fill != none { + fill + } else { + color + } + let stroke = style.stroke + let stroke = if type(stroke) == function { + stroke(lang) + } else { + stroke + } -/// Configures codly. -/// Is used in a similar way as `set` rules. You can imagine the following: -/// ```typc -/// // This is a representation of the actual code. -/// // The actual code behave like a set rule that uses `state`. -/// let codly( -/// enabled: true, -/// offset: 0, -/// range: none, -/// languages: (:), -/// display-name: true, -/// display-icon: true, -/// default-color: rgb("#283593"), -/// radius: 0.32em, -/// inset: 0.32em, -/// fill: none, -/// zebra-fill: luma(240), -/// stroke: 1pt + luma(240), -/// lang-inset: 0.32em, -/// lang-outset: (x: 0.32em, y: 0pt), -/// lang-radius: 0.32em, -/// lang-stroke: (lang) => lang.color + 0.5pt, -/// lang-fill: (lang) => lang.color.lighten(80%), -/// lang-format: auto, -/// number-format: (number) => [ #number ], -/// number-align: left + horizon, -/// number-placement: false, -/// smart-indent: false, -/// annotations: none, -/// annotation-format: numbering.with("(1)"), -/// highlights: none, -/// highlight-radius: 0.32em, -/// highlight-fill: (color) => color.lighten(80%), -/// highlight-stroke: (color) => 0.5pt + color, -/// highlight-inset: 0.32em, -/// highlight-outset: 0em, -/// highlight-clip: true, -/// reference-by: line, -/// reference-sep: "-", -/// reference-number-format: numbering.with("1"), -/// header: none, -/// header-repeat: false, -/// header-transform: (x) => x, -/// header-cell-args: (), -/// footer: none, -/// footer-repeat: false, -/// footer-transform: (x) => x, -/// footer-cell-args: (), -/// breakable: false, -/// ) = {} -/// ``` -/// -/// Each argument is explained in the docs: https://github.com/Dherse/codly/blob/main/docs.pdf -#let __codly-inner( - ..args, -) = { - let pos = args.named() + let sep = if icon != [] and name != [] { + h(0.2em) + } else { + [] + } + let body = icon + sep + name + if body == [] { + return [] + } - if args.pos().len() > 0 { - panic("codly: no positional arguments are allowed") + let padding = __codly-inset(style.inset) + let b = measure(body) + let badge = box( + radius: style.radius, + fill: fill, + inset: padding, + stroke: stroke, + outset: 0pt, + height: b.height + padding.top + padding.bottom, + body, + ) + + return badge +} + +/// Trims leading whitespace-only children from content, used for references. +#let __codly-trim(body) = { + if type(body) == str { + return body.trim() } - for (key, arg) in __codly-args { - if key in pos { - // Remove the argument from the list. - let i = pos.remove(key) + if body.has("children") { + let out = () + let start = true + for child in body.children { + if start and child.has("text") and child.text.trim().len() == 0 { + continue + } else if start and child == [ ] { + continue + } - // Update the state. - (arg.update)(i) + start = false + out.push(child) } - } - if pos.len() > 0 { - panic("codly: unknown arguments: " + pos.keys().join(", ")) + (body.func())(out) + } else { + body } } -/// Resets the codly state to its default values. -/// This is useful if you want to reset the state of codly to its default values. -/// -/// Note that this is mostly intended for testing purposes. -/// -/// -> content -#let codly-reset() = { - for (_, arg) in __codly-args { - (arg.reset)() +// Native figure targets emit these small elements instead of formatting their +// reference bodies inline. The shared settings remain compatible with ref-set_. +#let __codly-line-ref-show(codly-ref, it) = e.get(get => { + let shared = get(codly-ref) + let separator = if it.separator == auto { shared.sep } else { it.separator } + let numbering = if it.numbering == auto { shared.numbering } else { it.numbering } + [#ref(it.block)#separator#numbering(it.number)#if it.suffix != none { it.suffix }] +}) + +#let __codly-highlight-ref-show(codly-ref, it) = e.get(get => { + let shared = get(codly-ref) + let separator = if it.separator == auto { shared.sep } else { it.separator } + let numbering = if it.numbering == auto { shared.numbering } else { it.numbering } + let body = if it.by == "line" { + numbering(it.line) + } else { + assert(it.item != none, message: "codly: tag is required for item reference") + it.item + } + [#ref(it.block)#separator#__codly-trim(body)] +}) + +#let __codly-annotation-ref-show(codly-ref, it) = e.get(get => { + let shared = get(codly-ref) + let separator = if it.separator == auto { shared.sep } else { it.separator } + let numbering = if it.numbering == auto { shared.numbering } else { it.numbering } + let body = if it.by == "line" { + numbering(it.line) + if it.suffix == none { [] } else { it.suffix } + } else { + it.item + } + [#ref(it.block)#separator#__codly-trim(body)] +}) + +/// Cache nesting and boundary events by geometry, independently of line styles. +#let __codly-highlight-layout(spans) = { + let keys = () + for (start, end) in spans { + let depth = 0 + for (other-start, other-end) in spans { + if other-start <= start and other-end >= end and (other-start != start or other-end != end) { + depth += 1 + } + } + keys.push((-depth, start, end, keys.len())) + } + let order = keys.sorted() + let boundaries = () + let pending = () + for (_, start, end, _) in order { + boundaries.push(start) + boundaries.push(end) + pending.push((start, pending.len())) } + (order: order, boundaries: boundaries.sorted(), pending: pending.sorted()) } -#let get-len(child) = { +/// The length in characters of a piece of content, where content labeled +/// `` (a tagged highlight) occupies no positions. +#let __codly-line-length(child) = { if child.has("label") and child.label == { 0 } else if child.has("text") { child.text.len() } else if child.has("children") { - child.children.map(get-len).sum() + let length = 0 + for child in child.children { + length += __codly-line-length(child) + } + length } else if child.has("child") { - get-len(child.child) + __codly-line-length(child.child) } else if child.has("body") { - get-len(child.body) + __codly-line-length(child.body) } else { 0 } } -#let ws_regex = regex("\s") -#let indent-regex = regex("^\\s*") -#let get-flattened-body(elem) = { +/// Splits text near highlight boundaries, keeping whitespace and graphemes whole. +#let __codly-line-body(elem, boundaries, offset: 0) = { if elem.has("label") and elem.label == { - return (elem,) - } - - if elem.has("children") { - elem.children.map(get-flattened-body).flatten() + (elem,) + } else if elem.has("children") { + let children = () + for child in elem.children { + children += __codly-line-body(child, boundaries, offset: offset) + offset += __codly-line-length(child) + } + children } else if elem.has("child") and elem.has("styles") { - get-flattened-body(elem.child) - .map(x => (elem.func())(x, elem.styles)) - .flatten() + let children = __codly-line-body(elem.child, boundaries, offset: offset) + for index in range(children.len()) { + children.at(index) = (elem.func())(children.at(index), elem.styles) + } + children } else if elem.has("text") { - // Separate the whitespaces at the start of text + let next = 0 + while next < boundaries.len() and boundaries.at(next) < offset { next += 1 } + if next == boundaries.len() or boundaries.at(next) > offset + elem.text.len() { + return (elem,) + } let out = () - let ws = none - for cluster in elem.text.clusters() { - let m = cluster.match(ws_regex) - if m != none and ws != none { - ws += cluster - } else if m != none and ws == none { - ws = cluster - } else if ws != none { - out.push(text(ws)) - ws = none - out.push(text(cluster)) - } else { - out.push(text(cluster)) + let part = "" + let token = "" + let was-space = false + let clusters = elem.text.clusters() + clusters.push("") + for cluster in clusters { + let is-space = cluster.contains(__codly-whitespace) + if token != "" and (not is-space or not was-space) { + offset += token.len() + if next < boundaries.len() and boundaries.at(next) <= offset { + if part != "" { + out.push(text(part)) + part = "" + } + out.push(text(token)) + while next < boundaries.len() and boundaries.at(next) <= offset { next += 1 } + } else { + part += token + } + token = "" } + token += cluster + was-space = is-space } - - if ws != none { - out.push(text(ws)) - } + if part != "" { out.push(text(part)) } out } else { (elem,) } } -#let __sort-highlights(highlights) = { - // Check if highlight 'a' contains highlight 'b' - let contains(a, b) = { - // a contains b if b is fully within a's range - // but they're not the same highlight - (a.start <= b.start and a.end >= b.end and - not (a.start == b.start and a.end == b.end)) - } - - // Calculate nesting depth for a highlight - // Depth = number of highlights that contain this one - let get-depth(h, all-highlights) = { - let depth = 0 - for other in all-highlights { - if contains(other, h) { - depth += 1 - } - } - depth - } - - // Add depth information to each highlight - let with-depths = highlights.enumerate().map(((i, h)) => { - ( - original-index: i, - highlight: h, - depth: get-depth(h, highlights) - ) - }) - - // Sort by: - // 1. Depth (descending - deepest/most nested first) - // 2. Start position (ascending) - // 3. End position (ascending) - // 4. Original index (to maintain stable sort) - let sorted = with-depths.sorted(key: item => ( - -item.depth, // Negative for descending order - item.highlight.start, - item.highlight.end, - item.original-index - )) - - // Return just the highlights without the extra metadata - sorted.map(item => item.highlight) -} - -#let codly-line( - highlight-stroke, - highlight-fill, - highlight-radius, - highlight-inset, - highlight-baseline, - highlight-outset, - highlight-clip, - default-color, - highlights, - smart-indent, - reference-by, - reference-sep, - reference-number-format, +/// Renders a single highlighted span of a code line. All styling is resolved +/// from the per-highlight overrides in the metadata record, falling back to +/// the element's own fields, and references use the `codly-ref` settings. +#let __codly-highlight-show( + codly-ref, + codly-highlight-ref, it, - block-label: none ) = { - let highlighted = it.body - let highlights = { - if highlights == none { - () - } else { - __sort-highlights(highlights - .filter(x => x.line == it.number) - .map(x => { - if not "start" in x or x.start == none { - x.insert("start", 0) - } + let hl = it.highlight + let body = it.body + if it.__continuation-indent != none { + body = par(hanging-indent: it.__continuation-indent, body) + } - if not "end" in x or x.end == none { - x.insert("end", 999999999) - } + let base = if hl != none and hl.fill != none { hl.fill } else { it.color } + if type(base) == function { base = base(it.color) } + let style = (:) + for name in ("radius", "clip", "inset", "outset", "baseline") { + style.insert(name, if hl != none and hl.at(name) != none { hl.at(name) } else { it.at(name) }) + } + let fill = if it.fill != none { (it.fill)(base) } + let stroke = if hl != none and hl.stroke != none { hl.stroke } else { it.stroke } + if type(stroke) == function { stroke = stroke(base) } + + // Build the hidden reference figure if the highlight is labeled. + let label = if hl != none and hl.label != none { + e.get(get => { + assert( + hl.at("block-label", default: none) != none, + message: "codly: for labels on highlights to work, you must have the code block contained within a figure and that figure must have a label.", + ) + let ref-set = get(codly-ref) + if ref-set.by == "item" { + assert(hl.tag != none, message: "codly: tag is required for item reference") + } - if not "fill" in x { - x.insert("fill", default-color) - } - x - })) - } + let block-label = hl.at("block-label") + place(hide(pdf.artifact[#figure( + kind: "codly-referencer", + supplement: none, + numbering: (..) => codly-highlight-ref( + [], + block: block-label, + line: hl.at("line-number"), + item: hl.tag, + by: ref-set.by, + ), + [], + )#hl.label])) + }) } - // Smart indentation code. - let body = if it.body.has("children") { - it.body.children + let tag = if hl != none { hl.tag } else { none } + if tag == none { + box( + radius: style.radius, + clip: style.clip, + fill: fill, + stroke: stroke, + inset: style.inset, + outset: style.outset, + baseline: style.baseline, + body + label, + ) } else { - (body, ) + // Explicit widths prevent reflow at the weak break between body and tag. + let inset-sep = __codly-inset(style.inset) + let size-body = measure(body) + let size-tag = measure(tag) + let max-height = ( + calc.max( + size-body.height, + size-tag.height, + ) + + inset-sep.top + + inset-sep.bottom + ) + let width-body = size-body.width + inset-sep.left + inset-sep.right + let width-tag = size-tag.width + inset-sep.left + inset-sep.right + let body-box = box( + radius: (top-right: 0pt, bottom-right: 0pt, rest: style.radius), + width: width-body, + height: max-height, + clip: style.clip, + fill: fill, + stroke: stroke, + inset: style.inset, + outset: style.outset, + baseline: style.baseline, + body, + ) + let tag-box = box( + radius: (top-left: 0pt, bottom-left: 0pt, rest: style.radius), + width: width-tag, + height: max-height, + clip: style.clip, + fill: fill, + stroke: stroke, + inset: style.inset, + outset: style.outset, + baseline: style.baseline, + tag + label, + ) + [#body-box#h(0pt, weak: true)#tag-box] } +} - let width = none - if smart-indent { - // Check the indentation of the line by taking l, - // and checking for the first element in the sequence. - let first = for child in body { - if child.has("children") { - for c in child.children { - if c.has("text") { - c - break - } +/// Renders a single code line: smart indentation, per-character highlights +/// (delegated to `codly-highlight`), and line reference figures. +// Keep the shared rendering helpers out of each line's context captures. +// Its font-dependent work still runs inside the caller's deferred context. +#let __codly-line-render(codly-highlight, codly-ref, codly-line-ref, it, line-show) = { + let line = it.body + let line-highlights = it.highlights + let smart-indent = it.smart-indent + let block-label = it.block-label + let highlights = () + let layout = none + if line-highlights != none and line-highlights.len() > 0 { + let spans = () + for hl in line-highlights { + if hl.line == line.number { + if hl.at("label", default: none) != none { + hl.insert("line-number", line.number) + hl.insert("block-label", block-label) } - } else if child.has("child") { - if child.child.has("text") { - child.child - break - } - } else if child.has("body") { - if child.body.has("text") { - child.body - break - } - } else if child.has("text") { - child - break - } - } - - if first != none and first.has("text") { - let match = first.text.match(indent-regex) - - // Ensure there is a match and it starts at the beginning of the line. - if match != none and match.start == 0 { - // Calculate the indentation. - let indent = match.end - match.start - - // Then measure the necessary indent. - let indent = first.text.slice(match.start, match.end) - width = measure([#indent]).width + highlights.push(hl) + spans.push((hl.start, hl.end)) } } + if highlights.len() > 0 { layout = __codly-highlight-layout(spans) } } - - // If there are no highlights, return the line as is. - if highlights.len() == 0 { - if width != none { - // We add the indentation to the line. - highlighted = { - set par(hanging-indent: width) - highlighted - } - } - - let l = raw.line(it.number, it.count, it.text, highlighted) - - // Build a label if the code block has one. - if block-label != none { - let lab = label(str(block-label) + ":" + str(it.number)) - - return [#figure( - kind: "codly-line", - supplement: none, - caption: none, - outlined: false, - numbering: (..) => { - ref(block-label) - reference-sep - reference-number-format(it.number) - }, - l - )#lab] - } else { - return l + if layout != none { + let sorted = () + for (depth, _, _, index) in layout.order { + let hl = highlights.at(index) + hl.insert("depth", -depth) + sorted.push(hl) } + highlights = sorted } - // Apply highlights - let l = it.body - for hl in highlights { - let fill = highlight-fill(hl.fill) - let stroke = highlight-stroke(hl.fill) - let hl-inset = if "inset" in hl { - hl.inset - } else { - highlight-inset - } - - let hl-baseline = if "baseline" in hl { - hl.baseline - } else { - highlight-baseline - } - - let hl-outset = if "outset" in hl { - hl.outset - } else { - highlight-outset - } - - let hl-clip = if "clip" in hl { - hl.clip - } else { - highlight-clip - } - - let hl-radius = if "radius" in hl { - hl.radius - } else { - highlight-radius + // Keep empty and highlighted lines at a consistent height. + let line-height = measure[1].height + let body = line.body + let wrap-data = none + let needs-marker = false + if smart-indent and it.__wrap != none { + // Highlight insets can change the width. Measure their ordinary renderer + // before deciding whether to add any probes to this source row. + let natural = if highlights.len() == 0 { line.body } else { + line-show(codly-highlight, codly-ref, codly-line-ref, it + (__wrap: none)) } + needs-marker = measure(natural).width > it.__wrap.width + } + if needs-marker { + let row = it.__wrap.row + let marker = it.__wrap.marker + let advance = measure(marker).width + 0.3em.to-absolute() + wrap-data = ( + owner: it.__wrap.owner, + row: row, + marker: marker, + advance: advance, + tolerance: line-height / 2, + ) + body = wrap.annotate(body, it.__wrap.owner, row) + } + if it.__callout != none { body = callout-impl.annotate(body, it.__callout) } + body = box(height: line-height, width: 0pt, baseline: 0pt) + body - let tag = if "tag" in hl { - hl.tag - } else { - none - } + // Continue wrapped lines at their original indentation. + let width = none + let prefix = if smart-indent { line.text.match(indent.leading-spaces).text } else { "" } + if smart-indent { + if prefix != "" { width = measure(text(prefix)).width } + } + if wrap-data != none { + wrap-data.insert("indent", if width == none { 0pt } else { width }) + width = wrap-data.indent + wrap-data.advance + } + let highlight-options(start) = { + // Use the actual fragment start: highlight positions are one-based and + // whitespace runs are atomic; crossing spans may also reopen later. + let remaining = calc.max(prefix.len() - start, 0) + if not smart-indent or (remaining == 0 and wrap-data == none) { return (:) } + ( + __continuation-indent: measure(text(" " * remaining)).width + + if wrap-data == none { 0pt } else { wrap-data.advance }, + ) + } - let children = () - let collection = none + // Split before applying `set par`, which would otherwise wrap each fragment. + let highlighted = body + if highlights.len() > 0 { + let source = __codly-line-body(body, layout.boundaries) + let pending = layout.pending + + // Descending indices keep the outer highlights first without re-sorting. + let next = 0 + let next-end = calc.inf + let active = () + let open = () + let groups = ((),) + let starts = () let i = 0 - - let body = get-flattened-body(highlighted) - - let len = body.len() - for (index, child) in body.enumerate() { - let last = index == len - 1 - if child.has("label") and child.label == { - if collection != none { - collection.push(child) - continue - } else { - children.push(child) - continue + for child in source { + let end = i + __codly-line-length(child) + let changed = false + if i >= next-end { + let remaining = () + for index in active { + if i < highlights.at(index).end { remaining.push(index) } } + active = remaining + changed = true } - - let len = get-len(child) - - if collection != none { - collection.push(child) - } else if collection == none and ( - i >= hl.start or i + len >= hl.start - ) and (i < hl.end) { - collection = (child,) - } else { - children.push(child) - } - - let label = if "label" in hl and hl.label != none { - assert.ne(block-label, none, message: { - "codly: for labels on highlights to work, " - "you must have the code block contained within a figure " - "and that figure must have a label." - }) - - let referenced = if reference-by == "line" { - reference-number-format(it.number) - } else { - assert("tag" in hl, message: "codly: tag is required for item reference") - hl.tag + while next < pending.len() and pending.at(next).first() <= end { + let index = pending.at(next).last() + let hl = highlights.at(index) + // Expired spans cannot rejoin; duplicate records apply only once. + if i < hl.end { + let position = 0 + let duplicate = false + for other in active { + if highlights.at(other) == hl { + duplicate = true + break + } + if other > index { position += 1 } + } + if not duplicate { + active.insert(position, index) + changed = true + } } - - place(hide[#figure( - kind: "codly-referencer", - supplement: none, - numbering: (..) => { - ref(block-label) - reference-sep - __codly-trim(referenced) - }, - [] - )#hl.label]) - } else { - none + next += 1 } - if collection != none and (i + len >= hl.end or last) { - if tag == none { - let content = box( - radius: hl-radius, - clip: hl-clip, - fill: fill, - stroke: stroke, - inset: hl-inset, - outset: hl-outset, - baseline: hl-baseline, - collection.join() + label, - ) - children.push(content) - } else { - let col = collection.join() - let height-col = measure(col).height - let height-tag = measure(tag).height - let highlight-inset-sep = __codly-inset(hl-inset) - let max-height = calc.max( - height-col, - height-tag, - ) + highlight-inset-sep.top + highlight-inset-sep.bottom - let hl-box = box( - radius: ( - top-right: 0pt, - bottom-right: 0pt, - rest: hl-radius, - ), - height: max-height, - clip: hl-clip, - fill: fill, - stroke: stroke, - inset: hl-inset, - outset: hl-outset, - baseline: hl-baseline, - collection.join(), - ) - let tag-box = box( - radius: ( - top-left: 0pt, - bottom-left: 0pt, - rest: hl-radius, - ), - height: max-height, - clip: hl-clip, - fill: fill, - stroke: stroke, - inset: hl-inset, - outset: hl-outset, - baseline: hl-baseline, - tag + label, - ) + if changed { + next-end = calc.inf + for index in active { + next-end = calc.min(next-end, highlights.at(index).end) + } - children.push([#hl-box#h( - 0pt, - weak: true, - )#tag-box]) + // Keep shared outer highlights open across changes to their children. + let shared = 0 + while ( + shared < calc.min(open.len(), active.len()) and open.at(shared) == active.at(shared) + ) { + shared += 1 + } + while open.len() > shared { + let hl = highlights.at(open.pop()) + let content = codly-highlight(groups.pop().join(), highlight: hl, ..highlight-options( + starts.pop(), + )) + groups.last().push(content) + } + while open.len() < active.len() { + open.push(active.at(open.len())) + groups.push(()) + starts.push(i) } - collection = none } + groups.last().push(child) + i = end + } - i += len + // Close spans that continue through or beyond the end of the line. + while open.len() > 0 { + let hl = highlights.at(open.pop()) + let content = codly-highlight(groups.pop().join(), highlight: hl, ..highlight-options( + starts.pop(), + )) + groups.last().push(content) } - highlighted = children.join() + highlighted = groups.first().join() } if width != none { - // We add the indentation to the line. highlighted = { set par(hanging-indent: width) highlighted } } - let l = raw.line(it.number, it.count, it.text, highlighted) + if wrap-data != none { highlighted = [#metadata(wrap-data)<__codly-wrap-row>#highlighted] } + let output = raw.line(line.number, line.count, line.text, highlighted) + if block-label == none { + // End the paragraph while its hanging indent is in scope. Unlike an + // empty placement, this boundary needs no positioned frame. + return output + parbreak() + } - // Build a label if the code block has one. - if block-label != none { - let lab = label(str(block-label) + ":" + str(it.number)) - return [#figure( - kind: "codly-line", - supplement: none, - numbering: (..) => { - ref(block-label) - reference-sep - reference-number-format(it.number) + let number = line.number + let reference = it.reference + if reference != none { number = reference.number } + let line-label = label( + str(block-label) + + ":" + + if reference == none { + str(number) + } else { + reference.label }, - l - )#lab] - } else { - return l - } + ) + [#output#place(hide(pdf.artifact[#figure( + kind: "codly-line", + supplement: none, + caption: none, + outlined: false, + numbering: (..) => codly-line-ref( + [], + block: block-label, + number: number, + suffix: if reference == none { none } else { reference.suffix }, + ), + [], + )#line-label]))] } -#let in_range(ranges, line) = { - if ranges == none or ranges.len() == 0 { - return true +#let __codly-line-show( + codly-highlight, + codly-ref, + codly-line-ref, + it, +) = { + let line = it.body + + // Skip placeholders and other non-line content are rendered as-is. + if type(line) != content or line.func() != raw.line { + return line } - // Return true if the line is contained in any of the ranges. - for r in ranges { - if r.at(0) == none { - if line <= r.at(1) { - return true - } - } else if r.at(1) == none { - if r.at(0) <= line { - return true - } - } else if r.at(0) <= line and line <= r.at(1) { - return true - } + // The renderer needs no element metadata or grid-cell styling fields. + let it = ( + body: line, + highlights: it.highlights, + smart-indent: it.smart-indent, + block-label: it.block-label, + reference: if it.block-label != none { it.reference }, + __wrap: if it.smart-indent { it.__wrap }, + __callout: it.at("__callout", default: none), + ) + let smart-indent = it.smart-indent + if smart-indent and it.__wrap != none and "width" not in it.__wrap { + return layout(size => __codly-line-show( + codly-highlight, + codly-ref, + codly-line-ref, + it + (__wrap: it.__wrap + (width: size.width)), + )) } + context __codly-line-render( + codly-highlight, + codly-ref, + codly-line-ref, + it, + __codly-line-show, + ) +} - return false +#let __codly-annotation-cell(constructor, body, label, num, numbering) = { + block(height: 1fr, layout(size => constructor( + body, + label, + num: num, + numbering: numbering, + height: size.height, + ))) } -#let __codly-raw( - it, - block-label: none, - alias: none, - extra: (:), -) = context { - let enabled = (__codly-args.enabled.type_check)(if "enabled" in extra { - extra.enabled - } else { - state("codly-enabled", __codly-args.enabled.default).get() - }) +/// Resolve syntax-highlighted content before constructing the line element so +/// its show rules, highlights, indentation, and references see a real raw.line. +#let __codly-sublang-line(constructor, line, source: none, ..args) = context { + let record = query(selector(source).before(here())).last(default: none) + let resolved = if record == none { line } else { record.value } + constructor( + raw.line(line.number, line.count, resolved.text, resolved.body), + ..args, + ) +} - if not enabled { - return it +#let __codly-line-loop( + codly-line, + codly-number, + smart-skip, + lines, + annotations, + callouts, + ranges, + skips, + skip-last-empty, + number-enabled, + skip-line, + skip-number, + codly-annotation, + codly-callout, + codly-bubble, + codly-annotation-ref, + ref-set, + highlights, + smart-indent, + block-label, + offset, + lang-block, + sublang-lines: (:), + get: none, + indentation: none, + wrap-settings: none, + unnumbered: (), + parent: [], +) = { + let items = () + let lines_to_number = () + let rows = () + let guide-depths = () + let last-number = none + let smart-skip-enabled = smart-skip.first or smart-skip.last or smart-skip.rest + let current-annot = none + let annotation-cell = none + let annotation-rows = 0 + let annots = 0 + let in-skip = false + let in-first = true + let has-annots = annotations.len() > 0 + let skip-index = 0 + let formatted-skips = 0 + let line-array = type(skip-line) == array + let number-array = type(skip-number) == array + let fallback-line = if line-array { skip-line.at(-1, default: __default("skip-line")) } else { + skip-line } - - if type(it) != content or it.func() != raw { - panic("codly-raw: body must be a raw content") + let fallback-number = if number-array { + skip-number.at(-1, default: __default("skip-number")) + } else { skip-number } + let unnumbered-by-line = (:) + if unnumbered != none { + for entry in unnumbered { + unnumbered-by-line.insert(str(entry.line), entry.fill) + } } - - // Optimization: skip alises checking if `alias` already set - let aliases = if alias == none { - (__codly-args.aliases.type_check)(if "aliases" in extra { - extra.aliases - } else { - state("codly-aliases", __codly-args.aliases.default).get() - }) - } else { - (:) + let unnumbered-after = 0 + let range-index = 0 + let has-ranges = ranges != none and ranges.len() > 0 + let last-line = lines.len() + if has-ranges and skip-last-empty and last-line > 0 and lines.last().text.trim() == "" { + last-line -= 1 } - let alias = if it.lang != none and it.lang in aliases { - let real = aliases.at(it.lang) - return { - show raw.where(block: true): __codly-raw.with(alias: it.lang, extra: extra, block-label: block-label) + // Index once per block instead of passing every highlight to every line. + let highlights-by-line = (:) + let has-highlights = highlights != none and highlights.len() > 0 + if has-highlights { + for hl in highlights { + let key = str(hl.line) + if key not in highlights-by-line { + highlights-by-line.insert(key, ()) + } + highlights-by-line.at(key).push(hl) + } + } - raw( - it.text, - block: true, - align: it.align, - lang: real, - theme: it.theme, - syntaxes: it.syntaxes, - tab-size: it.tab-size, + // Callouts are keyed by source line so offsets and inserted skips do not + // change the line they attach to. Preserve input order on each side. + let callouts-by-line = (:) + let callout-owner = if callouts != none and callouts.len() > 0 { here() } + let callout-defaults = if callout-owner != none { get(codly-callout) } + let bubble-defaults = if callout-owner != none { get(codly-bubble) } + if callouts != none { + for callout in callouts { + callout += callout-impl.resolve(callout, callout-defaults, bubble-defaults: bubble-defaults) + assert( + callout.pointer == none or callout.pointer >= 0, + message: "codly: callout pointer must be non-negative", ) + let key = str(callout.line) + if key not in callouts-by-line { callouts-by-line.insert(key, ()) } + callouts-by-line.at(key).push(callout) } - } else if alias == none { - it.lang - } else { - alias } - let highlight-inset = ( - __codly-args.highlight-inset.type_check - )(if "highlight-inset" in extra { - extra.highlight-inset - } else { - state("codly-highlight-inset", __codly-args.highlight-inset.default).get() - }); - - show raw.line.where(label: ): codly-line.with( - (__codly-args.highlight-stroke.type_check)(if "highlight-stroke" in extra { - extra.highlight-stroke - } else { - state("codly-highlight-stroke", __codly-args.highlight-stroke.default).get() - }), - (__codly-args.highlight-fill.type_check)(if "highlight-fill" in extra { - extra.highlight-fill - } else { - state("codly-highlight-fill", __codly-args.highlight-fill.default).get() - }), - (__codly-args.highlight-radius.type_check)(if "highlight-radius" in extra { - extra.highlight-radius - } else { - state("codly-highlight-radius", __codly-args.highlight-radius.default).get() - }), - highlight-inset, - __codly-inset(highlight-inset).bottom, - (__codly-args.highlight-outset.type_check)(if "highlight-outset" in extra { - extra.highlight-outset - } else { - state("codly-highlight-outset", __codly-args.highlight-outset.default).get() - }), - (__codly-args.highlight-clip.type_check)(if "highlight-clip" in extra { - extra.highlight-clip - } else { - state("codly-highlight-clip", __codly-args.highlight-clip.default).get() - }), - (__codly-args.default-color.type_check)(if "default-color" in extra { - extra.default-color - } else { - state("codly-default-color", __codly-args.default-color.default).get() - }), - (__codly-args.highlights.type_check)(if "highlights" in extra { - extra.highlights - } else { - state("codly-highlights", __codly-args.highlights.default).get() - }), - (__codly-args.smart-indent.type_check)(if "smart-indent" in extra { - extra.smart-indent - } else { - state("codly-smart-indent", __codly-args.smart-indent.default).get() - }), - (__codly-args.reference-by.type_check)(if "reference-by" in extra { - extra.reference-by - } else { - state("codly-reference-by", __codly-args.reference-by.default).get() - }), - (__codly-args.reference-sep.type_check)(if "reference-sep" in extra { - extra.reference-sep - } else { - state("codly-reference-sep", __codly-args.reference-sep.default).get() - }), - (__codly-args.reference-number-format.type_check)(if "reference-number-format" in extra { - extra.reference-number-format - } else { - state("codly-reference-number-format", __codly-args.reference-number-format.default).get() - }), - block-label: block-label, - ) - - show figure.where(kind: "codly-line"): it => { - set align(left + horizon) - it.body - } - - show figure.where(kind: "__codly-raw-line"): it => { - set align(left + horizon) - it.body - } - - show figure.where(kind: "__codly-end-block"): it => none - - set par(justify: false, first-line-indent: 0pt) - - let range = (__codly-args.range.type_check)(if "range" in extra { - extra.range - } else { - state("codly-range", __codly-args.range.default).get() - }) - - let ranges = (__codly-args.ranges.type_check)(if "ranges" in extra { - extra.ranges - } else { - state("codly-ranges", __codly-args.ranges.default).get() - }) - - if ranges == none and range != none { - ranges = (range, ) - } + for line in lines { + if has-annots { + let annot = annotations.at(annotations.len() - 1, default: none) + if annot != none and line.number == annot.start { + current-annot = annot + annots += 1 + } - // Pre-check ranges (skips range check in every iter) - if ranges != none { - for r in ranges { - assert(type(r) == array, message: "codly: ranges must be an array of arrays, found " + str(type(r))) - assert(r.len() == 2, message: "codly: ranges must be an array of arrays with two elements") + if current-annot != none and line.number > current-annot.end { + if annotation-cell != none { + items.at(annotation-cell) = grid.cell( + rowspan: annotation-rows, + align: left + horizon, + items.at(annotation-cell), + ) + annotation-cell = none + annotation-rows = 0 + } + current-annot = none + _ = annotations.pop() + let annot = annotations.at(annotations.len() - 1, default: none) + if annot != none and line.number == annot.start { + current-annot = annot + annots += 1 + } + } } - } - - let display-names = ( - __codly-args.display-name.type_check - )(if "display-name" in extra { - extra.display-name - } else { - state("codly-display-name", __codly-args.display-name.default).get() - }) - - let display-icons = ( - __codly-args.display-icon.type_check - )(if "display-icon" in extra { - extra.display-icon - } else { - state("codly-display-icon", __codly-args.display-icon.default).get() - }) - let lang-outset = ( - __codly-args.lang-outset.type_check - )(if "lang-outset" in extra { - extra.lang-outset - } else { - state("codly-lang-outset", __codly-args.lang-outset.default).get() - }) - - let language-block = { - let fn = (__codly-args.lang-format.type_check)(if "lang-format" in extra { - extra.lang-format - } else { - state("codly-lang-format", __codly-args.lang-format.default).get() - }) - - if fn == none { - (..) => none - } else if fn == auto { - auto - } else { - fn + let explicit-skip-data = skips.at(skip-index, default: none) + let explicit-skip = explicit-skip-data != none and line.number == explicit-skip-data.position + if has-ranges { + while range-index < ranges.len() and ranges.at(range-index).last() < line.number { + range-index += 1 + } } - } - let default-color = ( - __codly-args.default-color.type_check - )(if "default-color" in extra { - extra.default-color - } else { - state("codly-default-color", __codly-args.default-color.default).get() - }) - - let radius = (__codly-args.radius.type_check)(if "radius" in extra { - extra.radius - } else { - state("codly-radius", __codly-args.radius.default).get() - }) - - let offset = (__codly-args.offset.type_check)(if "offset" in extra { - extra.offset - } else { - state("codly-offset", __codly-args.offset.default).get() - }) - - let offset-from = ( - __codly-args.offset-from.type_check - )(if "offset-from" in extra { - extra.offset-from - } else { - state("codly-offset-from", __codly-args.offset-from.default).get() - }) + let interval = if has-ranges { ranges.at(range-index, default: none) } + let in-range = not has-ranges or (interval != none and interval.first() <= line.number) + let insert-skip = ( + smart-skip-enabled + and not in-range + and not in-skip + and if in-first { + smart-skip.first + } else if interval != none and interval.first() <= last-line { + smart-skip.rest + } else { + smart-skip.last + } + ) - if offset-from != none { - let origin = query(offset-from) - if origin.len() == 0 { - panic("codly: offset-from must be used with a valid label, could not find: " + str(offset-from)) - } else if origin.len() > 1 { - panic("codly: offset-from must be used with a unique label, found multiple: " + str(offset-from)) + if explicit-skip or insert-skip { + if number-enabled { + let number = if explicit-skip and number-array { + skip-number.at(formatted-skips, default: fallback-number) + } else { fallback-number } + items.push(codly-number(if number == none { [] } else { number }, parent)) + } + let body = if explicit-skip and line-array { + skip-line.at(formatted-skips, default: fallback-line) + } else { fallback-line } + let body = codly-line(body) + items.push(if has-annots { + grid.cell(colspan: if annotation-cell == none { 2 } else { 1 }, body) + } else { body }) + if annotation-cell != none { annotation-rows += 1 } + lines_to_number.push(-99999999) + rows.push((kind: "skip", source-line: none, number: none)) + if indentation != none { guide-depths.push(0) } + if explicit-skip { + formatted-skips += 1 + offset += explicit-skip-data.length + skip-index += 1 + while skip-index < skips.len() and skips.at(skip-index) == explicit-skip-data { + skip-index += 1 + } + } } - let origin = origin.first() - let end = query(figure.where(kind: "__codly-end-block").after(origin.location())).first() - let lines = query(figure.where(kind: "__codly-raw-line").after(origin.location()).before(end.location())) - if lines.len() > 0 { - offset += lines.last().body.children.at(0).number + if not in-range { + in-skip = true + continue } - } + in-skip = false - let stroke = (__codly-args.stroke.type_check)(if "stroke" in extra { - extra.stroke - } else { - state("codly-stroke", __codly-args.stroke.default).get() - }) - - let zebra-color = ( - __codly-args.zebra-fill.type_check - )(if "zebra-fill" in extra { - extra.zebra-fill - } else { - state("codly-zebra-fill", __codly-args.zebra-fill.default).get() - }) - - let numbers-format = ( - __codly-args.number-format.type_check - )(if "number-format" in extra { - extra.number-format - } else { - state("codly-number-format", __codly-args.number-format.default).get() - }) - - let numbers-alignment = ( - __codly-args.number-align.type_check - )(if "number-align" in extra { - extra.number-align - } else { - state("codly-number-align", __codly-args.number-align.default).get() - }) - - let number-placement = ( - __codly-args.number-placement.type_check - )(if "number-placement" in extra { - extra.number-placement - } else { - state("codly-number-placement", __codly-args.number-placement.default).get() - }) - - if number-placement != "inside" and number-placement != "outside" { - panic("codly: number-placement must be either `\"inside\"` or `\"outside\"`") - } - - let numbers-outside = (number-placement == "outside") and numbers-format != none + if skip-last-empty and line.number == line.count and line.text.trim() == "" { + continue + } - let padding = __codly-inset( - (__codly-args.inset.type_check)(if "inset" in extra { - extra.inset + let row-callouts = callouts-by-line.at(str(line.number), default: ()) + let mapped-number = none + let number = line.number + offset + let fill = unnumbered-by-line.at(str(line.number), default: none) + let unnumbered-line = fill != none + let reference = none + if unnumbered-line { + unnumbered-after += 1 + number -= 1 + reference = ( + label: "l" + str(number) + "p" + str(unnumbered-after), + number: number, + suffix: [+#unnumbered-after], + ) + // Keep subsequent displayed numbers contiguous with the prior line. + offset -= 1 + mapped-number = -99999998 } else { - state("codly-inset", __codly-args.inset.default).get() - }), - ) + unnumbered-after = 0 + mapped-number = number + last-number = number + } + let new-annotation = none + if current-annot != none and annotation-cell == none { + let label = if current-annot.label != none { + place(hide(pdf.artifact[#figure( + kind: "codly-referencer", + supplement: none, + numbering: (..) => codly-annotation-ref( + [], + block: block-label, + line: number, + item: if current-annot.content == none { str(annots) } else { current-annot.content }, + suffix: if reference == none { none } else { reference.suffix }, + by: ref-set.by, + ), + [], + )#current-annot.label])) + } else { + [] + } - let breakable = ( - __codly-args.breakable.type_check - )(if "breakable" in extra { - extra.breakable - } else { - state("codly-breakable", __codly-args.breakable.default).get() - }) + new-annotation = __codly-annotation-cell( + codly-annotation, + current-annot.content, + label, + annots, + current-annot.numbering, + ) + } - let fill = (__codly-args.fill.type_check)(if "fill" in extra { - extra.fill - } else { - state("codly-fill", __codly-args.fill.default).get() - }) + let callout-colspan = ( + (if number-enabled { 2 } else { 1 }) + + (if has-annots and current-annot == none { 1 } else { 0 }) + ) + for row in callout-impl.row-cells( + row-callouts.filter(c => c.placement == "above"), + codly-callout, + callout-defaults, + callout-owner, + line.number, + callout-colspan, + ) { + items.push(row) + if new-annotation != none { + annotation-cell = items.len() + items.push(new-annotation) + new-annotation = none + } + lines_to_number.push(-99999997) + rows.push((kind: "callout", source-line: line.number, number: number)) + if indentation != none { guide-depths.push(0) } + if annotation-cell != none { annotation-rows += 1 } + } + lines_to_number.push(mapped-number) + rows.push(( + kind: "code", + source-line: line.number, + number: if unnumbered-line { none } else { number }, + )) + if indentation != none { guide-depths.push(indentation.depths.at(line.number - 1)) } + if number-enabled { + items.push(codly-number(if unnumbered-line { fill } else { number }, parent)) + } - let skips = { - let skips = (__codly-args.skips.type_check)(if "skips" in extra { - extra.skips + // The line's number carries the offset, matching its displayed number. + let numbered = if number == line.number { line } else { + raw.line(number, line.count, line.text, line.body) + } + let line-source = sublang-lines.at(str(line.number), default: none) + let render = if line-source == none { codly-line } else { + __codly-sublang-line.with(codly-line, source: line-source) + } + let pointers = row-callouts.map(c => c.pointer).filter(p => p != none).dedup() + assert( + pointers.all(p => p <= line.text.len()), + message: "codly: callout pointer exceeds source line length", + ) + let rendered-line = render( + numbered, + highlights: if has-highlights { + highlights-by-line.at(str(number), default: ()) + } else { highlights }, + smart-indent: smart-indent, + __wrap: if wrap-settings == none { none } else { wrap-settings + (row: line.number) }, + block-label: block-label, + reference: reference, + __callout: if pointers.len() > 0 { + (owner: callout-owner, row: line.number, pointers: pointers) + }, + ) + if in-first { + in-first = false + rendered-line += lang-block + } + items.push(if has-annots { + grid.cell(colspan: if current-annot != none { 1 } else { 2 }, rendered-line) } else { - state("codly-skips", __codly-args.skips.default).get() + rendered-line }) - if skips == none { - () - } else { - skips.sorted(key: x => x.at(0)).dedup() - } - } - let highlighted-lines = ( - __codly-args.highlighted-lines.type_check - )(if "highlighted-lines" in extra { - extra.highlighted-lines - } else { - state("codly-highlighted-lines", __codly-args.highlighted-lines.default).get() - }); - - let highlighted-default-color = ( - __codly-args.highlighted-default-color.type_check - )(if "highlighted-default-color" in extra { - extra.highlighted-default-color - } else { - state("codly-highlighted-default-color", __codly-args.highlighted-default-color.default).get() - }); - - let highlighted-by-line = () - if highlighted-lines == none { - - } else if type(highlighted-lines) == array { - if highlighted-lines.len() > 0 { - let ix = 1 - for l in highlighted-lines.sorted(key: (x) => if type(x) == int { x } else { x.at(0) }) { - let (ln, col) = if type(l) == int { - (l, highlighted-default-color) - } else if type(l) == array { - assert(l.len() == 2, message: "codly: a highlighted line definition must be an integer or an array of two elements: the line, and the highlight color (array length mismatch)") - let ln = l.at(0) - assert(type(ln) == int, message: "codly: the type of a `highlighted-lines` line must be either an integer, found: " + str(type(ln))); - - let col = l.at(1) - assert( - type(col) == color or type(col) == gradient or type(col) == pattern, - message: "codly: the type of a `highlighted-lines` color must be either a color, a gradient, or a pattern, found: " + str(type(col)) - ) - - (ln, col) - } - - while ix < ln { - ix += 1 - highlighted-by-line.push(none) - } - - highlighted-by-line.push(col) - ix += 1 - } + if new-annotation != none { + annotation-cell = items.len() + items.push(new-annotation) + } + if annotation-cell != none { annotation-rows += 1 } + + for row in callout-impl.row-cells( + row-callouts.filter(c => c.placement == "below"), + codly-callout, + callout-defaults, + callout-owner, + line.number, + callout-colspan, + ) { + items.push(row) + lines_to_number.push(-99999997) + rows.push((kind: "callout", source-line: line.number, number: number)) + if indentation != none { guide-depths.push(0) } + if annotation-cell != none { annotation-rows += 1 } } - } else { - panic("codly: incorrect type for highlighted-lines, this shouldn't happen") } - let has-skips = skips != none and skips != () - let skip-line = ( - __codly-args.skip-line.type_check - )(if "skip-line" in extra { - extra.skip-line - } else { - state("codly-skip-line", __codly-args.skip-line.default).get() - }) - - let skip-number = ( - __codly-args.skip-number.type_check - )(if "skip-number" in extra { - extra.skip-number - } else { - state("codly-skip-number", __codly-args.skip-number.default).get() - }) - - let skip-last-empty = ( - __codly-args.skip-last-empty.type_check - )(if "skip-last-empty" in extra { - extra.skip-last-empty - } else { - state("codly-skip-last-empty", __codly-args.skip-last-empty.default).get() - }) - - let reference-by = ( - __codly-args.reference-by.type_check - )(if "reference-by" in extra { - extra.reference-by - } else { - state("codly-reference-by", __codly-args.reference-by.default).get() - }) - - if not reference-by in ("line", "item") { - panic("codly: reference-by must be either 'line' or 'item'") + if annotation-cell != none { + items.at(annotation-cell) = grid.cell( + rowspan: annotation-rows, + align: left + horizon, + items.at(annotation-cell), + ) } - let reference-sep = ( - __codly-args.reference-sep.type_check - )(if "reference-sep" in extra { - extra.reference-sep - } else { - state("codly-reference-sep", __codly-args.reference-sep.default).get() - }) - - let reference-number-format = ( - __codly-args.reference-number-format.type_check - )(if "reference-number-format" in extra { - extra.reference-number-format - } else { - state("codly-reference-number-format", __codly-args - .reference-number-format - .default).get() - }) + ( + items: items, + lines_to_number: lines_to_number, + rows: rows, + last-number: last-number, + guide-depths: guide-depths, + ) +} - let annotation-format = { - let fn = ( - __codly-args.annotation-format.type_check - )(if "annotation-format" in extra { - extra.annotation-format - } else { - state("codly-annotation-format", __codly-args - .annotation-format - .default).get() - }) - if fn == none { - _ => none - } else { - fn - } - } +#let __codly-annotation-show( + it, +) = { + // Preserve the equation's typography and unbreakable layout for accessible text. + let body = text( + font: "New Computer Modern Math", + weight: 450, + )[#pdf.artifact[$lr(}, size: #it.height)$] + #(it.numbering)(it.num) + #it.body + #it.label] + box(width: measure(body).width, body) +} - let annotations = { - let annotations = ( - __codly-args.annotations.type_check - )(if "annotations" in extra { - extra.annotations - } else { - state("codly-annotations", __codly-args.annotations.default).get() - }) - annotations = if annotations == none or annotations.len() == 0 { - () - } else { - annotations.sorted(key: x => x.start).map(x => { - if (not "end" in x) or x.end == none { - x.insert("end", x.start) +// Share static captures across blocks while keeping the style lookup deferred. +#let __codly-block-render( + get, + block-show, + codly-line, + codly-highlight, + codly-lang, + codly-file, + codly-header, + codly-footer, + codly-number, + codly-annotation, + codly-callout, + codly-bubble, + codly-annotation-ref, + codly-ref, + sublang-block, + constructor, + args, + alias-style, + it, + prepared-lines: none, +) = { + if args.alias == none and args.aliases != none { + if it.lang != none { + if it.lang in args.aliases { + let aliased-args = args + let _ = aliased-args.remove("alias") + let target-lang = args.aliases.at(it.lang) + let raw-args = (:) + let safe-resource(value) = ( + type(value) != str + and ( + type(value) != array or value.all(value => type(value) != str) + ) + ) + if not safe-resource(it.theme) and it.theme != alias-style.theme { + panic( + "codly: aliases cannot safely copy an explicit string `raw.theme`; use `path(\"...\")` or `read(\"...\")`", + ) } - - if (not "content" in x) { - x.insert("content", none) + if not safe-resource(it.syntaxes) and it.syntaxes != alias-style.syntaxes { + panic( + "codly: aliases cannot safely copy explicit string `raw.syntaxes`; use `path(\"...\")` or `read(\"...\")`", + ) } - - if "label" in x { - if block-label == none { - panic("codly: label " + str(x.label) + " is only allowed in a figure block") - } + if safe-resource(it.theme) { raw-args.insert("theme", it.theme) } + if safe-resource(it.syntaxes) { raw-args.insert("syntaxes", it.syntaxes) } + return context { + set text(size: alias-style.size) + constructor( + raw( + it.text, + block: true, + align: it.align, + lang: target-lang, + tab-size: it.tab-size, + ..raw-args, + ), + alias: it.lang, + ..aliased-args, + ) } - - x - }) + } } + } - // Check for overlapping annotations. - let current = none - for a in annotations { - if current != none and a.start <= current.end { - panic("codly: overlapping annotations") + if args.rainbow != none and args.rainbow.enabled { + if args.rainbow.pairs.len() > 0 and it.text.contains(rainbow.delimiters) { + let settings = e.fields(args.rainbow) + let sublangs = args.sublangs + let prepared = args + (rainbow: none, sublangs: none) + // Resolve inherited guide colors before the delimiter pass clears rainbow. + if args.indent-guides != none and args.indent-guides.enabled { + let guides = e.fields(args.indent-guides) + let style = indent.settings(guides, args.rainbow) + prepared.indent-guides = ( + guides + (rainbow: true, palette: style.palette, depth-offset: style.depth-offset) + ) } - current = a + return rainbow.prepare(it, settings, sublangs, lines => block-show( + codly-line, + codly-highlight, + codly-lang, + codly-file, + codly-header, + codly-footer, + codly-number, + codly-annotation, + codly-callout, + codly-bubble, + codly-annotation-ref, + codly-ref, + sublang-block, + constructor, + prepared, + alias-style, + it, + prepared-lines: lines, + )) } - - annotations } - let has-annotations = annotations != none and annotations.len() > 0 - // Get the widest annotation. - let annot-bodies-width = annotations - .map(x => x.content) - .map(measure) - .map(x => x.width) - let num = annotation-format(annotations.len()) - let lr-width = if annotations.len() > 0 { - measure(math.lr("}", size: 5em) + num).width + let lang = if args.alias == none { + it.lang } else { - 0pt + args.alias } - let annot-width = annot-bodies-width.fold( - 0pt, - (a, b) => calc.max(a, b), - ) + padding.left + padding.right + lr-width - - // Get the height of an individual line. - let line-height = measure( - raw.line(1, 1, "X", [X]) - ).height + padding.top + padding.bottom - - let items = () - let lines_to_number = () - let height = measure[1].height - let current-annot = none - let first-annot = false - let annots = 0 - - // prepare the header - let header = (__codly-args.header.type_check)(if "header" in extra { - extra.header - } else { - state("codly-header", __codly-args.header.default).get() - }) - let header-repeat = ( - __codly-args.header-repeat.type_check - )(if "header-repeat" in extra { - extra.header-repeat - } else { - state("codly-header-repeat", __codly-args.header-repeat.default).get() - }) - - // The language block (icon + name) - let languages = ( - __codly-args.languages.type_check - )(if "languages" in extra { - extra.languages - } else { - state("codly-languages", __codly-args.languages.default).get() - }) - - let language-block = if alias == none { - none - } else if alias in languages { - let lang = languages.at(alias) - let name = if type(lang) == str { - lang - } else if display-names and "name" in lang { - lang.name - } else { - [] - } - let icon = if display-icons and "icon" in lang { - lang.icon - } else { - [] - } - let color = if "color" in lang { - lang.color - } else { - default-color - } - - if language-block == auto { - let radius = ( - __codly-args.lang-radius.type_check - )(if "lang-radius" in extra { - extra.lang-radius - } else { - state("codly-lang-radius", __codly-args.lang-radius.default).get() - }) - let padding = __codly-inset( - (__codly-args.lang-inset.type_check)(if "lang-inset" in extra { - extra.lang-inset - } else { - state("codly-lang-inset", __codly-args.lang-inset.default).get() - }), - ) - - let lang-stroke = ( - __codly-args.lang-stroke.type_check - )(if "lang-stroke" in extra { - extra.lang-stroke - } else { - state("codly-lang-stroke", __codly-args.lang-stroke.default).get() - }) - let lang-fill = ( - __codly-args.lang-fill.type_check - )(if "lang-fill" in extra { - extra.lang-fill - } else { - state("codly-lang-fill", __codly-args.lang-fill.default).get() - }) - - let fill = if type(lang-fill) == function { - (lang-fill)((name: name, icon: icon, color: color)) - } else if type(lang-fill) == color or type(lang-fill) == gradient or type(lang-fill) == pattern { - lang-fill - } else { - color - } - - let stroke = if type(lang-stroke) == function { - (lang-stroke)((name: name, icon: icon, color: color)) - } else { - lang-stroke - } - - let sep = if icon != [] and name != [] { - h(0.2em) - } else { - [] - } - let b = measure(icon + sep + name) - box( - radius: radius, - fill: fill, - inset: padding, - stroke: stroke, - outset: 0pt, - height: b.height + padding.top + padding.bottom, - icon + sep + name, - ) - } else { - (language-block)(name, icon, color) - } - } else if display-names { - if language-block == auto { - let radius = ( - __codly-args.lang-radius.type_check - )(if "lang-radius" in extra { - extra.lang-radius - } else { - state("codly-lang-radius", __codly-args.lang-radius.default).get() - }) - - let padding = __codly-inset( - (__codly-args.lang-inset.type_check)(if "lang-inset" in extra { - extra.lang-inset - } else { - state("codly-lang-inset", __codly-args.lang-inset.default).get() - }), + import "file.typ" as file-impl + let badge-position = args.at("lang-position") + if badge-position == auto and args.file != none { badge-position = top + right } + let positioned = badge-position != auto and badge-position != none + if positioned { + assert( + badge-position.x in (left, right, none) and badge-position.y in (top, bottom, none), + message: "codly: lang-position must select left/right and top/bottom", + ) + } + let badge-bottom = positioned and badge-position.y == bottom + let badge-side = if positioned and badge-position.x == left { left } else { right } + let file-position = args.at("file-position") + if file-position != none { + assert( + file-position.x in (left, right, none) and file-position.y in (top, bottom, none), + message: "codly: file-position must select left/right and top/bottom", + ) + } + let file-bottom = file-position != none and file-position.y == bottom + let file-side = if file-position != none and file-position.x == right { right } else { left } + let language-badge = if lang != none and positioned { codly-lang(lang) } + let file-badge = if args.file != none and file-position != none { codly-file(args.file) } + if ( + args.header == none + and ( + (file-badge != none and not file-bottom) + or (positioned and not badge-bottom and language-badge != none) ) + ) { + args.header = [] + } + if ( + args.footer == none + and ((badge-bottom and language-badge != none) or (file-bottom and file-badge != none)) + ) { args.footer = [] } + + // Preserve legacy in-code placement unless an explicit band is requested. + let lang-block = if lang != none and badge-position == auto { + // construct externally to minimize hashing + let lang-block = codly-lang( + lang, + ) - let lang-stroke = ( - __codly-args.lang-stroke.type_check - )(if "lang-stroke" in extra { - extra.lang-stroke - } else { - state("codly-lang-stroke", __codly-args.lang-stroke.default).get() - }) - - let lang-fill = ( - __codly-args.lang-fill.type_check - )(if "lang-fill" in extra { - extra.lang-fill - } else { - state("codly-lang-fill", __codly-args.lang-fill.default).get() - }) - - let fill = if type(lang-fill) == function { - (lang-fill)((name: alias, icon: [], color: default-color)) - } else if type(lang-fill) == color or type(lang-fill) == gradient or type(lang-fill) == pattern { - lang-fill - } else { - default-color - } - - let stroke = if type(lang-stroke) == function { - (lang-stroke)((name: alias, icon: [], color: default-color)) - } else { - lang-stroke - } - - let b = measure(alias) - box( - radius: radius, - fill: fill, - inset: padding, - stroke: stroke, - outset: 0pt, - height: b.height + padding.top + padding.bottom, - alias, - ) - } else { - (language-block)(alias, [], default-color) - } + let lang-settings = get(codly-lang) + place( + lang-settings.align, + dx: lang-settings.outset.x, + // Leading replaces the vertical line inset, so retain enough offset to + // keep the badge inside the code block's top border. + dy: if args.leading == none { lang-settings.outset.y } else { + calc.max(0em, 0.35em - args.leading) + }, + lang-block, + ) } - // Push the line and the language block in a grid for alignment - let language-block-final = place( - right + horizon, - dx: lang-outset.x, - dy: lang-outset.y, - language-block, + let has-annotations = args.annotations != none and args.annotations.len() > 0 + let column-count = ( + (if args.number-enabled { 2 } else { 1 }) + (if has-annotations { 1 } else { 0 }) ) - let lb = measure(language-block) - - let header = if header != none { - let header-args = ( - __codly-args.header-cell-args.type_check - )(if "header-cell-args" in extra { - extra.header-cell-args - } else { - state("codly-header-cell-args", __codly-args - .header-cell-args - .default).get() - }) - let transform = ( - __codly-args.header-transform.type_check - )(if "header-transform" in extra { - extra.header-transform - } else { - state("codly-header-transform", __codly-args - .header-transform - .default).get() - }) + let header-block = if args.header != none { + let fields = if e.eid(args.header) == e.eid(codly-header) { e.fields(args.header) } else { + (body: args.header) + } + let body = fields.remove("body") + let body = if (file-badge != none and not file-bottom) or (positioned and not badge-bottom) { + file-impl.band( + body, + file: if not file-bottom { file-badge }, + file-side: file-side, + language: if badge-bottom { none } else if positioned { language-badge }, + side: badge-side, + ) + } else { body + lang-block } + let header = codly-header(body, ..fields) + let header-set = get(codly-header) + fields + let cell-args = __codly-cell-args( + header-set.align, + header-set.breakable, + if header-set.fill == auto { luma(240) } else { header-set.fill }, + header-set.inset, + header-set.stroke, + ) - lines_to_number.push(-999999999) ( grid.header( - repeat: header-repeat, + repeat: header-set.repeat, grid.cell( - transform(header) + language-block-final, - colspan: if numbers-format == none { 1 } else { 2 }, - align: auto, - breakable: auto, - fill: auto, - inset: auto, + header, + colspan: column-count, rowspan: 1, - stroke: none, - x: auto, - y: auto, - ..header-args, + x: 0, + y: 0, + ..cell-args, ), ), ) } else { - none + () } - let smart-skip = ( - __codly-args.smart-skip.type_check - )(if "smart-skip" in extra { - extra.smart-skip - } else { - state("codly-smart-skip", __codly-args - .smart-skip - .default).get() - }) - - let smart-skip-top = if type(smart-skip) == bool { - smart-skip - } else if "first" in smart-skip { - smart-skip.first - } else if "rest" in smart-skip { - smart-skip.rest - } else { - false - } + let footer-block = if args.footer != none { + let fields = if e.eid(args.footer) == e.eid(codly-footer) { e.fields(args.footer) } else { + (body: args.footer) + } + let body = fields.remove("body") + let body = if badge-bottom or (file-bottom and file-badge != none) { + file-impl.band( + body, + file: if file-bottom { file-badge }, + file-side: file-side, + language: if badge-bottom { language-badge }, + side: badge-side, + ) + } else { body } + let footer = codly-footer(body, ..fields) + let footer-set = get(codly-footer) + fields + let cell-args = __codly-cell-args( + footer-set.align, + footer-set.breakable, + if footer-set.fill == auto { none } else { footer-set.fill }, + footer-set.inset, + footer-set.stroke, + ) - let smart-skip-bot = if type(smart-skip) == bool { - smart-skip - } else if "last" in smart-skip { - smart-skip.last - } else if "rest" in smart-skip { - smart-skip.rest + ( + grid.footer( + repeat: footer-set.repeat, + grid.cell( + footer, + colspan: column-count, + rowspan: 1, + ..cell-args, + ), + ), + ) } else { - false + () } - let smart-skip-rest = if type(smart-skip) == bool { - smart-skip - } else if "rest" in smart-skip { - smart-skip.rest + // Process skips. + let skips = if args.skips != none { + args.skips.sorted(key: x => x.position) } else { - false + () } - let smart-skip-enabled = if type(smart-skip) == bool { - smart-skip - } else { - smart-skip.values().any((v) => v) + // Process range/ranges. + let range = args.range + let ranges = args.ranges + if range != none and ranges != none { + panic("codly: cannot specify both `range` and `ranges`") + } else if range != none { + ranges = (range,) } - let in-skip = false - let in-first = true - let had-first = false - for line in it.lines { - first-annot = false - - // Check for annotations - let annot = annotations.at(0, default: none) - if annot != none and line.number == annot.start { - current-annot = annot - first-annot = true - annots += 1 - } - - // Cleanup annotations - if current-annot != none and line.number > current-annot.end { - current-annot = none - _ = annotations.remove(0) - - // Check for annotations again - let annot = annotations.at(0, default: none) - if annot != none and line.number == annot.start { - current-annot = annot - first-annot = true - annots += 1 - } - } - - // Try and look for a skip - let skip = skips.at(0, default: none) - if skip != none and line.number == skip.at(0) { - if numbers-format != none { - items.push(skip-number) - } + if ranges != none { ranges = __codly-ranges(ranges) } - items.push(skip-line) - lines_to_number.push(-99999999); - // Advance the offset. - offset += skip.at(1) - _ = skips.remove(0) - } else if smart-skip-enabled and not in_range(ranges, line.number) and not in-skip { - if in-first { - if smart-skip-top { - if numbers-format != none { - items.push(skip-number) - } - items.push(skip-line) - lines_to_number.push(-99999999); - } - } else if array.range(line.number, line.count).any((i) => in_range(ranges, i)) { - if smart-skip-rest { - if numbers-format != none { - items.push(skip-number) - } - items.push(skip-line) - lines_to_number.push(-99999999); - } - } else { - if smart-skip-bot { - if numbers-format != none { - items.push(skip-number) - } - items.push(skip-line) - lines_to_number.push(-99999999); - } - } - } - - // Don't include if not in range - if not in_range(ranges, line.number) { - in-skip = true - continue - } else { - in-skip = false + let annotations = if args.annotations == none { () } else { + args.annotations.sorted(key: annot => -annot.start) + } + let previous = none + for annot in annotations { + if args.block-label == none and annot.label != none { + panic( + "codly: annotations with labels (" + str(annot.label) + ") require `block-label` to be set", + ) } - - in-first = false - - // Remove the last line if it's empty - if skip-last-empty and line.text.trim().len() == 0 and line.number == line.count { - continue + if previous != none and annot.end >= previous { + panic("codly: overlapping annotations") } + previous = annot.start + } - // Always push the formatted line number - let l = figure( - kind: "__codly-raw-line", - numbering: none, - placement: none, - outlined: false, - gap: 0pt, - caption: none, - [#raw.line( - line.number + offset, - line.count, - line.text, - box(height: height, width: 0pt) + line.body, - ) ] - ) - - lines_to_number.push(line.number + offset) + // handle number formatting + let numbers-format = if args.number-enabled { + codly-number + } else { + none + } - // Must be done before the smart indentation code. - // Otherwise it results in two paragraphs. - if numbers-format != none { - items.push(numbers-format(line.number + offset)) + // handle offset and `offset-from`: + let offset = args.offset + if args.offset-from != none { + let last-number = __codly-block-info(args.offset-from).last-number + if last-number != none { + offset += last-number } + } - let annot = none - if current-annot != none and first-annot { - let height = line-height * (current-annot.end - current-annot.start + 1) - let num = annotation-format(annots) - let label = if "label" in current-annot and current-annot.label != none { - let referenced = if reference-by == "line" { - reference-number-format(line.number + offset) - } else { - num + let highlighted-by-line = (:) + if args.highlighted != none { + let default-fill = auto + for hl in args.highlighted { + let fill = hl.color + if fill == none { + if default-fill == auto { + let settings = get(codly-highlight) + default-fill = (settings.fill)(settings.color) } - - place(hide[#figure( - kind: "codly-referencer", - supplement: none, - numbering: (..) => { - ref(block-label) - reference-sep - referenced - }, - [] - )#current-annot.label]) - } else { - none + fill = default-fill } + highlighted-by-line.insert(str(hl.line), fill) + } + } - let annot-content = { - $lr(}, size: #height) #num #current-annot.content #label$ - } + let ref-set = if annotations.len() > 0 and args.block-label != none { + let settings = get(codly-ref) + (by: settings.by, sep: settings.sep, numbering: settings.numbering) + } - annot = grid.cell( - rowspan: current-annot.end - current-annot.start + 1, - align: left + horizon, - breakable: auto, - fill: auto, - inset: auto, - colspan: 1, - stroke: none, - x: auto, - y: auto, - annot-content, + // Resolve sublanguage syntax through metadata, retaining the original source + // lines for range/skip/annotation processing. The line loop resolves each + // displayed line before codly-line applies its character-level formatting. + let output-blocks = () + let sublang-lines = (:) + let lines = if prepared-lines == none { it.lines } else { prepared-lines } + if args.sublangs != none and args.sublangs.len() > 0 { + let nl-regex = regex("(\r\n|\r|\n)") + let raw-text-lines = it.text.split(nl-regex) + for (idx, s) in args.sublangs.enumerate() { + let source-lines = raw-text-lines.slice(s.start - 1, s.end) + let new-block = raw( + source-lines.join("\n"), + block: true, + lang: s.lang, + align: it.align, + tab-size: it.tab-size, + theme: it.theme, + syntaxes: it.syntaxes, ) - } - if had-first or ( - display-names != true and display-icons != true - ) { - if annot == none and has-annotations { - items.push(grid.cell( - l, - colspan: if current-annot == none { 2 } else { 1 }, - align: auto, - breakable: auto, - fill: auto, - inset: auto, - rowspan: 1, - stroke: none, - x: auto, - y: auto, - )) - } else if annot == none { - items.push(l) - } else { - items.push(l) - items.push(annot) - } - continue - } else if alias == none { - if annot == none and has-annotations { - items.push(grid.cell( - l, - colspan: if current-annot == none { 2 } else { 1 }, - align: auto, - breakable: auto, - fill: auto, - inset: auto, - rowspan: 1, - stroke: none, - x: auto, - y: auto, - )) - } else if annot == none { - items.push(l) - } else { - items.push(l) - items.push(annot) - } - continue - } + output-blocks.push(sublang-block(new-block, idx)) + let idx = str(idx) - had-first = true - if has-annotations { - if header != none { - if annot == none and has-annotations { - items.push(grid.cell( - l, - colspan: if current-annot == none { 2 } else { 1 }, - align: auto, - breakable: auto, - fill: auto, - inset: auto, - rowspan: 1, - stroke: none, - x: auto, - y: auto, - )) - } else if annot == none { - items.push(annot) - } else { - items.push(l) - items.push(annot) - } - } else { - // Annotation printing - if annot == none and has-annotations { - items.push(grid.cell( - grid( - columns: (1fr, lb.width + padding.left + padding.right), - align: left, - column-gutter: 0pt, - fill: none, - gutter: 0pt, - inset: 0pt, - row-gutter: 0pt, - stroke: none, - l, language-block-final, - ), - colspan: if current-annot == none { 2 } else { 1 }, - align: auto, - breakable: auto, - fill: auto, - inset: auto, - rowspan: 1, - stroke: none, - x: auto, - y: auto, - )) - } else if annot == none { - items.push(grid( - columns: (1fr, lb.width + padding.left + padding.right), - align: left, - column-gutter: 0pt, - fill: none, - gutter: 0pt, - inset: 0pt, - row-gutter: 0pt, - stroke: none, - l, language-block-final, - )) - items = (..items, grid( - columns: (1fr, lb.width + padding.left + padding.right), - align: left, - column-gutter: 0pt, - fill: none, - gutter: 0pt, - inset: 0pt, - row-gutter: 0pt, - stroke: none, - l, language-block-final, - )) - } else { - items.push(grid( - columns: (1fr, lb.width + padding.left + padding.right), - align: left, - column-gutter: 0pt, - fill: none, - gutter: 0pt, - inset: 0pt, - row-gutter: 0pt, - stroke: none, - l, language-block-final, - )) - items.push(annot) - } - } - } else { - if header != none { - items.push(l) - } else { - items.push( - grid( - columns: (1fr, lb.width + padding.left + padding.right), - align: left, - column-gutter: 0pt, - fill: none, - gutter: 0pt, - inset: 0pt, - row-gutter: 0pt, - stroke: none, - l, language-block-final, - ), + for i in std.range(source-lines.len()) { + sublang-lines.insert( + str(s.start + i), + label("__codly_sublang_line_" + idx + "_" + str(i)), ) } } } - // prepare the footer - let footer = (__codly-args.footer.type_check)(if "footer" in extra { - extra.footer - } else { - state("codly-footer", __codly-args.footer.default).get() - }) - let footer-repeat = ( - __codly-args.footer-repeat.type_check - )(if "footer-repeat" in extra { - extra.footer-repeat - } else { - state("codly-footer-repeat", __codly-args.footer-repeat.default).get() - }) - let footer = if footer != none { - let footer-args = ( - __codly-args.footer-cell-args.type_check - )(if "footer-cell-args" in extra { - extra.footer-cell-args - } else { - state("codly-footer-cell-args", __codly-args - .footer-cell-args - .default).get() - }) - let transform = ( - __codly-args.footer-transform.type_check - )(if "footer-transform" in extra { - extra.footer-transform - } else { - state("codly-footer-transform", __codly-args - .footer-transform - .default).get() - }) - - ( - grid.footer( - repeat: footer-repeat, - grid.cell( - transform(footer), - colspan: if numbers-format == none { 1 } else { 2 }, - align: auto, - breakable: auto, - fill: auto, - inset: auto, - rowspan: 1, - stroke: none, - x: auto, - y: auto, - ..footer-args - ), - ), - ) - } else { - () + // Handling of `smart-skip` + let smart-skip = args.smart-skip + let guides = args.indent-guides + let wrap-settings = if args.smart-indent and args.wrap-marker != none and args.wrap-marker != [] { + (owner: here(), marker: args.wrap-marker) } - - // If the fill or zebra color is a gradient, we will draw it on a separate layer. - let is-complex-fill = ( - (type(fill) != color and fill != none) or ( - type(zebra-color) != color and zebra-color != none - ) - ) - - let width_lines_number = calc.max(2, (calc.ceil(calc.log(it.lines.len())) + 1)) * 1em - - let line_colors = () - for (i, line) in lines_to_number.enumerate() { - let highlighted = highlighted-by-line.at(line - 1, default: none) - if highlighted != none { - line_colors.push(highlighted) - } else if zebra-color != none and calc.rem(i, 2) == 0 { - line_colors.push(zebra-color) - } else { - line_colors.push(fill) - } + let indentation = if guides != none and guides.enabled { + indent.scan(lines.map(l => l.text), width: guides.width, blank-lines: guides.blank-lines) } - - let block_content = block( - breakable: breakable, - clip: true, - width: 100%, - radius: radius, - stroke: if numbers-outside { none } else { stroke }, - { - if is-complex-fill { - // We use place to draw the fill on a separate layer. - place( - grid( - columns: if has-annotations { - (1fr, annot-width) - } else { - (1fr,) - }, - stroke: none, - inset: padding.pairs().map(((k, x)) => (k, x * 1.5)).to-dict(), - fill: (x, y) => if zebra-color != none and calc.rem(y, 2) == 0 { - zebra-color - } else { - fill - }, - ..header, - ..it.lines.map(line => hide(line)), - ..footer, - ), - ) - } - - if numbers-format != none { - grid( - columns: if has-annotations { - (auto, 1fr, annot-width) - } else { - (auto, 1fr) - }, - inset: padding.pairs().map(((k, x)) => (k, x * 1.5)).to-dict(), - stroke: (x,y) => - if numbers-outside { - let idx_end = if has-annotations { - 2 - } else { - 1 - } - - ( - left: if x == 1 { stroke } else { none }, - right: if x == idx_end { stroke } else { none }, - top: if x != 0 and y == 0 { stroke } else { none }, - bottom: if x != 0 and y == it.lines.len() - 1 { stroke } else { none }, - ) - } else { - none - }, - align: (numbers-alignment, left + horizon), - fill: if is-complex-fill { - none - } else { - (x, y) => if numbers-outside and x == 0 { - none - } else { - line_colors.at(y, default: fill) - } - }, - column-gutter: 0pt, - gutter: 0pt, - row-gutter: 0pt, - ..header, - ..items, - ..footer, - ) - } else { - grid( - columns: if has-annotations { - (1fr, annot-width) - } else { - (1fr) - }, - inset: padding.pairs().map(((k, x)) => (k, x * 1.5)).to-dict(), - stroke: none, - align: (numbers-alignment, left + horizon), - fill: (x, y) => line_colors.at(y, default: if zebra-color != none and calc.rem(y, 2) == 0 { - zebra-color - } else { - fill - }), - column-gutter: 0pt, - gutter: 0pt, - row-gutter: 0pt, - ..header, - ..items, - ..footer, - ) - } - }, + let ( + items: items, + lines_to_number: lines_to_number, + rows: rows, + last-number: last-number, + guide-depths: guide-depths, + ) = __codly-line-loop( + codly-line, + codly-number, + smart-skip, + lines, + annotations, + args.callouts, + ranges, + skips, + args.skip-last-empty, + args.number-enabled, + args.skip-line, + args.skip-number, + codly-annotation, + codly-callout, + codly-bubble, + codly-annotation-ref, + ref-set, + args.highlights, + args.smart-indent, + args.at("block-label", default: none), + offset, + if args.header == none { lang-block } else { [] }, + sublang-lines: sublang-lines, + get: get, + indentation: indentation, + wrap-settings: wrap-settings, + unnumbered: args.unnumbered, + parent: it, ) - block_content - - figure( - kind: "__codly-end-block", - supplement: none, - numbering: none, - placement: none, - outlined: false, - gap: 0pt, - caption: none, - )[] - - if offset != 0 { - state("codly-offset").update(0) + // The header counts as a displayed row for palette compatibility. + if args.header != none { + lines_to_number.insert(0, -999999999) + rows.insert(0, (kind: "header", source-line: none, number: none)) + if indentation != none { guide-depths.insert(0, 0) } } - - if offset-from != none { - state("codly-offset-from").update(none) - } - - if range != none { - state("codly-range").update(none) + if args.footer != none { + rows.push((kind: "footer", source-line: none, number: none)) } - if ranges != none { - state("codly-ranges").update(none) + let indexed-rows = () + for (index, row) in rows.enumerate() { + indexed-rows.push(row + (index: index)) } + rows = indexed-rows - if has-skips { - state("codly-skips").update(()) - } + let get-line = get(codly-line) + let fill = if get-line.fill == auto { __default("fill") } else { get-line.fill } - if has-annotations { - state("codly-annotations").update(()) - } + let line-colors = rows.map(row => { + // Header/footer cells own their fills and never invoke the line callback. + if row.kind in ("header", "footer") { return none } + let highlighted = if row.number == none { none } else { + highlighted-by-line.at(str(row.number), default: none) + } + if highlighted != none { highlighted } else { __codly-row-fill(fill, row) } + }) - if header != () { - state("codly-header").update(none) + let number-settings = get(codly-number) + let numbers-outside = number-settings.placement == "outside" + let numbers-enabled = args.number-enabled + let annot-width = auto + let line-padding = __codly-inset(get-line.inset) + let padding = if args.leading == none { + line-padding + } else { + ( + top: args.leading, + right: line-padding.right, + bottom: args.leading, + left: line-padding.left, + ) } - - if footer != () { - state("codly-footer").update(none) + let grid-inset = ( + top: padding.top * 1.5, + right: padding.right * 1.5, + bottom: padding.bottom * 1.5, + left: padding.left * 1.5, + ) + let gutters = (:) + if args.gutter != none { gutters.insert("gutter", args.gutter) } + if args.column-gutter != none { gutters.insert("column-gutter", args.column-gutter) } + if args.row-gutter != none { gutters.insert("row-gutter", args.row-gutter) } + let numbers-alignment = number-settings.align + let outside-column = numbers-enabled and numbers-outside + let number-fill = number-settings.fill + let cell-fill = (x, y) => if numbers-enabled and x == 0 and number-fill == none { + none + } else { + let code-fill = line-colors.at(y, default: none) + if numbers-enabled and x == 0 { + if number-fill == auto { code-fill } else { number-fill } + } else { + code-fill + } } - - let highlights = state("codly-highlights").get() - if highlights != none and highlights != () { - state("codly-highlights").update(()) + let stroke = get-line.stroke + let stroke-inset = if stroke == none { 0pt } else if stroke.thickness == auto { 0.5pt } else { + stroke.thickness / 2 } - let highlighted = state("codly-highlighted-lines").get() - if highlighted != none and highlighted != () { - state("codly-highlighted-lines").update(()) + let grid-content = (intrinsic: false) => { + let code-width = if intrinsic { auto } else { 1fr } + set block(breakable: true) + if ( + outside-column + or wrap-settings != none + or (indentation != none and guide-depths.any(d => d > 0)) + ) { + geometry.outside( + (if args.number-enabled { (auto,) } else { () }) + + (code-width,) + + (if has-annotations { (annot-width,) } else { () }), + grid-inset, + (numbers-alignment, left + horizon), + cell-fill, + if outside-column { stroke } else { none }, + if outside-column { args.radius } else { 0pt }, + header-block, + items, + footer-block, + code-column: if args.number-enabled { 1 } else { 0 }, + outside: outside-column, + wraps: wrap-settings, + ..gutters, + guides: if indentation == none { none } else { + ( + indent.settings(guides, args.rainbow) + + ( + depths: guide-depths, + width: indentation.width, + // Raw code uses a monospaced font, so guide positions are + // integral space advances. Measure that advance once per + // block instead of once for every row and nesting level. + step: measure(text(" " * indentation.width)).width, + inset: grid-inset.left, + // Unindented continuation text must not run through a guide. + max-height: if args.smart-indent { none } else { + ( + measure[1].height + + grid-inset.top.to-absolute() + + grid-inset.bottom.to-absolute() + ) + }, + ) + ) + }, + ) + } else if numbers-format != none { + grid( + columns: if has-annotations { + (auto, code-width, annot-width) + } else { + (auto, code-width) + }, + inset: grid-inset, + stroke: none, + align: (numbers-alignment, left + horizon), + fill: cell-fill, + ..gutters, + ..header-block, + ..items, + ..footer-block, + ) + } else { + grid( + columns: if has-annotations { + (code-width, annot-width) + } else { + code-width + }, + inset: grid-inset, + stroke: none, + align: (numbers-alignment, left + horizon), + fill: cell-fill, + ..gutters, + ..header-block, + ..items, + ..footer-block, + ) + } } -} -#let __codly-get-parts(fig) = { - let num = fig.body.children.at(0) - let lbl = fig.body.children.at(1) - (num.body, label(lbl.body.text)) -} + let render-block = (width, intrinsic: false) => block( + breakable: args.breakable, + clip: not outside-column, + width: width, + radius: args.radius, + stroke: if outside-column { none } else { get-line.stroke }, + inset: stroke-inset, + outset: if outside-column { 0pt } else { -stroke-inset }, + grid-content(intrinsic: intrinsic), + ) -/// Initializes the codly show rule. -/// -/// ```typ -/// #show: codly-init -/// ``` -/// -/// - body (content): the body of the document -/// -/// -> content -#let codly-init( - body, -) = { - show figure.where(kind: raw): fig => { - if fig.has("label") { - show raw.where(block: true): it => __codly-raw(it, block-label: fig.label) - fig - } else { - fig - } + let block_content = if args.width == auto { + layout(size => { + // A fractional code column collapses during an unconstrained measure. + // Use an auto code column for the natural width, then cap the complete + // rendered block (grid insets, number/annotation columns, and border) + // to the width available after the surrounding margins. + let natural-width = measure(render-block(auto, intrinsic: true)).width + render-block(calc.min(natural-width, size.width)) + }) + } else { + render-block(args.width) } - show raw.where(block: true): it => __codly-raw(it) + // Empty native reference anchors need no figure layout. + show figure.where(kind: "codly-line"): it => { + set align(left + horizon) + it.body + } - body -} + set par(justify: false, first-line-indent: 0pt) -/// Allows setting codly setting locally. -/// Anything that happens inside the block will have the settings applied only to it. -/// The pre-existing settings will be restored after the block. This is useful -/// if you want to apply settings to a specific block only. -/// -/// #pre-example() -/// #example(````` -/// *Special:* -/// #local(default-color: red)[ -/// ``` -/// Hello, world! -/// ``` -/// ] -/// -/// *Normal:* -/// ``` -/// Hello, world! -/// ``` -/// `````, mode: "markup", scale-preview: 100%) -#let __local-inner(body, nested: false, ..args) = { - assert(type(nested) == bool, message: "local: nested must be a boolean, found: " + str(type(nested))) - if nested { - let extra = args.named() - context { - let current = state("codly-extra-args", (:)).get() - state("codly-extra-args", (:)).update(old => { - old + extra - }) - - let args = current + extra - show raw.where(block: true): it => __codly-raw(it, extra: args) - show figure.where(kind: raw): fig => { - if fig.has("label") { - show raw.where(block: true, extra: args): it => __codly-raw(it, block-label: fig.label) - fig - } else { - fig - } - } + output-blocks.join() + block_content - body - state("codly-extra-args").update(current) - } - } else { - let extra = args.named() - state("codly-extra-args").update(extra) - show raw.where(block: true): it => __codly-raw(it, extra: extra) - show figure.where(kind: raw): fig => { - if fig.has("label") { - show raw.where(block: true, extra: args): it => __codly-raw(it, block-label: fig.label) - fig - } else { - fig - } - } - body - state("codly-extra-args").update((:)) - } + [#metadata((last-number: last-number, lines: it.lines.len()))<__codly-block>] } +#let __codly-show( + codly-line, + codly-highlight, + codly-lang, + codly-file, + codly-header, + codly-footer, + codly-number, + codly-annotation, + codly-callout, + codly-bubble, + codly-annotation-ref, + codly-ref, + sublang-block, + constructor, + args, + alias-style, + it, + prepared-lines: none, +) = e.get(get => __codly-block-render( + get, + __codly-show, + codly-line, + codly-highlight, + codly-lang, + codly-file, + codly-header, + codly-footer, + codly-number, + codly-annotation, + codly-callout, + codly-bubble, + codly-annotation-ref, + codly-ref, + sublang-block, + constructor, + args, + alias-style, + it, + prepared-lines: prepared-lines, +)) + #let typst-icon = ( typ: ( name: "Typst", - icon: box( + icon: pdf.artifact(box( image("typst-small.png", height: 0.8em), baseline: 0.1em, inset: 0pt, outset: 0pt, - ), + )), color: rgb("#239DAD"), ), typc: ( name: "Typst code", - icon: box( + icon: pdf.artifact(box( image("typst-small.png", height: 0.8em), baseline: 0.1em, inset: 0pt, outset: 0pt, - ), + )), color: rgb("#239DAD"), ), ) diff --git a/src/rainbow.typ b/src/rainbow.typ new file mode 100644 index 0000000..1b546dd --- /dev/null +++ b/src/rainbow.typ @@ -0,0 +1,272 @@ +// R's backtick identifiers have no string scope; use its explicit bracket scopes. +#let code-scopes = "source - source.r, source.r punctuation.section, meta.interpolation, punctuation.typst, punctuation.math.typst" +#let ignore-scopes = "string, comment, constant.character, markup.raw" +#let delimiters = regex("[()\\[\\]{}]") +#let singletons = ( + "(": ((start: 0, text: "("),), + ")": ((start: 0, text: ")"),), + "[": ((start: 0, text: "["),), + "]": ((start: 0, text: "]"),), + "{": ((start: 0, text: "{"),), + "}": ((start: 0, text: "}"),), +) +#let palette = ( + rgb("#b04080"), + rgb("#996300"), + rgb("#267a45"), + rgb("#007f99"), + rgb("#425cc7"), + rgb("#8655a8"), +) + +// A private theme marks code with strong, independently of the display theme. +#let theme(code, ignored) = { + let escape(s) = s.replace("&", "&").replace("<", "<").replace(">", ">") + bytes( + "settings" + + "settingsforeground#000000" + + "scope" + + escape(code) + + "settingsfontStylebold" + + "scope" + + escape(ignored) + + "settingsfontStyle" + + "", + ) +} + +// Match while walking classified runs; only delimiter positions survive. +#let scan(lines, pairs) = { + let openers = (:) + let closers = "" + for pair in pairs { + openers.insert(pair.first(), pair.last()) + closers += pair.last() + } + let stack = () + let result = (:) + for line in lines { + let key = str(line.number) + let marks = () + let offset = 0 + let pieces = if line.body.has("children") { line.body.children } else { (line.body,) } + for piece in pieces { + let eligible = false + while not piece.has("text") { + if piece.func() == strong { + eligible = true + piece = piece.body + } else if piece.has("child") { piece = piece.child } else { break } + } + let value = piece.at("text", default: "") + let size = value.len() + if eligible { + let found = if size == 1 { singletons.at(value, default: ()) } else if value.contains( + delimiters, + ) { value.matches(delimiters) } else { () } + for found in found { + let symbol = found.text + let depth = none + if symbol in openers { + depth = stack.len() + stack.push((openers.at(symbol), key, marks.len())) + } else if closers.contains(symbol) { + if stack.len() > 0 and stack.last().first() == symbol { + let _ = stack.pop() + depth = stack.len() + } + } else { continue } + marks.push((offset + found.start, depth)) + } + } + offset += size + } + if marks.len() > 0 { result.insert(key, marks) } + } + for (_, line, index) in stack { result.at(line).at(index).at(1) = none } + result +} + +#let replace-leaf(piece, body) = { + if piece.has("text") { body } else if piece.has("child") { + (piece.func())(replace-leaf(piece.child, body), piece.styles) + } else { + let fields = piece.fields() + fields.body = replace-leaf(fields.body, body) + (piece.func())(..fields) + } +} + +#let paint(body, marks, colors, depth-offset, unmatched) = { + let pieces = if body.has("children") { body.children } else { (body,) } + let output = () + let offset = 0 + let next = 0 + for original in pieces { + if next == marks.len() { + output.push(original) + continue + } + let piece = original + while not piece.has("text") { + if piece.has("child") { piece = piece.child } else { piece = piece.body } + } + let value = piece.text + let end = offset + value.len() + if marks.at(next).first() < end { + let parts = () + let start = 0 + while next < marks.len() and marks.at(next).first() < end { + let (position, depth) = marks.at(next) + let color = if depth == none { unmatched } else { + colors.at(calc.rem(depth + depth-offset, colors.len())) + } + if color != none { + let at = position - offset + if at > start { parts.push(text(value.slice(start, at))) } + parts.push(text(fill: color, value.slice(at, at + 1))) + start = at + 1 + } + next += 1 + } + if start == 0 { output.push(original) } else { + if start < value.len() { parts.push(text(value.slice(start))) } + output.push(replace-leaf(original, parts.join())) + } + } else { output.push(original) } + offset = end + } + output.join() +} + +#let capture(body, origin, key, pairs: none) = { + show raw: it => [#metadata(( + origin: origin, + key: key, + value: if pairs == none { it.lines } else { scan(it.lines, pairs) }, + ))<__codly-rainbow>] + body +} + +#let resource(value, inherited, name) = { + let safe = type(value) != str and (type(value) != array or value.all(v => type(v) != str)) + assert( + safe or value == inherited, + message: "codly: rainbow cannot copy an explicit string `raw." + + name + + "`; use `path(...)` or `read(..., encoding: none)`", + ) + if safe { ((name): value) } else { (:) } +} + +#let paint-lines(lines, marks, settings) = { + for (key, positions) in marks { + let i = int(key) - 1 + let line = lines.at(i) + lines.at(i) = raw.line(line.number, line.count, line.text, paint( + line.body, + positions, + settings.palette, + settings.depth-offset, + settings.unmatched, + )) + } + lines +} + +// Single-language blocks render directly; sublanguages share one metadata query. +#let prepare(source, settings, sublangs, render) = context { + let origin = here() + let syntax = resource(source.syntaxes, raw.syntaxes, "syntaxes") + let classifier = theme(settings.code-scopes, settings.ignore-scopes) + let parent-text = source.text + let segments = if sublangs == none { () } else { sublangs } + if segments.len() == 0 { + let size = text.size + let font = text.font + return { + show raw: it => { + set text(size: size, font: font) + render(paint-lines(source.lines, scan(it.lines, settings.pairs), settings)) + } + raw( + parent-text, + block: true, + lang: source.lang, + tab-size: source.tab-size, + theme: classifier, + ..syntax, + ) + } + } + if segments.len() > 0 { + let rows = source.text.split(regex("\r\n|\r|\n")) + let parent = rows + let display-theme = resource(source.theme, raw.theme, "theme") + for (index, segment) in segments.enumerate() { + let code = rows.slice(segment.start - 1, segment.end).join("\n") + for i in range(segment.start - 1, segment.end) { parent.at(i) = "" } + capture( + raw( + code, + block: true, + lang: segment.lang, + tab-size: source.tab-size, + theme: classifier, + ..syntax, + ), + origin, + "marks-" + str(index), + pairs: settings.pairs, + ) + capture( + raw( + code, + block: true, + lang: segment.lang, + tab-size: source.tab-size, + ..display-theme, + ..syntax, + ), + origin, + "lines-" + str(index), + ) + } + parent-text = parent.join("\n") + } + capture( + raw( + parent-text, + block: true, + lang: source.lang, + tab-size: source.tab-size, + theme: classifier, + ..syntax, + ), + origin, + "parent", + pairs: settings.pairs, + ) + context { + let records = (:) + for record in query(selector(<__codly-rainbow>).after(origin).before(here())) { + if record.value.origin == origin { records.insert(record.value.key, record.value.value) } + } + // Speculative measurement cannot see its own metadata; color changes no metrics. + if records.len() != 1 + 2 * segments.len() { return render(source.lines) } + let lines = source.lines + let marks = records.at("parent") + for (index, segment) in segments.enumerate() { + let local-marks = records.at("marks-" + str(index)) + for (i, line) in records.at("lines-" + str(index)).enumerate() { + let number = segment.start + i + lines.at(number - 1) = raw.line(number, lines.len(), line.text, line.body) + let key = str(number) + if key in marks { let _ = marks.remove(key) } + let found = local-marks.at(str(i + 1), default: ()) + if found.len() > 0 { marks.insert(key, found) } + } + } + render(paint-lines(lines, marks, settings)) + } +} diff --git a/src/wrap.typ b/src/wrap.typ new file mode 100644 index 0000000..99ba800 --- /dev/null +++ b/src/wrap.typ @@ -0,0 +1,131 @@ +// Observe native line breaking with zero-width tags, without replacing source +// text, adding break opportunities, or splitting grapheme clusters. +#let standalone = regex("[\\p{Han}\\p{Hiragana}\\p{Katakana}\\p{Hangul}\\p{P}\\p{S}]") + +#let annotate(body, owner, row) = { + let probe(s) = [#text(s)#context [#metadata(( + owner: owner, + row: row, + width: measure(text(s)).width, + ))<__codly-wrap-point>]] + let visit(body) = { + if body.has("text") { + let parts = () + let token = "" + // raw.line.text has normalized tabs to ASCII spaces. Avoid `trim()` on + // every cluster; it allocated on the hottest wrap-marker path. + for c in body.text.clusters() { + let space = c == " " + let ascii-word = ( + (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or (c >= "0" and c <= "9") or c == "_" + ) + let separate = space or (not ascii-word and c.contains(standalone)) + if separate and token != "" { + parts.push(probe(token)) + token = "" + } + if space { parts.push(text(c)) } else if separate { parts.push(probe(c)) } else { + token += c + } + } + if token != "" { parts.push(probe(token)) } + parts.join() + } else if body.has("children") { + body.children.map(visit).join() + } else if body.has("child") and body.has("styles") { + (body.func())(visit(body.child), body.styles) + } else if body.has("body") { + let fields = body.fields() + fields.body = visit(fields.body) + (body.func())(..fields) + } else { body } + } + visit(body) +} + +// One query pair per block, shared by all of its page/column backgrounds. +#let marks(owner, end, regions) = { + let rows = (:) + for record in query(selector(<__codly-wrap-row>).after(owner).before(end)) { + if record.value.owner == owner { + rows.insert(str(record.value.row), record.value) + } + } + let previous = (:) + let result = () + let by-page = (:) + for r in regions { + let key = str(r.at.page) + by-page.insert(key, by-page.at(key, default: ()) + (r,)) + } + for point in query(selector(<__codly-wrap-point>).after(owner).before(end)) { + if point.value.owner != owner { continue } + let key = str(point.value.row) + let row = rows.at(key, default: none) + if row == none { continue } + let at = point.location().position() + let left = at.x - point.value.width + let region = by-page + .at(str(at.page), default: ()) + .find(r => r.at.x <= left and left < r.at.x + r.width and r.at.y <= at.y and at.y <= r.bottom) + let region-key = if region == none { none } else { (region.at.x, region.at.y) } + let before = previous.at(key, default: none) + if ( + before != none + and ( + at.page != before.at.page + or region-key != before.region + or ( + at.y - before.at.y > row.tolerance and left < before.at.x - 0.001pt + ) + ) + ) { + result.push((at: (page: at.page, x: left - row.advance, y: at.y), body: row.marker)) + } + previous.insert(key, (at: at, region: region-key)) + } + result +} + +// Paint a single footer region without reconstructing every cell region. The +// points are still visited in document order so continuations from a previous +// page or column are detected correctly. +#let marks-region(owner, end, region) = { + let rows = (:) + for record in query(selector(<__codly-wrap-row>).after(owner).before(end)) { + if record.value.owner == owner { + rows.insert(str(record.value.row), record.value) + } + } + let previous = (:) + let result = () + for point in query(selector(<__codly-wrap-point>).after(owner).before(end)) { + if point.value.owner != owner { continue } + let key = str(point.value.row) + let row = rows.at(key, default: none) + if row == none { continue } + let at = point.location().position() + let left = at.x - point.value.width + let before = previous.at(key, default: none) + let inside = ( + at.page == region.page + and left >= region.x + and left < region.x + region.width + and at.y >= region.top + and at.y < region.bottom + ) + if ( + inside + and before != none + and ( + at.page != before.page + or at.y < before.y - row.tolerance + or (at.y - before.y > row.tolerance and left < before.x - 0.001pt) + ) + ) { + result.push((at: (page: at.page, x: left - row.advance, y: at.y), body: row.marker)) + } + previous.insert(key, (page: at.page, x: left, y: at.y)) + } + result +} diff --git a/tests/accessibility/test.typ b/tests/accessibility/test.typ new file mode 100644 index 0000000..b7602ed --- /dev/null +++ b/tests/accessibility/test.typ @@ -0,0 +1,55 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 240pt, height: auto, margin: 5pt) +#set document(title: "Codly accessibility") +#set text(lang: "en") + +// Filename and language badges remain accessible under their real elements. +#codly.new("filename source", file: "accessible.txt", lang-position: bottom + right) + +// Plain code remains a normal accessible text block. +#codly.new(raw("plain source", block: true)) + +// Forward references exercise the native figure anchors used by lines, +// highlights, and annotations. The language badge also includes its icon. +@code:1 @marked @note +#figure(caption: [Accessible code])[ + #codly.new( + raw("fn main() {\n return 1\n}", block: true, lang: "typ"), + block-label: , + highlights: ((line: 1, label: ),), + annotations: ((start: 2, content: [Returns one], label: ),), + ) +] + +#{ + show: e.set_(codly.codly-number, placement: "outside") + show: codly.line-set_(stroke: blue + 1pt) + codly.new(raw("outside\nsecond", block: true), radius: 8pt, annotations: ( + (start: 1, end: 2, content: [Remark]), + )) +} + +#context { + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(query(figure.where(kind: "codly-line")).len(), 3) +} + +// Guides are decorative strokes; source remains searchable accessible text. +#codly.new(raw("guided source\n guided child", block: true), indent-guides: ( + rainbow: true, + palette: (purple, blue), +)) + +// Continuation markers are decorative artifacts; the source remains text. +#codly.new(raw("wrapped source " + "argument " * 15, block: true), wrap-marker: [↪]) + +// The single bubble outline is decorative; its body stays accessible text. +#codly.new(raw("bubble source extended", block: true), callouts: ( + (line: 1, pointer: 8, body: [Accessible bubble], bubble-fill: gradient.linear(aqua, yellow)), + (line: 1, placement: "above", pointer: 1, body: [Above left], bubble-width: 90pt), + (line: 1, placement: "above", pointer: 22, body: [Above right], bubble-width: 90pt), +)) diff --git a/tests/aliases/99-local-resources.sublime-syntax b/tests/aliases/99-local-resources.sublime-syntax new file mode 100644 index 0000000..f871402 --- /dev/null +++ b/tests/aliases/99-local-resources.sublime-syntax @@ -0,0 +1,9 @@ +%YAML 1.2 +--- +name: Codly Alias Test +file_extensions: [codly-alias-test] +scope: source.codly-alias-test +contexts: + main: + - match: '\bkeyword\b' + scope: keyword.control.custom diff --git a/tests/aliases/99-local-resources.tmTheme b/tests/aliases/99-local-resources.tmTheme new file mode 100644 index 0000000..24687ea --- /dev/null +++ b/tests/aliases/99-local-resources.tmTheme @@ -0,0 +1,3 @@ + + +nameCodly Alias Testsettingssettingsforeground#111111scopekeyword.control.customsettingsforeground#ff0000 diff --git a/tests/aliases/99-local-resources.typ b/tests/aliases/99-local-resources.typ new file mode 100644 index 0000000..bd0d784 --- /dev/null +++ b/tests/aliases/99-local-resources.typ @@ -0,0 +1,35 @@ +#import "../../codly.typ" as codly + +#set page(width: 220pt, height: auto, margin: 5pt) +#show text.where(text: "keyword"): it => context [#metadata(text.fill)#it] + +// Both paths are relative to this test, not Codly's source directory. +// Direct relative raw arguments must use path(...) or read(...); plain strings +// are safe when inherited through this set rule. +#set raw( + theme: "99-local-resources.tmTheme", + syntaxes: "99-local-resources.sublime-syntax", +) +#show: codly.set_(aliases: (custom-alias: "codly-alias-test")) +#codly.new(raw("keyword", lang: "custom-alias", block: true)) + +// A resolved path retains its caller origin when copied through the alias. +#codly.new(raw( + "keyword", + lang: "custom-alias", + block: true, + theme: path("99-local-resources.tmTheme"), + syntaxes: path("99-local-resources.sublime-syntax"), +)) + +#codly.new(raw( + "keyword", + lang: "custom-alias", + block: true, + theme: read("99-local-resources.tmTheme", encoding: none), + syntaxes: read("99-local-resources.sublime-syntax", encoding: none), +)) + +#context { + assert.eq(query().map(it => it.value), (rgb("#ff0000"),) * 3) +} diff --git a/tests/aliases/errors.typ b/tests/aliases/errors.typ new file mode 100644 index 0000000..44af3c6 --- /dev/null +++ b/tests/aliases/errors.typ @@ -0,0 +1,13 @@ +#import "../../codly.typ" as codly + +#show: codly.set_(aliases: (custom-alias: "codly-alias-test")) +#if sys.inputs.at("case") == "alias-theme" { + codly.new(raw("keyword", lang: "custom-alias", block: true, theme: "99-local-resources.tmTheme")) +} else { + codly.new(raw( + "keyword", + lang: "custom-alias", + block: true, + syntaxes: "99-local-resources.sublime-syntax", + )) +} diff --git a/tests/aliases/test.typ b/tests/aliases/test.typ new file mode 100644 index 0000000..c2385b7 --- /dev/null +++ b/tests/aliases/test.typ @@ -0,0 +1,29 @@ +#import "../../codly.typ" as codly + +#set page(width: 220pt, height: auto, margin: 5pt) + +// Alias reconstruction must not apply this relative block rule twice. +#show raw.line: it => context [ + #metadata((text: it.text, height: measure(it.body).height)) + #it +] +#{ + show: codly.set_(aliases: (cuda: "c++")) + show raw: set text(font: "DejaVu Sans Mono", fill: red) + show raw: set text(0.9em) + show raw.where(block: true): set text(0.8em) + + codly.new(raw("alias", lang: "cuda", block: true)) + codly.new(raw("direct", lang: "cpp", block: true)) +} + +#context { + let lines = query() + .map(it => it.value) + .filter(it => it.text in ("alias", "direct")) + assert.eq(lines.len(), 2) + assert.eq(lines.first().height, lines.last().height) +} + +#pagebreak() +#include "99-local-resources.typ" diff --git a/tests/annotation-references/ref/1.png b/tests/annotation-references/ref/1.png new file mode 100644 index 0000000..4e25b43 Binary files /dev/null and b/tests/annotation-references/ref/1.png differ diff --git a/tests/annotation-references/test.typ b/tests/annotation-references/test.typ new file mode 100644 index 0000000..452ecdc --- /dev/null +++ b/tests/annotation-references/test.typ @@ -0,0 +1,43 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#let suffix(body) = { + if type(body) == str { body } else if body.has("text") { body.text } else if body.has( + "children", + ) { body.children.map(suffix).join() } else { "" } +} + + +#set page(width: 315pt, height: auto, margin: 5pt) +#show: codly.ref-set_(sep: " / ", numbering: n => [L#n]) +#show: codly.annotation-show_(it => { + let fields = e.fields(it) + [#metadata((fields.num, fields.body, suffix((fields.numbering)(fields.num)))) + #metadata(fields.height)#it] +}) +#show grid.cell: it => { + if it.rowspan > 1 { [#metadata(it.rowspan)#it] } else { it } +} + +@first-note @second-note +#figure(caption: [Annotations])[ + #codly.new(raw("1\n2\n3\n4\n5\n6", block: true), offset: 10, block-label: , annotations: ( + (start: 5, end: 6, label: , content: [second], numbering: n => [#n!]), + (start: 2, end: 3, label: , content: [first]), + )) +] + + +#context { + assert.eq(query().map(it => it.value), ( + (1, [first], "(1)"), + (2, [second], "2!"), + )) + assert.eq(query().map(it => it.value), (2, 2)) + for annotation in query() { assert(annotation.value > 0pt) } + for (target, expected) in ((, (12, [first])), (, (15, [second]))) { + let target = query(target).first() + let reference = e.fields((target.numbering)()) + assert.eq((reference.line, reference.item), expected) + } +} diff --git a/tests/annotations/ref/1.png b/tests/annotations/ref/1.png new file mode 100644 index 0000000..fbbcbd8 Binary files /dev/null and b/tests/annotations/ref/1.png differ diff --git a/tests/annotations/ref/2.png b/tests/annotations/ref/2.png new file mode 100644 index 0000000..294c9fa Binary files /dev/null and b/tests/annotations/ref/2.png differ diff --git a/tests/annotations/ref/3.png b/tests/annotations/ref/3.png new file mode 100644 index 0000000..5cee6fe Binary files /dev/null and b/tests/annotations/ref/3.png differ diff --git a/tests/annotations/ref/4.png b/tests/annotations/ref/4.png new file mode 100644 index 0000000..efa7ee1 Binary files /dev/null and b/tests/annotations/ref/4.png differ diff --git a/tests/annotations/ref/5.png b/tests/annotations/ref/5.png new file mode 100644 index 0000000..81453b2 Binary files /dev/null and b/tests/annotations/ref/5.png differ diff --git a/tests/annotations/test.typ b/tests/annotations/test.typ new file mode 100644 index 0000000..c0d4302 --- /dev/null +++ b/tests/annotations/test.typ @@ -0,0 +1,122 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#let suffix(body) = { + if type(body) == str { body } else if body.has("text") { body.text } else if body.has( + "children", + ) { body.children.map(suffix).join() } else { "" } +} + +#let line-show(it) = { + let fields = e.fields(it) + let body = fields.body + if type(body) == content and body.func() == raw.line { + [#metadata((number: body.number, text: body.text))#it] + } else { + it + } +} + +#set page(width: 175pt, height: auto, margin: 5pt) +#show: codly.ref-set_(sep: " / ", numbering: n => [L#n]) +#show: codly.line-show_(line-show) +#show: codly.annotation-show_(it => { + let fields = e.fields(it) + [#metadata(( + num: fields.num, + body: fields.body, + height: fields.height, + numbering: suffix((fields.numbering)(fields.num)), + ))#it] +}) +#show grid.cell: it => { + if it.rowspan > 1 { [#metadata(it.rowspan)#it] } else { it } +} + +// A wrapped line makes the actual row span larger than the default line +// height. The annotation must follow the first and following line bounds. +#metadata("wrapped") +#{ + set text(size: 12pt) + show: codly.line-set_(inset: 3pt) + codly.new( + raw( + "short\nthis line is deliberately long so it wraps in the narrow test page\nend", + block: true, + ), + annotations: ((start: 1, end: 2, content: block(inset: 2pt)[wrapped]),), + ) +} + +// A range can start inside an annotation; only displayed rows belong to it. +#metadata("ranges") +#codly.new( + raw("a\nb\nc\nd\ne\nf", block: true), + ranges: (codly.range(4, 6),), + annotations: ((start: 3, end: 5, content: [ranged]),), +) + +// An explicit skip contributes a displayed row inside a spanning annotation. +#metadata("skips") +#codly.new( + raw("a\nb\nc\nd\ne\nf", block: true), + skips: ((3, 2),), + annotations: ((start: 2, end: 5, content: [skipped]),), +) + +// A breakable source may paginate while retaining a valid annotation element. +#metadata("paged") +#{ + set page(height: 78pt, margin: 5pt) + set text(size: 11pt) + codly.new( + raw(range(1, 15).map(n => "line " + str(n)).join("\n"), block: true), + breakable: true, + annotations: ((start: 2, end: 11, content: [paged]),), + ) +} + +#context { + let lines-between(start, end) = query( + selector().after(start.location()).before(end.location()), + ) + let ann-between(start, end) = query( + selector().after(start.location()).before(end.location()), + ) + let wrapped = lines-between(query().first(), query().first()) + let wrapped-ann = ann-between(query().first(), query().first()).first() + let wrapped-after = wrapped.at(2).location().position() + let wrapped-first = wrapped.first().location().position() + assert.eq(wrapped.map(it => it.value.number), (1, 2, 3)) + assert.eq(wrapped-ann.location().position().page, wrapped-first.page) + assert((wrapped-ann.location().position().y - wrapped-first.y).abs < 0.1pt) + assert(wrapped-ann.value.height + 9pt >= wrapped-after.y - wrapped-first.y - 0.1pt) + + let ranged = lines-between(query().first(), query().first()) + let ranged-ann = ann-between(query().first(), query().first()).first() + let ranged-start = ranged.filter(it => it.value.number == 4).first().location().position() + let ranged-after = ranged.filter(it => it.value.number == 6).first().location().position() + assert(ranged-ann.value.height + 9pt >= ranged-after.y - ranged-start.y - 0.1pt) + + let skipped = lines-between(query().first(), query().first()) + let skipped-ann = ann-between(query().first(), query().first()).first() + let skipped-start = skipped.filter(it => it.value.number == 2).first().location().position() + let skipped-after = skipped.filter(it => it.value.number == 8).first().location().position() + assert(skipped-ann.value.height + 9pt >= skipped-after.y - skipped-start.y - 0.1pt) + + let paged = query(selector().after(query().first().location())) + let pages = () + for line in paged { + let page = line.location().position().page + if page not in pages { pages.push(page) } + } + assert(paged.len() == 14) + assert(pages.len() > 1) + let paged-ann = query( + selector().after(query().first().location()), + ).first() + assert(paged-ann.value.height > 0pt) + + assert.eq(query().map(it => it.value.num), (1, 1, 1, 1)) + assert.eq(query().map(it => it.value), (2, 2, 5, 10)) +} diff --git a/tests/border-layout/ref/1.png b/tests/border-layout/ref/1.png new file mode 100644 index 0000000..1f475d5 Binary files /dev/null and b/tests/border-layout/ref/1.png differ diff --git a/tests/border-layout/ref/2.png b/tests/border-layout/ref/2.png new file mode 100644 index 0000000..8375c1b Binary files /dev/null and b/tests/border-layout/ref/2.png differ diff --git a/tests/border-layout/ref/3.png b/tests/border-layout/ref/3.png new file mode 100644 index 0000000..07e07f1 Binary files /dev/null and b/tests/border-layout/ref/3.png differ diff --git a/tests/border-layout/ref/4.png b/tests/border-layout/ref/4.png new file mode 100644 index 0000000..e8fd247 Binary files /dev/null and b/tests/border-layout/ref/4.png differ diff --git a/tests/border-layout/ref/5.png b/tests/border-layout/ref/5.png new file mode 100644 index 0000000..6248ece Binary files /dev/null and b/tests/border-layout/ref/5.png differ diff --git a/tests/border-layout/ref/6.png b/tests/border-layout/ref/6.png new file mode 100644 index 0000000..1ed333b Binary files /dev/null and b/tests/border-layout/ref/6.png differ diff --git a/tests/border-layout/test.typ b/tests/border-layout/test.typ new file mode 100644 index 0000000..a43526d --- /dev/null +++ b/tests/border-layout/test.typ @@ -0,0 +1,41 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 200pt, height: auto, margin: 20pt) + +#let example(stroke, outside: false) = { + show: codly.line-set_(stroke: stroke, fill: white) + show: e.set_(codly.codly-number, placement: if outside { "outside" } else { "inside" }) + codly.new(raw("margin check\nsecond line", block: true), radius: 0pt) +} + +#context { + let plain = measure(example(none), width: 160pt) + for stroke in (red + 8pt, stroke(paint: blue)) { + let thickness = if stroke.thickness == auto { 1pt } else { stroke.thickness } + let bordered = measure(example(stroke), width: 160pt) + assert.eq(bordered.width, plain.width) + assert(calc.abs(bordered.height - plain.height - thickness) < 0.001pt) + } +} + +#example(none) +#example(red + 8pt) +#example(stroke(paint: blue)) +#example(red + 8pt, outside: true) + +// Border space also applies at each fragment of a wrapped, breakable listing. +#{ + set page(height: 130pt, margin: 10pt) + show: codly.line-set_(stroke: red + 4pt) + codly.new( + raw( + range(1, 10) + .map(n => str(n) + " a longer line that wraps within this narrow block") + .join("\n"), + block: true, + ), + header: codly.codly-header([header], repeat: true, inset: 1em), + footer: codly.codly-footer([footer], repeat: true, inset: 1em), + ) +} diff --git a/tests/bubble-element/ref/1.png b/tests/bubble-element/ref/1.png new file mode 100644 index 0000000..e5637d2 Binary files /dev/null and b/tests/bubble-element/ref/1.png differ diff --git a/tests/bubble-element/ref/2.png b/tests/bubble-element/ref/2.png new file mode 100644 index 0000000..e2f0314 Binary files /dev/null and b/tests/bubble-element/ref/2.png differ diff --git a/tests/bubble-element/test.typ b/tests/bubble-element/test.typ new file mode 100644 index 0000000..5825133 --- /dev/null +++ b/tests/bubble-element/test.typ @@ -0,0 +1,161 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e +#set page(width: 290pt, height: auto, margin: 8pt) +#let source = "abcdefghijklmnopqrstuvwxyz0123456789" +#let pink = rgb("ffc4dd") +#let paint = gradient.linear(aqua.lighten(70%), purple.lighten(70%), angle: 90deg) +#let defaults = ( + fill: paint, + stroke: blue + 1pt, + inset: (x: 6pt, y: 3pt), + outset: (left: 2pt, right: 3pt, top: 1pt, bottom: 2pt), + radius: 5pt, + width: 90pt, + align: left, + gap: (x: 10pt, y: 5pt), + pointer-align: "center", + pointer-size: 6pt, + pointer-height: 7pt, + pointer-width: 14pt, + pointer-offset: 2pt, +) + +// Both the generic Elembic set rule and the convenience wrapper are supported. +#show: e.set_(codly.codly-bubble, ..defaults) +#let observe(key, body) = { + show: codly.bubble-show_(it => [#metadata((key: key, fields: e.fields(it)))#it]) + show: codly.callout-show_(it => [#metadata((key: key, fields: e.fields(it)))#it]) + show curve: it => [#metadata((key: key, path: it))#it] + body +} +#let sample(key, ..args) = observe(key, codly.new(raw(source, block: true), callouts: ( + ( + line: 1, + pointer: 18, + body: [Styled body], + ..args.named(), + ), +))) + +#sample("inherited") +#{ + show: codly.callout-set_(bubble-fill: yellow, bubble-width: 110pt, pointer-height: 10pt) + sample("callout-defaults") + sample("entry", bubble-fill: none, bubble-width: 80pt, pointer-height: 4pt) +} +#sample( + "all-local", + bubble-fill: pink, + bubble-stroke: none, + bubble-inset: 5pt, + bubble-outset: 0pt, + bubble-radius: 0pt, + bubble-width: 100pt, + bubble-align: right, + bubble-gap: 3pt, + pointer-align: "start", + pointer-size: 2pt, + pointer-height: 9pt, + pointer-width: 3pt, + pointer-offset: -4pt, + // These style the grid cell, not the bubble. + fill: luma(230), + inset: 8pt, + align: center, +) +#{ + show: codly.bubble-set_(fill: lime.lighten(70%), align: center, pointer-width: 22pt) + sample("nested", placement: "above") +} +#sample("restored", placement: "above", bubble-fill: auto, pointer-height: auto) +// Setting bubble styles does not turn plain rows into bubbles. +#sample("plain", pointer: none, body: [Plain row], fill: yellow.lighten(70%)) + +#context { + let bubbles = query(selector().before(here())) + assert.eq(bubbles.map(m => m.value.key), ( + "inherited", + "callout-defaults", + "entry", + "all-local", + "nested", + "restored", + )) + let find(key) = bubbles.find(m => m.value.key == key).value.fields + for key in ("inherited", "restored") { + for (name, value) in defaults { assert.eq(repr(find(key).at(name)), repr(value)) } + } + let callout = find("callout-defaults") + assert.eq((callout.fill, callout.width, callout.at("pointer-height")), (yellow, 110pt, 10pt)) + let local = find("entry") + assert.eq((local.fill, local.width, local.at("pointer-height")), (none, 80pt, 4pt)) + assert.eq(local.stroke, defaults.stroke) + for (name, value) in ( + fill: pink, + stroke: none, + inset: 5pt, + outset: 0pt, + radius: 0pt, + width: 100pt, + align: right, + gap: 3pt, + pointer-align: "start", + pointer-size: 2pt, + pointer-height: 9pt, + pointer-width: 3pt, + pointer-offset: -4pt, + ) { assert.eq(repr(find("all-local").at(name)), repr(value)) } + assert.eq(find("nested").align, center) + assert.eq(find("nested").fill, lime.lighten(70%)) + assert.eq(find("nested").at("pointer-width"), 22pt) + assert.eq(find("nested").inset, defaults.inset) + assert.eq(query(selector().before(here())).len(), 7) +} + +#pagebreak() +// Inherited widths/gaps participate in packing and annotation layout before +// drawing. Exercise both placements, no numbering, and a spanning annotation. +#for numbers in (true, false) { + show: codly.bubble-set_(width: 45pt, align: center, gap: (x: 6pt, y: 4pt)) + observe("group-" + repr(numbers), codly.new( + raw(source + "\n" + source, block: true), + number-enabled: numbers, + annotations: ((start: 1, end: 2, content: [span]),), + callouts: ( + (line: 1, pointer: 5, placement: "above", body: [First]), + (line: 1, pointer: 28, placement: "above", body: [Second]), + (line: 2, pointer: 5, body: [Third]), + (line: 2, pointer: 28, body: [Fourth], bubble-fill: yellow), + ), + )) +} +#context { + for key in ("group-true", "group-false") { + let paths = query().filter(m => m.value.key == key) + assert.eq(paths.len(), 4) + for pair in paths.chunks(2) { + assert.eq(pair.first().location().position().y, pair.last().location().position().y) + assert(pair.last().location().position().x > pair.first().location().position().x + 50pt) + } + } + // Verify resolved defaults affect real geometry/paint, not only metadata. + for (entry, path) in query().zip(query()) { + let it = entry.value.fields + let c = path.value.path.components + assert.eq(repr(path.value.path.fill), repr(it.fill)) + assert( + calc.abs(c.at(1).end.last().length - c.at(2).end.last().length) == it.at("pointer-height"), + ) + let width = it.width + if type(it.outset) == dictionary { 5pt } else { 0pt } + assert(calc.abs(c.at(5).end.first().length - width) < 0.01pt) + let anchor = query(<__codly-callout-anchor>).find(m => ( + m.value.owner == it.__anchor.owner + and m.value.row == it.line + and m.value.pointer == it.pointer + )) + let target = anchor.location().position().x - anchor.value.advance + it.at("pointer-offset") + let tip = path.location().position().x + c.at(2).end.first().length + // A negative offset with start alignment clamps to the bubble's left edge. + assert(calc.abs(tip - calc.max(path.location().position().x, target)) < 0.01pt) + } +} diff --git a/tests/callbacks/test.typ b/tests/callbacks/test.typ new file mode 100644 index 0000000..1d0203a --- /dev/null +++ b/tests/callbacks/test.typ @@ -0,0 +1,128 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 285pt, height: auto, margin: 5pt) + +// Hooks project the resolved element fields into metadata so the scoped +// values can be checked after layout, while the callbacks below assert their +// scalar inputs at the point they are evaluated. +#let line-fields(it) = { + let fields = e.fields(it) + [#metadata(fields.fill)#it] +} + +#let highlight-fields(it) = { + let fields = e.fields(it) + [#metadata(fields.color)#it] +} + +#let lang-fields(it) = { + let fields = e.fields(it) + let definition = fields.languages.at("py") + [#metadata(definition.color)#it] +} + +#let number-fields(it) = { + [#metadata(e.fields(it).number)#it] +} + +#let highlight-fill(color) = { + assert.eq(color, red) + color.lighten(70%) +} +#let highlight-stroke(color) = { + assert.eq(color, red) + 0.5pt + color +} +#let language-fill(lang) = { + assert.eq(lang, (name: [Python], icon: "P", color: red)) + red.lighten(70%) +} +#let language-stroke(lang) = { + assert.eq(lang, (name: [Python], icon: "P", color: red)) + 0.5pt + red +} + +// A nested scope overrides line, highlight, and language settings, then the +// enclosing values are restored for the following block. +#{ + show: codly.line-set_(fill: red) + show: codly.highlight-set_(color: red, fill: highlight-fill, stroke: highlight-stroke) + show: codly.lang-set_(languages: ( + py: ( + name: [Python], + icon: "P", + color: red, + fill: language-fill, + stroke: language-stroke, + ), + )) + show: codly.line-show_(line-fields) + show: codly.highlight-show_(highlight-fields) + show: codly.lang-show_(lang-fields) + codly.new(raw("outer", lang: "py", block: true), highlights: ((line: 1, start: 1, end: 1),)) + + { + show: codly.line-set_(fill: blue) + show: codly.highlight-set_( + color: blue, + fill: color => { + assert.eq(color, blue) + color.lighten(70%) + }, + stroke: color => { + assert.eq(color, blue) + 0.5pt + color + }, + ) + show: codly.lang-set_(languages: ( + py: ( + name: [Python], + icon: "P", + color: blue, + fill: lang => { + assert.eq(lang, (name: [Python], icon: "P", color: blue)) + blue.lighten(70%) + }, + stroke: lang => { + assert.eq(lang, (name: [Python], icon: "P", color: blue)) + 0.5pt + blue + }, + ), + )) + codly.new(raw("inner", lang: "py", block: true), highlights: ((line: 1, start: 1, end: 1),)) + } + + codly.new(raw("outer", lang: "py", block: true), highlights: ((line: 1, start: 1, end: 1),)) +} + +// Offset-from uses the preceding block's displayed number. The second block +// keeps a character highlight on displayed line 9 while callbacks receive a +// scalar color from the configured highlight element. +#show: codly.highlight-set_( + color: green, + fill: color => { + assert.eq(color, green) + green.lighten(70%) + }, + stroke: color => { + assert.eq(color, green) + 0.5pt + green + }, +) +#show: codly.highlight-show_(highlight-fields) +#show: e.show_(codly.codly-number, number-fields) +#codly.new(raw("one\ntwo", block: true), offset: 5) +#codly.new( + raw("three", block: true), + offset: 1, + offset-from: , + highlights: ((line: 9, start: 1, end: 1),), +) + +#context { + assert.eq(query().map(it => it.value), (red, blue, red)) + assert.eq(query().map(it => it.value), (red, blue, red, green)) + assert.eq(query().map(it => it.value), (red, blue, red)) + assert.eq(query().map(it => it.value), (6, 7, 9)) +} diff --git a/tests/callout-bubbles/ref/1.png b/tests/callout-bubbles/ref/1.png new file mode 100644 index 0000000..1f7a5ec Binary files /dev/null and b/tests/callout-bubbles/ref/1.png differ diff --git a/tests/callout-bubbles/ref/2.png b/tests/callout-bubbles/ref/2.png new file mode 100644 index 0000000..5ac4558 Binary files /dev/null and b/tests/callout-bubbles/ref/2.png differ diff --git a/tests/callout-bubbles/test.typ b/tests/callout-bubbles/test.typ new file mode 100644 index 0000000..a5ca2f6 --- /dev/null +++ b/tests/callout-bubbles/test.typ @@ -0,0 +1,155 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e +#set page(width: 290pt, height: auto, margin: 8pt) + +#show: codly.callout-set_(bubble-fill: gradient.linear(rgb("dbf0ff"), rgb("fce2ee"), angle: 90deg)) +#let source = "const [amet, consectetur] = [0, 0]" + +// Keep the native path inspectable: one closed outline owns both fill and +// stroke, with no rectangle/triangle seam. +#show curve: it => [#metadata(it)#it] +#show: codly.callout-show_(it => [#metadata(e.fields(it))#it]) + +#codly.new(raw(source, lang: "js", block: true), callouts: ( + (line: 1, pointer: 9, body: [This is a callout]), + (line: 1, pointer: 1, body: [Clamped at the left]), + (line: 1, pointer: source.len(), body: [Clamped at the right]), +)) + +// Local bubble properties override element defaults independently of the row. +// Long body text wraps natively inside the measured outline. +#codly.new(raw(source, block: true), number-enabled: false, callouts: ( + (line: 1, pointer: 16, pointer-align: "start", body: [Start], bubble-fill: yellow), + (line: 1, pointer: 16, pointer-align: "end", body: [End], bubble-radius: 8pt), + ( + line: 1, + pointer: 20, + body: [A longer *styled explanation* that must wrap across several lines inside the bubble without any manually inserted line breaks.], + fill: luma(225), + bubble-inset: (x: 10pt, y: 7pt), + bubble-outset: (x: 3pt, y: 2pt), + bubble-stroke: 1.5pt + gradient.linear(blue, red), + pointer-size: 7pt, + ), +)) + +// A wrapped source, nested highlights and a spanning annotation all share the +// actual source character anchor, even when numbering is outside the block. +#{ + show: codly.number-set_(placement: "outside") + codly.new( + raw( + "root {\n long_call(alpha, beta, gamma, delta, epsilon, zeta);\n}", + block: true, + lang: "js", + ), + width: 245pt, + indent-guides: true, + wrap-marker: [>], + rainbow: true, + annotations: ((start: 1, end: 3, content: [span]),), + highlights: ((line: 2, start: 5, end: 14),), + callouts: ((line: 2, pointer: 44, body: [Wrapped source anchor], bubble-width: 70%),), + ) +} + +// Earlier annotations release their column; the next active annotation keeps +// the brace column separate. Callouts keep source positions despite offsets. +#codly.new( + raw("before\nabcdefghijklmnopqrst\nafter", block: true), + offset: 100, + annotations: ((start: 1, content: [before]), (start: 3, content: [after])), + callouts: ((line: 2, pointer: 18, body: [Freed annotation column], bubble-fill: none),), +) + +#context { + let paths = query(selector().before(here())) + assert.eq(paths.len(), 8) + assert(paths.all(p => p.value.fill != auto)) + assert.eq(query(selector(<__codly-callout-anchor>).before(here())).len(), 7) + for path in paths { + let fields = query(selector().before(path.location())).last().value + let anchor = query(<__codly-callout-anchor>).find(m => ( + m.value.owner == fields.__anchor.owner + and m.value.row == fields.line + and m.value.pointer == fields.pointer + )) + assert(anchor != none) + let tip = path.value.components.at(2).end.first().length + let expected = anchor.location().position().x - anchor.value.advance + assert(calc.abs(path.location().position().x + tip - expected) < 0.01pt) + // Painted outline stays within the page even when the bubble shifts. + let width = path.value.components.at(5).end.first().length + assert(path.location().position().x >= 8pt) + assert(path.location().position().x + width <= 282pt) + } + assert.eq(repr(paths.first().value.fill), repr(paths.at(2).value.fill)) + assert.eq(paths.at(3).value.fill, yellow) + assert.eq(paths.last().value.fill, none) + // The long body really wrapped, rather than overflowing a one-line box. + assert(paths.at(5).value.components.at(8).end.last().length > 40pt) +} + +#pagebreak() +// Near both row edges, the bubble shifts while the arrow follows the source. +#codly.new( + raw("0123456789012345678901234567890123456789012345678", block: true), + number-enabled: false, + callouts: ( + (line: 1, pointer: 1, body: [Left edge]), + (line: 1, pointer: 49, body: [Right edge]), + ), +) +// Numbering and annotation states do not change source character coordinates. +#for numbers in (true, false) { + codly.new( + raw("before\n abcdefghijklmnopqrstuvwxyz\nafter", block: true), + number-enabled: numbers, + column-gutter: 5pt, + row-gutter: 2pt, + annotations: ((start: 1, content: [before]),), + callouts: ( + (line: 2, pointer: 25, body: [Right edge and released brace column], bubble-width: 160pt), + ), + ) + codly.new( + raw("before\n abcdefghijklmnopqrstuvwxyz\nafter", block: true), + number-enabled: numbers, + annotations: ((start: 1, end: 3, content: [span]),), + callouts: ( + ( + line: 2, + pointer: 6, + body: [Inside a brace], + bubble-stroke: none, + bubble-fill: tiling(size: (4pt, 4pt), rect( + width: 2pt, + height: 4pt, + fill: aqua, + stroke: none, + )), + ), + (line: 2, pointer: 0, body: [Line start], bubble-radius: 0pt, pointer-size: 8pt), + ), + ) +} + +// Unicode, explicit body breaks, full-width bubbles, and a narrow container. +#codly.new(raw("let café = \"🙂\";", block: true, lang: "js"), width: 140pt, callouts: ( + ( + line: 1, + pointer: 8, + body: [Explicit#linebreak()body lines], + bubble-width: 100%, + bubble-outset: (left: 1pt, right: 5pt, top: 2pt, bottom: 3pt), + ), +)) + +// Setting a pointer on the element enables bubbles; an entry can turn it off. +#{ + show: codly.callout-set_(pointer: 3, bubble-fill: yellow, bubble-inset: 8pt) + codly.new(raw("abc\ndef", block: true), callouts: ( + (line: 1, body: [Inherited bubble]), + (line: 2, pointer: none, body: [Plain row]), + )) +} diff --git a/tests/callout-controls/ref/1.png b/tests/callout-controls/ref/1.png new file mode 100644 index 0000000..e12dcf3 Binary files /dev/null and b/tests/callout-controls/ref/1.png differ diff --git a/tests/callout-controls/ref/2.png b/tests/callout-controls/ref/2.png new file mode 100644 index 0000000..557e968 Binary files /dev/null and b/tests/callout-controls/ref/2.png differ diff --git a/tests/callout-controls/ref/3.png b/tests/callout-controls/ref/3.png new file mode 100644 index 0000000..b5ebbc8 Binary files /dev/null and b/tests/callout-controls/ref/3.png differ diff --git a/tests/callout-controls/test.typ b/tests/callout-controls/test.typ new file mode 100644 index 0000000..14d84d9 --- /dev/null +++ b/tests/callout-controls/test.typ @@ -0,0 +1,157 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e +#set page(width: 290pt, height: auto, margin: 8pt) +#let source = "abcdefghijklmnopqrstuvwxyz0123456789" +#show curve: it => [#metadata(it)#it] +#show: codly.callout-show_(it => [#metadata(e.fields(it))#it]) +#show: codly.callout-set_(pointer-size: 4pt, bubble-fill: gradient.linear( + aqua.lighten(70%), + purple.lighten(70%), + angle: 90deg, +)) + +#let sample(key, ..args) = codly.new(raw(source, block: true), callouts: ( + ( + line: 1, + pointer: 18, + bubble-width: 120pt, + bubble-inset: 4pt, + body: [#box[#metadata(key)]Text#box[#metadata(key)]], + ..args.named(), + ), +)) + +// Default center is independent of cell alignment; local alignment overrides +// element settings. Check actual text positions, not just the field values. +#sample("center", align: left) +#sample("left", bubble-align: left) +#sample("right", bubble-align: right) +#{ + show: codly.callout-set_( + bubble-align: right, + pointer-height: 8pt, + pointer-width: 20pt, + pointer-offset: 3pt, + ) + sample("inherited", placement: "above") + sample( + "override", + bubble-align: center, + pointer-height: 14pt, + pointer-width: 6pt, + pointer-offset: -9pt, + ) +} + +#context { + for (start, end, path) in query(selector().before(here())).zip( + query(), + query(), + ) { + let a = start.location().position().x + let b = end.location().position().x + let x = path.location().position().x + if start.value in ("center", "override") { + assert(calc.abs((a + b) / 2 - x - 60pt) < 0.01pt, message: repr((start.value, a, b, x))) + } else if start.value == "left" { + assert(calc.abs(a - x - 4pt) < 0.01pt) + } else { + assert(calc.abs(b - x - 116pt) < 0.01pt) + } + } + let entries = query() + assert.eq(entries.at(0).value.at("bubble-align"), center) + assert.eq(entries.at(3).value.at("pointer-height"), 8pt) + assert.eq(entries.at(3).value.at("pointer-width"), 20pt) + assert.eq(entries.at(3).value.at("pointer-offset"), 3pt) +} + +#pagebreak() +// Independent arrow height/base, both placements, signs, zero and exaggerated +// values. Oversized bases and offsets clamp within the single rounded outline. +#sample("tall", pointer-height: 18pt, pointer-width: 6pt, pointer-offset: -12pt) +#sample("wide", placement: "above", pointer-height: 3pt, pointer-width: 32pt, pointer-offset: 12pt) +#sample("flat", pointer-height: 0pt, pointer-width: 24pt) +#sample("thin", placement: "above", pointer-height: 12pt, pointer-width: 0pt) +#sample( + "clamped-right", + pointer-height: 16pt, + pointer-width: 300pt, + pointer-offset: 500pt, + bubble-radius: 8pt, +) +#sample( + "clamped-left", + placement: "above", + pointer-height: 16pt, + pointer-width: 300pt, + pointer-offset: -500pt, + bubble-radius: 8pt, +) +#sample("shorthand", pointer-size: 9pt) + +#context { + for (path, entry) in query().zip(query()) { + let it = entry.value + let c = path.value.components + let tip = c.at(2).end + let base-left = c.at(1).end + let base-right = c.at(3).end + let width = c.at(5).end.first().length + let expected-height = if it.at("pointer-height") == auto { it.at("pointer-size") } else { + it.at("pointer-height") + } + let expected-width = if it.at("pointer-width") == auto { 2 * it.at("pointer-size") } else { + it.at("pointer-width") + } + assert( + calc.abs( + calc.abs(tip.last().length - base-left.last().length) - expected-height.to-absolute(), + ) + < 0.01pt, + message: repr((it.at("pointer-height"), expected-height.to-absolute(), tip, base-left)), + ) + let height = if it.placement == "above" { tip.last().length } else { c.at(8).end.last().length } + let radius = calc.min( + it.at("bubble-radius").to-absolute(), + width / 2, + (height - expected-height.to-absolute()) / 2, + ) + assert( + calc.abs( + base-right.first().length + - base-left.first().length + - calc.min(expected-width.to-absolute(), width - 2 * radius), + ) + < 0.01pt, + ) + assert(base-left.first().length >= 0pt and base-right.first().length <= width) + let anchor = query(<__codly-callout-anchor>).find(m => m.value.owner == it.__anchor.owner) + let x = path.location().position().x + let expected-tip = calc.min(x + width, calc.max( + x, + anchor.location().position().x - anchor.value.advance + it.at("pointer-offset"), + )) + assert(calc.abs(x + tip.first().length - expected-tip) < 0.01pt) + assert.eq(c.last(), curve.close()) + assert(type(path.value.fill) == gradient) + } +} + +#pagebreak() +// Vertical spacing is local to adjacent lanes, not a global maximum. Horizontal +// spacing remains independent, and omitted axes use the documented default. +#codly.new(raw(source, block: true), callouts: ( + (line: 1, pointer: 18, body: [First], bubble-gap: (x: 0pt, y: 18pt)), + (line: 1, pointer: 18, body: [Second], bubble-gap: (x: 0pt, y: 2pt)), + (line: 1, pointer: 18, body: [Third], bubble-gap: (x: 0pt, y: 2pt)), + (line: 1, pointer: 18, body: [Fourth], bubble-gap: (y: 2pt)), +)) +#context { + let paths = query().slice(-4) + let expected = (18pt, 2pt, 2pt) + for (a, b, gap) in paths.zip(paths.slice(1), expected) { + let bottom = a.location().position().y + a.value.components.at(8).end.last().length + assert(calc.abs(b.location().position().y - bottom - gap - 0.6pt) < 0.01pt) + } +} diff --git a/tests/callout-layout/test.typ b/tests/callout-layout/test.typ new file mode 100644 index 0000000..5e05728 --- /dev/null +++ b/tests/callout-layout/test.typ @@ -0,0 +1,89 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e +#set page(width: 180pt, height: 130pt, margin: 6pt) +#show curve: it => [#metadata(it)#it] +#show: codly.callout-show_(it => [#metadata(e.fields(it))#it]) + +// Consecutive bubbles force page breaks. Their path and body must remain on +// one page, with a valid source anchor and no duplicate or lost content. +#codly.new( + raw(range(1, 9).map(i => "line " + str(i)).join("\n"), block: true), + callouts: range(1, 9).map(i => ( + line: i, + placement: if calc.odd(i) { "above" } else { "below" }, + pointer: 3, + bubble-width: 90pt, + body: [#metadata(i)A wrapping explanation for line #i.#metadata(i)], + )), +) + +#context { + let paths = query(selector().before(here())) + assert.eq(paths.len(), 8) + let starts = query() + let ends = query() + assert.eq(starts.map(m => m.value), range(1, 9)) + assert.eq(ends.map(m => m.value), range(1, 9)) + assert(paths.map(p => p.location().position().page).dedup().len() > 1) + for (path, start, end) in paths.zip(starts, ends) { + assert.eq(path.location().position().page, start.location().position().page) + assert.eq(start.location().position().page, end.location().position().page) + } +} + +// Width auto performs an intrinsic layout pass; anchors must still resolve. +#codly.new(raw("abcdef", block: true), width: auto, callouts: ( + (line: 1, pointer: 4, body: [Auto width]), +)) + +// Anchors use source positions through range filtering, skips, offsets and +// syntax resolution for sublanguages. Hidden rows create no bubble or anchor. +#context { + let origin = here() + codly.new( + raw("hidden\nlet variable = 3;\nnext\nlast", block: true), + range: (2, 4), + offset: 20, + skips: ((position: 3, length: 5),), + sublangs: ((start: 2, end: 2, lang: "js"),), + callouts: ( + (line: 1, pointer: 99, body: [Hidden]), + (line: 2, pointer: 6, placement: "above", body: [Sublanguage source]), + ), + ) + context { + let rows = query(selector().after(origin).before(here())) + assert.eq(rows.len(), 1) + assert.eq((rows.first().value.line, rows.first().value.pointer), (2, 6)) + let anchors = query(selector(<__codly-callout-anchor>).after(origin).before(here())) + assert.eq(anchors.len(), 1) + } +} + +// Grouped bubbles remain intact when the entire group moves to a new page. +// Collisions use distinct lanes; separated anchors share a lane on either side. +#context { + let origin = here() + for side in ("above", "below") { + codly.new(raw("abcdefghijklmnopqrstuvwxyz", block: true), callouts: ( + (line: 1, placement: side, pointer: 3, bubble-width: 35pt, body: [Left]), + (line: 1, placement: side, pointer: 21, bubble-width: 35pt, body: [Right]), + ( + line: 1, + placement: side, + pointer: 3, + bubble-width: 90pt, + body: [A collision wraps into another lane], + ), + )) + } + context { + let paths = query(selector().after(origin).before(here())) + assert.eq(paths.len(), 6) + for group in paths.chunks(3) { + assert.eq(group.map(p => p.location().position().page).dedup().len(), 1) + assert.eq(group.at(0).location().position().y, group.at(1).location().position().y) + assert(group.at(2).location().position().y > group.at(1).location().position().y) + } + } +} diff --git a/tests/callout-placement/ref/1.png b/tests/callout-placement/ref/1.png new file mode 100644 index 0000000..0ec6b1c Binary files /dev/null and b/tests/callout-placement/ref/1.png differ diff --git a/tests/callout-placement/ref/2.png b/tests/callout-placement/ref/2.png new file mode 100644 index 0000000..bd8eae7 Binary files /dev/null and b/tests/callout-placement/ref/2.png differ diff --git a/tests/callout-placement/ref/3.png b/tests/callout-placement/ref/3.png new file mode 100644 index 0000000..c5b79b9 Binary files /dev/null and b/tests/callout-placement/ref/3.png differ diff --git a/tests/callout-placement/ref/4.png b/tests/callout-placement/ref/4.png new file mode 100644 index 0000000..119bf10 Binary files /dev/null and b/tests/callout-placement/ref/4.png differ diff --git a/tests/callout-placement/test.typ b/tests/callout-placement/test.typ new file mode 100644 index 0000000..4d646d4 --- /dev/null +++ b/tests/callout-placement/test.typ @@ -0,0 +1,248 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e +#set page(width: 320pt, height: auto, margin: 8pt) +#let source = "abcdefghijklmnopqrstuvwxyz0123456789" +#let gradient-fill = gradient.linear(aqua.lighten(70%), purple.lighten(75%), angle: 90deg) + +#let sample( + key, + entries, + numbers: true, + annotations: none, + code: source + "\n" + source + "\nlast", + ..args, +) = { + show curve: it => [#metadata((key: key, path: it))#it] + show grid.cell: it => { + if it.rowspan > 1 { [#metadata((key: key, span: it.rowspan))#it] } else { it } + } + show: codly.line-show_(it => { + let body = e.fields(it).body + if body.func() == raw.line { + [#metadata((key: key, number: body.number))#it] + } else { it } + }) + show: codly.callout-show_(it => [#metadata((key: key, fields: e.fields(it)))#it]) + show: codly.callout-set_(bubble-fill: gradient-fill, bubble-gap: 4pt) + codly.new( + raw(code, block: true), + number-enabled: numbers, + annotations: annotations, + callouts: entries, + ..args, + ) +} + +// Two independent bubbles on one shared row, above and below respectively. +#sample("below", ( + (line: 1, pointer: 5, body: [Left], bubble-width: 42pt), + (line: 1, pointer: 30, body: [Right], bubble-width: 42pt), +)) +#sample("above", ( + (line: 1, pointer: 5, placement: "above", body: [Left], bubble-width: 42pt), + (line: 1, pointer: 30, placement: "above", body: [Right], bubble-width: 42pt), +)) +#sample("mixed", ( + (line: 2, pointer: 7, placement: "above", body: [Above]), + (line: 2, pointer: 24, body: [Below]), + (line: 2, placement: "above", body: [Plain row above], fill: yellow.lighten(70%)), +)) + +#context { + for key in ("below", "above") { + let paths = query().filter(m => m.value.key == key) + assert.eq(paths.len(), 2) + let a = paths.first().location().position() + let b = paths.last().location().position() + assert.eq(a.y, b.y) + assert(b.x > a.x + 42pt) + let code = query().find(m => m.value.key == key).location().position() + if key == "above" { assert(a.y < code.y) } else { assert(a.y > code.y) } + } +} + +#pagebreak() +// Colliding bubbles stack, with the same character anchor and no overlap. +#sample("collision", ( + (line: 1, pointer: 15, body: [First], bubble-width: 105pt), + (line: 1, pointer: 15, body: [Second explanation wraps naturally], bubble-width: 105pt), + (line: 1, pointer: 15, body: [Third], bubble-width: 105pt), +)) +// Different heights align their arrow tips toward the source line. +#sample("above-heights", ( + (line: 1, placement: "above", pointer: 5, body: [Short], bubble-width: 40pt), + (line: 1, placement: "above", pointer: 30, body: [A longer body that wraps], bubble-width: 60pt), +)) +// Global placement inherits; local placement overrides it. +#{ + show: codly.callout-set_(placement: "above") + sample("inherit", ( + (line: 1, pointer: 8, body: [Inherited above]), + (line: 1, pointer: 27, placement: "below", body: [Local below]), + )) +} + +#context { + let paths = query().filter(m => m.value.key == "collision") + for (a, b) in paths.zip(paths.slice(1)) { + let height = a.value.path.components.at(8).end.last().length + assert(b.location().position().y > a.location().position().y + height) + } + let heights = query().filter(m => m.value.key == "above-heights") + let tips = heights.map(m => ( + m.location().position().y + m.value.path.components.at(2).end.last().length + )) + assert(calc.abs(tips.first() - tips.last()) < 0.01pt) + let inherited = query().filter(m => m.value.key == "inherit") + assert.eq(inherited.map(m => m.value.fields.placement), ("above", "below")) +} + +#pagebreak() +// Active braces start with the first above-row and span both grouped sides. +// A completed brace frees the last column before an above-callout. +#for numbers in (true, false) { + sample( + "brace-" + repr(numbers), + ( + (line: 1, placement: "above", pointer: 5, body: [A], bubble-width: 25pt), + (line: 1, placement: "above", pointer: 25, body: [B], bubble-width: 25pt), + (line: 2, pointer: 5, body: [C], bubble-width: 25pt), + (line: 2, pointer: 25, body: [D], bubble-width: 25pt), + ), + numbers: numbers, + annotations: ((start: 1, end: 2, content: [spanning]),), + row-gutter: 2pt, + ) + sample( + "completed-" + repr(numbers), + ( + (line: 2, placement: "above", pointer: 20, body: [Previous brace ended]), + ), + numbers: numbers, + annotations: ((start: 1, content: [before]),), + ) +} +#{ + show: codly.number-set_(placement: "outside") + sample( + "outside", + ( + (line: 2, placement: "above", pointer: 6, body: [Above]), + (line: 2, pointer: 26, body: [Below]), + ), + annotations: ((start: 1, end: 3, content: [brace]),), + indent-guides: true, + ) +} + +#context { + for key in ("brace-true", "brace-false") { + assert.eq(query().filter(m => m.value.key == key).map(m => m.value.span), (4,)) + } +} + +#pagebreak() +// Forward character probes track a wrapped source with highlights and guides. +#sample( + "wrapped", + ( + (line: 2, placement: "above", pointer: 8, body: [First], bubble-width: 35pt), + (line: 2, placement: "above", pointer: 44, body: [Wrapped], bubble-width: 50pt), + (line: 2, pointer: 44, body: [Same anchor below]), + ), + code: "root {\n long_call(alpha, beta, gamma, delta, epsilon, zeta);\n}", + width: 210pt, + indent-guides: true, + wrap-marker: [>], + rainbow: true, + sublangs: ((start: 1, end: 3, lang: "js"),), + highlights: ((line: 2, start: 5, end: 14),), + annotations: ((start: 1, end: 3, content: [span]),), +) + +// Large gaps force stacking even for otherwise separated bubbles. A local +// gap overrides the element setting; above-collisions retain declaration order. +#sample("large-gap", ( + ( + line: 1, + placement: "above", + pointer: 5, + body: [First], + bubble-width: 40pt, + bubble-gap: (x: 180pt, y: 4pt), + ), + (line: 1, placement: "above", pointer: 30, body: [Second], bubble-width: 40pt), +)) +// Different cell styles must stay separate, but independent bubble paints +// and decorations can share one cell. Oversized spacing is not imposed here. +#sample("cell-styles", ( + (line: 1, pointer: 5, body: [Cell A], bubble-width: 45pt, fill: yellow.lighten(70%)), + (line: 1, pointer: 30, body: [Cell B], bubble-width: 45pt, fill: aqua.lighten(70%)), +)) +#sample("bubble-styles", ( + ( + line: 1, + placement: "above", + pointer: 5, + body: [One], + bubble-width: 50pt, + bubble-outset: (left: 4pt, right: 9pt, top: 5pt, bottom: 2pt), + bubble-gap: 0pt, + bubble-inset: 6pt, + bubble-stroke: 2pt + gradient.linear(blue, red), + pointer-size: 9pt, + ), + ( + line: 1, + placement: "above", + pointer: 30, + body: [Two], + bubble-width: 50pt, + bubble-gap: 0pt, + bubble-radius: 8pt, + bubble-fill: tiling(size: (4pt, 4pt), rect(width: 2pt, height: 4pt, fill: aqua, stroke: none)), + ), +)) + +#context { + let gaps = query().filter(m => m.value.key == "large-gap") + // Above paths start at y = h-arrow, but their arrow tip is at y = h. + let first-height = gaps.first().value.path.components.at(2).end.last().length + let separation = ( + gaps.last().location().position().y - gaps.first().location().position().y - first-height + ) + assert(separation >= 4pt and separation < 5pt) + let gap-fields = query().filter(m => m.value.key == "large-gap") + assert.eq(gap-fields.map(m => m.value.fields.at("bubble-gap")), ((x: 180pt, y: 4pt), 4pt)) + let styled = query().filter(m => m.value.key == "cell-styles") + assert(styled.first().location().position().y < styled.last().location().position().y) + let bubbles = query().filter(m => m.value.key == "bubble-styles") + let tips = bubbles.map(m => ( + m.location().position().y + m.value.path.components.at(2).end.last().length + )) + // Different stroke thicknesses reserve different half-stroke clearances. + assert(calc.abs(tips.first() - tips.last()) < 1pt) + // Every arrow points at the rendered source character, on either side. + for path in query() { + let entry = query(selector().before(path.location())).last().value.fields + let anchor = query(<__codly-callout-anchor>).find(m => ( + m.value.owner == entry.__anchor.owner + and m.value.row == entry.line + and m.value.pointer == entry.pointer + )) + assert(anchor != none) + let tip = path.value.path.components.at(2).end + let at = path.location().position() + assert( + calc.abs(at.x + tip.first().length - anchor.location().position().x + anchor.value.advance) + < 0.01pt, + ) + if entry.placement == "above" { + assert(tip.last().length > 0pt) + assert(at.y + tip.last().length < anchor.location().position().y) + } else { + assert.eq(tip.last().length, 0pt) + assert(at.y > anchor.location().position().y) + } + } +} diff --git a/tests/callouts/ref/1.png b/tests/callouts/ref/1.png new file mode 100644 index 0000000..709adcf Binary files /dev/null and b/tests/callouts/ref/1.png differ diff --git a/tests/callouts/test.typ b/tests/callouts/test.typ new file mode 100644 index 0000000..ad18b52 --- /dev/null +++ b/tests/callouts/test.typ @@ -0,0 +1,132 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 250pt, height: auto, margin: 5pt) + +#let text-of(body) = { + if type(body) == str { body } else if body.has("text") { body.text } else if body.has( + "children", + ) { body.children.map(text-of).join() } else { "" } +} + +// The public type uses `body`, exposes the cell fields directly, and validates +// its one-indexed source-line anchor. +#let fields = e.fields(codly.callout( + line: 2, + body: [Explanation], + fill: yellow, + inset: 3pt, + pointer: 2, + pointer-align: "end", +)) +#assert.eq( + ( + fields.line, + text-of(fields.body), + fields.fill, + fields.inset, + fields.pointer, + fields.at("pointer-align"), + ), + ( + 2, + "Explanation", + yellow, + 3pt, + 2, + "end", + ), +) + +#show: codly.callout-show_(it => { + let fields = e.fields(it) + [#metadata((line: fields.line, body: text-of(fields.body)))#it] +}) +#show grid.cell: it => { + if it.rowspan > 1 { [#metadata(it.rowspan)#it] } else { it } +} + +// Element settings provide the defaults for a callout cell; explicit entry +// fields override them. Numbered callouts span the number column, and two +// entries on a line retain source order. +#metadata("numbered") +#{ + show: codly.callout-set_(fill: rgb("e5f2ff"), inset: (x: 8pt, y: 3pt), stroke: blue) + codly.new( + raw("one\ntwo\nthree", block: true, lang: "rs"), + callouts: ( + (line: 1, body: [element defaults]), + (line: 2, body: [local fill override], fill: rgb("fff0d6"), align: center), + (line: 2, body: [local none override], fill: none, align: center), + ), + ) +} + +// Without line numbers, the callout is the only primary-grid cell and spans +// the entire block width. +#metadata("unnumbered") +#codly.new( + raw("alpha\nbeta", block: true), + number-enabled: false, + callouts: ((line: 1, body: [full-width unnumbered callout], fill: aqua),), +) + +// A completed annotation leaves its final column available, so the following +// callout spans both the code and former annotation columns. +#metadata("annotation-before") +#codly.new( + raw("before\nafter\nend", block: true), + annotations: ((start: 1, end: 1, content: [before]),), + callouts: ((line: 2, body: [spans the inactive annotation column], fill: lime),), +) + +// A callout inside a multi-line annotation preserves the final brace column, +// contributes a visual row to the annotation span, and may itself have a +// multi-line body. +#metadata("annotation-spans-numbered") +#codly.new( + raw("first\nsecond\nthird", block: true), + annotations: ((start: 1, end: 3, content: [numbered brace]),), + callouts: ( + ( + line: 2, + body: [first paragraph#linebreak()second paragraph], + fill: rgb("ffe0e6"), + inset: 4pt, + breakable: true, + ), + ), +) + +// The same active-annotation rule applies when the number column is absent. +#metadata("annotation-spans-unnumbered") +#codly.new( + raw("red\ngreen", block: true), + number-enabled: false, + annotations: ((start: 1, end: 2, content: [unnumbered brace]),), + callouts: ((line: 1, body: [keeps brace column], fill: purple.lighten(80%)),), +) + +// An omitted source line has no callout after range filtering. +#metadata("range") +#codly.new( + raw("hidden\nshown", block: true), + range: (2,), + callouts: ((line: 1, body: [not shown]), (line: 2, body: [shown])), +) + +#context { + assert.eq(query().map(it => it.value), ( + (line: 1, body: "element defaults"), + (line: 2, body: "local fill override"), + (line: 2, body: "local none override"), + (line: 1, body: "full-width unnumbered callout"), + (line: 2, body: "spans the inactive annotation column"), + (line: 2, body: "first paragraphsecond paragraph"), + (line: 1, body: "keeps brace column"), + (line: 2, body: "shown"), + )) + // One active annotation has three source rows plus its callout; the other + // has two source rows plus its callout. + assert.eq(query().map(it => it.value), (4, 3)) +} diff --git a/tests/elements/test.typ b/tests/elements/test.typ new file mode 100644 index 0000000..dc36bf3 --- /dev/null +++ b/tests/elements/test.typ @@ -0,0 +1,246 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 285pt, height: auto, margin: 5pt) + +#let text-of(body) = { + if body.has("text") { + body.text + } else if body.has("child") { + text-of(body.child) + } else if body.has("children") { + body.children.map(text-of).join() + } else { + "" + } +} + +#let languages = (python: (name: [Python], color: red, icon: "P")) + +// Element-level rules receive resolved fields and are scoped to their block. +#{ + show: codly.set_(radius: 7pt) + show: codly.show_(it => { + let fields = e.fields(it) + [#metadata(( + radius: fields.radius, + body-block: fields.body.fields().at("block", default: false), + ))#it] + }) + show raw: it => { + if it.text == "inline" { + [#metadata(it.block)#it] + } else { + it + } + } + codly.new(raw("disabled", block: true)) + codly.new(raw("inline")) +} +#{ + show: codly.show_(it => { + let fields = e.fields(it) + [#metadata((radius: fields.radius))#it] + }) + codly.new(raw("enabled", block: true)) +} + +// selector() matches codly instances, while show_() transforms all codly +// instances in its scope. +#{ + show codly.selector(): it => { + let fields = e.fields(it) + [#metadata(true)#it] + } + codly.new(raw("selected", block: true)) + codly.new(raw("other", block: true)) +} + +// Language definitions and aliases are passed through to the language badge +// and syntax raw block respectively. +#{ + show: codly.lang-set_(languages: languages) + show: codly.lang-show_(it => { + let fields = e.fields(it) + [#metadata((body: fields.body, python: fields.languages.python.name))#it] + }) + show: codly.show_(it => { + let fields = e.fields(it) + let body-fields = if fields.body.func() == raw { fields.body.fields() } else { (:) } + [#metadata(body-fields.at("lang", default: none))#it] + }) + show: codly.line-show_(it => { + let fields = e.fields(it) + [#metadata(repr(fields.body.body))#it] + }) + codly.new(raw("let x = 1", block: true, lang: "python")) + codly.new( + raw("let x = 1", block: true, lang: "custom"), + aliases: (custom: "python"), + ) +} + +// Language badge visibility is independently configurable, and unknown keys +// fall back to their raw language name. +#{ + show: codly.lang-set_(languages: languages, display-name: false, display-icon: false) + show: codly.lang-show_(it => { + let fields = e.fields(it) + [#metadata(( + body: fields.body, + display-name: fields.display-name, + display-icon: fields.display-icon, + ))#it] + }) + codly.new(raw("hidden", block: true, lang: "python")) +} +#{ + show: codly.lang-set_(languages: languages) + show: codly.lang-show_(it => { + let fields = e.fields(it) + [#metadata(( + body: fields.body, + defined: fields.languages.at("missing", default: none), + ))#it] + }) + codly.new(raw("missing", block: true, lang: "missing")) +} + +// Plain header/footer content and pre-built element instances both retain +// their public fields when codly constructs the block grid. +#{ + show: e.show_(codly.codly-header, it => { + let fields = e.fields(it) + [#metadata((body: fields.body, fill: fields.fill))#it] + }) + show: e.show_(codly.codly-footer, it => { + let fields = e.fields(it) + [#metadata((body: fields.body, fill: fields.fill))#it] + }) + codly.codly-header([standalone-head], fill: red) + codly.codly-footer([standalone-foot], fill: blue) + codly.new(raw("one", block: true), header: [plain-head], footer: [plain-foot]) + codly.new( + raw("two", block: true), + header: codly.codly-header([element-head], fill: red), + footer: codly.codly-footer([element-foot], fill: blue), + ) +} + +// Header/footer set rules provide defaults for plain content; explicit +// element fields override those defaults in the resulting grid cells. +#{ + show: codly.header-set(fill: green) + show: e.set_(codly.codly-footer, fill: green) + show grid: it => { + let edges = () + for child in it.children { + if child.func() == grid.header or child.func() == grid.footer { + let cell = child.fields().children.first() + edges.push(( + kind: if child.func() == grid.header { "header" } else { "footer" }, + fill: cell.fields().fill, + )) + } + } + [#metadata(edges)#it] + } + codly.new(raw("style-one", block: true), header: [plain-header], footer: codly.codly-footer( + [explicit-footer], + fill: blue, + )) + codly.new( + raw("style-two", block: true), + header: codly.codly-header([explicit-header], fill: red), + footer: [plain-footer], + ) + codly.new( + raw("style-three", block: true), + header: codly.codly-header([inherited-header]), + footer: codly.codly-footer([inherited-footer]), + ) +} + +// Custom hooks can inspect line, number, highlight, and annotation elements. +#{ + show: codly.line-show_(it => { + let fields = e.fields(it) + [#metadata((number: fields.body.number, smart-indent: fields.smart-indent))#it] + }) + show: e.show_(codly.codly-number, it => { + [#metadata(e.fields(it).number)#it] + }) + show: codly.highlight-show_(it => { + let fields = e.fields(it) + [#metadata((body: fields.body, color: fields.color))#it] + }) + show: codly.annotation-show_(it => { + let fields = e.fields(it) + [#metadata((body: fields.body, num: fields.num))#it] + }) + codly.new( + raw("abc\ndef", block: true), + highlights: ((line: 1, start: 0, end: 2),), + annotations: ((start: 2, content: [note]),), + ) +} + +// Gradient and pattern fills exercise the main grid fill path. +#{ + show: codly.line-set_(fill: gradient.linear(red, blue)) + codly.new(raw("gradient", block: true)) +} +#{ + show: codly.line-set_(fill: tiling(size: (4pt, 4pt), relative: "parent", rect( + width: 2pt, + height: 2pt, + ))) + codly.new(raw("pattern", block: true)) +} + +#context { + assert.eq(query().first().value, (radius: 7pt, body-block: true)) + assert.eq(query().first().value, false) + assert.eq(query().first().value, (radius: 0.32em)) + assert.eq(query().map(it => it.value), (true, true)) + assert.eq(query().map(it => it.value.body), ("python", "custom")) + assert.eq(query().first().value.python, [Python]) + assert.eq(query().map(it => it.value), ("python", "custom", "python")) + let syntax = query().map(it => it.value) + assert.eq(syntax.len(), 2) + assert.eq(syntax.first(), syntax.last()) + assert.eq(query().first().value, ( + body: "python", + display-name: false, + display-icon: false, + )) + assert.eq(query().first().value, (body: "missing", defined: none)) + let headers = e.query(codly.codly-header) + assert.eq(headers.map(it => text-of(it)), ( + "standalone-head", + "plain-head", + "element-head", + "plain-header", + "explicit-header", + "inherited-header", + )) + let footers = e.query(codly.codly-footer) + assert.eq(footers.map(it => text-of(it)), ( + "standalone-foot", + "plain-foot", + "element-foot", + "explicit-footer", + "plain-footer", + "inherited-footer", + )) + let styled = query().map(it => it.value) + assert.eq(styled, ( + ((kind: "header", fill: green), (kind: "footer", fill: blue)), + ((kind: "header", fill: red), (kind: "footer", fill: green)), + ((kind: "header", fill: green), (kind: "footer", fill: green)), + )) + assert.eq(query().map(it => it.value.number), (1, 2)) + assert.eq(query().map(it => it.value), (1, 2)) + assert.eq(query().first().value.color, rgb("#283593")) + assert.eq(query().first().value, (body: [note], num: 1)) +} diff --git a/tests/errors/layout.typ b/tests/errors/layout.typ new file mode 100644 index 0000000..3915000 --- /dev/null +++ b/tests/errors/layout.typ @@ -0,0 +1,63 @@ +#import "../../codly.typ" as codly + +#set page(width: 160pt, height: auto, margin: 5pt) + +#let case = sys.inputs.at("case") +#show: codly.ref-set_(by: "item") + +#let cases = ( + filename-position: (file: "main.py", file-position: center), + language-position: (lang-position: bottom + center), + range-conflict: (range: (1, 2), ranges: ((2, 3),)), + annotation-overlap: (annotations: ((start: 1, end: 3), (start: 2, end: 3))), + annotation-touching: (annotations: ((start: 1, end: 2), (start: 2, end: 3))), + annotation-label: (annotations: ((start: 1, label: ),)), + item-without-tag: (block-label: , highlights: ((line: 1, label: ),)), + missing-offset: (offset-from: ), + callout-pointer-negative: (callouts: ((line: 1, pointer: -1, body: []),)), + callout-pointer-past-end: (callouts: ((line: 1, pointer: 20, body: []),)), + callout-negative-size: (callouts: ((line: 1, pointer: 1, pointer-size: -1pt, body: []),)), + callout-negative-width: (callouts: ((line: 1, pointer: 1, bubble-width: -1pt, body: []),)), + callout-negative-inset: (callouts: ((line: 1, pointer: 1, bubble-inset: -1pt, body: []),)), + callout-negative-gap: (callouts: ((line: 1, pointer: 1, bubble-gap: -1pt, body: []),)), + callout-negative-gap-y: (callouts: ((line: 1, pointer: 1, bubble-gap: (y: -1pt), body: []),)), + callout-gap-keys: (callouts: ((line: 1, pointer: 1, bubble-gap: (z: 2pt), body: []),)), + callout-gap-type: (callouts: ((line: 1, pointer: 1, bubble-gap: (x: "bad"), body: []),)), + callout-negative-pointer-height: ( + callouts: ((line: 1, pointer: 1, pointer-height: -1pt, body: []),), + ), + callout-negative-pointer-width: ( + callouts: ((line: 1, pointer: 1, pointer-width: -1pt, body: []),), + ), +) + +#if case == "empty-fill" { + show: codly.line-set_(fill: ()) + codly.new(raw("one", block: true)) +} else if case == "filename-missing" { + codly.new(path("no-such-source.py")) +} else if case == "bubble-negative-height" { + show: codly.bubble-set_(pointer-height: -1pt) + codly.new(raw("one", block: true), callouts: ((line: 1, pointer: 1, body: [Bad height]),)) +} else if case == "highlight-label" { + include "../issues/47-crash-label.typ" +} else if case == "duplicate-info" { + [ + #codly.new(raw("one", block: true)) + #parbreak() + #codly.new(raw("two", block: true)) + ] + context { codly.info() } +} else if case == "info-without-code" { + [#metadata(none)] + context { codly.info() } +} else if case == "missing-reference" { + ref() +} else if case in ("alias-theme", "alias-syntax") { + include "../aliases/errors.typ" +} else { + [#figure(caption: [Code])[ + #codly.new(raw("one\ntwo\nthree", block: true), ..cases.at(case)) + ]] + if case == "item-without-tag" { ref() } +} diff --git a/tests/errors/test.typ b/tests/errors/test.typ new file mode 100644 index 0000000..de826c5 --- /dev/null +++ b/tests/errors/test.typ @@ -0,0 +1,28 @@ +#import "../../codly.typ" as codly + +#set page(width: 160pt, height: auto, margin: 5pt) + +// Check immediate argument diagnostics; run.py covers deferred layout failures. +#let rejects(expected, args) = context { + let message = catch(() => measure(codly.new(raw("one\ntwo\nthree", block: true), ..args))) + assert(message != none, message: "accepted invalid arguments: " + repr(args)) + assert(message.contains(expected), message: "expected " + expected + ", got " + message) +} + +#for (expected, args) in ( + ("greater than 0", (highlights: ((line: 0),))), + ("at least 0", (highlights: ((line: 1, start: -1),))), + ("at least 0", (highlights: ((line: 1, end: -1),))), + ("greater than 0", (annotations: ((start: 0),))), + ("greater than 0", (annotations: ((start: 1, end: 0),))), + ("at least `start`", (annotations: ((start: 3, end: 2),))), + ("greater than 0", (callouts: ((line: 0, body: []),))), + ("placement", (callouts: ((line: 1, placement: "sideways", body: []),))), + ("bubble-align", (callouts: ((line: 1, bubble-align: "center", body: []),))), +) { + rejects(expected, args) +} + +#context { + assert(catch(() => codly.info()).contains("unique code block label")) +} diff --git a/tests/filename-layout/test.typ b/tests/filename-layout/test.typ new file mode 100644 index 0000000..81520be --- /dev/null +++ b/tests/filename-layout/test.typ @@ -0,0 +1,41 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e +#set page(width: 260pt, height: 160pt, margin: 8pt) +#show: codly.line-set_(fill: white) +#show: codly.header-set(repeat: true) +#show: e.set_(codly.codly-footer, repeat: true) +#show: codly.file-show_(it => [#metadata(e.fields(it).body)#it]) +#show: codly.lang-show_(it => [#metadata(e.fields(it).body)#it]) +#show: codly.line-show_(it => { + let line = e.fields(it).body + if line.func() == raw.line { [#metadata(line.text)#it] } else { it } +}) + +// Repeated headers and footers retain badges while normal line rendering, +// filtering, offset numbering, and labels stay on the shared code path. +#let source = range(1, 25).map(i => "print(" + str(i) + ")").join("\n") +#figure(caption: [A file listing])[ + #codly.new( + source, + file: "long.py", + file-position: top + left, + lang-position: bottom + right, + block-label: , + header: [Repeated header], + footer: [Repeated footer], + range: (3, 22), + offset: 10, + highlights: ((line: 14, start: 0, end: 5),), + callouts: ((line: 4, body: [A note]),), + ) +]+@listing:13 + +#context { + assert(counter(page).final().first() > 1) + let lines = query().map(m => m.value) + assert.eq(lines, range(3, 23).map(i => "print(" + str(i) + ")")) + assert(query().all(m => m.value == "long.py")) + assert(query().all(m => m.value == "py")) + assert.eq(codly.info().last-number, 32) +} diff --git a/tests/filename/fixtures/Makefile b/tests/filename/fixtures/Makefile new file mode 100644 index 0000000..434f34e --- /dev/null +++ b/tests/filename/fixtures/Makefile @@ -0,0 +1,2 @@ +all: + echo hello diff --git a/tests/filename/fixtures/hello.py b/tests/filename/fixtures/hello.py new file mode 100644 index 0000000..76f5111 --- /dev/null +++ b/tests/filename/fixtures/hello.py @@ -0,0 +1,4 @@ +def hello(name): + print(f"Hello, {name}!") + +hello("world") diff --git a/tests/filename/fixtures/nested/input.typ b/tests/filename/fixtures/nested/input.typ new file mode 100644 index 0000000..f788ad7 --- /dev/null +++ b/tests/filename/fixtures/nested/input.typ @@ -0,0 +1 @@ +#let nested = path("../hello.py") diff --git "a/tests/filename/fixtures/\303\251chantillon \"test\".py" "b/tests/filename/fixtures/\303\251chantillon \"test\".py" new file mode 100644 index 0000000..8086979 --- /dev/null +++ "b/tests/filename/fixtures/\303\251chantillon \"test\".py" @@ -0,0 +1 @@ +print("Unicode and quoted filenames") diff --git a/tests/filename/ref/1.png b/tests/filename/ref/1.png new file mode 100644 index 0000000..47cd1d5 Binary files /dev/null and b/tests/filename/ref/1.png differ diff --git a/tests/filename/ref/2.png b/tests/filename/ref/2.png new file mode 100644 index 0000000..0373c59 Binary files /dev/null and b/tests/filename/ref/2.png differ diff --git a/tests/filename/ref/3.png b/tests/filename/ref/3.png new file mode 100644 index 0000000..282825b Binary files /dev/null and b/tests/filename/ref/3.png differ diff --git a/tests/filename/test.typ b/tests/filename/test.typ new file mode 100644 index 0000000..aab8543 --- /dev/null +++ b/tests/filename/test.typ @@ -0,0 +1,130 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e +#set page(width: 300pt, height: auto, margin: 8pt) +#show: codly.line-set_(fill: white) + +#let sample(key, body, ..args) = { + show: codly.file-show_(it => [#metadata((key: key, fields: e.fields(it)))#it]) + show: codly.lang-show_(it => [#metadata((key: key, fields: e.fields(it)))#it]) + show: codly.line-show_(it => { + let line = e.fields(it).body + if line.func() == raw.line { [#metadata((key: key, line: line))#it] } else { it } + }) + codly.new(body, ..args) +} +#sample("path", path("fixtures/hello.py")) +#sample("string", "print(1)", file: "snippet.py") +#sample("override", path("fixtures/hello.py"), file: "renamed.rs", lang: "js") +#sample("hidden", path("fixtures/hello.py"), file: none, lang-position: none) +#sample("auto-width", "print(1)", file: "main.py", width: auto) +#sample("raw", raw("print(1)", block: true, lang: "py"), file: [*Raw input*]) +#sample("literal", "fixtures/hello.py", lang: none) +#sample("empty", "", file: "empty.txt", skip-last-empty: false) + +#context { + let files = query(selector().before(here())) + assert.eq(files.map(m => m.value.key), ( + "path", + "string", + "override", + "auto-width", + "raw", + "empty", + )) + assert.eq(files.at(0).value.fields.body, "hello.py") + assert.eq(files.at(2).value.fields.body, "renamed.rs") + let langs = query(selector().before(here())) + assert.eq(langs.map(m => m.value.fields.body), ("py", "py", "js", "py", "py", "txt")) + let lines(key) = query().filter(m => m.value.key == key).map(m => m.value.line.text) + assert.eq(lines("path").join("\n"), read("fixtures/hello.py").trim(at: end)) + assert.eq(lines("string"), ("print(1)",)) + assert.eq(lines("literal"), ("fixtures/hello.py",)) +} + +#pagebreak() +// All four corners for both badges; shared sides must not overlap. +#for (i, file-pos) in (top + left, top + right, bottom + left, bottom + right).enumerate() { + for (j, lang-pos) in (top + left, top + right, bottom + left, bottom + right).enumerate() { + sample( + "corner-" + str(i) + str(j), + "print(1)", + file: "main.py", + file-position: file-pos, + lang-position: lang-pos, + number-enabled: calc.even(i + j), + ) + } +} +#context { + for (i, file-pos) in (top + left, top + right, bottom + left, bottom + right).enumerate() { + for (j, lang-pos) in (top + left, top + right, bottom + left, bottom + right).enumerate() { + let key = "corner-" + str(i) + str(j) + let file = query().find(m => m.value.key == key).location().position() + let lang = query().find(m => m.value.key == key).location().position() + let source = query().find(m => m.value.key == key).location().position() + if file-pos.y == top { assert(file.y < source.y) } else { assert(file.y > source.y) } + if lang-pos.y == top { assert(lang.y < source.y) } else { assert(lang.y > source.y) } + if file-pos.y == lang-pos.y { + assert(calc.abs(file.y - lang.y) < 0.01pt, message: repr((key, file, lang))) + if file-pos.x == right and lang-pos.x == left { assert(file.x > lang.x + 15pt) } else { + assert(lang.x > file.x + 35pt) + } + } + } + } +} + +#pagebreak() +// Inputs imported from another directory retain their caller-owned path root. +#import "fixtures/nested/input.typ": nested +#sample("nested", nested) +#sample("quoted", path("fixtures/échantillon \"test\".py")) +#sample("no-extension", path("fixtures/Makefile")) +#sample("hidden-file", "print(1)", file: "main.py", file-position: none) +#{ + show: codly.set_(file-position: bottom + right, lang-position: top + left) + show: codly.file-set_( + fill: gradient.linear(aqua, white), + stroke: blue + 1pt, + inset: (x: 10pt, y: 6pt), + radius: (top-left: 8pt, bottom-right: 8pt), + ) + sample("inherited-position", path("fixtures/hello.py")) + sample( + "styled", + path("fixtures/hello.py"), + header: [Custom header], + footer: [Custom footer], + file-position: top + right, + aliases: (py: "python"), + annotations: ((start: 1, end: 2, content: [Note]),), + callouts: ((line: 2, pointer: 8, placement: "above", body: [Callout]),), + indent-guides: true, + rainbow: true, + ) +} +#sample("filename-only", "unknown source", file: "note.txt", lang: none) +#context { + let find(key) = query().find(m => m.value.key == key).value.fields + assert.eq(find("nested").body, "hello.py") + assert.eq(find("quoted").body, "échantillon \"test\".py") + assert.eq(find("no-extension").body, "Makefile") + assert.eq(query().filter(m => m.value.key == "no-extension").len(), 0) + assert.eq(query().filter(m => m.value.key == "hidden-file").len(), 0) + assert.eq(find("styled").inset, (x: 10pt, y: 6pt)) + assert.eq(type(find("styled").fill), gradient) + let inherited-file = query() + .find(m => m.value.key == "inherited-position") + .location() + .position() + let inherited-lang = query() + .find(m => m.value.key == "inherited-position") + .location() + .position() + let inherited-source = query() + .find(m => m.value.key == "inherited-position") + .location() + .position() + assert(inherited-lang.y < inherited-source.y and inherited-file.y > inherited-source.y) + assert.eq(query().find(m => m.value.key == "styled").value.fields.body, "py") +} diff --git a/tests/github-regressions/ref/1.png b/tests/github-regressions/ref/1.png new file mode 100644 index 0000000..562a857 Binary files /dev/null and b/tests/github-regressions/ref/1.png differ diff --git a/tests/github-regressions/test.typ b/tests/github-regressions/test.typ new file mode 100644 index 0000000..ff99815 --- /dev/null +++ b/tests/github-regressions/test.typ @@ -0,0 +1,87 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 240pt, height: auto, margin: 5pt) + +#let line-marker(it) = { + let fields = e.fields(it) + let body = fields.body + let value = if body.func() == raw.line { + (number: body.number, fill: fields.fill, text: body.text) + } else { + (number: "skip", fill: fields.fill, text: "") + } + [#metadata(value)#it] +} + +// #118: whole-line highlights remain effective when line numbers are hidden. +#{ + show: codly.line-set_(fill: luma(230)) + show: codly.highlight-set_(color: blue, fill: color => color) + show grid: it => { + assert.eq((it.fill)(0, 0), luma(230)) + assert.eq((it.fill)(0, 1), red) + assert.eq((it.fill)(0, 2), blue) + it + } + show: codly.line-show_(line-marker) + codly.new( + raw("one\ntwo\nthree", block: true), + number-enabled: false, + highlighted: ((2, red), 3), + ) +} + +// #106: a one dimensional number alignment setting must not hide a figure's +// text when numbers are disabled. +#for alignment in (left, top) [#figure[ + #{ + show: e.set_(codly.codly-number, align: alignment) + show: codly.line-show_(it => { + let body = e.fields(it).body + [#metadata(body.text)#it] + }) + codly.new(raw("Text\nText\nText\nText", block: true), number-enabled: false) + } +]] + +// #110: `rest` still inserts the gap before a final singleton range when +// `last` is disabled. +#{ + show: codly.line-show_(line-marker) + codly.new( + raw("first\n\nmiddle\n\nlast", block: true), + ranges: ((1, 1), (3, 3), (5, 5)), + smart-skip: (first: false, last: false, rest: true), + number-enabled: false, + ) +} + +// PR #115: an empty block remains constructible with no displayed line number. +#codly.new(raw("", block: true), number-enabled: false) + +// #94: complex line fills and headers can coexist. +#{ + show: codly.line-set_(fill: gradient.linear(red, blue)) + codly.new(raw("gradient", block: true), header: [header]) +} + +// #135: an explicit empty language fill is valid. +#{ + show: codly.lang-set_(fill: none, languages: (py: (name: "Python", color: green, fill: none))) + codly.new(raw("print", lang: "py", block: true)) +} + +#context { + let lines = query().map(it => it.value) + assert.eq(lines.at(0).text, "one") + assert.eq(lines.at(1).text, "two") + assert.eq(lines.at(2).text, "three") + assert.eq(lines.at(3).number, 1) + assert.eq(lines.at(4).number, "skip") + assert.eq(lines.at(5).number, 3) + assert.eq(lines.at(6).number, "skip") + assert.eq(lines.at(7).number, 5) + assert.eq(codly.info(), (last-number: none, lines: 1)) + assert.eq(query().map(it => it.value), ("Text",) * 8) +} diff --git a/tests/header-footer-fill/ref/1.png b/tests/header-footer-fill/ref/1.png new file mode 100644 index 0000000..4bef2b8 Binary files /dev/null and b/tests/header-footer-fill/ref/1.png differ diff --git a/tests/header-footer-fill/test.typ b/tests/header-footer-fill/test.typ new file mode 100644 index 0000000..5d40e71 --- /dev/null +++ b/tests/header-footer-fill/test.typ @@ -0,0 +1,87 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 240pt, height: auto, margin: 5pt) + +#let head = green.lighten(75%) +#let foot = purple.lighten(75%) +#let body = yellow.lighten(75%) +#let gutter = red.lighten(75%) +#let palette = (blue.lighten(75%), body, none) + +#let sample(mode, style) = { + let key = mode + "-" + style + let expected = if style == "defaults" { (luma(240), none) } else if style == "explicit" { + (head, foot) + } else { (none, none) } + let enabled = mode != "hidden" + show: codly.number-set_( + placement: if mode == "outside" { "outside" } else { "inside" }, + fill: gutter, + ) + show: codly.header-set(fill: head) + show: e.set_(codly.codly-footer, fill: foot) + show: codly.line-set_(fill: if style == "defaults" { gradient.linear(aqua, blue) } else if style + == "explicit" { + row => { + // Bands and geometry spacer rows must never reach the line callback. + assert.eq(row.kind, "code") + assert.eq(row.index, row.number) + assert.eq(row.at("source-line"), row.number) + body + } + } else { palette }) + show grid: it => { + if it.fill != none { + let bands = () + for child in it.children { + if child.func() in (grid.header, grid.footer) { + bands.push(child.children.first().fill) + } + } + assert.eq(bands, expected) + if style == "explicit" { + assert.eq((it.fill)(if enabled { 1 } else { 0 }, 1), body) + } else if style == "unfilled" { + assert.eq((it.fill)(if enabled { 1 } else { 0 }, 1), palette.at(1)) + assert.eq((it.fill)(if enabled { 1 } else { 0 }, 2), none) + } + } + it + } + [#metadata((key: key, outside: mode == "outside", expected: expected))] + [#text(size: 8pt, key)] + codly.new( + raw("first\nsecond", block: true), + number-enabled: enabled, + header: codly.codly-header( + [Header], + fill: if style == "defaults" { auto } else { expected.first() }, + ), + footer: codly.codly-footer( + [Footer], + fill: if style == "defaults" { auto } else { expected.last() }, + ), + ) + [#metadata(key)] +} + +#for mode in ("inside", "outside", "hidden") { + for style in ("defaults", "explicit", "unfilled") { sample(mode, style) } +} + +// Reconstructed outside backgrounds use the same element-owned band fills. +#context { + let ends = query() + for start in query().filter(it => it.value.outside) { + let end = ends.find(it => it.value == start.value.key) + let marks = query( + selector(<__codly-geometry>).after(start.location()).before(end.location()), + ).filter(it => it.value.kind == "cell-start") + for (role, expected) in ("header", "footer").zip(start.value.expected) { + let bands = marks.filter(it => it.value.role == role) + assert.eq(bands.len(), 1) + assert.eq(bands.first().value.fill, expected) + } + } +} diff --git a/tests/highlight-baseline/ref/1.png b/tests/highlight-baseline/ref/1.png new file mode 100644 index 0000000..33853ae Binary files /dev/null and b/tests/highlight-baseline/ref/1.png differ diff --git a/tests/highlight-baseline/test.typ b/tests/highlight-baseline/test.typ new file mode 100644 index 0000000..74a20dc --- /dev/null +++ b/tests/highlight-baseline/test.typ @@ -0,0 +1,36 @@ +#import "../../codly.typ" as codly + +#set page(width: 240pt, height: auto, margin: 5pt) + +#let mark(name) = [#metadata(name)word] +#let sample(highlight: none) = [ + #box(mark("plain")) #codly.codly-highlight(mark("highlight"), highlight: highlight) + #parbreak() +] + +#sample() +#sample(highlight: (line: 1, inset: (top: 2pt, bottom: 5pt))) +#sample(highlight: (line: 1, tag: [tag])) +#sample(highlight: (line: 1, baseline: 3pt)) +#sample(highlight: (line: 1, baseline: auto)) + +#{ + show: codly.highlight-set_(baseline: auto) + sample() +} + +// Issue #131 / PR #132, rendered through the complete raw-line pipeline. +#codly.new(highlights: ((line: 3, start: 3, end: 10, fill: orange),), raw( + "pub fn main() {\n println!(\"Hello!\");\n println!(\"Hello!\");\n}", + lang: "rust", + block: true, +)) + +#context { + let marks = query() + assert.eq(marks.len(), 12) + for i in range(0, marks.len(), step: 2) { + let shift = marks.at(i).location().position().y - marks.at(i + 1).location().position().y + assert.eq(shift, if i == 6 { -3pt } else { 0pt }) + } +} diff --git a/tests/highlight-nesting/test.typ b/tests/highlight-nesting/test.typ new file mode 100644 index 0000000..8682a54 --- /dev/null +++ b/tests/highlight-nesting/test.typ @@ -0,0 +1,166 @@ +#import "../../src/lib.typ": __codly-line-show, __codly-line-loop + +#set page(width: 160pt, height: auto, margin: 5pt) + +// Expose the nesting as delimiters so we can check the complete structure, +// including whether an outer highlight is emitted more than once. +#let wrap(body, highlight: none) = { + [<#highlight.tag>] + body + [] +} + +#let text-of(body) = { + if body.has("text") { + body.text + } else if body.has("children") { + body.children.map(text-of).join() + } else if body.has("child") { + text-of(body.child) + } else if body.has("body") { + text-of(body.body) + } else { + "" + } +} + +#let check(highlights, expected, body: [abcdefghi]) = { + show figure.where(kind: "__codly-raw-line"): it => [] + show raw.line: it => assert.eq(text-of(it.body), expected) + __codly-line-show(wrap, none, none, ( + body: raw.line(1, 1, "", body), + highlights: highlights.map(hl => (line: 1, ..hl)), + smart-indent: false, + block-label: none, + )) +} + +// Plain text, three nesting levels, sibling highlights, and gaps. +#check( + ( + (start: 2, end: 8, tag: "outer"), + (start: 3, end: 5, tag: "middle"), + (start: 4, end: 4, tag: "inner"), + (start: 7, end: 7, tag: "sibling"), + ), + "abcdefghi", +) + +// Shared starts and ends must preserve one wrapper per highlight. +#check( + ( + (start: 1, end: 9, tag: "outer"), + (start: 1, end: 3, tag: "left"), + (start: 7, end: 9, tag: "right"), + ), + "abcdefghi", +) + +// Equal spans retain the existing innermost-first input order. +#check( + ( + (start: 2, end: 8, tag: "first"), + (start: 2, end: 8, tag: "second"), + ), + "abcdefghi", +) + +// Adjacent highlights at the same depth remain separate. +#check( + ( + (start: 2, end: 4, tag: "left"), + (start: 5, end: 7, tag: "right"), + ), + "abcdefghi", +) + +// Crossing spans split as necessary to produce properly nested elements. +#check( + ( + (start: 2, end: 5, tag: "left"), + (start: 4, end: 7, tag: "right"), + ), + "abcdefghi", +) + +// A span continuing past the line is closed at the end of the content. +#check(((start: 3, end: 100, tag: "tail"),), "abcdefghi") +#check(((start: 20, end: 30, tag: "outside"),), "abcdefghi") +#check((), "abcdefghi") + +// Grapheme clusters stay whole when a boundary lands inside a combining +// character or an emoji. Positions use the source string's character offsets. +#check(((start: 2, end: 2, tag: "accent"),), "ábc", body: text("ábc")) +#check(((start: 2, end: 2, tag: "emoji"),), "a🙂b", body: text("a🙂b")) + +// Styled content is traversed for text checks and remains intact in a span. +#check( + ((start: 3, end: 6, tag: "styled"),), + "abcdefgh", + body: [ab#text(fill: red)[cd]#strong[ef]gh], +) + +// Empty spans emit nothing; equal geometry preserves distinct tags and order. +#check(((start: 4, end: 3, tag: "empty"),), "abcdef", body: [abcdef]) +#check( + ((start: 2, end: 2, tag: "first"), (start: 2, end: 2, tag: "second")), + "abc", + body: [abc], +) + +// Duplicate records collapse even when separated by another equal span. +#check( + ( + (start: 2, end: 8, tag: "first"), + (start: 2, end: 8, tag: "second"), + (start: 2, end: 8, tag: "first"), + ), + "abcdefghi", +) + +// Whitespace runs are atomic even when several boundaries fall inside them. +#check( + ( + (start: 2, end: 9, tag: "outer"), + (start: 5, end: 6, tag: "inner"), + ), + " ab cd e", + body: text(" ab cd e"), +) + +// Per-line indexing uses displayed numbers, including offsets from each skip. +#context { + let first = (line: 11, start: 1, end: 2) + let second = (line: 14, start: 1, end: 3) + let last = (line: 19, start: 2, end: 3) + let expected = ("11": (first,), "14": (second,), "15": (), "19": (last,)) + let render(line, highlights: none, ..args) = { + if line.func() == raw.line { + assert.eq(highlights, expected.at(str(line.number))) + } + line + } + let result = __codly-line-loop( + render, + number => [], + (first: false, rest: false, last: false), + range(1, 5).map(number => raw.line(number, 4, "abc", [abc])), + (), // annotations + (), // callouts + none, // ranges + ((position: 2, length: 2), (position: 4, length: 3)), + false, // skip-last-empty + false, // number-enabled + [skip], + [], // skip-number + none, // codly-annotation + none, // codly-callout + none, // codly-bubble + none, // codly-annotation-ref + none, // ref-set + (last, second, first, (line: 50, start: 1, end: 2)), + false, // smart-indent + none, // block-label + 10, // offset + [], // lang-block + ) + assert.eq(result.lines_to_number, (11, -99999999, 14, 15, -99999999, 19)) +} diff --git a/tests/highlight-style/test.typ b/tests/highlight-style/test.typ new file mode 100644 index 0000000..8ee7ab8 --- /dev/null +++ b/tests/highlight-style/test.typ @@ -0,0 +1,44 @@ +#import "../../codly.typ" as codly + +#set page(width: 240pt, height: auto, margin: 5pt) +#show: codly.highlight-set_( + color: red, + fill: color => color, + stroke: color => color + 1pt, + clip: false, +) + +#let check(expected, body) = { + show box: it => { + if it.body == [word] { + let baseline = it.baseline.at("shift", default: auto) + assert.eq((it.fill, it.stroke, it.clip, baseline), expected) + [#metadata(none)] + } + it + } + body +} + +#check((red, red + 1pt, false, 0pt), codly.codly-highlight([word])) +#check((blue, green + 2pt, true, 3pt), codly.codly-highlight([word], highlight: ( + line: 1, + fill: blue, + stroke: green + 2pt, + clip: true, + baseline: 3pt, +))) +#check((blue, blue + 1pt, false, 0pt), codly.codly-highlight([word], highlight: ( + line: 1, + fill: color => { + assert.eq(color, red) + blue + }, +))) + +#{ + show: codly.highlight-set_(fill: none, stroke: none) + check((none, none, false, 0pt), codly.codly-highlight([word])) +} + +#context assert.eq(query().len(), 4) diff --git a/tests/indent-guides-layout/test.typ b/tests/indent-guides-layout/test.typ new file mode 100644 index 0000000..e42b4d7 --- /dev/null +++ b/tests/indent-guides-layout/test.typ @@ -0,0 +1,119 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 200pt, height: 150pt, margin: 5pt) +#let source = ( + "root {\n child {\n long_call(argument_one, argument_two, argument_three);\n\n leaf();\n }\n}\n" + * 3 +) + +// Enabling guides preserves horizontal placement, wrapping, references, and page breaks. +#for (numbers, outside, smart) in ( + (true, false, true), + (true, true, true), + (false, false, true), + (true, false, false), +) { + context { + let origin = here() + for enabled in (false, true) { + pagebreak(weak: true) + show: e.set_(codly.codly-number, placement: if outside { "outside" } else { "inside" }) + show: codly.line-set_(stroke: 0.6pt + black) + show: codly.line-show_(it => [#metadata((enabled, e.fields(it).body.number))#it]) + codly.new( + raw(source, lang: "js", block: true), + indent-guides: enabled, + rainbow: true, + smart-indent: smart, + number-enabled: numbers, + header: [head], + footer: [foot], + radius: 4pt, + annotations: ((start: 2, end: 5, content: [note]),), + highlights: ((line: 3, start: 8, end: 17),), + sublangs: ((start: 3, end: 3, lang: "rs"),), + ) + } + context { + let runs = ((), ()) + for row in query(selector().after(origin).before(here())) { + runs + .at(if row.value.first() { 1 } else { 0 }) + .push((row.value.last(), row.location().position())) + } + assert.eq(runs.first().len(), 21) + assert.eq(runs.last().len(), 21) + let offset = runs.last().first().last().page - runs.first().first().last().page + for (before, after) in runs.first().zip(runs.last()) { + assert.eq(before.first(), after.first()) + let a = before.last() + let b = after.last() + assert.eq((a.x, a.page + offset), (b.x, b.page)) + // The geometry renderer redistributes padding in the last row of a + // spanning annotation at a page boundary. Keep this exception narrow. + if not outside and before.first() == 5 { + assert(b.y >= a.y and b.y - a.y <= 3pt) + } else { assert.eq(a.y, b.y) } + } + } + } +} + +#pagebreak() +// Splitting/styling whitespace must not change smart indentation. +#context { + let original = raw.line( + 1, + 1, + " wrapped source source source source source source", + text(" wrapped source source source source source source"), + ) + let split = raw.line( + 1, + 1, + original.text, + text(fill: red, " ") + + strong(" ") + + text("wrapped source source source source source source"), + ) + assert.eq(measure(codly.codly-line(original), width: 90pt), measure( + codly.codly-line(split), + width: 90pt, + )) +} + +// Native labels, source text, and highlight character coordinates survive. +@guide-code:12 @guide-highlight +#figure(caption: [Guides])[ + #codly.new( + raw("root\n call();", lang: "js", block: true), + block-label: , + offset: 10, + indent-guides: (rainbow: true, palette: (purple, orange)), + highlights: ((line: 12, start: 4, end: 8, label: ),), + ) +] +#context { + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(codly.info(), (last-number: 12, lines: 2)) +} + +#pagebreak() +// One logical row can span several pages: every fragment needs its guide. +#context { + let origin = here() + show line: it => [#metadata(it.end.last())#it] + codly.new(raw(" " + "long_argument " * 120, block: true), number-enabled: false, indent-guides: ( + width: 2, + x-offset: 0.5em, + )) + context { + let strokes = query(selector().after(origin).before(here())) + assert(strokes.len() >= 3) + assert(strokes.all(m => m.value > 0pt and m.value <= 140pt)) + let x = strokes.first().location().position().x + assert(strokes.all(m => calc.abs(m.location().position().x - x) < 0.001pt)) + } +} diff --git a/tests/indent-guides-offset/test.typ b/tests/indent-guides-offset/test.typ new file mode 100644 index 0000000..602688e --- /dev/null +++ b/tests/indent-guides-offset/test.typ @@ -0,0 +1,81 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 260pt, height: auto, margin: 5pt) +#assert.eq(e.fields(codly.indent-guides()).x-offset, 0pt) +#assert.eq(e.fields(codly.indent-guides(x-offset: -0.5em)).x-offset, -0.5em) +#assert(catch(() => codly.indent-guides(x-offset: "right")) != none) +#assert(catch(() => codly.indent-guides(x-offset: 10%)) != none) + +// Check the actual painted positions and source positions at two font sizes, +// with inside/outside/disabled numbers. Rainbow preparation must retain offsets. +#for size in (10pt, 16pt) { + set text(size: size) + for (numbers, outside) in ((true, false), (true, true), (false, false)) { + context { + let origin = here() + for shift in (0pt, 2pt, -2pt, 0.5em, -0.25em) { + show: e.set_(codly.codly-number, placement: if outside { "outside" } else { "inside" }) + show: codly.line-show_(it => [#metadata(shift)#it]) + show line: it => context { + [#metadata(( + shift: shift.to-absolute(), + color: it.stroke.paint, + height: it.end.last(), + ))#it] + } + codly.new( + raw( + "root {\n child {\n call(long_argument, another_long_argument);\n }\n}", + lang: "js", + block: true, + ), + number-enabled: numbers, + rainbow: (palette: (red, blue)), + indent-guides: (width: 4, x-offset: shift), + ) + } + context { + let strokes = query(selector().after(origin).before(here())) + let rows = query(selector().after(origin).before(here())) + assert.eq(strokes.len(), 20) + assert.eq(rows.len(), 25) + let baseline = strokes.slice(0, 4) + let base-rows = rows.slice(0, 5) + for variant in range(1, 5) { + let moved = strokes.slice(variant * 4, (variant + 1) * 4) + for (a, b) in baseline.zip(moved) { + let delta = b.location().position().x - a.location().position().x + assert(calc.abs(delta - b.value.shift) < 0.001pt) + assert.eq(a.value.color, b.value.color) + assert(calc.abs((a.value.height - b.value.height).length) < 0.001pt) + } + let moved-rows = rows.slice(variant * 5, (variant + 1) * 5) + let dy = ( + moved-rows.first().location().position().y - base-rows.first().location().position().y + ) + for (a, b) in base-rows.zip(moved-rows) { + assert.eq(a.location().position().x, b.location().position().x) + assert(calc.abs(b.location().position().y - a.location().position().y - dy) < 0.001pt) + } + } + } + } + } +} + +// Extreme offsets must not paint into line numbers or annotation cells. +#context { + let origin = here() + show line: it => [#metadata(none)#it] + for shift in (-1000pt, 1000pt) { + codly.new( + raw("root\n child", block: true), + annotations: ((start: 2, content: [note]),), + indent-guides: (x-offset: shift), + ) + } + context { + assert.eq(query(selector().after(origin).before(here())).len(), 0) + } +} diff --git a/tests/indent-guides/ref/1.png b/tests/indent-guides/ref/1.png new file mode 100644 index 0000000..bec6a96 Binary files /dev/null and b/tests/indent-guides/ref/1.png differ diff --git a/tests/indent-guides/test.typ b/tests/indent-guides/test.typ new file mode 100644 index 0000000..718333e --- /dev/null +++ b/tests/indent-guides/test.typ @@ -0,0 +1,194 @@ +#import "../../codly.typ" as codly +#import "../../src/indent.typ" as ind +#import "@preview/elembic:1.1.1" as e + +#set page(width: 260pt, height: auto, margin: 5pt) + +#let scan(source, ..options) = ind.scan(source.split("\n"), ..options) +#for width in (1, 2, 3, 4, 8) { + let source = ( + "root\n" + + " " * width + + "child\n" + + " " * (width * 2) + + "leaf\n" + + " " * width + + "sibling\nroot" + ) + assert.eq(scan(source), (width: width, depths: (0, 1, 2, 1, 0))) +} +#assert.eq(scan(""), (width: 4, depths: (0,))) +#assert.eq(scan("a\nb\nc"), (width: 4, depths: (0, 0, 0))) +#assert.eq(scan("\n \n \n"), (width: 4, depths: (0, 0, 0, 0))) +#assert.eq( + scan("root\n child\n leaf\n aligned\n leaf\n child\nroot"), + (width: 4, depths: (0, 1, 2, 3, 2, 1, 0)), +) +#assert.eq(scan(" cropped\n leaf\n cropped"), (width: 4, depths: (1, 2, 1))) +#assert.eq(scan("a\n b\n alignment\n c", width: 4).depths, (0, 1, 1, 1)) +#assert.eq(scan("\n a\n\n \n b\n\n c\n \n", width: 4).depths, ( + 0, + 1, + 1, + 1, + 2, + 1, + 1, + 0, + 0, +)) +#assert.eq(scan(" a\n\n b", width: 4, blank-lines: false).depths, (1, 0, 1)) +#assert.eq(" é😀".match(ind.leading-spaces).text, " ") +#assert.eq("\u{a0}text".match(ind.leading-spaces).text, "") +#assert.eq(" text".match(ind.leading-spaces).text, "") + +// Match rendered whitespace across tab stops and CRLF, including a mixed prefix. +#for size in (2, 4, 8) { + set raw(tab-size: size) + show raw: it => { + assert.eq(it.lines.map(l => l.text), ( + "root", + " " * size + "child", + " " * (2 * size) + "leaf", + " " * (size + 1) + "mixed", + " " * size + "child", + "root", + )) + assert.eq(ind.scan(it.lines.map(l => l.text)), (width: size, depths: (0, 1, 2, 1, 1, 0))) + } + raw("root\r\n\tchild\r\n\t\tleaf\r\n \t mixed\r\n\tchild\r\nroot", block: true) +} + +#let colors = (red, blue) +#assert.eq(e.fields(codly.new(none, indent-guides: true)).indent-guides.enabled, true) +#assert.eq(e.fields(codly.new(none, indent-guides: false)).indent-guides.enabled, false) +#assert.eq(e.fields(codly.indent-guides(palette: colors)).palette, colors) +#let style = ind.settings(e.fields(codly.indent-guides()), codly.rainbow( + palette: colors, + depth-offset: 1, +)) +#assert.eq(style.palette, colors) +#assert.eq(style.depth-offset, 1) +#assert.eq( + ind + .settings(e.fields(codly.indent-guides(rainbow: false, color: green)), codly.rainbow()) + .palette, + (green,), +) +#assert.eq( + ind.settings(e.fields(codly.indent-guides(rainbow: true, palette: colors)), none).palette, + colors, +) + +// Hidden rows still determine width; offsets and inserted skips only affect labels. +#context { + let origin = here() + codly.new( + raw("root {\n first {\n leaf\n }\n}", lang: "rs", block: true), + indent-guides: true, + rainbow: (palette: colors, depth-offset: 1), + range: (3, 3), + offset: 20, + skips: ((position: 3, length: 5),), + ) + context { + let marks = query(selector(<__codly-geometry>).after(origin).before(here())).filter(m => ( + m.value.kind == "cell-start" and m.value.at("guides", default: ()) != () + )) + assert.eq(marks.len(), 1) + assert.eq(marks.first().value.guides.map(g => g.color), (blue, red)) + } +} + +// Explicit monochrome overrides rainbow; local false suppresses inherited guides. +#context { + let origin = here() + show: codly.set_(indent-guides: true) + codly.new(raw("a\n b", block: true), indent-guides: false) + codly.new(raw("flat\ncode", block: true)) + context { + assert.eq(query(selector(<__codly-geometry>).after(origin).before(here())).len(), 0) + } +} + +#context { + let origin = here() + codly.new( + raw("{\n {\n x\n }\n}", lang: "js", block: true), + rainbow: true, + indent-guides: (rainbow: false, color: green, thickness: 0.04em), + ) + context { + let marks = query(selector(<__codly-geometry>).after(origin).before(here())).filter(m => ( + m.value.kind == "cell-start" + )) + for m in marks { assert(m.value.guides.all(g => g.color == green)) } + } +} + +// Diagnostics go through constructors and public block casts. +#for (options, message) in ( + ((width: 0), "width must be positive"), + ((width: -2), "width must be positive"), + ((palette: ()), "palette must not be empty"), + ((depth-offset: -1), "depth-offset must be nonnegative"), + ((thickness: 0pt), "thickness must be positive"), + ((thickness: -1pt), "thickness must be positive"), +) { + let error = catch(() => measure(codly.new(raw("a\n b", block: true), indent-guides: options))) + assert(error != none and error.contains(message)) +} +#for options in ((width: 2.5), (rainbow: "yes"), (palette: ("red",)), (blank-lines: 1)) { + assert(catch(() => measure(codly.new(raw("a", block: true), indent-guides: options))) != none) +} + +// Verify actual strokes, palette cycling, and full-height wrapped guides. +#context { + let origin = here() + show line: it => [#metadata((end: it.end, stroke: it.stroke))#it] + codly.new( + raw( + "root\n a\n b\n c\n long_call(argument_one, argument_two, argument_three, argument_four)", + block: true, + ), + indent-guides: (width: 2, rainbow: true, palette: colors, depth-offset: 1), + ) + context { + let strokes = query(selector().after(origin).before(here())).map(m => m.value) + assert.eq(strokes.len(), 10) + assert.eq(strokes.map(s => s.stroke.paint), ( + blue, + blue, + red, + blue, + red, + blue, + blue, + red, + blue, + red, + )) + assert(strokes.all(s => s.end.last() > 0pt)) + assert(strokes.last().end.last() > strokes.first().end.last()) + } +} + +// Disabling smart indentation limits strokes to the first visual row. +#context { + let origin = here() + for smart in (false, true) { + show line: it => [#metadata((smart, it.end.last()))#it] + codly.new( + raw(" call(" + "long_argument, " * 14 + ")", block: true), + indent-guides: (width: 2), + smart-indent: smart, + ) + } + context { + let lengths = query(selector().after(origin).before(here())).map( + m => m.value.last(), + ) + assert.eq(lengths.len(), 2) + assert(lengths.last() > lengths.first()) + } +} diff --git a/tests/issues/108-dark-theme.tmTheme b/tests/issues/108-dark-theme.tmTheme new file mode 100644 index 0000000..2db76a4 --- /dev/null +++ b/tests/issues/108-dark-theme.tmTheme @@ -0,0 +1,12 @@ + + +nameCodly Dark Theme Regression +settings +settings +foreground#a9b1d6 +background#1a1b26 + +scopekeywordsettings +foreground#bb9af7 + + diff --git a/tests/issues/108-dark-theme.typ b/tests/issues/108-dark-theme.typ new file mode 100644 index 0000000..46c54d5 --- /dev/null +++ b/tests/issues/108-dark-theme.typ @@ -0,0 +1,49 @@ +#import "../../codly.typ" as codly + +#set page(width: 270pt, height: auto, margin: 5pt) + +// #108: style the code rows, leaving badges and syntax token colors independent. +#{ + let foreground = rgb("#a9b1d6") + let keyword = rgb("#bb9af7") + let background = rgb("#1a1b26") + set raw(theme: path("108-dark-theme.tmTheme")) + show raw.line: set text(fill: foreground) + show: codly.line-set_(fill: background) + show: codly.number-set_(placement: "outside", fill: none) + show: codly.highlight-set_(fill: color => color) + show: codly.lang-set_(languages: (py: (name: "Python", color: blue))) + show text: it => context { + if it.text.trim() != "" { + [#metadata((it.text, text.fill))#it] + } else { + it + } + } + + codly.new(raw("return identifier", lang: "py", block: true), highlights: ( + (line: 1, start: 7, end: 17, fill: rgb("#343b58")), + )) + + // Explicitly styled names must remain customizable. + { + show: codly.lang-set_(languages: ( + py: (name: text(fill: white)[Custom], color: blue, fill: background), + )) + codly.new(raw("return identifier", lang: "py", block: true)) + } + + context { + let colors = query().map(it => it.value) + assert.eq( + colors.filter(it => it.last() == foreground).map(it => it.first()).join(), + "identifieridentifier", + ) + assert.eq(colors.filter(it => it.first() == "return"), ( + ("return", keyword), + ("return", keyword), + )) + assert.eq(colors.filter(it => it.first() == "Python"), (("Python", black),)) + assert.eq(colors.filter(it => it.first() == "Custom"), (("Custom", white),)) + } +} diff --git a/tests/issues/131-typstv15-baseline.typ b/tests/issues/131-typstv15-baseline.typ new file mode 100644 index 0000000..6544be6 --- /dev/null +++ b/tests/issues/131-typstv15-baseline.typ @@ -0,0 +1,15 @@ +#import "../../codly.typ" as codly + +#set page(width: 240pt, height: auto, margin: 5pt) + +#codly.new( + highlights: ( + (line: 3, start: 3, end: 10, fill: orange), + ), + ```rust + pub fn main() { + println!("Hello, world!"); + println!("Hello, world!"); + } + ```, +) diff --git a/tests/issues/133-nested-highlights.typ b/tests/issues/133-nested-highlights.typ new file mode 100644 index 0000000..e04701c --- /dev/null +++ b/tests/issues/133-nested-highlights.typ @@ -0,0 +1,32 @@ +#import "../../codly.typ" as codly + +#set page(width: 240pt, height: auto, margin: 5pt) + +#let example(code) = { + table( + columns: (1fr, .6fr), + table.header[*Code*][*Result*], + raw(block: true, lang: "typ", code.text), + eval(code.text, mode: "markup", scope: (codly: codly)), + ) +} + +#codly.new( + ``` + this is a test + ```, + highlights: ( + (line: 1, start: 1, fill: green, tag: [Outer]), + (line: 1, start: 6, end: 9, fill: red, tag: [Inner]), + ), +) + +#codly.new( + ``` + this is a test + ```, + highlights: ( + (line: 1, start: 1, fill: green, tag: [Outer]), + (line: 1, start: 6, fill: red, tag: [Inner]), + ), +) diff --git a/tests/issues/22-highlight-align.typ b/tests/issues/22-highlight-align.typ index 538a5f7..70c87a5 100644 --- a/tests/issues/22-highlight-align.typ +++ b/tests/issues/22-highlight-align.typ @@ -1,41 +1,42 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly -#set page(width: 300pt, height: auto) -#show: codly-init.with() - -#codly( +#set page(width: 240pt, height: auto, margin: 5pt) +#show: codly.lang-set_( display-icon: false, display-name: false, ) #grid(columns: 3)[ -#codly(highlights: ((line: 2, start: 0, end: none), )) -```kotlin -val n = 0 -val n = 0 -``` + #codly.new(highlights: ((line: 2, start: 0, end: none),))[ + ```kotlin + val n = 0 + val n = 0 + ``` + ] ][ - #codly(highlights: ((line: 1, start: 0, end: none), )) - #local( - highlight-inset: 0em, - highlight-outset: 0.32em, - highlight-clip: true, - )[ + #show: codly.highlight-set_( + inset: 0em, + outset: 0.32em, + clip: true, + ) + + #codly.new(highlights: ((line: 1, start: 0, end: none),))[ ```kotlin -val n = 0 -val n = 0 + val n = 0 + val n = 0 ``` - ] + ] ][ - #codly(highlights: ((line: 2, start: 0, end: none), )) - #local( - highlight-inset: 0em, - highlight-outset: 0.32em, - highlight-clip: true, - )[ + #show: codly.highlight-set_( + inset: 0em, + outset: 0.32em, + clip: true, + ) + + #codly.new(highlights: ((line: 2, start: 0, end: none),))[ ```kotlin -val n = 0 -val n = 0 + val n = 0 + val n = 0 ``` ] ] diff --git a/tests/issues/32-spacing.typ b/tests/issues/32-spacing.typ index a3a3005..cdffc22 100644 --- a/tests/issues/32-spacing.typ +++ b/tests/issues/32-spacing.typ @@ -1,8 +1,8 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly -// Remove these lines to see the difference -#show: codly-init.with() -#codly(number-format: none, stroke: none, display-icon: false, display-name: false, inset: 0.205em) + +#show: codly.line-set_(stroke: none, inset: 0.205em) +#show: codly.lang-set_(display-icon: false, display-name: false) #let code = ```py import pandas as pd @@ -48,9 +48,9 @@ def preprocess_headers(data: pd.DataFrame) -> None: data["Class"] = pd.Categorical(data["Class"], ordered=True, categories=["L", "M", "H"]) ``` -#set page("a3") +#set page(width: 470pt, height: auto, margin: 5pt) #columns(2)[ - #code + #codly.new(number-enabled: false, code) #colbreak() - #no-codly(code) -] \ No newline at end of file + #code +] diff --git a/tests/issues/36-highlight-bug.typ b/tests/issues/36-highlight-bug.typ index 2b29ef7..eb5c66a 100644 --- a/tests/issues/36-highlight-bug.typ +++ b/tests/issues/36-highlight-bug.typ @@ -1,20 +1,18 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly #set page(height: auto, width: 10cm, margin: 1em) -#show: codly-init.with() -#codly( +#codly.new( highlights: ( (line: 1, start: 14, end: 18, fill: blue), - (line: 3, start: 5, end:10, fill: green), - (line: 3, start: 24, end:38, fill: blue), + (line: 3, start: 5, end: 10, fill: green), + (line: 3, start: 24, end: 38, fill: blue), ), -) - -```java -public class MyApp { - public static void main(String[] args) { - System.out.println("Hello, world!"); + ```java + public class MyApp { + public static void main(String[] args) { + System.out.println("Hello, world!"); + } } -} -``` \ No newline at end of file + ```, +) diff --git a/tests/issues/39-offset-by-name.typ b/tests/issues/39-offset-by-name.typ index 8471619..cc4306a 100644 --- a/tests/issues/39-offset-by-name.typ +++ b/tests/issues/39-offset-by-name.typ @@ -1,10 +1,9 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly -#show: codly-init.with() +#set page(height: auto, margin: 5pt, width: 450pt) = First part -#codly(offset: 10) -```py +#codly.new(offset: 10, ```py import pandas as pd def preprocess_headers(data: pd.DataFrame) -> None: @@ -27,11 +26,10 @@ def preprocess_headers(data: pd.DataFrame) -> None: inplace=True, errors="raise", ) -``` +```) = Second part -#codly(offset-from: ) -```py +#codly.new(offset-from: , ```py # Replace values with correct ones data.loc[data["Relation"] == "Mum", "Relation"] = "Mother" data.loc[data["Nationality"] == "KW", "Nationality"] = "Kuwait" @@ -50,13 +48,13 @@ def preprocess_headers(data: pd.DataFrame) -> None: grade_ids = [f"G-{i:02d}" for i in range(1, 13)] data["GradeID"] = data["GradeID"].cat.set_categories(grade_ids, ordered=True) data["Class"] = pd.Categorical(data["Class"], ordered=True, categories=["L", "M", "H"]) -``` +```) = No offset -```py +#codly.new(```py def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) fib(11) -``` \ No newline at end of file +```) diff --git a/tests/issues/40-header-no-number.typ b/tests/issues/40-header-no-number.typ index 450951a..1924a9c 100644 --- a/tests/issues/40-header-no-number.typ +++ b/tests/issues/40-header-no-number.typ @@ -1,24 +1,22 @@ -#import "../../codly.typ": codly-init, codly +#import "../../codly.typ" as codly -#show: codly-init +#set page(height: auto, margin: 5pt, width: 450pt) -#codly(header: [ Hello, world! ], footer: [ Goodbye, world! ]) -```py +#codly.new(header: [ Hello, world! ], footer: [ Goodbye, world! ], ```py def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) print(fib(10)) -``` +```) -#codly(number-format: none, header: [ Hello, world! ], footer: [ Goodbye, world! ]) -```py +#codly.new(number-enabled: false, header: [ Hello, world! ], footer: [ Goodbye, world! ], ```py def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) print(fib(10)) -``` +```) diff --git a/tests/issues/47-crash-label.typ b/tests/issues/47-crash-label.typ index 1c65f84..c511551 100644 --- a/tests/issues/47-crash-label.typ +++ b/tests/issues/47-crash-label.typ @@ -1,11 +1,9 @@ -#import "../../codly.typ": * -#show: codly-init.with() +#import "../../codly.typ" as codly #set page(height: auto, margin: 5pt, width: 250pt) -#codly(highlights: ((line: 2, start: 3, label: ), )) -```rs +#codly.new(highlights: ((line: 2, start: 3, label: ),), ```rs pub fn main() { println!("Hello, World!"); } -``` \ No newline at end of file +```) diff --git a/tests/issues/50-skips-no-reset.typ b/tests/issues/50-skips-no-reset.typ index 59c92e3..daad669 100644 --- a/tests/issues/50-skips-no-reset.typ +++ b/tests/issues/50-skips-no-reset.typ @@ -1,41 +1,37 @@ -#import "../../codly.typ": * -#show: codly-init.with() +#import "../../codly.typ" as codly #set page(height: auto, margin: 5pt, width: 450pt) = Should have a skip -#codly( -skips: ((9,9),) -) -```py +#codly.new(skips: ((9, 9),), ```py def start_server(): # Create a socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - + # Setup address and port - host = '10.60.10.26' + host = '10.60.10.26' port = 12345 server_socket.bind((host, port)) - - # Start waiting for connections + + # Start waiting for connections server_socket.listen(1) print(f"Server listening on {host}:{port}...") - ``` +```) = Should not have a skip -```py +#codly.new(```py def start_server(): # Create a socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - + # Setup address and port - host = '10.60.10.26' + host = '10.60.10.26' port = 12345 server_socket.bind((host, port)) - - # Start waiting for connections + + # Start waiting for connections server_socket.listen(1) print(f"Server listening on {host}:{port}...") - ``` \ No newline at end of file +```) diff --git a/tests/issues/52-skip-line-disabled.typ b/tests/issues/52-skip-line-disabled.typ index d03371b..5980ef1 100644 --- a/tests/issues/52-skip-line-disabled.typ +++ b/tests/issues/52-skip-line-disabled.typ @@ -1,12 +1,7 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly -#set page(width: 300pt, height: auto) -#show: codly-init.with() - -#codly-skip(2, 4) -#codly(number-format: none) - -```txt +#set page(width: 240pt, height: auto, margin: 5pt) +#codly.new(number-enabled: false, skips: ((2, 4),), ```txt a b c @@ -14,11 +9,9 @@ d e f g -``` +```) -#codly-skip(2, 4) -#codly(number-format: numbering.with("1")) -```txt +#codly.new(skips: ((2, 4),), ```txt a b c @@ -26,4 +19,4 @@ d e f g -``` \ No newline at end of file +```) diff --git a/tests/issues/56-contextual-funcs.typ b/tests/issues/56-contextual-funcs.typ index 2a5b48d..b6f59ed 100644 --- a/tests/issues/56-contextual-funcs.typ +++ b/tests/issues/56-contextual-funcs.typ @@ -1,11 +1,15 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly +#set page(width: 240pt, height: auto, margin: 5pt) -#local( - fill: () => red -)[ - ```rs - pub fn main() { - println!("Hello, World!"); - } - ``` -] \ No newline at end of file +// An explicit palette preserves alternating rows; a scalar fills every row. +#show: codly.line-set_(fill: (luma(240), red)) +#show grid: it => { + assert.eq(range(3).map(y => (it.fill)(1, y)), (luma(240), red, luma(240))) + it +} + +#codly.new(```rs +pub fn main() { + println!("Hello, World!"); +} +```) diff --git a/tests/issues/63-continue-highlights.typ b/tests/issues/63-continue-highlights.typ index 8fdf6e8..3e2e3a7 100644 --- a/tests/issues/63-continue-highlights.typ +++ b/tests/issues/63-continue-highlights.typ @@ -1,9 +1,7 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly -#set page(width: 300pt, height: 300pt) -#show: codly-init.with() -#codly( - enabled: true, +#set page(width: 240pt, height: auto, margin: 5pt) +#codly.new( annotations: ( (start: 1), (start: 3), @@ -11,15 +9,15 @@ (start: 7), (start: 9), ), + ``` + a + b + c + d + e + f + g + h + i + ```, ) -``` -a -b -c -d -e -f -g -h -i -``` \ No newline at end of file diff --git a/tests/issues/67-show-rule-regex.typ b/tests/issues/67-show-rule-regex.typ new file mode 100644 index 0000000..1a44e3a --- /dev/null +++ b/tests/issues/67-show-rule-regex.typ @@ -0,0 +1,23 @@ +#import "../../codly.typ" as codly + +#set page(width: 240pt, height: auto, margin: 5pt) + +#show raw.where(block: true): codly.new + +#lorem(30) + +#show raw.where(lang: "foo_lang"): it => [ + #show regex("\b(for|to|begin|end)\b"): keyword => text(weight: "bold", keyword) + #it +] + +#figure(kind: raw, caption: "my_code")[ + ```foo_lang + for i=1 to 10 + begin + print(i) + end + ``` +] + +In @lst1-fig, #lorem(10). diff --git a/tests/issues/71-no-highlights-in-local.typ b/tests/issues/71-no-highlights-in-local.typ index ae4e445..390eba1 100644 --- a/tests/issues/71-no-highlights-in-local.typ +++ b/tests/issues/71-no-highlights-in-local.typ @@ -1,21 +1,17 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly -#show: codly-init.with() +#set page(width: 240pt, height: auto, margin: 5pt) = This does not work -#local( - raw("System.out.println();", lang: "java", block: true), - highlights: ( - (line: 1, start: 1, end: 6), - ), -) +#{ + show: codly.set_(highlights: ((line: 1, start: 1, end: 6),)) + codly.new(raw("System.out.println();", lang: "java", block: true)) +} == This works -#codly( - highlights: ( - (line: 1, start: 1, end: 6), - ), +#codly.new( + highlights: ((line: 1, start: 1, end: 6),), + raw("System.out.println();", lang: "java", block: true), ) -#raw("System.out.println();", lang: "java", block: true) \ No newline at end of file diff --git a/tests/issues/81-highlight-inset-with-tags.typ b/tests/issues/81-highlight-inset-with-tags.typ index 76c1f0a..790da49 100644 --- a/tests/issues/81-highlight-inset-with-tags.typ +++ b/tests/issues/81-highlight-inset-with-tags.typ @@ -1,11 +1,11 @@ -#import "../../codly.typ": * -#show: codly-init +#import "../../codly.typ" as codly -#codly( - highlight-inset: (x: 0.2em, y: 0pt), +#set page(width: 240pt, height: auto, margin: 5pt) + +#show: codly.highlight-set_(inset: (x: 0.2em, y: 0pt)) +#codly.new( highlights: ((line: 1, tag: "(1)"),), + ``` + code + ```, ) - -``` -code -``` \ No newline at end of file diff --git a/tests/issues/85-annotation-labels.typ b/tests/issues/85-annotation-labels.typ index 7124f49..56205a7 100644 --- a/tests/issues/85-annotation-labels.typ +++ b/tests/issues/85-annotation-labels.typ @@ -1,46 +1,46 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly - -#show: codly-init +#set page(width: 240pt, height: auto, margin: 5pt) +#show: codly.ref-set_(by: "item") @test1 #figure(caption: "Test")[ - #codly( - reference-by: "item", + #codly.new( + block-label:
, annotations: ( (start: 2, label: ), - ) - ) - ```py - print("Hello, world!") + ), + ```py + print("Hello, world!") - for i in range(1, 5): - print(i) + for i in range(1, 5): + print(i) - if i == 3: - print("Three!") - ``` + if i == 3: + print("Three!") + ```, + ) ]
@test2 @test3 #figure(caption: "Test")[ - #codly( - reference-by: "item", + #codly.new( + block-label: , annotations: ( (start: 2, label: ), (start: 4, label: ), - ) - ) - ```py - print("Hello, world!") + ), + ```py + print("Hello, world!") - for i in range(1, 5): - print(i) + for i in range(1, 5): + print(i) - if i == 3: - print("Three!") - ``` -] \ No newline at end of file + if i == 3: + print("Three!") + ```, + ) +] diff --git a/tests/issues/89-nested-highlights.typ b/tests/issues/89-nested-highlights.typ index 386e8e9..3766f36 100644 --- a/tests/issues/89-nested-highlights.typ +++ b/tests/issues/89-nested-highlights.typ @@ -1,14 +1,15 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly +#set page(width: 240pt, height: auto, margin: 5pt) -#show: codly-init - -#codly(highlights: ( - (line: 2, start: 10, end: 10, fill: green), - (line: 2, start: 14, end: 14, fill: red), - (line: 2, start: 10, end: 14, fill: blue), -)) -```py -def add(x, y): - return x + y -``` \ No newline at end of file +#codly.new( + highlights: ( + (line: 2, start: 10, end: 10, fill: green), + (line: 2, start: 14, end: 14, fill: red), + (line: 2, start: 10, end: 14, fill: blue), + ), + ```py + def add(x, y): + return x + y + ```, +) diff --git a/tests/issues/91-end-of-file-newline.txt b/tests/issues/91-end-of-file-newline.txt new file mode 100644 index 0000000..efff04d --- /dev/null +++ b/tests/issues/91-end-of-file-newline.txt @@ -0,0 +1,2 @@ +// test.txt +test diff --git a/tests/issues/91-end-of-file-newline.typ b/tests/issues/91-end-of-file-newline.typ new file mode 100644 index 0000000..47f124f --- /dev/null +++ b/tests/issues/91-end-of-file-newline.typ @@ -0,0 +1,25 @@ +#import "../../codly.typ" as codly + +#set page(width: 240pt, height: auto, margin: 5pt) +#show: codly.line-set_( + stroke: 2pt + red, +) +#show: codly.number-set_(placement: "outside", fill: none) + +#codly.new( + raw(read("./91-end-of-file-newline.txt"), block: true), +) + +#codly.new( + raw(read("./91-end-of-file-newline.txt").trim("\n"), block: true), +) + +#show: codly.number-set_(placement: "inside", fill: auto) + +#codly.new( + raw(read("./91-end-of-file-newline.txt"), block: true), +) + +#codly.new( + raw(read("./91-end-of-file-newline.txt").trim("\n"), block: true), +) diff --git a/tests/issues/96-multiline-highlights.typ b/tests/issues/96-multiline-highlights.typ new file mode 100644 index 0000000..2cec38b --- /dev/null +++ b/tests/issues/96-multiline-highlights.typ @@ -0,0 +1,32 @@ +#import "../../codly.typ" as codly + +#set page(width: 300pt, height: auto, margin: 5pt) + +// #96: the requested `line: (8, 10)` form is rejected during element parsing. +#assert( + catch(() => codly.highlight(line: (8, 10), start: 5, fill: red)) != none, +) + +// The current workaround requires one span per source line. +#codly.new( + ```f90 + do concurrent(l=1:ctx_dg%n_different_order, k=1:ctx_dg%n_different_order) + n_dof_k = ctx_dg%n_dof_per_order(k) + n_dof_l = ctx_dg%n_dof_per_order(l) + + face_phi_xi(k,l)%array = sum(ctx_dg%quadGL_face_phi_phi_w(k,l)%array, dim=3) + + do concurrent(jdim=1:2, kdim=1:2, iface=1:3, j=1:n_dof_l, i=1:n_dof_k) + face_phi_xi_nCntau(k,l)%array(i,j,iface,kdim,jdim) = dot_product(& + face_coeff_Cx(iface,:,kdim,jdim), & + ctx_dg%quadGL_face_phi_phi_w(k,l)%array(i,j,:,iface)) + end do + end do + ```, + highlights: ( + (line: 5, start: 3, fill: yellow), + (line: 8, start: 5, fill: red), + (line: 9, start: 0, fill: red), + (line: 10, start: 0, fill: red), + ), +) diff --git a/tests/issues/ref/1.png b/tests/issues/ref/1.png index 540ead5..2d2f2e3 100644 Binary files a/tests/issues/ref/1.png and b/tests/issues/ref/1.png differ diff --git a/tests/issues/ref/10.png b/tests/issues/ref/10.png index 03b0245..17046b1 100644 Binary files a/tests/issues/ref/10.png and b/tests/issues/ref/10.png differ diff --git a/tests/issues/ref/11.png b/tests/issues/ref/11.png index bf0915c..b4b11b9 100644 Binary files a/tests/issues/ref/11.png and b/tests/issues/ref/11.png differ diff --git a/tests/issues/ref/12.png b/tests/issues/ref/12.png index f32963f..1c1efa6 100644 Binary files a/tests/issues/ref/12.png and b/tests/issues/ref/12.png differ diff --git a/tests/issues/ref/13.png b/tests/issues/ref/13.png index c2da6d3..28be5b8 100644 Binary files a/tests/issues/ref/13.png and b/tests/issues/ref/13.png differ diff --git a/tests/issues/ref/14.png b/tests/issues/ref/14.png index 582c31d..109ba95 100644 Binary files a/tests/issues/ref/14.png and b/tests/issues/ref/14.png differ diff --git a/tests/issues/ref/15.png b/tests/issues/ref/15.png new file mode 100644 index 0000000..3fe65b4 Binary files /dev/null and b/tests/issues/ref/15.png differ diff --git a/tests/issues/ref/16.png b/tests/issues/ref/16.png new file mode 100644 index 0000000..05853da Binary files /dev/null and b/tests/issues/ref/16.png differ diff --git a/tests/issues/ref/17.png b/tests/issues/ref/17.png new file mode 100644 index 0000000..fe868af Binary files /dev/null and b/tests/issues/ref/17.png differ diff --git a/tests/issues/ref/18.png b/tests/issues/ref/18.png new file mode 100644 index 0000000..754e2e5 Binary files /dev/null and b/tests/issues/ref/18.png differ diff --git a/tests/issues/ref/19.png b/tests/issues/ref/19.png new file mode 100644 index 0000000..3f4c59e Binary files /dev/null and b/tests/issues/ref/19.png differ diff --git a/tests/issues/ref/2.png b/tests/issues/ref/2.png index 1b71513..109d25b 100644 Binary files a/tests/issues/ref/2.png and b/tests/issues/ref/2.png differ diff --git a/tests/issues/ref/3.png b/tests/issues/ref/3.png index 7cf3c51..c6343fe 100644 Binary files a/tests/issues/ref/3.png and b/tests/issues/ref/3.png differ diff --git a/tests/issues/ref/4.png b/tests/issues/ref/4.png index 43a3fca..89df8ef 100644 Binary files a/tests/issues/ref/4.png and b/tests/issues/ref/4.png differ diff --git a/tests/issues/ref/5.png b/tests/issues/ref/5.png index 4fec5fb..e195932 100644 Binary files a/tests/issues/ref/5.png and b/tests/issues/ref/5.png differ diff --git a/tests/issues/ref/6.png b/tests/issues/ref/6.png index 719c8f3..b9a1e60 100644 Binary files a/tests/issues/ref/6.png and b/tests/issues/ref/6.png differ diff --git a/tests/issues/ref/7.png b/tests/issues/ref/7.png index 471b5b4..3a5aeb6 100644 Binary files a/tests/issues/ref/7.png and b/tests/issues/ref/7.png differ diff --git a/tests/issues/ref/8.png b/tests/issues/ref/8.png index fe8bcaa..2c43228 100644 Binary files a/tests/issues/ref/8.png and b/tests/issues/ref/8.png differ diff --git a/tests/issues/ref/9.png b/tests/issues/ref/9.png index a57b9e0..039c298 100644 Binary files a/tests/issues/ref/9.png and b/tests/issues/ref/9.png differ diff --git a/tests/issues/test.typ b/tests/issues/test.typ index 3104ab9..14d483f 100644 --- a/tests/issues/test.typ +++ b/tests/issues/test.typ @@ -1,61 +1,59 @@ -#import "../../codly.typ": codly-reset +#import "../../codly.typ" as codly #include "22-highlight-align.typ" #pagebreak() -#codly-reset() #include "32-spacing.typ" #pagebreak() -#codly-reset() #include "36-highlight-bug.typ" #pagebreak() -#codly-reset() #include "39-offset-by-name.typ" #pagebreak() -#codly-reset() -#include "40-header-no-number.typ" +#include "40-header-no-number.typ" #pagebreak() -#codly-reset() -/* -Waiting for typst-test update: -#assert-panic(() => { - include "47-crash-label.typ" - pagebreak() -}, message: "inequality assertion failed: codly: for labels on highlights to work, you must have the code block -contained within a figure and that figure must have a label.")*/ -#codly-reset() +// Issue 47 is checked as an expected compiler error by tests/run.py. #include "50-skips-no-reset.typ" #pagebreak() -#codly-reset() #include "52-skip-line-disabled.typ" #pagebreak() -#codly-reset() #include "56-contextual-funcs.typ" #pagebreak() -#codly-reset() #include "63-continue-highlights.typ" #pagebreak() -#codly-reset() #include "71-no-highlights-in-local.typ" #pagebreak() -#codly-reset() #include "81-highlight-inset-with-tags.typ" #pagebreak() -#codly-reset() #include "85-annotation-labels.typ" #pagebreak() -#codly-reset() #include "89-nested-highlights.typ" -#codly-reset() \ No newline at end of file +#pagebreak() + +#include "131-typstv15-baseline.typ" +#pagebreak() + +#include "133-nested-highlights.typ" +#pagebreak() + +#include "67-show-rule-regex.typ" +#pagebreak() + +#include "96-multiline-highlights.typ" +#pagebreak() + +#include "91-end-of-file-newline.typ" +#pagebreak() + +#include "108-dark-theme.typ" diff --git a/tests/language-style/ref/1.png b/tests/language-style/ref/1.png new file mode 100644 index 0000000..a043433 Binary files /dev/null and b/tests/language-style/ref/1.png differ diff --git a/tests/language-style/test.typ b/tests/language-style/test.typ new file mode 100644 index 0000000..4bc7710 --- /dev/null +++ b/tests/language-style/test.typ @@ -0,0 +1,110 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 285pt, height: auto, margin: 5pt) + +#let text-of(body) = { + if body == none { + "" + } else if body.has("text") { + body.text + } else if body.has("child") { + text-of(body.child) + } else if body.has("body") { + text-of(body.body) + } else if body.has("children") { + body.children.map(text-of).join() + } else { + "" + } +} + +#let radius = (top-left: 1pt, top-right: 2pt, bottom-right: 3pt, bottom-left: 4pt) +#let languages = ( + py: ( + name: [Python], + icon: [P], + color: red, + radius: radius, + ), +) + +#show box: it => { + if text-of(it.body) in ("PPython", "P", "Python") { + [#metadata(it.radius)#it] + } else { it } +} + +#let capture-lang = it => { + let fields = e.fields(it) + [#metadata(( + body: fields.body, + display-name: fields.display-name, + display-icon: fields.display-icon, + radius: fields.radius, + definition-radius: fields.languages.py.at("radius", default: none), + ))#it] +} + +// Both parts of the badge are shown by default. +#{ + show: codly.lang-set_(languages: languages, radius: radius) + show: codly.lang-show_(capture-lang) + codly.new(raw("both", lang: "py", block: true)) +} + +// Each visibility switch works independently, including the icon-only case. +#{ + show: codly.lang-set_(languages: languages, radius: 6pt, display-name: false) + show: codly.lang-show_(capture-lang) + codly.new(raw("icon", lang: "py", block: true)) +} +#{ + show: codly.lang-set_(languages: languages, radius: 6pt, display-icon: false) + show: codly.lang-show_(capture-lang) + codly.new(raw("name", lang: "py", block: true)) +} +#{ + show: codly.lang-set_(languages: languages, radius: 6pt, display-name: false, display-icon: false) + show: codly.lang-show_(capture-lang) + codly.new(raw("hidden", lang: "py", block: true)) +} + +// #129: image icons must survive when the language name is hidden. +#{ + show image: it => context [#metadata(measure(it))#it] + show: codly.lang-set_(display-name: false, languages: ( + typ: (name: "Typst", icon: box(image("../../src/typst-small.png", height: 0.8em)), color: teal), + )) + codly.new(raw("image icon", lang: "typ", block: true)) +} + +#context { + let fields = query().map(it => it.value) + assert.eq(fields.len(), 4) + assert.eq(fields.map(it => (it.display-name, it.display-icon)), ( + (true, true), + (false, true), + (true, false), + (false, false), + )) + assert.eq(fields.map(it => it.radius), ( + radius, + 6pt, + 6pt, + 6pt, + )) + assert.eq(fields.map(it => it.definition-radius), ( + radius, + radius, + radius, + radius, + )) + + let rendered = e.query(codly.codly-lang).map(text-of) + assert.eq(rendered, ("PPython", "P", "Python", "", "")) + assert.eq(query().map(it => it.value), (radius, radius, radius)) + let images = query() + assert.eq(images.len(), 1) + assert(images.first().value.width > 0pt and images.first().value.height > 0pt) +} diff --git a/tests/layout/ref/1.png b/tests/layout/ref/1.png new file mode 100644 index 0000000..ca01121 Binary files /dev/null and b/tests/layout/ref/1.png differ diff --git a/tests/layout/ref/2.png b/tests/layout/ref/2.png new file mode 100644 index 0000000..68b5367 Binary files /dev/null and b/tests/layout/ref/2.png differ diff --git a/tests/layout/ref/3.png b/tests/layout/ref/3.png new file mode 100644 index 0000000..47c8295 Binary files /dev/null and b/tests/layout/ref/3.png differ diff --git a/tests/layout/test.typ b/tests/layout/test.typ new file mode 100644 index 0000000..587c7fe --- /dev/null +++ b/tests/layout/test.typ @@ -0,0 +1,85 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 240pt, height: auto, margin: 5pt) + +// Per-instance header/footer settings must reach their grid cells. Headers +// span all columns, including the annotation column. +#show grid.cell: it => { + [#metadata((colspan: it.colspan, fill: it.fill, inset: it.inset))#it] +} + +#codly.new( + raw("one\ntwo", block: true), + annotations: ((start: 1, content: [note]),), + header: codly.codly-header([header-numbered], fill: red, inset: 2pt), + footer: codly.codly-footer([footer-numbered], fill: blue, inset: 3pt), +) + +#codly.new( + raw("one\ntwo", block: true), + number-enabled: false, + annotations: ((start: 1, content: [note]),), + header: codly.codly-header([header-plain], fill: green, inset: 4pt), + footer: codly.codly-footer([footer-plain], fill: purple, inset: 5pt), +) + +// An explicit `none` preserves an unfilled outside number column. +#let line-gradient = gradient.linear(red, blue) +#let stripe-tiling = tiling( + size: (4pt, 4pt), + relative: "parent", + rect(width: 2pt, height: 2pt), +) +#{ + show: codly.line-set_(fill: (stripe-tiling, line-gradient)) + show: e.set_(codly.codly-number, placement: "outside", fill: none) + show rect: it => [#metadata(it.fill)#it] + codly.new(raw("gradient\ntiling", block: true)) +} + +// This is also a visual regression for repeated headers and footers across a +// page break; the metadata asserts that the per-instance repeat flag is used. +// Explicit insets keep header text within Typst's continuation clipping region. +#let repeat-case() = { + set page(height: 180pt, margin: 6pt) + show: e.show_(codly.codly-header, it => [#metadata(e.fields(it).repeat)#it]) + show: e.show_(codly.codly-footer, it => [#metadata(e.fields(it).repeat)#it]) + codly.new( + raw(range(1, 14).map(str).join("\n"), block: true), + breakable: true, + header: codly.codly-header([repeat header], repeat: true, fill: luma(230), inset: 1em), + footer: codly.codly-footer([repeat footer], repeat: true, fill: luma(235), inset: 1em), + ) +} + +#repeat-case() + +#context { + assert.eq(query().map(it => type(it.value)), ( + type(stripe-tiling), + type(line-gradient), + )) + let cells = query() + .map(it => it.value) + .filter(it => ( + it.fill + in ( + red, + blue, + green, + purple, + ) + )) + assert.eq(cells, ( + (colspan: 3, fill: red, inset: 2pt), + (colspan: 3, fill: blue, inset: 3pt), + (colspan: 2, fill: green, inset: 4pt), + (colspan: 2, fill: purple, inset: 5pt), + )) + for label in (, ) { + let repeats = query(label).map(it => it.value) + assert(repeats.len() > 1) + for repeat in repeats { assert.eq(repeat, true) } + } +} diff --git a/tests/leading/ref/1.png b/tests/leading/ref/1.png new file mode 100644 index 0000000..5427daa Binary files /dev/null and b/tests/leading/ref/1.png differ diff --git a/tests/leading/test.typ b/tests/leading/test.typ new file mode 100644 index 0000000..ad7347f --- /dev/null +++ b/tests/leading/test.typ @@ -0,0 +1,91 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 240pt, height: auto, margin: 5pt) + +// The public block field is optional, type-checked, and does not change the +// existing layout unless requested. +#assert.eq(e.fields(codly.new(none)).at("leading", default: none), none) +#assert.eq(e.fields(codly.new(none, leading: 0.2em)).leading, 0.2em) +#let grid-options = e.fields(codly.new(none, gutter: 1pt, column-gutter: 2pt, row-gutter: 3pt)) +#assert.eq(grid-options.gutter, 1pt) +#assert.eq(grid-options.column-gutter, 2pt) +#assert.eq(grid-options.row-gutter, 3pt) + +#let source = raw("first\nsecond", block: true, lang: "rs") + +// Leading affects only vertical row padding; the horizontal inset remains +// governed by the line element's normal settings. +#context { + let tight = measure(width: 160pt, codly.new(source, leading: 0pt)) + let default = measure(width: 160pt, codly.new(source)) + let loose = measure(width: 160pt, codly.new(source, leading: 0.6em)) + assert.eq(tight.width, default.width) + assert.eq(default.width, loose.width) + assert(tight.height < default.height) + assert(default.height < loose.height) +} + +// The derived vertical badge offset keeps badges clear of the top border when +// the code rows are tightened and becomes zero once there is enough padding. +#let sample(leading) = { + show place: it => [#metadata(it.dy)#it] + codly.new(source, leading: leading) +} + +#sample(0.2em) +#sample(0.6em) + +#context { + let offsets = query().map(it => it.value) + assert(calc.abs(measure(h(offsets.first())).width - measure(h(0.15em)).width) < 0.001pt) + assert(calc.abs(measure(h(offsets.last())).width) < 0.001pt) +} + +// Primary-grid spacing is forwarded on every layout path. The native grid +// receives all three values so Typst can apply its normal directional override +// rules (`column-gutter`/`row-gutter` take precedence over `gutter`). +#let spacing-sample(number-enabled: true, ..options) = { + show grid: it => { + if it.columns.len() == if number-enabled { 2 } else { 1 } { + [#metadata(( + column: it.at("column-gutter"), + row: it.at("row-gutter"), + ))#it] + } else { + it + } + } + codly.new(source, number-enabled: number-enabled, ..options) +} + +#spacing-sample(gutter: 1pt) +#spacing-sample(number-enabled: false, gutter: 1pt, column-gutter: 2pt, row-gutter: 3pt) +#{ + show: codly.number-set_(placement: "outside") + spacing-sample(gutter: 1pt, column-gutter: 2pt, row-gutter: 3pt) +} + +#context assert.eq(query().map(it => it.value), ( + (column: (0% + 1pt,), row: (0% + 1pt,)), + (column: (0% + 2pt,), row: (0% + 3pt,)), + (column: (0% + 2pt,), row: (0% + 3pt,)), +)) + +// Keep an intentionally exaggerated visual fixture in the reference image. +// The first listing makes generic gutter and leading obvious; the second shows +// both directional overrides at once. += Exaggerated grid spacing +#codly.new( + raw("alpha\nbeta\ngamma", block: true, lang: "rs"), + leading: 0.9em, + gutter: 0.6em, +) + +#codly.new( + raw("one\ntwo\nthree", block: true, lang: "rs"), + leading: 0.9em, + gutter: 0.6em, + column-gutter: 1.2em, + row-gutter: 1.1em, +) diff --git a/tests/line-context/test.typ b/tests/line-context/test.typ new file mode 100644 index 0000000..8415977 --- /dev/null +++ b/tests/line-context/test.typ @@ -0,0 +1,54 @@ +#import "../../codly.typ" as codly + +// A line's minimum height survives glyph-specific text rules and whitespace. +// Assertions check the invisible struts without rendering the probe blocks. +#context { + let reference = measure(width: 160pt, codly.new( + raw("1", block: true), + number-enabled: false, + skip-last-empty: false, + )).height + for smart-indent in (false, true) { + for source in (" ", " ", "\t", "lowercase", "{}", " lowercase") { + let body = { + show regex("[a-z{}]+"): set text(size: 3pt) + codly.new( + raw(source, block: true), + number-enabled: false, + skip-last-empty: false, + smart-indent: smart-indent, + ) + } + assert.eq( + measure(width: 160pt, body).height, + reference, + message: "minimum code row height changed for " + repr(source), + ) + } + } +} + +// Metrics must be resolved after a line show rule changes the text settings. +// Moving the renderer out of its context would measure the wrong font size. +#context { + let source = raw.line(1, 1, " lowercase", [ lowercase]) + for size in (3pt, 19pt) { + for highlights in (none, ((line: 1, start: 5, end: 14),)) { + let expected = { + set text(size: size, font: "Libertinus Serif", top-edge: "bounds", bottom-edge: "bounds") + codly.codly-line(source, highlights: highlights) + } + let actual = { + show: codly.line-show_(it => text( + size: size, + font: "Libertinus Serif", + top-edge: "bounds", + bottom-edge: "bounds", + it, + )) + codly.codly-line(source, highlights: highlights) + } + assert.eq(measure(width: 70pt, actual), measure(width: 70pt, expected)) + } + } +} diff --git a/tests/number-fill/test.typ b/tests/number-fill/test.typ new file mode 100644 index 0000000..87aa777 --- /dev/null +++ b/tests/number-fill/test.typ @@ -0,0 +1,146 @@ +#import "../../codly.typ" as codly + +#let gutter = rgb("#d9d9d9") +#let code = rgb("#eef3ff") +#let zebra = rgb("#fff0c9") + +#set page(width: 230pt, height: auto, margin: 5pt) + +// The ordinary grid path colors only the number column. +#let inside-fill(it) = { + if it.fill != none and (it.fill)(0, 0) == gutter { + [#metadata(((it.fill)(0, 0), (it.fill)(1, 0)))#it] + } else { + it + } +} +#{ + show: codly.number-set_(fill: gutter, placement: "inside") + show: codly.line-set_(fill: code) + show grid: inside-fill + codly.new(raw("first\nsecond", block: true)) +} + +// The default `auto` follows the row fill, including zebra striping. +#let auto-fill(it) = { + if it.fill != none and (it.fill)(0, 0) == zebra { + [#metadata(( + (it.fill)(0, 0), + (it.fill)(0, 1), + (it.fill)(1, 0), + (it.fill)(1, 1), + ))#it] + } else { + it + } +} +#{ + show: codly.number-set_(placement: "inside") + show: codly.line-set_(fill: (zebra, code)) + show grid: auto-fill + codly.new(raw("first\nsecond", block: true)) +} + +// `none` leaves the number column unpainted. +#let no-fill(it) = { + if it.fill != none and (it.fill)(0, 0) == none { + [#metadata(((it.fill)(0, 0), (it.fill)(1, 0)))#it] + } else { + it + } +} +#{ + show: codly.number-set_(fill: none, placement: "inside") + show: codly.line-set_(fill: code) + show grid: no-fill + codly.new(raw("first\nsecond", block: true)) +} + +// Outside numbering uses geometry reconstruction, including across pages. +#{ + set page(height: 70pt) + show: codly.number-set_(fill: gutter, placement: "outside") + show: codly.line-set_(fill: code) + show rect: it => { + if it.fill == gutter { + [#metadata((width: it.width, height: it.height))#it] + } else { + it + } + } + codly.new(raw(range(1, 10).map(str).join("\n"), block: true), breakable: true) +} + +// A palette cycles by displayed row and `auto` number fills mirror it. +#let palette = (red.lighten(75%), green.lighten(75%), blue.lighten(75%)) +#let palette-fill(it) = { + if it.fill != none and (it.fill)(0, 0) == palette.first() { + let fills = () + for y in range(5) { fills.push(((it.fill)(0, y), (it.fill)(1, y))) } + [#metadata(fills)#it] + } else { + it + } +} +#{ + show: codly.number-set_(placement: "inside", fill: auto) + show: codly.line-set_(fill: palette) + show grid: palette-fill + codly.new( + raw("first\nsecond\nthird\nfourth\nfifth", block: true), + highlighted: ((2, fuchsia.lighten(75%)),), + ) +} + +// Callback rows expose source and displayed line data before grid layout. +#let callback-fill(row) = if row.kind == "code" and row.at("source-line") == 2 { + yellow.lighten(70%) +} else { + luma(245) +} +#let callback-grid(it) = { + if it.fill != none { + let fills = () + for y in range(3) { fills.push(((it.fill)(0, y), (it.fill)(1, y))) } + [#metadata(fills)#it] + } else { + it + } +} +#{ + show: codly.number-set_(placement: "inside", fill: auto) + show: codly.line-set_(fill: callback-fill) + show grid: callback-grid + codly.new(raw("first\nsecond\nthird", block: true)) +} + +#context { + assert.eq(query().map(it => it.value), ((gutter, code),)) + assert.eq(query().map(it => it.value), ((zebra, code, zebra, code),)) + assert.eq(query().map(it => it.value), ((none, code),)) + let outside = query() + let pages = () + for item in outside { + let page = item.location().position().page + if page not in pages { pages.push(page) } + } + assert(outside.len() > 1) + assert(pages.len() > 1) + assert(outside.all(it => it.value.width > 0pt and it.value.height > 0pt)) + assert.eq(query().map(it => it.value), ( + ( + (palette.at(0), palette.at(0)), + (fuchsia.lighten(75%), fuchsia.lighten(75%)), + (palette.at(2), palette.at(2)), + (palette.at(0), palette.at(0)), + (palette.at(1), palette.at(1)), + ), + )) + assert.eq(query().map(it => it.value), ( + ( + (luma(245), luma(245)), + (yellow.lighten(70%), yellow.lighten(70%)), + (luma(245), luma(245)), + ), + )) +} diff --git a/tests/number-placement/1-trivial.typ b/tests/number-placement/1-trivial.typ index dd1a40f..af455f6 100644 --- a/tests/number-placement/1-trivial.typ +++ b/tests/number-placement/1-trivial.typ @@ -1,37 +1,39 @@ -// Compile with `typst compile tests/numbers-out-margin/test.typ --root . tests/numbers-out-margin/out/test.pdf` in root directory +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e -#import "../../codly.typ": * += First test -#set page("a4") +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new[```java + System.out.println("Hello, world!"); + ```] +} -#show: codly-init += Second test +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new[```java + System.out.println("Hello, world!"); + System.out.println("Hello, world!"); + System.out.println("Hello, world!"); + ```] +} -= First test += Third test +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new[```scala + def factorial(n: Int): Int = -#codly(number-placement: "outside") + @tailrec + def loop(current: Int, accum: Int): Int = -```java -System.out.println("Hello, world!"); -``` + if n == 0 then accum + else loop(current - 1, n * accum) -= Second test -```java -System.out.println("Hello, world!"); -System.out.println("Hello, world!"); -System.out.println("Hello, world!"); -``` + loop(n, 1) // Call to the closure using the base case -= Third test -```scala -def factorial(n: Int): Int = - - @tailrec - def loop(current: Int, accum: Int): Int = - - if n == 0 then accum - else loop(current - 1, n * accum) - - loop(n, 1) // Call to the closure using the base case - -end factorial -``` \ No newline at end of file + end factorial + ```] +} diff --git a/tests/number-placement/2-multiple.typ b/tests/number-placement/2-multiple.typ index a714f08..206bede 100644 --- a/tests/number-placement/2-multiple.typ +++ b/tests/number-placement/2-multiple.typ @@ -1,121 +1,122 @@ -// Compile with `typst compile tests/numbers-out-margin/test.typ --root . tests/numbers-out-margin/out/test.pdf` in root directory - -#import "../../codly.typ": * - -#set page("a4") - -#show: codly-init +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e = Standard simple -#codly(number-format: none) -```java +#codly.new(number-enabled: false)[```java public static void main(String args[]) { System.out.println("Hello, world!"); } -``` +```] -#codly(number-format: numbering.with("1")) -```java +#codly.new[```java public static void main(String args[]) { System.out.println("Hello, world!"); } -``` +```] -#codly(number-format: numbering.with("1"), number-placement: "outside") -```java -public static void main(String args[]) { - System.out.println("Hello, world!"); +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new[```java + public static void main(String args[]) { + System.out.println("Hello, world!"); + } + ```] } -``` == Without zebra fill -#codly(zebra-fill: none, number-placement: "inside") -```java -public static void main(String args[]) { - System.out.println("Hello, world!"); +#{ + show: codly.line-set_(fill: none) + show: e.set_(codly.codly-number, placement: "inside") + codly.new[```java + public static void main(String args[]) { + System.out.println("Hello, world!"); + } + ```] } -``` -#codly(zebra-fill: none, number-placement: "outside") -```java -public static void main(String args[]) { - System.out.println("Hello, world!"); +#{ + show: codly.line-set_(fill: none) + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new[```java + public static void main(String args[]) { + System.out.println("Hello, world!"); + } + ```] } -``` - -#codly(zebra-fill: luma(240)) == Disabling number format but number outside margin enabled -#codly(number-format: none, number-placement: "outside") -```java -public static void main(String args[]) { - System.out.println("Hello, world!"); +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new(number-enabled: false)[```java + public static void main(String args[]) { + System.out.println("Hello, world!"); + } + ```] } -``` == With number format but with number outside margin disabled as well -#codly(number-format: numbering.with("1"), number-placement: "inside") -```java -public static void main(String args[]) { - System.out.println("Hello, world!"); +#{ + show: e.set_(codly.codly-number, placement: "inside") + codly.new[```java + public static void main(String args[]) { + System.out.println("Hello, world!"); + } + ```] } -``` = Annotations side effects -#codly(annotations: ((start: 1, content: "Begin with that!"), )) -```java +#codly.new(annotations: ((start: 1, content: "Begin with that!"),))[```java public static void main(String args[]) { System.out.println("Hello, world!"); } -``` +```] -#codly(lang-format: auto) -#codly(annotations: ((start: 1, end: 3, content: "Begin with that!"), )) -```java +#codly.new(annotations: ((start: 1, end: 3, content: "Begin with that!"),))[```java public static void main(String args[]) { System.out.println("Hello, world!"); } -``` +```] -#codly(lang-format: auto) -#codly(number-placement: "outside", annotations: ((start: 1, content: "Begin with that!"), )) -```java -public static void main(String args[]) { - System.out.println("Hello, world!"); +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new(annotations: ((start: 1, content: "Begin with that!"),))[```java + public static void main(String args[]) { + System.out.println("Hello, world!"); + } + ```] } -``` = With highlight -#codly( - highlights: ( - (line: 1, start: 14, end: 18, fill: blue), - (line: 3, start: 5, end:10, fill: green), - (line: 3, start: 24, end:38, fill: blue), - ), -) - -```java -public class MyApp { - public static void main(String[] args) { - System.out.println("Hello, world!"); +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new( + highlights: ( + (line: 1, start: 14, end: 18, fill: blue), + (line: 3, start: 5, end: 10, fill: green), + (line: 3, start: 24, end: 38, fill: blue), + ), + )[```java + public class MyApp { + public static void main(String[] args) { + System.out.println("Hello, world!"); + } } + ```] } -``` -#codly(number-placement: "inside") - -#codly( - highlights: ( - (line: 1, start: 14, end: 18, fill: blue), - (line: 3, start: 5, end:10, fill: green), - (line: 3, start: 24, end:38, fill: blue), - ), -) - -```java -public class MyApp { - public static void main(String[] args) { - System.out.println("Hello, world!"); +#{ + show: e.set_(codly.codly-number, placement: "inside") + codly.new( + highlights: ( + (line: 1, start: 14, end: 18, fill: blue), + (line: 3, start: 5, end: 10, fill: green), + (line: 3, start: 24, end: 38, fill: blue), + ), + )[```java + public class MyApp { + public static void main(String[] args) { + System.out.println("Hello, world!"); + } } + ```] } -``` \ No newline at end of file diff --git a/tests/number-placement/3-highlight-bug.typ b/tests/number-placement/3-highlight-bug.typ index 67084dd..b514143 100644 --- a/tests/number-placement/3-highlight-bug.typ +++ b/tests/number-placement/3-highlight-bug.typ @@ -1,43 +1,33 @@ -// Compile with `typst compile tests/numbers-out-margin/test.typ --root . tests/numbers-out-margin/out/test.pdf` in root directory - -#import "../../codly.typ": * - -#set page("a4") - -#show: codly-init +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e = With highlight -#codly( +#codly.new( highlights: ( (line: 1, start: 14, end: 18, fill: blue), - (line: 3, start: 5, end:10, fill: green), - (line: 3, start: 24, end:38, fill: blue), + (line: 3, start: 5, end: 10, fill: green), + (line: 3, start: 24, end: 38, fill: blue), ), -) - -```java +)[```java public class MyApp { public static void main(String[] args) { System.out.println("Hello, world!"); } } -``` - -#codly(number-placement: "outside") +```] -#codly( - highlights: ( +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new(highlights: ( (line: 1, start: 14, end: 18, fill: blue), - (line: 3, start: 5, end:10, fill: green), - (line: 3, start: 24, end:38, fill: blue), - ), -) - -```java -public class MyApp { - public static void main(String[] args) { - System.out.println("Hello, world!"); + (line: 3, start: 5, end: 10, fill: green), + (line: 3, start: 24, end: 38, fill: blue), + ))[```java + public class MyApp { + public static void main(String[] args) { + System.out.println("Hello, world!"); + } } + ```] } -``` \ No newline at end of file diff --git a/tests/number-placement/4-longcode.typ b/tests/number-placement/4-longcode.typ index f3bc78e..c566496 100644 --- a/tests/number-placement/4-longcode.typ +++ b/tests/number-placement/4-longcode.typ @@ -1,139 +1,136 @@ -// Compile with `typst compile tests/numbers-out-margin/test.typ --root . tests/numbers-out-margin/out/test.pdf` in root directory +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e -#import "../../codly.typ": * - -#set page("a4") - -#show: codly-init.with() +#set page(height: 400pt) = Long code -#codly( - number-placement: "outside", breakable : true, number-align: right -) - -```scala -package labs.lab10_streams - -import hevs.graphics.FunGraphics - -import java.awt.{Color, Dimension, Point} -import java.io.{File, FileNotFoundException, IOException} - -object CSVReader { - - def parseFile(in: String): Array[Line] = { - // This vector hold the lines - var textLines: Array[String] = Array.empty[String] - - try { - // Read the CSV File - val buf = scala.io.Source.fromFile(in) - textLines = buf.getLines().toArray - buf.close() - } catch { - - case e: FileNotFoundException => - System.err.println("Bad input file " + in + " !\n") - e.printStackTrace() - System.exit(-1) - - case e: IOException => - System.err.println("Error reading file " + " !\n") - e.printStackTrace() - System.exit(-1) - } - - println(textLines.length + " shapes found in csv file.") - val lines: Array[Line] = new Array[Line](textLines.length) - - // TODO: complete with your code here to fill the lines array - - lines - } - - /** - * Writes the lines to an SVG file ! - * - * @param lines - */ - def writeSVG(lines: Array[Line]): Unit = { - - val SVG_FILENAME = "drawingTest.svg" - - /** - * Write the SVG File - */ - try { - val svgWriter = new SVGWriter(new File(SVG_FILENAME)) - svgWriter.writeHeader(getDimensions(lines)) - svgWriter.writeLines(lines) - svgWriter.close() - - } catch { - case e: FileNotFoundException => - System.err.println("Bad output file " + SVG_FILENAME + " !\n") - e.printStackTrace() - System.exit(-1) - } - } - - /** - * Computes the required space on the screen for the given lines - * - * @param lines Different vector lines - * @return The space required to display that on screen - */ - def getDimensions(lines: Array[Line]): Dimension = { - // The dimension of the image, used for SVG output & the view Frame. - val imgDim = new Dimension - - /** - * Compute the image dimension - */ - for (i <- lines.indices) { - imgDim.height = Math.max(imgDim.height, lines(i).p2.x) - imgDim.width = Math.max(imgDim.width, lines(i).p2.y) - } - - imgDim.height += 50; - imgDim.width += 50 - imgDim - } - - /** - * Displays a vector of lines on a graphical window - * - * @param lines - */ - def displayLines(lines: Array[Line]): Unit = { - - // Compute the dimensions of the window - val imgDim = getDimensions(lines) - - // The window to display our lines - val display = new FunGraphics(imgDim.height, imgDim.width, "CSV File Drawing") - - /** - * Display the lines - */ - for (p <- lines) { - display.setColor(p.color) - display.drawLine(p.p1.x, p.p1.y, p.p2.x, p.p2.y) - } - } - - /** - * Entry point of the program - */ - def main(args: Array[String]): Unit = { - val theLines = parseFile("res/drawingTest.csv") - - // Display what we read on the display - displayLines(theLines) - - // Convert to SVG - writeSVG(theLines) - } +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none, align: right) + // @typstyle off: preserve tabs in the Scala fixture. + codly.new(breakable: true)[```scala + package labs.lab10_streams + + import hevs.graphics.FunGraphics + + import java.awt.{Color, Dimension, Point} + import java.io.{File, FileNotFoundException, IOException} + + object CSVReader { + + def parseFile(in: String): Array[Line] = { + // This vector hold the lines + var textLines: Array[String] = Array.empty[String] + + try { + // Read the CSV File + val buf = scala.io.Source.fromFile(in) + textLines = buf.getLines().toArray + buf.close() + } catch { + + case e: FileNotFoundException => + System.err.println("Bad input file " + in + " !\n") + e.printStackTrace() + System.exit(-1) + + case e: IOException => + System.err.println("Error reading file " + " !\n") + e.printStackTrace() + System.exit(-1) + } + + println(textLines.length + " shapes found in csv file.") + val lines: Array[Line] = new Array[Line](textLines.length) + + // TODO: complete with your code here to fill the lines array + + lines + } + + /** + * Writes the lines to an SVG file ! + * + * @param lines + */ + def writeSVG(lines: Array[Line]): Unit = { + + val SVG_FILENAME = "drawingTest.svg" + + /** + * Write the SVG File + */ + try { + val svgWriter = new SVGWriter(new File(SVG_FILENAME)) + svgWriter.writeHeader(getDimensions(lines)) + svgWriter.writeLines(lines) + svgWriter.close() + + } catch { + case e: FileNotFoundException => + System.err.println("Bad output file " + SVG_FILENAME + " !\n") + e.printStackTrace() + System.exit(-1) + } + } + + /** + * Computes the required space on the screen for the given lines + * + * @param lines Different vector lines + * @return The space required to display that on screen + */ + def getDimensions(lines: Array[Line]): Dimension = { + // The dimension of the image, used for SVG output & the view Frame. + val imgDim = new Dimension + + /** + * Compute the image dimension + */ + for (i <- lines.indices) { + imgDim.height = Math.max(imgDim.height, lines(i).p2.x) + imgDim.width = Math.max(imgDim.width, lines(i).p2.y) + } + + imgDim.height += 50; + imgDim.width += 50 + imgDim + } + + /** + * Displays a vector of lines on a graphical window + * + * @param lines + */ + def displayLines(lines: Array[Line]): Unit = { + + // Compute the dimensions of the window + val imgDim = getDimensions(lines) + + // The window to display our lines + val display = new FunGraphics(imgDim.height, imgDim.width, "CSV File Drawing") + + /** + * Display the lines + */ + for (p <- lines) { + display.setColor(p.color) + display.drawLine(p.p1.x, p.p1.y, p.p2.x, p.p2.y) + } + } + + /** + * Entry point of the program + */ + def main(args: Array[String]): Unit = { + val theLines = parseFile("res/drawingTest.csv") + + // Display what we read on the display + displayLines(theLines) + + // Convert to SVG + writeSVG(theLines) + } + } + ```] } -``` \ No newline at end of file diff --git a/tests/number-placement/5-skips.typ b/tests/number-placement/5-skips.typ index 88ebaa1..e28c3f3 100644 --- a/tests/number-placement/5-skips.typ +++ b/tests/number-placement/5-skips.typ @@ -1,58 +1,55 @@ -// Compile with `typst compile tests/numbers-out-margin/test.typ --root . tests/numbers-out-margin/out/test.pdf` in root directory - -#import "../../codly.typ": * - -#set page("a4") - -#show: codly-init +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e = Code skips -#codly(skips: ((4, 32), )) -```py +#codly.new(skips: ((4, 32),))[```py def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) fib(25) -``` - -#codly(number-placement: "outside") -#codly(skips: ((4, 32), )) -```py -def fib(n): +```] + +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new(skips: ((4, 32),))[```py + def fib(n): + if n <= 1: + return n + return fib(n - 1) + fib(n - 2) + + fib(25) + ```] +} + +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + codly.new( + skips: ((4, 120),), + skip-number: align(center, emoji.face.shock), + )[```py + def fib(n): if n <= 1: - return n + return n return fib(n - 1) + fib(n - 2) -fib(25) -``` - -#codly( - skips: ((4, 120), ), - skip-number: align(center, emoji.face.shock) -) -```py -def fib(n): - if n <= 1: - return n - return fib(n - 1) + fib(n - 2) - -fib(25) -``` + fib(25) + ```] +} -#codly( - skips: ((4, 120), ), +#codly.new( + skips: ((4, 120),), skip-number: align( - center, - emoji.face.shock), - number-placement: "inside") -```py + center, + emoji.face.shock, + ), +)[```py def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) fib(25) -``` \ No newline at end of file +```] diff --git a/tests/number-placement/ref/1.png b/tests/number-placement/ref/1.png index 0e9deb9..32a513a 100644 Binary files a/tests/number-placement/ref/1.png and b/tests/number-placement/ref/1.png differ diff --git a/tests/number-placement/ref/2.png b/tests/number-placement/ref/2.png index 1f15583..aff482d 100644 Binary files a/tests/number-placement/ref/2.png and b/tests/number-placement/ref/2.png differ diff --git a/tests/number-placement/ref/3.png b/tests/number-placement/ref/3.png index 498291b..56821b5 100644 Binary files a/tests/number-placement/ref/3.png and b/tests/number-placement/ref/3.png differ diff --git a/tests/number-placement/ref/4.png b/tests/number-placement/ref/4.png index ef35973..28d9797 100644 Binary files a/tests/number-placement/ref/4.png and b/tests/number-placement/ref/4.png differ diff --git a/tests/number-placement/ref/5.png b/tests/number-placement/ref/5.png index 9a91cb2..0a4e4cb 100644 Binary files a/tests/number-placement/ref/5.png and b/tests/number-placement/ref/5.png differ diff --git a/tests/number-placement/ref/6.png b/tests/number-placement/ref/6.png index 80d0467..03ec8b3 100644 Binary files a/tests/number-placement/ref/6.png and b/tests/number-placement/ref/6.png differ diff --git a/tests/number-placement/ref/7.png b/tests/number-placement/ref/7.png index b22ae88..eb53dfc 100644 Binary files a/tests/number-placement/ref/7.png and b/tests/number-placement/ref/7.png differ diff --git a/tests/number-placement/ref/8.png b/tests/number-placement/ref/8.png index e2ca30d..570a52f 100644 Binary files a/tests/number-placement/ref/8.png and b/tests/number-placement/ref/8.png differ diff --git a/tests/number-placement/ref/9.png b/tests/number-placement/ref/9.png index b96d527..7b7db53 100644 Binary files a/tests/number-placement/ref/9.png and b/tests/number-placement/ref/9.png differ diff --git a/tests/number-placement/test.typ b/tests/number-placement/test.typ index ec69f7c..1d323a3 100644 --- a/tests/number-placement/test.typ +++ b/tests/number-placement/test.typ @@ -1,23 +1,16 @@ -#import "../../codly.typ": codly-reset - #set text(font: ("libertinus serif", "Noto Color Emoji")) +#set page(width: 16cm + 10pt, height: auto, margin: 5pt) #include "1-trivial.typ" #pagebreak() -#codly-reset() #include "2-multiple.typ" #pagebreak() -#codly-reset() #include "3-highlight-bug.typ" #pagebreak() -#codly-reset() #include "4-longcode.typ" #pagebreak() -#codly-reset() -#include "5-skips.typ" -#pagebreak() -#codly-reset() \ No newline at end of file +#include "5-skips.typ" diff --git a/tests/outside-geometry/ref/1.png b/tests/outside-geometry/ref/1.png new file mode 100644 index 0000000..7a19ca7 Binary files /dev/null and b/tests/outside-geometry/ref/1.png differ diff --git a/tests/outside-geometry/ref/10.png b/tests/outside-geometry/ref/10.png new file mode 100644 index 0000000..ba36c7c Binary files /dev/null and b/tests/outside-geometry/ref/10.png differ diff --git a/tests/outside-geometry/ref/2.png b/tests/outside-geometry/ref/2.png new file mode 100644 index 0000000..e17cc36 Binary files /dev/null and b/tests/outside-geometry/ref/2.png differ diff --git a/tests/outside-geometry/ref/3.png b/tests/outside-geometry/ref/3.png new file mode 100644 index 0000000..1dad4b5 Binary files /dev/null and b/tests/outside-geometry/ref/3.png differ diff --git a/tests/outside-geometry/ref/4.png b/tests/outside-geometry/ref/4.png new file mode 100644 index 0000000..acf4c95 Binary files /dev/null and b/tests/outside-geometry/ref/4.png differ diff --git a/tests/outside-geometry/ref/5.png b/tests/outside-geometry/ref/5.png new file mode 100644 index 0000000..2561875 Binary files /dev/null and b/tests/outside-geometry/ref/5.png differ diff --git a/tests/outside-geometry/ref/6.png b/tests/outside-geometry/ref/6.png new file mode 100644 index 0000000..79ad78e Binary files /dev/null and b/tests/outside-geometry/ref/6.png differ diff --git a/tests/outside-geometry/ref/7.png b/tests/outside-geometry/ref/7.png new file mode 100644 index 0000000..4a5a05d Binary files /dev/null and b/tests/outside-geometry/ref/7.png differ diff --git a/tests/outside-geometry/ref/8.png b/tests/outside-geometry/ref/8.png new file mode 100644 index 0000000..77ec603 Binary files /dev/null and b/tests/outside-geometry/ref/8.png differ diff --git a/tests/outside-geometry/ref/9.png b/tests/outside-geometry/ref/9.png new file mode 100644 index 0000000..3f3b3fd Binary files /dev/null and b/tests/outside-geometry/ref/9.png differ diff --git a/tests/outside-geometry/test.typ b/tests/outside-geometry/test.typ new file mode 100644 index 0000000..e48256c --- /dev/null +++ b/tests/outside-geometry/test.typ @@ -0,0 +1,247 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#show: e.set_(codly.codly-number, fill: none) + +// Observe the geometry layer itself. The outer codly block has no stroke in +// outside-number mode, so these are the clipped blocks drawn by geometry.outside. +#show block: it => { + let fields = it.fields() + let radius = fields.at("radius", default: none) + let stroke = fields.at("stroke", default: none) + if fields.at("clip", default: false) { + [#metadata(( + radius: radius, + stroke: stroke, + clip: fields.at("clip", default: false), + height: fields.at("height"), + ))#it] + } else { it } +} + +#let body-fill = rgb("#d7ecff") +#let alternate-fill = rgb("#ffe2b8") +#let header-fill = rgb("#d9f5df") +#let footer-fill = rgb("#f8d8e4") + +// These rectangles are emitted by geometry.background, after its interval +// reconstruction, rather than by the source grid (whose fill is none). +#show rect: it => { + let fill = it.fields().at("fill", default: none) + if fill in (body-fill, alternate-fill, header-fill, footer-fill) { + [#metadata((fill: fill, width: it.width, height: it.height))#it] + } else { it } +} + +#let edge-text = ("repeat header", "repeat footer", "once header", "once footer") +#show text: it => if it.text in edge-text { + [#metadata(it.text)#it] +} else { it } + +#show: codly.line-show_(it => { + let body = e.fields(it).body + if type(body) == content and body.func() == raw.line { + [#metadata(body.text)#it] + } else { it } +}) + +#let repeated-source = ( + "R01", + "R02 " + + range(4) + .map(_ => { + "this deliberately long source line wraps in the narrow code column so its cell crosses a region boundary " + }) + .join(), + "R03", + "R04", + "R05", + "R06", + "R07", + "R08", + "R09", + "R10", + "R11", + "R12", + "R13", + "R14", + "R15", + "R16", + "R17", + "R18", +).join("\n") + +#metadata(none) +#{ + set page(width: 210pt, height: 118pt, margin: 6pt) + set text(size: 8pt) + show: e.set_(codly.codly-number, placement: "outside") + show: codly.line-set_(fill: (alternate-fill, body-fill), stroke: blue + 3pt) + codly.new( + raw(repeated-source, block: true), + breakable: true, + radius: 20pt, + annotations: ((start: 2, end: 17, content: [spanning annotation]),), + header: codly.codly-header([repeat header], repeat: true, fill: header-fill, inset: 3pt), + footer: codly.codly-footer([repeat footer], repeat: true, fill: footer-fill, inset: 3pt), + ) +} + +// Non-repeating edges must occupy only the first/final fragment while the +// geometry backdrop still has an anchor on every fragment. +#metadata(none) +#pagebreak() +#{ + set page(width: 210pt, height: 105pt, margin: 6pt) + set text(size: 8pt) + show: e.set_(codly.codly-number, placement: "outside") + show: codly.line-set_(fill: (alternate-fill, body-fill), stroke: blue + 3pt) + codly.new( + raw(range(1, 15).map(n => "O" + str(n)).join("\n"), block: true), + breakable: true, + radius: 20pt, + header: codly.codly-header([once header], repeat: false, fill: header-fill, inset: 3pt), + footer: codly.codly-footer([once footer], repeat: false, fill: footer-fill, inset: 3pt), + ) +} + +// Regions are also columns, not just pages. A distinct radius makes these +// emitted backgrounds identifiable without relying on source grid metadata. +#metadata(none) +#pagebreak() +#{ + set page(width: 210pt, height: 118pt, margin: 6pt, columns: 2) + set text(size: 7pt) + show: e.set_(codly.codly-number, placement: "outside") + show: codly.line-set_(fill: (alternate-fill, body-fill), stroke: purple + 3pt) + codly.new( + raw(range(1, 30).map(n => "C" + str(n)).join("\n"), block: true), + breakable: true, + radius: 19pt, + ) +} + +// Marker scopes must keep an outside-number block in header content from +// truncating the outer block's interval collection. +#metadata(none) +#pagebreak() +#{ + set page(width: 210pt, height: 118pt, margin: 6pt) + set text(size: 8pt) + show: e.set_(codly.codly-number, placement: "outside") + show: codly.line-set_(fill: (alternate-fill, body-fill), stroke: orange + 3pt) + codly.new( + raw("outer one\nouter two", block: true), + breakable: true, + radius: 18pt, + header: codly.codly-header( + [outer header #{ + show: codly.line-set_(stroke: red + 2pt) + codly.new(raw("nested", block: true), radius: 5pt) + }], + inset: 3pt, + ), + ) +} + +#context { + let repeat = query().first() + let once = query().first() + let columns = query().first() + let paints = query() + let backgrounds = query() + + // Rendered line elements retain every source line, including the long + // wrapped row. The source itself is deliberately compact and deterministic. + let lines = query() + assert.eq(lines.len(), 18 + 14 + 29 + 2 + 1) + assert(lines.map(it => it.value).any(it => it.starts-with("R02"))) + + // Every actual clipped backdrop preserves the requested radius and stroke. + let wide = backgrounds.filter(it => it.value.stroke.paint == blue) + let column-blocks = backgrounds.filter(it => it.value.stroke.paint == purple) + let nested-outer = backgrounds.filter(it => ( + it.value.stroke.paint == orange and it.value.radius == 0% + 18pt + )) + let nested-inner = backgrounds.filter(it => ( + it.value.stroke.paint == red and it.value.radius == 0% + 5pt + )) + assert(wide.len() > 3) + for background in wide { + assert(background.value.clip) + assert.eq(background.value.stroke.thickness, 3pt) + assert.eq(background.value.radius, 0% + 20pt) + } + for background in column-blocks { + assert(background.value.clip) + assert.eq(background.value.stroke.thickness, 3pt) + assert.eq(background.value.radius, 0% + 19pt) + } + assert.eq(nested-outer.len(), 1) + assert.eq(nested-inner.len(), 1) + + // Paints exist on continuation regions. The long wrapped row and spanning + // annotation are covered by the visual fixture; this also catches a missing + // reconstructed paint layer (the source grid itself emits no filled rects). + let repeat-paints = query( + selector().after(repeat.location()).before(once.location()), + ) + let repeat-lines = query( + selector().after(repeat.location()).before(once.location()), + ) + let repeat-pages = () + for paint in repeat-paints { + let page = paint.location().position().page + if page not in repeat-pages { repeat-pages.push(page) } + } + assert(repeat-pages.len() > 1) + assert(repeat-paints.len() > 8) + assert(repeat-paints.any(it => it.value.fill == header-fill)) + assert(repeat-paints.any(it => it.value.fill == footer-fill)) + + // The actual wrapped R02 row must resume before R03 on the next page. + let wrapped = repeat-lines.filter(it => it.value.starts-with("R02")).first() + let after-wrapped = repeat-lines.filter(it => it.value == "R03").first() + assert(wrapped.location().position().page < after-wrapped.location().position().page) + let wrapped-paint = repeat-paints.filter(it => { + let p = it.location().position() + ( + p.page == after-wrapped.location().position().page + and calc.abs(p.x - after-wrapped.location().position().x) < 4pt + and p.y < after-wrapped.location().position().y + and it.value.fill == alternate-fill + ) + }) + assert(wrapped-paint.len() > 0) + + // The annotation ends immediately before R18. Require an emitted + // right-column rect on R18's page; source line metadata alone cannot + // satisfy this. + let after-annotation = repeat-lines.filter(it => it.value == "R18").first() + let annotation-paint = repeat-paints.filter(it => { + let p = it.location().position() + ( + p.page == after-annotation.location().position().page + and p.x > after-annotation.location().position().x + and it.value.fill == alternate-fill + ) + }) + assert(annotation-paint.len() > 0) + + // Show-rule metadata is cloned with the repeated visible content; false + // repetition leaves one actual header/footer text instance each. + let visible = query().map(it => it.value) + assert(visible.filter(it => it == "repeat header").len() > 1) + assert(visible.filter(it => it == "repeat footer").len() > 1) + assert.eq(visible.filter(it => it == "once header").len(), 1) + assert.eq(visible.filter(it => it == "once footer").len(), 1) + + // At least two geometry blocks are laid out at different horizontal + // positions in the multi-column case. + let column-xs = () + for background in column-blocks { + let x = background.location().position().x + if x not in column-xs { column-xs.push(x) } + } + assert(column-xs.len() > 1) +} diff --git a/tests/outside-styling/ref/1.png b/tests/outside-styling/ref/1.png index ac43c7d..b15a858 100644 Binary files a/tests/outside-styling/ref/1.png and b/tests/outside-styling/ref/1.png differ diff --git a/tests/outside-styling/test.typ b/tests/outside-styling/test.typ index 095c818..301ed9e 100644 --- a/tests/outside-styling/test.typ +++ b/tests/outside-styling/test.typ @@ -1,15 +1,17 @@ +#import "../../codly.typ" as codly + #set page(height: auto, width: 300pt, margin: 10pt) #let replace-at(str, colors) = { for (i, c) in str.clusters().enumerate() { let found = false - + for (color, positions) in colors.pairs() { if positions.contains(i) { text(eval(color).darken(20%), c) //highlight(fill: eval(color).lighten(75%), top-edge: "ascender", extent: 0.4pt, c) //underline(stroke: 0.7pt + eval(color), extent: 0.4pt, c) - + found = true break } @@ -21,57 +23,46 @@ } } -#[ - #show raw.line: it => { - if it.number == 1 { - replace-at(it.text, (green: (4,), red: (10,), blue: (16,), purple: (22,))) - } else if it.number == 2 { - replace-at(it.text, (green: range(4,5), red: range(10,12), blue: range(17,20), purple: range(25,28))) - } else if it.number == 3 { - replace-at(it.text, (green: (0,1,2,3,4,5,6,7,8))) - } else { - it - } +#let color-lines(it) = { + if it.number == 1 { + replace-at(it.text, (green: (4,), red: (10,), blue: (16,), purple: (22,))) + } else if it.number == 2 { + replace-at(it.text, ( + green: range(4, 5), + red: range(10, 12), + blue: range(17, 20), + purple: range(25, 28), + )) + } else if it.number == 3 { + replace-at(it.text, (green: (0, 1, 2, 3, 4, 5, 6, 7, 8))) + } else { + it } - +} + +#[ + #show raw.line: color-lines + ``` Test1 test2 test3 test4 Test1 test22 test333 test444 Test12345 ``` - + ```python if test: pass ``` -] - -#import "../../codly.typ": * -#show: codly-init.with() - -#[ - #show raw.line: it => { - if it.number == 1 { - replace-at(it.text, (green: (4,), red: (10,), blue: (16,), purple: (22,))) - } else if it.number == 2 { - replace-at(it.text, (green: range(4,5), red: range(10,12), blue: range(17,20), purple: range(25,28))) - } else if it.number == 3 { - replace-at(it.text, (green: (0,1,2,3,4,5,6,7,8))) - } else { - it - } - } - - ``` + #codly.new[``` Test1 test2 test3 test4 Test1 test22 test333 test444 Test12345 - ``` + ```] - - ```python + + #codly.new[```python if test: pass - ``` -] \ No newline at end of file + ```] +] diff --git a/tests/rainbow-layout/ref/1.png b/tests/rainbow-layout/ref/1.png new file mode 100644 index 0000000..076c8c8 Binary files /dev/null and b/tests/rainbow-layout/ref/1.png differ diff --git a/tests/rainbow-layout/ref/2.png b/tests/rainbow-layout/ref/2.png new file mode 100644 index 0000000..2286fa0 Binary files /dev/null and b/tests/rainbow-layout/ref/2.png differ diff --git a/tests/rainbow-layout/ref/3.png b/tests/rainbow-layout/ref/3.png new file mode 100644 index 0000000..853ac93 Binary files /dev/null and b/tests/rainbow-layout/ref/3.png differ diff --git a/tests/rainbow-layout/ref/4.png b/tests/rainbow-layout/ref/4.png new file mode 100644 index 0000000..d759df8 Binary files /dev/null and b/tests/rainbow-layout/ref/4.png differ diff --git a/tests/rainbow-layout/test.typ b/tests/rainbow-layout/test.typ new file mode 100644 index 0000000..644ddfa --- /dev/null +++ b/tests/rainbow-layout/test.typ @@ -0,0 +1,31 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 190pt, height: 100pt, margin: 5pt) +#show: e.set_(codly.codly-number, placement: "outside", fill: none) +#show: codly.line-set_(stroke: 0.6pt + black) + +#let source = ("f({value: [1, 2, 3]});\n" * 8).trim() +#for enabled in (false, true) { + if enabled { pagebreak() } + show: codly.line-show_(it => [#metadata((enabled, e.fields(it).body.number))#it]) + codly.new(raw(source, lang: "js", block: true), rainbow: enabled, header: [head], radius: 4pt) +} + +#context { + let runs = ((), ()) + for row in query() { + let index = if row.value.first() { 1 } else { 0 } + runs.at(index).push((row.value.last(), row.location().position())) + } + assert.eq(runs.first().len(), 8) + assert.eq(runs.last().len(), 8) + for run in runs { assert(run.last().last().page > run.first().last().page) } + let page-offset = runs.last().first().last().page - runs.first().first().last().page + for (before, after) in runs.first().zip(runs.last()) { + assert.eq(before.first(), after.first()) + let a = before.last() + let b = after.last() + assert.eq((a.x, a.y, a.page + page-offset), (b.x, b.y, b.page)) + } +} diff --git a/tests/rainbow/custom.sublime-syntax b/tests/rainbow/custom.sublime-syntax new file mode 100644 index 0000000..c73b95c --- /dev/null +++ b/tests/rainbow/custom.sublime-syntax @@ -0,0 +1,16 @@ +%YAML 1.2 +--- +name: Codly Rainbow Test +file_extensions: [codly-rainbow-test] +scope: source.codly-rainbow-test +contexts: + main: + - match: '\bword\b' + scope: keyword.control.custom + - match: '~.*' + scope: custom.ignored + - match: '%' + push: + - meta_scope: string.quoted.custom + - match: '%' + pop: true diff --git a/tests/rainbow/ref/1.png b/tests/rainbow/ref/1.png new file mode 100644 index 0000000..c67e386 Binary files /dev/null and b/tests/rainbow/ref/1.png differ diff --git a/tests/rainbow/test.typ b/tests/rainbow/test.typ new file mode 100644 index 0000000..827c8fe --- /dev/null +++ b/tests/rainbow/test.typ @@ -0,0 +1,227 @@ +#import "../../codly.typ" as codly +#import "../../src/rainbow.typ" as r +#import "@preview/elembic:1.1.1" as e + +#set page(width: 280pt, height: auto, margin: 5pt) + +// These expectations describe every eligible delimiter, including unmatched ones. +#let check(lang, source, expected, pairs: ("()", "[]", "{}"), syntaxes: ()) = { + show raw: it => { + let marks = r.scan(it.lines, pairs) + let actual = "" + for (line, positions) in marks { + let source = it.lines.at(int(line) - 1).text + for (at, depth) in positions { + actual += (if depth == none { "?" } else { str(depth) }) + source.slice(at, at + 1) + } + } + assert.eq(actual, expected, message: lang + ": " + source) + [] + } + raw(source, lang: lang, block: true, syntaxes: syntaxes, theme: r.theme( + r.code-scopes, + r.ignore-scopes, + )) +} + +#check( + "rs", + "fn f() { println!(\"{}\", \"}\"); let s = r###\"{\"###; let c = '}'; }", + "0(0)0{1(1)0}", +) +#check("rs", "fn f() { /* } /* { */ ] */\n[1]\n}", "0(0)0{1[1]0}") +#check("js", "function f() { return `literal { ${g({x: \"}\"})} }`; }", "0(0)0{1{2(3{3}2)1}0}") +#check("js", "const r = /[{}()]/g; const x = a / b; f([]); // {", "0(1[1]0)") +#check("js", "`outer ${foo(`inner ${bar({a: \"}\"})}`)}`", "0{1(2{3(4{4}3)2}1)0}") +#check( + "py", + "def f():\n s = f\"literal {{ {g({'x': '}'})} }}\"\n # {}\n return []", + "0(0)0{1(2{2}1)0}0[0]", +) +#check("py", "s = ''' {\n ] } '''\nf(\"escaped \\\" {\")", "0(0)") +#check("c", "int f() { char *s = \"}\\\"{\"; /* { */ return (1); }", "0(0)0{1(1)0}") +#check("json", "{\"}\": [\"{\", {\"x\": 1}]}", "0{1[2{2}1]0}") +#check("cpp", "f(R\"tag({[]})tag\", '}'); // {", "0(0)") +#check("lua", "f([=[ { [ ] } ]=]) -- {", "0(0)") +#check("sql", "SELECT f('it''s }', 1); -- {", "0(0)") +#check("bash", "f() { echo '}' \"{\"; } # {", "0(0)0{0}") +#check("r", "f <- function(x) { list(a = c(1, 2), b = \"}\") } # (", "0(0)0{1(2(2)1)0}") +#check("r", "f(`{`, \"}\")", "0(0)") +#check("r", "x[[1]]; r\"({})\"", "0[1[1]0]") +#check("typc", "let f(x) = { \"}\"; (x,) } // {\n[hi #f(1)]", "0(0)0{1(1)0}0[1(1)0]") +#check("rs", "é\t😀({})", "0(1{1}0)") +#check("rs", "{\r\n[1]\r\n}", "0{1[1]0}") +#check("rs", "([)]", "?(1[?)1]") +#check("rs", "() {", "0(0)?{") +#check("rs", "f({[]})", "0{0}", pairs: ("{}",)) +#check("rs", "f(<1); a > b;", "0(0)") +#check("codly-unknown", "f({}) // {", "") +#check("codly-rainbow-test", "word(%}%)", "0(0)", syntaxes: path("custom.sublime-syntax")) + +#let colors = (rgb("#e00000"), rgb("#0000e0"), rgb("#00a000")) +#let settings = e.fields(codly.rainbow( + palette: colors, + pairs: ("{}",), + code-scopes: "source.custom", +)) +#assert.eq(settings.palette, colors) +#assert.eq(settings.pairs, ("{}",)) +#assert.eq(settings.code-scopes, "source.custom") +#let record-line(it) = { + let body = e.fields(it).body + if body.func() != raw.line { return it } + show text: t => context { + if t.text == "word" { [#metadata(text.fill)] } + if text.fill in colors { [#metadata((t.text, text.fill))#t] } else { t } + } + [#metadata((body.number, body.text))#it] +} + +// Exact rendered colors, with original text and native references preserved. +@colored:2 @mark +#context { + let origin = here() + show: codly.line-show_(record-line) + [#figure(caption: [Rainbow])[ + #codly.new( + raw("{\nf([1], \"}\")\n}", lang: "js", block: true), + block-label: , + rainbow: (palette: colors), + range: (2, 2), + highlights: ((line: 2, start: 2, end: 5, label: ),), + ) + ]] + context { + let seen = query(selector().after(origin).before(here())).map(it => it.value) + assert.eq(seen, ( + ("(", colors.at(1)), + ("[", colors.at(2)), + ("]", colors.at(2)), + (")", colors.at(1)), + )) + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(codly.info(), (last-number: 2, lines: 3)) + } +} + +// Sublanguages have independent stacks and restore the parent's nesting. +#context { + let origin = here() + show: codly.line-show_(record-line) + codly.new( + raw("{\nf({x: \"}\"})\ng([])\n}", lang: "rs", block: true), + rainbow: (palette: colors), + sublangs: ((start: 2, end: 2, lang: "js"),), + offset: 10, + annotations: ((start: 2, end: 3, content: [mixed]),), + ) + context { + let seen = query(selector().after(origin).before(here())).map(it => it.value) + assert.eq(seen, ( + ("{", colors.at(0)), + ("(", colors.at(0)), + ("{", colors.at(1)), + ("}", colors.at(1)), + (")", colors.at(0)), + ("(", colors.at(1)), + ("[", colors.at(2)), + ("]", colors.at(2)), + (")", colors.at(1)), + ("}", colors.at(0)), + )) + let lines = query(selector().after(origin).before(here())).map(it => it.value) + assert.eq(lines.map(it => it.first()), (11, 12, 13, 14)) + assert.eq(lines.at(1).last(), "f({x: \"}\"})") + } +} + +// Palette cycling, pair selection and unmatched styling are independent. +#context { + let origin = here() + show: codly.line-show_(record-line) + codly.new(raw("f({[]}) }", lang: "rs", block: true, theme: none), rainbow: ( + palette: colors, + pairs: ("{}",), + depth-offset: 4, + unmatched: colors.at(2), + )) + context { + let seen = query(selector().after(origin).before(here())).map(it => it.value) + assert.eq(seen, (("{", colors.at(1)), ("}", colors.at(1)), ("}", colors.at(2)))) + } +} + +#context { + let origin = here() + set raw(syntaxes: "custom.sublime-syntax", theme: "../aliases/99-local-resources.tmTheme") + show: codly.line-show_(record-line) + codly.new( + raw("word(%}%)\nword([1]) ~ }", lang: "custom-alias", block: true), + aliases: (custom-alias: "codly-rainbow-test"), + rainbow: (palette: colors, ignore-scopes: r.ignore-scopes + ", custom.ignored"), + sublangs: ((start: 2, end: 2, lang: "codly-rainbow-test"),), + ) + context { + assert.eq( + query(selector().after(origin).before(here())).map(it => it.value), + (rgb("#ff0000"),) * 2, + ) + let seen = query(selector().after(origin).before(here())).map(it => it.value) + assert.eq(seen, ( + ("(", colors.at(0)), + (")", colors.at(0)), + ("(", colors.at(0)), + ("[", colors.at(1)), + ("]", colors.at(1)), + (")", colors.at(0)), + )) + } +} + +// Speculative measurements must retain the normal wrapping and line heights. +#context { + let body = raw( + "\tfn é(x) { let y = [1, 2, 3]; f(x, y); }\n// comment {\n", + lang: "rs", + block: true, + ) + assert.eq(measure(codly.new(body), width: 120pt), measure( + codly.new(body, rainbow: true), + width: 120pt, + )) + show raw: set text(font: "Libertinus Serif", size: 0.9em) + show raw.where(block: true): set text(0.8em) + assert.eq(measure(codly.new(body), width: 120pt), measure( + codly.new(body, rainbow: true), + width: 120pt, + )) +} + +#context { + let origin = here() + show: codly.line-show_(record-line) + { + show: codly.set_(rainbow: (palette: colors)) + codly.new(raw("fn f() {}", lang: "rs", block: true), rainbow: false) + codly.new(raw("()", lang: "rs", block: true), rainbow: (pairs: ())) + } + codly.new(raw("let answer = 42;", lang: "rs", block: true), rainbow: true) + context { + assert.eq(query(selector(<__codly-rainbow>).after(origin).before(here())).len(), 0) + assert.eq(query(selector().after(origin).before(here())).len(), 0) + } +} + +#context { + for (options, message) in ( + ((palette: ()), "palette must not be empty"), + ((depth-offset: -1), "depth-offset must be nonnegative"), + ) { + let error = catch(() => measure(codly.new( + raw("()", lang: "rs", block: true), + rainbow: options, + ))) + assert(error != none and error.contains(message)) + } +} diff --git a/tests/reference-elements/test.typ b/tests/reference-elements/test.typ new file mode 100644 index 0000000..494aa70 --- /dev/null +++ b/tests/reference-elements/test.typ @@ -0,0 +1,113 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 240pt, height: auto, margin: 5pt) + +#let text-of(body) = { + if body.has("text") { + body.text + } else if body.has("child") { + text-of(body.child) + } else if body.has("children") { + body.children.map(text-of).join() + } else { + "" + } +} + +#let line-seen(it) = { + let fields = e.fields(it) + [#metadata(( + number: fields.number, + separator: fields.separator, + body: text-of((fields.numbering)(fields.number)), + suffix: fields.suffix, + ))#it] +} + +#let highlight-seen(it) = { + let fields = e.fields(it) + [#metadata(( + line: fields.line, + item: fields.item, + by: fields.by, + separator: fields.separator, + body: if fields.numbering == auto { "auto" } else { + text-of((fields.numbering)(fields.line)) + }, + ))#it] +} + +#let annotation-seen(it) = { + let fields = e.fields(it) + [#metadata(( + line: fields.line, + item: fields.item, + by: fields.by, + separator: fields.separator, + body: if fields.numbering == auto { "auto" } else { + text-of((fields.numbering)(fields.line)) + }, + ))#it] +} + +// Each generated target remains a native reference while exposing a separate +// element and scoped style hooks for its semantic kind. +#{ + show: codly.line-ref-set_(separator: " line ", numbering: n => [L#n]) + show: codly.highlight-ref-set_(separator: " highlight ", numbering: n => [H#n]) + show: codly.annotation-ref-set_(separator: " annotation ", numbering: n => [A#n]) + show: codly.line-ref-show_(line-seen) + show: codly.highlight-ref-show_(highlight-seen) + show: codly.annotation-ref-show_(annotation-seen) + + [ + @styled:12 @styled-highlight @styled-note + #figure(caption: [Styled references])[ + #codly.new( + raw("one\ntwo", block: true), + offset: 10, + block-label: , + highlights: ((line: 12, start: 0, end: 3, label: ),), + annotations: ((start: 2, content: [note], label: ),), + ) + ] + ] +} + +// Item references use the same native targets and own element hooks. +#{ + show: codly.ref-set_(by: "item") + show: codly.highlight-ref-show_(highlight-seen) + show: codly.annotation-ref-show_(annotation-seen) + + [ + @item-highlight @item-note + #figure(caption: [Item references])[ + #codly.new( + raw("one", block: true), + block-label: , + highlights: ((line: 1, tag: [tag], label: ),), + annotations: ((start: 1, content: [note], label: ),), + ) + ] + ] +} + +#context { + for target in (, , ) { + let reference = (query(target).first().numbering)() + assert.eq(e.fields(reference).body, []) + } + assert.eq(query().map(it => it.value), ( + (number: 12, separator: " line ", body: "L12", suffix: none), + )) + assert.eq(query().map(it => it.value), ( + (line: 12, item: none, by: "line", separator: " highlight ", body: "H12"), + (line: 1, item: [tag], by: "item", separator: auto, body: "auto"), + )) + assert.eq(query().map(it => it.value), ( + (line: 12, item: [note], by: "line", separator: " annotation ", body: "A12"), + (line: 1, item: [note], by: "item", separator: auto, body: "auto"), + )) +} diff --git a/tests/reference-offset/ref/1.png b/tests/reference-offset/ref/1.png new file mode 100644 index 0000000..0785216 Binary files /dev/null and b/tests/reference-offset/ref/1.png differ diff --git a/tests/reference-offset/test.typ b/tests/reference-offset/test.typ new file mode 100644 index 0000000..7b8e940 --- /dev/null +++ b/tests/reference-offset/test.typ @@ -0,0 +1,27 @@ +#import "../../codly.typ" as codly + +#set page(width: 200pt, height: auto, margin: 5pt) + +#codly.new( + raw("a\nb\nc", block: true), + offset: 10, + skips: ((2, 3),), + number-enabled: false, +) +#codly.new(raw("next", block: true), offset-from: ) + +#codly.new(raw("", block: true), skip-last-empty: true) +#codly.new(raw("after empty", block: true), offset-from: ) + +#figure[ + #codly.new(raw("a\nb", block: true), offset: 20, block-label: ) +] +#codly.new(raw("after labeled", block: true), offset-from: ) + +#context { + assert.eq(codly.info(), (last-number: 16, lines: 3)) + assert.eq(codly.info(), (last-number: 22, lines: 2)) + let ends = query(<__codly-block>) + assert.eq(ends.map(it => it.value.last-number), (16, 17, none, 1, 22, 23)) + assert.eq(query(figure.where(kind: "__codly-raw-line")).len(), 0) +} diff --git a/tests/references/ref/1.png b/tests/references/ref/1.png new file mode 100644 index 0000000..dc1a1b7 Binary files /dev/null and b/tests/references/ref/1.png differ diff --git a/tests/references/test.typ b/tests/references/test.typ new file mode 100644 index 0000000..7573f3c --- /dev/null +++ b/tests/references/test.typ @@ -0,0 +1,67 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 315pt, height: auto, margin: 5pt) +#set heading(numbering: "1") + +// Native forward references; no global ref show rule is needed. +#show: codly.line-ref-set_(separator: "-") +#show: codly.line-ref-show_(it => { + let fields = e.fields(it) + [#metadata(( + number: fields.number, + separator: fields.separator, + suffix: fields.suffix, + ))#it] +}) +#show: codly.highlight-ref-show_(it => { + let fields = e.fields(it) + [#metadata((line: fields.line, item: fields.item, by: fields.by))#it] +}) +#show: codly.annotation-ref-show_(it => { + let fields = e.fields(it) + [#metadata((line: fields.line, item: fields.item, by: fields.by))#it] +}) + +@code:1 @marked @note + +#figure(caption: [Code])[ + #codly.new( + raw("first\nsecond\nthird", block: true), + block-label: , + highlights: ((line: 1, start: 1, end: 3, label: ),), + annotations: ((start: 2, label: ),), + ) +] + +#show: codly.ref-set_(by: "item") +#figure(caption: [Tagged code])[ + #codly.new(raw("one", block: true), block-label: , highlights: ( + (line: 1, tag: [A], label: ), + )) +] +@tag @tagged:1 + += Ordinary heading +@heading @code @tagged + +#context { + for target in (, , , , ) { + assert.eq(query(target).first().func(), figure) + } + assert.eq(query(figure.where(kind: "codly-line")).len(), 4) + assert.eq(query(figure.where(kind: "__codly-end-block")).len(), 0) + assert.eq(codly.info(), (last-number: 3, lines: 3)) + assert.eq(counter(figure.where(kind: query().first().kind)).at(), (2,)) + assert.eq(query().map(it => it.value), ( + (number: 1, separator: "-", suffix: none), + (number: 1, separator: "-", suffix: none), + )) + assert.eq(query().map(it => it.value), ( + (line: 1, item: none, by: "line"), + (line: 1, item: [A], by: "item"), + )) + assert.eq(query().map(it => it.value), ( + (line: 2, item: [1], by: "line"), + )) +} diff --git a/tests/rendering-options/ref/1.png b/tests/rendering-options/ref/1.png new file mode 100644 index 0000000..b0e4e80 Binary files /dev/null and b/tests/rendering-options/ref/1.png differ diff --git a/tests/rendering-options/test.typ b/tests/rendering-options/test.typ new file mode 100644 index 0000000..9d065af --- /dev/null +++ b/tests/rendering-options/test.typ @@ -0,0 +1,116 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 255pt, height: auto, margin: 5pt) + +#let check(expected, body) = { + show: codly.line-show_(it => { + let line = e.fields(it).body + [#metadata(if line.func() == raw.line { line.number } else { "skip" })#it] + }) + [#metadata(expected)#body] +} + +// Unsorted, overlapping ranges merge; one placeholder per omitted run. +#check(("skip", 3, 4, 5, "skip", 8, "skip"), codly.new( + raw("1\n2\n3\n4\n5\n6\n7\n8\n9\n10", block: true), + ranges: ((8, 8), (3, 4), (4, 5)), + smart-skip: true, +)) + +// Skip records are sorted once, and exact duplicates do not block later skips. +#check((1, "skip", 4, 5, "skip", 9), codly.new( + raw("1\n2\n3\n4", block: true), + skips: ((4, 3), (2, 2), (2, 2)), +)) + +// Open ranges and an omitted trailing empty line. +#check(("skip", 2, 3), codly.new( + raw("1\n2\n3\n", block: true), + range: (2,), + smart-skip: true, +)) + +// Exercise each smart-skip switch independently and keep ordinary gaps hidden. +#for (flags, expected) in ( + ((first: true), ("skip", 2, 3, 6)), + ((rest: true, first: false, last: false), (2, 3, "skip", 6)), + ((last: true), (2, 3, 6, "skip")), + (false, (2, 3, 6)), +) { + check(expected, codly.new( + raw("1\n2\n3\n4\n5\n6\n7\n8", block: true), + ranges: ((2, 3), (6, 6)), + smart-skip: flags, + )) +} + +#for (source, options, expected) in ( + ("", (:), ()), + ("", (skip-last-empty: false), (1,)), + ("one\n\n", (:), (1, 2)), + ("one\n\n", (skip-last-empty: false), (1, 2, 3)), + ("one\ntwo", (range: (20,), smart-skip: true), ("skip",)), + ("one\ntwo", (number-enabled: false, offset: 40), (41, 42)), +) { + check(expected, codly.new(raw(source, block: true), ..options)) +} + +// A large displayed offset must not require a densely padded highlight array. +#{ + show grid: it => { + assert.eq((it.fill)(1, 0), red) + assert.eq((it.fill)(1, 1), blue) + it + } + codly.new(raw("one\ntwo", block: true), offset: 1000000, highlighted: ( + (1000002, blue), + (1000001, red), + )) +} + +// Default whole-line fill follows the highlight element's settings. +#{ + show: codly.highlight-set_(color: green, fill: color => color) + show grid: it => { + assert.eq((it.fill)(1, 0), green) + it + } + codly.new(raw("one", block: true), highlighted: (1,)) +} + +// Deferred measurements must see font changes made by a custom line show rule. +#{ + show: codly.line-show_(it => text(size: 22pt, it)) + show raw.line: it => context { + if it.text == "" { assert.eq(it.body.children.first().height, measure[1].height) } + it + } + codly.new(raw("one\n\n two", block: true)) +} + +// Outside borders use displayed rows, including headers and footers. +#{ + show: e.set_(codly.codly-number, placement: "outside", fill: none) + show: codly.line-set_(stroke: red + 1pt) + show block: it => if type(it.stroke) == stroke and it.stroke.thickness == 1pt { + [#metadata(it.height)#it] + } else { it } + codly.new(raw("1\n2\n3\n4\n5", block: true), range: (2, 3), header: [head], footer: [foot]) +} + +#context { + let outline = query().first() + let marks = query(<__codly-geometry>) + let top = marks.filter(it => it.value.kind == "cell-start").first().location().position().y + let bottom = marks.filter(it => it.value.kind == "cell-end").last().location().position().y + assert.eq(outline.value, bottom - top) + for expected in query() { + let actual = () + let end = query(selector(<__codly-block>).after(expected.location())).first() + for line in query(selector().after(expected.location()).before(end.location())) { + actual.push(line.value) + } + assert.eq(actual, expected.value) + } +} diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 0000000..d6a5971 --- /dev/null +++ b/tests/run.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Run visual/assertion tests and check errors raised during deferred layout.""" +import argparse +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +LAYOUT_ERRORS = { + "empty-fill": "`fill` palettes must not be empty", + "filename-position": "file-position must select left/right and top/bottom", + "language-position": "lang-position must select left/right and top/bottom", + "filename-missing": "no-such-source.py", + "range-conflict": "cannot specify both `range` and `ranges`", + "annotation-overlap": "overlapping annotations", + "annotation-touching": "overlapping annotations", + "annotation-label": "require `block-label`", + "highlight-label": "contained within a figure", + "item-without-tag": "tag is required for item reference", + "missing-offset": "expected a unique code block label", + "duplicate-info": "expected a unique code block label", + "info-without-code": "no code block found after", + "missing-reference": "does not exist in the document", + "alias-theme": "explicit string `raw.theme`", + "alias-syntax": "explicit string `raw.syntaxes`", + "callout-pointer-negative": "pointer must be non-negative", + "callout-pointer-past-end": "pointer exceeds source line length", + "callout-negative-size": "pointer size and bubble radius must be non-negative", + "callout-negative-width": "bubble width must be non-negative", + "callout-negative-inset": "bubble inset and outset must be non-negative", + "callout-negative-gap": "bubble gap must be non-negative", + "callout-negative-gap-y": "bubble gap must be non-negative", + "callout-gap-keys": "bubble gap accepts only x and y", + "callout-gap-type": "bubble gap axes must be lengths", + "callout-negative-pointer-height": "pointer height and width must be non-negative", + "callout-negative-pointer-width": "pointer height and width must be non-negative", + "bubble-negative-height": "pointer height and width must be non-negative", +} + + +def run_accessibility(output): + """Compile the accessibility fixture as PDF/UA and check text extraction.""" + result = subprocess.run( + ["typst", "compile", "--root", str(ROOT), "--pdf-standard", "ua-1", + "--font-path", str(ROOT / "fonts"), + str(ROOT / "tests/accessibility/test.typ"), str(output)], + cwd=ROOT, capture_output=True, text=True, + ) + passed = result.returncode == 0 + if passed: + extracted = subprocess.run( + ["pdftotext", "-layout", str(output), "-"], + capture_output=True, text=True, + ) + passed = extracted.returncode == 0 and all( + text in extracted.stdout + for text in ("filename source", "accessible.txt", "plain source", "fn main()", "return 1", "Returns one", "outside", "Remark", "guided source", "guided child", "wrapped source", "bubble source", "Accessible bubble", "Above left", "Above right") + ) + if not passed: + result = extracted + print(f"{'pass' if passed else 'FAIL'} accessibility/pdf-ua", flush=True) + if not passed: + print(result.stderr or result.stdout) + return passed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--errors-only", action="store_true") + args = parser.parse_args() + failed = False + if not args.errors_only: + result = subprocess.run( + ["tt", "run", "--no-fail-fast", "--font-path", "fonts"], cwd=ROOT + ) + failed = result.returncode != 0 + with tempfile.TemporaryDirectory(prefix="codly-tests-") as temporary: + if not args.errors_only: + failed |= not run_accessibility(Path(temporary) / "accessibility.pdf") + for case, expected in LAYOUT_ERRORS.items(): + result = subprocess.run( + ["typst", "compile", "--root", str(ROOT), "--font-path", + str(ROOT / "fonts"), "--input", f"case={case}", + str(ROOT / "tests/errors/layout.typ"), str(Path(temporary) / "test.pdf")], + cwd=ROOT, capture_output=True, text=True, + ) + passed = result.returncode == 1 and expected in result.stderr + print(f"{'pass' if passed else 'FAIL'} layout/{case}", flush=True) + if not passed: + print(result.stderr or f"Expected a diagnostic containing: {expected}") + failed = True + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/single-line/ref/1.png b/tests/single-line/ref/1.png index 028d804..34a01f0 100644 Binary files a/tests/single-line/ref/1.png and b/tests/single-line/ref/1.png differ diff --git a/tests/single-line/ref/2.png b/tests/single-line/ref/2.png deleted file mode 100644 index a9fe759..0000000 Binary files a/tests/single-line/ref/2.png and /dev/null differ diff --git a/tests/single-line/test.typ b/tests/single-line/test.typ index cdeb9de..874ded0 100644 --- a/tests/single-line/test.typ +++ b/tests/single-line/test.typ @@ -1,69 +1,51 @@ -#import "../../codly.typ": * +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e -#set page("a6") +#set page(width: 8cm + 10pt, height: auto, margin: 5pt) -#show: codly-init - -```typst -Hello, world! -``` - -#codly-disable() -```typst +#codly.new[```typst Hello, world! -``` +```] -#codly-enable() -```typst +#codly.new[```typst Hello, world! -``` +```] -#codly(number-format: none) -```typst +#codly.new(number-enabled: false)[```typst Hello, world! -``` +```] -#codly(lang-format: none) -```typst -Hello, world! -``` +#{ + show: e.show_(codly.codly-lang, it => []) + codly.new(number-enabled: false)[```typst + Hello, world! + ```] +} -#codly(annotations: ((start: 1, content: "Hello, world!"), )) -```typst -Hello, world! -``` +#{ + show: e.show_(codly.codly-lang, it => []) + codly.new(number-enabled: false, annotations: ((start: 1, content: "Hello, world!"),))[```typst + Hello, world! + ```] +} -#codly(lang-format: auto) -#codly(annotations: ((start: 1, content: "Hello, world!"), )) -```typst +#codly.new(number-enabled: false, annotations: ((start: 1, content: "Hello, world!"),))[```typst Hello, world! -``` +```] = With highlight -#codly(highlights: ((line: 1, tag: "Hello, world!"), )) -#codly(inset: 0.5pt) -```typst -Hello, world! -``` - -#no-codly[ - ```typst +#{ + show: codly.line-set_(inset: 0.5pt) + codly.new(number-enabled: false, highlights: ((line: 1, tag: "Hello, world!"),))[```typst Hello, world! - ``` -] - -#codly(inset: 10pt) -```typst -Hello, world! -``` - -#codly(inset: 0.5pt) -```typst -Hello, world! -``` - -#codly-reset() -#codly(languages: (py: (name: "Python", icon: "Sss ", color: rgb("#4584b6")), )) -```py -# Example code that calculates the sum of the first 10 natural numbers squares -``` \ No newline at end of file + ```] +} + +#{ + show: codly.lang-set_(languages: ( + py: (name: "Python", icon: "Sss ", color: rgb("#4584b6")), + )) + codly.new[```py + # Example code that calculates the sum of the first 10 natural numbers squares + ```] +} diff --git a/tests/skip-formatting/test.typ b/tests/skip-formatting/test.typ new file mode 100644 index 0000000..a8f9455 --- /dev/null +++ b/tests/skip-formatting/test.typ @@ -0,0 +1,95 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 180pt, height: auto, margin: 5pt) + +// PR #103: explicit skips consume entries; smart skips use the final value. +#let check(options, lines, numbers) = { + show: codly.line-show_(it => { + let body = e.fields(it).body + if body == none or body.func() != raw.line { + [#metadata(body)#it] + } else { it } + }) + show: e.show_(codly.codly-number, it => { + let body = e.fields(it).number + if type(body) != int { [#metadata(body)#it] } else { it } + }) + [#metadata((lines: lines, numbers: numbers)) + #codly.new(raw("one\ntwo\nthree\nfour\nfive", block: true), ..options)] +} + +#check( + ( + skips: ((4, 0), (2, 0), (2, 0), (5, 0)), + skip-line: ([A], [B]), + skip-number: ([I], [II]), + ), + ([A], [B], [B]), + ([I], [II], [II]), +) + +#check( + ( + skips: ((2, 0), (4, 0)), + skip-line: [gap], + skip-number: none, + ), + ([gap], [gap]), + ([], []), +) + +#check( + ( + skips: ((2, 0), (4, 0)), + skip-line: (none, [gap]), + skip-number: ([I], none), + ), + (none, [gap]), + ([I], []), +) + +#check( + ( + ranges: ((2, 2), (4, 4)), + smart-skip: true, + skips: ((2, 0), (4, 0)), + skip-line: ([A], [B]), + skip-number: ([I], [II]), + ), + ([B], [A], [B], [B], [B]), + ([II], [I], [II], [II], [II]), +) + +#check( + ( + skips: ((2, 0),), + skip-line: (), + skip-number: (), + ), + (align(center)[ ... ],), + ([ ... ],), +) + +#check( + ( + number-enabled: false, + skips: ((2, 0), (4, 0)), + skip-line: ([A], [B]), + skip-number: ([I], [II]), + ), + ([A], [B]), + (), +) + +#context { + let cases = query() + assert.eq(cases.len(), 6) + for case in cases { + let end = query(selector(<__codly-block>).after(case.location())).first() + let lines = query(selector().after(case.location()).before(end.location())) + let numbers = query(selector().after(case.location()).before(end.location())) + assert.eq(lines.map(it => it.value), case.value.lines) + assert.eq(numbers.map(it => it.value), case.value.numbers) + } +} diff --git a/tests/smart-indent-highlights/ref/1.png b/tests/smart-indent-highlights/ref/1.png new file mode 100644 index 0000000..92c8106 Binary files /dev/null and b/tests/smart-indent-highlights/ref/1.png differ diff --git a/tests/smart-indent-highlights/test.typ b/tests/smart-indent-highlights/test.typ new file mode 100644 index 0000000..35921f9 --- /dev/null +++ b/tests/smart-indent-highlights/test.typ @@ -0,0 +1,77 @@ +#import "../../codly.typ" as codly + +#set page(width: 230pt, height: auto, margin: 5pt) +#let source = " alpha bravo delta foxtt gamma hotel indii julii" +#let marker = [#metadata(none)↪] + +// Equal-length words expose actual visual line starts without relying on +// internal paragraph settings or raster snapshots to infer indentation. +#for spans in ( + ((line: 1, start: 1, end: 999),), + ((line: 1, start: 3, end: 999),), // whitespace remains atomic + ((line: 1, start: 5, end: 999),), // begins after the source indentation + ((line: 1, start: 1, end: 999), (line: 1, start: 5, end: 999)), +) { + for enabled in (false, true) { + context { + let origin = here() + show regex("alpha|bravo|delta|foxtt|gamma|hotel|indii|julii"): it => [#it#metadata( + none, + )] + codly.new( + raw(source, block: true), + highlights: spans, + wrap-marker: if enabled { marker } else { none }, + indent-guides: true, + ) + context { + let words = query(selector().after(origin).before(here())).map(m => m + .location() + .position()) + assert.eq(words.len(), 8) + let first = words.first() + let starts = words + .enumerate() + .filter(((i, at)) => i > 0 and at.y > words.at(i - 1).y + 1pt) + .map(((i, at)) => at) + assert(starts.len() > 0) + let rows = query(selector(<__codly-wrap-row>).after(origin).before(here())) + let advance = if enabled { rows.first().value.advance } else { 0pt } + for at in starts { + assert(calc.abs(at.x - first.x - advance) < 0.01pt, message: repr(( + spans, + enabled, + first.x, + at.x, + advance, + ))) + } + let arrows = query(selector().after(origin).before(here())) + assert.eq(arrows.len(), if enabled { starts.len() } else { 0 }) + } + } + } +} + +// Disabling smart-indent still allows a highlight to wrap, without retaining +// the original indentation or painting the configured continuation marker. +#context { + let origin = here() + show regex("alpha|bravo|delta|foxtt|gamma|hotel|indii|julii"): it => [#it#metadata( + none, + )] + codly.new( + raw(source, block: true), + highlights: ((line: 1, start: 1, end: 999),), + smart-indent: false, + wrap-marker: marker, + ) + context { + let words = query(selector().after(origin).before(here())).map(m => m + .location() + .position()) + let next = words.find(at => at.y > words.first().y + 1pt) + assert(next != none and next.x < words.first().x - 10pt) + assert.eq(query(selector().after(origin).before(here())).len(), 0) + } +} diff --git a/tests/sublang-base/ref/1.png b/tests/sublang-base/ref/1.png new file mode 100644 index 0000000..3ff0e4a Binary files /dev/null and b/tests/sublang-base/ref/1.png differ diff --git a/tests/sublang-base/test.typ b/tests/sublang-base/test.typ new file mode 100644 index 0000000..444e3d3 --- /dev/null +++ b/tests/sublang-base/test.typ @@ -0,0 +1,41 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 300pt, height: auto, margin: 5pt) + +#codly.new( + ````markdown + # Header + + This is a sub-block in another language, highlighted correctly: + ```rs + fn main() { + println!("Hello, world!"); + } + ``` + ````, +) + +#codly.new( + ````sh + cat main.rs + fn main() { + println!("Hello, world!"); + } + + cat main.c + #include + int main() { + printf("Hello, world!\n"); + return 0; + } + ````, + sublangs: ( + (start: 2, end: 4, lang: "rs"), + (start: 6, end: 10, lang: "c"), + ), + highlights: ( + (line: 3, start: 5, end: 12, tag: "rs-macro"), + (line: 9, start: 5, end: 10, tag: "c-func"), + ), +) diff --git a/tests/sublang-features/ref/1.png b/tests/sublang-features/ref/1.png new file mode 100644 index 0000000..deccb9d Binary files /dev/null and b/tests/sublang-features/ref/1.png differ diff --git a/tests/sublang-features/test.typ b/tests/sublang-features/test.typ new file mode 100644 index 0000000..de5c859 --- /dev/null +++ b/tests/sublang-features/test.typ @@ -0,0 +1,135 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 300pt, height: auto, margin: 5pt) + +#let text-of(body) = { + if body.has("text") { body.text } else if body.has("children") { + body.children.map(text-of).join() + } else if body.has("child") { text-of(body.child) } else if body.has("body") { + text-of(body.body) + } else { "" } +} + +#show: codly.line-show_(it => { + let fields = e.fields(it) + let line = fields.body + if line != none and line.func() == raw.line { + [#metadata(( + number: line.number, + count: line.count, + text: line.text, + body: text-of(line.body), + styled: line.body, + smart-indent: fields.smart-indent, + ))#it] + } else { + [#metadata(line)#it] + } +}) +#show: codly.highlight-show_(it => { + let fields = e.fields(it) + [#metadata((tag: fields.highlight.tag, body: text-of(fields.body)))#it] +}) +#show: e.show_(codly.codly-number, it => { + [#metadata(e.fields(it).number)#it] +}) +#show: codly.annotation-show_(it => { + let fields = e.fields(it) + [#metadata((num: fields.num, height: fields.height))#it] +}) + +// Both sublanguages participate in the SAME source-line loop: a hidden line, +// a smart skip, an explicit skip, offsets, nested highlights, and an annotation +// spanning the language boundary all retain their parent-block coordinates. +@mixed:12 @rust-mark @c-mark @mixed-note +#figure(caption: [Mixed languages])[ + #codly.new( + raw( + "echo before\n let value = 1;\n /* comment\n continued */\necho middle\nint answer() {\n return 42;\n}\n", + lang: "sh", + block: true, + ), + block-label: , + offset: 10, + sublangs: ((start: 2, end: 4, lang: "rs"), (start: 6, end: 8, lang: "c")), + ranges: ((1, 2), (4, none)), + smart-skip: true, + skips: ((7, 2),), + skip-line: [gap], + skip-number: [gap], + highlights: ( + (line: 12, start: 4, end: 18, tag: "outer", label: ), + (line: 12, start: 8, end: 13, tag: "inner"), + (line: 19, start: 4, end: 10, tag: "return", label: , fill: blue), + ), + highlighted: ((19, yellow),), + annotations: ((start: 2, end: 7, content: [span], label: ),), + ) +] + +// Reusing sublanguage index zero in later blocks must resolve their own text. +// Cover numbering disabled, offset-from, and a trailing empty sublanguage line. +#codly.new( + raw("let other = 2;\n", lang: "sh", block: true), + sublangs: ((start: 1, end: 2, lang: "rs"),), + number-enabled: false, + offset-from: , + smart-indent: false, + highlights: ((line: 21, start: 1, end: 3, tag: "later"),), +) + +// Ordinary raw syntax gives a reference for the complete styled line content; +// copying a sublanguage must preserve it before applying line formatting. +#codly.new(raw(" let value = 1;", lang: "rs", block: true)) +#codly.new(raw(" return 42;", lang: "c", block: true)) + +#context { + let lines = query().map(it => it.value) + assert.eq(lines.map(it => it.number), (11, 12, 14, 15, 16, 19, 20, 21, 1, 1)) + assert.eq(lines.slice(0, 7).map(it => it.count), (9, 9, 9, 9, 9, 9, 9)) + assert.eq(lines.at(1).text, " let value = 1;") + assert.eq(lines.at(2).text, " continued */") + assert.eq(lines.at(5).text, " return 42;") + assert.eq(lines.at(7).text, "let other = 2;") + assert.eq(lines.at(7).smart-indent, false) + for line in lines { assert.eq(line.body, line.text) } + assert.eq(query().map(it => it.value), ([gap], [gap])) + assert.eq(query().map(it => it.value), ( + 11, + 12, + [gap], + 14, + 15, + 16, + [gap], + 19, + 20, + 1, + 1, + )) + let annotations = query() + assert.eq(annotations.map(it => it.value.num), (1,)) + // A figure's inherited block settings must not collapse the brace height. + // It must cover the annotated rows, including highlights and both skips. + let positions = query().slice(0, 7).map(it => it.location().position()) + let height = annotations.first().value.height + assert(height >= positions.at(5).y - positions.at(1).y) + assert(height <= positions.at(6).y - positions.at(0).y) + + let highlights = query().map(it => it.value) + assert.eq(highlights.map(it => it.tag).sorted(), ("inner", "later", "outer", "return")) + assert(highlights.find(it => it.tag == "inner").body.contains("value")) + assert.eq(highlights.find(it => it.tag == "return").body, " return") + assert.eq(highlights.find(it => it.tag == "later").body, "let") + + assert.eq(lines.at(1).styled, lines.at(8).styled) + assert.eq(lines.at(5).styled, lines.at(9).styled) + for target in (, , , , ) { + assert.eq(query(target).len(), 1) + assert.eq(query(target).first().func(), figure) + } + assert.eq(query().len(), 0) + assert.eq(codly.info(), (last-number: 20, lines: 9)) + assert.eq(codly.info(), (last-number: 21, lines: 2)) +} diff --git a/tests/types/test.typ b/tests/types/test.typ new file mode 100644 index 0000000..cd948a9 --- /dev/null +++ b/tests/types/test.typ @@ -0,0 +1,237 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 160pt, height: auto, margin: 5pt) + +// Keep the expected values as data so every constructor and cast follows the +// same assertion path. `e.fields` removes the implementation metadata from +// the comparison. +#let check-constructors(cases) = { + for case in cases { + assert.eq(e.fields(case.at(0)), case.at(1)) + } +} + +#let check-casts(cases) = { + for case in cases { + let result = e.types.cast(case.at(1), case.at(0)) + assert.eq(result.at(0), true) + assert.eq(e.fields(result.at(1)), case.at(2)) + } +} + +#let default-numbering = numbering.with("(1)") + +#check-constructors(( + // `language` and `smart-skip` take named fields. + (codly.language(name: "Python"), (name: "Python", color: none, icon: none)), + ( + codly.language(name: [Python], color: blue, icon: "py"), + (name: [Python], color: blue, icon: "py"), + ), + (codly.smart-skip(), (first: none, last: none, rest: none)), + (codly.smart-skip(first: true, last: false, rest: none), (first: true, last: false, rest: none)), + + // Pair-like constructors accept both positional and named required fields. + (codly.range(2, 5), (start: 2, end: 5)), + (codly.range(start: 3, end: none), (start: 3, end: none)), + (codly.skip(4), (position: 4, length: 1)), + (codly.skip(position: 5, length: 3), (position: 5, length: 3)), + (codly.highlighted-line(6), (line: 6, color: none)), + (codly.highlighted-line(line: 7, color: green), (line: 7, color: green)), + + // Optional values are retained, while omitted highlight bounds normalize. + ( + codly.highlight(8), + ( + line: 8, + start: 0, + end: 999999999, + fill: none, + tag: none, + inset: none, + baseline: none, + clip: none, + outset: none, + radius: none, + label: none, + stroke: none, + depth: none, + ), + ), + ( + codly.highlight( + 9, + start: 2, + end: 6, + fill: red, + tag: "marked", + inset: 1pt, + baseline: 2pt, + clip: true, + outset: 3pt, + radius: 4pt, + depth: 5, + ), + ( + line: 9, + start: 2, + end: 6, + fill: red, + tag: "marked", + inset: 1pt, + baseline: 2pt, + clip: true, + outset: 3pt, + radius: 4pt, + label: none, + stroke: none, + depth: 5, + ), + ), + ( + codly.highlight(10, start: none, end: none), + ( + line: 10, + start: 0, + end: 999999999, + fill: none, + tag: none, + inset: none, + baseline: none, + clip: none, + outset: none, + radius: none, + label: none, + stroke: none, + depth: none, + ), + ), + ( + codly.annotation(11), + ( + start: 11, + end: 11, + content: none, + label: none, + numbering: default-numbering, + ), + ), + ( + codly.annotation(12, end: 14, content: [explanation]), + ( + start: 12, + end: 14, + content: [explanation], + label: none, + numbering: default-numbering, + ), + ), +)) + +#check-casts(( + // Dictionary and string casts for language preserve unknown fields. + ( + codly.language, + (name: "Rust", color: orange, icon: "rs", aliases: 2), + (name: "Rust", color: orange, icon: "rs", aliases: 2), + ), + (codly.language, "Typst", (name: "Typst", color: none, icon: none)), + + // Bool and dictionary casts for smart skips fill omitted sides from `rest`. + (codly.smart-skip, true, (first: true, last: true, rest: true)), + (codly.smart-skip, (rest: true, first: false), (first: false, last: true, rest: true)), + + // Arrays and dictionaries both feed the pair-like constructors. + (codly.range, (2, 4), (start: 2, end: 4)), + (codly.range, (start: 3), (start: 3, end: none)), + (codly.skip, (5, 2), (position: 5, length: 2)), + (codly.skip, (position: 6), (position: 6, length: 1)), + (codly.highlighted-line, 7, (line: 7, color: none)), + (codly.highlighted-line, (8, purple), (line: 8, color: purple)), + (codly.highlighted-line, (line: 9, color: teal), (line: 9, color: teal)), + + // Highlight and annotation dictionary casts retain supplied values and + // apply the same defaults as their constructors. + ( + codly.highlight, + (line: 13, start: 1, end: 4, tag: "tag", clip: false, depth: 2), + ( + line: 13, + start: 1, + end: 4, + fill: none, + tag: "tag", + inset: none, + baseline: none, + clip: false, + outset: none, + radius: none, + label: none, + stroke: none, + depth: 2, + ), + ), + ( + codly.annotation, + (start: 15, end: 16, content: [note]), + ( + start: 15, + end: 16, + content: [note], + label: none, + numbering: default-numbering, + ), + ), +)) + +// Exercise the same casts through the public block constructor, where these +// types are consumed in arrays and option fields. +#let block-fields = e.fields(codly.new( + none, + range: (17, 19), + smart-skip: true, + skips: ((20, 2),), + highlighted: (21,), + highlights: ((line: 22, start: 1, end: 2),), + annotations: ((start: 23, content: [note]),), +)) + +#assert.eq(e.fields(block-fields.range), (start: 17, end: 19)) +#assert.eq(e.fields(block-fields.smart-skip), (first: true, last: true, rest: true)) +#assert.eq(e.fields(block-fields.skips.at(0)), (position: 20, length: 2)) +#assert.eq(e.fields(block-fields.highlighted.at(0)), (line: 21, color: none)) +#assert.eq(e.fields(block-fields.highlights.at(0)), ( + line: 22, + start: 1, + end: 2, + fill: none, + tag: none, + inset: none, + baseline: none, + clip: none, + outset: none, + radius: none, + label: none, + stroke: none, + depth: none, +)) +#assert.eq(e.fields(block-fields.annotations.at(0)), ( + start: 23, + end: 23, + content: [note], + label: none, + numbering: default-numbering, +)) + +// Failed casts report false without requiring the full diagnostic text. +#for (type, value) in ( + (codly.language, (color: red)), // missing required name + (codly.range, "range"), + (codly.skip, true), + (codly.highlighted-line, "line"), + (codly.highlight, false), + (codly.annotation, 0), +) { + assert.eq(e.types.cast(value, type).at(0), false) +} diff --git a/tests/unnumbered-lines/ref/1.png b/tests/unnumbered-lines/ref/1.png new file mode 100644 index 0000000..c64e615 Binary files /dev/null and b/tests/unnumbered-lines/ref/1.png differ diff --git a/tests/unnumbered-lines/test.typ b/tests/unnumbered-lines/test.typ new file mode 100644 index 0000000..5bad961 --- /dev/null +++ b/tests/unnumbered-lines/test.typ @@ -0,0 +1,23 @@ +#import "../../codly.typ" as codly + +#set page(width: 300pt, height: auto, margin: 5pt) + +// The labels use the prior displayed line and a one-indexed position after it. +@code:l1p1 and @code:l2p1 + +#figure(caption: [Unnumbered label-only lines])[ + #codly.new( + raw("first\nstart:\nsecond\ndone:\nthird", block: true), + block-label: , + unnumbered: ((2, [😀]), 4), + ) +] + +#context { + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(codly.info(), (last-number: 3, lines: 5)) +} diff --git a/tests/width/ref/1.png b/tests/width/ref/1.png new file mode 100644 index 0000000..e33f75a Binary files /dev/null and b/tests/width/ref/1.png differ diff --git a/tests/width/test.typ b/tests/width/test.typ new file mode 100644 index 0000000..1132ee2 --- /dev/null +++ b/tests/width/test.typ @@ -0,0 +1,19 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 300pt, height: auto, margin: 5pt) + +#let code = ```rs +pub fn main() { + println!("Hello, world!"); +} +``` + +#codly.new(code) +#codly.new(code, width: auto) +#codly.new(code, width: 50%) +#codly.new(code, width: 100pt) +#codly.new( + raw("a deliberately long line verifies auto width stops at the page margins", block: true), + width: auto, +) diff --git a/tests/wrap-marker-layout/test.typ b/tests/wrap-marker-layout/test.typ new file mode 100644 index 0000000..65bf06d --- /dev/null +++ b/tests/wrap-marker-layout/test.typ @@ -0,0 +1,90 @@ +#import "../../codly.typ" as codly +#import "@preview/elembic:1.1.1" as e + +#set page(width: 220pt, height: 150pt, margin: 5pt) +#let marker = [#metadata(none)#text(fill: gray)[↪]] + +// References, highlights, annotations, sublanguages, ranges and offsets. +@wrapped:12 @wrapped-highlight +#figure(caption: [Wrap markers])[ + #codly.new( + raw( + "root\n f(very_long_argument, another_long_argument, argument_three);\n next();\nend", + lang: "js", + block: true, + ), + wrap-marker: marker, + indent-guides: true, + rainbow: true, + offset: 10, + block-label: , + range: (2, 3), + sublangs: ((start: 2, end: 2, lang: "rs"),), + highlights: ((line: 12, start: 4, end: 5, label: ),), + annotations: ((start: 2, end: 3, content: [note]),), + ) +] +#context { + assert.eq(query().len(), 1) + assert.eq(query().len(), 1) + assert.eq(codly.info(), (last-number: 13, lines: 4)) + assert(query().len() > 0) +} + +#pagebreak() +// Every fragment of a long source row needs markers after the first visual line. +#for outside in (false, true) { + context { + let origin = here() + show: e.set_(codly.codly-number, placement: if outside { "outside" } else { "inside" }) + codly.new( + raw(" call(" + "argument, " * 80 + ");", block: true), + wrap-marker: marker, + indent-guides: true, + header: [header], + footer: [footer], + ) + context { + let arrows = query(selector().after(origin).before(here())) + assert(arrows.len() > 10) + assert(arrows.last().location().position().page > arrows.first().location().position().page) + } + } + pagebreak() +} + +// Multiple columns on a page and nested Codly blocks have separate owners. +#set page(width: 420pt, height: 200pt, columns: 2) +#context { + let origin = here() + codly.new(raw(" " + "word " * 160, block: true), wrap-marker: marker) + context { + let arrows = query(selector().after(origin).before(here())) + assert(arrows.len() > 10) + assert(arrows.any(m => m.location().position().x > 210pt)) + } +} + +#pagebreak() +#set page(width: 420pt, height: auto, columns: 1) +// Nested blocks can reuse source row numbers without sharing continuation state. +#context { + let origin = here() + let nested = codly.new( + raw(" " + "nested " * 20, block: true), + wrap-marker: [#metadata(none)↪], + number-enabled: false, + ) + codly.new( + raw(" " + "outer " * 20, block: true), + wrap-marker: [#metadata(none)↪], + annotations: ((start: 1, content: box(width: 130pt, nested)),), + ) + context { + let rows = query(selector(<__codly-wrap-row>).after(origin).before(here())) + assert.eq(rows.len(), 2) + assert.ne(rows.first().value.owner, rows.last().value.owner) + assert(query(selector().after(origin).before(here())).len() > 0) + assert(query(selector().after(origin).before(here())).len() > 0) + } +} diff --git a/tests/wrap-marker/ref/1.png b/tests/wrap-marker/ref/1.png new file mode 100644 index 0000000..c4530fb Binary files /dev/null and b/tests/wrap-marker/ref/1.png differ diff --git a/tests/wrap-marker/test.typ b/tests/wrap-marker/test.typ new file mode 100644 index 0000000..8662796 --- /dev/null +++ b/tests/wrap-marker/test.typ @@ -0,0 +1,121 @@ +#import "../../codly.typ" as codly +#import "../../src/wrap.typ" as wrap +#import "@preview/elembic:1.1.1" as e + +#set page(width: 230pt, height: auto, margin: 5pt) +#let marker = [#metadata(none)#text(fill: gray)[↪]] +#let source = "fn main() {\n if ready {\n call(very_long_argument, another_long_argument, argument_three);\n }\n}" + +// Only the wrapped source row gets markers; source text and numbering survive. +#context { + let origin = here() + show: codly.line-show_(it => [#metadata(( + e.fields(it).body.number, + e.fields(it).body.text, + ))#it]) + codly.new( + raw(source, lang: "rs", block: true), + wrap-marker: marker, + rainbow: true, + indent-guides: (x-offset: 0.3em), + ) + context { + assert.eq(query(selector().after(origin).before(here())).len(), 2) + assert.eq( + query(selector().after(origin).before(here())).map(m => m.value.last()), + source.split("\n"), + ) + // Short source rows take the fast path and need no per-character tags. + let rows = query(selector(<__codly-wrap-row>).after(origin).before(here())) + assert.eq(rows.map(m => m.value.row), (3,)) + } +} + +// An arbitrary styled text marker works through scoped defaults and overrides. +#context { + let origin = here() + show: codly.set_(wrap-marker: [#metadata(none)#text(red)[-->]]) + codly.new(raw(source, lang: "rs", block: true), number-enabled: false) + context { assert(query(selector().after(origin).before(here())).len() > 0) } +} + +// Off, empty, disabled smart-indent, short lines, blank lines, and inline raw. +#context { + let origin = here() + show: codly.set_(wrap-marker: marker) + codly.new(raw(source, block: true), wrap-marker: none) + codly.new(raw(source, block: true), wrap-marker: []) + codly.new(raw(source, block: true), smart-indent: false) + codly.new(raw("short\n\n short", block: true)) + codly.new(raw("inline " + "long " * 30)) + context { + assert.eq(query(selector().after(origin).before(here())).len(), 0) + assert.eq(query(selector(<__codly-wrap-point>).after(origin).before(here())).len(), 0) + } +} + +// Instrumentation keeps metrics for punctuation, repeated spaces, and graphemes. +#context { + for source in ("f(a, b); x/y/z", "e\u{301} = 👩‍💻; 中文中文中文", "office affine ffi") { + for width in (45pt, 90pt, 150pt) { + let plain = text(font: "DejaVu Sans Mono", source) + let tagged = wrap.annotate(plain, here(), 1) + assert.eq( + measure(plain, width: width), + measure(tagged, width: width), + message: source + " " + repr(width), + ) + } + } +} + +// Native wrap opportunities include CJK and punctuation, without splitting on spaces. +#context { + let origin = here() + codly.new(raw(" " + "中文中文" * 25, block: true), wrap-marker: marker) + context { assert(query(selector().after(origin).before(here())).len() >= 3) } +} + +// A single unbreakable token overflows natively; it must not invent wraps. +#context { + let origin = here() + codly.new(raw("x" * 100, block: true), wrap-marker: marker) + context { assert.eq(query(selector().after(origin).before(here())).len(), 0) } +} + +#assert.eq(e.fields(codly.new(none)).at("wrap-marker", default: none), none) +#assert(catch(() => codly.new(none, wrap-marker: 42)) != none) + +// A highlight wrapping internally reserves marker space too, and markers are +// painted above the highlight fill rather than disappearing behind it. +#context { + let origin = here() + codly.new( + raw(source, lang: "rs", block: true), + wrap-marker: marker, + highlights: ((line: 3, start: 0, end: 999),), + indent-guides: true, + ) + context { assert.eq(query(selector().after(origin).before(here())).len(), 2) } +} + +// Raised or lowered highlights on a short line are not line wraps. +#context { + let origin = here() + for baseline in (-8pt, 0pt, 8pt) { + codly.new(raw("short call();", block: true), wrap-marker: marker, highlights: ( + (line: 1, start: 6, end: 10, baseline: baseline), + )) + } + context { assert.eq(query(selector().after(origin).before(here())).len(), 0) } +} + +// Tagged highlights retain their existing fixed-width layout. An overflowing +// tagged span must not be mistaken for a soft wrap. +#context { + let origin = here() + codly.new(raw(" " + "argument " * 15, block: true), wrap-marker: marker, highlights: ( + (line: 1, start: 1, end: 999, tag: [1]), + )) + context { assert.eq(query(selector().after(origin).before(here())).len(), 0) } +} diff --git a/typst.toml b/typst.toml index 46131f5..cb21b4d 100644 --- a/typst.toml +++ b/typst.toml @@ -9,5 +9,5 @@ license = "MIT" description = "Codly is a beautiful code presentation template with many features like smart indentation, line numbering, highlighting, etc." repository = "https://github.com/Dherse/codly" exclude = [ "assets/*", "docs.pdf" ] -compiler = "0.12.0" -keywords = [ "code", "pretty", "template", "raw" ] \ No newline at end of file +compiler = "0.15.0" +keywords = [ "code", "pretty", "template", "raw" ]