Code Viewer

Find and jump inside a real C++ source file
Browses the same 10,542-line simdutf header used in the Side-by-Side Code Diff example, colorized on the fly. Find scans the file and jumps to the next occurrence; matching terms are highlighted as they scroll into view.

How to build a feature like this

A code viewer fits virtualization closely: thousands of rows that are all exactly one text line tall. Treat the file as an array of strings and virtualize with a numeric item-size, so the engine positions rows arithmetically - no DOM measurement - and only the visible window is ever mounted. Uniformity holds because monospace text on one line cannot wrap away from the fixed row height, and 1ch gives you exact, font-size-relative widths for the gutter and the longest line. Two further behaviors make it an editor rather than a pager: find-and-jump that scrolls programmatically to a model-side match index, and on-the-fly coloring that runs per mounted row as a pure function of the text.

1. Model the file as an array of uniform lines

Split the source into an array - element i is line i + 1 - and pass it as items with a numeric item-size. A real array is the mainstream choice: rows carry the text they render. (If a view needs only numbering, an index-only sparse array works too - the slot receives index and never reads a payload.) Give the scroll host a definite height, or flex-fill it with min-height: 0 in a flex/grid parent.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. The overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import { computed, ref } from 'vue';

const virtualScrollRef = ref<InstanceType<typeof VirtualScroll> | null>(null);

const LINE_HEIGHT = 20; // px; must equal the CSS row height below

// A file is an array of strings, one element per virtual row. A numeric
// item-size lets the engine position rows arithmetically and never measure
// the DOM.
const lines = ref<string[]>([]); // lines[0] === file line 1

// Longest line in `ch` units. `ch` is the width of '0' in the monospace font,
// so maxChars * 1ch is the true pixel width of the widest line. Because every
// row gets this min width, rows never wrap - and the horizontal scroll range
// stays constant while rows recycle in and out of the DOM.
const maxChars = computed(() => lines.value.reduce((m, l) => Math.max(m, l.length), 0));
const codeMinStyle = computed(() => ({ minInlineSize: `${maxChars.value}ch` }));
</script>

<template>
  &lt;!-- Uniform-height, vertical-only virtualization. -->
  <VirtualScroll
    virtual-scrollbar
    class="code-viewer"
    :items="lines"
    :item-size="LINE_HEIGHT"
    :buffer-before="10"
    :buffer-after="10"
    ref="virtualScrollRef"
    aria-label="Code viewer"
  >
    &lt;!-- One row = line number + code: the gutter is in lockstep with its line
         by construction and scrolls away with it - no second scroll surface,
         no gutter/list synchronization. -->
    <template #item="{ index }">
      <div class="row">
        <span class="gutter">{{ index + 1 }}</span>
        <span class="code" :style="codeMinStyle">{{ lines[index] }}</span>
      </div>
    </template>
  </VirtualScroll>
</template>

2. Lock the line geometry with monospace CSS

Every row is exactly LINE_HEIGHT tall: the row root fixes the height, flex centering plus a compact line-height keep the row height independent of font metrics, and white-space: pre guarantees the code is one line that never wraps. The gutter is a fixed-width, right-aligned span inside the row - no second scroll surface to keep in sync, and the number provably matches the code beside it. Because each line is one row, the line box math is uniform even when content differs; a fixed-height row with wrapping text would be the case for dynamic measurement instead.

  • Use a monospace font: every glyph advances exactly 1ch, so a line's pixel width is its length in ch - the formula above is exact, and gutter digits stay constant-width.
  • Give the numbers font-variant-numeric: tabular-nums so digits do not jitter while scrolling.
  • If numbers must stay pinned like an editor's, the gutter has to live outside the scroll host as a fixed sibling column that shares the row-height math - inside-row gutters scroll away with their lines, which is the zero-sync approach shown here.
<style scoped>
.code-viewer {
  height: 480px; /* the scroll viewport needs a definite height */
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

/* Exactly LINE_HEIGHT tall. Flex centering plus a compact line-height make
   the row height independent of font metrics: every line stays one 20px row
   even though the 12px text itself is shorter than the row. */
.row {
  display: flex;
  align-items: center;
  height: 20px;
  font-size: 12px;
  line-height: 1;
}
.gutter {
  flex: none;
  width: 5rem; /* fixed width, right-aligned numbers */
  padding-inline-end: 1rem;
  text-align: end;
  color: color-mix(in oklab, currentColor 45%, transparent);
  font-variant-numeric: tabular-nums; /* digits do not jitter while scrolling */
}
.code {
  flex: 1;
  white-space: pre;
}

/* Wide rows overflow the viewport and the host pans them natively; opt the
   wrapper out of its default `contain: layout` so they extend the host's
   scrollable area. */
:deep(.virtual-scroll-container .virtual-scroll-wrapper) {
  contain: none;
}
</style>

The min-width is applied to every row rather than left to content: rows mount and unmount as you scroll, so a scroll range derived from whatever is currently mounted would shrink and grow with each window. Reserving maxChars × 1ch on every row keeps the horizontal range - and the horizontal scrollbar - stable for the whole scroll.

3. Find and jump with programmatic scroll

Searching is kept out of the DOM: on a debounced query, scan the file once and store the matching line indices; the renderer only consults that array when a row is mounted. Navigation is then scrollToIndex(matchIndex, null, options) - the second argument is the column (grid mode only, so null here) and the options choose alignment and animation. With uniform sizes the engine jumps straight to the row offset, so a 'center' jump to match #500 costs no more than a jump to #1.

// Find-and-jump: matches are precomputed model-side (one full-file
// scan per search), then navigation is pure index math + programmatic scroll.
const matches = ref<number[]>([]);
let current = -1;
let debounce: ReturnType<typeof setTimeout> | undefined;

watch(searchQuery, (query) => {
  clearTimeout(debounce);
  const q = query.trim().toLowerCase();
  if (q.length < 2) {
    matches.value = [];
    return;
  }
  debounce = setTimeout(() => scan(q), 150);
});

function scan(q: string) {
  matches.value = [];
  for (let i = 0; i < lines.value.length; i++) {
    if (lines.value[i]!.toLowerCase().includes(q)) {
      matches.value.push(i);
    }
  }
  current = matches.value.length > 0 ? 0 : -1;
  if (current !== -1) {
    scrollToMatch();
  }
}

function scrollToMatch() {
  const target = matches.value[current];
  if (target !== undefined) {
    // (row, col, options): col is null for a vertical list; 'center' puts the
    // match mid-viewport. The engine can jump straight to the offset because
    // uniform sizes make every row offset arithmetic.
    virtualScrollRef.value?.scrollToIndex(target, null, { align: 'center', behavior: 'smooth' });
  }
}

function findNext() {
  if (matches.value.length === 0) {
    return;
  }
  current = (current + 1) % matches.value.length;
  scrollToMatch();
}

4. Colorize rows as they mount

Syntax coloring is applied only to mounted rows: call the tokenizer from the #item slot, so a 5,000-line file pays for only the mounted window (a few dozen rows) per frame instead of 5,000 passes, and scrolling stays smooth because rows recycle. Make the tokenizer a pure function of the line text - same input, same segments - which is exactly what recycled rows require. Handle block comments by pre-stripping them per line with a carry-over state machine (one pass over the file), then append the trailing comment as a single styled segment. Active search terms can be split out of any segment as an extra marked piece using the match array from step 3.

// On-the-fly coloring runs ONLY for rows entering the window: call it from
// the #item slot. It is a pure function of the line text, so the same index
// always yields the same segments - safe under row recycling.
function lineSegments(line: string): CodeSegment[] {
  const segments: CodeSegment[] = [];
  const pattern =
    /('(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|([A-Z_$][\w$]*)|(\s+)|(.)/gi;
  for (const m of line.matchAll(pattern)) {
    const [ , str, num, word ] = m;
    if (str !== undefined) {
      segments.push({ text: str, cls: 'string' });
    } else if (num !== undefined) {
      segments.push({ text: num, cls: 'number' });
    } else if (word !== undefined) {
      segments.push({ text: word, cls: KEYWORDS.has(word) ? 'keyword' : undefined });
    } else {
      segments.push({ text: m[ 0 ] }); // whitespace & punctuation
    }
  }
  return segments;
}

// In the #item template, replace the plain text interpolation with a v-for
// over these segments, rendering each with its class. A trailing comment
// (stripped beforehand, tracking /* */ state across lines) can be appended
// as one extra italic segment.
10,542 lines · original.txt
1/* auto-generated on 2026-04-14 20:34:32 -0700. Do not edit! */
2/* begin file include/simdutf.h */
3#ifndef SIMDUTF_H
4#define SIMDUTF_H
5
6/* begin file include/simdutf/compiler_check.h */
7#ifndef SIMDUTF_COMPILER_CHECK_H
8#define SIMDUTF_COMPILER_CHECK_H
9
10#ifndef __cplusplus
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y