未验证 提交 bd0e1b8d 编写于 作者: C Connor Peet

Merge branch 'connor4312/typeahead-style-improvements'

......@@ -9,6 +9,7 @@ import { Emitter } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper';
import { XTermAttributes, XTermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private';
import { IBeforeProcessDataEvent, ITerminalConfiguration, ITerminalProcessManager } from 'vs/workbench/contrib/terminal/common/terminal';
import type { IBuffer, IBufferCell, ITerminalAddon, Terminal } from 'xterm';
......@@ -37,6 +38,9 @@ const statsToggleOffThreshold = 0.5; // if latency is less than `threshold * thi
*/
const PREDICTION_OMIT_RE = /^(\x1b\[\??25[hl])+/;
const core = (terminal: Terminal): XTermCore => (terminal as any)._core;
const flushOutput = (terminal: Terminal) => core(terminal).writeSync('');
const enum CursorMoveDirection {
Back = 'D',
Forwards = 'C',
......@@ -156,6 +160,13 @@ const enum MatchResult {
}
export interface IPrediction {
/**
* Whether applying this prediction can modify the style attributes of the
* terminal. If so it means we need to reset the cursor style if it's
* rolled back.
*/
readonly affectsStyle?: boolean;
/**
* Returns a sequence to apply the prediction.
* @param buffer to write to
......@@ -329,22 +340,25 @@ class TentativeBoundary implements IPrediction {
* Prediction for a single alphanumeric character.
*/
class CharacterPrediction implements IPrediction {
public readonly affectsStyle = true;
protected appliedAt?: {
pos: ICoordinate,
pos: ICoordinate;
oldAttributes: string;
oldChar: string;
};
constructor(private readonly style: string, private readonly char: string) { }
constructor(private readonly style: TypeAheadStyle, private readonly char: string) { }
public apply(buffer: IBuffer, cursor: Cursor) {
public apply(_: IBuffer, cursor: Cursor) {
const cell = cursor.getCell();
this.appliedAt = cell
? { pos: cursor.coordinate, oldAttributes: getBufferCellAttributes(cell), oldChar: cell.getChars() }
? { pos: cursor.coordinate, oldAttributes: attributesToSeq(cell), oldChar: cell.getChars() }
: { pos: cursor.coordinate, oldAttributes: '', oldChar: '' };
cursor.shift(1);
return this.style + this.char + this.appliedAt.oldAttributes;
return this.style.apply + this.char + this.style.undo;
}
public rollback(cursor: Cursor) {
......@@ -362,7 +376,7 @@ class CharacterPrediction implements IPrediction {
return ''; // not applied
}
return cursor.clone().moveTo(this.appliedAt.pos) + this.appliedAt.oldAttributes + input;
return cursor.clone().moveTo(this.appliedAt.pos) + input;
}
public matches(input: StringReader) {
......@@ -384,20 +398,32 @@ class CharacterPrediction implements IPrediction {
}
}
class BackspacePrediction extends CharacterPrediction {
constructor() {
super('', '\b');
}
class BackspacePrediction implements IPrediction {
protected appliedAt?: {
pos: ICoordinate;
oldAttributes: string;
oldChar: string;
};
public apply(_: IBuffer, cursor: Cursor) {
const cell = cursor.getCell();
this.appliedAt = cell
? { pos: cursor.coordinate, oldAttributes: getBufferCellAttributes(cell), oldChar: cell.getChars() }
? { pos: cursor.coordinate, oldAttributes: attributesToSeq(cell), oldChar: cell.getChars() }
: { pos: cursor.coordinate, oldAttributes: '', oldChar: '' };
return cursor.shift(-1) + DELETE_CHAR;
}
public rollback(cursor: Cursor) {
if (!this.appliedAt) {
return ''; // not applied
}
const { oldAttributes, oldChar, pos } = this.appliedAt;
const r = cursor.moveTo(pos) + (oldChar ? `${oldAttributes}${oldChar}${cursor.moveTo(pos)}` : DELETE_CHAR);
return r;
}
public rollForwards() {
return '';
}
......@@ -483,7 +509,7 @@ class CursorMovePrediction implements IPrediction {
public apply(buffer: IBuffer, cursor: Cursor) {
const prevPosition = cursor.x;
const currentCell = cursor.getCell();
const prevAttrs = currentCell ? getBufferCellAttributes(currentCell) : '';
const prevAttrs = currentCell ? attributesToSeq(currentCell) : '';
const { amount, direction, moveByWords } = this;
const delta = direction === CursorMoveDirection.Back ? -1 : 1;
......@@ -650,7 +676,11 @@ export class PredictionTimeline {
private readonly succeededEmitter = new Emitter<IPrediction>();
public readonly onPredictionSucceeded = this.succeededEmitter.event;
constructor(public readonly terminal: Terminal) { }
public get isShowingPredictions() {
return this.showPredictions;
}
constructor(public readonly terminal: Terminal, private readonly style: TypeAheadStyle) { }
public setShowPredictions(show: boolean) {
if (show === this.showPredictions) {
......@@ -668,6 +698,7 @@ export class PredictionTimeline {
const toApply = this.expected.filter(({ gen }) => gen === this.expected[0].gen).map(({ p }) => p);
if (show) {
this.cursor = undefined;
this.style.expectIncomingStyle(toApply.reduce((count, p) => p.affectsStyle ? count + 1 : count, 0));
this.terminal.write(toApply.map(p => p.apply(buffer, this.getCursor(buffer))).join(''));
} else {
this.terminal.write(toApply.reverse().map(p => p.rollback(this.getCursor(buffer))).join(''));
......@@ -729,10 +760,13 @@ export class PredictionTimeline {
case MatchResult.Failure:
// on a failure, roll back all remaining items in this generation
// and clear predictions, since they are no longer valid
output += this.expected.filter(p => p.gen === startingGen)
.reverse()
.map(({ p }) => p.rollback(this.getCursor(buffer)))
.join('');
const rollback = this.expected.filter(p => p.gen === startingGen).reverse();
output += rollback.map(({ p }) => p.rollback(this.getCursor(buffer))).join('');
if (rollback.some(r => r.p.affectsStyle)) {
// reading the current style should generally be safe, since predictions
// always restore the style if they modify it.
output += attributesToSeq(core(this.terminal)._inputHandler._curAttrData);
}
this.expected = [];
this.cursor = undefined;
this.failedEmitter.fire(prediction);
......@@ -756,6 +790,9 @@ export class PredictionTimeline {
if (gen !== this.expected[0].gen) {
break;
}
if (p.affectsStyle) {
this.style.expectIncomingStyle();
}
output += p.apply(buffer, this.getCursor(buffer));
}
......@@ -788,7 +825,10 @@ export class PredictionTimeline {
if (this.currentGen === this.expected[0].gen) {
const text = prediction.apply(buffer, this.getCursor(buffer));
if (this.showPredictions) {
if (this.showPredictions && text) {
if (prediction.affectsStyle) {
this.style.expectIncomingStyle();
}
// console.log('predict:', JSON.stringify(text));
this.terminal.write(text);
}
......@@ -804,13 +844,27 @@ export class PredictionTimeline {
* after this one will only be displayed after the give prediction matches
* pty output/
*/
public addBoundary(buffer: IBuffer, prediction: IPrediction) {
this.addPrediction(buffer, prediction);
public addBoundary(): void;
public addBoundary(buffer: IBuffer, prediction: IPrediction): void;
public addBoundary(buffer?: IBuffer, prediction?: IPrediction) {
if (buffer && prediction) {
this.addPrediction(buffer, prediction);
}
this.currentGen++;
}
/**
* Peeks the last prediction written.
*/
public peekEnd() {
return this.expected[this.expected.length - 1]?.p;
}
public getCursor(buffer: IBuffer) {
if (!this.cursor) {
if (this.showPredictions) {
flushOutput(this.terminal);
}
this.cursor = new Cursor(this.terminal.rows, this.terminal.cols, buffer);
}
......@@ -829,7 +883,7 @@ export class PredictionTimeline {
/**
* Gets the escape sequence to restore state/appearence in the cell.
*/
const getBufferCellAttributes = (cell: IBufferCell) => cell.isAttributeDefault()
const attributesToSeq = (cell: XTermAttributes) => cell.isAttributeDefault()
? `${CSI}0m`
: [
cell.isBold() && `${CSI}1m`,
......@@ -849,26 +903,179 @@ const getBufferCellAttributes = (cell: IBufferCell) => cell.isAttributeDefault()
cell.isBgDefault() && `${CSI}49m`,
].filter(seq => !!seq).join('');
const parseTypeheadStyle = (style: ITerminalConfiguration['typeaheadStyle']) => {
switch (style) {
case 'bold':
return `${CSI}1m`;
case 'dim':
return `${CSI}2m`;
case 'italic':
return `${CSI}3m`;
case 'underlined':
return `${CSI}4m`;
case 'inverted':
return `${CSI}7m`;
default:
const { r, g, b } = Color.fromHex(style).rgba;
return `${CSI}38;2;${r};${g};${b}m`;
const arrayHasPrefixAt = <T>(a: ReadonlyArray<T>, ai: number, b: ReadonlyArray<T>) => {
if (a.length - ai > b.length) {
return false;
}
for (let bi = 0; bi < b.length; bi++, ai++) {
if (b[ai] !== a[ai]) {
return false;
}
}
return true;
};
/**
* @see https://github.com/xtermjs/xterm.js/blob/065eb13a9d3145bea687239680ec9696d9112b8e/src/common/InputHandler.ts#L2127
*/
const getColorWidth = (params: (number | number[])[], pos: number) => {
const accu = [0, 0, -1, 0, 0, 0];
let cSpace = 0;
let advance = 0;
do {
const v = params[pos + advance];
accu[advance + cSpace] = typeof v === 'number' ? v : v[0];
if (typeof v !== 'number') {
let i = 0;
do {
if (accu[1] === 5) {
cSpace = 1;
}
accu[advance + i + 1 + cSpace] = v[i];
} while (++i < v.length && i + advance + 1 + cSpace < accu.length);
break;
}
// exit early if can decide color mode with semicolons
if ((accu[1] === 5 && advance + cSpace >= 2)
|| (accu[1] === 2 && advance + cSpace >= 5)) {
break;
}
// offset colorSpace slot for semicolon mode
if (accu[1]) {
cSpace = 1;
}
} while (++advance + pos < params.length && advance + cSpace < accu.length);
return advance;
};
class TypeAheadStyle {
private static compileArgs(args: ReadonlyArray<number>) {
return `${CSI}${args.join(';')}m`;
}
/**
* Number of typeahead style arguments we expect to read. If this is 0 and
* we see a style coming in, we know that the PTY actually wanted to update.
*/
private expectedIncomingStyles = 0;
private applyArgs!: ReadonlyArray<number>;
private originalUndoArgs!: ReadonlyArray<number>;
private undoArgs!: ReadonlyArray<number>;
public apply!: string;
public undo!: string;
constructor(value: ITerminalConfiguration['typeaheadStyle']) {
this.onUpdate(value);
}
/**
* Signals that a style was written to the terminal and we should watch
* for it coming in.
*/
public expectIncomingStyle(n = 1) {
this.expectedIncomingStyles += n * 2;
}
/**
* Should be called when an attribut eupdate happens in the terminal.
*/
public onDidWriteSGR(args: (number | number[])[]) {
const originalUndo = this.undoArgs;
for (let i = 0; i < args.length;) {
const px = args[i];
const p = typeof px === 'number' ? px : px[0];
if (this.expectedIncomingStyles) {
if (arrayHasPrefixAt(args, i, this.undoArgs)) {
this.expectedIncomingStyles--;
i += this.undoArgs.length;
continue;
}
if (arrayHasPrefixAt(args, i, this.applyArgs)) {
this.expectedIncomingStyles--;
i += this.applyArgs.length;
continue;
}
}
const width = p === 38 || p === 48 || p === 58 ? getColorWidth(args, i) : 1;
switch (this.applyArgs[0]) {
case 1:
if (p === 2) {
this.undoArgs = [22, 2];
} else if (p === 22 || p === 0) {
this.undoArgs = [22];
}
break;
case 2:
if (p === 1) {
this.undoArgs = [22, 1];
} else if (p === 22 || p === 0) {
this.undoArgs = [22];
}
break;
case 38:
if (p === 0 || p === 39 || p === 100) {
this.undoArgs = [39];
} else if ((p >= 30 && p <= 38) || (p >= 90 && p <= 97)) {
this.undoArgs = args.slice(i, i + width) as number[];
}
break;
default:
if (p === this.applyArgs[0]) {
this.undoArgs = this.applyArgs;
} else if (p === 0) {
this.undoArgs = this.originalUndoArgs;
}
// no-op
}
i += width;
}
if (originalUndo !== this.undoArgs) {
this.undo = TypeAheadStyle.compileArgs(this.undoArgs);
}
}
/**
* Updates the current typeahead style.
*/
public onUpdate(style: ITerminalConfiguration['typeaheadStyle']) {
const { applyArgs, undoArgs } = this.getArgs(style);
this.applyArgs = applyArgs;
this.undoArgs = this.originalUndoArgs = undoArgs;
this.apply = TypeAheadStyle.compileArgs(this.applyArgs);
this.undo = TypeAheadStyle.compileArgs(this.undoArgs);
}
private getArgs(style: ITerminalConfiguration['typeaheadStyle']) {
switch (style) {
case 'bold':
return { applyArgs: [1], undoArgs: [22] };
case 'dim':
return { applyArgs: [2], undoArgs: [22] };
case 'italic':
return { applyArgs: [3], undoArgs: [23] };
case 'underlined':
return { applyArgs: [4], undoArgs: [24] };
case 'inverted':
return { applyArgs: [7], undoArgs: [27] };
default:
const { r, g, b } = Color.fromHex(style).rgba;
return { applyArgs: [38, 2, r, g, b], undoArgs: [39] };
}
}
}
export class TypeAheadAddon extends Disposable implements ITerminalAddon {
private typeheadStyle = parseTypeheadStyle(this.config.config.typeaheadStyle);
private typeaheadStyle = new TypeAheadStyle(this.config.config.typeaheadStyle);
private typeaheadThreshold = this.config.config.typeaheadThreshold;
private lastRow?: { y: number; startingX: number };
private timeline?: PredictionTimeline;
......@@ -883,10 +1090,14 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon {
}
public activate(terminal: Terminal): void {
const timeline = this.timeline = new PredictionTimeline(terminal);
const timeline = this.timeline = new PredictionTimeline(terminal, this.typeaheadStyle);
const stats = this.stats = this._register(new PredictionStats(this.timeline));
timeline.setShowPredictions(this.typeaheadThreshold === 0);
this._register(terminal.parser.registerCsiHandler({ final: 'm' }, args => {
this.typeaheadStyle.onDidWriteSGR(args);
return false;
}));
this._register(terminal.onData(e => this.onUserData(e)));
this._register(terminal.onResize(() => {
timeline.setShowPredictions(false);
......@@ -894,7 +1105,7 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon {
this.reevaluatePredictorState(stats, timeline);
}));
this._register(this.config.onConfigChanged(() => {
this.typeheadStyle = parseTypeheadStyle(this.config.config.typeaheadStyle);
this.typeaheadStyle.onUpdate(this.config.config.typeaheadStyle);
this.typeaheadThreshold = this.config.config.typeaheadThreshold;
this.reevaluatePredictorState(stats, timeline);
}));
......@@ -989,13 +1200,24 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon {
const reader = new StringReader(data);
while (reader.remaining > 0) {
if (reader.eatCharCode(127)) { // backspace
const previous = this.timeline.peekEnd();
if (previous && previous instanceof CharacterPrediction) {
this.timeline.addBoundary();
}
// backspace must be able to read the previously-written character in
// the event that it needs to undo it
if (this.timeline.isShowingPredictions) {
flushOutput(this.timeline.terminal);
}
addLeftNavigating(new BackspacePrediction());
continue;
}
if (reader.eatCharCode(32, 126)) { // alphanum
const char = data[reader.index - 1];
if (this.timeline.addPrediction(buffer, new CharacterPrediction(this.typeheadStyle, char)) && this.timeline.getCursor(buffer).x === terminal.cols) {
if (this.timeline.addPrediction(buffer, new CharacterPrediction(this.typeaheadStyle, char)) && this.timeline.getCursor(buffer).x === terminal.cols) {
this.timeline.addBoundary(buffer, new TentativeBoundary(new LinewrapPrediction()));
}
continue;
......
......@@ -3,6 +3,10 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IBufferCell } from 'xterm';
export type XTermAttributes = Omit<IBufferCell, 'getWidth' | 'getChars' | 'getCode'> & { clone?(): XTermAttributes };
export interface XTermCore {
_onScroll: IEventEmitter<number>;
_onKey: IEventEmitter<{ key: string }>;
......@@ -16,6 +20,10 @@ export interface XTermCore {
triggerDataEvent(data: string, wasUserInput?: boolean): void;
};
_inputHandler: {
_curAttrData: XTermAttributes;
};
_renderService: {
dimensions: {
actualCellWidth: number;
......
......@@ -77,7 +77,7 @@ suite('Workbench - Terminal Typeahead', () => {
const predictedHelloo = [
`${CSI}?25l`, // hide cursor
`${CSI}2;7H`, // move cursor cursor
`${CSI}2;7H`, // move cursor
'o', // new character
`${CSI}2;8H`, // place cursor back at end of line
`${CSI}?25h`, // show cursor
......@@ -107,14 +107,14 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('predicts a single character', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('o');
t.expectWritten(`${CSI}3mo`);
t.expectWritten(`${CSI}3mo${CSI}23m`);
});
test('validates character prediction', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('o');
expectProcessed('o', predictedHelloo);
......@@ -122,7 +122,7 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('rolls back character prediction', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('o');
......@@ -130,6 +130,27 @@ suite('Workbench - Terminal Typeahead', () => {
`${CSI}?25l`, // hide cursor
`${CSI}2;7H`, // move cursor cursor
`${CSI}X`, // delete character
`${CSI}0m`, // reset style
'q', // new character
`${CSI}?25h`, // show cursor
].join(''));
assert.strictEqual(addon.stats?.accuracy, 0);
});
test('restores cursor graphics mode', () => {
const t = createMockTerminal({
lines: ['hello|'],
cursorAttrs: { isAttributeDefault: false, isBold: true, isFgPalette: true, getFgColor: 1 },
});
addon.activate(t.terminal);
t.onData('o');
expectProcessed('q', [
`${CSI}?25l`, // hide cursor
`${CSI}2;7H`, // move cursor cursor
`${CSI}X`, // delete character
`${CSI}1m`, // reset style
`${CSI}38;5;1m`, // reset style
'q', // new character
`${CSI}?25h`, // show cursor
].join(''));
......@@ -137,13 +158,13 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('validates against and applies graphics mode on predicted', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('o');
expectProcessed(`${CSI}4mo`, [
`${CSI}?25l`, // hide cursor
`${CSI}2;7H`, // move cursor cursor
`${CSI}4m`, // PTY's style
`${CSI}2;7H`, // move cursor
`${CSI}4m`, // new PTY's style
'o', // new character
`${CSI}2;8H`, // place cursor back at end of line
`${CSI}?25h`, // show cursor
......@@ -152,13 +173,13 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('ignores cursor hides or shows', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('o');
expectProcessed(`${CSI}?25lo${CSI}?25h`, [
`${CSI}?25l`, // hide cursor from PTY
`${CSI}?25l`, // hide cursor
`${CSI}2;7H`, // move cursor cursor
`${CSI}2;7H`, // move cursor
'o', // new character
`${CSI}?25h`, // show cursor from PTY
`${CSI}2;8H`, // place cursor back at end of line
......@@ -168,7 +189,7 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('matches backspace at EOL (bash style)', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('\x7F');
expectProcessed(`\b${CSI}K`, `\b${CSI}K`);
......@@ -176,7 +197,7 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('matches backspace at EOL (zsh style)', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('\x7F');
expectProcessed('\b \b', '\b \b');
......@@ -184,7 +205,7 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('gradually matches backspace', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
t.onData('\x7F');
expectProcessed('\b', '');
......@@ -193,7 +214,7 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('waits for validation before deleting to left of cursor', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
// initially should not backspace (until the server confirms it)
......@@ -214,7 +235,7 @@ suite('Workbench - Terminal Typeahead', () => {
});
test('avoids predicting password input', () => {
const t = createMockTerminal('hello|');
const t = createMockTerminal({ lines: ['hello|'] });
addon.activate(t.terminal);
expectProcessed('Your password: ', 'Your password: ');
......@@ -223,7 +244,7 @@ suite('Workbench - Terminal Typeahead', () => {
expectProcessed('\r\n', '\r\n');
t.onData('o'); // back to normal mode
t.expectWritten(`${CSI}3mo`);
t.expectWritten(`${CSI}3mo${CSI}23m`);
});
});
});
......@@ -245,10 +266,14 @@ function stubPrediction(): IPrediction {
};
}
function createMockTerminal(...lines: string[]) {
function createMockTerminal({ lines, cursorAttrs }: {
lines: string[],
cursorAttrs?: any,
}) {
const written: string[] = [];
const cursor = { y: 1, x: 1 };
const onData = new Emitter<string>();
const csiEmitter = new Emitter<number[]>();
for (let y = 0; y < lines.length; y++) {
const line = lines[y];
......@@ -269,14 +294,28 @@ function createMockTerminal(...lines: string[]) {
},
clearWritten: () => written.splice(0, written.length),
onData: (s: string) => onData.fire(s),
csiEmitter,
terminal: {
cols: 80,
rows: 5,
onResize: new Emitter<void>().event,
onData: onData.event,
parser: {
registerCsiHandler(_: unknown, callback: () => void) {
csiEmitter.event(callback);
},
},
write(line: string) {
written.push(line);
},
_core: {
_inputHandler: {
_curAttrData: mockCell('', cursorAttrs)
},
writeSync() {
}
},
buffer: {
active: {
type: 'normal',
......@@ -296,9 +335,13 @@ function createMockTerminal(...lines: string[]) {
};
}
function mockCell(char: string) {
function mockCell(char: string, attrs: { [key: string]: unknown } = {}) {
return new Proxy({}, {
get(_, prop) {
if (typeof prop === 'string' && attrs.hasOwnProperty(prop)) {
return () => attrs[prop];
}
switch (prop) {
case 'getWidth':
return () => 1;
......@@ -306,6 +349,8 @@ function mockCell(char: string) {
return () => char;
case 'getCode':
return () => char.charCodeAt(0) || 0;
case 'isAttributeDefault':
return () => true;
default:
return String(prop).startsWith('is') ? (() => false) : (() => 0);
}
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册