Commit c39e47aa authored by Camillo Bruni's avatar Camillo Bruni Committed by V8 LUCI CQ

[tools][system-analyzer] Improve selection support

- Double click on the current timeline selection to focus and zoom in
- Make timeline-tracks focusable by setting a tabindex
- Add back arrow-key navigation for the map panel (only when focused)
- Prepare code for adding keyboard-based horizontal scrolling
- Use --code-font CSS variable

Bug: v8:10644
Change-Id: Ic473695c9fcdc795d173cd064b4660e100ae8b24
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/3568475Reviewed-by: 's avatarPatrick Thier <pthier@chromium.org>
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Cr-Commit-Position: refs/heads/main@{#79786}
parent 91bfde42
......@@ -41,6 +41,10 @@ export class V8CustomElement extends HTMLElement {
_update() {
throw Error('Subclass responsibility');
}
get isFocused() {
return document.activeElement === this;
}
}
export class FileReader extends V8CustomElement {
......
:root {
--code-font: Consolas, Monaco, Menlo, monospace;
--background-color: #000000;
--surface-color-rgb: 18, 18, 18;
--surface-color: rgb(var(--surface-color-rgb));
......@@ -69,6 +70,10 @@ kbd {
white-space: nowrap;
}
kbd, code, pre {
font-family: var(--code-font);
}
a {
color: var(--primary-color);
text-decoration: none;
......@@ -245,7 +250,7 @@ button:hover,
padding: 0 10px 0 10px;
}
.legend dt {
font-family: monospace;
font-family: var(--code-font);
}
.legend h3 {
margin-top: 10px;
......
......@@ -176,6 +176,12 @@ found in the LICENSE file. -->
<h3>Keyboard Shortcuts for Navigation</h3>
<dl>
<dt><kbd>A</kbd></dt>
<dd>Scroll left</dd>
<dt><kbd>D</kbd></dt>
<dd>Sroll right</dd>
<dt><kbd>SHIFT</kbd> + <kbd>Arrow Up</kbd></dt>
<dd>Follow Map transition forward (first child)</dd>
......
......@@ -231,10 +231,10 @@ class App {
handleTimeRangeSelect(e) {
e.stopImmediatePropagation();
this.selectTimeRange(e.start, e.end);
this.selectTimeRange(e.start, e.end, e.focus, e.zoom);
}
selectTimeRange(start, end) {
selectTimeRange(start, end, focus = false, zoom = false) {
this._state.selectTimeRange(start, end);
this.showMapEntries(this._state.mapTimeline.selectionOrSelf, false);
this.showIcEntries(this._state.icTimeline.selectionOrSelf, false);
......@@ -243,7 +243,7 @@ class App {
this.showApiEntries(this._state.apiTimeline.selectionOrSelf, false);
this.showTickEntries(this._state.tickTimeline.selectionOrSelf, false);
this.showTimerEntries(this._state.timerTimeline.selectionOrSelf, false);
this._view.timelinePanel.timeSelection = {start, end};
this._view.timelinePanel.timeSelection = {start, end, focus, zoom};
}
handleFocusLogEntry(e) {
......@@ -421,115 +421,53 @@ class Navigation {
this.state = state;
this._view = view;
}
get map() {
return this.state.map
}
set map(value) {
this.state.map = value
}
get chunks() {
return this.state.mapTimeline.chunks;
}
increaseTimelineResolution() {
this._view.timelinePanel.nofChunks *= 1.5;
this.state.nofChunks *= 1.5;
}
decreaseTimelineResolution() {
this._view.timelinePanel.nofChunks /= 1.5;
this.state.nofChunks /= 1.5;
}
selectNextEdge() {
if (!this.map) return;
if (this.map.children.length != 1) return;
this.map = this.map.children[0].to;
this._view.mapTrack.selectedEntry = this.map;
this.updateUrl();
this._view.mapPanel.map = this.map;
}
selectPrevEdge() {
if (!this.map) return;
if (!this.map.parent) return;
this.map = this.map.parent;
this._view.mapTrack.selectedEntry = this.map;
this.updateUrl();
this._view.mapPanel.map = this.map;
}
selectDefaultMap() {
this.map = this.chunks[0].at(0);
this._view.mapTrack.selectedEntry = this.map;
this.updateUrl();
this._view.mapPanel.map = this.map;
}
moveInChunks(next) {
if (!this.map) return this.selectDefaultMap();
let chunkIndex = this.map.chunkIndex(this.chunks);
let chunk = this.chunks[chunkIndex];
let index = chunk.indexOf(this.map);
if (next) {
chunk = chunk.next(this.chunks);
} else {
chunk = chunk.prev(this.chunks);
}
if (!chunk) return;
index = Math.min(index, chunk.size() - 1);
this.map = chunk.at(index);
this._view.mapTrack.selectedEntry = this.map;
this.updateUrl();
this._view.mapPanel.map = this.map;
}
moveInChunk(delta) {
if (!this.map) return this.selectDefaultMap();
let chunkIndex = this.map.chunkIndex(this.chunks)
let chunk = this.chunks[chunkIndex];
let index = chunk.indexOf(this.map) + delta;
let map;
if (index < 0) {
map = chunk.prev(this.chunks).last();
} else if (index >= chunk.size()) {
map = chunk.next(this.chunks).first()
} else {
map = chunk.at(index);
}
this.map = map;
this._view.mapTrack.selectedEntry = this.map;
this.updateUrl();
this._view.mapPanel.map = this.map;
}
updateUrl() {
let entries = this.state.entries;
let params = new URLSearchParams(entries);
window.history.pushState(entries, '', '?' + params.toString());
}
scrollLeft() {}
scrollRight() {}
handleKeyDown(event) {
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
if (event.shiftKey) {
this.selectPrevEdge();
} else {
this.moveInChunk(-1);
}
case 'd':
this.scrollLeft();
return false;
case 'ArrowDown':
event.preventDefault();
if (event.shiftKey) {
this.selectNextEdge();
} else {
this.moveInChunk(1);
}
case 'a':
this.scrollRight();
return false;
case 'ArrowLeft':
this.moveInChunks(false);
break;
case 'ArrowRight':
this.moveInChunks(true);
break;
case '+':
this.increaseTimelineResolution();
break;
return false;
case '-':
this.decreaseTimelineResolution();
break;
return false;
}
}
}
......
......@@ -50,10 +50,12 @@ export class SelectTimeEvent extends AppEvent {
return 'timerangeselect';
}
constructor(start = 0, end = Infinity) {
constructor(start = 0, end = Infinity, focus = false, zoom = false) {
super(SelectTimeEvent.name);
this.start = start;
this.end = end;
this.focus = focus;
this.zoom = zoom;
}
}
......
......@@ -42,7 +42,7 @@ h3 {
}
.code {
font-family: monospace;
font-family: var(--code-font);
}
.footer {
......
......@@ -7,7 +7,7 @@ found in the LICENSE file. -->
</head>
<style>
.scriptNode {
font-family: Consolas, monospace;
font-family: var(--code-font);
}
.scriptNode span {
......
......@@ -24,10 +24,8 @@ DOM.defineCustomElement(
}
set nofChunks(count) {
const time = this.currentTime
for (const track of this.timelineTracks) {
track.nofChunks = count;
track.currentTime = time;
}
}
......@@ -35,6 +33,12 @@ DOM.defineCustomElement(
return this.timelineTracks[0].nofChunks;
}
set currentTime(time) {
for (const track of this.timelineTracks) {
track.currentTime = time;
}
}
get currentTime() {
return this.timelineTracks[0].currentTime;
}
......@@ -54,12 +58,23 @@ DOM.defineCustomElement(
this.timeSelection = {start: event.start, end: event.end};
}
set timeSelection(timeSelection) {
if (timeSelection.start > timeSelection.end) {
set timeSelection(selection) {
if (selection.start > selection.end) {
throw new Error('Invalid time range');
}
for (const track of this.timelineTracks) {
track.timeSelection = timeSelection;
const tracks = Array.from(this.timelineTracks);
if (selection.zoom) {
// To avoid inconsistencies copy the zoom/nofChunks from the first
// track
const firstTrack = tracks.pop();
firstTrack.timeSelection = selection;
selection.zoom = false;
for (const track of tracks) track.timeSelection = selection;
this.nofChunks = firstTrack.nofChunks;
} else {
for (const track of this.timelineTracks) {
track.timeSelection = selection;
}
}
}
});
......@@ -37,6 +37,7 @@ export class TimelineTrackBase extends V8CustomElement {
this.timelineMarkersNode = this.$('#timelineMarkers');
this._scalableContentNode = this.$('#scalableContent');
this.isLocked = false;
this.setAttribute('tabindex', 0);
}
_initEventListeners() {
......@@ -46,6 +47,8 @@ export class TimelineTrackBase extends V8CustomElement {
this.hitPanelNode.onclick = this._handleClick.bind(this);
this.hitPanelNode.ondblclick = this._handleDoubleClick.bind(this);
this.hitPanelNode.onmousemove = this._handleMouseMove.bind(this);
this.$('#selectionForeground')
.addEventListener('mousemove', this._handleMouseMove.bind(this));
window.addEventListener('resize', () => this._resetCachedDimensions());
}
......@@ -72,9 +75,24 @@ export class TimelineTrackBase extends V8CustomElement {
this._updateChunks();
}
set timeSelection(selection) {
this._selectionHandler.timeSelection = selection;
set timeSelection({start, end, focus = false, zoom = false}) {
this._selectionHandler.timeSelection = {start, end};
this.updateSelection();
if (focus || zoom) {
if (!Number.isFinite(start) || !Number.isFinite(end)) {
throw new Error('Invalid number ranges');
}
if (focus) {
this.currentTime = (start + end) / 2;
}
if (zoom) {
const margin = 0.2;
const newVisibleTime = (end - start) * (1 + 2 * margin);
const currentVisibleTime =
this._cachedTimelineBoundingClientRect.width / this._timeToPixel;
this.nofChunks = this.nofChunks * (currentVisibleTime / newVisibleTime);
}
}
}
updateSelection() {
......@@ -125,8 +143,14 @@ export class TimelineTrackBase extends V8CustomElement {
}
set nofChunks(count) {
const centerTime = this.currentTime;
const kMinNofChunks = 100;
if (count < kMinNofChunks) count = kMinNofChunks;
const kMaxNofChunks = 10 * 1000;
if (count > kMaxNofChunks) count = kMaxNofChunks;
this._nofChunks = count | 0;
this._updateChunks();
this.currentTime = centerTime;
}
get nofChunks() {
......@@ -150,18 +174,42 @@ export class TimelineTrackBase extends V8CustomElement {
set selectedEntry(value) {
this._selectedEntry = value;
this.drawAnnotations(value);
}
get selectedEntry() {
return this._selectedEntry;
}
get focusedEntry() {
return this._focusedEntry;
}
set focusedEntry(entry) {
this._focusedEntry = entry;
if (entry) this._drawAnnotations(entry);
}
set scrollLeft(offset) {
this.timelineNode.scrollLeft = offset;
this._cachedTimelineScrollLeft = offset;
}
get scrollLeft() {
return this._cachedTimelineScrollLeft;
}
set currentTime(time) {
const position = this.timeToPosition(time);
const centerOffset = this._timelineBoundingClientRect.width / 2;
this.scrollLeft = Math.max(0, position - centerOffset);
}
get currentTime() {
const centerOffset =
this._timelineBoundingClientRect.width / 2 + this.scrollLeft;
return this.relativePositionToTime(centerOffset);
}
handleEntryTypeDoubleClick(e) {
this.dispatchEvent(new SelectionEvent(e.target.parentNode.entries));
}
......@@ -387,12 +435,20 @@ class SelectionHandler {
constructor(timeline) {
this._timeline = timeline;
this._timelineNode = this._timeline.$('#timeline');
this._timelineNode.addEventListener(
'mousedown', e => this._handleTimeSelectionMouseDown(e));
'mousedown', this._handleMouseDown.bind(this));
this._timelineNode.addEventListener(
'mouseup', e => this._handleTimeSelectionMouseUp(e));
'mouseup', this._handleMouseUp.bind(this));
this._timelineNode.addEventListener(
'mousemove', e => this._handleTimeSelectionMouseMove(e));
'mousemove', this._handleMouseMove.bind(this));
this._selectionNode = this._timeline.$('#selection');
this._selectionForegroundNode = this._timeline.$('#selectionForeground');
this._selectionForegroundNode.addEventListener(
'dblclick', this._handleDoubleClick.bind(this));
this._selectionBackgroundNode = this._timeline.$('#selectionBackground');
this._leftHandleNode = this._timeline.$('#leftHandle');
this._rightHandleNode = this._timeline.$('#rightHandle');
}
update() {
......@@ -406,9 +462,10 @@ class SelectionHandler {
this._leftHandleNode.style.left = startPosition + 'px';
this._rightHandleNode.style.left = endPosition + 'px';
const delta = endPosition - startPosition;
const selectionNode = this._selectionBackgroundNode;
selectionNode.style.left = startPosition + 'px';
selectionNode.style.width = delta + 'px';
this._selectionForegroundNode.style.left = startPosition + 'px';
this._selectionForegroundNode.style.width = delta + 'px';
this._selectionBackgroundNode.style.left = startPosition + 'px';
this._selectionBackgroundNode.style.width = delta + 'px';
}
set timeSelection(selection) {
......@@ -437,26 +494,6 @@ class SelectionHandler {
this._timeSelection.end != Infinity;
}
get _timelineNode() {
return this._timeline.$('#timeline');
}
get _selectionNode() {
return this._timeline.$('#selection');
}
get _selectionBackgroundNode() {
return this._timeline.$('#selectionBackground');
}
get _leftHandleNode() {
return this._timeline.$('#leftHandle');
}
get _rightHandleNode() {
return this._timeline.$('#rightHandle');
}
get _leftHandlePosX() {
return this._leftHandleNode.getBoundingClientRect().x;
}
......@@ -475,7 +512,7 @@ class SelectionHandler {
SelectionHandler.SELECTION_OFFSET;
}
_handleTimeSelectionMouseDown(event) {
_handleMouseDown(event) {
if (event.button !== 0) return;
let xPosition = event.clientX
// Update origin time in case we click on a handle.
......@@ -488,7 +525,7 @@ class SelectionHandler {
this._selectionOriginTime = this.positionToTime(xPosition);
}
_handleTimeSelectionMouseMove(event) {
_handleMouseMove(event) {
if (event.button !== 0) return;
if (!this.isSelecting) return;
const currentTime = this.positionToTime(event.clientX);
......@@ -497,7 +534,7 @@ class SelectionHandler {
Math.max(this._selectionOriginTime, currentTime)));
}
_handleTimeSelectionMouseUp(event) {
_handleMouseUp(event) {
if (event.button !== 0) return;
this._selectionOriginTime = -1;
if (this._timeSelection.start === -1) return;
......@@ -506,6 +543,13 @@ class SelectionHandler {
this._timeline.dispatchEvent(new SelectTimeEvent(
this._timeSelection.start, this._timeSelection.end));
}
_handleDoubleClick(event) {
if (!this.hasSelection) return;
// Focus and zoom to the current selection.
this._timeline.dispatchEvent(new SelectTimeEvent(
this._timeSelection.start, this._timeSelection.end, true, true));
}
}
class Legend {
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import {kChunkVisualWidth, MapLogEntry} from '../../log/map.mjs';
import {FocusEvent} from '../events.mjs';
import {CSSColor, DOM} from '../helper.mjs';
import {TimelineTrackBase} from './timeline-track-base.mjs'
......@@ -12,8 +13,11 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-map',
class TimelineTrackMap extends TimelineTrackBase {
constructor() {
super(templateText);
this.navigation = new Navigation(this)
}
_handleKeyDown(event) {}
getMapStyle(map) {
return map.edge && map.edge.from ? CSSColor.onBackgroundColor :
CSSColor.onPrimaryColor;
......@@ -135,4 +139,117 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-map',
}
return buffer;
}
})
\ No newline at end of file
})
class Navigation {
constructor(track) {
this._track = track;
this._track.addEventListener('keydown', this._handleKeyDown.bind(this));
this._map = undefined;
}
_handleKeyDown(event) {
if (!this._track.isFocused) return;
let handled = false;
switch (event.key) {
case 'ArrowDown':
handled = true;
if (event.shiftKey) {
this.selectPrevEdge();
} else {
this.moveInChunk(-1);
}
break;
case 'ArrowUp':
handled = true;
if (event.shiftKey) {
this.selectNextEdge();
} else {
this.moveInChunk(1);
}
break;
case 'ArrowLeft':
handled = true;
this.moveInChunks(false);
break;
case 'ArrowRight':
handled = true;
this.moveInChunks(true);
break;
case 'Enter':
handled = true;
this.selectMap();
break
}
if (handled) {
event.stopPropagation();
event.preventDefault();
return false;
}
}
get map() {
return this._track.focusedEntry;
}
set map(map) {
this._track.focusedEntry = map;
}
get chunks() {
return this._track.chunks;
}
selectMap() {
if (!this.map) return;
this._track.dispatchEvent(new FocusEvent(this.map))
}
selectNextEdge() {
if (!this.map) return;
if (this.map.children.length != 1) return;
this.show(this.map.children[0].to);
}
selectPrevEdge() {
if (!this.map) return;
if (!this.map.parent) return;
this.map = this.map.parent;
}
selectDefaultMap() {
this.map = this.chunks[0].at(0);
}
moveInChunks(next) {
if (!this.map) return this.selectDefaultMap();
let chunkIndex = this.map.chunkIndex(this.chunks);
let currentChunk = this.chunks[chunkIndex];
let currentIndex = currentChunk.indexOf(this.map);
let newChunk;
if (next) {
newChunk = chunk.next(this.chunks);
} else {
newChunk = chunk.prev(this.chunks);
}
if (!newChunk) return;
let newIndex = Math.min(currentIndex, newChunk.size() - 1);
this.map = newChunk.at(newIndex);
}
moveInChunk(delta) {
if (!this.map) return this.selectDefaultMap();
let chunkIndex = this.map.chunkIndex(this.chunks)
let chunk = this.chunks[chunkIndex];
let index = chunk.indexOf(this.map) + delta;
let map;
if (index < 0) {
map = chunk.prev(this.chunks).last();
} else if (index >= chunk.size()) {
map = chunk.next(this.chunks).first()
} else {
map = chunk.at(index);
}
this.map = map;
}
}
......@@ -130,7 +130,15 @@ found in the LICENSE file. -->
border-right: 1px solid var(--on-surface-color);
margin-left: -5px;
}
#selectionForeground{
z-index: 2;
cursor: grab;
height: 100%;
position: absolute;
}
#selectionForeground:active {
cursor: grabbing;
}
#selectionBackground {
background-color: rgba(133, 68, 163, 0.5);
height: 100%;
......@@ -159,7 +167,7 @@ found in the LICENSE file. -->
<style>
/* SVG styles */
.txt {
font: 8px monospace;
font: 8px var(--code-font);
transform: var(--txt-scale);
}
.annotationLabel {
......@@ -208,6 +216,7 @@ found in the LICENSE file. -->
<div id="timeline">
<div id="selection" class="dataSized">
<div id="leftHandle"></div>
<div id="selectionForeground"></div>
<div id="selectionBackground"></div>
<div id="rightHandle"></div>
</div>
......
......@@ -37,7 +37,7 @@ found in the LICENSE file. -->
}
.textContent {
font-family: monospace;
font-family: var(--code-font);
white-space: pre;
overflow-wrap: anywhere;
overflow-x: hidden;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment