term-transcript 0.4.0

Snapshotting and snapshot testing for CLI / REPL applications
Documentation
{{!
  Computes content height based on line count in interactions.
  Expected hash inputs: `interactions`, `const`.
}}
{{~#*inline "compute_content_height"}}
  {{#scope lines=0 margins=0 displayed_interactions=0}}
    {{#each interactions}}
      {{#if (not input.hidden)}}
        {{lines set=(add (lines) (count_lines input.text))}}
        {{margins set=(add (margins) 1)}}
        {{displayed_interactions set=(add (displayed_interactions) 1)}}
      {{/if}}
      {{lines set=(add (lines) (len output_svg))}}
      {{#if (ne 0 (len output_svg))}}
        {{margins set=(add (margins) 1)}}
      {{/if}}
    {{/each}}
    {{#if (gt (margins) 0)}}
      {{! The last margin is not displayed. }}
      {{margins set=(sub (margins) 1)}}
    {{/if}}
    {{add (mul (lines) const.LINE_HEIGHT)
          (mul (margins) const.BLOCK_MARGIN)
          (mul 2 (displayed_interactions) const.USER_INPUT_PADDING) }}
  {{/scope}}
{{/inline~}}

{{!
  Computes scroll animation parameters.
  Expected hash inputs: `content_height`, `const`, `scroll`, `width`
}}
{{~#*inline "compute_scroll_animation"}}
  {{#if (gte scroll.max_height content_height)}}
  {{! No need for scroll animation }}
    null
  {{else}}
    {{#scope
      steps=(div (sub content_height scroll.max_height) scroll.pixels_per_scroll round="up")
      y_step=0
      view_box=""
      scrollbar_y=""
      sep=""
    }}
      {{y_step set=(div (sub scroll.max_height const.SCROLLBAR_HEIGHT) (steps))}}
      {{#each (range 0 (add (steps) 1))}}
        {{#sep}}{{#if @first}}""{{else}}";"{{/if}}{{/sep}}
        {{#view_box}}"{{view_box}}{{sep}}0 {{mul ../scroll.pixels_per_scroll @index}} {{../width}} {{../scroll.max_height}}"{{/view_box}}
        {{#scrollbar_y}}"{{scrollbar_y}}{{sep}}0 {{mul (y_step) @index round="nearest"}}"{{/scrollbar_y}}
      {{/each}}

      {
        "duration": {{mul scroll.interval (steps)}},
        "view_box": "{{view_box}}",
        "scrollbar_x": {{sub width const.SCROLLBAR_RIGHT_OFFSET}},
        "scrollbar_y": "{{scrollbar_y}}"
      }
    {{/scope}}
  {{/if}}
{{/inline~}}

{{! Root template }}
{{~#*inline "root"}}
<!-- Created with {{{creator.name}}} v{{{creator.version}}} ({{{creator.repo}}}) -->
<svg viewBox="0 {{#if window_frame}}-{{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}}
  {{/if}}
</svg>
{{/inline~}}

{{! CSS definitions }}
{{~#*inline "styles"}}
<style>
  {{~#if additional_styles}}

  {{{additional_styles}}}
  {{~/if}}

  .container {
    font: 14px {{font_family}};
    line-height: {{const.LINE_HEIGHT}}px;
  }
  .input,.output,.output-bg {
    white-space: pre;
  }
  .input-bg { fill: #fff; fill-opacity: 0.1; }
  .output-bg { user-select: none; text-rendering: geometricPrecision; stroke-width: 0.1; }
  {{~#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}}

  .bold,.prompt { font-weight: 600; }
  .italic { font-style: italic; }
  .underline { text-decoration: underline; }
  .dimmed { fill-opacity: 0.7; }
  {{~#if wrap}}

  .hard-br { font-size: 16px; fill-opacity: 0.8; user-select: none; }
  {{~/if}}

  .fg0 { 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~}}

{{! Terminal background }}
{{~#*inline "background"}}
<rect width="100%" height="100%" y="{{#if window_frame}}-{{const.WINDOW_FRAME_HEIGHT}}{{else}}0{{/if}}" rx="4.5" style="fill: {{ palette.colors.black }};" />
{{~#if window_frame}}

<rect width="100%" height="26" y="-22" clip-path="inset(0 0 -10 0 round 4.5)" style="fill: #fff; fill-opacity: 0.1;"/>
<circle cx="17" cy="-9" r="7" style="fill: {{ palette.colors.red }};"/>
<circle cx="37" cy="-9" r="7" style="fill: {{ palette.colors.yellow }};"/>
<circle cx="57" cy="-9" r="7" style="fill: {{ palette.colors.green }};"/>
{{~/if}}

{{/inline~}}

{{~#*inline "content"}}
    <svg x="0" y="{{const.WINDOW_PADDING}}" width="{{width}}" height="{{screen_height}}" viewBox="0 0 {{width}} {{screen_height}}">
      {{~#if (scroll_animation)}}
      {{~#with (scroll_animation)}}

      <animate attributeName="viewBox" values="{{view_box}}" dur="{{duration}}s" repeatCount="indefinite" calcMode="discrete" />
      {{~/with}}
      {{~/if}}

      {{! Render backgrounds for each input beforehand since they cannot be placed in <text>. }}
      {{#scope y_pos=0 input_height=0}}
      <g class="input-bg">
        {{~#each interactions}}
        {{~#if (not input.hidden)}}
        {{~input_height set=(add (mul (count_lines input.text) ../const.LINE_HEIGHT) (mul 2 ../const.USER_INPUT_PADDING))~}}

        <rect x="0" y="{{y_pos}}" 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="{{y_pos}}" width="2" height="{{input_height}}" class="input-failure-hl" />
        <rect x="100%" y="{{y_pos}}" width="2" height="{{input_height}}" class="input-failure-hl" transform="translate(-2, 0)" />
        {{~/if~}}
        {{~y_pos set=(add (y_pos) (input_height) ../const.BLOCK_MARGIN)~}}
        {{~/if~}} {{! if (not input.hidden) }}
        {{~y_pos set=(add (y_pos) (mul ../const.LINE_HEIGHT (len output_svg)))~}}
        {{~#if (ne (len output_svg) 0)}}
          {{~y_pos set=(add (y_pos) ../const.BLOCK_MARGIN)}}
        {{~/if}}
        {{~/each~}}
      </g>
      {{~/scope}}
      {{#if line_numbers}}

      {{>number_lines}}
      {{/if}}
      {{! Render main text }}
      {{#scope
        x_pos=const.WINDOW_PADDING
        input_x_pos=const.WINDOW_PADDING
        y_pos=14
      }}
      {{~#if line_numbers~}}
        {{x_pos set=(add (x_pos) const.LN_WIDTH const.LN_PADDING)}}
      {{~/if~}}
      {{~#if (eq line_numbers "continuous")~}}
        {{input_x_pos set=(x_pos)}}
      {{~/if}}

      {{! The awkward newlines at the end of line <tspan>s are required for the text to be properly copyable }}
      <text class="container fg7">
        {{~#each interactions~}}
        {{~#if (not input.hidden)~}}
        {{~y_pos set=(add (y_pos) ../const.USER_INPUT_PADDING)~}}
        <tspan xml:space="preserve" x="{{input_x_pos}}" y="{{y_pos}}" class="input{{#if failure}} input-failure{{/if}}">
          {{~#each (split_lines input.text)~}}
          <tspan x="{{input_x_pos}}" y="{{y_pos}}">{{#if @first}}<tspan class="prompt">{{../input.prompt}}</tspan> {{/if}}{{this}}
</tspan>
          {{~y_pos set=(add (y_pos) ../../const.LINE_HEIGHT)}}
          {{~/each~}}
</tspan>
        {{~y_pos set=(add (y_pos) ../const.USER_INPUT_PADDING ../const.BLOCK_MARGIN)}}
        {{~/if~}} {{! if (not input.hidden) }}
        {{~#each output_svg}}
        {{~#if (ne background null)~}}
        <tspan xml:space="preserve" x="{{x_pos}}" y="{{y_pos}}" class="output-bg">{{{background}}}</tspan>
        {{~/if~}}
        <tspan xml:space="preserve" x="{{x_pos}}" y="{{y_pos}}" class="output">{{{foreground}}}
</tspan>
        {{~y_pos set=(add (y_pos) ../../const.LINE_HEIGHT)~}}
        {{~/each~}}
        {{~#if (gt (len output_svg) 0)~}}
          {{~y_pos set=(add (y_pos) ../const.BLOCK_MARGIN)~}}
        {{~/if~}}
        {{~/each~}}
      </text>
      {{/scope}}
    </svg>
{{/inline~}}

{{~#*inline "scrollbar"}}
{{#with (scroll_animation)}}
<rect class="scrollbar" x="{{scrollbar_x}}" y="10" width="5" height="40">
  <animateTransform attributeName="transform" attributeType="XML" type="translate" values="{{scrollbar_y}}" dur="{{duration}}s" repeatCount="indefinite" calcMode="discrete" />
</rect>
{{/with}}
{{/inline~}}

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

{{! Main logic }}
{{#scope
  content_height=(eval "compute_content_height" const=const interactions=interactions)
  scroll_animation=null
  screen_height=0
  height=0
  line_number=1
}}
  {{~#if scroll~}}
    {{scroll_animation set=(eval "compute_scroll_animation"
      const=const
      scroll=scroll
      width=width
      content_height=(content_height)
    )}}
  {{~/if~}}
  {{~#if (scroll_animation)~}}
    {{screen_height set=scroll.max_height}}
  {{~else~}}
    {{screen_height set=(content_height)}}
  {{~/if~}}
  {{~height set=(add (screen_height) (mul const.WINDOW_PADDING 2))~}}
  {{~#if window_frame~}}
    {{height set=(add (height) const.WINDOW_FRAME_HEIGHT)}}
  {{~/if~}}
{{>root~}} {{! <-- All rendering happens here }}
{{/scope}}