// Copyright (C) 2019 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use size file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import m from 'mithril'; import {currentTargetOffset} from '../base/dom_utils'; import {Icons} from '../base/semantic_icons'; import {randomColor} from '../components/colorizer'; import {SpanNote, Note} from '../public/note'; import {raf} from '../core/raf_scheduler'; import {Button, ButtonBar} from '../widgets/button'; import {TRACK_SHELL_WIDTH} from './css_constants'; import {getMaxMajorTicks, generateTicks, TickType} from './gridline_helper'; import {Size2D} from '../base/geom'; import {Panel} from './panel_container'; import {Timestamp} from '../components/widgets/timestamp'; import {assertUnreachable} from '../base/logging'; import {DetailsPanel} from '../public/details_panel'; import {TimeScale} from '../base/time_scale'; import {canvasClip} from '../base/canvas_utils'; import {Selection} from '../public/selection'; import {TraceImpl} from '../core/trace_impl'; const FLAG_WIDTH = 16; const AREA_TRIANGLE_WIDTH = 10; const FLAG = `\uE153`; function toSummary(s: string) { const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length; return s.slice(0, Math.min(newlineIndex, s.length, 16)); } function getStartTimestamp(note: Note | SpanNote) { const noteType = note.noteType; switch (noteType) { case 'SPAN': return note.start; case 'DEFAULT': return note.timestamp; default: assertUnreachable(noteType); } } export class NotesPanel implements Panel { readonly kind = 'panel'; readonly selectable = false; private readonly trace: TraceImpl; private timescale?: TimeScale; // The timescale from the last render() private hoveredX: null | number = null; private mouseDragging = false; constructor(trace: TraceImpl) { this.trace = trace; } render(): m.Children { const allCollapsed = this.trace.workspace.flatTracks.every( (n) => n.collapsed, ); return m( '.notes-panel', { onmousedown: () => { // If the user clicks & drags, very likely they just want to measure // the time horizontally, not set a flag. This debouncing is done to // avoid setting accidental flags like measuring the time on the brush // timeline. this.mouseDragging = false; }, onclick: (e: MouseEvent) => { if (!this.mouseDragging) { const x = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; this.onClick(x); e.stopPropagation(); } }, onmousemove: (e: MouseEvent) => { this.mouseDragging = true; this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; raf.scheduleCanvasRedraw(); }, onmouseenter: (e: MouseEvent) => { this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; raf.scheduleCanvasRedraw(); }, onmouseout: () => { this.hoveredX = null; this.trace.timeline.hoveredNoteTimestamp = undefined; }, }, m( ButtonBar, {className: 'pf-toolbar'}, m(Button, { onclick: (e: Event) => { e.preventDefault(); if (allCollapsed) { this.trace.commands.runCommand( 'perfetto.CoreCommands#ExpandAllGroups', ); } else { this.trace.commands.runCommand( 'perfetto.CoreCommands#CollapseAllGroups', ); } }, title: allCollapsed ? 'Expand all' : 'Collapse all', icon: allCollapsed ? 'unfold_more' : 'unfold_less', compact: true, }), m(Button, { onclick: (e: Event) => { e.preventDefault(); this.trace.workspace.pinnedTracks.forEach((t) => this.trace.workspace.unpinTrack(t), ); raf.scheduleFullRedraw(); }, title: 'Clear all pinned tracks', icon: 'clear_all', compact: true, }), // TODO(stevegolton): Re-introduce this when we fix track filtering // m(TextInput, { // placeholder: 'Filter tracks...', // title: // 'Track filter - enter one or more comma-separated search terms', // value: this.trace.state.trackFilterTerm, // oninput: (e: Event) => { // const filterTerm = (e.target as HTMLInputElement).value; // this.trace.dispatch(Actions.setTrackFilterTerm({filterTerm})); // }, // }), // m(Button, { // type: 'reset', // icon: 'backspace', // onclick: () => { // this.trace.dispatch( // Actions.setTrackFilterTerm({filterTerm: undefined}), // ); // }, // title: 'Clear track filter', // }), ), ); } renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) { ctx.fillStyle = '#999'; ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height); const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH}; ctx.save(); ctx.translate(TRACK_SHELL_WIDTH, 0); canvasClip(ctx, 0, 0, trackSize.width, trackSize.height); this.renderPanel(ctx, trackSize); ctx.restore(); } private renderPanel(ctx: CanvasRenderingContext2D, size: Size2D): void { let aNoteIsHovered = false; const visibleWindow = this.trace.timeline.visibleWindow; const timescale = new TimeScale(visibleWindow, { left: 0, right: size.width, }); const timespan = visibleWindow.toTimeSpan(); this.timescale = timescale; if (size.width > 0 && timespan.duration > 0n) { const maxMajorTicks = getMaxMajorTicks(size.width); const offset = this.trace.timeline.timestampOffset(); const tickGen = generateTicks(timespan, maxMajorTicks, offset); for (const {type, time} of tickGen) { const px = Math.floor(timescale.timeToPx(time)); if (type === TickType.MAJOR) { ctx.fillRect(px, 0, 1, size.height); } } } ctx.textBaseline = 'bottom'; ctx.font = '10px Helvetica'; for (const note of this.trace.notes.notes.values()) { const timestamp = getStartTimestamp(note); // TODO(hjd): We should still render area selection marks in viewport is // *within* the area (e.g. both lhs and rhs are out of bounds). if ( (note.noteType === 'DEFAULT' && !visibleWindow.contains(note.timestamp)) || (note.noteType === 'SPAN' && !visibleWindow.overlaps(note.start, note.end)) ) { continue; } const currentIsHovered = this.hoveredX !== null && this.hitTestNote(this.hoveredX, note); if (currentIsHovered) aNoteIsHovered = true; const selection = this.trace.selection.selection; const isSelected = selection.kind === 'note' && selection.id === note.id; const x = timescale.timeToPx(timestamp); const left = Math.floor(x); // Draw flag or marker. if (note.noteType === 'SPAN') { this.drawAreaMarker( ctx, left, Math.floor(timescale.timeToPx(note.end)), note.color, isSelected, ); } else { this.drawFlag(ctx, left, size.height, note.color, isSelected); } if (note.text) { const summary = toSummary(note.text); const measured = ctx.measureText(summary); // Add a white semi-transparent background for the text. ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.fillRect( left + FLAG_WIDTH + 2, size.height + 2, measured.width + 2, -12, ); ctx.fillStyle = '#3c4b5d'; ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1); } } // A real note is hovered so we don't need to see the preview line. // TODO(hjd): Change cursor to pointer here. if (aNoteIsHovered) { this.trace.timeline.hoveredNoteTimestamp = undefined; } // View preview note flag when hovering on notes panel. if (!aNoteIsHovered && this.hoveredX !== null) { const timestamp = timescale.pxToHpTime(this.hoveredX).toTime(); if (visibleWindow.contains(timestamp)) { this.trace.timeline.hoveredNoteTimestamp = timestamp; const x = timescale.timeToPx(timestamp); const left = Math.floor(x); this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true); } } ctx.restore(); } private drawAreaMarker( ctx: CanvasRenderingContext2D, x: number, xEnd: number, color: string, fill: boolean, ) { ctx.fillStyle = color; ctx.strokeStyle = color; const topOffset = 10; // Don't draw in the track shell section. if (x >= 0) { // Draw left triangle. ctx.beginPath(); ctx.moveTo(x, topOffset); ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH); ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset); ctx.lineTo(x, topOffset); if (fill) ctx.fill(); ctx.stroke(); } // Draw right triangle. ctx.beginPath(); ctx.moveTo(xEnd, topOffset); ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH); ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset); ctx.lineTo(xEnd, topOffset); if (fill) ctx.fill(); ctx.stroke(); // Start line after track shell section, join triangles. const startDraw = Math.max(x, 0); ctx.beginPath(); ctx.moveTo(startDraw, topOffset); ctx.lineTo(xEnd, topOffset); ctx.stroke(); } private drawFlag( ctx: CanvasRenderingContext2D, x: number, height: number, color: string, fill?: boolean, ) { const prevFont = ctx.font; const prevBaseline = ctx.textBaseline; ctx.textBaseline = 'alphabetic'; // Adjust height for icon font. ctx.font = '24px Material Symbols Sharp'; ctx.fillStyle = color; ctx.strokeStyle = color; // The ligatures have padding included that means the icon is not drawn // exactly at the x value. This adjusts for that. const iconPadding = 6; if (fill) { ctx.fillText(FLAG, x - iconPadding, height + 2); } else { ctx.strokeText(FLAG, x - iconPadding, height + 2.5); } ctx.font = prevFont; ctx.textBaseline = prevBaseline; } private onClick(x: number) { if (!this.timescale) { return; } // Select the hovered note, or create a new single note & select it if (x < 0) return; for (const note of this.trace.notes.notes.values()) { if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) { this.trace.selection.selectNote({id: note.id}); return; } } const timestamp = this.timescale.pxToHpTime(x).toTime(); const color = randomColor(); const noteId = this.trace.notes.addNote({timestamp, color}); this.trace.selection.selectNote({id: noteId}); } private hitTestNote(x: number, note: SpanNote | Note): boolean { if (!this.timescale) { return false; } const timescale = this.timescale; const noteX = timescale.timeToPx(getStartTimestamp(note)); if (note.noteType === 'SPAN') { return ( (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) || (timescale.timeToPx(note.end) > x && x > timescale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH) ); } else { const width = FLAG_WIDTH; return noteX <= x && x < noteX + width; } } } export class NotesEditorTab implements DetailsPanel { constructor(private trace: TraceImpl) {} render(selection: Selection) { if (selection.kind !== 'note') { return undefined; } const id = selection.id; const note = this.trace.notes.getNote(id); if (note === undefined) { return m('.', `No Note with id ${id}`); } const startTime = getStartTimestamp(note); return m( '.notes-editor-panel', { key: id, // Every note shoul get its own brand new DOM. }, m( '.notes-editor-panel-heading-bar', m( '.notes-editor-panel-heading', `Annotation at `, m(Timestamp, {ts: startTime}), ), m('input[type=text]', { oncreate: (v: m.VnodeDOM) => { // NOTE: due to bad design decisions elsewhere this component is // rendered every time the mouse moves on the canvas. We cannot set // `value: note.text` as an input as that will clobber the input // value as we move the mouse. const inputElement = v.dom as HTMLInputElement; inputElement.value = note.text; inputElement.focus(); }, onchange: (e: InputEvent) => { const newText = (e.target as HTMLInputElement).value; this.trace.notes.changeNote(id, {text: newText}); }, }), m( 'span.color-change', `Change color: `, m('input[type=color]', { value: note.color, onchange: (e: Event) => { const newColor = (e.target as HTMLInputElement).value; this.trace.notes.changeNote(id, {color: newColor}); }, }), ), m(Button, { label: 'Remove', icon: Icons.Delete, onclick: () => this.trace.notes.removeNote(id), }), ), ); } }