blob: cd089159b3836a3e58e897b3e1def9c69d1070de [file] [log] [blame]
/**
* @module modules/debugger-app-sk
* @description <h2><code>debugger-app-sk</code></h2>
*
*/
import { $$ } from '../../../infra-sk/modules/dom';
import 'codemirror/mode/clike/clike'; // Syntax highlighting for c-like languages.
import CodeMirror, { EditorConfiguration } from 'codemirror';
import { html, TemplateResult } from 'lit-html';
import { classMap } from 'lit-html/directives/class-map';
import { define } from '../../../elements-sk/modules/define';
import '../../../infra-sk/modules/theme-chooser-sk';
import { isDarkMode } from '../../../infra-sk/modules/theme-chooser-sk/theme-chooser-sk';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import * as SkSLConstants from '../sksl-constants/sksl-constants';
import { Convert, DebugTrace } from '../debug-trace/debug-trace';
import {
DebugTracePlayer,
VariableData,
} from '../debug-trace-player/debug-trace-player';
import '../../../infra-sk/modules/app-sk';
// It is assumed that this symbol is being provided by a version.js file loaded in before this
// file.
declare const SKIA_VERSION: string;
// Define a new mode and mime-type for SkSL shaders. We follow the shader naming
// convention found in CodeMirror.
CodeMirror.defineMIME('x-shader/x-sksl', {
name: 'clike',
keywords: SkSLConstants.keywords,
types: SkSLConstants.types,
builtin: SkSLConstants.builtins,
blockKeywords: SkSLConstants.blockKeywords,
defKeywords: SkSLConstants.defKeywords,
typeFirstDefinitions: true,
atoms: SkSLConstants.atoms,
modeProps: { fold: ['brace', 'include'] },
});
enum ErrorReporting {
Yes = 1,
No = 0,
}
export class DebuggerAppSk extends ElementSk {
private trace: DebugTrace | null = null;
private player: DebugTracePlayer = new DebugTracePlayer();
private codeMirror: CodeMirror.Editor | null = null;
private currentLineHandle: CodeMirror.LineHandle | null = null;
private currentHoveredWord: string = '';
private currentStackFrame: number = -1; // -1 corresponds to global scope
private currentLineNumber: number = 0; // 0 corresponds to no current line
private localStorage: Storage = window.localStorage; // can be overridden in tests
private queryParameter: string = window.location.search; // can be overridden in tests
constructor() {
super(DebuggerAppSk.template);
}
setLocalStorageForTest(mockStorage: Storage): void {
this.localStorage = mockStorage;
}
setQueryParameterForTest(overrideQueryParam: string): void {
this.queryParameter = overrideQueryParam;
}
private static themeFromCurrentMode(): string {
return isDarkMode() ? 'ambiance' : 'base16-light';
}
connectedCallback(): void {
super.connectedCallback();
this._render();
// Set up drag-and-drop support.
this.enableDragAndDrop($$<HTMLDivElement>('#drag-area', this)!);
// Set up CodeMirror.
const editorDiv: HTMLDivElement = $$<HTMLDivElement>('#codeEditor', this)!;
this.codeMirror = CodeMirror(editorDiv, <EditorConfiguration>{
value: '/*** Drag in a DebugTrace JSON file to start the debugger. ***/',
lineNumbers: true,
mode: 'x-shader/x-sksl',
theme: DebuggerAppSk.themeFromCurrentMode(),
viewportMargin: Infinity,
scrollbarStyle: 'native',
readOnly: true,
gutters: ['CodeMirror-linenumbers', 'cm-breakpoints'],
fixedGutter: true,
});
this.codeMirror!.on('gutterClick', (_, line: number) => {
// 'line' comes from CodeMirror so is indexed starting from zero.
this.toggleBreakpoint(line + 1);
});
editorDiv.addEventListener('mousemove', (e: MouseEvent) => {
const mousePos = { left: e.pageX, top: e.pageY };
const codePos = this.codeMirror!.coordsChar(mousePos);
const word = this.codeMirror!.findWordAt(codePos);
const hoveredWord: string = this.codeMirror!.getRange(
word.anchor,
word.head
);
if (hoveredWord != this.currentHoveredWord) {
this.currentHoveredWord = hoveredWord;
this._render();
}
});
// Listen for theme changes.
document.addEventListener('theme-chooser-toggle', () => {
this.codeMirror!.setOption('theme', DebuggerAppSk.themeFromCurrentMode());
});
// If ?local-storage(=anything), try loading a debug trace from local storage.
const params = new URLSearchParams(this.queryParameter);
if (params.has('local-storage')) {
this.loadJSONData(
this.localStorage.getItem('sksl-debug-trace')!,
ErrorReporting.No
);
// Remove ?local-storage from the query parameters on the window, so a reload or copy-paste
// will present a clean slate.
const url = new URL(window.location.toString());
url.searchParams.delete('local-storage');
window.history.pushState({}, '', url.toString());
}
}
getEditor(): CodeMirror.Editor | null {
return this.codeMirror;
}
private updateControls(): void {
this.currentStackFrame = this.player.getStackDepth() - 1;
this.currentLineNumber = this.player.getCurrentLine();
this.updateCurrentLineMarker();
this._render();
}
private updateCurrentLineMarker(): void {
if (this.currentLineHandle !== null) {
this.codeMirror!.removeLineClass(
this.currentLineHandle!,
'background',
'cm-current-line'
);
this.currentLineHandle = null;
}
if (this.currentLineNumber > 0) {
// Subtract one from the line number because CodeMirror uses zero-indexed lines.
const lineNumber = this.currentLineNumber - 1;
this.currentLineHandle = this.codeMirror!.addLineClass(
lineNumber,
'background',
'cm-current-line'
);
this.codeMirror!.scrollIntoView(
{ line: lineNumber, ch: 0 },
/* margin= */ 36
);
}
}
private arrowIfCurrentFrameIs(n: number): string {
return this.currentStackFrame == n ? 'âž” ' : '';
}
private stackDisplay(): TemplateResult[] {
let stack: string[] = [];
if (this.trace) {
stack = this.player
.getCallStack()
.map(
(funcIdx: number, frame: number) =>
this.arrowIfCurrentFrameIs(frame) +
this.trace!.functions[funcIdx].name
);
}
stack.unshift(`${this.arrowIfCurrentFrameIs(-1)}global scope`);
const result: TemplateResult[] = stack.map(
(text: string, index: number) => html` <tr>
<td>
<a
href="javascript:;"
@click=${() => this.changeStackFrame(index - 1)}
>${text}</a
>
</td>
</tr>`
);
return result.reverse();
}
private changeStackFrame(frame: number): void {
this.currentLineNumber =
frame >= 0 ? this.player.getCurrentLineInStackFrame(frame) : 0;
this.currentStackFrame = frame;
this.updateCurrentLineMarker();
this._render();
}
private varsDisplay(vars: VariableData[]): TemplateResult[] {
if (this.trace && vars.length > 0) {
return vars.map((v: VariableData) => {
const name: string = this.trace!.slots[v.slotIndex].name;
const componentName: string =
name + this.player.getSlotComponentSuffix(v.slotIndex);
const nameClass = {
'change-highlight': v.dirty,
'hover-highlight': name == this.currentHoveredWord,
};
const valueClass = {
'hover-highlight': name == this.currentHoveredWord,
};
return html` <tr>
<td class=${classMap(nameClass)}>${componentName}</td>
<td class=${classMap(valueClass)}>${v.value}</td>
</tr>`;
});
}
return [
html`<tr>
<td>&nbsp;</td>
</tr>`,
];
}
private localVarsDisplay(): TemplateResult[] {
if (this.currentStackFrame < 0) {
return [];
}
return this.varsDisplay(
this.player.getLocalVariables(this.currentStackFrame)
);
}
private globalVarsDisplay(): TemplateResult[] {
return this.varsDisplay(this.player.getGlobalVariables());
}
loadJSONData(jsonData: string, reportErrors?: ErrorReporting): void {
try {
this.trace = Convert.toDebugTrace(jsonData);
this.codeMirror!.setValue(this.trace.source.join('\n'));
this.player.setBreakpoints(new Set());
this.resetTrace();
this.resetBreakpointGutter();
this._render();
} catch (ex) {
if (reportErrors ?? ErrorReporting.Yes) {
this.codeMirror!.setValue(
ex instanceof Error ? ex.message : String(ex)
);
}
}
}
private enableDragAndDrop(dropArea: HTMLDivElement): void {
dropArea.addEventListener('dragover', (event: DragEvent) => {
event.stopPropagation();
event.preventDefault();
event.dataTransfer!.dropEffect = 'move';
});
dropArea.addEventListener('drop', (event: DragEvent) => {
event.stopPropagation();
event.preventDefault();
const fileList = event.dataTransfer!.files;
if (fileList.length === 1) {
const reader = new FileReader();
reader.addEventListener('load', () => {
try {
this.loadJSONData(reader.result as string);
} catch (ex) {
this.codeMirror!.setValue('Unable to read JSON trace file.');
}
});
reader.readAsText(fileList[0]);
}
});
}
step(): void {
this.player.step();
this.updateControls();
}
stepOver(): void {
this.player.stepOver();
this.updateControls();
}
stepOut(): void {
this.player.stepOut();
this.updateControls();
}
run(): void {
this.player.run();
this.updateControls();
}
resetTrace(): void {
this.player.reset(this.trace);
this.player.step();
this.updateControls();
}
private static makeDivWithClass(name: string): HTMLDivElement {
const marker: HTMLDivElement = document.createElement('div');
marker.classList.add(name);
return marker;
}
private resetBreakpointGutter(): void {
this.codeMirror!.clearGutter('cm-breakpoints');
this.player
.getLineNumbersReached()
.forEach((timesReached: number, line: number) => {
this.codeMirror!.setGutterMarker(
line - 1,
'cm-breakpoints',
DebuggerAppSk.makeDivWithClass('cm-reachable')
);
});
}
toggleBreakpoint(line: number): void {
// The line number is 1-indexed.
if (this.player.getBreakpoints().has(line)) {
this.player.removeBreakpoint(line);
this.codeMirror!.setGutterMarker(
line - 1,
'cm-breakpoints',
DebuggerAppSk.makeDivWithClass('cm-reachable')
);
} else if (this.player.getLineNumbersReached().has(line)) {
this.player.addBreakpoint(line);
this.codeMirror!.setGutterMarker(
line - 1,
'cm-breakpoints',
DebuggerAppSk.makeDivWithClass('cm-breakpoint')
);
} else {
// Don't allow breakpoints to be set on unreachable lines.
}
this._render();
}
private static template = (self: DebuggerAppSk): TemplateResult => html`
<app-sk id="drag-area">
<header>
<h2>SkSL Debugger</h2>
<span>
<a
id="githash"
href="https://skia.googlesource.com/skia/+show/${SKIA_VERSION}"
>
${SKIA_VERSION.slice(0, 7)}
</a>
<theme-chooser-sk></theme-chooser-sk>
</span>
</header>
<main>
<div id="debuggerControls">
<span id="buttonGroup">
<button ?disabled=${self.trace === null} @click=${self.resetTrace}>
Reset
</button>
</span>
<span id="buttonGroup">
<button ?disabled=${self.trace === null} @click=${self.stepOver}>
Step
</button>
<button ?disabled=${self.trace === null} @click=${self.step}>
Step In
</button>
<button ?disabled=${self.trace === null} @click=${self.stepOut}>
Step Out
</button>
</span>
<span id="buttonGroup">
<button ?disabled=${self.trace === null} @click=${self.run}>
${self.player.getBreakpoints().size > 0
? 'Run to Breakpoint'
: 'Run'}
</button>
</span>
</div>
<br />
<div id="debuggerPane">
<div id="codeEditor"></div>
<div id="debuggerTables">
<table>
<tr>
<td class="heading">Stack</td>
</tr>
${self.stackDisplay()}
</table>
<table>
<tr ?hidden=${self.currentStackFrame < 0}>
<td class="heading" colspan="2">Local Variables</td>
</tr>
${self.localVarsDisplay()}
<tr>
<td class="heading" colspan="2">Global Variables</td>
</tr>
${self.globalVarsDisplay()}
</table>
</div>
</div>
</main>
</app-sk>
`;
}
define('debugger-app-sk', DebuggerAppSk);