Repository: jlfwong/speedscope
Pull request #297 (2020-07-20 04:13:26)

Implement search highlighting in time order & left heavy views
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 


Patch:
diff --git a/src/lib/text-utils.ts b/src/lib/text-utils.ts
index 7b170b7..aeff9f3 100644
--- a/src/lib/text-utils.ts
+++ b/src/lib/text-utils.ts
@@ -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)
+interface TrimmedTextResult {
+  trimmedString: string
+  trimmedLength: number
+  prefixLength: number
+  suffixLength: number
+  originalLength: number
+  originalString: string
+}
+
+// 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 - prefixLength, prefixLength)
-  return prefix + ELLIPSIS + suffix
+  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,
+  }
 }
 
-export function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
-  if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
+// 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
+}
diff --git a/src/views/callee-flamegraph-view.tsx b/src/views/callee-flamegraph-view.tsx
index 7eebc52..7af82b8 100644
--- a/src/views/callee-flamegraph-view.tsx
+++ b/src/views/callee-flamegraph-view.tsx
@@ -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()}
     />
   )
 })
diff --git a/src/views/flamechart-pan-zoom-view.tsx b/src/views/flamechart-pan-zoom-view.tsx
index 437e9b6..6888cc6 100644
--- a/src/views/flamechart-pan-zoom-view.tsx
+++ b/src/views/flamechart-pan-zoom-view.tsx
@@ -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
@@ -48,6 +55,9 @@ export interface FlamechartPanZoomViewProps {
 
   logicalSpaceViewportSize: Vec2
   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()
 
@@ -690,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) {
diff --git a/src/views/flamechart-view-container.tsx b/src/views/flamechart-view-container.tsx
index 55b5244..58acaa8 100644
--- a/src/views/flamechart-view-container.tsx
+++ b/src/views/flamechart-view-container.tsx
@@ -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}
     />
   )
diff --git a/src/views/flamechart-view.tsx b/src/views/flamechart-view.tsx
index 02a63a8..b14a5ea 100644
--- a/src/views/flamechart-view.tsx
+++ b/src/views/flamechart-view.tsx
@@ -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() {
@@ -114,6 +115,14 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
           setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
           logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
           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 && (
diff --git a/src/views/flamechart-wrapper.tsx b/src/views/flamechart-wrapper.tsx
index d63cbf8..d0f602c 100644
--- a/src/views/flamechart-wrapper.tsx
+++ b/src/views/flamechart-wrapper.tsx
@@ -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) {
@@ -84,6 +95,8 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
           renderInverted={this.props.renderInverted}
           logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
           setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
+          searchIsActive={this.props.searchIsActive}
+          searchQuery={this.props.searchQuery}
         />
         {this.renderTooltip()}
       </div>
diff --git a/src/views/inverted-caller-flamegraph-view.tsx b/src/views/inverted-caller-flamegraph-view.tsx
index 521544e..d372bf3 100644
--- a/src/views/inverted-caller-flamegraph-view.tsx
+++ b/src/views/inverted-caller-flamegraph-view.tsx
@@ -14,7 +14,7 @@ import {
 } 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'
 
@@ -90,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()}
     />
   )
 })
diff --git a/src/views/profile-table-view.tsx b/src/views/profile-table-view.tsx
index ed04d35..3ed78fb 100644
--- a/src/views/profile-table-view.tsx
+++ b/src/views/profile-table-view.tsx
@@ -357,6 +357,7 @@ const style = StyleSheet.create({
   scrollView: {
     overflowY: 'auto',
     overflowX: 'hidden',
+    flexGrow: 1,
   },
   tableView: {
     width: '100%',
diff --git a/src/views/scrollable-list-view.tsx b/src/views/scrollable-list-view.tsx
index 23d535a..5cecec2 100644
--- a/src/views/scrollable-list-view.tsx
+++ b/src/views/scrollable-list-view.tsx
@@ -50,7 +50,6 @@ export const ScrollableListView = ({
         requestAnimationFrame(() => {
           setViewportSize(viewport.getBoundingClientRect()[widthOrHeight])
           if (initialScroll.current != null) {
-            console.log('executing initial scroll to ', initialScroll.current)
             viewport.scrollTo({[leftOrTop]: initialScroll.current})
             initialScroll.current = null
           }
diff --git a/src/views/search-view.tsx b/src/views/search-view.tsx
index c63c8b5..e66776d 100644
--- a/src/views/search-view.tsx
+++ b/src/views/search-view.tsx
@@ -8,11 +8,11 @@ function stopPropagation(ev: Event) {
   ev.stopPropagation()
 }
 
-interface SearchViewProps {
+export interface SearchViewProps {
   searchQuery: string
   searchIsActive: boolean
 
-  setSearchQuery: (query: string | null) => void
+  setSearchQuery: (query: string) => void
   setSearchIsActive: (active: boolean) => void
 }
 
@@ -36,6 +36,18 @@ export const SearchView = memo(
         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],
     )
@@ -48,10 +60,17 @@ export const SearchView = memo(
           ev.preventDefault()
 
           if (inputRef.current) {
-            // If the search box is already open, then re-select it.
+            // 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()
+              }
+            })
           }
         }
       }
@@ -62,15 +81,6 @@ export const SearchView = memo(
       }
     }, [setSearchIsActive])
 
-    const focusInput = useCallback((node: HTMLInputElement | null) => {
-      if (node) {
-        requestAnimationFrame(() => {
-          node.select()
-        })
-      }
-      inputRef.current = node
-    }, [])
-
     const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
 
     if (!searchIsActive) return null
@@ -85,7 +95,7 @@ export const SearchView = memo(
           onKeyDown={onKeyDown}
           onKeyUp={stopPropagation}
           onKeyPress={stopPropagation}
-          ref={focusInput}
+          ref={inputRef}
         />
 
         <svg
diff --git a/src/views/style.ts b/src/views/style.ts
index dca459c..91ca7d6 100644
--- a/src/views/style.ts
+++ b/src/views/style.ts
@@ -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 {


Test patch:
diff --git a/src/lib/text-utils.test.ts b/src/lib/text-utils.test.ts
new file mode 100644
index 0000000..374e800
--- /dev/null
+++ b/src/lib/text-utils.test.ts
@@ -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]`,
+  })
+})