| // Copyright (C) 2018 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 {hex} from 'color-convert'; |
| import m from 'mithril'; |
| import {currentTargetOffset} from '../base/dom_utils'; |
| import {Icons} from '../base/semantic_icons'; |
| import {TimeSpan} from '../base/time'; |
| import {TrackRenderer} from '../core/track_manager'; |
| import {raf} from '../core/raf_scheduler'; |
| import {Track, TrackTags} from '../public/track'; |
| import {checkerboard} from './checkerboard'; |
| import { |
| SELECTION_FILL_COLOR, |
| TRACK_BORDER_COLOR, |
| TRACK_SHELL_WIDTH, |
| } from './css_constants'; |
| import {globals} from './globals'; |
| import {generateTicks, TickType, getMaxMajorTicks} from './gridline_helper'; |
| import {Size2D, VerticalBounds} from '../base/geom'; |
| import {Panel} from './panel_container'; |
| import {drawVerticalLineAtTime} from './vertical_line_helper'; |
| import {classNames} from '../base/classnames'; |
| import {Button, ButtonBar} from '../widgets/button'; |
| import {Popup, PopupPosition} from '../widgets/popup'; |
| import {canvasClip} from '../base/canvas_utils'; |
| import {TimeScale} from '../base/time_scale'; |
| import {exists, Optional} from '../base/utils'; |
| import {Intent} from '../widgets/common'; |
| import {TrackRenderContext} from '../public/track'; |
| import {calculateResolution} from '../common/resolution'; |
| import {featureFlags} from '../core/feature_flags'; |
| import {Tree, TreeNode} from '../widgets/tree'; |
| import {TrackNode} from '../public/workspace'; |
| import {MiddleEllipsis} from '../widgets/middle_ellipsis'; |
| |
| export const SHOW_TRACK_DETAILS_BUTTON = featureFlags.register({ |
| id: 'showTrackDetailsButton', |
| name: 'Show track details button', |
| description: 'Show track details button in track shells.', |
| defaultValue: false, |
| }); |
| |
| export function getTitleFontSize(title: string): string | undefined { |
| const length = title.length; |
| if (length > 55) { |
| return '9px'; |
| } |
| if (length > 50) { |
| return '10px'; |
| } |
| if (length > 45) { |
| return '11px'; |
| } |
| if (length > 40) { |
| return '12px'; |
| } |
| if (length > 35) { |
| return '13px'; |
| } |
| return undefined; |
| } |
| |
| function isTrackSelected(track: TrackNode) { |
| const selection = globals.selectionManager.selection; |
| if (selection.kind !== 'area') return false; |
| return selection.trackUris.includes(track.uri); |
| } |
| |
| interface TrackChipAttrs { |
| text: string; |
| } |
| |
| class TrackChip implements m.ClassComponent<TrackChipAttrs> { |
| view({attrs}: m.CVnode<TrackChipAttrs>) { |
| return m('span.chip', attrs.text); |
| } |
| } |
| |
| export function renderChips(chips: ReadonlyArray<string>) { |
| return chips.map((chip) => m(TrackChip, {text: chip})); |
| } |
| |
| 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; |
| }, |
| }), |
| ); |
| } |
| } |
| |
| interface TrackShellAttrs { |
| readonly title: string; |
| readonly buttons: m.Children; |
| readonly tags?: TrackTags; |
| readonly chips?: ReadonlyArray<string>; |
| readonly button?: string; |
| readonly pluginId?: string; |
| readonly track: TrackNode; |
| } |
| |
| class TrackShell implements m.ClassComponent<TrackShellAttrs> { |
| // Set to true when we click down and drag the |
| private dragging = false; |
| private dropping: 'before' | 'after' | undefined = undefined; |
| |
| view({attrs}: m.CVnode<TrackShellAttrs>) { |
| // The shell should be highlighted if the current search result is inside |
| // this track. |
| let highlightClass = undefined; |
| const searchIndex = globals.searchManager.resultIndex; |
| const searchResults = globals.searchManager.searchResults; |
| if (searchIndex !== -1 && searchResults !== undefined) { |
| const uri = searchResults.trackUris[searchIndex]; |
| if (uri === attrs.track.uri) { |
| highlightClass = 'flash'; |
| } |
| } |
| |
| const currentSelection = globals.selectionManager.selection; |
| const pinned = attrs.track.isPinned; |
| const chips = attrs.chips && renderChips(attrs.chips); |
| |
| return m( |
| `.track-shell[draggable=true]`, |
| { |
| className: classNames( |
| highlightClass, |
| this.dragging && 'drag', |
| this.dropping && `drop-${this.dropping}`, |
| ), |
| ondragstart: (e: DragEvent) => this.ondragstart(e, attrs.track), |
| ondragend: this.ondragend.bind(this), |
| ondragover: this.ondragover.bind(this), |
| ondragleave: this.ondragleave.bind(this), |
| ondrop: (e: DragEvent) => this.ondrop(e, attrs.track), |
| }, |
| m( |
| '.track-menubar', |
| m( |
| 'h1', |
| { |
| ref: attrs.title, |
| }, |
| m('.popup', attrs.title, chips), |
| m(MiddleEllipsis, {text: attrs.title}, chips), |
| ), |
| m( |
| ButtonBar, |
| {className: 'track-buttons'}, |
| attrs.buttons, |
| SHOW_TRACK_DETAILS_BUTTON.get() && |
| this.renderTrackDetailsButton(attrs), |
| m(Button, { |
| className: classNames(!pinned && 'pf-visible-on-hover'), |
| onclick: () => { |
| pinned ? attrs.track.unpin() : attrs.track.pin(); |
| raf.scheduleFullRedraw(); |
| }, |
| icon: Icons.Pin, |
| iconFilled: pinned, |
| title: pinned ? 'Unpin' : 'Pin to top', |
| compact: true, |
| }), |
| currentSelection.kind === 'area' |
| ? m(Button, { |
| onclick: (e: MouseEvent) => { |
| globals.selectionManager.toggleTrackAreaSelection( |
| attrs.track.uri, |
| ); |
| e.stopPropagation(); |
| }, |
| compact: true, |
| icon: isTrackSelected(attrs.track) |
| ? Icons.Checkbox |
| : Icons.BlankCheckbox, |
| title: isTrackSelected(attrs.track) |
| ? 'Remove track' |
| : 'Add track to selection', |
| }) |
| : '', |
| ), |
| ), |
| ); |
| } |
| |
| ondragstart(e: DragEvent, track: TrackNode) { |
| const dataTransfer = e.dataTransfer; |
| if (dataTransfer === null) return; |
| this.dragging = true; |
| raf.scheduleFullRedraw(); |
| dataTransfer.setData('perfetto/track', `${track.uri}`); |
| dataTransfer.setDragImage(new Image(), 0, 0); |
| } |
| |
| ondragend() { |
| this.dragging = false; |
| raf.scheduleFullRedraw(); |
| } |
| |
| ondragover(e: DragEvent) { |
| if (this.dragging) return; |
| if (!(e.target instanceof HTMLElement)) return; |
| const dataTransfer = e.dataTransfer; |
| if (dataTransfer === null) return; |
| if (!dataTransfer.types.includes('perfetto/track')) return; |
| dataTransfer.dropEffect = 'move'; |
| e.preventDefault(); |
| |
| // Apply some hysteresis to the drop logic so that the lightened border |
| // changes only when we get close enough to the border. |
| if (e.offsetY < e.target.scrollHeight / 3) { |
| this.dropping = 'before'; |
| } else if (e.offsetY > (e.target.scrollHeight / 3) * 2) { |
| this.dropping = 'after'; |
| } |
| raf.scheduleFullRedraw(); |
| } |
| |
| ondragleave() { |
| this.dropping = undefined; |
| raf.scheduleFullRedraw(); |
| } |
| |
| ondrop(e: DragEvent, track: TrackNode) { |
| if (this.dropping === undefined) return; |
| const dataTransfer = e.dataTransfer; |
| if (dataTransfer === null) return; |
| raf.scheduleFullRedraw(); |
| const srcId = dataTransfer.getData('perfetto/track'); |
| const dstId = track.uri; |
| console.log(srcId, dstId); |
| // globals.dispatch(Actions.moveTrack({srcId, op: this.dropping, dstId})); |
| this.dropping = undefined; |
| } |
| |
| private renderTrackDetailsButton(attrs: TrackShellAttrs) { |
| let parent = attrs.track.parent; |
| let fullPath: m.ChildArray = [attrs.track.displayName]; |
| while (parent && parent !== globals.workspace) { |
| fullPath = [parent.displayName, ' \u2023 ', ...fullPath]; |
| parent = parent.parent; |
| } |
| return m( |
| Popup, |
| { |
| trigger: m(Button, { |
| className: 'pf-visible-on-hover', |
| icon: 'info', |
| title: 'Show track details', |
| compact: true, |
| }), |
| position: PopupPosition.RightStart, |
| }, |
| m( |
| '.pf-track-details-dropdown', |
| m( |
| Tree, |
| m(TreeNode, { |
| left: 'URI', |
| right: attrs.track.uri, |
| }), |
| m(TreeNode, { |
| left: 'Key', |
| right: attrs.track.uri, |
| }), |
| m(TreeNode, {left: 'Path', right: fullPath}), |
| m(TreeNode, {left: 'Display Name', right: attrs.track.displayName}), |
| m(TreeNode, {left: 'Plugin ID', right: attrs.pluginId}), |
| m( |
| TreeNode, |
| {left: 'Tags'}, |
| attrs.tags && |
| Object.entries(attrs.tags).map(([key, value]) => { |
| return m(TreeNode, {left: key, right: value?.toString()}); |
| }), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| export interface TrackContentAttrs { |
| track: Track; |
| hasError?: boolean; |
| height?: number; |
| } |
| export class TrackContent implements m.ClassComponent<TrackContentAttrs> { |
| private mouseDownX?: number; |
| private mouseDownY?: number; |
| private selectionOccurred = false; |
| |
| private getTargetContainerSize(event: MouseEvent): number { |
| const target = event.target as HTMLElement; |
| return target.getBoundingClientRect().width; |
| } |
| |
| private getTargetTimeScale(event: MouseEvent): TimeScale { |
| const timeWindow = globals.timeline.visibleWindow; |
| return new TimeScale(timeWindow, { |
| left: 0, |
| right: this.getTargetContainerSize(event), |
| }); |
| } |
| |
| view(node: m.CVnode<TrackContentAttrs>) { |
| const attrs = node.attrs; |
| return m( |
| '.track-content', |
| { |
| style: exists(attrs.height) && { |
| height: `${attrs.height}px`, |
| }, |
| className: classNames(attrs.hasError && 'pf-track-content-error'), |
| onmousemove: (e: MouseEvent) => { |
| const {x, y} = currentTargetOffset(e); |
| attrs.track.onMouseMove?.({ |
| x, |
| y, |
| timescale: this.getTargetTimeScale(e), |
| }); |
| raf.scheduleRedraw(); |
| }, |
| onmouseout: () => { |
| attrs.track.onMouseOut?.(); |
| raf.scheduleRedraw(); |
| }, |
| onmousedown: (e: MouseEvent) => { |
| const {x, y} = currentTargetOffset(e); |
| this.mouseDownX = x; |
| this.mouseDownY = y; |
| }, |
| onmouseup: (e: MouseEvent) => { |
| if (this.mouseDownX === undefined || this.mouseDownY === undefined) { |
| return; |
| } |
| const {x, y} = currentTargetOffset(e); |
| if ( |
| Math.abs(x - this.mouseDownX) > 1 || |
| Math.abs(y - this.mouseDownY) > 1 |
| ) { |
| this.selectionOccurred = true; |
| } |
| this.mouseDownX = undefined; |
| this.mouseDownY = 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. |
| const {x, y} = currentTargetOffset(e); |
| if ( |
| attrs.track.onMouseClick?.({ |
| x, |
| y, |
| timescale: this.getTargetTimeScale(e), |
| }) |
| ) { |
| e.stopPropagation(); |
| } |
| raf.scheduleRedraw(); |
| }, |
| }, |
| node.children, |
| ); |
| } |
| } |
| |
| interface TrackComponentAttrs { |
| readonly heightPx?: number; |
| readonly title: string; |
| readonly buttons?: m.Children; |
| readonly tags?: TrackTags; |
| readonly chips?: ReadonlyArray<string>; |
| readonly track?: Track; |
| readonly error?: Error | undefined; |
| readonly pluginId?: string; |
| readonly trackNode: TrackNode; |
| |
| // Issues a scrollTo() on this DOM element at creation time. Default: false. |
| revealOnCreate?: boolean; |
| } |
| |
| class TrackComponent implements m.ClassComponent<TrackComponentAttrs> { |
| view({attrs}: m.CVnode<TrackComponentAttrs>) { |
| // TODO(hjd): The min height below must match the track_shell_title |
| // max height in common.scss so we should read it from CSS to avoid |
| // them going out of sync. |
| const TRACK_HEIGHT_MIN_PX = 18; |
| const TRACK_HEIGHT_DEFAULT_PX = 24; |
| const trackHeightRaw = attrs.heightPx ?? TRACK_HEIGHT_DEFAULT_PX; |
| const trackHeight = Math.max(trackHeightRaw, TRACK_HEIGHT_MIN_PX); |
| |
| return m( |
| '.track', |
| { |
| style: { |
| // Note: Sub-pixel track heights can mess with sticky elements. |
| // Round up to the nearest integer number of pixels. |
| height: `${Math.ceil(trackHeight)}px`, |
| }, |
| id: 'track_' + attrs.trackNode.uri, |
| }, |
| [ |
| m(TrackShell, { |
| buttons: [ |
| attrs.error && m(CrashButton, {error: attrs.error}), |
| attrs.buttons, |
| ], |
| title: attrs.title, |
| tags: attrs.tags, |
| chips: attrs.chips, |
| pluginId: attrs.pluginId, |
| track: attrs.trackNode, |
| }), |
| attrs.track && |
| m(TrackContent, { |
| track: attrs.track, |
| hasError: Boolean(attrs.error), |
| height: attrs.heightPx, |
| }), |
| ], |
| ); |
| } |
| |
| oncreate(vnode: m.VnodeDOM<TrackComponentAttrs>) { |
| const {attrs} = vnode; |
| if (globals.trackManager.scrollToTrackUriOnCreate === attrs.trackNode.uri) { |
| vnode.dom.scrollIntoView(); |
| globals.trackManager.scrollToTrackUriOnCreate = undefined; |
| } |
| this.onupdate(vnode); |
| |
| if (attrs.revealOnCreate) { |
| vnode.dom.scrollIntoView(); |
| } |
| } |
| |
| onupdate(vnode: m.VnodeDOM<TrackComponentAttrs>) { |
| vnode.attrs.track?.onFullRedraw?.(); |
| 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 popupElement = dom.querySelector('.popup') as HTMLElement; |
| const titleElement = dom.querySelector( |
| '.pf-middle-ellipsis', |
| ) as HTMLElement; |
| |
| if (popupElement.clientWidth >= titleElement.clientWidth) { |
| popupElement.classList.add('show-popup'); |
| } else { |
| popupElement.classList.remove('show-popup'); |
| } |
| } |
| } |
| |
| interface TrackPanelAttrs { |
| readonly title: string; |
| readonly tags?: TrackTags; |
| readonly chips?: ReadonlyArray<string>; |
| readonly trackRenderer?: TrackRenderer; |
| readonly revealOnCreate?: boolean; |
| readonly pluginId?: string; |
| readonly track: TrackNode; |
| } |
| |
| export class TrackPanel implements Panel { |
| readonly kind = 'panel'; |
| readonly selectable = true; |
| |
| constructor(private readonly attrs: TrackPanelAttrs) {} |
| |
| get trackUri(): string { |
| return this.attrs.track.uri; |
| } |
| |
| render(): m.Children { |
| const attrs = this.attrs; |
| |
| if (attrs.trackRenderer) { |
| if (attrs.trackRenderer.getError()) { |
| return m(TrackComponent, { |
| title: attrs.title, |
| error: attrs.trackRenderer.getError(), |
| track: attrs.trackRenderer.track, |
| chips: attrs.chips, |
| pluginId: attrs.pluginId, |
| trackNode: attrs.track, |
| }); |
| } |
| return m(TrackComponent, { |
| title: attrs.title, |
| heightPx: attrs.trackRenderer.track.getHeight(), |
| buttons: attrs.trackRenderer.track.getTrackShellButtons?.(), |
| tags: attrs.tags, |
| track: attrs.trackRenderer.track, |
| error: attrs.trackRenderer.getError(), |
| revealOnCreate: attrs.revealOnCreate, |
| chips: attrs.chips, |
| pluginId: attrs.pluginId, |
| trackNode: attrs.track, |
| }); |
| } else { |
| return m(TrackComponent, { |
| title: attrs.title, |
| revealOnCreate: attrs.revealOnCreate, |
| chips: attrs.chips, |
| pluginId: attrs.pluginId, |
| trackNode: attrs.track, |
| }); |
| } |
| } |
| |
| highlightIfTrackSelected( |
| ctx: CanvasRenderingContext2D, |
| timescale: TimeScale, |
| size: Size2D, |
| ) { |
| const selection = globals.selectionManager.selection; |
| if (selection.kind !== 'area') { |
| return; |
| } |
| const selectedAreaDuration = selection.end - selection.start; |
| if (selection.trackUris.includes(this.attrs.track.uri)) { |
| ctx.fillStyle = SELECTION_FILL_COLOR; |
| ctx.fillRect( |
| timescale.timeToPx(selection.start), |
| 0, |
| timescale.durationToPx(selectedAreaDuration), |
| size.height, |
| ); |
| } |
| } |
| |
| renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) { |
| 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); |
| |
| const visibleWindow = globals.timeline.visibleWindow; |
| const timespan = visibleWindow.toTimeSpan(); |
| const timescale = new TimeScale(visibleWindow, { |
| left: 0, |
| right: trackSize.width, |
| }); |
| drawGridLines(ctx, timespan, timescale, trackSize); |
| |
| const track = this.attrs.trackRenderer; |
| |
| if (track !== undefined) { |
| const trackRenderCtx: TrackRenderContext = { |
| trackUri: track.desc.uri, |
| visibleWindow, |
| size: trackSize, |
| resolution: calculateResolution(visibleWindow, trackSize.width), |
| ctx, |
| timescale, |
| }; |
| if (!track.getError()) { |
| track.render(trackRenderCtx); |
| } |
| } else { |
| checkerboard(ctx, trackSize.height, 0, trackSize.width); |
| } |
| |
| this.highlightIfTrackSelected(ctx, timescale, trackSize); |
| |
| // Draw vertical line when hovering on the notes panel. |
| renderHoveredNoteVertical(ctx, timescale, trackSize); |
| renderHoveredCursorVertical(ctx, timescale, trackSize); |
| renderWakeupVertical(ctx, timescale, trackSize); |
| renderNoteVerticals(ctx, timescale, trackSize); |
| |
| ctx.restore(); |
| } |
| |
| getSliceVerticalBounds(depth: number): Optional<VerticalBounds> { |
| if (this.attrs.trackRenderer === undefined) { |
| return undefined; |
| } |
| return this.attrs.trackRenderer.track.getSliceVerticalBounds?.(depth); |
| } |
| } |
| |
| export function drawGridLines( |
| ctx: CanvasRenderingContext2D, |
| timespan: TimeSpan, |
| timescale: TimeScale, |
| size: Size2D, |
| ): void { |
| ctx.strokeStyle = TRACK_BORDER_COLOR; |
| ctx.lineWidth = 1; |
| |
| if (size.width > 0 && timespan.duration > 0n) { |
| const maxMajorTicks = getMaxMajorTicks(size.width); |
| const offset = globals.timestampOffset(); |
| for (const {type, time} of generateTicks(timespan, maxMajorTicks, offset)) { |
| const px = Math.floor(timescale.timeToPx(time)); |
| if (type === TickType.MAJOR) { |
| ctx.beginPath(); |
| ctx.moveTo(px + 0.5, 0); |
| ctx.lineTo(px + 0.5, size.height); |
| ctx.stroke(); |
| } |
| } |
| } |
| } |
| |
| export function renderHoveredCursorVertical( |
| ctx: CanvasRenderingContext2D, |
| timescale: TimeScale, |
| size: Size2D, |
| ) { |
| if (globals.state.hoverCursorTimestamp !== -1n) { |
| drawVerticalLineAtTime( |
| ctx, |
| timescale, |
| globals.state.hoverCursorTimestamp, |
| size.height, |
| `#344596`, |
| ); |
| } |
| } |
| |
| export function renderHoveredNoteVertical( |
| ctx: CanvasRenderingContext2D, |
| timescale: TimeScale, |
| size: Size2D, |
| ) { |
| if (globals.state.hoveredNoteTimestamp !== -1n) { |
| drawVerticalLineAtTime( |
| ctx, |
| timescale, |
| globals.state.hoveredNoteTimestamp, |
| size.height, |
| `#aaa`, |
| ); |
| } |
| } |
| |
| export function renderWakeupVertical( |
| ctx: CanvasRenderingContext2D, |
| timescale: TimeScale, |
| size: Size2D, |
| ) { |
| const currentSelection = globals.selectionManager.legacySelection; |
| const sliceDetails = globals.selectionManager.legacySelectionDetails; |
| if (currentSelection !== null) { |
| if ( |
| currentSelection.kind === 'SCHED_SLICE' && |
| exists(sliceDetails) && |
| sliceDetails.wakeupTs !== undefined |
| ) { |
| drawVerticalLineAtTime( |
| ctx, |
| timescale, |
| sliceDetails.wakeupTs, |
| size.height, |
| `black`, |
| ); |
| } |
| } |
| } |
| |
| export function renderNoteVerticals( |
| ctx: CanvasRenderingContext2D, |
| timescale: TimeScale, |
| size: Size2D, |
| ) { |
| // All marked areas should have semi-transparent vertical lines |
| // marking the start and end. |
| for (const note of globals.noteManager.notes.values()) { |
| if (note.noteType === 'SPAN') { |
| const transparentNoteColor = |
| 'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)'; |
| drawVerticalLineAtTime( |
| ctx, |
| timescale, |
| note.start, |
| size.height, |
| transparentNoteColor, |
| 1, |
| ); |
| drawVerticalLineAtTime( |
| ctx, |
| timescale, |
| note.end, |
| size.height, |
| transparentNoteColor, |
| 1, |
| ); |
| } else if (note.noteType === 'DEFAULT') { |
| drawVerticalLineAtTime( |
| ctx, |
| timescale, |
| note.timestamp, |
| size.height, |
| note.color, |
| ); |
| } |
| } |
| } |