Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions src/headless/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export class Terminal extends CoreTerminal {
public readonly onA11yChar = this._onA11yCharEmitter.event;
private readonly _onA11yTabEmitter = this._register(new Emitter<number>());
public readonly onA11yTab = this._onA11yTabEmitter.event;
private readonly _onRowChange = this._register(new Emitter<{ start: number, end: number }>());
public readonly onRowChange = this._onRowChange.event;

constructor(
options: ITerminalOptions = {}
Expand All @@ -53,6 +55,11 @@ export class Terminal extends CoreTerminal {
this._register(Event.forward(this._inputHandler.onTitleChange, this._onTitleChange));
this._register(Event.forward(this._inputHandler.onA11yChar, this._onA11yCharEmitter));
this._register(Event.forward(this._inputHandler.onA11yTab, this._onA11yTabEmitter));
this._register(this._inputHandler.onRequestRefreshRows(e => {
if (e) {
this._onRowChange.fire({ start: e.start, end: e.end });
}
}));
Comment on lines +58 to +62
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the event also fire undefined? I think that means every row needs to be updated?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't love the might be undefined or might be an object as the event spec, and I never needed the undefined case so I thought it made the API cleaner. If I remember right another event gets fired on full repaint so its redundant. I'm indifferent to changing the interface to { start, end}|undefined.

}

/**
Expand Down
86 changes: 86 additions & 0 deletions src/headless/public/Terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,92 @@ describe('Headless API Tests', function (): void {
await writeSync('\x07');
deepStrictEqual(calls, [true]);
});

it('onRowChange', async () => {
let callCount = 0;
term.onRowChange(() => callCount++);
strictEqual(callCount, 0);
await writeSync('hello');
strictEqual(callCount, 1);
await writeSync('\nworld');
strictEqual(callCount, 2);
});

it('onRowChange - cursor positioning', async () => {
term = new Terminal({ rows: 25, cols: 80, allowProposedApi: true });
const calls: Array<{ start: number, end: number }> = [];
term.onRowChange(e => calls.push(e));

// Basic cursor movements
await writeSync('\x1b[10;40Htest'); // Move to middle, write
strictEqual(calls.length, 1);
strictEqual(calls[0].start, 0);
strictEqual(calls[0].end, 9);

// Multiple cursor movements in one write
calls.length = 0;
await writeSync('\x1b[5;20Ha\x1b[15;60Hb\x1b[20;10Hc');
strictEqual(calls.length, 1);
strictEqual(calls[0].start, 4);
strictEqual(calls[0].end, 19);

// Large scrollback buffer test
const termScroll = new Terminal({ rows: 10, cols: 80, scrollback: 1000, allowProposedApi: true });
const scrollCalls: Array<{ start: number, end: number }> = [];
termScroll.onRowChange(e => scrollCalls.push(e));

// Fill and scroll beyond viewport - validate all events
for (let i = 0; i < 30; i++) {
const beforeCount = scrollCalls.length;
await new Promise<void>(resolve => termScroll.write(`Line ${i}\n`, resolve));
strictEqual(scrollCalls.length, beforeCount + 1);
const event = scrollCalls[scrollCalls.length - 1];
if (i < 9) {
// Before scrolling: current row + linefeed to next row
strictEqual(event.start, i);
strictEqual(event.end, i + 1);
} else {
// After scrolling starts: entire viewport
strictEqual(event.start, 0);
strictEqual(event.end, 9);
}
}

// Cursor movement in scrolled terminal
scrollCalls.length = 0;
await new Promise<void>(resolve => termScroll.write('\x1b[5;40HX', resolve));
strictEqual(scrollCalls.length, 1);
strictEqual(scrollCalls[0].start, 4);
strictEqual(scrollCalls[0].end, 9);
});

it('onRowChange - scrolling', async () => {
term = new Terminal({ rows: 3, cols: 5, allowProposedApi: true });
const calls: Array<{ start: number, end: number }> = [];
term.onRowChange(e => calls.push(e));

// Fill terminal
await writeSync('line1\nline2\nline3');
calls.length = 0;

// Cause scroll
await writeSync('\nline4');
strictEqual(calls.length, 1);
strictEqual(calls[0].start, 0);
strictEqual(calls[0].end, 2);
});

it('onRowChange - text wrapping', async () => {
term = new Terminal({ rows: 3, cols: 5, allowProposedApi: true });
const calls: Array<{ start: number, end: number }> = [];
term.onRowChange(e => calls.push(e));

// Write text that wraps
await writeSync('verylongtext');
strictEqual(calls.length, 1);
strictEqual(calls[0].start, 0);
strictEqual(calls[0].end, 2);
});
});

describe('buffer', () => {
Expand Down
8 changes: 6 additions & 2 deletions src/headless/public/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Terminal as TerminalCore } from 'headless/Terminal';
import { AddonManager } from 'common/public/AddonManager';
import { ITerminalOptions } from 'common/Types';
import { Disposable } from 'vs/base/common/lifecycle';
import type { Event } from 'vs/base/common/event';
import { Event } from 'vs/base/common/event';
/**
* The set of options that only have an effect when set in the Terminal constructor.
*/
Expand Down Expand Up @@ -81,6 +81,10 @@ export class Terminal extends Disposable implements ITerminalApi {
public get onScroll(): Event<number> { return this._core.onScroll; }
public get onTitleChange(): Event<string> { return this._core.onTitleChange; }
public get onWriteParsed(): Event<void> { return this._core.onWriteParsed; }
public get onRowChange(): Event<{ start: number, end: number }> {
this._checkProposedApi();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can skip making it proposed since it's so simple

return Event.map(this._core.onRowChange, e => ({ start: e.start, end: e.end }));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to avoid Event.map here if possible, now sure why this would be needed?

}

public get parser(): IParser {
this._checkProposedApi();
Expand Down Expand Up @@ -186,7 +190,7 @@ export class Terminal extends Disposable implements ITerminalApi {
}
public loadAddon(addon: ITerminalAddon): void {
// TODO: This could cause issues if the addon calls renderer apis
this._addonManager.loadAddon(this as any, addon);
this._addonManager.loadAddon(this as any, addon as any);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this needed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typescript compiler was complaining because the interfaces on headless and regular are different. I'm not sure why it ever worked without an as cast.

}

private _verifyIntegers(...values: number[]): void {
Expand Down
7 changes: 7 additions & 0 deletions typings/xterm-headless.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,13 @@ declare module '@xterm/headless' {
*/
onResize: IEvent<{ cols: number, rows: number }>;

/**
* Adds an event listener for when buffer rows change during parsing. The event
* value contains the range of rows that changed.
Comment on lines +752 to +753
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Adds an event listener for when buffer rows change during parsing. The event
* value contains the range of rows that changed.
* Adds an event listener for when buffer rows change during parsing. The
* event value contains the range of rows that changed. This is particularly
* useful when implementing a custom renderer.

* @returns an `IDisposable` to stop listening.
*/
onRowChange: IEvent<{ start: number, end: number }>;
Comment on lines +751 to +756
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also add to xterm.d.ts as xterm-headless is a subset of that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if instead xterm-headless should just forward onRequestRefreshRows to the existing onRender? Would that accomplish what you're after?


/**
* Adds an event listener for when a scroll occurs. The event value is the
* new position of the viewport.
Expand Down