@@ -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. */
117214const RAIL = "│" ;
118215const 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+ */
338517function * 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