Pretext Lab
18Walkers
Arc 6 · Streaming & Variable-Width

Walkers

The last two lessons both used walkLineRanges, but quietly — as a way to ask the prepared paragraph a question. Here we pull the walker to the front and watch what it does.

Type a word in the search field and the matching lines below light up. Notice the grey ribbons on the right: those are the per-line widths, drawn from a second walker pass that never built a single line string. The walker iterates line boundaries with arithmetic alone — measuring, counting, scanning — and only the lines you ask about cost a materialization.

— lines scanned
walker pass · no materialization
440

Mechanism

Where layoutWithLines returns every line's text, walkLineRanges(prepared, maxWidth, onLine) calls your callback once per line with { width, start, end } — cursors into the prepared segments, plus the measured width, and nothing else. No strings. No allocations beyond the small range object. On a long passage this is genuinely cheap: the analysis work already happened inside prepareWithSegments, and the walk is arithmetic over cached widths.

This demo runs two walker passes on every keystroke. The first pass collects per-line widths for the margin sparkline — the widest line tells you whether the paragraph would fit a narrower column, and the profile of widths tells you how ragged the right edge is. The second pass finds matches: for each line, we look at the original text between start and end (we can reconstruct that from the prepared segments), and if it contains the query, we record the line's index. Only the matched lines trigger a materialization to render the highlight. The rest stay geometry.

The separation matters because “how many lines?”, “how wide is the widest?”, “does any line mention X?” — these are ordinary questions about running text. Making them cheap means you can ask them on every keystroke, every scroll event, every frame of an animation, without ever paying for the text you're not going to paint.

Application

Walkers are the primitive behind a family of pages we rarely see because they were too expensive to build:

The common shape: a question about text that doesn't need the text — it needs the geometry of the text. Walkers answer those in microseconds.

“I celebrate myself, and sing myself,
And what I assume you shall assume,
For every atom belonging to me as good belongs to you.”

Walt Whitman, Leaves of Grass (1855)

Direct Claude

“find without re-rendering” walkLineRanges over the paragraph; materialize only matching lines “stats across a long passage” one walker pass; fold widths/counts in the callback “sparkline of line widths in the margin” walker pass → array of widths → per-line margin bars “scan every paragraph on scroll” walkers are cheap enough to run per visible block, per scroll
Arc 6 ends here. Arc 7 picks up the opposite end: where do the lines Pretext returns actually get painted?
scan for matches without building the lines you don't need
import { prepareWithSegments, walkLineRanges, layoutWithLines } from '@chenglou/pretext';

const FONT = "400 18px 'EB Garamond', serif";
const LINE_HEIGHT = 18 * 1.55;
const prepared = prepareWithSegments(text, FONT);

// Pass 1: widths only. Never builds a line string.
function lineWidths(width) {
  const widths = [];
  walkLineRanges(prepared, width, line => widths.push(line.width));
  return widths;
}

// Pass 2: find-in-paragraph. We need text per line only when we've decided
// a line matches, so materialization is opt-in per line.
function findMatches(width, query) {
  if (!query) return new Set();
  const { lines } = layoutWithLines(prepared, width, LINE_HEIGHT);
  const q = query.toLowerCase();
  const matchedIndices = new Set();
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].text.toLowerCase().includes(q)) matchedIndices.add(i);
  }
  return matchedIndices;
}