Skip to content

Commit 01f4255

Browse files
committed
web/shell terminal: fix round 4 (the ghost bound, floors checked after every step, the lowest redraw over every count of dropped rows), links accept only a file, measured wording in README/NOTES
1 parent 659d0e0 commit 01f4255

7 files changed

Lines changed: 514 additions & 120 deletions

File tree

‎web/shell/README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,14 @@ characters, memory only — so a `cd` does not move older links. Two identical l
286286
told apart (the later wins): the workbench hands the provider a line's text, not its row.
287287

288288
**Resize.** The editor redraws its region when the panel changes size by emulating xterm's own reflow (the line editor is
289-
tested against real xterm.js 6.1). The rule is *never write over a row that is not the editor's own*: where the
290-
emulation is unsure it leaves a stale copy of the prompt's first rows instead (visible in 138 of 2,400 sessions that
291-
change width and height at once, a copy in the scrollback in more). The one case it cannot do exactly is a widening that
292-
reaches the top of the whole buffer (about 3 of 100 realistic sessions); `src/terminal/NOTES.md` has the table.
289+
tested against real xterm.js 6.1). The rule is *do not write over a row that is not the editor's own*: where the
290+
emulation is unsure it starts the redraw lower and leaves a stale copy of the prompt's first rows instead. Measured on
291+
the real xterm over 33,600 sessions of 6 to 16 resize events: a copy visible on screen in about 1 session of 12 that
292+
change width and height at once, a copy in the scrollback in more, and earlier output written over in **0** of them
293+
(the earlier generations of this code: 51 to 480). Outside those families a very rare chain can still write over a line
294+
(1 in about 100,000 sessions was found, then fixed; the rate is not zero by construction). The one case it cannot do
295+
exactly is a widening that reaches the top of the whole buffer (about 3 of 100 realistic sessions, a stale copy);
296+
`src/terminal/NOTES.md` has the table and the method.
293297

294298
**Typing.** A joiner (ZWNJ/ZWJ) typed key by key is dropped silently; a pasted or completed one is kept where it joins
295299
letters of its script or an emoji sequence, and anything else that is not typed is said once under the line.

‎web/shell/src/terminal/NOTES.md‎

Lines changed: 108 additions & 54 deletions
Large diffs are not rendered by default.

‎web/shell/src/terminal/line-editor.js‎

Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ class LineEditor {
156156
this.ghostLines = 0; // how many logical lines those rows are the rows of, and how many cells they hold: what a wider
157157
this.ghostCells = 0; // panel can join them to (at least this many lines, and no fewer rows than the cells need)
158158
this.blank = 0; // rows known to be blank under what the region takes of the screen (a lower bound; see resize)
159-
this.span = 1; // the rows the region has on the screen, as the terminal has them until it is drawn again
159+
this.span = 0; // the rows the region has on the screen, as the terminal has them until it is drawn again
160160
this.targetRow = 0; // the row the cursor was left on, in region rows
161161
this.phantom = false; // the cursor is on a row made by forcing a wrap
162162
this.fresh = false; // the next draw starts a prompt: write the prompt marks with it
@@ -317,9 +317,10 @@ class LineEditor {
317317
if (target.col > 0) { out += CSI + target.col + 'C'; }
318318

319319
// What the region takes of the blank rows under it is not blank any more, and what it gives back (it got shorter: the
320-
// rows were erased) is; a region that fills the panel leaves none, and Ctrl+L puts it at the top of the screen.
320+
// rows were erased) is: the rows from where it begins to the bottom of the panel are the rows it had and the blank
321+
// ones under them (Ctrl+L puts it at the top of an empty screen, which is no fewer).
321322
const shown = written - winTop + 1;
322-
this.blank = shown >= this.rows ? 0 : clear ? this.rows - shown : Math.max(0, this.blank + this.span - shown);
323+
this.blank = Math.max(0, this.blank + this.span - shown);
323324
this.span = shown;
324325
this.winTop = winTop;
325326
this.cursorRow = target.row - winTop;
@@ -345,11 +346,10 @@ class LineEditor {
345346
this.phantom = false;
346347
}
347348

348-
/** Nothing is known about the rows under what is drawn next, and it is not the region that was: one row, as at a prompt. */
349+
/** A new prompt: nothing is known of the rows under what is drawn next, and none of the region that was is a row of it. */
349350
_noRegion() {
350351
this.blank = 0;
351-
this.span = 1;
352-
this.lastRow = this.winTop;
352+
this.span = 0;
353353
}
354354

355355
/** The rows that were pushed into the scrollback are history from here on. */
@@ -399,14 +399,20 @@ class LineEditor {
399399
* ghostLines the logical lines those rows are rows of (a wider panel joins the rows of one line, never two lines,
400400
* so each stays at least one row);
401401
* ghostCells the cells those rows hold (joined, they need at least ceil(cells / width) rows).
402-
* Lines and cells are counted when the rows are pushed and only ever from the rows xterm really pushed: the rows the
403-
* screen had at that moment (`_headRows` of the arrangement `_reflowRows` works out), so a row is never credited with
404-
* more cells than it holds. Counted afterwards, at the width the panel is asked about, they were: a row xterm pushed
405-
* is as wide as the screen was (24 cells, pushed when the panel was 24 wide; and the line of the cursor is never
406-
* re-wrapped, so a wider panel does not make it 64), which is what made two rows into one at 104 columns and the
407-
* redraw start a row too high. Pulled rows are the newest: what is left is at least as many lines, and as many cells,
408-
* as the rows that came back could have held. A narrower panel only splits rows, which makes the floor lower, not
409-
* wrong. `_noGhosts` forgets all three: they say nothing of rows that are no longer there.
402+
* Why none of them can be more than there is:
403+
* - the cells are counted when the rows are pushed, from the rows xterm really pushed (`_headRows` of what
404+
* `_reflowRows` works out): a row is credited with the cells it holds, not with what a re-wrap at the width asked
405+
* about would give. A row xterm pushed is as wide as the screen was (24 cells, pushed when the panel was 24 wide),
406+
* and the line of the cursor is not re-wrapped (so a wider panel does not make it 64): counted afterwards at the
407+
* new width they were 105 cells in two rows that join to one at 104 columns, and the redraw started a row high;
408+
* - lines and cells are only counted from rows that were at or above the cursor (`_headRows`: xterm makes room for
409+
* the rows a line gains by dropping rows from the bottom of its buffer, and what is left of a line under the cursor
410+
* is flagged as a continuation of the row above it);
411+
* - what is pulled back is the newest of them, so what is left is at least the lines and the cells of the rest
412+
* (a row holds at most as many cells as the wider of the two widths); a narrower panel only splits rows, which
413+
* leaves the floor low, not wrong; and rows are not cut afterwards, because the editor's own redraw begins every
414+
* window with a row that is a line of its own for xterm, so they are not the line of the cursor any more;
415+
* - a new prompt, Ctrl+L and Ctrl+C forget all three (`_noGhosts`): they say nothing of rows that are not there.
410416
* @param {number} cols
411417
* @param {number} rows
412418
*/
@@ -515,19 +521,23 @@ class LineEditor {
515521
* scrollback. `arranged` is what `_reflowRows` made of the screen (one arrangement per hypothesis about the blank
516522
* rows under the region): the rows as the re-wrapping left them, line by line, and the first row of a window is a
517523
* line of its own (see `_reflowRows`). The least of the arrangements is taken: each is a floor, and the one that is
518-
* the truth is one of them.
519-
* @param {Array<Array<{ line: number, cells: number }>>} arranged
524+
* the truth is one of them. Only the rows that were at or above the cursor are looked at (`safe`): xterm makes room
525+
* for the rows a line gains by dropping rows from the BOTTOM of its buffer, the blank ones and, when there are not
526+
* enough of them, the rows of the lines under the cursor, and what is left of such a line is the end of it, flagged as
527+
* a continuation of whatever row is above it (a wrapped line that begins with a continuation row has no first row, and
528+
* its first pushed row is one line with the row before it). Rows that were above the cursor are never dropped.
529+
* @param {Array<{ rows: Array<{ line: number, cells: number }>, safe: number }>} arranged
520530
* @param {number} count
521531
* @returns {{ lines: number, cells: number }}
522532
*/
523533
_headRows(arranged, count) {
524534
let lines = Infinity;
525535
let cells = Infinity;
526-
for (const rows of arranged) {
536+
for (const { rows, safe } of arranged) {
527537
let seen = 0;
528538
let held = 0;
529539
let line = -1;
530-
for (let i = 0; i < count && i < rows.length; i++) {
540+
for (let i = 0; i < count && i < safe && i < rows.length; i++) {
531541
if (rows[i].line !== line) { seen++; line = rows[i].line; }
532542
held += rows[i].cells;
533543
}
@@ -570,32 +580,42 @@ class LineEditor {
570580
* lower of two answers left).
571581
* @param {number} cols the new width
572582
* @param {number} rows the new height
573-
* @returns {{ row: number, added: number, removed: number, span: number, arranged: Array<Array<{ line: number, cells: number }>> }}
583+
* @returns {{ row: number, added: number, removed: number, span: number, arranged: Array<{ rows: Array<{ line: number, cells: number }>, safe: number }> }}
574584
* the row, the rows the re-wrapping added and took out, how many rows the region has on the screen afterwards (at
575-
* least), and the rows the screen has
585+
* least), and the rows the screen has (one arrangement per hypothesis)
576586
*/
577587
_cursorRowAfterReflow(cols, rows) {
578-
if (rows >= this.rows) {
579-
const whole = this._reflowRows(cols, this.lastRow);
580-
return { row: whole.row, added: whole.added, removed: whole.removed, span: whole.rows.length, arranged: [whole.rows] };
581-
}
582-
const lost = this.rows - rows;
583-
// the most blank rows there can be under the region on the screen (a window fills it), and the fewest
588+
// (a panel that is not shorter has lost nothing: both ends are then the whole region)
589+
const lost = Math.max(0, this.rows - rows);
590+
// the fewest blank rows there are under the region (`blank`: a floor), and the most there can be (a window fills the
591+
// screen, and a region on a screen that has not scrolled is at the top of it)
584592
const most = Math.max(0, this.rows - 1 - (this.lastRow - this.winTop));
585-
const least = Math.min(this.blank, most);
586-
const cut = this._reflowRows(cols, Math.max(this.targetRow, this.lastRow - Math.max(0, lost - least)));
587-
const kept = this._reflowRows(cols, Math.max(this.targetRow, this.lastRow - Math.max(0, lost - most)));
588-
const lower = kept.row < cut.row ? kept : cut;
589-
return { row: lower.row, added: lower.added, removed: lower.removed, span: cut.rows.length, arranged: [cut.rows, kept.rows] };
593+
const loBottom = Math.max(this.targetRow, this.lastRow - Math.max(0, lost - this.blank));
594+
const hiBottom = Math.max(this.targetRow, this.lastRow - Math.max(0, lost - most));
595+
const cut = this._reflowRows(cols, loBottom);
596+
const kept = hiBottom === loBottom ? cut : this._reflowRows(cols, hiBottom);
597+
// the true number of rows that were dropped is anywhere between the two ends, and the row the cursor ends on is not
598+
// monotonic in it: take the lowest redraw over all of them
599+
let lower = kept.row < cut.row ? kept : cut;
600+
for (let b = loBottom + 1; b < hiBottom; b++) {
601+
const x = this._reflowRows(cols, b);
602+
if (x.row < lower.row) { lower = x; }
603+
}
604+
// The redraw begins where the lower of the two says the region begins. Where the cut end has the cursor lower, that is
605+
// below the first row of ITS region: the rows above it are not erased (they are what a copy of the prompt is) and
606+
// are not the rows it takes of the blank ones. (The kept end has the rows of the cut end and more, and a cursor
607+
// that is lower by no more than those rows: it is never the one with fewer.)
608+
const span = Math.max(0, cut.rows.length - (cut.row - lower.row));
609+
return { row: lower.row, added: lower.added, removed: lower.removed, span, arranged: [cut, kept] };
590610
}
591611

592612
/**
593613
* `_cursorRowAfterReflow` for a given last row of the region that is still there.
594614
* @param {number} cols the new width
595615
* @param {number} bottom the last row of the region that the terminal still has (the rows under it were dropped)
596-
* @returns {{ row: number, added: number, removed: number, rows: Array<{ line: number, cells: number }> }}
616+
* @returns {{ row: number, added: number, removed: number, rows: Array<{ line: number, cells: number }>, safe: number }}
597617
* the row, the rows the re-wrapping added and took out, and the screen's rows afterwards, top to bottom: the logical
598-
* line each belongs to and the cells it holds
618+
* line each belongs to and the cells it holds; and how many of the first of them were at or above the cursor
599619
*/
600620
_reflowRows(cols, bottom) {
601621
const top = this.winTop;
@@ -640,7 +660,6 @@ class LineEditor {
640660
let removed = 0;
641661
if (cols < this.cols) {
642662
for (let k = on.length - 1; k >= 0; k--) {
643-
if (was[k] === 1 && now[k] === 1) { continue; } // not wrapped, and not too wide: not touched
644663
if (holds(row, k)) { continue; }
645664
wrapped[k] = true;
646665
const gain = Math.max(0, now[k] - was[k]);
@@ -650,7 +669,7 @@ class LineEditor {
650669
} else {
651670
const at = row;
652671
for (let k = 0; k < on.length; k++) {
653-
if (was[k] < 2 || holds(at, k)) { continue; } // only wrapped lines are joined
672+
if (holds(at, k)) { continue; }
654673
wrapped[k] = true;
655674
const loss = Math.max(0, was[k] - now[k]);
656675
row -= loss;
@@ -660,21 +679,27 @@ class LineEditor {
660679
// the rows the screen has afterwards: a line that was not touched has its rows as they were, each cut at the new width
661680
/** @type {Array<{ line: number, cells: number }>} */
662681
const rows = [];
682+
// and how many of the first of them were at or above the cursor row: the lines above its line, and the rows of its
683+
// own line down to it (a cursor above the region, which a wider panel can leave it, has none)
684+
let safe = 0;
685+
const at0 = this.cursorRow;
663686
on.forEach((l, k) => {
687+
const first = rows.length;
664688
if (wrapped[k]) {
665689
for (const n of packed[k]) { rows.push({ line: k, cells: n }); }
666-
return;
667-
}
668-
for (const widths of l.byRow) {
669-
let held = 0;
670-
for (const w of widths || []) {
671-
if (held + w > cols) { break; }
672-
held += w;
690+
} else {
691+
for (const widths of l.byRow) {
692+
let held = 0;
693+
for (const w of widths || []) {
694+
if (held + w > cols) { break; }
695+
held += w;
696+
}
697+
rows.push({ line: k, cells: held });
673698
}
674-
rows.push({ line: k, cells: held });
675699
}
700+
if (start[k] + was[k] <= at0) { safe += rows.length - first; } else if (start[k] <= at0) { safe += Math.min(rows.length - first, at0 - start[k] + 1); }
676701
});
677-
return { row, added, removed, rows };
702+
return { row, added, removed, rows, safe };
678703
}
679704

680705
text() { return this.buf.join(''); }
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
[
2+
[
3+
"a maximise chain: 160x36 then back to 100x18 with the cursor above the last line of a pasted block (the number of rows xterm dropped is between the floor and the ceiling and the row it ends on is not monotonic in it)",
4+
{
5+
"cols": 100,
6+
"rows": 18,
7+
"earlier": [
8+
"ban000"
9+
],
10+
"steps": [
11+
{
12+
"send": "中文. 日本語のファイル.txt"
13+
},
14+
{
15+
"send": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
16+
},
17+
{
18+
"send": "ünïcödé git cat hello TODO"
19+
},
20+
{
21+
"send": "\u001b[H"
22+
},
23+
{
24+
"paste": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\n\n\nn4wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\n\n1wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\n\n\nq1wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"
25+
},
26+
{
27+
"send": "echo yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
28+
},
29+
{
30+
"paste": "q2n1 wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\nq2n2 w"
31+
},
32+
{
33+
"send": "\u001b[A\u001b[A"
34+
},
35+
{
36+
"size": [
37+
160,
38+
36
39+
]
40+
},
41+
{
42+
"size": [
43+
100,
44+
18
45+
]
46+
}
47+
],
48+
"prompt": "中文 $ "
49+
}
50+
],
51+
[
52+
"paste two lines, Up Up, 180x14, then 70x5 (the true bottom is strictly between the two ends)",
53+
{
54+
"cols": 120,
55+
"rows": 8,
56+
"earlier": [
57+
"ban000",
58+
"ban001"
59+
],
60+
"steps": [
61+
{
62+
"paste": "q1n1 \nq1n2 wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\nq1n3 wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"
63+
},
64+
{
65+
"send": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
66+
},
67+
{
68+
"send": "\u001b[A\u001b[A"
69+
},
70+
{
71+
"size": [
72+
180,
73+
14
74+
]
75+
},
76+
{
77+
"size": [
78+
70,
79+
5
80+
]
81+
},
82+
{
83+
"send": "Z"
84+
}
85+
],
86+
"prompt": "$ "
87+
}
88+
],
89+
[
90+
"80x15, a seven-line paste, typed text, Up six times, 140x36, then 60x9",
91+
{
92+
"cols": 80,
93+
"rows": 15,
94+
"earlier": [
95+
"ban000",
96+
"ban001"
97+
],
98+
"steps": [
99+
{
100+
"paste": "q1n1 wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\nq1n2 wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\nq1n3 \nq1n4 wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\nq1n5 wwwww\nq1n6 wwwwwww\nq1n7 "
101+
},
102+
{
103+
"send": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
104+
},
105+
{
106+
"send": "\u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\u001b[A"
107+
},
108+
{
109+
"size": [
110+
140,
111+
36
112+
]
113+
},
114+
{
115+
"size": [
116+
60,
117+
9
118+
]
119+
},
120+
{
121+
"send": "\u001b[A"
122+
},
123+
{
124+
"send": "Z"
125+
}
126+
],
127+
"prompt": "$ "
128+
}
129+
]
130+
]

0 commit comments

Comments
 (0)