term-transcript 0.5.0

Snapshotting and snapshot testing for CLI / REPL applications
Documentation
{{! Renders `class` and/or `style` attributes for a background SVG span }}
{{~#*inline "svg_bg_tspan_attrs"}}
  {{~#scope bg=bg classes="" styles=""}}
    {{~#if inverted~}}
      {{~#if (eq (typeof fg) "null")~}}
        {{~set @bg 7 ~}} {{! indexed white }}
      {{~else~}}
        {{~set @bg fg ~}}
      {{~/if~}}
    {{~/if~}}
    {{~#set @classes append=true}}
      {{~#if dimmed}} dimmed{{/if~}}
      {{~#if blink}} blink{{/if~}}
      {{! Here, we intentionally use the *foreground* class }}
      {{~#if (eq (typeof @bg) "number")}} fg{{@bg}}{{/if~}}
    {{/set~}}
    {{~set @classes (trim @classes)~}}

    {{~#if (eq (typeof @bg) "string")}}
      {{~#set @styles append=true}}fill: {{@bg}}; stroke: {{@bg}};{{/set~}}
    {{~/if~}}
    {{#if @classes}} class="{{@classes}}"{{/if}}{{#if @styles}} style="{{@styles}}"{{/if}}
  {{~/scope~}}
{{~/inline~}}

{{! Root template }}
{{~#*inline "root"}}
<!-- Created with {{{creator.name}}} v{{{creator.version}}} ({{{creator.repo}}}) -->
<svg viewBox="0 {{#if window}}-{{const.WINDOW_FRAME_HEIGHT}}{{else}}0{{/if}} {{width}} {{height}}" width="{{width}}" height="{{height}}" xmlns="http://www.w3.org/2000/svg">
  {{>styles}}
  {{>background}}

  {{~>content}}
  {{~#if scroll_animation}}
  {{>scrollbar}}
  {{>scroll_animation}}
  {{/if}}
</svg>
{{/inline~}}

{{! CSS definitions }}
{{~#*inline "styles"}}
<style>
  {{>common_styles_header}}
  .container {
    font: {{const.FONT_SIZE}}px {{#if embedded_font}}"{{embedded_font.family_name}}", monospace{{else}}{{font_family}}{{/if}};
    line-height: {{round line_height digits=2}}px;
  }
  text { white-space: pre; text-decoration-thickness: 1px; }
  .input-bg { fill: #fff; fill-opacity: 0.1; }
  .input-hidden { display: none; }
  .output-bg { stroke-width: 0.5; }
  .output-sep { stroke: {{ palette.colors.white }}; stroke-width: 1; stroke-opacity: 0.35; }
  {{~#if has_failures}}

  .input-bg .input-failure { fill: #ff0041; fill-opacity: 0.15; }
  .input-failure-hl { fill: #ff0041; fill-opacity: 1; }
  {{/if}}
  {{~#if scroll_animation}}

  .scrollbar { fill: #fff; fill-opacity: 0.35; }
  {{~/if}}
  {{~#if line_numbers}}

  .line-numbers { text-anchor: end; fill-opacity: 0.35; user-select: none; }
  {{/if}}

  {{>common_font_styles}}
  .underline { text-decoration-line: underline; }
  .strike { text-decoration-line: line-through; }
  .underline.strike { text-decoration-line: underline line-through; }
  .dimmed { fill-opacity: {{dim_opacity}}; }
  .concealed { fill-opacity: 0 !important; }
  @keyframes blink {
    0% { fill-opacity: 1; stroke-opacity: 1; }
    100% { fill-opacity: {{blink.opacity}}; stroke-opacity: {{blink.opacity}}; }
  }
  .blink { animation: {{mul 2 blink.interval}}s steps(2, jump-none) 0s infinite blink; }
  {{~#if wrap}}

  .hard-br { font-weight: bold; fill-opacity: 0.6; user-select: none; }
  {{~/if}}

  .fg0,.inv.fg-none { fill: {{ palette.colors.black }}; } .output-bg .fg0 { stroke: {{ palette.colors.black }}; }
  .fg1 { fill: {{ palette.colors.red }}; } .output-bg .fg1 { stroke: {{ palette.colors.red }}; }
  .fg2 { fill: {{ palette.colors.green }}; } .output-bg .fg2 { stroke: {{ palette.colors.green }}; }
  .fg3 { fill: {{ palette.colors.yellow }}; } .output-bg .fg3 { stroke: {{ palette.colors.yellow }}; }
  .fg4 { fill: {{ palette.colors.blue }}; } .output-bg .fg4 { stroke: {{ palette.colors.blue }}; }
  .fg5 { fill: {{ palette.colors.magenta }}; } .output-bg .fg5 { stroke: {{ palette.colors.magenta }}; }
  .fg6 { fill: {{ palette.colors.cyan }}; } .output-bg .fg6 { stroke: {{ palette.colors.cyan }}; }
  .fg7 { fill: {{ palette.colors.white }}; } .output-bg .fg7 { stroke: {{ palette.colors.white }}; }
  .fg8 { fill: {{ palette.intense_colors.black }}; } .output-bg .fg8 { stroke: {{ palette.intense_colors.black }}; }
  .fg9 { fill: {{ palette.intense_colors.red }}; } .output-bg .fg9 { stroke: {{ palette.intense_colors.red }}; }
  .fg10 { fill: {{ palette.intense_colors.green }}; } .output-bg .fg10 { stroke: {{ palette.intense_colors.green }}; }
  .fg11 { fill: {{ palette.intense_colors.yellow }}; } .output-bg .fg11 { stroke: {{ palette.intense_colors.yellow }}; }
  .fg12 { fill: {{ palette.intense_colors.blue }}; } .output-bg .fg12 { stroke: {{ palette.intense_colors.blue }}; }
  .fg13 { fill: {{ palette.intense_colors.magenta }}; } .output-bg .fg13 { stroke: {{ palette.intense_colors.magenta }}; }
  .fg14 { fill: {{ palette.intense_colors.cyan }}; } .output-bg .fg14 { stroke: {{ palette.intense_colors.cyan }}; }
  .fg15 { fill: {{ palette.intense_colors.white }}; } .output-bg .fg15 { stroke: {{ palette.intense_colors.white }}; }
</style>
{{/inline~}}

{{~#*inline "content"}}
    <svg{{#if scroll_animation}} id="scroll-container"{{/if}} x="0" y="{{const.WINDOW_PADDING}}" width="{{width}}" height="{{screen_height}}" viewBox="0 0 {{width}} {{screen_height}}">
      {{#scope
        x_pos=const.WINDOW_PADDING
        input_x_pos=const.WINDOW_PADDING
        y_pos=0
        input_bg=""
        output_bg=""
        text_fg=""
        hard_breaks=""
      ~}}
      {{~#if line_numbers~}}
        {{set @x_pos (add @x_pos const.LN_WIDTH const.LN_PADDING)}}
      {{~/if~}}

      {{~#if line_numbers~}}
        {{~#if (eq line_numbers.scope "continuous")~}}
          {{~set @input_x_pos @x_pos~}}
        {{~/if~}}
      {{~/if~}}

      {{~#each interactions}}
        {{~#if (not input.hidden)}}
          {{! Render input background }}
          {{~set @../y_pos (add @../y_pos ../const.USER_INPUT_PADDING)~}}
          {{~#set @../input_bg append=true ~}}
            {{! `y_pos` refers to the top of the text line, so for <rect>s we need to subtract the top margin }}
            {{~#scope
              top_y=(round (sub @../y_pos ../const.USER_INPUT_PADDING) digits=2)
              input_height=(round (add (mul (count_lines input.text) ../line_height) (mul 2 ../const.USER_INPUT_PADDING)) digits=1)
            ~}}
            <rect x="0" y="{{@top_y}}" width="100%" height="{{@input_height}}"{{#if failure}} class="input-failure"{{/if}}>
            {{~#if failure~}}
              <title>This command exited with non-zero code</title>
            {{~/if~}}
            </rect>
            {{~#if failure~}}
            <rect x="0" y="{{@top_y}}" width="2" height="{{@input_height}}" class="input-failure-hl" />
            <rect x="100%" y="{{@top_y}}" width="2" height="{{@input_height}}" class="input-failure-hl" transform="translate(-2, 0)" />
            {{~/if~}}
            {{~/scope~}}
          {{~/set~}}
        {{~/if~}} {{! if (not input.hidden) }}

        {{! The awkward newlines at the end of line <text>s are required for the text to be properly copyable }}
        {{~#set @../text_fg append=true ~}}
        <g class="input{{#if input.hidden}} input-hidden{{/if}}{{#if failure}} input-failure{{/if}}">
          {{~#each (split_lines input.text)~}}
            <g transform="translate({{@../../input_x_pos}} {{
              round (add @../../y_pos ../../text_y_offset) digits=2
            }})">{{#scope text_x_offset=0 sp="" ~}}
              {{~#if @first~}}
                {{~set @text_x_offset (round (mul (char_width ../input.prompt) ../../advance_width) digits=2)~}}
                {{~set @sp " "~}}
                <text class="prompt"{{#if ../../adjust_text_length}} textLength="{{@text_x_offset}}"{{/if}}>{{../input.prompt}}</text>
              {{~/if~}}
              <text x="{{@text_x_offset}}"{{#if ../../adjust_text_length}} textLength="{{
                round (mul (add (char_width this) (char_width @sp)) ../../advance_width) digits=2
              }}"{{/if}}>{{@sp}}{{this}}</text>
            {{~/scope}}<text>
</text></g>
            {{~#if (not ../input.hidden)~}}
              {{~set @../../y_pos (add @../../y_pos ../../line_height)}}
            {{~/if~}}
          {{~/each~}}
        </g>
        {{~/set~}}
        {{~#if (not input.hidden)~}}
          {{~set @../y_pos (add @../y_pos ../const.USER_INPUT_PADDING ../const.BLOCK_MARGIN)}}
        {{~/if~}}

        {{! Render output backround and text }}
        {{~#set @../text_fg append=true ~}}
          {{~#if (and (not @first) input.hidden)~}}
            {{~#scope line_y_pos=(round (sub @../y_pos (sub (div ../const.BLOCK_MARGIN 2) 1)) digits=1)~}}
            <line class="output-sep" x1="0" y1="{{@line_y_pos}}" x2="100%" y2="{{@line_y_pos}}" />
            {{~/scope~}}
          {{~/if~}}
          <g class="output">
        {{~/set~}}
        {{~#each output as |line|~}}
          {{~#scope char_pos=0 line_fg="" line_bg="" ~}}
          {{~#each line.spans as |span|~}}
            {{~#set @../line_bg append=true ~}}
              {{~#if (or (ne (typeof span.bg) "null") span.inverted)~}}
                <rect x="{{
                  round (add @../../../x_pos (mul @../char_pos ../../../advance_width)) digits=2
                }}" y="{{
                  round @../../../y_pos digits=2
                }}" width="{{
                  round (mul (char_width span.text) ../../../advance_width) digits=2
                }}" height="{{
                  round ../../../line_height digits=2
                }}"{{>svg_bg_tspan_attrs span}}/>
              {{~/if~}}
            {{~/set~}}
            {{! Render span foreground }}
            {{~#set @../line_fg append=true ~}}
              <text x="{{
                round (mul @../char_pos ../../../advance_width) digits=2
              }}"{{#if (and ../../../adjust_text_length (gt (char_width span.text) 1))}} textLength="{{
                round (mul (char_width span.text) ../../../advance_width) digits=2
              }}"{{/if}}{{>svg_tspan_attrs span}}>{{span.text}}</text>
              {{~#if @last~}}
              <text{{#if ../line.br}} class="hard-br"{{/if}}>
</text>
              {{~/if~}}
            {{~/set~}}
            {{! Update char position on the line }}
            {{~set @../char_pos (add @../char_pos (char_width span.text))~}}
          {{~else~}}{{! The line may be empty }}
          {{~/each~}}

          {{~#scope mark=@root.wrap.hard_break_at.mark~}}
            {{~#if (and line.br @mark)~}}
              {{~#set @../../hard_breaks append=true~}}
                <text x="{{
                  round (add @../../x_pos 8 (mul ../../advance_width @char_pos)) digits=2
                }}" y="{{
                  round (add @../../y_pos ../../text_y_offset) digits=2
                }}">{{@mark}}</text>
              {{~/set~}}
            {{~/if~}}
          {{~/scope~}}

          {{! Append collected `line_bg` to the overall background }}
          {{~#set @../../output_bg append=true}}{{{@line_bg}}}{{/set~}}

          {{! Append collected `line_fg` to the overall text }}
          {{~#set @../../text_fg append=true ~}}
            {{~#if @line_fg~}}
              <g transform="translate({{@../../x_pos}} {{
                round (add @../../y_pos ../../text_y_offset) digits=2
              }})" clip-path="xywh(0 0 100% {{../../line_height}}px)">{{{@line_fg}}}</g>
            {{~else~}}
              {{! The line is empty. In this case, append an empty `<text>` so that the output is correctly parsed. }}
              <text>
</text>
            {{~/if~}}
          {{~/set~}}
          {{~/scope~}}
          {{~set @../../y_pos (add @../../y_pos ../../line_height)~}}
        {{~else~}}{{! The output may be empty }}
        {{~/each~}}
        {{~#set @../text_fg append=true }}</g>{{/set~}}
        {{~#if (ne (len output) 0)}}
          {{~set @../y_pos (add @../y_pos ../const.BLOCK_MARGIN)}}
        {{~/if}}
      {{~/each~}}

      <g class="input-bg">{{{@input_bg}}}</g>
      {{~#if @output_bg~}}
        <g class="output-bg">{{{@output_bg}}}</g>
      {{~/if~}}
      {{#if line_numbers}}

      {{>number_lines}}
      {{/if~}}
      {{~#if @hard_breaks}}<g class="container fg7 hard-br">{{{@hard_breaks}}}</g>{{/if~}}
      <g class="container fg7">{{{@text_fg}}}</g>
      {{~/scope~}}
    </svg>
{{/inline~}}

{{~#*inline "number_lines"~}}
  {{~#scope
    x_pos=(add const.WINDOW_PADDING const.LN_WIDTH)
    y_pos=0
    line_number=1
  ~}}
  <g class="container fg7 line-numbers">
  {{~#each interactions}}
    {{~#if (not input.hidden)}}
      {{~set @../y_pos (add @../y_pos ../const.USER_INPUT_PADDING)~}}
      {{~#if (eq ../line_numbers.scope "continuous")}}
        {{~#each (range 0 (count_lines input.text))~}}
          <text x="{{@../../x_pos}}" y="{{round (add @../../y_pos ../../text_y_offset) digits=2}}">{{add this @../../line_number}}</text>
          {{~set @../../y_pos (add @../../y_pos ../../line_height)~}}
        {{~/each~}}
        {{~set @../line_number (add @../line_number (count_lines input.text))~}}
      {{~else~}}
        {{~set @../y_pos (add @../y_pos (mul (count_lines input.text) ../line_height))~}}
      {{~/if~}}
      {{~set @../y_pos (add @../y_pos ../const.USER_INPUT_PADDING ../const.BLOCK_MARGIN)~}}
    {{~/if~}}
    {{! Number lines in the output }}
    {{~#if (eq ../line_numbers.scope "each_output")~}}
      {{~set @../line_number 1 ~}}
    {{~/if~}}

    {{~#scope skip_line_number=false ~}}
    {{~#each output as |line|~}}
      {{~#if (not @../skip_line_number)~}}
        <text x="{{@../../x_pos}}" y="{{round (add @../../y_pos ../../text_y_offset) digits=2}}">{{@../../line_number}}</text>
        {{~set @../../line_number (add 1 @../../line_number)~}}
      {{~else~}}
        {{~#scope mark=@root.line_numbers.continued.mark~}}
          {{~#if @mark~}}
          <text x="{{add @../../x_pos 3}}" y="{{round (add @../../y_pos ../../text_y_offset) digits=2}}">{{@mark}}</text>
          {{~/if~}}
        {{~/scope~}}
      {{~/if~}}
      {{~set @../skip_line_number (and line.br (ne ../../line_numbers.continued "inherit"))~}}
      {{~set @../../y_pos (add @../../y_pos ../../line_height)~}}
    {{~/each~}}
    {{~/scope~}}
    {{~#if (gt (len output) 0)~}}
      {{~set @../y_pos (add @../y_pos ../const.BLOCK_MARGIN)~}}
    {{~/if~}}
  {{~/each~}}
  </g>
  {{~/scope~}}
{{~/inline~}}

{{~#*inline "main"~}}
{{#scope
  content_height=0
  scroll_animation=null
  line_height=line_height
  advance_width=advance_width
  adjust_text_length=true
  text_y_offset=0
  screen_height=0
  height=0
  width=width
  line_number=1
}}
  {{~#set @line_height}}{{>compute_line_height}}{{/set~}}
  {{~#debug line_height=@line_height}}computed line height{{/debug~}}

  {{~#set @content_height~}}
    {{~>compute_content_height this line_height=@line_height ~}}
  {{~/set~}}
  {{~set @content_height (round @content_height digits=1)~}}
  {{~#debug content_height=@content_height}}computed content height{{/debug~}}

  {{~#if embedded_font~}}
    {{~#scope metrics=null ~}}
      {{~#set @metrics~}}
        {{~>scale_font_metrics metrics=embedded_font.metrics font_size=const.FONT_SIZE ~}}
      {{~/set~}}
      {{~#if (eq @advance_width null)~}}
        {{~set @advance_width (lookup @metrics "advance_width")~}}
        {{~#debug metrics=@metrics}}using advance width from font metrics{{/debug~}}
        {{~set @adjust_text_length false ~}}
      {{~else~}}
        {{! Scale advance width from ems to pixels }}
        {{~#debug advance_width=@advance_width}}using user-provided advance width{{/debug~}}
        {{~set @advance_width (mul @advance_width const.FONT_SIZE)~}}
      {{~/if~}}
      {{!
        Top and bottom marigns are (line_height - (ascent - descent)) / 2. To get an Y offset, we need to add the top margin
        and the ascent, i.e. (line_height + ascent + descent) / 2.
      }}
      {{~set @text_y_offset (div (add @line_height (lookup @metrics "ascent") (lookup @metrics "descent")) 2)~}}
      {{~#debug metrics=@metrics text_y_offset=@text_y_offset}}computed Y offset for text baseline from font metrics{{/debug~}}
    {{~/scope~}}
  {{~else~}}
    {{~#if (eq @advance_width null)~}}
      {{~#debug}}using default advance width{{/debug~}}
      {{~set @advance_width 8 ~}} {{! Set to a reasonable default: ~0.57em }}
    {{~else~}}
      {{! Scale advance width from ems to pixels }}
      {{~set @advance_width (mul @advance_width const.FONT_SIZE)~}}
    {{~/if~}}
    {{! Use the above formula with heuristic values: ascent ~ 1em, descent ~ -0.3em. Hence, their sum is ~0.65em or ~9px. }}
    {{~set @text_y_offset (div (add @line_height 9) 2)~}}
    {{~#debug text_y_offset=@text_y_offset}}computed Y offset for text baseline heuristically{{/debug~}}
  {{~/if~}}
  {{~#if line_numbers~}}
    {{! Adjust width to take line numbers into account }}
    {{~set @width (add @width const.LN_WIDTH const.LN_PADDING)~}}
  {{~/if~}}
  {{~#if scroll~}}
    {{~#set @scroll_animation~}}
      {{~>compute_scroll_animation this width=@width content_height=@content_height~}}
    {{~/set~}}
  {{~/if~}}
  {{~#if @scroll_animation~}}
    {{set @screen_height scroll.max_height}}
  {{~else~}}
    {{set @screen_height @content_height}}
  {{~/if~}}
  {{~set @height (add @screen_height (mul const.WINDOW_PADDING 2))~}}
  {{~#if window~}}
    {{set @height (add @height const.WINDOW_FRAME_HEIGHT)}}
  {{~/if~}}
{{>root (splat_vars this)~}} {{! <-- All rendering happens here }}
{{/scope}}
{{~/inline~}}

{{~>_common~}}