Compare commits

..
5 Commits
Author SHA1 Message Date
Jamie Wong c3b35d7b0f 1.8.0 2020-07-19 21:27:00 -07:00
Jamie Wong dfaefe54fd 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
2020-07-19 21:20:14 -07:00
Jamie Wong 7514f4c0c9 Fix performance issues for the caller/callee flamegraphs in the sandwich view (#296)
This fixes two unrelated problems which together caused performance issues in the sandwich view & made hover tooltips appear to be broken.

The first issue was caused by continuously priming the `requestAnimationFrame` loop when it should be a no-op, and the second issue was caused by using different cache keys when trying to access a memoized value in the caller & callee flamegraph components. This resulted in thrash, and especially bad performance because the cache miss was resulting in us re-allocating the WebGL framebuffer on every frame, which is unsurprisingly quite slow.

Fixes #212 
Fixes #155 
Fixes #74 (though this was maybe already fixed)
2020-07-18 22:37:15 -07:00
Jamie Wong ff447c2719 1.7.0 2020-07-13 22:10:28 -07:00
Jamie Wong 668bb032ba Introduce filtering via Ctrl+F/Cmd+F into the sandwich view (#293)
This is the first step towards fixing #38. 

I started with the easiest part from a UI-paradigm perspective, and also the place that's the most confusing that search doesn't work. Before this PR, browers' Cmd+F/Ctrl+F would *look* like it worked in the Sandwich view, but they wouldn't work fully because the view in the sandwich view is a virtualized table, meaning that it doesn't put all of the rows in the DOM. Instead, it only renders enough to fill the viewport to make rendering much faster.

Here's what the changes from this PR look like in action:

![Kapture 2020-07-12 at 23 17 33](https://user-images.githubusercontent.com/150329/87276802-ef2b8780-c495-11ea-9856-9c834ea7f028.gif)

Before closing #38, I'll be adding search functionality to the flamechart views too.
2020-07-13 22:04:19 -07:00
20 changed files with 1012 additions and 299 deletions
+16
View File
@@ -1,5 +1,21 @@
## Unreleased
## [1.8.0] - 2020-07-19
### Added
- Added search highlighting in time order & left heavy views [[#297](https://github.com/jlfwong/speedscope/pull/297)]
### Fixed
- Fix performance issues for the caller/callee flamegraphs in the sandwich view [[#296](https://github.com/jlfwong/speedscope/pull/296)]
## [1.7.0] - 2020-07-13
### Added
- Introduced filtering via Ctrl+F/Cmd+F into the sandwich view [[#293](https://github.com/jlfwong/speedscope/pull/293)]
## [1.6.0] - 2020-05-30
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "speedscope",
"version": "1.6.0",
"version": "1.8.0",
"description": "",
"repository": "jlfwong/speedscope",
"main": "index.js",
+116
View File
@@ -0,0 +1,116 @@
import {buildTrimmedText, ELLIPSIS, remapRangesToTrimmedText} from './text-utils'
import {fuzzyMatchStrings} from './fuzzy-find'
function assertTrimmed(text: string, length: number, expectedTrimmed: string) {
expect(buildTrimmedText(text, length).trimmedString).toEqual(
expectedTrimmed.replace('...', ELLIPSIS),
)
}
test('buildTrimmedText', () => {
assertTrimmed('hello world', 1, '...')
assertTrimmed('hello world', 2, 'h...')
assertTrimmed('hello world', 3, 'h...d')
assertTrimmed('hello world', 4, 'he...d')
assertTrimmed('hello world', 10, 'hello...orld')
assertTrimmed('hello world', 11, 'hello world')
assertTrimmed('hello world', 100, 'hello world')
})
function highlightText(text: string, highlightedRanges: [number, number][]): string {
let last = 0
let highlighted = ''
for (let range of highlightedRanges) {
highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
last = range[1]
}
highlighted += text.slice(last)
return highlighted
}
function assertTrimmedHighlight({
text,
pattern,
expectedHighlighted,
length,
expectedHighlightedTrimmed,
}: {
text: string
pattern: string
expectedHighlighted: string
length: number
expectedHighlightedTrimmed: string
}) {
const match = fuzzyMatchStrings(text, pattern)
const trimmed = buildTrimmedText(text, length)
if (!match) {
fail()
return
}
const matchedRangesForTrimmedText = remapRangesToTrimmedText(trimmed, match.matchedRanges)
const highlighted = highlightText(text, match.matchedRanges)
const highlightedTrimmed = highlightText(trimmed.trimmedString, matchedRangesForTrimmedText)
expect(highlighted).toEqual(expectedHighlighted)
expect(highlightedTrimmed).toEqual(expectedHighlightedTrimmed.replace('...', ELLIPSIS))
}
test('remapRangesToTrimmedText', () => {
assertTrimmedHighlight({
text: 'hello world',
pattern: 'he',
length: 4,
expectedHighlighted: '[he]llo world',
expectedHighlightedTrimmed: `[he]...d`,
})
assertTrimmedHighlight({
text: 'hello world',
pattern: 'o w',
length: 4,
expectedHighlighted: 'hell[o w]orld',
expectedHighlightedTrimmed: `he[...]d`,
})
assertTrimmedHighlight({
text: 'hello world',
pattern: 'ow',
length: 4,
expectedHighlighted: 'hell[o] [w]orld',
expectedHighlightedTrimmed: `he[...]d`,
})
assertTrimmedHighlight({
text: 'hello world',
pattern: 'hello',
length: 4,
expectedHighlighted: '[hello] world',
expectedHighlightedTrimmed: `[he...]d`,
})
assertTrimmedHighlight({
text: 'hello world',
pattern: 'hello world',
length: 4,
expectedHighlighted: '[hello world]',
expectedHighlightedTrimmed: `[he...d]`,
})
assertTrimmedHighlight({
text: 'hello world',
pattern: 'helloworld',
length: 4,
expectedHighlighted: '[hello] [world]',
expectedHighlightedTrimmed: `[he...][d]`,
})
assertTrimmedHighlight({
text: 'hello world',
pattern: 'world',
length: 4,
expectedHighlighted: 'hello [world]',
expectedHighlightedTrimmed: `he[...d]`,
})
})
+174 -8
View File
@@ -18,22 +18,188 @@ export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: stri
return measureTextCache.get(text)!
}
function buildTrimmedText(text: string, length: number) {
const prefixLength = Math.floor(length / 2)
const prefix = text.substr(0, prefixLength)
const suffix = text.substr(text.length - prefixLength, prefixLength)
return prefix + ELLIPSIS + suffix
interface TrimmedTextResult {
trimmedString: string
trimmedLength: number
prefixLength: number
suffixLength: number
originalLength: number
originalString: string
}
export function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
// Trim text, placing an ellipsis in the middle, with a slight bias towards
// keeping text from the beginning rather than the end
export function buildTrimmedText(text: string, length: number): TrimmedTextResult {
if (text.length <= length) {
return {
trimmedString: text,
trimmedLength: text.length,
prefixLength: text.length,
suffixLength: 0,
originalString: text,
originalLength: text.length,
}
}
let prefixLength = Math.floor(length / 2)
const suffixLength = length - prefixLength - 1
const prefix = text.substr(0, prefixLength)
const suffix = text.substr(text.length - suffixLength, suffixLength)
const trimmedString = prefix + ELLIPSIS + suffix
return {
trimmedString,
trimmedLength: trimmedString.length,
prefixLength: prefix.length,
suffixLength: suffix.length,
originalString: text,
originalLength: text.length,
}
}
// Trim text to fit within the given number of pixels on the canvas
export function trimTextMid(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): TrimmedTextResult {
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) {
return buildTrimmedText(text, text.length)
}
const [lo] = binarySearch(
0,
text.length,
n => {
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n).trimmedString)
},
maxWidth,
)
return buildTrimmedText(text, lo)
}
enum IndexTypeInTrimmed {
IN_PREFIX,
IN_SUFFIX,
ELIDED,
}
function getIndexTypeInTrimmed(result: TrimmedTextResult, index: number): IndexTypeInTrimmed {
if (index < result.prefixLength) {
return IndexTypeInTrimmed.IN_PREFIX
} else if (index < result.originalLength - result.suffixLength) {
return IndexTypeInTrimmed.ELIDED
} else {
return IndexTypeInTrimmed.IN_SUFFIX
}
}
export function remapRangesToTrimmedText(
trimmedText: TrimmedTextResult,
ranges: [number, number][],
): [number, number][] {
// We intentionally don't just re-run fuzzy matching on the trimmed
// text, beacuse if the search query is "helloWorld", the frame name
// is "application::helloWorld", and that gets trimmed down to
// "appl...oWorld", we still want "oWorld" to be highlighted, even
// though the string "appl...oWorld" is not matched by the query
// "helloWorld".
//
// There's a weird case to consider here: what if the trimmedText is
// also matched by the query, but results in a different match than
// the original query? Consider, e.g. the search string of "ab". The
// string "hello ab shabby" will be matched at the first "ab", but
// may be trimmed to "hello...shabby". In this case, should we
// highlight the "ab" hidden by the ellipsis, or the "ab" in
// "shabby"? The code below highlights the ellipsis so that the
// matched characters don't change as you zoom in and out.
const rangesToHighlightInTrimmedText: [number, number][] = []
const lengthLoss = trimmedText.originalLength - trimmedText.trimmedLength
let highlightedEllipsis = false
for (let [origStart, origEnd] of ranges) {
let startPosType = getIndexTypeInTrimmed(trimmedText, origStart)
let endPosType = getIndexTypeInTrimmed(trimmedText, origEnd - 1)
switch (startPosType) {
case IndexTypeInTrimmed.IN_PREFIX: {
switch (endPosType) {
case IndexTypeInTrimmed.IN_PREFIX: {
// The entire range fits in the prefix. Add it unmodified.
rangesToHighlightInTrimmedText.push([origStart, origEnd])
break
}
case IndexTypeInTrimmed.ELIDED: {
// The range starts in the prefix, but ends in the elided
// section. Add just the prefix + one char for the ellipsis.
rangesToHighlightInTrimmedText.push([
origStart,
origStart + trimmedText.prefixLength + 1,
])
highlightedEllipsis = true
break
}
case IndexTypeInTrimmed.IN_SUFFIX: {
// The range crosses from the prefix to the suffix.
// Highlight everything including the ellipsis.
rangesToHighlightInTrimmedText.push([origStart, origEnd - lengthLoss])
break
}
}
break
}
case IndexTypeInTrimmed.ELIDED: {
switch (endPosType) {
case IndexTypeInTrimmed.IN_PREFIX: {
// This should be impossible
throw new Error('Unexpected highlight range starts in elided and ends in prefix')
}
case IndexTypeInTrimmed.ELIDED: {
// The match starts & ends within the elided section.
if (!highlightedEllipsis) {
rangesToHighlightInTrimmedText.push([
trimmedText.prefixLength,
trimmedText.prefixLength + 1,
])
highlightedEllipsis = true
}
break
}
case IndexTypeInTrimmed.IN_SUFFIX: {
// The match starts in elided, but ends in suffix.
if (highlightedEllipsis) {
rangesToHighlightInTrimmedText.push([
trimmedText.trimmedLength - trimmedText.suffixLength,
origEnd - lengthLoss,
])
} else {
rangesToHighlightInTrimmedText.push([trimmedText.prefixLength, origEnd - lengthLoss])
highlightedEllipsis = true
}
break
}
}
break
}
case IndexTypeInTrimmed.IN_SUFFIX: {
switch (endPosType) {
case IndexTypeInTrimmed.IN_PREFIX: {
// This should be impossible
throw new Error('Unexpected highlight range starts in suffix and ends in prefix')
}
case IndexTypeInTrimmed.ELIDED: {
// This should be impossible
throw new Error('Unexpected highlight range starts in suffix and ends in elided')
break
}
case IndexTypeInTrimmed.IN_SUFFIX: {
// Match starts & ends in suffix
rangesToHighlightInTrimmedText.push([origStart - lengthLoss, origEnd - lengthLoss])
break
}
}
break
}
}
}
return rangesToHighlightInTrimmedText
}
+2 -19
View File
@@ -8,36 +8,19 @@ import {HashParams} from '../lib/hash-params'
import {actionCreatorWithIndex} from './profiles-state'
export namespace actions {
// Set the top-level profile group from which other data will be derived
export const setProfileGroup = actionCreator<ProfileGroup>('setProfileGroup')
// Set the index into the profile group to view
export const setProfileIndexToView = actionCreator<number>('setProfileIndexToView')
export const setGLCanvas = actionCreator<HTMLCanvasElement | null>('setGLCanvas')
// Set which top-level view should be displayed
export const setViewMode = actionCreator<ViewMode>('setViewMode')
// Set whether or not recursion should be flattened when viewing flamegraphs
export const setFlattenRecursion = actionCreator<boolean>('setFlattenRecursion')
// Set whether a file drag is currently active. Used to indicate that the
// application is a valid drop target.
export const setSearchQuery = actionCreator<string>('setSearchQuery')
export const setSearchIsActive = actionCreator<boolean>('setSearchIsActive')
export const setDragActive = actionCreator<boolean>('setDragActive')
// Set whether the application is currently in a loading state. Used to
// display a loading progress bar.
export const setLoading = actionCreator<boolean>('setLoading')
// Set whether the application is in an errored state.
export const setError = actionCreator<boolean>('setError')
// Set whether parameters defined by the URL encoded k=v pairs after the # in the URL
export const setHashParams = actionCreator<HashParams>('setHashParams')
export namespace sandwichView {
// Set the table sorting method used for the sandwich view.
export const setTableSortMethod = actionCreator<SortMethod>('sandwichView.setTableSortMethod')
export const setSelectedFrame = actionCreatorWithIndex<Frame | null>(
-4
View File
@@ -40,10 +40,6 @@ export const getRowAtlas = memoizeByReference((canvasContext: CanvasContext) =>
)
})
export const getProfileWithRecursionFlattened = memoizeByReference((profile: Profile) =>
profile.getProfileWithRecursionFlattened(),
)
export const getProfileToView = memoizeByShallowEquality(
({profile, flattenRecursion}: {profile: Profile; flattenRecursion: boolean}): Profile => {
return flattenRecursion ? profile.getProfileWithRecursionFlattened() : profile
+29 -3
View File
@@ -19,20 +19,43 @@ export const enum ViewMode {
}
export interface ApplicationState {
// The top-level profile group from which most other data will be derived
profileGroup: ProfileGroupState
// Parameters defined by the URL encoded k=v pairs after the # in the URL
hashParams: HashParams
glCanvas: HTMLCanvasElement | null
// Which top-level view should be displayed
viewMode: ViewMode
// True if recursion should be flattened when viewing flamegraphs
flattenRecursion: boolean
viewMode: ViewMode
// The query used in top-level views
//
// An empty string indicates that the search is open by no filter is applied.
// searchIsActive is stored separately, because we may choose to persist the
// query even when the search input is closed.
searchQuery: string
searchIsActive: boolean
// True when a file drag is currently active. Used to indicate that the
// application is a valid drop target.
dragActive: boolean
// True when the application is currently in a loading state. Used to
// display a loading progress bar.
loading: boolean
// True when the application is an error state, e.g. because the profile
// imported was invalid.
error: boolean
// The table sorting method using for the sandwich view, specifying the column
// to sort by, and the direction to sort that clumn.
tableSortMethod: SortMethod
profileGroup: ProfileGroupState
}
const protocol = window.location.protocol
@@ -56,6 +79,9 @@ export function createAppStore(initialState?: ApplicationState): redux.Store<App
viewMode: setter<ViewMode>(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART),
searchQuery: setter<string>(actions.setSearchQuery, ''),
searchIsActive: setter<boolean>(actions.setSearchIsActive, false),
glCanvas: setter<HTMLCanvasElement | null>(actions.setGLCanvas, null),
dragActive: setter<boolean>(actions.setDragActive, false),
+3
View File
@@ -12,7 +12,10 @@ import {objectsHaveShallowEquality} from '../lib/utils'
export type ProfileGroupState = {
name: string
// The index within the list of profiles currently being viewed
indexToView: number
profiles: ProfileState[]
} | null
+6 -1
View File
@@ -13,7 +13,7 @@ import {
getFrameToColorBucket,
} from '../store/getters'
import {FlamechartID} from '../store/flamechart-view-state'
import {FlamechartWrapper} from './flamechart-wrapper'
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
import {useAppSelector} from '../store'
import {h} from 'preact'
import {memo} from 'preact/compat'
@@ -81,6 +81,11 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps
// This overrides the setSelectedNode specified in useFlamechartSettesr
setSelectedNode={noop}
{...callerCallee.calleeFlamegraph}
/*
* TODO(jlfwong): When implementing search for the sandwich views,
* change these flags
* */
{...useDummySearchProps()}
/>
)
})
+94 -25
View File
@@ -1,13 +1,20 @@
import {Rect, AffineTransform, Vec2, clamp} from '../lib/math'
import {CallTreeNode} from '../lib/profile'
import {CallTreeNode, Frame} from '../lib/profile'
import {Flamechart, FlamechartFrame} from '../lib/flamechart'
import {CanvasContext} from '../gl/canvas-context'
import {FlamechartRenderer} from '../gl/flamechart-renderer'
import {Sizes, FontSize, Colors, FontFamily, commonStyle} from './style'
import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from '../lib/text-utils'
import {
cachedMeasureTextWidth,
ELLIPSIS,
trimTextMid,
remapRangesToTrimmedText,
} from '../lib/text-utils'
import {style} from './flamechart-style'
import {h, Component} from 'preact'
import {css} from 'aphrodite'
import {memoizeByReference} from '../lib/utils'
import {FuzzyMatch, fuzzyMatchStrings} from '../lib/fuzzy-find'
interface FlamechartFrameLabel {
configSpaceBounds: Rect
@@ -47,7 +54,10 @@ export interface FlamechartPanZoomViewProps {
setConfigSpaceViewportRect: (rect: Rect) => void
logicalSpaceViewportSize: Vec2
setLogicalSpaceViewportBounds: (size: Vec2) => void
setLogicalSpaceViewportSize: (size: Vec2) => void
searchIsActive: boolean
searchQuery: string
}
export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
@@ -183,7 +193,6 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = Colors.DARK_GRAY
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
const minConfigSpaceWidthToRender = (
@@ -192,6 +201,15 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
const LABEL_PADDING_PX = 5 * window.devicePixelRatio
const memoizedFuzzyMatch = memoizeByReference((frame: Frame): FuzzyMatch | null => {
return fuzzyMatchStrings(frame.name, this.props.searchQuery)
})
const frameMatchesSearchQuery = (frame: Frame): FuzzyMatch | null => {
if (!this.props.searchIsActive) return null
if (this.props.searchQuery.length === 0) return null
return memoizedFuzzyMatch(frame)
}
const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
const width = frame.end - frame.start
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
@@ -226,16 +244,52 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
}
if (physicalLabelBounds.width() > minWidthToRender) {
const match = frameMatchesSearchQuery(frame.node.frame)
const trimmedText = trimTextMid(
ctx,
frame.node.frame.name,
physicalLabelBounds.width() - 2 * LABEL_PADDING_PX,
)
if (match) {
const rangesToHighlightInTrimmedText = remapRangesToTrimmedText(
trimmedText,
match.matchedRanges,
)
// Once we have the character ranges to highlight, we need to
// actually do the highlighting.
let lastEndIndex = 0
let left = physicalLabelBounds.left() + LABEL_PADDING_PX
ctx.fillStyle = Colors.YELLOW
ctx.beginPath()
const padding = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2 - 2
for (let [startIndex, endIndex] of rangesToHighlightInTrimmedText) {
left += ctx.measureText(trimmedText.trimmedString.substring(lastEndIndex, startIndex))
.width
const highlightWidth = ctx.measureText(
trimmedText.trimmedString.substring(startIndex, endIndex),
).width
ctx.rect(
left,
physicalLabelBounds.top() + padding,
highlightWidth,
physicalViewSpaceFrameHeight - 2 * padding,
)
left += highlightWidth
lastEndIndex = endIndex
}
ctx.fill()
}
// Note that this is specifying the position of the starting text
// baseline.
ctx.fillStyle = Colors.DARK_GRAY
ctx.fillText(
trimmedText,
trimmedText.trimmedString,
physicalLabelBounds.left() + LABEL_PADDING_PX,
Math.round(
physicalLabelBounds.bottom() -
@@ -259,8 +313,9 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
const minConfigSpaceWidthToRenderOutline = (
configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
).x
const renderIndirectlySelectedFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
if (!this.props.selectedNode) return
const renderSpecialFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
if (!this.props.selectedNode && !this.props.searchIsActive) return
const width = frame.end - frame.start
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
@@ -271,25 +326,30 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
let outlineColor: string | null = null
if (frame.node.frame === this.props.selectedNode.frame) {
if (this.props.selectedNode != null && frame.node.frame === this.props.selectedNode.frame) {
if (frame.node === this.props.selectedNode) {
if (ctx.strokeStyle !== Colors.DARK_BLUE) {
ctx.stroke()
ctx.beginPath()
ctx.strokeStyle = Colors.DARK_BLUE
}
} else {
if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
ctx.stroke()
ctx.beginPath()
ctx.strokeStyle = Colors.PALE_DARK_BLUE
}
outlineColor = Colors.DARK_BLUE
} else if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
outlineColor = Colors.PALE_DARK_BLUE
}
} else {
if (frameMatchesSearchQuery(frame.node.frame)) {
outlineColor = Colors.YELLOW
}
}
// Identify the flamechart frames with a function that matches the
// selected flamechart frame.
if (outlineColor != null) {
if (ctx.strokeStyle !== outlineColor) {
// If the outline color changed, stroke the existing path
// constructed by previous ctx.rect calls, then update the stroke
// style before drawing the next one.
ctx.stroke()
ctx.beginPath()
ctx.strokeStyle = outlineColor
}
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
ctx.rect(
Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
@@ -300,13 +360,13 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
}
for (let child of frame.children) {
renderIndirectlySelectedFrameOutlines(child, depth + 1)
renderSpecialFrameOutlines(child, depth + 1)
}
}
ctx.beginPath()
for (let frame of this.props.flamechart.getLayers()[0] || []) {
renderIndirectlySelectedFrameOutlines(frame)
renderSpecialFrameOutlines(frame)
}
ctx.stroke()
@@ -396,7 +456,11 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
),
)
}
this.props.setLogicalSpaceViewportBounds(new Vec2(width, height))
const newSize = new Vec2(width, height)
if (!newSize.equals(logicalSpaceViewportSize)) {
this.props.setLogicalSpaceViewportSize(newSize)
}
}
onWindowResize = () => {
@@ -686,6 +750,11 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
if (this.props.flamechart !== nextProps.flamechart) {
this.hoveredLabel = null
this.renderCanvas()
} else if (
this.props.searchQuery !== nextProps.searchQuery ||
this.props.searchIsActive !== nextProps.searchIsActive
) {
this.renderCanvas()
} else if (this.props.selectedNode !== nextProps.selectedNode) {
this.renderCanvas()
} else if (this.props.configSpaceViewportRect !== nextProps.configSpaceViewportRect) {
+19
View File
@@ -18,6 +18,8 @@ import {ActiveProfileState} from './application'
import {Vec2, Rect} from '../lib/math'
import {actions} from '../store/actions'
import {memo} from 'preact/compat'
import {useAppSelector} from '../store'
import {SearchViewProps} from './search-view'
interface FlamechartSetters {
setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void
@@ -64,9 +66,24 @@ export type FlamechartViewProps = {
flamechartRenderer: FlamechartRenderer
renderInverted: boolean
getCSSColorForFrame: (frame: Frame) => string
searchIsActive: boolean
searchQuery: string
setSearchQuery: (query: string) => void
setSearchIsActive: (active: boolean) => void
} & FlamechartSetters &
FlamechartViewState
const {setSearchQuery, setSearchIsActive} = actions
function useSearchViewProps(): SearchViewProps {
return {
searchIsActive: useAppSelector(state => state.searchIsActive, []),
setSearchQuery: useActionCreator(setSearchQuery, []),
searchQuery: useAppSelector(state => state.searchQuery, []),
setSearchIsActive: useActionCreator(setSearchIsActive, []),
}
}
export const getChronoViewFlamechart = memoizeByShallowEquality(
({
profile,
@@ -134,6 +151,7 @@ export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) =
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.CHRONO, index)}
{...useSearchViewProps()}
{...chronoViewState}
/>
)
@@ -185,6 +203,7 @@ export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerPr
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)}
{...useSearchViewProps()}
{...leftHeavyViewState}
/>
)
+10 -1
View File
@@ -14,6 +14,7 @@ import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
import {Hovertip} from './hovertip'
import {FlamechartViewProps} from './flamechart-view-container'
import {StatelessComponent} from '../lib/typed-redux'
import {SearchView} from './search-view'
export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
private configSpaceSize() {
@@ -113,7 +114,15 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
configSpaceViewportRect={this.props.configSpaceViewportRect}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
searchQuery={this.props.searchQuery}
searchIsActive={this.props.searchIsActive}
/>
<SearchView
searchQuery={this.props.searchQuery}
searchIsActive={this.props.searchIsActive}
setSearchQuery={this.props.setSearchQuery}
setSearchIsActive={this.props.setSearchIsActive}
/>
{this.renderTooltip()}
{this.props.selectedNode && (
+14 -1
View File
@@ -8,6 +8,17 @@ import {noop, formatPercent} from '../lib/utils'
import {Hovertip} from './hovertip'
import {FlamechartViewProps} from './flamechart-view-container'
import {StatelessComponent} from '../lib/typed-redux'
import {useCallback} from 'preact/hooks'
import {SearchViewProps} from './search-view'
export function useDummySearchProps(): SearchViewProps {
return {
searchIsActive: false,
searchQuery: '',
setSearchQuery: useCallback((q: string) => {}, []),
setSearchIsActive: useCallback((v: boolean) => {}, []),
}
}
export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
private clampViewportToFlamegraph(viewportRect: Rect) {
@@ -83,7 +94,9 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
canvasContext={this.props.canvasContext}
renderInverted={this.props.renderInverted}
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
searchIsActive={this.props.searchIsActive}
searchQuery={this.props.searchQuery}
/>
{this.renderTooltip()}
</div>
@@ -10,12 +10,11 @@ import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
getProfileWithRecursionFlattened,
getFrameToColorBucket,
} from '../store/getters'
import {FlamechartID} from '../store/flamechart-view-state'
import {useAppSelector} from '../store'
import {FlamechartWrapper} from './flamechart-wrapper'
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
import {h} from 'preact'
import {memo} from 'preact/compat'
@@ -65,8 +64,6 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
if (!callerCallee) throw new Error('callerCallee missing')
const {selectedFrame} = callerCallee
profile = flattenRecursion ? getProfileWithRecursionFlattened(profile) : profile
const frameToColorBucket = getFrameToColorBucket(profile)
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
@@ -93,6 +90,11 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
// This overrides the setSelectedNode specified in useFlamechartSettesr
setSelectedNode={noop}
{...callerCallee.invertedCallerFlamegraph}
/*
* TODO(jlfwong): When implementing search for the sandwich views,
* change these flags
* */
{...useDummySearchProps()}
/>
)
})
+233 -150
View File
@@ -1,17 +1,18 @@
import {h, Component, JSX} from 'preact'
import {h, Component, JSX, ComponentChild} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {Profile, Frame} from '../lib/profile'
import {sortBy, formatPercent} from '../lib/utils'
import {FontSize, Colors, Sizes, commonStyle} from './style'
import {ColorChit} from './color-chit'
import {ScrollableListView, ListItem} from './scrollable-list-view'
import {ListItem, ScrollableListView} from './scrollable-list-view'
import {actions} from '../store/actions'
import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters'
import {ActiveProfileState} from './application'
import {useActionCreator} from '../lib/preact-redux'
import {useAppSelector} from '../store'
import {memo} from 'preact/compat'
import {useCallback, useMemo, useRef} from 'preact/hooks'
import {useCallback, useMemo} from 'preact/hooks'
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
export enum SortField {
SYMBOL_NAME,
@@ -67,8 +68,13 @@ class SortIcon extends Component<SortIconProps, {}> {
}
}
interface ProfileTableRowViewProps {
interface ProfileTableRowInfo {
frame: Frame
matchedRanges: [number, number][] | null
}
interface ProfileTableRowViewProps {
info: ProfileTableRowInfo
index: number
profile: Profile
selectedFrame: Frame | null
@@ -76,8 +82,33 @@ interface ProfileTableRowViewProps {
getCSSColorForFrame: (frame: Frame) => string
}
const ProfileTableRowView = (props: ProfileTableRowViewProps) => {
const {frame, profile, index, selectedFrame, setSelectedFrame, getCSSColorForFrame} = props
function highlightRanges(
text: string,
ranges: [number, number][],
highlightedClassName: string,
): JSX.Element {
const spans: ComponentChild[] = []
let last = 0
for (let range of ranges) {
spans.push(text.slice(last, range[0]))
spans.push(<span className={highlightedClassName}>{text.slice(range[0], range[1])}</span>)
last = range[1]
}
spans.push(text.slice(last))
return <span>{spans}</span>
}
const ProfileTableRowView = ({
info,
profile,
index,
selectedFrame,
setSelectedFrame,
getCSSColorForFrame,
}: ProfileTableRowViewProps) => {
const {frame, matchedRanges} = info
const totalWeight = frame.getTotalWeight()
const selfWeight = frame.getSelfWeight()
const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight()
@@ -107,7 +138,13 @@ const ProfileTableRowView = (props: ProfileTableRowViewProps) => {
</td>
<td title={frame.file} className={css(style.textCell)}>
<ColorChit color={getCSSColorForFrame(frame)} />
{frame.name}
{matchedRanges
? highlightRanges(
frame.name,
matchedRanges,
css(style.matched, selected && style.matchedSelected),
)
: frame.name}
</td>
</tr>
)
@@ -120,166 +157,197 @@ interface ProfileTableViewProps {
sortMethod: SortMethod
setSelectedFrame: (frame: Frame | null) => void
setSortMethod: (sortMethod: SortMethod) => void
searchQuery: string
searchIsActive: boolean
}
export const ProfileTableView = memo((props: ProfileTableViewProps) => {
const {
export const ProfileTableView = memo(
({
profile,
sortMethod,
setSortMethod,
selectedFrame,
setSelectedFrame,
getCSSColorForFrame,
} = props
searchQuery,
searchIsActive,
}: ProfileTableViewProps) => {
const onSortClick = useCallback(
(field: SortField, ev: MouseEvent) => {
ev.preventDefault()
const onSortClick = useCallback(
(field: SortField, ev: MouseEvent) => {
ev.preventDefault()
if (sortMethod.field == field) {
// Toggle
setSortMethod({
field,
direction:
sortMethod.direction === SortDirection.ASCENDING
? SortDirection.DESCENDING
: SortDirection.ASCENDING,
})
} else {
// Set a sane default
switch (field) {
case SortField.SYMBOL_NAME: {
setSortMethod({field, direction: SortDirection.ASCENDING})
break
}
case SortField.SELF: {
setSortMethod({field, direction: SortDirection.DESCENDING})
break
}
case SortField.TOTAL: {
setSortMethod({field, direction: SortDirection.DESCENDING})
break
if (sortMethod.field == field) {
// Toggle
setSortMethod({
field,
direction:
sortMethod.direction === SortDirection.ASCENDING
? SortDirection.DESCENDING
: SortDirection.ASCENDING,
})
} else {
// Set a sane default
switch (field) {
case SortField.SYMBOL_NAME: {
setSortMethod({field, direction: SortDirection.ASCENDING})
break
}
case SortField.SELF: {
setSortMethod({field, direction: SortDirection.DESCENDING})
break
}
case SortField.TOTAL: {
setSortMethod({field, direction: SortDirection.DESCENDING})
break
}
}
}
},
[sortMethod, setSortMethod],
)
const rowList = useMemo((): {frame: Frame; matchedRanges: [number, number][] | null}[] => {
const rowList: ProfileTableRowInfo[] = []
profile.forEachFrame(frame => {
let matchedRanges: [number, number][] | null = null
if (searchIsActive) {
const match = fuzzyMatchStrings(frame.name, searchQuery)
if (match == null) return
matchedRanges = match.matchedRanges
}
rowList.push({frame, matchedRanges})
})
switch (sortMethod.field) {
case SortField.SYMBOL_NAME: {
sortBy(rowList, f => f.frame.name.toLowerCase())
break
}
case SortField.SELF: {
sortBy(rowList, f => f.frame.getSelfWeight())
break
}
case SortField.TOTAL: {
sortBy(rowList, f => f.frame.getTotalWeight())
break
}
}
},
[sortMethod, setSortMethod],
)
const frameList = useMemo((): Frame[] => {
const frameList: Frame[] = []
profile.forEachFrame(f => frameList.push(f))
// TODO(jlfwong): This is pretty inefficient to do this on every render, but doesn't
// seem to be a bottleneck, so we'll leave it alone.
switch (sortMethod.field) {
case SortField.SYMBOL_NAME: {
sortBy(frameList, f => f.name.toLowerCase())
break
}
case SortField.SELF: {
sortBy(frameList, f => f.getSelfWeight())
break
}
case SortField.TOTAL: {
sortBy(frameList, f => f.getTotalWeight())
break
}
}
if (sortMethod.direction === SortDirection.DESCENDING) {
frameList.reverse()
}
return frameList
}, [profile, sortMethod])
const listViewRef = useRef<ScrollableListView | null>(null)
const listViewCallback = useCallback(
(listView: ScrollableListView | null) => {
if (listView === listViewRef.current) return
listViewRef.current = listView
if (!selectedFrame || !listView) return
const index = frameList.indexOf(selectedFrame)
if (index === -1) return
listView.scrollIndexIntoView(index)
},
[listViewRef, selectedFrame, frameList],
)
const renderItems = useCallback(
(firstIndex: number, lastIndex: number) => {
const rows: JSX.Element[] = []
for (let i = firstIndex; i <= lastIndex; i++) {
rows.push(
ProfileTableRowView({
frame: frameList[i],
index: i,
profile: profile,
selectedFrame: selectedFrame,
setSelectedFrame: setSelectedFrame,
getCSSColorForFrame: getCSSColorForFrame,
}),
)
if (sortMethod.direction === SortDirection.DESCENDING) {
rowList.reverse()
}
return <table className={css(style.tableView)}>{rows}</table>
},
[frameList, profile, selectedFrame, setSelectedFrame, getCSSColorForFrame],
)
return rowList
}, [profile, sortMethod, searchQuery, searchIsActive])
const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
const renderItems = useCallback(
(firstIndex: number, lastIndex: number) => {
const rows: JSX.Element[] = []
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
onSortClick,
])
const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [
onSortClick,
])
const onSymbolNameClick = useCallback(
(ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev),
[onSortClick],
)
for (let i = firstIndex; i <= lastIndex; i++) {
rows.push(
ProfileTableRowView({
info: rowList[i],
index: i,
profile: profile,
selectedFrame: selectedFrame,
setSelectedFrame: setSelectedFrame,
getCSSColorForFrame: getCSSColorForFrame,
}),
)
}
return (
<div className={css(commonStyle.vbox, style.profileTableView)}>
<table className={css(style.tableView)}>
<thead className={css(style.tableHeader)}>
<tr>
<th className={css(style.numericCell)} onClick={onTotalClick}>
<SortIcon
activeDirection={sortMethod.field === SortField.TOTAL ? sortMethod.direction : null}
/>
Total
</th>
<th className={css(style.numericCell)} onClick={onSelfClick}>
<SortIcon
activeDirection={sortMethod.field === SortField.SELF ? sortMethod.direction : null}
/>
Self
</th>
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
}
/>
Symbol Name
</th>
</tr>
</thead>
</table>
<ScrollableListView
ref={listViewCallback}
axis={'y'}
items={listItems}
className={css(style.scrollView)}
renderItems={renderItems}
/>
</div>
)
})
if (rows.length === 0) {
if (searchIsActive) {
rows.push(
<tr>
<td className={css(style.emptyState)}>
No symbol names match query "{searchQuery}".
</td>
</tr>,
)
} else {
rows.push(
<tr>
<td className={css(style.emptyState)}>No symbols found.</td>
</tr>,
)
}
}
return <table className={css(style.tableView)}>{rows}</table>
},
[
rowList,
profile,
selectedFrame,
setSelectedFrame,
getCSSColorForFrame,
searchIsActive,
searchQuery,
],
)
const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [
rowList,
])
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
onSortClick,
])
const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [
onSortClick,
])
const onSymbolNameClick = useCallback(
(ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev),
[onSortClick],
)
return (
<div className={css(commonStyle.vbox, style.profileTableView)}>
<table className={css(style.tableView)}>
<thead className={css(style.tableHeader)}>
<tr>
<th className={css(style.numericCell)} onClick={onTotalClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.TOTAL ? sortMethod.direction : null
}
/>
Total
</th>
<th className={css(style.numericCell)} onClick={onSelfClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SELF ? sortMethod.direction : null
}
/>
Self
</th>
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
}
/>
Symbol Name
</th>
</tr>
</thead>
</table>
<ScrollableListView
axis={'y'}
items={listItems}
className={css(style.scrollView)}
renderItems={renderItems}
initialIndexInView={
selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
}
/>
</div>
)
},
)
const style = StyleSheet.create({
profileTableView: {
@@ -289,6 +357,7 @@ const style = StyleSheet.create({
scrollView: {
overflowY: 'auto',
overflowX: 'hidden',
flexGrow: 1,
},
tableView: {
width: '100%',
@@ -347,6 +416,16 @@ const style = StyleSheet.create({
background: Colors.GREEN,
right: 0,
},
matched: {
borderBottom: `2px solid ${Colors.BLACK}`,
},
matchedSelected: {
borderColor: Colors.WHITE,
},
emptyState: {
textAlign: 'center',
fontWeight: 'bold',
},
})
interface ProfileTableViewContainerProps {
@@ -372,6 +451,8 @@ export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContain
[index],
)
const setSortMethod = useActionCreator(setTableSortMethod, [])
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
const searchQuery = useAppSelector(state => state.searchQuery, [])
return (
<ProfileTableView
@@ -381,6 +462,8 @@ export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContain
sortMethod={tableSortMethod}
setSelectedFrame={setSelectedFrame}
setSortMethod={setSortMethod}
searchIsActive={searchIsActive}
searchQuery={searchQuery}
/>
)
})
+28 -2
View File
@@ -10,7 +10,9 @@ import {StatelessComponent} from '../lib/typed-redux'
import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view'
import {CalleeFlamegraphView} from './callee-flamegraph-view'
import {ActiveProfileState} from './application'
import {useDispatch} from '../lib/preact-redux'
import {useDispatch, useActionCreator} from '../lib/preact-redux'
import {SearchView} from './search-view'
import {useAppSelector} from '../store'
interface SandwichViewProps {
selectedFrame: Frame | null
@@ -18,6 +20,10 @@ interface SandwichViewProps {
activeProfileState: ActiveProfileState
setSelectedFrame: (selectedFrame: Frame | null) => void
glCanvas: HTMLCanvasElement
searchQuery: string
searchIsActive: boolean
setSearchQuery: (query: string | null) => void
setSearchIsActive: (active: boolean) => void
}
class SandwichView extends StatelessComponent<SandwichViewProps> {
@@ -39,7 +45,13 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
}
render() {
const {selectedFrame} = this.props
const {
selectedFrame,
searchIsActive,
setSearchIsActive,
searchQuery,
setSearchQuery,
} = this.props
let flamegraphViews: JSX.Element | null = null
if (selectedFrame) {
@@ -72,6 +84,12 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableViewContainer activeProfileState={this.props.activeProfileState} />
<SearchView
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchIsActive={searchIsActive}
setSearchIsActive={setSearchIsActive}
/>
</div>
{flamegraphViews}
</div>
@@ -81,6 +99,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
const style = StyleSheet.create({
tableView: {
position: 'relative',
flex: 1,
},
panZoomViewWraper: {
@@ -124,6 +143,8 @@ interface SandwichViewContainerProps {
glCanvas: HTMLCanvasElement
}
const {setSearchQuery, setSearchIsActive} = actions
export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
const {activeProfileState, glCanvas} = ownProps
const {sandwichViewState, index} = activeProfileState
@@ -141,6 +162,7 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
},
[dispatch, index],
)
return (
<SandwichView
activeProfileState={activeProfileState}
@@ -148,6 +170,10 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
setSelectedFrame={setSelectedFrame}
selectedFrame={callerCallee ? callerCallee.selectedFrame : null}
profileIndex={index}
searchQuery={useAppSelector(state => state.searchQuery, [])}
setSearchQuery={useActionCreator(setSearchQuery, [])}
searchIsActive={useAppSelector(state => state.searchIsActive, [])}
setSearchIsActive={useActionCreator(setSearchIsActive, [])}
/>
)
})
+91 -80
View File
@@ -1,57 +1,76 @@
// A simple implementation of an efficient scrolling list view which
// renders only items within the viewport + a couple extra items.
import {h, Component, JSX} from 'preact'
import {h, JSX} from 'preact'
import {useState, useCallback, useRef, useMemo, useEffect} from 'preact/hooks'
export interface ListItem {
size: number
}
interface RangeResult {
firstVisibleIndex: number
lastVisibleIndex: number
invisiblePrefixSize: number
}
interface ScrollableListViewProps {
items: ListItem[]
axis: 'x' | 'y'
renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
className?: string
initialIndexInView?: number | null
}
interface ScrollableListViewState {
firstVisibleIndex: number | null
lastVisibleIndex: number | null
invisiblePrefixSize: number | null
viewportSize: number | null
cachedTotalSize: number
}
export const ScrollableListView = ({
items,
axis,
renderItems,
className,
initialIndexInView,
}: ScrollableListViewProps) => {
const [viewportSize, setViewportSize] = useState<number | null>(null)
const [viewportScrollOffset, setViewportScrollOffset] = useState<number>(0)
export class ScrollableListView extends Component<
ScrollableListViewProps,
ScrollableListViewState
> {
constructor(props: ScrollableListViewProps) {
super(props)
this.state = {
firstVisibleIndex: null,
lastVisibleIndex: null,
invisiblePrefixSize: null,
viewportSize: null,
cachedTotalSize: props.items.reduce((a, b) => a + b.size, 0),
const viewportRef = useRef<HTMLDivElement | null>(null)
const widthOrHeight = axis === 'x' ? 'width' : 'height'
const leftOrTop = axis === 'x' ? 'left' : 'top'
const scrollLeftOrScrollTop = axis === 'x' ? 'scrollLeft' : 'scrollTop'
// This is kind of a weird hack, but I'm not sure what the better of doing something like this is.
const offset = initialIndexInView
? items.reduce((a, b, i) => (i < initialIndexInView ? a + b.size : a), 0)
: 0
const initialScroll = useRef<number | null>(offset)
const viewportCallback = useCallback(
(viewport: HTMLDivElement | null) => {
if (viewport) {
requestAnimationFrame(() => {
setViewportSize(viewport.getBoundingClientRect()[widthOrHeight])
if (initialScroll.current != null) {
viewport.scrollTo({[leftOrTop]: initialScroll.current})
initialScroll.current = null
}
})
} else {
setViewportSize(null)
}
viewportRef.current = viewport
},
[setViewportSize, widthOrHeight, leftOrTop],
)
const rangeResult: RangeResult | null = useMemo(() => {
if (viewportRef.current == null || viewportSize == null || viewportScrollOffset == null) {
return null
}
}
private viewport: HTMLDivElement | null = null
private viewportRef = (viewport: Element | null) => {
this.viewport = (viewport as HTMLDivElement) || null
}
private recomputeVisibleIndices(props: ScrollableListViewProps) {
if (!this.viewport) return
const {items} = props
const viewportSize = this.viewport.getBoundingClientRect().height
// We render items up to a quarter viewport height outside of the
// viewport both above and below to prevent flickering.
const minY = this.viewport.scrollTop - viewportSize / 4
const maxY = this.viewport.scrollTop + viewportSize + viewportSize / 4
const minY = viewportScrollOffset - viewportSize / 4
const maxY = viewportScrollOffset + viewportSize + viewportSize / 4
let total = 0
let invisiblePrefixSize = 0
@@ -77,62 +96,54 @@ export class ScrollableListView extends Component<
}
const lastVisibleIndex = Math.min(i, items.length - 1)
this.setState({invisiblePrefixSize, firstVisibleIndex, lastVisibleIndex})
}
private pendingScroll = 0
public scrollIndexIntoView(index: number) {
this.pendingScroll = this.props.items.reduce((sum, cur, i) => {
if (i >= index) return sum
return sum + cur.size
}, 0)
}
private applyPendingScroll() {
if (!this.viewport) return
const leftOrTop = this.props.axis === 'y' ? 'top' : 'left'
this.viewport.scrollTo({
[leftOrTop]: this.pendingScroll,
})
}
componentWillReceiveProps(nextProps: ScrollableListViewProps) {
if (this.props.items !== nextProps.items) {
this.recomputeVisibleIndices(nextProps)
return {
firstVisibleIndex,
lastVisibleIndex,
invisiblePrefixSize,
}
}
}, [viewportSize, viewportScrollOffset, items])
componentDidMount() {
this.applyPendingScroll()
this.recomputeVisibleIndices(this.props)
window.addEventListener('resize', this.onWindowResize)
}
const totalSize = useMemo(() => items.reduce((a, b) => a + b.size, 0), [items])
componentWillUnmount() {
window.removeEventListener('resize', this.onWindowResize)
}
const onViewportScroll = useCallback(() => {
if (viewportRef.current != null) {
setViewportScrollOffset(viewportRef.current[scrollLeftOrScrollTop])
}
}, [scrollLeftOrScrollTop])
onWindowResize = () => {
this.recomputeVisibleIndices(this.props)
}
useEffect(() => {
const resizeListener = () => {
if (viewportRef.current != null) {
setViewportSize(viewportRef.current.getBoundingClientRect()[widthOrHeight])
}
}
onViewportScroll = (ev: UIEvent) => {
this.recomputeVisibleIndices(this.props)
}
window.addEventListener('resize', resizeListener)
return () => {
window.removeEventListener('resize', resizeListener)
}
}, [widthOrHeight])
render() {
const {cachedTotalSize, firstVisibleIndex, lastVisibleIndex, invisiblePrefixSize} = this.state
const visibleItems = useMemo(() => {
return rangeResult
? renderItems(rangeResult.firstVisibleIndex, rangeResult.lastVisibleIndex)
: null
}, [renderItems, rangeResult])
const content = useMemo(() => {
return (
<div className={this.props.className} ref={this.viewportRef} onScroll={this.onViewportScroll}>
<div style={{height: cachedTotalSize}}>
<div style={{transform: `translateY(${invisiblePrefixSize}px)`}}>
{firstVisibleIndex != null &&
lastVisibleIndex != null &&
this.props.renderItems(firstVisibleIndex, lastVisibleIndex)}
</div>
<div style={{height: totalSize}}>
<div style={{transform: `translateY(${rangeResult?.invisiblePrefixSize || 0}px)`}}>
{visibleItems}
</div>
</div>
)
}
}, [rangeResult?.invisiblePrefixSize, visibleItems, totalSize])
return (
<div className={className} ref={viewportCallback} onScroll={onViewportScroll}>
{content}
</div>
)
}
+156
View File
@@ -0,0 +1,156 @@
import {StyleSheet, css} from 'aphrodite'
import {h} from 'preact'
import {useCallback, useRef, useEffect} from 'preact/hooks'
import {memo} from 'preact/compat'
import {Sizes, Colors, FontSize} from './style'
function stopPropagation(ev: Event) {
ev.stopPropagation()
}
export interface SearchViewProps {
searchQuery: string
searchIsActive: boolean
setSearchQuery: (query: string) => void
setSearchIsActive: (active: boolean) => void
}
export const SearchView = memo(
({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => {
const onInput = useCallback(
(ev: Event) => {
const value = (ev.target as HTMLInputElement).value
setSearchQuery(value)
},
[setSearchQuery],
)
const inputRef = useRef<HTMLInputElement | null>(null)
const onKeyDown = useCallback(
(ev: KeyboardEvent) => {
ev.stopPropagation()
// Hitting Esc should close the search box
if (ev.key === 'Escape') {
setSearchIsActive(false)
}
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
if (inputRef.current) {
// If the input is already focused, select all
inputRef.current.select()
}
// It seems like when an input is focused, the browser find menu pops
// up without this line. It seems like it's not sufficient to only
// preventDefault in the window keydown handler.
ev.preventDefault()
}
},
[setSearchIsActive],
)
useEffect(() => {
const onWindowKeyDown = (ev: KeyboardEvent) => {
// Cmd+F or Ctrl+F open the search box
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
// Prevent the browser's search menu from appearing
ev.preventDefault()
if (inputRef.current) {
// If the search box is already open, then re-select it immediately.
inputRef.current.select()
} else {
// Otherwise, focus the search, then focus the input on the next
// frame, when the search box should have mounted.
setSearchIsActive(true)
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.select()
}
})
}
}
}
window.addEventListener('keydown', onWindowKeyDown)
return () => {
window.removeEventListener('keydown', onWindowKeyDown)
}
}, [setSearchIsActive])
const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
if (!searchIsActive) return null
return (
<div className={css(style.searchView)}>
<span className={css(style.icon)}>🔍</span>
<input
className={css(style.input)}
value={searchQuery}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
ref={inputRef}
/>
<svg
onClick={close}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.99999 4.16217L11.6427 10.8048M11.6427 4.16217L4.99999 10.8048"
stroke="#BDBDBD"
/>
</svg>
</div>
)
},
)
const style = StyleSheet.create({
searchView: {
position: 'absolute',
top: 0,
right: 10,
height: Sizes.TOOLBAR_HEIGHT,
width: 150,
borderWidth: 2,
borderColor: Colors.BLACK,
borderStyle: 'solid',
fontSize: FontSize.LABEL,
boxSizing: 'border-box',
background: Colors.DARK_GRAY,
color: Colors.WHITE,
display: 'flex',
},
input: {
border: 'none',
background: 'none',
fontSize: FontSize.LABEL,
flex: 1,
color: Colors.WHITE,
':focus': {
border: 'none',
outline: 'none',
},
'::selection': {
color: Colors.WHITE,
background: Colors.DARK_BLUE,
},
},
icon: {
display: 'inline-block',
verticalAlign: 'middle',
paddingTop: '0px',
margin: '0 2px 0 4px',
},
})
+1
View File
@@ -22,6 +22,7 @@ export enum Colors {
PALE_DARK_BLUE = '#8EB7ED',
GREEN = '#6FCF97',
TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
YELLOW = '#FEDC62',
}
export enum Sizes {
+13
View File
@@ -108,6 +108,19 @@ function ToolbarCenterContent(props: ToolbarProps): JSX.Element {
}
}, [setProfileSelectShown])
useEffect(() => {
const onWindowKeyPress = (ev: KeyboardEvent) => {
if (ev.key === 't') {
ev.preventDefault()
setProfileSelectShown(true)
}
}
window.addEventListener('keypress', onWindowKeyPress)
return () => {
window.removeEventListener('keypress', onWindowKeyPress)
}
}, [setProfileSelectShown])
if (activeProfileState && profileGroup && profiles) {
if (profileGroup.profiles.length === 1) {
return <Fragment>{activeProfileState.profile.getName()}</Fragment>