function groupCaretsByRow(textRenderInfo) {
  // textRenderInfo is frozen so it's safe to cache based on it
  let rows = _caretsByRowCache.get(textRenderInfo)
  if (!rows) {
    rows = []
    const {caretPositions} = textRenderInfo
    let curRow

    const visitCaret = (x, bottom, top, charIndex) => {
      // new row if not overlapping by at least half
      if (!curRow || (top < (curRow.top + curRow.bottom) / 2)) {
        rows.push(curRow = {bottom, top, carets: []})
      }
      // expand vertical limits if necessary
      if (top > curRow.top) curRow.top = top
      if (bottom < curRow.bottom) curRow.bottom = bottom
      curRow.carets.push({
        x,
        y: bottom,
        height: top - bottom,
        charIndex,
      })
    }

    let i = 0
    for (; i < caretPositions.length; i += 4) {
      visitCaret(caretPositions[i], caretPositions[i + 2], caretPositions[i + 3], i / 4)
    }
    // Add one more caret after the final char
    visitCaret(caretPositions[i - 3], caretPositions[i - 2], caretPositions[i - 1], i / 4)
  }
  _caretsByRowCache.set(textRenderInfo, rows)
  return rows
}
Group a set of carets by row of text, caching the result. A single row of text may contain carets of
differing positions/heights if it has multiple fonts, and they may overlap slightly across rows, so this
uses an assumption of "at least overlapping by half" to put them in the same row.
@return Array<{bottom: number, top: number, carets: TextCaret[]}>