blob: f0edc213c06160fd92a2efdf025eeda111a44f73 [file] [log] [blame]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this 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 {classNames} from '../base/classnames';
import {currentTargetOffset} from '../base/dom_utils';
import {Bounds2D, Point2D, Vector2D} from '../base/geom';
import {Icons} from '../base/semantic_icons';
import {Button, ButtonBar} from './button';
import {Chip, ChipBar} from './chip';
import {Intent} from './common';
import {Icon} from './icon';
import {MiddleEllipsis} from './middle_ellipsis';
import {Popup} from './popup';
import {clamp} from '../base/math_utils';
/**
* The TrackWidget defines the look and style of a track.
*
* ┌──────────────────────────────────────────────────────────────────┐
* │pf-track (grid) │
* │┌─────────────────────────────────────────┐┌─────────────────────┐│
* ││pf-track-shell ││pf-track-content ││
* ││┌───────────────────────────────────────┐││ ││
* │││pf-track-menubar (sticky) │││ ││
* │││┌───────────────┐┌────────────────────┐│││ ││
* ││││pf-track-title ││pf-track-buttons ││││ ││
* │││└───────────────┘└────────────────────┘│││ ││
* ││└───────────────────────────────────────┘││ ││
* │└─────────────────────────────────────────┘└─────────────────────┘│
* └──────────────────────────────────────────────────────────────────┘
*/
export interface CrashButtonAttrs {
error: Error;
}
export class CrashButton implements m.ClassComponent<CrashButtonAttrs> {
view({attrs}: m.Vnode<CrashButtonAttrs>): m.Children {
return m(
Popup,
{
trigger: m(Button, {
icon: Icons.Crashed,
compact: true,
}),
},
this.renderErrorMessage(attrs.error),
);
}
private renderErrorMessage(error: Error): m.Children {
return m(
'',
'This track has crashed',
m(Button, {
label: 'Re-raise exception',
intent: Intent.Primary,
className: Popup.DISMISS_POPUP_GROUP_CLASS,
onclick: () => {
throw error;
},
}),
);
}
}
export interface TrackComponentAttrs {
// The title of this track.
readonly title: string;
// The full path to this track.
readonly path?: string;
// Show dropdown arrow and make clickable. Defaults to false.
readonly collapsible?: boolean;
// Show an up or down dropdown arrow.
readonly collapsed: boolean;
// Height of the track in pixels. All tracks have a fixed height.
readonly heightPx: number;
// Optional buttons to place on the RHS of the track shell.
readonly buttons?: m.Children;
// Optional list of chips to display after the track title.
readonly chips?: ReadonlyArray<string>;
// Optional error to display on this track.
readonly error?: Error | undefined;
// The integer indentation level of this track. If omitted, defaults to 0.
readonly indentationLevel?: number;
// Track titles are sticky. This is the offset in pixels from the top of the
// scrolling parent. Defaults to 0.
readonly topOffsetPx?: number;
// Issues a scrollTo() on this DOM element at creation time. Default: false.
readonly revealOnCreate?: boolean;
// Called when arrow clicked.
readonly onToggleCollapsed?: () => void;
// Style the component differently if it has children.
readonly isSummary?: boolean;
// HTML id applied to the root element.
readonly id: string;
// Whether to highlight the track or not.
readonly highlight?: boolean;
readonly onTrackContentMouseMove?: (
pos: Point2D,
contentSize: Bounds2D,
) => void;
readonly onTrackContentMouseOut?: () => void;
readonly onTrackContentClick?: (
pos: Point2D,
contentSize: Bounds2D,
) => boolean;
}
const TRACK_HEIGHT_MIN_PX = 18;
const INDENTATION_LEVEL_MAX = 16;
export class TrackWidget implements m.ClassComponent<TrackComponentAttrs> {
view({attrs}: m.CVnode<TrackComponentAttrs>) {
const {
indentationLevel = 0,
collapsible,
collapsed,
highlight,
heightPx,
id,
isSummary,
} = attrs;
const trackHeight = Math.max(heightPx, TRACK_HEIGHT_MIN_PX);
const expanded = collapsible && !collapsed;
return m(
'.pf-track',
{
id,
className: classNames(
expanded && 'pf-expanded',
highlight && 'pf-highlight',
isSummary && 'pf-is-summary',
),
style: {
// Note: Sub-pixel track heights can mess with sticky elements.
// Round up to the nearest integer number of pixels.
'--indent': clamp(indentationLevel, 0, INDENTATION_LEVEL_MAX),
'height': `${Math.ceil(trackHeight)}px`,
},
},
this.renderShell(attrs),
this.renderContent(attrs),
);
}
oncreate(vnode: m.VnodeDOM<TrackComponentAttrs>) {
this.onupdate(vnode);
if (vnode.attrs.revealOnCreate) {
vnode.dom.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
}
onupdate(vnode: m.VnodeDOM<TrackComponentAttrs>) {
this.decidePopupRequired(vnode.dom);
}
// Works out whether to display a title popup on hover, based on whether the
// current title is truncated.
private decidePopupRequired(dom: Element) {
const popupTitleElement = dom.querySelector(
'.pf-track-title-popup',
) as HTMLElement;
const truncatedTitleElement = dom.querySelector(
'.pf-middle-ellipsis',
) as HTMLElement;
if (popupTitleElement.clientWidth > truncatedTitleElement.clientWidth) {
popupTitleElement.classList.add('pf-visible');
} else {
popupTitleElement.classList.remove('pf-visible');
}
}
private renderShell(attrs: TrackComponentAttrs): m.Children {
const chips =
attrs.chips &&
m(
ChipBar,
attrs.chips.map((chip) =>
m(Chip, {label: chip, compact: true, rounded: true}),
),
);
const {topOffsetPx = 0, collapsible, collapsed} = attrs;
return m(
'.pf-track-shell',
{
className: classNames(collapsible && 'pf-clickable'),
onclick: (e: MouseEvent) => {
// Block all clicks on the shell from propagating through to the
// canvas
e.stopPropagation();
if (collapsible) {
attrs.onToggleCollapsed?.();
}
},
},
m(
'.pf-track-menubar',
{
style: {
position: 'sticky',
top: `${topOffsetPx}px`,
},
},
m(
'h1.pf-track-title',
{
ref: attrs.path, // TODO(stevegolton): Replace with aria tags?
},
collapsible &&
m(Icon, {icon: collapsed ? Icons.ExpandDown : Icons.ExpandUp}),
m(
MiddleEllipsis,
{text: attrs.title},
m('.pf-track-title-popup', attrs.title),
),
chips,
),
m(
ButtonBar,
{
className: 'pf-track-buttons',
// Block button clicks from hitting the shell's on click event
onclick: (e: MouseEvent) => e.stopPropagation(),
},
attrs.buttons,
attrs.error && m(CrashButton, {error: attrs.error}),
),
),
);
}
private mouseDownPos?: Vector2D;
private selectionOccurred = false;
private renderContent(attrs: TrackComponentAttrs): m.Children {
const {
heightPx,
onTrackContentMouseMove,
onTrackContentMouseOut,
onTrackContentClick,
} = attrs;
const trackHeight = Math.max(heightPx, TRACK_HEIGHT_MIN_PX);
return m('.pf-track-content', {
style: {
height: `${trackHeight}px`,
},
className: classNames(attrs.error && 'pf-track-content-error'),
onmousemove: (e: MouseEvent) => {
onTrackContentMouseMove?.(
currentTargetOffset(e),
getTargetContainerSize(e),
);
},
onmouseout: () => {
onTrackContentMouseOut?.();
},
onmousedown: (e: MouseEvent) => {
this.mouseDownPos = currentTargetOffset(e);
},
onmouseup: (e: MouseEvent) => {
if (!this.mouseDownPos) return;
if (
this.mouseDownPos.sub(currentTargetOffset(e)).manhattanDistance > 1
) {
this.selectionOccurred = true;
}
this.mouseDownPos = undefined;
},
onclick: (e: MouseEvent) => {
// This click event occurs after any selection mouse up/drag events
// so we have to look if the mouse moved during this click to know
// if a selection occurred.
if (this.selectionOccurred) {
this.selectionOccurred = false;
return;
}
// Returns true if something was selected, so stop propagation.
if (
onTrackContentClick?.(
currentTargetOffset(e),
getTargetContainerSize(e),
)
) {
e.stopPropagation();
}
},
});
}
}
function getTargetContainerSize(event: MouseEvent): Bounds2D {
const target = event.target as HTMLElement;
return target.getBoundingClientRect();
}