blob: 9eadc35acbae70a02810b609bbdc8952ca1f9757 [file] [log] [blame]
/**
* @module modules/commands-sk
* @description A list view of draw commands for a single frame, and a tool for
* filtering them. contains the logic for processing the parsed json object from
* wasm, and extracting things like layer info and command counts which drive
* other modules.
*
* Contains play-sk as a submodule, which playes over the filtered list of
* commands.
*
* Data flows along this path in one direction depending on which end triggers a
* change.
* filter text box <=> this._includedSet <=> histogram-sk
*
* @evt histogram-update: An event containing the list of histogram entries.
* Emitted every time the histogram is recomputed.
*
* @evt move-command-position: When the play-sk module or user selects a different
* command, this event is emitted, and it's detail contains the command index in
* the unfiltered command list for this frame.
*
*/
import { define } from 'elements-sk/define';
import { html, TemplateResult } from 'lit-html';
import { ElementDocSk } from '../element-doc-sk/element-doc-sk';
import { PlaySk, PlaySkMoveToEventDetail } from '../play-sk/play-sk';
import { HistogramSkToggleEventDetail } from '../histogram-sk/histogram-sk'
import { DefaultMap } from '../default-map';
import 'elements-sk/icon/save-icon-sk';
import 'elements-sk/icon/content-copy-icon-sk';
import 'elements-sk/icon/image-icon-sk';
import { SkpJsonCommandList, SkpJsonCommand, SkpJsonAuditTrail, SkpJsonGpuOp } from '../debugger';
import '../play-sk';
export interface CommandsSkMovePositionEventDetail {
// the index of a command in the frame to which the wasm view should move.
position: number,
// true if we're currently paused
paused: boolean,
}
export type CommandRange = [number, number];
// Represents one of the icons that can appear on a command
export interface PrefixItem {
icon: string,
color: string,
count: number,
};
/** A processed command object, created from a SkpJsonCommand */
export interface Command {
// Index of the command in the unfiltered list.
index: number,
// the save/restore depth before this command is executed.
depth: number,
// the parsed json representation of the command. exact type depends on the command.
details: SkpJsonCommand,
name: string,
// if this command is one of an indenting pair, the command index range that the pair enclose
// (save, restore)
range?: CommandRange,
prefixes: PrefixItem[],
// Whether the command will be executed during playback
visible: boolean,
// index of any image referenced by this command
imageIndex?: number,
};
/** An entry of the command histogram
* obtained by totalling up occurances in the range filtered command list
*/
export interface HistogramEntry {
// name of a command (original CamelCase)
name: string,
// number of occurances in the current frame (or the whole file for a single-frame SKP)
countInFrame: number,
// number of occurances in the current range filter
countInRange: number,
}
/** An event detail containing a new histogram
* or new filter set to be displayed by the histogram-sk module.
* The event may update one or both of the two fields.
*/
export interface CommandsSkHistogramEventDetail {
/** A newly computed histogram that needs to be displayed by histogram-sk */
hist?: HistogramEntry[];
/** whether the command is include by the filter */
included?: Set<string>;
}
// Information about layers collected by processCommands.
// TODO(nifong): This could be collected in the C++ and returned from
// getLayerSummaries and then commands-sk wouldn't have to be involved
// with layer things at all.
export interface LayerInfo {
// A Map from layer ids to command indices where they were drawn
// with a DrawImageRectLayer command. Includes only layer used this frame
uses: DefaultMap<number, number[]>;
// A map from layer ids to names that were provided in the render node annotations.
// This should be sufficient for it to always contain what we attempt to look up.
// Only valid for the duration of this frame.
names: Map<number, string>;
}
// Jumpting to a command by it's unfiltered index can be done by emitting
// 'jump-command' with this event detail
export interface CommandsSkJumpEventDetail {
unfilteredIndex: number;
}
// event issued when the user clicks 'Image' to jump to this image with this id.
export interface CommandsSkSelectImageEventDetail {
id: number;
}
// Colors to use for gpu op ids
const COLORS = [
"#1B9E77",
"#D95F02",
"#7570B3",
"#E7298A",
"#66A61E",
"#E6AB02",
"#A6761D",
"#666666",
"#09c5d2",
"#064f77",
"#3a4ce4",
"#d256f0",
"#feb4c7",
"#fa3029",
"#ff6821",
"#a8ff21",
"#a5cf80",
"#36d511",
"#95f19c",
];
// Commands that increase save/restore depth
const INDENTERS: {[key: string]: PrefixItem} = {
'Save': { icon: 'save-icon-sk', color: '#B2DF8A', count: 1 },
'SaveLayer': { icon: 'content-copy-icon-sk', color: '#FDBF6F', count: 1 },
'BeginDrawPicture': { icon: 'image-icon-sk', color: '#A6CEE3', count: 1 },
};
// commands that decrease save/restore depth
const OUTDENTERS: string[] = ['Restore', 'EndDrawPicture'];
export class CommandsSk extends ElementDocSk {
private static template = (ele: CommandsSk) =>
html`
<div>
${CommandsSk.filterTemplate(ele)}
<div class="horizontal-flex">
<button @click=${ele._opIdFilter} class="short">Show By Op-Id</button>
<play-sk .visual=${'full'}></play-sk>
</div>
<div class="list">
${ ele._filtered.map((i: number, filtPos: number) =>
CommandsSk.opTemplate(ele, filtPos, ele._cmd[i])) }
</div>
</div>`;
private static opTemplate = (ele: CommandsSk, filtpos: number, op: Command) =>
html`<div class="op" id="op-${op.index}" @click=${
(e: MouseEvent) => {ele._clickItem(e, filtpos)}}>
<details>
<summary class="command-summary ${ ele.position == op.index ? 'selected' : ''}">
<div class="command-icons-group">
<span class="index">${op.index}</span>
${ op.prefixes.map((pre: PrefixItem) =>
CommandsSk.prefixItemTemplate(ele, pre)) }
</div>
<div class="command-title">${ op.name }</div>
<code class="short-desc">${ op.details.shortDesc }</code>
${ op.range
? html`<button @click=${() => {ele.range = op.range!}}
title="Range-filter the command list to this save/restore pair">Zoom</button>`
: ''
}
${ op.imageIndex
? html`<button @click=${()=>{ele._jumpToImage(op.imageIndex!)}}
title="Show the image referenced by this command in the resource viewer"
>Image</button>`
: ''
}
<div class="gpu-ops-group">
${ (op.details.auditTrail && op.details.auditTrail.Ops)
? op.details.auditTrail.Ops.map((gpuOp: SkpJsonGpuOp) =>
CommandsSk.gpuOpIdTemplate(ele, gpuOp) )
: ''
}
</div>
</summary>
<div>
<checkbox-sk title="Toggle command visibility" checked=${ op.visible }
@change=${ele._toggleVisible(op.index)}></checkbox-sk>
<strong>Index: </strong> <span class=index>${op.index}</span>
</div>
${ele._renderRullOpRepresentation(ele, op)}
</details>
</div>
<hr>`;
private static prefixItemTemplate = (ele: CommandsSk, item: PrefixItem) =>
html`${ ele._icon(item) }
${ item.count > 1
? html`<div title="depth of indenting operation"
class=count>${ item.count }</div>`
: ''
}`;
private static gpuOpIdTemplate = (ele: CommandsSk, gpuOp: SkpJsonGpuOp) =>
html`<span title="GPU Op ID - group of commands this was executed with on the GPU"
class="gpu-op-id" style="background: ${ ele._gpuOpColor(gpuOp.OpsTaskID) }"
>${ gpuOp.OpsTaskID }</span>`;
private static filterTemplate = (ele: CommandsSk) =>
html`
<div class="horizontal-flex">
<label title="Filter command names (Single leading ! negates entire filter).
Command types can also be filted by clicking on their names in the histogram"
>Filter</label>
<input @change=${ele._textFilter} value="!DrawAnnotation"
id="text-filter"></input>&nbsp;
<label>Range</label>
<input @change=${ele._rangeInputHandler} class=range-input value="${ ele._range[0] }"
id="rangelo"></input>
<b>:</b>
<input @change=${ele._rangeInputHandler} class=range-input value="${ ele._range[1] }"
id="rangehi"></input>
<button @click=${ele.clearFilter} id="clear-filter-button">Clear</button>
</div>`;
// processed command list (no filtering applied). change with processCommands
private _cmd: Command[] = [];
// list of indices of commands that passed the range and name filters.
private _filtered: number[] = [];
// position in filtered (visible) command list
private _item: number = 0;
// range filter
private _range: CommandRange = [0, 100];
// counts of command occurances
private _histogram: HistogramEntry[] = [];
// known command names (set by processCommands) names are lowercased.
private _available = new Set<string>();
// subset of command names that should pass the command filter
// (names are lowercased)
private _includedSet = new Set<string>();
// Play bar submodule
private _playSk: PlaySk | null = null;
// information about layers collected from commands
private _layerInfo: LayerInfo = {
uses: new DefaultMap<number, number[]>(() => []),
names: new Map<number, string>(),
};
// the command count with no filtering
get count() {
return this._cmd.length;
}
// the command count with all filters applied
get countFiltered() {
return this._filtered.length;
}
get layerInfo(): LayerInfo {
return this._layerInfo;
}
// set the current playback position in the list
// (index in filtered list)
set item(i: number) {
this._item = i;
this.querySelector<HTMLDivElement>('#op-' + this._filtered[this._item]
)?.scrollIntoView({block: 'nearest'});
this._render();
// notify debugger-page-sk that it needs to draw this.position
this.dispatchEvent(
new CustomEvent<CommandsSkMovePositionEventDetail>(
'move-command-position', {
detail: {position: this.position, paused: this._playSk!.mode === 'pause'},
bubbles: true,
}));
this._playSk!.movedTo(this._item);
}
// get the playback index in _cmd after filtering is applied.
get position() {
return this._filtered[this._item];
}
set range(range: CommandRange) {
this._range = range;
this._applyRangeFilter();
}
set textFilter(q: string) {
this.querySelector<HTMLInputElement>('#text-filter')!.value = q;
if (!this.count) { return; }
this._textFilter(); // does render
}
// Return a list of op indices that pass the current filters.
get filtered(): number[] {
return this._filtered;
}
constructor() {
super(CommandsSk.template);
}
connectedCallback() {
super.connectedCallback();
this._render();
this._playSk = this.querySelector<PlaySk>('play-sk')!;
this._playSk.addEventListener('moveto', (e) => {
this.item = (e as CustomEvent<PlaySkMoveToEventDetail>).detail.item;
});
this.addDocumentEventListener('toggle-command-inclusion', (e) => {
this._toggleName((e as CustomEvent<HistogramSkToggleEventDetail>).detail.name);
});
// Jump to a command by it's unfiltered index.
this.addDocumentEventListener('jump-command', (e) => {
const i = (e as CustomEvent<CommandsSkJumpEventDetail>).detail.unfilteredIndex;
const filteredIndex = this._filtered.findIndex(e => e==i);
if (filteredIndex !== undefined) {
this.item = filteredIndex;
}
});
}
// _processCommands iterates over the commands to extract several things.
// * A depth at every command based on Save/Restore pairs.
// * A histogram showing how many times each type of command is used.
// * A map from layer node ids to the index of any layer use events in the command list.
// * The full set of command names that occur
processCommands(cmd: SkpJsonCommandList) {
const commands: Command[] = [];
let depth = 0;
const prefixes: PrefixItem[] = []; // A stack of indenting commands
// Match up saves and restores, a stack of indices
const matchup: number[] = [];
// All command types that occur in this frame
this._available = new Set<string>();
interface tally {
count_in_frame: number,
count_in_range_filter: number,
}
this._layerInfo.uses = new DefaultMap<number, number[]>(() => []);
this._layerInfo.names = new Map<number, string>();
// Finds things like "RenderNode(id=10, name='DecorView')"
const renderNodeRe = /^RenderNode\(id=([0-9]+), name='([A-Za-z0-9_]+)'\)/;
cmd.commands.forEach((com, i) => {
const name = com.command;
this._available.add(name.toLowerCase());
const out: Command = {
index: i,
depth: depth,
details: com, // unaltered object from json
name: name,
prefixes: [],
visible: true,
};
// DrawCommand.cpp will write this field if the command references an image
if (com.imageIndex) {
out.imageIndex = com.imageIndex;
}
if (name in INDENTERS) {
depth++;
matchup.push(i);
// If this is the same type of indenting op we've already seen
// then just increment the count, otherwise add as a new
// op in prefixes.
if (depth > 1 && prefixes[prefixes.length-1].icon
== INDENTERS[name].icon) {
prefixes[prefixes.length-1].count++;
} else {
prefixes.push(this._copyPrefix(INDENTERS[name]));
}
} else if (OUTDENTERS.indexOf(name) !== -1) {
depth--;
// Now that we can match an OUTDENTER with an INDENTER we can set
// the _zoom property for both commands.
const begin: number = matchup.pop()!;
const range = [begin, i] as CommandRange;
out.range = range;
commands[begin].range = range;
// Only pop the op from prefixes if its count has reached 1.
if (prefixes[prefixes.length-1].count > 1) {
prefixes[prefixes.length-1].count--;
} else {
prefixes.pop();
}
out.depth = depth;
} else if (name === 'DrawImageRectLayer') {
// A command indicating that a render node with an offscreen buffer (android only)
// was drawn as an image.
const node = com.layerNodeId!;
this._layerInfo.uses.get(node).push(i);
} else if (name === 'DrawAnnotation') {
// DrawAnnotation is a bit of metadata added by the android view system.
// All render nodes have names, but not all of them are drawn with offscreen buffers
const annotationKey = com.key;
const found = com.key!.match(renderNodeRe);
if (found) {
// group 1 is the render node id
// group 2 is the name of the rendernode.
this._layerInfo.names.set(parseInt(found[1]), found[2]);
}
}
// deep copy prefixes because we want a snapshot of the current list and counts
out.prefixes = prefixes.map((p: PrefixItem) => this._copyPrefix(p));
commands.push(out);
});
this._cmd = commands;
this.range = [0, this._cmd.length-1]; // this assignment also triggers render
}
// User clicked the clear filter button, clear both filters
clearFilter() {
this.querySelector<HTMLInputElement>('#text-filter')!.value = '';
if (!this.count) { return; }
this.range = [0, this._cmd.length-1]; // setter triggers _applyRangeFilter, follow that
}
// Stop playback and move by a given offset in the filtered list.
keyMove(offset: number) {
this._playSk!.mode = 'pause';
this.item = Math.max(0, Math.min(this._item + offset, this.countFiltered));
}
end() {
this.item = this._filtered.length - 1;
}
private _clickItem(e: MouseEvent, filtIndex: number) {
if (this._item !== filtIndex) {
// Don't open the dropdown unless you click the already selected item again
e.preventDefault();
}
this.item = filtIndex;
}
// filter change coming from histogram
private _toggleName(name: string) {
const lowerName = name.toLowerCase();
if (!this._available.has(lowerName)) {
return;
}
if (this._includedSet.has(lowerName)) {
this._includedSet.delete(lowerName);
} else {
this._includedSet.add(lowerName);
}
// represent _includedSet as a negative text filter and put it in the box
const diff = new Set(this._available);
for (let c of this._includedSet) {
diff.delete(c)
}
let filter = '';
if (diff.size > 0) {
filter = '!'+Array.from(diff).join(' ');
}
this.querySelector<HTMLInputElement>('#text-filter')!.value = filter;
// don't trigger _textFilter() since that would send an event back to histogram and
// start an infinite loop. this._includedSet is correct, apply it and render.
this._applyCommandFilter();
}
// Returns a JSON string representation of the command, augmentend with visually rich
// or interactive elements for certain types.
private _renderRullOpRepresentation(ele: CommandsSk, op: Command) {
// Use json.stringify's replacer feature to replace certain objects.
// we would like to replace them directly with html templates, but json.stringify
// toStrings them, so instead replace them with a magic string and add the template
// to a list, then replace those magic strings with items from the list afterwards.
// An unlikely string meaning 'insert html template here'
const magic = '546rftvyghbjjkjiuytre';
// a list of templates to be used to replaces occurrences of magic.
const inserts: TemplateResult[] = [];
const replacer = function(name: string, value: any) {
if (name === 'imageIndex') {
// Show a clickable button that takes the user to the image resource viewer.
inserts.push(html`<b>${value}</b>
<button @click=${()=>{ele._jumpToImage(value)}}
title="Show the image referenced by this command in the resource viewer"
>Image</button>`);
return magic;
}
return value;
}
const strung = JSON.stringify(op.details, replacer, 2);
// JSON.stringify adds some quotes around the magic word.
// including these in our delimeter removes them.
const jsonparts = strung.split('"'+magic+'"');
let result = [html`${jsonparts[0]}`];
for (let i = 1; i < jsonparts.length; i++) {
result.push(inserts[i-1]);
result.push(html`${jsonparts[i]}`)
}
return html`<pre>${result}</pre>`;
}
private _jumpToImage(index: number){
this.dispatchEvent(new CustomEvent<CommandsSkSelectImageEventDetail>(
'select-image', {
detail: { id: index },
bubbles: true,
}));
}
// (index is in the unfiltered list)
private _toggleVisible(index: number){
this._cmd[index].visible = !this._cmd[index].visible;
}
// lit-html does not appear to support setting a tag's name with a ${} so here's
// a crummy workaround
private _icon(item: PrefixItem) {
if (item.icon === 'save-icon-sk') {
return html`<save-icon-sk style="fill: ${ item.color };"
class=icon> </save-icon-sk>`;
} else if (item.icon === 'content-copy-icon-sk') {
return html`<content-copy-icon-sk style="fill: ${ item.color };"
class=icon> </content-copy-icon-sk>`;
} else if (item.icon === 'image-icon-sk') {
return html`<image-icon-sk style="fill: ${ item.color };"
class=icon> </image-icon-sk>`;
}
}
// Any deterministic mapping between integers and colors will do
private _gpuOpColor(index: number) {
return COLORS[index % COLORS.length];
}
// deep copy
private _copyPrefix(p: PrefixItem): PrefixItem {
return {icon: p.icon, color: p.color, count: p.count};
}
private _rangeInputHandler(e: Event) {
const lo = parseInt(this.querySelector<HTMLInputElement>('#rangelo')!.value);
const hi = parseInt(this.querySelector<HTMLInputElement>('#rangehi')!.value);
this.range = [lo, hi];
}
// parse the text filter input, and if it is possible to represent it purely as
// a command filter, store it in this._includedSet
private _textFilter() {
let rawFilter = this.querySelector<HTMLInputElement>('#text-filter'
)!.value.trim().toLowerCase();
const negative = (rawFilter[0] == '!');
// make sure to copy it so we don't alter this._available
this._includedSet = new Set<string>(this._available);
if (rawFilter !== '') {
if (negative) {
rawFilter = rawFilter.slice(1).trim();
const tokens = rawFilter.split(/\s+/);
// negative filters can always be represented with histogram selections
for (const token of tokens) {
this._includedSet.delete(token);
}
} else {
// for positive filters, the text could either be a set of command names,
// or a free text search.
const tokens = rawFilter.split(/\s+/);
this._includedSet = new Set<string>();
for (const token of tokens) {
if (this._available.has(token)) {
this._includedSet.add(token);
} else {
// not a command name, bail out, reset this, do a free text search
this._includedSet = new Set<string>(this._available);
// since we just altered this._includedSet we have to let histogram know.
this.dispatchEvent(new CustomEvent<CommandsSkHistogramEventDetail>(
'histogram-update', {
detail: { included: new Set<string>(this._includedSet) },
bubbles: true,
}));
this._freeTextSearch(tokens);
// TODO(nifong): need some visual feedback to let the user know
console.log(`Query interpreted as free text search because ${token}\
doesn't appear to be a command name`);
return;
}
}
}
}
this.dispatchEvent(new CustomEvent<CommandsSkHistogramEventDetail>(
'histogram-update', {
detail: { included: new Set<string>(this._includedSet) },
bubbles: true,
}));
this._applyCommandFilter(); // note we still do this for emtpy filters.
}
private _freeTextSearch(tokens: string[]) {
// Free text search every command's json representation and include its index in
// this._filtered if any token is found
const matches = function(s: string) {
for (const token of tokens) {
if (s.indexOf(token) >= 0) {
return true;
}
}
return false;
}
this._filtered = [];
for (let i = this._range[0]; i <= this._range[1]; i++) {
const commandText = JSON.stringify(this._cmd[i].details).toLowerCase();
if (matches(commandText)) {
this._filtered.push(i);
}
}
this._render();
if (this._filtered.length > 0) {
this.item = this._filtered.length - 1; // after render because it causes a scroll
}
}
// Applies range filter and recalculates command name histogram.
// The range filter is the first filter applied. The histogram shows any command
// that passes
// the range filter, and shows a nonzero count for any command that passes the command
// filter.
private _applyRangeFilter() {
// Despite the name, there's not much to "apply" but
// the histogram needs to change when the range filter changes which is
// why this function is seperate from _applyCommandFilter
// Calculate data for histogram
// Each command type gets two different counts
interface tally {
count_in_frame: number,
count_in_range_filter: number,
}
const counts = new DefaultMap<string, tally>(() => ({
count_in_frame: 0,
count_in_range_filter: 0
}));
for (let i = 0; i < this._cmd.length; i++) {
let c = this._cmd[i];
counts.get(c.name).count_in_frame += 1; // always increment first count
if (i >= this._range[0] && i <= this._range[1]) {
counts.get(c.name)!.count_in_range_filter += 1; // optionally increment filtered count.
}
}
// Now format the histogram as a sorted array suitable for use in the template.
// First convert the counts map into an Array of HistogramEntry.
this._histogram = [];
counts.forEach((value, key) => {
this._histogram.push({
name: key,
countInFrame: value.count_in_frame,
countInRange: value.count_in_range_filter,
})
});
// Now sort the array, descending on the rangeCount, ascending
// on the op name.
// sort by rangeCount so entries don't move on enable/disable
this._histogram.sort(function(a,b) {
if (a.countInRange == b.countInRange) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
} else {
return b.countInRange - a.countInRange;
}
});
// the user's selections are present in the text filter. Apply them now
// triggers render
this._textFilter();
// that populated this._includedSet, which we also need to notify histogram of.
// send this to the histogram element
this.dispatchEvent(
new CustomEvent<CommandsSkHistogramEventDetail>(
'histogram-update', {
detail: {
hist: this._histogram,
// Make a copy so listener can't accidently write to it.
included: new Set<string>(this._includedSet),
},
bubbles: true,
}));
}
// Apply a filter specified by this._includedSet and set the filtered list to be visible.
private _applyCommandFilter() {
// Try to retain the user's playback position in the unfiltered list when doing this
// (it is not always possible)
const oldPos = this._filtered[this._item];
let newPos: number | null = null;
this._filtered = [];
for (let i = this._range[0]; i <= this._range[1]; i++) {
if (this._includedSet.has(this._cmd[i].name.toLowerCase())) {
this._filtered.push(i);
if (i === oldPos) {
newPos = this._filtered.length - 1;
}
}
}
this._playSk!.size = this._filtered.length;
this._render(); // gotta render before you can scroll
if (newPos !== null) {
this.item = newPos; // setter triggers scroll
} else {
this.item = this._filtered.length - 1;
}
}
// Filters out all but the last command of each gpu op group
// Experimental, probably breaks assumptions elsewhere
private _opIdFilter() {
this._filtered = [];
const commandsOfEachOp = new DefaultMap<number, number[]>(() => []);
this._cmd.forEach((command, index) => {
if (command.details.auditTrail &&
command.details.auditTrail.Ops) {
const opid = command.details.auditTrail.Ops[0].OpsTaskID;
commandsOfEachOp.get(opid).push(index);
}
});
const sortedKeys: number[] = Array.from(commandsOfEachOp.keys());
sortedKeys.sort((a, b) => a - b); // force it to sort as a number, not a string
sortedKeys.forEach((k) => {
commandsOfEachOp.get(k)!.forEach((i) => {
this._filtered.push(i);
});
});
this._playSk!.size = this._filtered.length;
this.item = this._filtered.length - 1;
}
}
define('commands-sk', CommandsSk);