diff --git a/src/components/stack-chart/Canvas.tsx b/src/components/stack-chart/Canvas.tsx index a30950153b..50661e63cb 100644 --- a/src/components/stack-chart/Canvas.tsx +++ b/src/components/stack-chart/Canvas.tsx @@ -78,6 +78,7 @@ type OwnProps = { readonly displayStackType: boolean; readonly useStackChartSameWidths: boolean; readonly timelineUnit: TimelineUnit; + readonly searchStringsRegExp: RegExp | null; }; type Props = Readonly< @@ -183,6 +184,7 @@ class StackChartCanvasImpl extends React.PureComponent { getMarker, marginLeft, useStackChartSameWidths, + searchStringsRegExp, viewport: { containerWidth, containerHeight, @@ -359,6 +361,26 @@ 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(); + for ( + let callNodeIndex = 0; + callNodeIndex < callNodeTable.length; + callNodeIndex++ + ) { + const funcIndex = callNodeTable.func[callNodeIndex]; + const funcNameIndex = thread.funcTable.name[funcIndex]; + const funcName = thread.stringTable.getString(funcNameIndex); + searchStringsRegExp.lastIndex = 0; + if (searchStringsRegExp.test(funcName)) { + 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 +502,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); @@ -510,6 +534,16 @@ class StackChartCanvasImpl extends React.PureComponent { const colorStyles = mapCategoryColorNameToStackChartStyles( category.color ); + + // When a search is active, dim boxes that do not match. + const isDimmed = + searchMatchedCallNodes !== null && + currentCallNodeIndex !== null && + !searchMatchedCallNodes.has(currentCallNodeIndex); + if (isDimmed) { + ctx.globalAlpha = 0.5; + } + // Draw the box. fastFillStyle.set( isHovered || isSelected @@ -550,6 +584,11 @@ class StackChartCanvasImpl extends React.PureComponent { ctx.fillText(fittedText, textX, intY + textDevicePixelsOffsetTop); } } + + // Reset dimming after drawing. + if (isDimmed) { + ctx.globalAlpha = 1; + } } } 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..e407b9de0b 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 reduced globalAlpha. + const dimCalls = drawCalls.filter( + ([fn, value]) => fn === 'set globalAlpha' && value === 0.5 + ); + expect(dimCalls.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 dimCalls = drawCalls.filter( + ([fn, value]) => fn === 'set globalAlpha' && value < 1 + ); + expect(dimCalls).toHaveLength(0); + }); + + it('does not dim any boxes when there is no search string', function () { + setupSamples(); + const drawCalls = flushDrawLog(); + + // No dimming should be applied without a search. + const dimCalls = drawCalls.filter( + ([fn, value]) => fn === 'set globalAlpha' && value < 1 + ); + expect(dimCalls).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..6742557cd0 100644 --- a/src/test/fixtures/mocks/canvas-context.ts +++ b/src/test/fixtures/mocks/canvas-context.ts @@ -35,6 +35,7 @@ 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 SetGlobalAlphaOperation = ['set globalAlpha', number]; export type DrawOperation = | BeginPathOperation @@ -44,7 +45,8 @@ export type DrawOperation = | SetFillStyleOperation | FillRectOperation | ClearRectOperation - | FillTextOperation; + | FillTextOperation + | SetGlobalAlphaOperation; export function flushDrawLog(): DrawOperation[] { return (window as any).__flushDrawLog();