Skip to content

Commit dfaefe5

Browse files
authored
Implement search highlighting in time order & left heavy views (#297)
This implements the next step towards full featured search in speedscope: visual highlighting of matching search results in the time ordered & left heavy views. This doesn't yet add the ability to click prev/next to select the next matching element in the editor, but I'm still planning on doing something like that. I haven't figured out yet what I want the user experience to be like for that. ![speedscope-flamegraph-search](https://user-images.githubusercontent.com/150329/87898991-9ebba900-ca04-11ea-9bd9-31ad8d4c6d2a.gif) This works towards fixing #38
1 parent 7514f4c commit dfaefe5

12 files changed

+455
-46
lines changed

src/lib/text-utils.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {buildTrimmedText, ELLIPSIS, remapRangesToTrimmedText} from './text-utils'
2+
import {fuzzyMatchStrings} from './fuzzy-find'
3+
4+
function assertTrimmed(text: string, length: number, expectedTrimmed: string) {
5+
expect(buildTrimmedText(text, length).trimmedString).toEqual(
6+
expectedTrimmed.replace('...', ELLIPSIS),
7+
)
8+
}
9+
10+
test('buildTrimmedText', () => {
11+
assertTrimmed('hello world', 1, '...')
12+
assertTrimmed('hello world', 2, 'h...')
13+
assertTrimmed('hello world', 3, 'h...d')
14+
assertTrimmed('hello world', 4, 'he...d')
15+
assertTrimmed('hello world', 10, 'hello...orld')
16+
assertTrimmed('hello world', 11, 'hello world')
17+
assertTrimmed('hello world', 100, 'hello world')
18+
})
19+
20+
function highlightText(text: string, highlightedRanges: [number, number][]): string {
21+
let last = 0
22+
let highlighted = ''
23+
for (let range of highlightedRanges) {
24+
highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
25+
last = range[1]
26+
}
27+
highlighted += text.slice(last)
28+
return highlighted
29+
}
30+
31+
function assertTrimmedHighlight({
32+
text,
33+
pattern,
34+
expectedHighlighted,
35+
length,
36+
expectedHighlightedTrimmed,
37+
}: {
38+
text: string
39+
pattern: string
40+
expectedHighlighted: string
41+
length: number
42+
expectedHighlightedTrimmed: string
43+
}) {
44+
const match = fuzzyMatchStrings(text, pattern)
45+
const trimmed = buildTrimmedText(text, length)
46+
47+
if (!match) {
48+
fail()
49+
return
50+
}
51+
52+
const matchedRangesForTrimmedText = remapRangesToTrimmedText(trimmed, match.matchedRanges)
53+
const highlighted = highlightText(text, match.matchedRanges)
54+
const highlightedTrimmed = highlightText(trimmed.trimmedString, matchedRangesForTrimmedText)
55+
56+
expect(highlighted).toEqual(expectedHighlighted)
57+
expect(highlightedTrimmed).toEqual(expectedHighlightedTrimmed.replace('...', ELLIPSIS))
58+
}
59+
60+
test('remapRangesToTrimmedText', () => {
61+
assertTrimmedHighlight({
62+
text: 'hello world',
63+
pattern: 'he',
64+
length: 4,
65+
expectedHighlighted: '[he]llo world',
66+
expectedHighlightedTrimmed: `[he]...d`,
67+
})
68+
69+
assertTrimmedHighlight({
70+
text: 'hello world',
71+
pattern: 'o w',
72+
length: 4,
73+
expectedHighlighted: 'hell[o w]orld',
74+
expectedHighlightedTrimmed: `he[...]d`,
75+
})
76+
77+
assertTrimmedHighlight({
78+
text: 'hello world',
79+
pattern: 'ow',
80+
length: 4,
81+
expectedHighlighted: 'hell[o] [w]orld',
82+
expectedHighlightedTrimmed: `he[...]d`,
83+
})
84+
85+
assertTrimmedHighlight({
86+
text: 'hello world',
87+
pattern: 'hello',
88+
length: 4,
89+
expectedHighlighted: '[hello] world',
90+
expectedHighlightedTrimmed: `[he...]d`,
91+
})
92+
93+
assertTrimmedHighlight({
94+
text: 'hello world',
95+
pattern: 'hello world',
96+
length: 4,
97+
expectedHighlighted: '[hello world]',
98+
expectedHighlightedTrimmed: `[he...d]`,
99+
})
100+
101+
assertTrimmedHighlight({
102+
text: 'hello world',
103+
pattern: 'helloworld',
104+
length: 4,
105+
expectedHighlighted: '[hello] [world]',
106+
expectedHighlightedTrimmed: `[he...][d]`,
107+
})
108+
109+
assertTrimmedHighlight({
110+
text: 'hello world',
111+
pattern: 'world',
112+
length: 4,
113+
expectedHighlighted: 'hello [world]',
114+
expectedHighlightedTrimmed: `he[...d]`,
115+
})
116+
})

src/lib/text-utils.ts

Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,188 @@ export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: stri
1818
return measureTextCache.get(text)!
1919
}
2020

21-
function buildTrimmedText(text: string, length: number) {
22-
const prefixLength = Math.floor(length / 2)
21+
interface TrimmedTextResult {
22+
trimmedString: string
23+
trimmedLength: number
24+
prefixLength: number
25+
suffixLength: number
26+
originalLength: number
27+
originalString: string
28+
}
29+
30+
// Trim text, placing an ellipsis in the middle, with a slight bias towards
31+
// keeping text from the beginning rather than the end
32+
export function buildTrimmedText(text: string, length: number): TrimmedTextResult {
33+
if (text.length <= length) {
34+
return {
35+
trimmedString: text,
36+
trimmedLength: text.length,
37+
prefixLength: text.length,
38+
suffixLength: 0,
39+
originalString: text,
40+
originalLength: text.length,
41+
}
42+
}
43+
44+
let prefixLength = Math.floor(length / 2)
45+
const suffixLength = length - prefixLength - 1
2346
const prefix = text.substr(0, prefixLength)
24-
const suffix = text.substr(text.length - prefixLength, prefixLength)
25-
return prefix + ELLIPSIS + suffix
47+
const suffix = text.substr(text.length - suffixLength, suffixLength)
48+
const trimmedString = prefix + ELLIPSIS + suffix
49+
return {
50+
trimmedString,
51+
trimmedLength: trimmedString.length,
52+
prefixLength: prefix.length,
53+
suffixLength: suffix.length,
54+
originalString: text,
55+
originalLength: text.length,
56+
}
2657
}
2758

28-
export function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
29-
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
59+
// Trim text to fit within the given number of pixels on the canvas
60+
export function trimTextMid(
61+
ctx: CanvasRenderingContext2D,
62+
text: string,
63+
maxWidth: number,
64+
): TrimmedTextResult {
65+
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) {
66+
return buildTrimmedText(text, text.length)
67+
}
3068
const [lo] = binarySearch(
3169
0,
3270
text.length,
3371
n => {
34-
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
72+
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n).trimmedString)
3573
},
3674
maxWidth,
3775
)
3876
return buildTrimmedText(text, lo)
3977
}
78+
79+
enum IndexTypeInTrimmed {
80+
IN_PREFIX,
81+
IN_SUFFIX,
82+
ELIDED,
83+
}
84+
85+
function getIndexTypeInTrimmed(result: TrimmedTextResult, index: number): IndexTypeInTrimmed {
86+
if (index < result.prefixLength) {
87+
return IndexTypeInTrimmed.IN_PREFIX
88+
} else if (index < result.originalLength - result.suffixLength) {
89+
return IndexTypeInTrimmed.ELIDED
90+
} else {
91+
return IndexTypeInTrimmed.IN_SUFFIX
92+
}
93+
}
94+
95+
export function remapRangesToTrimmedText(
96+
trimmedText: TrimmedTextResult,
97+
ranges: [number, number][],
98+
): [number, number][] {
99+
// We intentionally don't just re-run fuzzy matching on the trimmed
100+
// text, beacuse if the search query is "helloWorld", the frame name
101+
// is "application::helloWorld", and that gets trimmed down to
102+
// "appl...oWorld", we still want "oWorld" to be highlighted, even
103+
// though the string "appl...oWorld" is not matched by the query
104+
// "helloWorld".
105+
//
106+
// There's a weird case to consider here: what if the trimmedText is
107+
// also matched by the query, but results in a different match than
108+
// the original query? Consider, e.g. the search string of "ab". The
109+
// string "hello ab shabby" will be matched at the first "ab", but
110+
// may be trimmed to "hello...shabby". In this case, should we
111+
// highlight the "ab" hidden by the ellipsis, or the "ab" in
112+
// "shabby"? The code below highlights the ellipsis so that the
113+
// matched characters don't change as you zoom in and out.
114+
115+
const rangesToHighlightInTrimmedText: [number, number][] = []
116+
const lengthLoss = trimmedText.originalLength - trimmedText.trimmedLength
117+
let highlightedEllipsis = false
118+
119+
for (let [origStart, origEnd] of ranges) {
120+
let startPosType = getIndexTypeInTrimmed(trimmedText, origStart)
121+
let endPosType = getIndexTypeInTrimmed(trimmedText, origEnd - 1)
122+
123+
switch (startPosType) {
124+
case IndexTypeInTrimmed.IN_PREFIX: {
125+
switch (endPosType) {
126+
case IndexTypeInTrimmed.IN_PREFIX: {
127+
// The entire range fits in the prefix. Add it unmodified.
128+
rangesToHighlightInTrimmedText.push([origStart, origEnd])
129+
break
130+
}
131+
case IndexTypeInTrimmed.ELIDED: {
132+
// The range starts in the prefix, but ends in the elided
133+
// section. Add just the prefix + one char for the ellipsis.
134+
rangesToHighlightInTrimmedText.push([
135+
origStart,
136+
origStart + trimmedText.prefixLength + 1,
137+
])
138+
highlightedEllipsis = true
139+
break
140+
}
141+
case IndexTypeInTrimmed.IN_SUFFIX: {
142+
// The range crosses from the prefix to the suffix.
143+
// Highlight everything including the ellipsis.
144+
rangesToHighlightInTrimmedText.push([origStart, origEnd - lengthLoss])
145+
break
146+
}
147+
}
148+
break
149+
}
150+
case IndexTypeInTrimmed.ELIDED: {
151+
switch (endPosType) {
152+
case IndexTypeInTrimmed.IN_PREFIX: {
153+
// This should be impossible
154+
throw new Error('Unexpected highlight range starts in elided and ends in prefix')
155+
}
156+
case IndexTypeInTrimmed.ELIDED: {
157+
// The match starts & ends within the elided section.
158+
if (!highlightedEllipsis) {
159+
rangesToHighlightInTrimmedText.push([
160+
trimmedText.prefixLength,
161+
trimmedText.prefixLength + 1,
162+
])
163+
highlightedEllipsis = true
164+
}
165+
break
166+
}
167+
case IndexTypeInTrimmed.IN_SUFFIX: {
168+
// The match starts in elided, but ends in suffix.
169+
if (highlightedEllipsis) {
170+
rangesToHighlightInTrimmedText.push([
171+
trimmedText.trimmedLength - trimmedText.suffixLength,
172+
origEnd - lengthLoss,
173+
])
174+
} else {
175+
rangesToHighlightInTrimmedText.push([trimmedText.prefixLength, origEnd - lengthLoss])
176+
highlightedEllipsis = true
177+
}
178+
break
179+
}
180+
}
181+
break
182+
}
183+
case IndexTypeInTrimmed.IN_SUFFIX: {
184+
switch (endPosType) {
185+
case IndexTypeInTrimmed.IN_PREFIX: {
186+
// This should be impossible
187+
throw new Error('Unexpected highlight range starts in suffix and ends in prefix')
188+
}
189+
case IndexTypeInTrimmed.ELIDED: {
190+
// This should be impossible
191+
throw new Error('Unexpected highlight range starts in suffix and ends in elided')
192+
break
193+
}
194+
case IndexTypeInTrimmed.IN_SUFFIX: {
195+
// Match starts & ends in suffix
196+
rangesToHighlightInTrimmedText.push([origStart - lengthLoss, origEnd - lengthLoss])
197+
break
198+
}
199+
}
200+
break
201+
}
202+
}
203+
}
204+
return rangesToHighlightInTrimmedText
205+
}

src/views/callee-flamegraph-view.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
getFrameToColorBucket,
1414
} from '../store/getters'
1515
import {FlamechartID} from '../store/flamechart-view-state'
16-
import {FlamechartWrapper} from './flamechart-wrapper'
16+
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
1717
import {useAppSelector} from '../store'
1818
import {h} from 'preact'
1919
import {memo} from 'preact/compat'
@@ -81,6 +81,11 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps
8181
// This overrides the setSelectedNode specified in useFlamechartSettesr
8282
setSelectedNode={noop}
8383
{...callerCallee.calleeFlamegraph}
84+
/*
85+
* TODO(jlfwong): When implementing search for the sandwich views,
86+
* change these flags
87+
* */
88+
{...useDummySearchProps()}
8489
/>
8590
)
8691
})

0 commit comments

Comments
 (0)