diff --git a/src/components/stack-chart/Canvas.tsx b/src/components/stack-chart/Canvas.tsx index a30950153b..5dde512a3d 100644 --- a/src/components/stack-chart/Canvas.tsx +++ b/src/components/stack-chart/Canvas.tsx @@ -15,6 +15,7 @@ import type { changeMouseTimePosition } from '../../actions/profile-view'; type ChangeMouseTimePosition = typeof changeMouseTimePosition; import { mapCategoryColorNameToStackChartStyles, + getDimmedStyles, getForegroundColor, getBackgroundColor, } from '../../utils/colors'; @@ -78,6 +79,7 @@ type OwnProps = { readonly displayStackType: boolean; readonly useStackChartSameWidths: boolean; readonly timelineUnit: TimelineUnit; + readonly searchStringsRegExp: RegExp | null; }; type Props = Readonly< @@ -183,6 +185,7 @@ class StackChartCanvasImpl extends React.PureComponent { getMarker, marginLeft, useStackChartSameWidths, + searchStringsRegExp, viewport: { containerWidth, containerHeight, @@ -359,6 +362,48 @@ class StackChartCanvasImpl extends React.PureComponent { const callNodeTable = callNodeInfo.getCallNodeTable(); + // Pre-compute which call nodes match the search string so we can dim + // non-matching nodes when a search is active. + let searchMatchedCallNodes: Set | null = null; + if (searchStringsRegExp) { + searchMatchedCallNodes = new Set(); + const { funcTable, resourceTable, sources, stringTable } = thread; + for ( + let callNodeIndex = 0; + callNodeIndex < callNodeTable.length; + callNodeIndex++ + ) { + const funcIndex = callNodeTable.func[callNodeIndex]; + searchStringsRegExp.lastIndex = 0; + const funcName = stringTable.getString(funcTable.name[funcIndex]); + if (searchStringsRegExp.test(funcName)) { + searchMatchedCallNodes.add(callNodeIndex); + continue; + } + + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + searchStringsRegExp.lastIndex = 0; + const fileName = stringTable.getString(sources.filename[sourceIndex]); + if (searchStringsRegExp.test(fileName)) { + searchMatchedCallNodes.add(callNodeIndex); + continue; + } + } + + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex !== -1) { + searchStringsRegExp.lastIndex = 0; + const resourceName = stringTable.getString( + resourceTable.name[resourceIndex] + ); + if (searchStringsRegExp.test(resourceName)) { + searchMatchedCallNodes.add(callNodeIndex); + } + } + } + } + // Only draw the stack frames that are vertically within view. for (let depth = startDepth; depth < endDepth; depth++) { // Get the timing information for a row of stack frames. @@ -480,8 +525,10 @@ class StackChartCanvasImpl extends React.PureComponent { // Look up information about this stack frame. let text, category, isSelected; + let currentCallNodeIndex: IndexIntoCallNodeTable | null = null; if ('callNode' in stackTiming && stackTiming.callNode) { const callNodeIndex = stackTiming.callNode[i]; + currentCallNodeIndex = callNodeIndex; const funcIndex = callNodeTable.func[callNodeIndex]; const funcNameIndex = thread.funcTable.name[funcIndex]; text = thread.stringTable.getString(funcNameIndex); @@ -507,9 +554,19 @@ class StackChartCanvasImpl extends React.PureComponent { depth === hoveredItem.depth && i === hoveredItem.stackTimingIndex; - const colorStyles = mapCategoryColorNameToStackChartStyles( - category.color - ); + // When a search is active, use the dimmed style for non-matching nodes + // so that matching nodes stand out with their category color. + // Hovered or selected nodes always use their real category color. + const isDimmed = + searchMatchedCallNodes !== null && + currentCallNodeIndex !== null && + !searchMatchedCallNodes.has(currentCallNodeIndex) && + !isHovered && + !isSelected; + const colorStyles = isDimmed + ? getDimmedStyles() + : mapCategoryColorNameToStackChartStyles(category.color); + // Draw the box. fastFillStyle.set( isHovered || isSelected @@ -542,11 +599,11 @@ class StackChartCanvasImpl extends React.PureComponent { if (textW > textMeasurement.minWidth) { const fittedText = textMeasurement.getFittedText(text, textW); if (fittedText) { - fastFillStyle.set( - isHovered || isSelected - ? colorStyles.getSelectedTextColor() - : getForegroundColor() - ); + if (isHovered || isSelected || isDimmed) { + fastFillStyle.set(colorStyles.getSelectedTextColor()); + } else { + fastFillStyle.set(getForegroundColor()); + } ctx.fillText(fittedText, textX, intY + textDevicePixelsOffsetTop); } } diff --git a/src/components/stack-chart/index.tsx b/src/components/stack-chart/index.tsx index ba98f87a9b..1fdc46658d 100644 --- a/src/components/stack-chart/index.tsx +++ b/src/components/stack-chart/index.tsx @@ -23,6 +23,7 @@ import { getStackChartSameWidths, getShowUserTimings, getSelectedThreadsKey, + getSearchStringsAsRegExp, } from 'firefox-profiler/selectors/url-state'; import type { SameWidthsIndexToTimestampMap } from 'firefox-profiler/profile-logic/stack-timing'; import { selectedThreadSelectors } from '../../selectors/per-thread'; @@ -87,6 +88,7 @@ type StateProps = { readonly hasFilteredCtssSamples: boolean; readonly useStackChartSameWidths: boolean; readonly timelineUnit: TimelineUnit; + readonly searchStringsRegExp: RegExp | null; }; type DispatchProps = { @@ -244,6 +246,7 @@ class StackChartImpl extends React.PureComponent { hasFilteredCtssSamples, useStackChartSameWidths, timelineUnit, + searchStringsRegExp, } = this.props; const maxViewportHeight = combinedTimingRows.length * STACK_FRAME_HEIGHT; @@ -304,6 +307,7 @@ class StackChartImpl extends React.PureComponent { displayStackType: displayStackType, useStackChartSameWidths, timelineUnit, + searchStringsRegExp, }} /> @@ -347,6 +351,7 @@ export const StackChart = explicitConnect<{}, StateProps, DispatchProps>({ selectedThreadSelectors.getHasFilteredCtssSamples(state), useStackChartSameWidths: getStackChartSameWidths(state), timelineUnit: getProfileTimelineUnit(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), }; }, mapDispatchToProps: { diff --git a/src/test/components/StackChart.test.tsx b/src/test/components/StackChart.test.tsx index e8a2769361..1dab68d697 100644 --- a/src/test/components/StackChart.test.tsx +++ b/src/test/components/StackChart.test.tsx @@ -33,6 +33,7 @@ import { changeImplementationFilter, changeCallTreeSummaryStrategy, updatePreviewSelection, + changeCallTreeSearchString, } from '../../actions/profile-view'; import { changeSelectedTab } from '../../actions/app'; import { selectedThreadSelectors } from '../../selectors/per-thread'; @@ -271,6 +272,56 @@ describe('StackChart', function () { expect(drawnFrames).not.toContain('Z'); }); + it('dims non-matching boxes when searching', function () { + const { dispatch, flushRafCalls } = setupSamples(); + flushDrawLog(); + + // Dispatch a search string that matches some function names. + act(() => { + dispatch(changeCallTreeSearchString('B')); + }); + flushRafCalls(); + + const drawCalls = flushDrawLog(); + + // Non-matching boxes should be drawn with the dimmed style. + const dimmedFillCalls = drawCalls.filter( + ([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa' + ); + expect(dimmedFillCalls.length).toBeGreaterThan(0); + }); + + it('does not dim boxes that match the search string', function () { + // Use a single-node call stack so there is exactly one box. + const { dispatch, flushRafCalls } = setupSamples(` + A[cat:DOM] + `); + flushDrawLog(); + + // Search for "A" — the only node matches, so nothing should be dimmed. + act(() => { + dispatch(changeCallTreeSearchString('A')); + }); + flushRafCalls(); + + const drawCalls = flushDrawLog(); + const dimmedFillCalls = drawCalls.filter( + ([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa' + ); + expect(dimmedFillCalls).toHaveLength(0); + }); + + it('does not dim any boxes when there is no search string', function () { + setupSamples(); + const drawCalls = flushDrawLog(); + + // No dimmed fill should be applied without a search. + const dimmedFillCalls = drawCalls.filter( + ([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa' + ); + expect(dimmedFillCalls).toHaveLength(0); + }); + describe('EmptyReasons', () => { it('shows reasons when a profile has no samples', () => { const profile = getEmptyProfile(); diff --git a/src/test/fixtures/mocks/canvas-context.ts b/src/test/fixtures/mocks/canvas-context.ts index 27eef057fb..d1f8437ad3 100644 --- a/src/test/fixtures/mocks/canvas-context.ts +++ b/src/test/fixtures/mocks/canvas-context.ts @@ -35,7 +35,6 @@ export type SetFillStyleOperation = ['set fillStyle', string]; export type FillRectOperation = ['fillRect', number, number, number, number]; export type ClearRectOperation = ['clearRect', number, number, number, number]; export type FillTextOperation = ['fillText', string]; - export type DrawOperation = | BeginPathOperation | MoveToOperation diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 0efd24d0f6..b93ec771d3 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -10,12 +10,14 @@ import { GREEN_50, GREEN_60, GREEN_70, + GREY_10, GREY_20, GREY_30, GREY_40, GREY_50, GREY_60, GREY_70, + GREY_80, MAGENTA_60, MAGENTA_70, ORANGE_50, @@ -209,6 +211,23 @@ export function mapCategoryColorNameToStackChartStyles( return mapCategoryColorNameToStyles(colorName); } +/** + * A neutral style used to dim non-matching nodes in the stack chart when a + * search filter is active. Closer to the background than any category color + * so that matching nodes stand out clearly. + */ +const DIMMED_STYLE: ColorStyles = { + ...DEFAULT_STYLE, + _selectedFillStyle: [GREY_10, GREY_80], + _unselectedFillStyle: [GREY_10, GREY_80], + _selectedTextColor: [GREY_50, GREY_40], + gravity: 0, +}; + +export function getDimmedStyles(): ColorStyles { + return DIMMED_STYLE; +} + export function getForegroundColor(): string { return lightDark('#000000', GREY_20); }