xref: /aosp_15_r20/external/perfetto/ui/src/widgets/track_widget.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {classNames} from '../base/classnames';
17import {currentTargetOffset} from '../base/dom_utils';
18import {Bounds2D, Point2D, Vector2D} from '../base/geom';
19import {Icons} from '../base/semantic_icons';
20import {ButtonBar} from './button';
21import {Chip, ChipBar} from './chip';
22import {Icon} from './icon';
23import {MiddleEllipsis} from './middle_ellipsis';
24import {clamp} from '../base/math_utils';
25
26/**
27 * The TrackWidget defines the look and style of a track.
28 *
29 * ┌──────────────────────────────────────────────────────────────────┐
30 * │pf-track (grid)                                                   │
31 * │┌─────────────────────────────────────────┐┌─────────────────────┐│
32 * ││pf-track-shell                           ││pf-track-content     ││
33 * ││┌───────────────────────────────────────┐││                     ││
34 * │││pf-track-menubar (sticky)              │││                     ││
35 * │││┌───────────────┐┌────────────────────┐│││                     ││
36 * ││││pf-track-title ││pf-track-buttons    ││││                     ││
37 * │││└───────────────┘└────────────────────┘│││                     ││
38 * ││└───────────────────────────────────────┘││                     ││
39 * │└─────────────────────────────────────────┘└─────────────────────┘│
40 * └──────────────────────────────────────────────────────────────────┘
41 */
42
43export interface TrackComponentAttrs {
44  // The title of this track.
45  readonly title: string;
46
47  // The full path to this track.
48  readonly path?: string;
49
50  // Show dropdown arrow and make clickable. Defaults to false.
51  readonly collapsible?: boolean;
52
53  // Show an up or down dropdown arrow.
54  readonly collapsed: boolean;
55
56  // Height of the track in pixels. All tracks have a fixed height.
57  readonly heightPx: number;
58
59  // Optional buttons to place on the RHS of the track shell.
60  readonly buttons?: m.Children;
61
62  // Optional list of chips to display after the track title.
63  readonly chips?: ReadonlyArray<string>;
64
65  // Render this track in error colours.
66  readonly error?: boolean;
67
68  // The integer indentation level of this track. If omitted, defaults to 0.
69  readonly indentationLevel?: number;
70
71  // Track titles are sticky. This is the offset in pixels from the top of the
72  // scrolling parent. Defaults to 0.
73  readonly topOffsetPx?: number;
74
75  // Issues a scrollTo() on this DOM element at creation time. Default: false.
76  readonly revealOnCreate?: boolean;
77
78  // Called when arrow clicked.
79  readonly onToggleCollapsed?: () => void;
80
81  // Style the component differently if it has children.
82  readonly isSummary?: boolean;
83
84  // HTML id applied to the root element.
85  readonly id: string;
86
87  // Whether to highlight the track or not.
88  readonly highlight?: boolean;
89
90  // Whether the shell should be draggable and emit drag/drop events.
91  readonly reorderable?: boolean;
92
93  // Mouse events.
94  readonly onTrackContentMouseMove?: (
95    pos: Point2D,
96    contentSize: Bounds2D,
97  ) => void;
98  readonly onTrackContentMouseOut?: () => void;
99  readonly onTrackContentClick?: (
100    pos: Point2D,
101    contentSize: Bounds2D,
102  ) => boolean;
103
104  // If reorderable, these functions will be called when track shells are
105  // dragged and dropped.
106  readonly onMoveBefore?: (nodeId: string) => void;
107  readonly onMoveAfter?: (nodeId: string) => void;
108}
109
110const TRACK_HEIGHT_MIN_PX = 18;
111const INDENTATION_LEVEL_MAX = 16;
112
113export class TrackWidget implements m.ClassComponent<TrackComponentAttrs> {
114  view({attrs}: m.CVnode<TrackComponentAttrs>) {
115    const {
116      indentationLevel = 0,
117      collapsible,
118      collapsed,
119      highlight,
120      heightPx,
121      id,
122      isSummary,
123    } = attrs;
124
125    const trackHeight = Math.max(heightPx, TRACK_HEIGHT_MIN_PX);
126    const expanded = collapsible && !collapsed;
127
128    return m(
129      '.pf-track',
130      {
131        id,
132        className: classNames(
133          expanded && 'pf-expanded',
134          highlight && 'pf-highlight',
135          isSummary && 'pf-is-summary',
136        ),
137        style: {
138          // Note: Sub-pixel track heights can mess with sticky elements.
139          // Round up to the nearest integer number of pixels.
140          '--indent': clamp(indentationLevel, 0, INDENTATION_LEVEL_MAX),
141          'height': `${Math.ceil(trackHeight)}px`,
142        },
143      },
144      this.renderShell(attrs),
145      this.renderContent(attrs),
146    );
147  }
148
149  oncreate(vnode: m.VnodeDOM<TrackComponentAttrs>) {
150    this.onupdate(vnode);
151
152    if (vnode.attrs.revealOnCreate) {
153      vnode.dom.scrollIntoView({behavior: 'smooth', block: 'nearest'});
154    }
155  }
156
157  onupdate(vnode: m.VnodeDOM<TrackComponentAttrs>) {
158    this.decidePopupRequired(vnode.dom);
159  }
160
161  // Works out whether to display a title popup on hover, based on whether the
162  // current title is truncated.
163  private decidePopupRequired(dom: Element) {
164    const popupTitleElement = dom.querySelector(
165      '.pf-track-title-popup',
166    ) as HTMLElement;
167    const truncatedTitleElement = dom.querySelector(
168      '.pf-middle-ellipsis',
169    ) as HTMLElement;
170
171    if (popupTitleElement.clientWidth > truncatedTitleElement.clientWidth) {
172      popupTitleElement.classList.add('pf-visible');
173    } else {
174      popupTitleElement.classList.remove('pf-visible');
175    }
176  }
177
178  private renderShell(attrs: TrackComponentAttrs): m.Children {
179    const chips =
180      attrs.chips &&
181      m(
182        ChipBar,
183        attrs.chips.map((chip) =>
184          m(Chip, {label: chip, compact: true, rounded: true}),
185        ),
186      );
187
188    const {
189      id,
190      topOffsetPx = 0,
191      collapsible,
192      collapsed,
193      reorderable = false,
194      onMoveAfter = () => {},
195      onMoveBefore = () => {},
196    } = attrs;
197
198    return m(
199      `.pf-track-shell[data-track-node-id=${id}]`,
200      {
201        className: classNames(collapsible && 'pf-clickable'),
202        onclick: (e: MouseEvent) => {
203          // Block all clicks on the shell from propagating through to the
204          // canvas
205          e.stopPropagation();
206          if (collapsible) {
207            attrs.onToggleCollapsed?.();
208          }
209        },
210        draggable: reorderable,
211        ondragstart: (e: DragEvent) => {
212          e.dataTransfer?.setData('text/plain', id);
213        },
214        ondragover: (e: DragEvent) => {
215          if (!reorderable) {
216            return;
217          }
218          const target = e.currentTarget as HTMLElement;
219          const threshold = target.offsetHeight / 2;
220          if (e.offsetY > threshold) {
221            target.classList.remove('pf-drag-before');
222            target.classList.add('pf-drag-after');
223          } else {
224            target.classList.remove('pf-drag-after');
225            target.classList.add('pf-drag-before');
226          }
227        },
228        ondragleave: (e: DragEvent) => {
229          if (!reorderable) {
230            return;
231          }
232          const target = e.currentTarget as HTMLElement;
233          const related = e.relatedTarget as HTMLElement | null;
234          if (related && !target.contains(related)) {
235            target.classList.remove('pf-drag-after');
236            target.classList.remove('pf-drag-before');
237          }
238        },
239        ondrop: (e: DragEvent) => {
240          if (!reorderable) {
241            return;
242          }
243          const id = e.dataTransfer?.getData('text/plain');
244          const target = e.currentTarget as HTMLElement;
245          const threshold = target.offsetHeight / 2;
246          if (id !== undefined) {
247            if (e.offsetY > threshold) {
248              onMoveAfter(id);
249            } else {
250              onMoveBefore(id);
251            }
252          }
253          target.classList.remove('pf-drag-after');
254          target.classList.remove('pf-drag-before');
255        },
256      },
257      m(
258        '.pf-track-menubar',
259        {
260          style: {
261            position: 'sticky',
262            top: `${topOffsetPx}px`,
263          },
264        },
265        m(
266          'h1.pf-track-title',
267          {
268            ref: attrs.path, // TODO(stevegolton): Replace with aria tags?
269          },
270          collapsible &&
271            m(Icon, {icon: collapsed ? Icons.ExpandDown : Icons.ExpandUp}),
272          m(
273            MiddleEllipsis,
274            {text: attrs.title},
275            m('.pf-track-title-popup', attrs.title),
276          ),
277          chips,
278        ),
279        m(
280          ButtonBar,
281          {
282            className: 'pf-track-buttons',
283            // Block button clicks from hitting the shell's on click event
284            onclick: (e: MouseEvent) => e.stopPropagation(),
285          },
286          attrs.buttons,
287        ),
288      ),
289    );
290  }
291
292  private mouseDownPos?: Vector2D;
293  private selectionOccurred = false;
294
295  private renderContent(attrs: TrackComponentAttrs): m.Children {
296    const {
297      heightPx,
298      onTrackContentMouseMove,
299      onTrackContentMouseOut,
300      onTrackContentClick,
301    } = attrs;
302    const trackHeight = Math.max(heightPx, TRACK_HEIGHT_MIN_PX);
303
304    return m('.pf-track-content', {
305      style: {
306        height: `${trackHeight}px`,
307      },
308      className: classNames(attrs.error && 'pf-track-content-error'),
309      onmousemove: (e: MouseEvent) => {
310        onTrackContentMouseMove?.(
311          currentTargetOffset(e),
312          getTargetContainerSize(e),
313        );
314      },
315      onmouseout: () => {
316        onTrackContentMouseOut?.();
317      },
318      onmousedown: (e: MouseEvent) => {
319        this.mouseDownPos = currentTargetOffset(e);
320      },
321      onmouseup: (e: MouseEvent) => {
322        if (!this.mouseDownPos) return;
323        if (
324          this.mouseDownPos.sub(currentTargetOffset(e)).manhattanDistance > 1
325        ) {
326          this.selectionOccurred = true;
327        }
328        this.mouseDownPos = undefined;
329      },
330      onclick: (e: MouseEvent) => {
331        // This click event occurs after any selection mouse up/drag events
332        // so we have to look if the mouse moved during this click to know
333        // if a selection occurred.
334        if (this.selectionOccurred) {
335          this.selectionOccurred = false;
336          return;
337        }
338
339        // Returns true if something was selected, so stop propagation.
340        if (
341          onTrackContentClick?.(
342            currentTargetOffset(e),
343            getTargetContainerSize(e),
344          )
345        ) {
346          e.stopPropagation();
347        }
348      },
349    });
350  }
351}
352
353function getTargetContainerSize(event: MouseEvent): Bounds2D {
354  const target = event.target as HTMLElement;
355  return target.getBoundingClientRect();
356}
357