Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reflow text on resize #1864

Merged
merged 38 commits into from
Jan 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8a50729
Reflow wider
Tyriar Dec 28, 2018
314f98f
Mostly working for reflowing to smaller
Tyriar Dec 28, 2018
fa47036
Fix row removal in reflowLarger
Tyriar Dec 28, 2018
8bc04c2
Tidy up
Tyriar Dec 28, 2018
3311ed5
Do shrink in reverse, fix up row remove count again
Tyriar Dec 28, 2018
ae29dbb
Fix scrollbar when wrapping beyond single viewport of data
Tyriar Dec 28, 2018
7684f93
Fix ydisp/ybase after trimming buffer
Tyriar Dec 28, 2018
358898d
Fix some tests
Tyriar Dec 28, 2018
a33e8a6
Properly shrink rows to cols every time
Tyriar Dec 28, 2018
2a0da17
Add a bunch of reflow tests
Tyriar Dec 28, 2018
72369e0
Only enable reflow on the normal buffer
Tyriar Dec 28, 2018
7435971
Merge remote-tracking branch 'origin/master' into 622_reflow3
Tyriar Dec 29, 2018
4a9f10d
Remove some of Buffer's dependency on Terminal
Tyriar Dec 29, 2018
dde9618
Keep track of cols/rows inside Buffer
Tyriar Dec 29, 2018
478742a
Make reflow small crazy fast
Tyriar Dec 30, 2018
c9f4a65
Clean up comments and todos
Tyriar Dec 31, 2018
b7081ab
Move loop into reflowLarger (adjust indent)
Tyriar Dec 31, 2018
135e31f
Speed up reflow larger by batching removals
Tyriar Dec 31, 2018
1612cec
Fix reflow larger bug, add regression test
Tyriar Dec 31, 2018
db488eb
Reflow combined chars
Tyriar Dec 31, 2018
40e8618
Discard cut off combined data when resizing BufferLines
Tyriar Dec 31, 2018
840970e
Update markers after a reflow
Tyriar Dec 31, 2018
6559931
Add lots of tests
Tyriar Dec 31, 2018
2ce67b8
Remove unneeded MockTerminal member
Tyriar Dec 31, 2018
90950e2
Merge branch 'master' into 622_reflow3
jerch Jan 5, 2019
6581eb7
fix leftover BufferLineConstructor
jerch Jan 5, 2019
7fe3f0a
Improve BufferLine test
Tyriar Jan 11, 2019
68197ed
Merge branch 'master' into 622_reflow3
Tyriar Jan 17, 2019
4843ca5
Fix reflow larger with wide chars
Tyriar Jan 21, 2019
ce69dce
Progress on reflow smaller with wide chars
Tyriar Jan 24, 2019
df7cd9c
Get reflow smaller working for wide chars
Tyriar Jan 24, 2019
178c513
Clean up
Tyriar Jan 24, 2019
deb04ca
Merge branch 'master' into 622_reflow3
Tyriar Jan 24, 2019
dfff04c
Pull toRemove step into BufferReflow
Tyriar Jan 24, 2019
d4bd8ae
Pull more parts out of reflow larger
Tyriar Jan 24, 2019
99ac780
Remove out param from reflow large method
Tyriar Jan 24, 2019
109e3a5
jsdoc
Tyriar Jan 24, 2019
52a429f
Merge branch 'master' into 622_reflow3
Tyriar Jan 25, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
759 changes: 757 additions & 2 deletions src/Buffer.test.ts

Large diffs are not rendered by default.

274 changes: 252 additions & 22 deletions src/Buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
* @license MIT
*/

import { CircularList } from './common/CircularList';
import { CircularList, IInsertEvent, IDeleteEvent } from './common/CircularList';
import { CharData, ITerminal, IBuffer, IBufferLine, BufferIndex, IBufferStringIterator, IBufferStringIteratorResult } from './Types';
import { EventEmitter } from './common/EventEmitter';
import { IMarker } from 'xterm';
import { BufferLine } from './BufferLine';
import { DEFAULT_COLOR } from './renderer/atlas/Types';
import { reflowSmallerGetNewLineLengths, reflowLargerGetLinesToRemove, reflowLargerCreateNewLayout, reflowLargerApplyNewLayout } from './BufferReflow';

export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0);
export const CHAR_DATA_ATTR_INDEX = 0;
Expand All @@ -25,6 +26,8 @@ export const WHITESPACE_CELL_CHAR = ' ';
export const WHITESPACE_CELL_WIDTH = 1;
export const WHITESPACE_CELL_CODE = 32;

export const FILL_CHAR_DATA: CharData = [DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE];

/**
* This class represents a terminal buffer (an internal state of the terminal), where the
* following information is stored (in high-level):
Expand All @@ -45,6 +48,8 @@ export class Buffer implements IBuffer {
public savedX: number;
public savedCurAttr: number;
public markers: Marker[] = [];
private _cols: number;
private _rows: number;

/**
* Create a new Buffer.
Expand All @@ -56,22 +61,24 @@ export class Buffer implements IBuffer {
private _terminal: ITerminal,
private _hasScrollback: boolean
) {
this._cols = this._terminal.cols;
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
this._rows = this._terminal.rows;
this.clear();
}

public getBlankLine(attr: number, isWrapped?: boolean): IBufferLine {
const fillCharData: CharData = [attr, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE];
return new BufferLine(this._terminal.cols, fillCharData, isWrapped);
return new BufferLine(this._cols, fillCharData, isWrapped);
}

public get hasScrollback(): boolean {
return this._hasScrollback && this.lines.maxLength > this._terminal.rows;
return this._hasScrollback && this.lines.maxLength > this._rows;
}

public get isCursorInViewport(): boolean {
const absoluteY = this.ybase + this.y;
const relativeY = absoluteY - this.ydisp;
return (relativeY >= 0 && relativeY < this._terminal.rows);
return (relativeY >= 0 && relativeY < this._rows);
}

/**
Expand All @@ -97,7 +104,7 @@ export class Buffer implements IBuffer {
if (fillAttr === undefined) {
fillAttr = DEFAULT_ATTR;
}
let i = this._terminal.rows;
let i = this._rows;
while (i--) {
this.lines.push(this.getBlankLine(fillAttr));
}
Expand All @@ -112,9 +119,9 @@ export class Buffer implements IBuffer {
this.ybase = 0;
this.y = 0;
this.x = 0;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._terminal.rows));
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
this.scrollTop = 0;
this.scrollBottom = this._terminal.rows - 1;
this.scrollBottom = this._rows - 1;
this.setupTabStops();
}

Expand All @@ -134,18 +141,17 @@ export class Buffer implements IBuffer {
// The following adjustments should only happen if the buffer has been
// initialized/filled.
if (this.lines.length > 0) {
// Deal with columns increasing (we don't do anything when columns reduce)
if (this._terminal.cols < newCols) {
const ch: CharData = [DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]; // does xterm use the default attr?
// Deal with columns increasing (reducing needs to happen after reflow)
if (this._cols < newCols) {
for (let i = 0; i < this.lines.length; i++) {
this.lines.get(i).resize(newCols, ch);
this.lines.get(i).resize(newCols, FILL_CHAR_DATA);
}
}

// Resize rows in both directions as needed
let addToY = 0;
if (this._terminal.rows < newRows) {
for (let y = this._terminal.rows; y < newRows; y++) {
if (this._rows < newRows) {
for (let y = this._rows; y < newRows; y++) {
if (this.lines.length < newRows + this.ybase) {
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
// There is room above the buffer and there are no empty elements below the line,
Expand All @@ -159,13 +165,12 @@ export class Buffer implements IBuffer {
} else {
// Add a blank line if there is no buffer left at the top to scroll to, or if there
// are blank lines after the cursor
const fillCharData: CharData = [DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE];
this.lines.push(new BufferLine(newCols, fillCharData));
this.lines.push(new BufferLine(newCols, FILL_CHAR_DATA));
}
}
}
} else { // (this._terminal.rows >= newRows)
for (let y = this._terminal.rows; y > newRows; y--) {
} else { // (this._rows >= newRows)
for (let y = this._rows; y > newRows; y--) {
if (this.lines.length > newRows + this.ybase) {
if (this.lines.length > this.ybase + this.y + 1) {
// The line is a blank line below the cursor, remove it
Expand Down Expand Up @@ -205,8 +210,218 @@ export class Buffer implements IBuffer {
}

this.scrollBottom = newRows - 1;

if (this._hasScrollback) {
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
this._reflow(newCols);

// Trim the end of the line off if cols shrunk
if (this._cols > newCols) {
for (let i = 0; i < this.lines.length; i++) {
this.lines.get(i).resize(newCols, FILL_CHAR_DATA);
}
}
}

this._cols = newCols;
this._rows = newRows;
}

private _reflow(newCols: number): void {
if (this._cols === newCols) {
return;
}

// Iterate through rows, ignore the last one as it cannot be wrapped
if (newCols > this._cols) {
this._reflowLarger(newCols);
} else {
this._reflowSmaller(newCols);
}
}

private _reflowLarger(newCols: number): void {
const toRemove: number[] = reflowLargerGetLinesToRemove(this.lines, newCols);
if (toRemove.length > 0) {
const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove);
reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout);
this._reflowLargerAdjustViewport(newCols, newLayoutResult.countRemoved);
}
}

private _reflowLargerAdjustViewport(newCols: number, countRemoved: number): void {
// Adjust viewport based on number of items removed
let viewportAdjustments = countRemoved;
while (viewportAdjustments-- > 0) {
if (this.ybase === 0) {
this.y--;
// Add an extra row at the bottom of the viewport
this.lines.push(new BufferLine(newCols, FILL_CHAR_DATA));
} else {
if (this.ydisp === this.ybase) {
this.ydisp--;
}
this.ybase--;
}
}
}

private _reflowSmaller(newCols: number): void {
// Gather all BufferLines that need to be inserted into the Buffer here so that they can be
// batched up and only committed once
const toInsert = [];
let countToInsert = 0;
// Go backwards as many lines may be trimmed and this will avoid considering them
for (let y = this.lines.length - 1; y >= 0; y--) {
// Check whether this line is a problem
let nextLine = this.lines.get(y) as BufferLine;
if (!nextLine.isWrapped && nextLine.getTrimmedLength() <= newCols) {
continue;
}

// Gather wrapped lines and adjust y to be the starting line
const wrappedLines: BufferLine[] = [nextLine];
while (nextLine.isWrapped && y > 0) {
nextLine = this.lines.get(--y) as BufferLine;
wrappedLines.unshift(nextLine);
}

const lastLineLength = wrappedLines[wrappedLines.length - 1].getTrimmedLength();
const destLineLengths = reflowSmallerGetNewLineLengths(wrappedLines, this._cols, newCols);
const linesToAdd = destLineLengths.length - wrappedLines.length;
let trimmedLines: number;
if (this.ybase === 0 && this.y !== this.lines.length - 1) {
// If the top section of the buffer is not yet filled
trimmedLines = Math.max(0, this.y - this.lines.maxLength + linesToAdd);
} else {
trimmedLines = Math.max(0, this.lines.length - this.lines.maxLength + linesToAdd);
}

// Add the new lines
const newLines: BufferLine[] = [];
for (let i = 0; i < linesToAdd; i++) {
const newLine = this.getBlankLine(DEFAULT_ATTR, true) as BufferLine;
newLines.push(newLine);
}
if (newLines.length > 0) {
toInsert.push({
// countToInsert here gets the actual index, taking into account other inserted items.
// using this we can iterate through the list forwards
start: y + wrappedLines.length + countToInsert,
newLines
});
countToInsert += newLines.length;
}
wrappedLines.push(...newLines);

// Copy buffer data to new locations, this needs to happen backwards to do in-place
let destLineIndex = destLineLengths.length - 1; // Math.floor(cellsNeeded / newCols);
let destCol = destLineLengths[destLineIndex]; // cellsNeeded % newCols;
if (destCol === 0) {
destLineIndex--;
destCol = destLineLengths[destLineIndex];
}
let srcLineIndex = wrappedLines.length - linesToAdd - 1;
let srcCol = lastLineLength;
while (srcLineIndex >= 0) {
const cellsToCopy = Math.min(srcCol, destCol);
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true);
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
destCol -= cellsToCopy;
if (destCol === 0) {
destLineIndex--;
destCol = destLineLengths[destLineIndex];
}
srcCol -= cellsToCopy;
if (srcCol === 0) {
srcLineIndex--;
// TODO: srcCol shoudl take trimmed length into account
srcCol = wrappedLines[Math.max(srcLineIndex, 0)].getTrimmedLength(); // this._cols;
}
}

// Null out the end of the line ends if a wide character wrapped to the following line
for (let i = 0; i < wrappedLines.length; i++) {
if (destLineLengths[i] < newCols) {
wrappedLines[i].set(destLineLengths[i], FILL_CHAR_DATA);
}
}

// Adjust viewport as needed
let viewportAdjustments = linesToAdd - trimmedLines;
while (viewportAdjustments-- > 0) {
if (this.ybase === 0) {
if (this.y < this._rows - 1) {
this.y++;
this.lines.pop();
} else {
this.ybase++;
this.ydisp++;
}
} else {
if (this.ybase === this.ydisp) {
this.ydisp++;
}
this.ybase++;
}
}
}

// Rearrange lines in the buffer if there are any insertions, this is done at the end rather
// than earlier so that it's a single O(n) pass through the buffer, instead of O(n^2) from many
// costly calls to CircularList.splice.
if (toInsert.length > 0) {
// Record buffer insert events and then play them back backwards so that the indexes are
// correct
const insertEvents: IInsertEvent[] = [];

// Record original lines so they don't get overridden when we rearrange the list
const originalLines: BufferLine[] = [];
for (let i = 0; i < this.lines.length; i++) {
originalLines.push(this.lines.get(i) as BufferLine);
}
const originalLinesLength = this.lines.length;

let originalLineIndex = originalLinesLength - 1;
let nextToInsertIndex = 0;
let nextToInsert = toInsert[nextToInsertIndex];
this.lines.length = Math.min(this.lines.maxLength, this.lines.length + countToInsert);
let countInsertedSoFar = 0;
for (let i = Math.min(this.lines.maxLength - 1, originalLinesLength + countToInsert - 1); i >= 0; i--) {
if (nextToInsert && nextToInsert.start > originalLineIndex + countInsertedSoFar) {
// Insert extra lines here, adjusting i as needed
for (let nextI = nextToInsert.newLines.length - 1; nextI >= 0; nextI--) {
this.lines.set(i--, nextToInsert.newLines[nextI]);
}
i++;

// Create insert events for later
insertEvents.push({
index: originalLineIndex + 1,
amount: nextToInsert.newLines.length
} as IInsertEvent);

countInsertedSoFar += nextToInsert.newLines.length;
nextToInsert = toInsert[++nextToInsertIndex];
} else {
this.lines.set(i, originalLines[originalLineIndex--]);
}
}

// Update markers
let insertCountEmitted = 0;
for (let i = insertEvents.length - 1; i >= 0; i--) {
insertEvents[i].index += insertCountEmitted;
this.lines.emit('insert', insertEvents[i]);
insertCountEmitted += insertEvents[i].amount;
}
const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength);
if (amountToTrim > 0) {
this.lines.emitMayRemoveListeners('trim', amountToTrim);
}
}
}

// private _reflowSmallerGetLinesNeeded()

/**
* Translates a string index back to a BufferIndex.
* To get the correct buffer position the string must start at `startCol` 0
Expand Down Expand Up @@ -283,7 +498,7 @@ export class Buffer implements IBuffer {
i = 0;
}

for (; i < this._terminal.cols; i += this._terminal.options.tabStopWidth) {
for (; i < this._cols; i += this._terminal.options.tabStopWidth) {
this.tabs[i] = true;
}
}
Expand All @@ -297,7 +512,7 @@ export class Buffer implements IBuffer {
x = this.x;
}
while (!this.tabs[--x] && x > 0);
return x >= this._terminal.cols ? this._terminal.cols - 1 : x < 0 ? 0 : x;
return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x;
}

/**
Expand All @@ -308,8 +523,8 @@ export class Buffer implements IBuffer {
if (x === null || x === undefined) {
x = this.x;
}
while (!this.tabs[++x] && x < this._terminal.cols);
return x >= this._terminal.cols ? this._terminal.cols - 1 : x < 0 ? 0 : x;
while (!this.tabs[++x] && x < this._cols);
return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x;
}

public addMarker(y: number): Marker {
Expand All @@ -322,12 +537,27 @@ export class Buffer implements IBuffer {
marker.dispose();
}
}));
marker.register(this.lines.addDisposableListener('insert', (event: IInsertEvent) => {
if (marker.line >= event.index) {
marker.line += event.amount;
}
}));
marker.register(this.lines.addDisposableListener('delete', (event: IDeleteEvent) => {
// Delete the marker if it's within the range
if (marker.line >= event.index && marker.line < event.index + event.amount) {
marker.dispose();
}

// Shift the marker if it's after the deleted range
if (marker.line > event.index) {
marker.line -= event.amount;
}
}));
marker.register(marker.addDisposableListener('dispose', () => this._removeMarker(marker)));
return marker;
}

private _removeMarker(marker: Marker): void {
// TODO: This could probably be optimized by relying on sort order and trimming the array using .length
this.markers.splice(this.markers.indexOf(marker), 1);
}

Expand Down
Loading