Skip to content

Commit e61d6ce

Browse files
authored
⚡️ Reuse the layout work REPL typing and focus did not change (#881) (#889)
* ⚡️ Reuse the reading geometry a keystroke did not change (#881) * ✅ Prove the reuse through a complete frame, and invalidate one input at a time (#881)
1 parent b88da5e commit e61d6ce

8 files changed

Lines changed: 923 additions & 69 deletions

File tree

‎packages/cli/src/repl/application.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,27 @@ const EMPTY_MODEL: ReplModel = Object.freeze({
898898
sessions: Object.freeze([]),
899899
});
900900

901+
/**
902+
* Whether answering this action needs the frame's measured admission.
903+
*
904+
* The four scrolls, and nothing else: every other branch of `reduceRepl`
905+
* decides from the state, the model and this process's overlay alone. It
906+
* lives here because the reducer is what reads `admission`, so a fifth
907+
* branch that starts reading one is written next to the answer about it.
908+
*
909+
* The root measures a frame before the action when this is true. For a
910+
* keystroke into the draft it is false, and measuring the reading beside that
911+
* draft would be work for an answer nothing consults.
912+
*/
913+
export function readsGeometry(action: ReplAction): boolean {
914+
return (
915+
action.kind === "scroll" ||
916+
action.kind === "scroll-sessions" ||
917+
action.kind === "scroll-entries" ||
918+
action.kind === "scroll-reading"
919+
);
920+
}
921+
901922
/**
902923
* Answer one action.
903924
*

‎packages/cli/src/repl/fitting.ts‎

Lines changed: 231 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,103 @@ export interface ReplPreparedReading {
113113
readonly rows: readonly ReplPreparedRow[];
114114
}
115115

116+
/**
117+
* Where one logical line was cut, and how wide the piece measured.
118+
*
119+
* The measured half of a row, and the only half an engine answer decides.
120+
* Which text it holds comes from the line it belongs to, so this stays true
121+
* for as long as that line's text and depth do.
122+
*/
123+
export interface ReplRowCut {
124+
/** The offset into the logical line this piece starts at. */
125+
readonly from: number;
126+
/** How many code units of it this piece holds. */
127+
readonly length: number;
128+
/** Whether a previous piece already carried the start of this line. */
129+
readonly continuation: boolean;
130+
/** The column this line was written at, carried onto its continuations. */
131+
readonly indent: number;
132+
/** What the engine measured this exact piece as. */
133+
readonly width: number;
134+
}
135+
136+
/**
137+
* Everything about a reading that measurement decides.
138+
*
139+
* The reservation and one cut list per logical line, in order. It is a
140+
* function of the lines' text and depth, the room they were fitted to, and
141+
* the terminal size — and of nothing else. A badge arriving, a phase
142+
* settling, a selection moving and a position being inspected all leave it
143+
* exactly as it was, which is why it can be kept and reused.
144+
*/
145+
export interface ReplFitGeometry {
146+
readonly reservation: ReplFitReservation;
147+
readonly cuts: readonly (readonly ReplRowCut[])[];
148+
}
149+
150+
/**
151+
* The geometry one running REPL is holding on to.
152+
*
153+
* Typing moves the draft, not the reading beside it, and a reading is the
154+
* expensive thing to fit: a two-hundred-line entry costs an engine render per
155+
* line plus a binary search per overflowing row, and a keystroke used to pay
156+
* that twice. This keeps the last answer and the exact inputs it was computed
157+
* from, so a frame whose reading has not moved reuses it.
158+
*
159+
* Inputs are compared, not digested. A few hundred short string comparisons
160+
* are far below one render, and a digest would trade a guarantee for a
161+
* cheaper comparison that is already cheap.
162+
*/
163+
export interface ReplFitStore {
164+
/** The geometry retained for these exact inputs, or none. */
165+
held(
166+
lines: readonly ReplReadingLine[],
167+
room: number,
168+
size: ReplTerminalSize,
169+
): ReplFitGeometry | undefined;
170+
/** Retain this geometry as the answer for these inputs. */
171+
keep(
172+
lines: readonly ReplReadingLine[],
173+
room: number,
174+
size: ReplTerminalSize,
175+
geometry: ReplFitGeometry,
176+
): void;
177+
}
178+
179+
/** A store holding one reading's geometry, owned by whoever creates it. */
180+
export function createFitStore(): ReplFitStore {
181+
let held:
182+
| {
183+
readonly shape: readonly { readonly text: string; readonly depth: number }[];
184+
readonly room: number;
185+
readonly columns: number;
186+
readonly rows: number;
187+
readonly geometry: ReplFitGeometry;
188+
}
189+
| undefined;
190+
const same = (lines: readonly ReplReadingLine[], room: number, size: ReplTerminalSize) =>
191+
held !== undefined &&
192+
held.room === room &&
193+
held.columns === size.columns &&
194+
held.rows === size.rows &&
195+
held.shape.length === lines.length &&
196+
held.shape.every((one, at) => one.text === lines[at].text && one.depth === lines[at].depth);
197+
return {
198+
held(lines, room, size) {
199+
return same(lines, room, size) ? held?.geometry : undefined;
200+
},
201+
keep(lines, room, size, geometry) {
202+
held = {
203+
shape: lines.map((one) => ({ text: one.text, depth: one.depth })),
204+
room,
205+
columns: size.columns,
206+
rows: size.rows,
207+
geometry,
208+
};
209+
},
210+
};
211+
}
212+
116213
/** The rail glyph, and the gap between it and the text beside it. */
117214
const RAIL = "│";
118215
const SEPARATOR = " ";
@@ -216,13 +313,40 @@ export function* prepareReading(
216313
size: ReplTerminalSize,
217314
lines: readonly ReplReadingLine[],
218315
room: number,
316+
store?: ReplFitStore,
219317
): Operation<Result<ReplPreparedReading>> {
318+
const retained = store?.held(lines, room, size);
319+
if (retained !== undefined) {
320+
return Ok(decorate(lines, retained));
321+
}
322+
const measured = yield* fitGeometry(renderer, size, lines, room);
323+
if (!measured.ok) {
324+
return measured;
325+
}
326+
store?.keep(lines, room, size, measured.value);
327+
return Ok(decorate(lines, measured.value));
328+
}
329+
330+
/**
331+
* Everything measurement decides about a reading, and nothing else.
332+
*
333+
* Every engine render this file performs for a reading happens here. What
334+
* comes back says where each line was cut and how wide each piece is; which
335+
* rail, badge and roles those pieces are drawn with is decided afterwards,
336+
* from the lines as they stand at the time.
337+
*/
338+
function* fitGeometry(
339+
renderer: ReplRenderer,
340+
size: ReplTerminalSize,
341+
lines: readonly ReplReadingLine[],
342+
room: number,
343+
): Operation<Result<ReplFitGeometry>> {
220344
const reserved = yield* reserveFor(renderer, size, room);
221345
if (!reserved.ok) {
222346
return reserved;
223347
}
224348
const reservation = reserved.value;
225-
const cut: ReplPreparedRow[] = [];
349+
const cuts: (readonly ReplRowCut[])[] = [];
226350
// Nesting is drawn inside the content region rather than beside it, so a
227351
// deeply nested element loses text room rather than pushing the status column
228352
// off the pane it was measured against.
@@ -232,34 +356,81 @@ export function* prepareReading(
232356
if (!fitted.ok) {
233357
return fitted;
234358
}
235-
cut.push(...fitted.value);
359+
cuts.push(fitted.value);
236360
}
237361
// The finished candidates, measured again as the text they ended up being.
238362
// The search above narrowed over prefixes; this is the row, and a row that
239363
// does not fit the region it was cut for is a frame refusal rather than
240364
// something to clip.
241-
const measured = yield* widths(
242-
renderer,
243-
size,
244-
cut.map((one) => one.text),
365+
const flat = cuts.flatMap((one, at) =>
366+
one.map((cut) => lines[at].text.slice(cut.from, cut.from + cut.length)),
245367
);
246-
if (!measured.ok) {
247-
return measured;
368+
const widest = yield* widths(renderer, size, flat);
369+
if (!widest.ok) {
370+
return widest;
248371
}
372+
const sized: (readonly ReplRowCut[])[] = [];
373+
let index = 0;
374+
for (const [at, one] of cuts.entries()) {
375+
const line: ReplRowCut[] = [];
376+
for (const cut of one) {
377+
const width = widest.value[index];
378+
index += 1;
379+
const indent = Math.min(
380+
lines[at].depth * 2 + cut.indent,
381+
Math.max(0, reservation.content - 1),
382+
);
383+
if (width > reservation.content - indent) {
384+
return Err(
385+
new ReplFitRefusal(
386+
`row ${keyOf(lines[at], cut)} measured ${width} columns in ${
387+
reservation.content - indent
388+
}`,
389+
),
390+
);
391+
}
392+
line.push(Object.freeze({ ...cut, width }));
393+
}
394+
sized.push(Object.freeze(line));
395+
}
396+
return Ok(Object.freeze({ reservation, cuts: Object.freeze(sized) }));
397+
}
398+
399+
/**
400+
* The rows these lines are drawn as, over geometry already measured.
401+
*
402+
* The live half: rails, badges, roles and token runs come from the lines as
403+
* they stand now, so an element settling or a position being inspected shows
404+
* immediately over cuts that were measured once.
405+
*/
406+
function decorate(
407+
lines: readonly ReplReadingLine[],
408+
geometry: ReplFitGeometry,
409+
): ReplPreparedReading {
249410
const rows: ReplPreparedRow[] = [];
250-
for (const [index, one] of cut.entries()) {
251-
const width = measured.value[index];
252-
const indent = Math.min(one.depth * 2 + one.indent, Math.max(0, reservation.content - 1));
253-
if (width > reservation.content - indent) {
254-
return Err(
255-
new ReplFitRefusal(
256-
`row ${one.key} measured ${width} columns in ${reservation.content - indent}`,
257-
),
411+
for (const [at, one] of geometry.cuts.entries()) {
412+
const line = lines[at];
413+
for (const cut of one) {
414+
rows.push(
415+
Object.freeze({
416+
...row(
417+
line,
418+
line.text.slice(cut.from, cut.from + cut.length),
419+
cut.from,
420+
cut.continuation,
421+
cut.indent,
422+
),
423+
width: cut.width,
424+
}),
258425
);
259426
}
260-
rows.push(Object.freeze({ ...one, width }));
261427
}
262-
return Ok(Object.freeze({ reservation, rows: Object.freeze(rows) }));
428+
return Object.freeze({ reservation: geometry.reservation, rows: Object.freeze(rows) });
429+
}
430+
431+
/** The key a cut of this line is drawn under. */
432+
function keyOf(line: ReplReadingLine, cut: ReplRowCut): string {
433+
return cut.continuation ? `${line.key}+${cut.from}` : line.key;
263434
}
264435

265436
/** One line of read-only text, fitted to a width. */
@@ -302,56 +473,64 @@ export function* fitPlain(
302473
const fitted: ReplFittedLine[] = [];
303474
for (const one of lines) {
304475
const runs = one.runs ?? tokenRuns([{ text: one.text, token: "source" }]);
305-
const rows = yield* fitLine(
306-
renderer,
307-
size,
308-
Object.freeze({
309-
key: one.key,
310-
text: one.text,
311-
runs,
312-
rail: "rail-pending" as const,
313-
depth: 0,
314-
badge: undefined,
315-
style: Object.freeze({ role: "source" as const, selected: false, inspected: false }),
316-
}),
317-
room,
318-
);
319-
if (!rows.ok) {
320-
return rows;
476+
const line = Object.freeze({
477+
key: one.key,
478+
text: one.text,
479+
runs,
480+
rail: "rail-pending" as const,
481+
depth: 0,
482+
badge: undefined,
483+
style: Object.freeze({ role: "source" as const, selected: false, inspected: false }),
484+
});
485+
const cuts = yield* fitLine(renderer, size, line, room);
486+
if (!cuts.ok) {
487+
return cuts;
321488
}
322-
for (const row of rows.value) {
489+
for (const cut of cuts.value) {
490+
const drawn = row(
491+
line,
492+
line.text.slice(cut.from, cut.from + cut.length),
493+
cut.from,
494+
cut.continuation,
495+
cut.indent,
496+
);
323497
fitted.push(
324498
Object.freeze({
325-
key: row.key,
326-
text: row.text,
327-
runs: row.runs,
328-
continuation: row.continuation,
329-
indent: row.indent,
499+
key: drawn.key,
500+
text: drawn.text,
501+
runs: drawn.runs,
502+
continuation: drawn.continuation,
503+
indent: drawn.indent,
330504
}),
331505
);
332506
}
333507
}
334508
return Ok(Object.freeze(fitted));
335509
}
336510

337-
/** One logical line as its rows. */
511+
/**
512+
* One logical line as the pieces it is cut into.
513+
*
514+
* Widths are filled in by the pass that measures every finished piece at once;
515+
* nothing reads them in between.
516+
*/
338517
function* fitLine(
339518
renderer: ReplRenderer,
340519
size: ReplTerminalSize,
341520
line: ReplReadingLine,
342521
room: number,
343-
): Operation<Result<readonly ReplPreparedRow[]>> {
522+
): Operation<Result<readonly ReplRowCut[]>> {
344523
// A blank line is a row. Measuring it would ask the engine the width of
345524
// nothing, and dropping it would edit the shape of somebody's document.
346525
if (line.text.length === 0) {
347-
return Ok([row(line, "", 0, false, 0)]);
526+
return Ok([piece(0, 0, false, 0)]);
348527
}
349528
const whole = yield* widths(renderer, size, [line.text]);
350529
if (!whole.ok) {
351530
return whole;
352531
}
353532
if (whole.value[0] <= room) {
354-
return Ok([row(line, line.text, 0, false, 0)]);
533+
return Ok([piece(0, line.text.length, false, 0)]);
355534
}
356535
// Where this line begins, so its continuations begin there too. Bounded well
357536
// inside the region: an indent that left no room for text would make a row
@@ -360,18 +539,23 @@ function* fitLine(
360539
line.text.length - line.text.trimStart().length,
361540
Math.max(0, Math.floor(room / 2)),
362541
);
363-
const rows: ReplPreparedRow[] = [];
542+
const cuts: ReplRowCut[] = [];
364543
let at = 0;
365544
while (at < line.text.length) {
366545
const rest = line.text.slice(at);
367546
const cut = yield* cutAt(renderer, size, rest, at > 0 ? room - hanging : room);
368547
if (!cut.ok) {
369548
return cut;
370549
}
371-
rows.push(row(line, rest.slice(0, cut.value), at, at > 0, hanging));
550+
cuts.push(piece(at, cut.value, at > 0, at > 0 ? hanging : 0));
372551
at += cut.value;
373552
}
374-
return Ok(Object.freeze(rows));
553+
return Ok(Object.freeze(cuts));
554+
}
555+
556+
/** One cut, before the pass that measures it. */
557+
function piece(from: number, length: number, continuation: boolean, indent: number): ReplRowCut {
558+
return Object.freeze({ from, length, continuation, indent, width: 0 });
375559
}
376560

377561
/**

0 commit comments

Comments
 (0)