| // 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 {Actions} from '../common/actions'; |
| import {randomColor} from '../common/colorizer'; |
| import {AreaNote, Note} from '../common/state'; |
| import {timestampOffset} from '../common/time'; |
| |
| import { |
| BottomTab, |
| bottomTabRegistry, |
| NewBottomTabArgs, |
| } from './bottom_tab'; |
| import {TRACK_SHELL_WIDTH} from './css_constants'; |
| import {PerfettoMouseEvent} from './events'; |
| import {globals} from './globals'; |
| import { |
| getMaxMajorTicks, |
| TickGenerator, |
| TickType, |
| timeScaleForVisibleWindow, |
| } from './gridline_helper'; |
| import {Panel, PanelSize} from './panel'; |
| import {Icons} from './semantic_icons'; |
| import {isTraceLoaded} from './sidebar'; |
| import {asTPTimestamp} from './sql_types'; |
| import {Button} from './widgets/button'; |
| import {Timestamp} from './widgets/timestamp'; |
| |
| 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|AreaNote) { |
| if (note.noteType === 'AREA') { |
| return globals.state.areas[note.areaId].start; |
| } else { |
| return note.timestamp; |
| } |
| } |
| |
| export class NotesPanel extends Panel { |
| hoveredX: null|number = null; |
| |
| oncreate({dom}: m.CVnodeDOM) { |
| dom.addEventListener('mousemove', (e: Event) => { |
| this.hoveredX = (e as PerfettoMouseEvent).layerX - TRACK_SHELL_WIDTH; |
| globals.rafScheduler.scheduleRedraw(); |
| }, {passive: true}); |
| dom.addEventListener('mouseenter', (e: Event) => { |
| this.hoveredX = (e as PerfettoMouseEvent).layerX - TRACK_SHELL_WIDTH; |
| globals.rafScheduler.scheduleRedraw(); |
| }); |
| dom.addEventListener('mouseout', () => { |
| this.hoveredX = null; |
| globals.dispatch(Actions.setHoveredNoteTimestamp({ts: -1n})); |
| }, {passive: true}); |
| } |
| |
| view() { |
| const allCollapsed = Object.values(globals.state.trackGroups) |
| .every((group) => group.collapsed); |
| |
| return m( |
| '.notes-panel', |
| { |
| onclick: (e: PerfettoMouseEvent) => { |
| this.onClick(e.layerX - TRACK_SHELL_WIDTH, e.layerY); |
| e.stopPropagation(); |
| }, |
| }, |
| isTraceLoaded() ? |
| [ |
| m('button', |
| { |
| onclick: (e: Event) => { |
| e.preventDefault(); |
| globals.dispatch(Actions.toggleAllTrackGroups( |
| {collapsed: !allCollapsed})); |
| }, |
| }, |
| m('i.material-icons', |
| {title: allCollapsed ? 'Expand all' : 'Collapse all'}, |
| allCollapsed ? 'unfold_more' : 'unfold_less')), |
| m('button', |
| { |
| onclick: (e: Event) => { |
| e.preventDefault(); |
| globals.dispatch(Actions.clearAllPinnedTracks({})); |
| }, |
| }, |
| m('i.material-icons', |
| {title: 'Clear all pinned tracks'}, |
| 'clear_all')), |
| ] : |
| ''); |
| } |
| |
| renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) { |
| let aNoteIsHovered = false; |
| |
| ctx.fillStyle = '#999'; |
| ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height); |
| |
| ctx.save(); |
| ctx.beginPath(); |
| ctx.rect(TRACK_SHELL_WIDTH, 0, size.width - TRACK_SHELL_WIDTH, size.height); |
| ctx.clip(); |
| |
| const span = globals.frontendLocalState.visibleTimeSpan; |
| const {visibleTimeScale} = globals.frontendLocalState; |
| if (size.width > TRACK_SHELL_WIDTH && span.duration > 0n) { |
| const maxMajorTicks = getMaxMajorTicks(size.width - TRACK_SHELL_WIDTH); |
| const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width); |
| const offset = timestampOffset(); |
| const tickGen = new TickGenerator(span, maxMajorTicks, offset); |
| for (const {type, time} of tickGen) { |
| const px = Math.floor(map.tpTimeToPx(time)); |
| if (type === TickType.MAJOR) { |
| ctx.fillRect(px, 0, 1, size.height); |
| } |
| } |
| } |
| |
| ctx.textBaseline = 'bottom'; |
| ctx.font = '10px Helvetica'; |
| |
| for (const note of Object.values(globals.state.notes)) { |
| 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 !== 'AREA' && !span.contains(timestamp)) || |
| (note.noteType === 'AREA' && |
| !span.contains(globals.state.areas[note.areaId].end) && |
| !span.contains(globals.state.areas[note.areaId].start))) { |
| continue; |
| } |
| const currentIsHovered = |
| this.hoveredX && this.mouseOverNote(this.hoveredX, note); |
| if (currentIsHovered) aNoteIsHovered = true; |
| |
| const selection = globals.state.currentSelection; |
| const isSelected = selection !== null && |
| ((selection.kind === 'NOTE' && selection.id === note.id) || |
| (selection.kind === 'AREA' && selection.noteId === note.id)); |
| const x = visibleTimeScale.tpTimeToPx(timestamp); |
| const left = Math.floor(x + TRACK_SHELL_WIDTH); |
| |
| // Draw flag or marker. |
| if (note.noteType === 'AREA') { |
| const area = globals.state.areas[note.areaId]; |
| this.drawAreaMarker( |
| ctx, |
| left, |
| Math.floor( |
| visibleTimeScale.tpTimeToPx(area.end) + TRACK_SHELL_WIDTH), |
| 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) { |
| globals.dispatch(Actions.setHoveredNoteTimestamp({ts: -1n})); |
| } |
| |
| // View preview note flag when hovering on notes panel. |
| if (!aNoteIsHovered && this.hoveredX !== null) { |
| const timestamp = visibleTimeScale.pxToHpTime(this.hoveredX).toTPTime(); |
| if (span.contains(timestamp)) { |
| globals.dispatch(Actions.setHoveredNoteTimestamp({ts: timestamp})); |
| const x = visibleTimeScale.tpTimeToPx(timestamp); |
| const left = Math.floor(x + TRACK_SHELL_WIDTH); |
| 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 >= globals.frontendLocalState.windowSpan.start + TRACK_SHELL_WIDTH) { |
| // 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, globals.frontendLocalState.windowSpan.start + TRACK_SHELL_WIDTH); |
| 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, _: number) { |
| if (x < 0) return; |
| const {visibleTimeScale} = globals.frontendLocalState; |
| const timestamp = visibleTimeScale.pxToHpTime(x).toTPTime(); |
| for (const note of Object.values(globals.state.notes)) { |
| if (this.hoveredX && this.mouseOverNote(this.hoveredX, note)) { |
| if (note.noteType === 'AREA') { |
| globals.makeSelection( |
| Actions.reSelectArea({areaId: note.areaId, noteId: note.id})); |
| } else { |
| globals.makeSelection(Actions.selectNote({id: note.id})); |
| } |
| return; |
| } |
| } |
| const color = randomColor(); |
| globals.makeSelection(Actions.addNote({timestamp, color})); |
| } |
| |
| private mouseOverNote(x: number, note: AreaNote|Note): boolean { |
| const timeScale = globals.frontendLocalState.visibleTimeScale; |
| const noteX = timeScale.tpTimeToPx(getStartTimestamp(note)); |
| if (note.noteType === 'AREA') { |
| const noteArea = globals.state.areas[note.areaId]; |
| return (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) || |
| (timeScale.tpTimeToPx(noteArea.end) > x && |
| x > timeScale.tpTimeToPx(noteArea.end) - AREA_TRIANGLE_WIDTH); |
| } else { |
| const width = FLAG_WIDTH; |
| return noteX <= x && x < noteX + width; |
| } |
| } |
| } |
| |
| interface NotesEditorTabConfig { |
| id: string; |
| } |
| |
| export class NotesEditorTab extends BottomTab<NotesEditorTabConfig> { |
| static readonly kind = 'org.perfetto.NotesEditorTab'; |
| |
| static create(args: NewBottomTabArgs): NotesEditorTab { |
| return new NotesEditorTab(args); |
| } |
| |
| constructor(args: NewBottomTabArgs) { |
| super(args); |
| } |
| |
| renderTabCanvas() {} |
| |
| getTitle() { |
| return 'Current Selection'; |
| } |
| |
| viewTab() { |
| const note = globals.state.notes[this.config.id]; |
| if (note === undefined) { |
| return m('.', `No Note with id ${this.config.id}`); |
| } |
| const startTime = getStartTimestamp(note); |
| return m( |
| '.notes-editor-panel', |
| m('.notes-editor-panel-heading-bar', |
| m('.notes-editor-panel-heading', |
| `Annotation at `, |
| m(Timestamp, {ts: asTPTimestamp(startTime)})), |
| m('input[type=text]', { |
| onkeydown: (e: Event) => { |
| e.stopImmediatePropagation(); |
| }, |
| value: note.text, |
| onchange: (e: InputEvent) => { |
| const newText = (e.target as HTMLInputElement).value; |
| globals.dispatch(Actions.changeNoteText({ |
| id: this.config.id, |
| newText, |
| })); |
| }, |
| }), |
| m('span.color-change', `Change color: `, m('input[type=color]', { |
| value: note.color, |
| onchange: (e: Event) => { |
| const newColor = (e.target as HTMLInputElement).value; |
| globals.dispatch(Actions.changeNoteColor({ |
| id: this.config.id, |
| newColor, |
| })); |
| }, |
| })), |
| m(Button, { |
| label: 'Remove', |
| icon: Icons.Delete, |
| minimal: true, |
| onclick: () => { |
| globals.dispatch(Actions.removeNote({id: this.config.id})); |
| globals.dispatch(Actions.setCurrentTab({tab: undefined})); |
| globals.rafScheduler.scheduleFullRedraw(); |
| }, |
| })), |
| ); |
| } |
| } |
| |
| bottomTabRegistry.register(NotesEditorTab); |