Merge branch 'master' into fix_weblinks

This commit is contained in:
Jörg Breitbart
2022-12-19 14:19:43 +01:00
30 changed files with 414 additions and 255 deletions
+5 -1
View File
@@ -246,7 +246,10 @@ export class AccessibilityManager extends Disposable {
private _handleKey(keyChar: string): void {
this._clearLiveRegion();
this._charsToConsume.push(keyChar);
// Only add the char if there is no control character.
if (!/\p{Control}/u.test(keyChar)) {
this._charsToConsume.push(keyChar);
}
}
private _refreshRows(start?: number, end?: number): void {
@@ -277,6 +280,7 @@ export class AccessibilityManager extends Disposable {
if (!this._renderService.dimensions.css.cell.height) {
return;
}
this._accessibilityTreeRoot.style.width = `${this._renderService.dimensions.css.canvas.width}px`;
if (this._rowElements.length !== this._terminal.rows) {
this._handleResize(this._terminal.rows);
}
+8
View File
@@ -315,7 +315,15 @@ export class Linkifier2 extends Disposable implements ILinkifier2 {
// When start is 0 a scroll most likely occurred, make sure links above the fold also get
// cleared.
const start = e.start === 0 ? 0 : e.start + 1 + this._bufferService.buffer.ydisp;
const oldEvent = this._currentLink ? this._lastMouseEvent : undefined;
this._clearCurrentLink(start, e.end + 1 + this._bufferService.buffer.ydisp);
if (oldEvent && this._element) {
// re-eval previously active link after changes
const position = this._positionFromMouseEvent(oldEvent, this._element, this._mouseService!);
if (position) {
this._askForLink(position, false);
}
}
}));
}
}
+25 -9
View File
@@ -66,14 +66,30 @@ export class OscLinkProvider implements ILinkProvider {
y
}
};
// OSC links always use underline and pointer decorations
result.push({
text,
range,
activate: (e, text) => (linkHandler ? linkHandler.activate(e, text, range) : defaultActivate(e, text)),
hover: (e, text) => linkHandler?.hover?.(e, text, range),
leave: (e, text) => linkHandler?.leave?.(e, text, range)
});
let ignoreLink = false;
if (!linkHandler?.allowNonHttpProtocols) {
try {
const parsed = new URL(text);
if (!['http:', 'https:'].includes(parsed.protocol)) {
ignoreLink = true;
}
} catch (e) {
// Ignore invalid URLs to prevent unexpected behaviors
ignoreLink = true;
}
}
if (!ignoreLink) {
// OSC links always use underline and pointer decorations
result.push({
text,
range,
activate: (e, text) => (linkHandler ? linkHandler.activate(e, text, range) : defaultActivate(e, text)),
hover: (e, text) => linkHandler?.hover?.(e, text, range),
leave: (e, text) => linkHandler?.leave?.(e, text, range)
});
}
}
finishLink = false;
@@ -94,7 +110,7 @@ export class OscLinkProvider implements ILinkProvider {
}
function defaultActivate(e: MouseEvent, uri: string): void {
const answer = confirm(`Do you want to navigate to ${uri}?`);
const answer = confirm(`Do you want to navigate to ${uri}?\n\nWARNING: This link could potentially be dangerous`);
if (answer) {
const newWindow = window.open();
if (newWindow) {
+16 -8
View File
@@ -445,17 +445,25 @@ export class Terminal extends CoreTerminal implements ITerminal {
this.textarea = document.createElement('textarea');
this.textarea.classList.add('xterm-helper-textarea');
this.textarea.setAttribute('aria-label', Strings.promptLabel);
this.textarea.setAttribute('aria-multiline', 'false');
if (!Browser.isChromeOS) {
// ChromeVox on ChromeOS does not like this. See
// https://issuetracker.google.com/issues/260170397
this.textarea.setAttribute('aria-multiline', 'false');
}
this.textarea.setAttribute('autocorrect', 'off');
this.textarea.setAttribute('autocapitalize', 'off');
this.textarea.setAttribute('spellcheck', 'false');
this.textarea.tabIndex = 0;
// Register the core browser service before the generic textarea handlers are registered so it
// handles them first. Otherwise the renderers may use the wrong focus state.
this._coreBrowserService = this._instantiationService.createInstance(CoreBrowserService, this.textarea, this._document.defaultView ?? window);
this._instantiationService.setService(ICoreBrowserService, this._coreBrowserService);
this.register(addDisposableDomListener(this.textarea, 'focus', (ev: KeyboardEvent) => this._handleTextAreaFocus(ev)));
this.register(addDisposableDomListener(this.textarea, 'blur', () => this._handleTextAreaBlur()));
this._helperContainer.appendChild(this.textarea);
this._coreBrowserService = this._instantiationService.createInstance(CoreBrowserService, this.textarea, this._document.defaultView ?? window);
this._instantiationService.setService(ICoreBrowserService, this._coreBrowserService);
this._charSizeService = this._instantiationService.createInstance(CharSizeService, this._document, this._helperContainer);
this._instantiationService.setService(ICharSizeService, this._charSizeService);
@@ -995,7 +1003,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
const shouldIgnoreComposition = this.browser.isMac && this.options.macOptionIsMeta && event.altKey;
if (!shouldIgnoreComposition && !this._compositionHelper!.keydown(event)) {
if (this.buffer.ybase !== this.buffer.ydisp) {
if (this.options.scrollOnUserInput && this.buffer.ybase !== this.buffer.ydisp) {
this._bufferService.scrollToBottom();
}
return false;
@@ -1057,10 +1065,10 @@ export class Terminal extends CoreTerminal implements ITerminal {
this.coreService.triggerDataEvent(result.key, true);
// Cancel events when not in screen reader mode so events don't get bubbled up and handled by
// other listeners. When screen reader mode is enabled, this could cause issues if the event
// is handled at a higher level, this is a compromise in order to echo keys to the screen
// reader.
if (!this.optionsService.rawOptions.screenReaderMode) {
// other listeners. When screen reader mode is enabled, we don't cancel them (unless ctrl or alt
// is also depressed) so that the cursor textarea can be updated, which triggers the screen
// reader to read it.
if (!this.optionsService.rawOptions.screenReaderMode || event.altKey || event.ctrlKey) {
return this.cancel(event, true);
}
@@ -4,7 +4,7 @@
*/
import { TextureAtlas } from 'browser/renderer/shared/TextureAtlas';
import { Terminal } from 'xterm';
import { ITerminalOptions, Terminal } from 'xterm';
import { ITerminal, ReadonlyColorSet } from 'browser/Types';
import { ICharAtlasConfig, ITextureAtlas } from 'browser/renderer/shared/Types';
import { generateConfig, configEquals } from 'browser/renderer/shared/CharAtlasUtils';
@@ -22,11 +22,10 @@ const charAtlasCache: ITextureAtlasCacheEntry[] = [];
/**
* Acquires a char atlas, either generating a new one or returning an existing
* one that is in use by another terminal.
* @param terminal The terminal.
* @param colors The colors to use.
*/
export function acquireTextureAtlas(
terminal: Terminal,
options: Required<ITerminalOptions>,
colors: ReadonlyColorSet,
deviceCellWidth: number,
deviceCellHeight: number,
@@ -34,7 +33,7 @@ export function acquireTextureAtlas(
deviceCharHeight: number,
devicePixelRatio: number
): ITextureAtlas {
const newConfig = generateConfig(deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, terminal, colors, devicePixelRatio);
const newConfig = generateConfig(deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, options, colors, devicePixelRatio);
// Check to see if the terminal already owns this config
for (let i = 0; i < charAtlasCache.length; i++) {
+12 -12
View File
@@ -5,11 +5,11 @@
import { ICharAtlasConfig } from './Types';
import { Attributes } from 'common/buffer/Constants';
import { Terminal } from 'xterm';
import { ITerminalOptions } from 'xterm';
import { IColorSet, ReadonlyColorSet } from 'browser/Types';
import { NULL_COLOR } from 'common/Color';
export function generateConfig(deviceCellWidth: number, deviceCellHeight: number, deviceCharWidth: number, deviceCharHeight: number, terminal: Terminal, colors: ReadonlyColorSet, devicePixelRatio: number): ICharAtlasConfig {
export function generateConfig(deviceCellWidth: number, deviceCellHeight: number, deviceCharWidth: number, deviceCharHeight: number, options: Required<ITerminalOptions>, colors: ReadonlyColorSet, devicePixelRatio: number): ICharAtlasConfig {
// null out some fields that don't matter
const clonedColors: IColorSet = {
foreground: colors.foreground,
@@ -27,21 +27,21 @@ export function generateConfig(deviceCellWidth: number, deviceCellHeight: number
contrastCache: colors.contrastCache
};
return {
customGlyphs: terminal.options.customGlyphs,
customGlyphs: options.customGlyphs,
devicePixelRatio,
letterSpacing: terminal.options.letterSpacing,
lineHeight: terminal.options.lineHeight,
letterSpacing: options.letterSpacing,
lineHeight: options.lineHeight,
deviceCellWidth: deviceCellWidth,
deviceCellHeight: deviceCellHeight,
deviceCharWidth: deviceCharWidth,
deviceCharHeight: deviceCharHeight,
fontFamily: terminal.options.fontFamily,
fontSize: terminal.options.fontSize,
fontWeight: terminal.options.fontWeight,
fontWeightBold: terminal.options.fontWeightBold,
allowTransparency: terminal.options.allowTransparency,
drawBoldTextInBrightColors: terminal.options.drawBoldTextInBrightColors,
minimumContrastRatio: terminal.options.minimumContrastRatio,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fontWeight: options.fontWeight,
fontWeightBold: options.fontWeightBold,
allowTransparency: options.allowTransparency,
drawBoldTextInBrightColors: options.drawBoldTextInBrightColors,
minimumContrastRatio: options.minimumContrastRatio,
colors: clonedColors
};
}
+27 -5
View File
@@ -362,15 +362,31 @@ export const powerlineDefinitions: { [index: string]: IVectorShape } = {
'\u{E0B2}': { d: 'M1,0 L0,.5 L1,1', type: VectorType.FILL, leftPadding: 2 },
// Left triangle line
'\u{E0B3}': { d: 'M2,-.5 L0,.5 L2,1.5', type: VectorType.STROKE, leftPadding: 1, rightPadding: 1 },
// Right semi-circle solid,
// Right semi-circle solid
'\u{E0B4}': { d: 'M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0', type: VectorType.FILL, rightPadding: 1 },
// Right semi-circle line,
// Right semi-circle line
'\u{E0B5}': { d: 'M0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0', type: VectorType.STROKE, rightPadding: 1 },
// Left semi-circle solid,
// Left semi-circle solid
'\u{E0B6}': { d: 'M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0', type: VectorType.FILL, leftPadding: 1 },
// Left semi-circle line,
'\u{E0B7}': { d: 'M1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0', type: VectorType.STROKE, leftPadding: 1 }
// Left semi-circle line
'\u{E0B7}': { d: 'M1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0', type: VectorType.STROKE, leftPadding: 1 },
// Lower left triangle
'\u{E0B8}': { d: 'M-.5,-.5 L1.5,1.5 L-.5,1.5', type: VectorType.FILL },
// Backslash separator
'\u{E0B9}': { d: 'M-.5,-.5 L1.5,1.5', type: VectorType.STROKE, leftPadding: 1, rightPadding: 1 },
// Lower right triangle
'\u{E0BA}': { d: 'M1.5,-.5 L-.5,1.5 L1.5,1.5', type: VectorType.FILL },
// Upper left triangle
'\u{E0BC}': { d: 'M1.5,-.5 L-.5,1.5 L-.5,-.5', type: VectorType.FILL },
// Forward slash separator
'\u{E0BD}': { d: 'M1.5,-.5 L-.5,1.5', type: VectorType.STROKE, leftPadding: 1, rightPadding: 1 },
// Upper right triangle
'\u{E0BE}': { d: 'M-.5,-.5 L1.5,1.5 L1.5,-.5', type: VectorType.FILL }
};
// Backslash separator redundant
powerlineDefinitions['\u{E0BB}'] = powerlineDefinitions['\u{E0B9}'];
// Forward slash separator redundant
powerlineDefinitions['\u{E0BF}'] = powerlineDefinitions['\u{E0BD}'];
/**
* Try drawing a custom block element or box drawing character, returning whether it was
@@ -584,6 +600,11 @@ function drawPowerlineChar(
fontSize: number,
devicePixelRatio: number
): void {
// Clip the cell to make sure drawing doesn't occur beyond bounds
const clipRegion = new Path2D();
clipRegion.rect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
ctx.clip(clipRegion);
ctx.beginPath();
// Scale the stroke with DPR and font size
const cssLineWidth = fontSize / 12;
@@ -606,6 +627,7 @@ function drawPowerlineChar(
xOffset,
yOffset,
false,
devicePixelRatio,
(charDefinition.leftPadding ?? 0) * (cssLineWidth / 2),
(charDefinition.rightPadding ?? 0) * (cssLineWidth / 2)
));
+2 -1
View File
@@ -109,7 +109,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
this._instantiationService.setService(IBufferService, this._bufferService);
this._logService = this.register(this._instantiationService.createInstance(LogService));
this._instantiationService.setService(ILogService, this._logService);
this.coreService = this.register(this._instantiationService.createInstance(CoreService, () => this.scrollToBottom()));
this.coreService = this.register(this._instantiationService.createInstance(CoreService));
this._instantiationService.setService(ICoreService, this.coreService);
this.coreMouseService = this.register(this._instantiationService.createInstance(CoreMouseService));
this._instantiationService.setService(ICoreMouseService, this.coreMouseService);
@@ -129,6 +129,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
this.register(forwardEvent(this._bufferService.onResize, this._onResize));
this.register(forwardEvent(this.coreService.onData, this._onData));
this.register(forwardEvent(this.coreService.onBinary, this._onBinary));
this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom()));
this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput()));
this.register(this.optionsService.onSpecificOptionChange('windowsMode', e => this._handleWindowsModeOptionChange(e)));
this.register(this._bufferService.onScroll(event => {
+2 -2
View File
@@ -65,7 +65,7 @@ describe('InputHandler', () => {
optionsService = new MockOptionsService();
bufferService = new BufferService(optionsService);
bufferService.resize(80, 30);
coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService);
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
});
@@ -2300,7 +2300,7 @@ describe('InputHandler - async handlers', () => {
optionsService = new MockOptionsService();
bufferService = new BufferService(optionsService);
bufferService.resize(80, 30);
coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService);
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
coreService.onData(data => { console.log(data); });
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+2
View File
@@ -39,3 +39,5 @@ export const isIpad = platform === 'iPad';
export const isIphone = platform === 'iPhone';
export const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].includes(platform);
export const isLinux = platform.indexOf('Linux') >= 0;
// Note that when this is true, isLinux will also be true.
export const isChromeOS = /\bCrOS\b/.test(userAgent);
+1
View File
@@ -95,6 +95,7 @@ export class MockCoreService implements ICoreService {
public onData: IEvent<string> = new EventEmitter<string>().event;
public onUserInput: IEvent<void> = new EventEmitter<void>().event;
public onBinary: IEvent<string> = new EventEmitter<string>().event;
public onRequestScrollToBottom: IEvent<void> = new EventEmitter<void>().event;
public reset(): void { }
public triggerDataEvent(data: string, wasUserInput?: boolean): void { }
public triggerBinaryEvent(data: string): void { }
+34 -4
View File
@@ -9,7 +9,7 @@ import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { IParams } from 'common/parser/Types';
import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
import { IBufferSet } from 'common/buffer/Types';
import { UnderlineStyle } from 'common/buffer/Constants';
import { Attributes, UnderlineStyle } from 'common/buffer/Constants';
export interface ICoreTerminal {
coreMouseService: ICoreMouseService;
@@ -133,10 +133,24 @@ export interface IOscLinkData {
uri: string;
}
/** Attribute data */
/**
* An object that represents all attributes of a cell.
*/
export interface IAttributeData {
/**
* "fg" is a 32-bit unsigned integer that stores the foreground color of the cell in the 24 least
* significant bits and additional flags in the remaining 8 bits.
*/
fg: number;
/**
* "bg" is a 32-bit unsigned integer that stores the background color of the cell in the 24 least
* significant bits and additional flags in the remaining 8 bits.
*/
bg: number;
/**
* "extended", aka "ext", stores extended attributes beyond those available in fg and bg. This
* data is optional on a cell and encodes less common data.
*/
extended: IExtendedAttrs;
clone(): IAttributeData;
@@ -152,8 +166,17 @@ export interface IAttributeData {
isStrikethrough(): number;
isProtected(): number;
// color modes
/**
* The color mode of the foreground color which determines how to decode {@link getFgColor},
* possible values include {@link Attributes.CM_DEFAULT}, {@link Attributes.CM_P16},
* {@link Attributes.CM_P256} and {@link Attributes.CM_RGB}.
*/
getFgColorMode(): number;
/**
* The color mode of the background color which determines how to decode {@link getBgColor},
* possible values include {@link Attributes.CM_DEFAULT}, {@link Attributes.CM_P16},
* {@link Attributes.CM_P256} and {@link Attributes.CM_RGB}.
*/
getBgColorMode(): number;
isFgRGB(): boolean;
isBgRGB(): boolean;
@@ -163,8 +186,15 @@ export interface IAttributeData {
isBgDefault(): boolean;
isAttributeDefault(): boolean;
// colors
/**
* Gets an integer representation of the foreground color, how to decode the color depends on the
* color mode {@link getFgColorMode}.
*/
getFgColor(): number;
/**
* Gets an integer representation of the background color, how to decode the color depends on the
* color mode {@link getBgColorMode}.
*/
getBgColor(): number;
// extended attrs
-1
View File
@@ -13,7 +13,6 @@ describe('CoreService', () => {
beforeEach(() => {
coreService = new CoreService(
() => {},
new MockBufferService(80, 30),
new MockLogService(),
new MockOptionsService());
+4 -9
View File
@@ -31,26 +31,21 @@ export class CoreService extends Disposable implements ICoreService {
public modes: IModes;
public decPrivateModes: IDecPrivateModes;
// Circular dependency, this must be unset or memory will leak after Terminal.dispose
private _scrollToBottom: (() => void) | undefined;
private readonly _onData = this.register(new EventEmitter<string>());
public readonly onData = this._onData.event;
private readonly _onUserInput = this.register(new EventEmitter<void>());
public readonly onUserInput = this._onUserInput.event;
private readonly _onBinary = this.register(new EventEmitter<string>());
public readonly onBinary = this._onBinary.event;
private readonly _onRequestScrollToBottom = this.register(new EventEmitter<void>());
public readonly onRequestScrollToBottom = this._onRequestScrollToBottom.event;
constructor(
// TODO: Move this into a service
scrollToBottom: () => void,
@IBufferService private readonly _bufferService: IBufferService,
@ILogService private readonly _logService: ILogService,
@IOptionsService private readonly _optionsService: IOptionsService
) {
super();
this._scrollToBottom = scrollToBottom;
this.register({ dispose: () => this._scrollToBottom = undefined });
this.modes = clone(DEFAULT_MODES);
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
}
@@ -68,8 +63,8 @@ export class CoreService extends Disposable implements ICoreService {
// Input is being sent to the terminal, the terminal should focus the prompt.
const buffer = this._bufferService.buffer;
if (buffer.ybase !== buffer.ydisp) {
this._scrollToBottom!();
if (wasUserInput && this._optionsService.rawOptions.scrollOnUserInput && buffer.ybase !== buffer.ydisp) {
this._onRequestScrollToBottom.fire();
}
// Fire onUserInput so listeners can react as well (eg. clear selection)
+1
View File
@@ -28,6 +28,7 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
linkHandler: null,
logLevel: 'info',
scrollback: 1000,
scrollOnUserInput: true,
scrollSensitivity: 1,
screenReaderMode: false,
smoothScrollDuration: 0,
+4 -2
View File
@@ -79,15 +79,16 @@ export interface ICoreService {
readonly onData: IEvent<string>;
readonly onUserInput: IEvent<void>;
readonly onBinary: IEvent<string>;
readonly onRequestScrollToBottom: IEvent<void>;
reset(): void;
/**
* Triggers the onData event in the public API.
* @param data The data that is being emitted.
* @param wasFromUser Whether the data originated from the user (as opposed to
* @param wasUserInput Whether the data originated from the user (as opposed to
* resulting from parsing incoming data). When true this will also:
* - Scroll to the bottom of the buffer.s
* - Scroll to the bottom of the buffer if option scrollOnUserInput is true.
* - Fire the `onUserInput` event (so selection can be cleared).
*/
triggerDataEvent(data: string, wasUserInput?: boolean): void;
@@ -243,6 +244,7 @@ export interface ITerminalOptions {
rows?: number;
screenReaderMode?: boolean;
scrollback?: number;
scrollOnUserInput?: boolean;
scrollSensitivity?: number;
smoothScrollDuration?: number;
tabStopWidth?: number;