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

Support reflowing lines on resize #622

Closed
Tyriar opened this issue Mar 30, 2017 · 12 comments · Fixed by #1864
Closed

Support reflowing lines on resize #622

Tyriar opened this issue Mar 30, 2017 · 12 comments · Fixed by #1864
Assignees
Labels
type/enhancement Features or improvements to existing features
Milestone

Comments

@Tyriar
Copy link
Member

Tyriar commented Mar 30, 2017

On resize ideally we want the text to reflow and wrap to the next line where appropriate.

@Tyriar
Copy link
Member Author

Tyriar commented Aug 3, 2017

I'm closing off the PR for this #644, if anything it was a good learning experience!

Basically to tackle this feature properly we need to come up with a lightweight view model, it's probably best to be left to one of the core maintainers as it's quite complex and needs an intimate knowledge of the internals of xterm.js to be done right.

I'll copy over the proposal from #644 (comment) for reference, it's still mostly correct and useful. I would also add to this that we need to consider the memory cost of the view model as well as potentially using the view model as a way to gain deeper insights into what's happening in the buffer for #731 (eg. knowing that a single character was added when a line is redrawn).


What we need

  • Wrap lines when reducing width
  • Unwrap lines when increasing width
  • Scroll bar needs to support wrapped height
  • Don't change the way input interacts with the buffer - when an escape sequence operates on row x, it should operate on the unwrapped row at x

Issues with current implementation

  • Accessing private properties on CircularList could lead to instability. This essentially obsoletes the unit tests on CircularList for how we're using it and makes it more difficult to maintain going forward.
  • A lot of costly array/string manipulation inside reflow.

Proposal

WrappableBuffer's state

I'm proposing we change WrappableBuffer to store the start index of the row

CircularList     WrappableBuffer where size = 4
[0]: "abcdef"      [0]: 0
[1]: "abcd"        [1]: 2
[2]: "abcdefgh"    [2]: 3

With this we have a basic model of the wrapping with minimal information. For fast access of the actual rows we will also want WrappableBuffer to have a map going the other way:

WrappableBuffer.wrappedToNormalIndex
[0]: [0, 0]
[1]: [0, 1]
[2]: [1, 0]
[3]: [2, 0]
[4]: [2, 1]

These two arrays are all that will be evaluated when the terminal is resized, so the resize of WrappableBuffer will look something like this:

resize(width: number) {
  // Lots of optimization potential here
  this.normalToWrappedIndex = new Array(this.circularList.length);
  this.wrappedToNormalIndex = new Array(this.circularList.length);
  let currentNormal = 0;
  let currentWrappedCount = 0;
  for (let i = 0; i < this.circularList.length; i++) {
    const line = this.circularList.get(i);
    const wrappedCount = Math.ceil(line.length / width);
    this.normalToWrappedIndex[i] = currentWrappedCount;
    for (let j = 0; j < wrappedCount; j++) {
      this.wrappedToNormalIndex[currentWrappedCount + j] = [i, j];
    }
    currentWrappedCount += wrappedCount;
  }
}

Retrieving rows

Since we're only doing the above on a resize we need to actually serve the wrapped rows somehow. This can be done at the time the wrapped row is requested with WrappableBuffer.getWrapped at which point we can cache the chunked row and store it for later use since string construction is relatively expensive.

When the line's wrapped state changes we can discard the cached chunked row.

Composition over inheritance

In order to prevent future misuse let's use a member variable instead of extending and have both CircularList and WrappableBuffer share the ICircularList interface.

CircularList`s internal changes

To deal CircularList's trimming mechanism we could have CircularList extend EventEmitter and throw an onTrim(count: number) event and act accordingly in WrappableBuffer (probably by storing an offset to add to the indexes).

If there are any other internal movement that WrappableBuffer needs to know about it could be exposed in an event.

Exposing both wrapped and unwrapped buffers

I believe we want to expose both wrapped (getWrapped?) and unwrapped buffers (get?) as escape sequences should be operating on the unwrapped rows but we want to display the wrapped rows.

Scrollback

Currently it's a little ambiguous what scrollback means, in the new solution scrollback would strictly define the number of unwrapped rows.

Result

Here's a once over of the ICircularList interface summarizing the changes that WrappableBuffer would make:

  • length: Similar to CircularList but changes on resize
  • maxLength: Computed based on CircularList.maxLength and WrappableBuffer.length;
  • forEach: Little change
  • get: Create and cache wrapped row
  • set: Re-evaluates wrapped state of all rows after the current row, this will typically only be called towards the end of the buffer
  • push: Evaluate wrapped state of new row
  • pop: Remove row
  • splice: Like set, should only be used near end of buffer
  • trimStart: Will throw an event in CircularList
  • shiftElements: Evaluate wrapped state of new rows, reuse when possible

@lmcbout
Copy link

lmcbout commented Sep 27, 2017

About this issue, in the section (What we need) " is exactly what we are looking for, a way to display in the xterm on a browser the wrap line when increasing/reducing the width of the window, be able to see all the data for the executed command.

@Tyriar
Copy link
Member Author

Tyriar commented Mar 22, 2018

I'm planning on tackling this for 3.4.0, I recently went over the history of this and wrote up a refreshed design document:

Requirements

  • Wrap lines when reducing width
  • Unwrap lines when increasing width
  • Scroll bar needs to use the total number of wrapped lines
  • Escape sequences need to act upon the wrapped buffer, not the unwrapped buffer.

Performance considerations

  • Do not reconstruct characters/rows/strings while maintaining the wrapped state
  • The total number of wrapped lines needs to be fetched very quickly as that is needed to position the scrollbar
  • Resize needs to be as fast as possible as this can fire many times on windows resize (and the terminal is just one component in VS Code)
  • Ideally scrollback would represent the number of wrapped rows, this isn't too important though if that's tough

Implementation

The solution will keep a separate view model WrappedBuffer which maintains arrays that map from normal to wrapped index and the other way around. For example:

CircularList       WrappedBuffer.normalToWrappedIndex where size = 4
[0]: "abcdef"      [0]: 0
[1]: "abcd"        [1]: 2
[2]: "abcdefgh"    [2]: 3
WrappableBuffer.wrappedToNormalIndex
[0]: [0, 0]
[1]: [0, 4]
[2]: [1, 0]
[3]: [2, 0]
[4]: [2, 4]

This will provide fast O(1) access to wrapped or unwrapped indexes at the cost of a O(n) operation on resize to update the lists. This operation could probably be optimized pretty heavily.

abstract class BaseWrappedList extends IWrappedList<LineData, CharData> {
    private _circularList: ICircularList<LineData>;

    // These should use TypedArrays/ArrayBuffers (may need to reduce MAX_BUFFER_SIZE)
    private wrappedToNormalIndex: [number, number][];
    private normalToWrappedIndex: number[];

    // O(n) where n is length of _circularList
    public onResize(cols: number): void {
        this.normalToWrappedIndex = new Array(this.circularList.length);
        this.wrappedToNormalIndex = new Array(this.circularList.length);
        let currentNormal = 0;
        let currentWrappedCount = 0;
        for (let i = 0; i < this.circularList.length; i++) {
            const line = this.circularList.get(i);
            // TODO: Use _isListEntryDoubleWidth to determine the line of the
            //       last wrapped cell in each line
            const wrappedCount = Math.ceil(line.length / cols);
            this.normalToWrappedIndex[i] = currentWrappedCount;
            for (let j = 0; j < wrappedCount; j++) {
                this.wrappedToNormalIndex[currentWrappedCount + j] = [i, j];
            }
            currentWrappedCount += wrappedCount;
        }
    }

    protected _isListEntryDoubleWidth(unwrappedRow: number, unwrappedCol: number): boolean {
        return false;
    }
}

class WrappedBuffer extends IWrappedList<LineData, CharData> {
    protected _isListEntryDoubleWidth(unwrappedRow: number, unwrappedCol: number): boolean {
        // Used to check if the last char is double width in order to wrap it to
        // the next row or not
        // Null check as needed
        return this.get(unwrappedRow, unwrappedCol)[2] === 2;
    }
}

Before

export interface ICircularList<T> extends IEventEmitter {
  length: number;
  maxLength: number;
  forEach: (callbackfn: (value: T, index: number) => void) => void; // Not sure this is used anymore

  get(index: number): T;
  set(index: number, value: T): void;
  push(value: T): void;
  pop(): T;
  splice(start: number, deleteCount: number, ...items: T[]): void;
  trimStart(count: number): void;
  shiftElements(start: number, count: number, offset: number): void;
}

class CircularList extends ICircularList<LineData> { ... }

After

export interface IWrappedList<T extends Array<U>, U> {
  // How to handle wide chars on the last cell? Protected method
  // _isListEntryDoubleWidth which is called on the last char to figure out the
  // right wrapped spot

  readonly length: number;

  get(index: number): T;
  splice(start: number, deleteCount: number, ...items: T[]): void;
  shiftElements(start: number, count: number, offset: number): void;
  onResize(cols: number): void;
}

export interface ICircularList<T> extends IEventEmitter {
  length: number;
  maxLength: number;

  get(index: number): T;
  set(index: number, value: T): void;
  push(value: T): void;
  pop(): T;
  trimStart(count: number): void;
}

Notes

  • When a line is wrapped and it is deleted, the delete line operation is perfermed on the wrapped portion of the line, breaking the line

    https://github.com/Microsoft/vscode/issues?utf8=%E2%9C%93&q=is%3Aopen%20no%3Aassignee%20-label%3Afeature-request%20-label%3Atestplan-item%20-label%3Aplan-item%20-label%3Aextension-candidate
    echo -e '\e[44m\e[A\e[A\e[A\e[A\e[A\e[1M\e[0m'
    

    If deleted from the middle of a wrapped line (eg. line 2 of a 3 line wrapped line), VTE will join the other portions of the line

    This means that the wrapped state will also affect how manipulations happen on the actual circular list.

  • When a line is inserted into the middle of a wrapped line, it will be marked as wrapped.

    echo 'https://github.com/Microsoft/vscode/issues?utf8=%E2%9C%93&q=is%3Aopen%20no%3Aassignee%20-label%3Afeature-request%20-label%3Atestplan-item%20-label%3Aplan-item%20-label%3Aextension-candidate'
    echo -e '\e[44m\e[A\e[A\e[A\e[A\e[A\e[1L%20-label%3Afeature-request%20-label%3Atestplan-item%20-label%3Aplan-item%20-label%3A\e[0m'
    

    The line will not join with a following wrapped portion unless the line wraps over to that line:

    echo 'https://github.com/Microsoft/vscode/issues?utf8=%E2%9C%93&q=is%3Aopen%20no%3Aassignee%20-label%3Afeature-request%20-label%3Atestplan-item%20-label%3Aplan-item%20-label%3Aextension-candidate'
    echo -e '\e[44m\e[A\e[A\e[A\e[A\e[A\e[1L%20-label%3Afeature-request%20-label%3Atestplan-item%20-label%3Aplan-item%20-label%3AA\e[0m'
    

@wmertens
Copy link

wmertens commented Mar 23, 2018 via email

@wmertens
Copy link

How about making the resize interruptable:

on resize:

  • mark the lines dirty
  • recalculate the visible lines first (needs some consideration for the first wrapped line)
  • then start from the beginning, calculate 100 lines, schedule the next 100
  • when you reach the end, unmark the lines dirty
  • any time resize happens, cancel the next scheduled work and start over

this way it should remain responsive at all times.

@Tyriar
Copy link
Member Author

Tyriar commented Mar 23, 2018

@wmertens

is it possible to recalculate the visible lines first? this to make resizing fast.

The issue with this is getting the scroll position correct, we could do more guessing but that would probably involve a lot more changes and may not be necessary. I'm hoping that because we're working on TypedArrays and just basic math it should be reasonable for a few terminals with 1000 scrollback (typical VS Code case). Time will tell though.

How about making the resize interruptable

This is an interesting idea, I'm not 100% sure if the synchronous JS will block another resize event from coming in. If not, we should definitely be able to cancelp 👍

@wmertens
Copy link

wmertens commented Mar 23, 2018

The issue with this is getting the scroll position correct

Sorry I don't know how the scrolling is implemented, can't you keep the top line at the top of the visible range?

So can you leave the scroll bar unchanged until the reflow is complete, and then update.

@Tyriar
Copy link
Member Author

Tyriar commented Mar 23, 2018

@wmertens

So can you leave the scroll bar unchanged until the reflow is complete, and then update.

Well that's the idea, but reflow will be synchronous so it will happen immediately after.

Sorry I don't know how the scrolling is implemented, can't you keep the top line at the top of the visible range?

The thing with the scroll bar is that we could maybe get away with only reflowing the viewport if not for the scroll bar. For the scroll bar we need to know the number of wrapped lines, and to get that we need to iteration over all the lines and perform the above resize algorithm.

I'm hoping it's just fast enough due to the TypedArrays but if not then maybe we have to fallback on being lazy and guessing the scroll bar but I can see weird bugs coming out of doing something like this. My thinking was that we could just wrap the viewport (and maybe surrounding rows), scrolling with the mouse wheel could be left unchanged. Scrolling by dragging the bar however would scroll the unwrapped rows or to some percentage of the viewport. I think this is over engineering the solution though and would cause some things to just not feel right (scroll bar height, jumping over an inconsistent amount of rows when scrolling).

@wmertens
Copy link

wmertens commented Mar 24, 2018 via email

@Tyriar
Copy link
Member Author

Tyriar commented May 17, 2018

My work on this got deferred because some other higher priority stuff came up 🙁

@Tyriar Tyriar removed this from the 3.4.0 milestone May 17, 2018
@Tyriar
Copy link
Member Author

Tyriar commented May 21, 2018

#1286 should get fixed when we tackle reflow, the proper fix needs to the reflow workaround where the data outside the screen to go away first. See #1373

@PerBothner
Copy link
Contributor

DomTerm supports Common Lisp-style "pretty-printing". There are certain escape sequence to indicate logical grouping, optional newlines (only break line if too long to fit), indentation etc. Then DomTerm's line-breaking engine calculates the line-breaks. On window resize the line-breaks are re-calculated.

This may not be something you want to do, but if would be nice if it is something an addon could do. I.e. there needs to be a hook into reflow. It would be nice to be able to add and remove "soft" newlines that aren't always at the right of the line.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type/enhancement Features or improvements to existing features
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants