• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 {DisposableStack} from '../base/disposable_stack';
18import {currentTargetOffset} from '../base/dom_utils';
19import {Bounds2D, Point2D, Vector2D} from '../base/geom';
20import {assertExists} from '../base/logging';
21import {clamp} from '../base/math_utils';
22import {hasChildren, MithrilEvent} from '../base/mithril_utils';
23import {Icons} from '../base/semantic_icons';
24import {Button, ButtonBar} from './button';
25import {Chip, ChipBar} from './chip';
26import {HTMLAttrs, Intent} from './common';
27import {MiddleEllipsis} from './middle_ellipsis';
28import {Popup} from './popup';
29
30/**
31 * This component defines the look and style of the DOM parts of a track (mainly
32 * the 'shell' part).
33 *
34 * ┌───────────────────────────────────────────────────────────────────────────┐
35 * │ pf-track                                                                  │
36 * |┌─────────────────────────────────────────────────────────────────────────┐|
37 * || pf-track__header                                                        ||
38 * │|┌─────────┐┌─────────────────────────────────────────┐┌─────────────────┐│|
39 * │|│::before ||pf-track__shell                          ││pf-track__content││|
40 * │|│(Indent) ||┌───────────────────────────────────────┐││                 ││|
41 * │|│         ||│pf-track__menubar (sticky)             │││                 ││|
42 * │|│         ||│┌───────────────┐┌────────────────────┐│││                 ││|
43 * │|│         ||││pf-track__title││pf-track__buttons   ││││                 ││|
44 * │|│         ||│└───────────────┘└────────────────────┘│││                 ││|
45 * │|│         ||└───────────────────────────────────────┘││                 ││|
46 * │|└─────────┘└─────────────────────────────────────────┘└─────────────────┘│|
47 * |└─────────────────────────────────────────────────────────────────────────┘|
48 * |┌─────────────────────────────────────────────────────────────────────────┐|
49 * || pf-track__children (if children supplied)                               ||
50 * |└─────────────────────────────────────────────────────────────────────────┘|
51 * └───────────────────────────────────────────────────────────────────────────┘
52 */
53
54export interface TrackShellAttrs extends HTMLAttrs {
55  // The title of this track.
56  readonly title: string;
57
58  // Optional subtitle to display underneath the track name.
59  readonly subtitle?: string;
60
61  // Show dropdown arrow and make clickable. Defaults to false.
62  readonly collapsible?: boolean;
63
64  // Show an up or down dropdown arrow.
65  readonly collapsed: boolean;
66
67  // Height of the track in pixels. All tracks have a fixed height.
68  readonly heightPx: number;
69
70  // Optional buttons to place on the RHS of the track shell.
71  readonly buttons?: m.Children;
72
73  // Optional list of chips to display after the track title.
74  readonly chips?: ReadonlyArray<string>;
75
76  // Render this track in error colours.
77  readonly error?: Error;
78
79  // Issues a scrollTo() on this DOM element at creation time. Default: false.
80  readonly scrollToOnCreate?: boolean;
81
82  // Style the component differently.
83  readonly summary?: boolean;
84
85  // Whether to highlight the track or not.
86  readonly highlight?: boolean;
87
88  // Whether the shell should be draggable and emit drag/drop events.
89  readonly reorderable?: boolean;
90
91  // This is the depth of the track in the tree - controls the indent level and
92  // the z-index of sticky headers.
93  readonly depth?: number;
94
95  // The stick top offset - this is the offset from the top of sticky summary
96  // track headers and sticky menu bars stick from the top of the viewport. This
97  // is used to allow nested sticky track headers and menubars of nested tracks
98  // to stick below the sticky header of their parent track(s).
99  readonly stickyTop?: number;
100
101  // The ID of the plugin that created this track.
102  readonly pluginId?: string;
103
104  // Render a lighter version of the track shell, with no buttons or chips, just
105  // the track title.
106  readonly lite?: boolean;
107
108  // Called when the track is expanded or collapsed (when the node is clicked).
109  onCollapsedChanged?(collapsed: boolean): void;
110
111  // Mouse events within the track content element.
112  onTrackContentMouseMove?(pos: Point2D, contentSize: Bounds2D): void;
113  onTrackContentMouseOut?(): void;
114  onTrackContentClick?(pos: Point2D, contentSize: Bounds2D): boolean;
115
116  // If reorderable, these functions will be called when track shells are
117  // dragged and dropped.
118  onMoveBefore?(nodeId: string): void;
119  onMoveInside?(nodeId: string): void;
120  onMoveAfter?(nodeId: string): void;
121}
122
123export class TrackShell implements m.ClassComponent<TrackShellAttrs> {
124  private mouseDownPos?: Vector2D;
125  private selectionOccurred = false;
126  private scrollIntoView = false;
127
128  view(vnode: m.CVnode<TrackShellAttrs>) {
129    const {attrs} = vnode;
130
131    const {
132      collapsible,
133      collapsed,
134      id,
135      summary,
136      heightPx,
137      ref,
138      depth = 0,
139      stickyTop = 0,
140      lite,
141    } = attrs;
142
143    const expanded = collapsible && !collapsed;
144    const trackHeight = heightPx;
145
146    return m(
147      '.pf-track',
148      {
149        id,
150        style: {
151          '--height': trackHeight,
152          '--depth': clamp(depth, 0, 16),
153          '--sticky-top': Math.max(0, stickyTop),
154        },
155        ref,
156      },
157      m(
158        '.pf-track__header',
159        {
160          className: classNames(
161            summary && 'pf-track__header--summary',
162            expanded && 'pf-track__header--expanded',
163            summary && expanded && 'pf-track__header--expanded--summary',
164          ),
165        },
166        this.renderShell(attrs),
167        !lite && this.renderContent(attrs),
168      ),
169      hasChildren(vnode) && m('.pf-track__children', vnode.children),
170    );
171  }
172
173  oncreate({dom, attrs}: m.VnodeDOM<TrackShellAttrs>) {
174    if (attrs.scrollToOnCreate) {
175      dom.scrollIntoView({behavior: 'smooth', block: 'nearest'});
176    }
177  }
178
179  onupdate({dom}: m.VnodeDOM<TrackShellAttrs, this>) {
180    if (this.scrollIntoView) {
181      dom.scrollIntoView({behavior: 'instant', block: 'nearest'});
182      this.scrollIntoView = false;
183    }
184  }
185
186  private renderShell(attrs: TrackShellAttrs): m.Children {
187    const {
188      id,
189      chips,
190      collapsible,
191      collapsed,
192      reorderable = false,
193      onMoveAfter = () => {},
194      onMoveBefore = () => {},
195      onMoveInside = () => {},
196      buttons,
197      highlight,
198      lite,
199      summary,
200    } = attrs;
201
202    const block = 'pf-track';
203    const blockElement = `${block}__shell`;
204    const dragBeforeClassName = `${blockElement}--drag-before`;
205    const dragInsideClassName = `${blockElement}--drag-inside`;
206    const dragAfterClassName = `${blockElement}--drag-after`;
207
208    function updateDragClassname(target: HTMLElement, className: string) {
209      // This is a bit brute-force, but gets the job done without triggering a
210      // full mithril redraw every frame while dragging...
211      target.classList.remove(dragBeforeClassName);
212      target.classList.remove(dragAfterClassName);
213      target.classList.remove(dragInsideClassName);
214      target.classList.add(className);
215    }
216
217    return m(
218      `.pf-track__shell`,
219      {
220        className: classNames(
221          collapsible && 'pf-track__shell--clickable',
222          highlight && 'pf-track__shell--highlight',
223        ),
224        onclick: () => {
225          collapsible && attrs.onCollapsedChanged?.(!collapsed);
226          if (!collapsed) {
227            this.scrollIntoView = true;
228          }
229        },
230        draggable: reorderable,
231        ondragstart: (e: DragEvent) => {
232          id && e.dataTransfer?.setData('text/plain', id);
233        },
234        ondragover: (e: DragEvent) => {
235          if (!reorderable) {
236            return;
237          }
238          const target = e.currentTarget as HTMLElement;
239          const position = currentTargetOffset(e);
240          if (summary) {
241            // For summary tracks, split the track into thirds, so it's
242            // possible to insert above, below and into.
243            const threshold = target.offsetHeight / 3;
244            if (position.y < threshold) {
245              // Hovering on the upper third, move before this node.
246              updateDragClassname(target, dragBeforeClassName);
247            } else if (position.y < threshold * 2) {
248              // Hovering in the middle, move inside this node.
249              updateDragClassname(target, dragInsideClassName);
250            } else {
251              // Hovering on the lower third, move after this node.
252              updateDragClassname(target, dragAfterClassName);
253            }
254          } else {
255            // For non-summary tracks, split the track in half, as it's only
256            // possible to insert before and after.
257            const threshold = target.offsetHeight / 2;
258            if (position.y < threshold) {
259              updateDragClassname(target, dragBeforeClassName);
260            } else {
261              updateDragClassname(target, dragAfterClassName);
262            }
263          }
264        },
265        ondragleave: (e: DragEvent) => {
266          if (!reorderable) {
267            return;
268          }
269          const target = e.currentTarget as HTMLElement;
270          const related = e.relatedTarget as HTMLElement | null;
271          if (related && !target.contains(related)) {
272            target.classList.remove(dragAfterClassName);
273            target.classList.remove(dragBeforeClassName);
274          }
275        },
276        ondrop: (e: DragEvent) => {
277          if (!reorderable) {
278            return;
279          }
280          const id = e.dataTransfer?.getData('text/plain');
281          const target = e.currentTarget as HTMLElement;
282          const position = currentTargetOffset(e);
283
284          if (id !== undefined) {
285            if (summary) {
286              // For summary tracks, split the track into thirds, so it's
287              // possible to insert above, below and into.
288              const threshold = target.offsetHeight / 3;
289              if (position.y < threshold) {
290                // Dropped on the upper third, move before this node.
291                onMoveBefore(id);
292              } else if (position.y < threshold * 2) {
293                // Dropped in the middle, move inside this node.
294                onMoveInside(id);
295              } else {
296                // Dropped on the lower third, move after this node.
297                onMoveAfter(id);
298              }
299            } else {
300              // For non-summary tracks, split the track in half, as it's only
301              // possible to insert before and after.
302              const threshold = target.offsetHeight / 2;
303              if (position.y < threshold) {
304                onMoveBefore(id);
305              } else {
306                onMoveAfter(id);
307              }
308            }
309          }
310
311          // Remove all the modifiers
312          target.classList.remove(dragAfterClassName);
313          target.classList.remove(dragInsideClassName);
314          target.classList.remove(dragBeforeClassName);
315        },
316      },
317      lite
318        ? attrs.title
319        : m(
320            '.pf-track__menubar',
321            collapsible
322              ? m(Button, {
323                  className: 'pf-track__collapse-button',
324                  compact: true,
325                  icon: collapsed ? Icons.ExpandDown : Icons.ExpandUp,
326                })
327              : m('.pf-track__title-spacer'),
328            m(TrackTitle, {title: attrs.title}),
329            chips &&
330              m(
331                ChipBar,
332                {className: 'pf-track__chips'},
333                chips.map((chip) =>
334                  m(Chip, {label: chip, compact: true, rounded: true}),
335                ),
336              ),
337            m(
338              ButtonBar,
339              {
340                className: 'pf-track__buttons',
341                // Block button clicks from hitting the shell's on click event
342                onclick: (e: MouseEvent) => e.stopPropagation(),
343              },
344              buttons,
345              // Always render this one last
346              attrs.error && renderCrashButton(attrs.error, attrs.pluginId),
347            ),
348            attrs.subtitle &&
349              !showSubtitleInContent(attrs) &&
350              m(
351                '.pf-track__subtitle',
352                m(MiddleEllipsis, {text: attrs.subtitle}),
353              ),
354          ),
355    );
356  }
357
358  private renderContent(attrs: TrackShellAttrs): m.Children {
359    const {
360      onTrackContentMouseMove,
361      onTrackContentMouseOut,
362      onTrackContentClick,
363      error,
364    } = attrs;
365
366    return m(
367      '.pf-track__canvas',
368      {
369        className: classNames(error && 'pf-track__canvas--error'),
370        onmousemove: (e: MithrilEvent<MouseEvent>) => {
371          e.redraw = false;
372          onTrackContentMouseMove?.(
373            currentTargetOffset(e),
374            getTargetContainerSize(e),
375          );
376        },
377        onmouseout: () => {
378          onTrackContentMouseOut?.();
379        },
380        onmousedown: (e: MouseEvent) => {
381          this.mouseDownPos = currentTargetOffset(e);
382        },
383        onmouseup: (e: MouseEvent) => {
384          if (!this.mouseDownPos) return;
385          if (
386            this.mouseDownPos.sub(currentTargetOffset(e)).manhattanDistance > 1
387          ) {
388            this.selectionOccurred = true;
389          }
390          this.mouseDownPos = undefined;
391        },
392        onclick: (e: MouseEvent) => {
393          // This click event occurs after any selection mouse up/drag events
394          // so we have to look if the mouse moved during this click to know
395          // if a selection occurred.
396          if (this.selectionOccurred) {
397            this.selectionOccurred = false;
398            return;
399          }
400
401          // Returns true if something was selected, so stop propagation.
402          if (
403            onTrackContentClick?.(
404              currentTargetOffset(e),
405              getTargetContainerSize(e),
406            )
407          ) {
408            e.stopPropagation();
409          }
410        },
411      },
412      attrs.subtitle &&
413        showSubtitleInContent(attrs) &&
414        m(MiddleEllipsis, {text: attrs.subtitle}),
415    );
416  }
417}
418
419function showSubtitleInContent(attrs: TrackShellAttrs) {
420  return attrs.summary && !attrs.collapsed;
421}
422
423function getTargetContainerSize(event: MouseEvent): Bounds2D {
424  const target = event.target as HTMLElement;
425  return target.getBoundingClientRect();
426}
427
428function renderCrashButton(error: Error, pluginId: string | undefined) {
429  return m(
430    Popup,
431    {
432      trigger: m(Button, {
433        icon: Icons.Crashed,
434        compact: true,
435      }),
436    },
437    m(
438      '.pf-track__crash-popup',
439      m('span', 'This track has crashed.'),
440      pluginId && m('span', `Owning plugin: ${pluginId}`),
441      m(Button, {
442        label: 'View & Report Crash',
443        intent: Intent.Primary,
444        className: Popup.DISMISS_POPUP_GROUP_CLASS,
445        onclick: () => {
446          throw error;
447        },
448      }),
449      // TODO(stevegolton): In the future we should provide a quick way to
450      // disable the plugin, or provide a link to the plugin page, but this
451      // relies on the plugin page being fully functional.
452    ),
453  );
454}
455
456interface TrackTitleAttrs {
457  readonly title: string;
458}
459
460class TrackTitle implements m.ClassComponent<TrackTitleAttrs> {
461  private readonly trash = new DisposableStack();
462
463  view({attrs}: m.Vnode<TrackTitleAttrs>) {
464    return m(
465      MiddleEllipsis,
466      {
467        className: 'pf-track__title',
468        text: attrs.title,
469      },
470      m('.pf-track__title-popup', attrs.title),
471    );
472  }
473
474  oncreate({dom}: m.VnodeDOM<TrackTitleAttrs>) {
475    const title = dom;
476    const popup = assertExists(dom.querySelector('.pf-track__title-popup'));
477
478    const resizeObserver = new ResizeObserver(() => {
479      // Determine whether to display a title popup based on ellipsization
480      if (popup.clientWidth > title.clientWidth) {
481        popup.classList.add('pf-track__title-popup--visible');
482      } else {
483        popup.classList.remove('pf-track__title-popup--visible');
484      }
485    });
486
487    resizeObserver.observe(title);
488    resizeObserver.observe(popup);
489
490    this.trash.defer(() => resizeObserver.disconnect());
491  }
492
493  onremove() {
494    this.trash.dispose();
495  }
496}
497