# Grid — high-performance headless data grid with virtual scrolling
#
# Renders 100K+ rows at 60fps. Semantic <table> with sticky header.
# Selection, keyboard navigation, inline editing, sorting, column resize.
# Exposes data-* attributes for all interactive states. Themeable via
# CSS custom properties (--grid-*). Ships zero layout CSS — bring your own.
#
# Usage:
#   Grid
#     data: employees
#     columns: [
#       { key: 'name',   title: 'Name',   width: 200 }
#       { key: 'age',    title: 'Age',    width: 80, align: 'right' }
#       { key: 'role',   title: 'Role',   width: 150, type: 'select', source: roles }
#       { key: 'active', title: 'Active', width: 60,  type: 'checkbox' }
#     ]
#     rowHeight: 32
#     overscan: 5
#     @beforeEdit: (row, col, oldVal, newVal) -> newVal
#     @afterEdit: (row, col, oldVal, newVal) -> null

export Grid = component

  @data       := []
  @columns    := []
  @rowHeight  := 32
  @headerHeight := 36
  @overscan   := 5
  @striped    := false
  @beforeEdit := null
  @afterEdit  := null

  formatRegistry = []

  registerFormat = (code, formatFn, parseFn) ->
    id = formatRegistry.length
    formatRegistry.push({ code, format: formatFn, parse: parseFn })
    id

  formatMap := new Map()

  _resolveCol = (ci) ->
    fid = formatMap.get("0,#{ci + 1}")
    if fid isnt undefined then return formatRegistry[fid]
    gid = formatMap.get("0,0")
    if gid isnt undefined then formatRegistry[gid] else null

  colFormatCache ~=
    cache = []
    ci = 0
    while ci < @columns.length
      cache.push(_resolveCol(ci))
      ci++
    cache

  cellFormat = (row, col) ->
    fid = formatMap.get("#{row + 1},#{col + 1}")
    if fid isnt undefined then formatRegistry[fid] else colFormatCache[col]

  _ready          := false
  scrollTop       := 0
  containerHeight := 0
  activeRow       := -1
  activeCol       := -1
  anchorRow       := -1
  anchorCol       := -1
  selecting       := false
  editing         := false
  editValue       := ''
  _enterCommit    = false
  dataVersion     := 0
  sortKeys        := []

  cmp = (a, b) -> (a > b) - (a < b)

  sortIndex ~=
    dataVersion
    len = @data.length
    idx = new Array(len)
    i = 0
    while i < len
      idx[i] = i
      i++
    if sortKeys.length > 0
      idx.sort (a, b) ->
        ki = 0
        while ki < sortKeys.length
          sk = sortKeys[ki]
          va = @data[a][@columns[sk.col].key]
          vb = @data[b][@columns[sk.col].key]
          result = if sk.dir is 'asc' then cmp(va, vb) else cmp(vb, va)
          if result isnt 0 then return result
          ki++
        a - b
    idx

  sortInfo ~=
    arrows = new Array(@columns.length).fill('')
    ranks = new Array(@columns.length).fill(0)
    ki = 0
    while ki < sortKeys.length
      sk = sortKeys[ki]
      arrows[sk.col] = if sk.dir is 'asc' then " ↓" else " ↑"
      ranks[sk.col] = ki + 1
      ki++
    { arrows, ranks }

  cellRef     ~= if activeRow >= 0 and activeCol >= 0 then "R#{activeRow + 1}:C#{activeCol + 1}" else ''
  totalRows   ~= @data.length
  startRow    ~= Math.max(0, Math.floor(scrollTop / @rowHeight) - @overscan)
  endRow      ~= Math.min(totalRows - 1, Math.ceil((scrollTop + containerHeight) / @rowHeight) + @overscan)
  offsetY     ~= startRow * @rowHeight
  bottomSpace ~= Math.max(0, (totalRows - endRow - 1) * @rowHeight)

  # --------------------------------------------------------------------------
  # DOM Recycling — pooled <tr>/<td> elements, imperative updates on scroll
  # --------------------------------------------------------------------------
  #
  # Instead of letting the reactive render loop recreate row components on
  # every scroll frame, we maintain a fixed pool of <tr> elements and update
  # cell textContent directly. This eliminates DOM allocation and GC pressure
  # during fast scrolling — the most performance-critical path in the grid.

  _trPool = []
  _topSpacer = null
  _botSpacer = null
  _selBorder = null

  _createSpacer: ->
    tr = document.createElement('tr')
    tr.className = 'spacer'
    td = document.createElement('td')
    td.style.padding = '0'
    td.style.border = 'none'
    td.style.lineHeight = '0'
    td.style.fontSize = '0'
    tr.appendChild(td)
    tr

  _ensurePool: (needed, numCols) ->
    while _trPool.length < needed
      tr = document.createElement('tr')
      ci = 0
      while ci < numCols
        td = document.createElement('td')
        tr.appendChild(td)
        ci++
      _trPool.push(tr)
    # Add cells if column count grew
    for tr in _trPool
      while tr.children.length < numCols
        tr.appendChild(document.createElement('td'))

  # Effect: update tbody rows from pool on every viewport/data change
  ~>
    return unless _ready
    tbody = @_body
    return unless tbody
    numCols = @columns.length
    count = endRow - startRow + 1
    count = 0 if count < 0

    @_ensurePool(count, numCols)

    _topSpacer.children[0].colSpan = numCols
    _topSpacer.children[0].style.height = "#{offsetY}px"
    _botSpacer.children[0].colSpan = numCols
    _botSpacer.children[0].style.height = "#{bottomSpace}px"

    # Detach all current rows (fast: replaceChildren rebuilds in one
    # reflow instead of N removeChild calls)
    rows = [_topSpacer]

    i = 0
    n = startRow
    while n <= endRow
      if n >= 0 and n < totalRows
        tr = _trPool[i]
        di = sortIndex[n]
        row = @data[di]

        # Stripe class
        if @striped and n % 2 is 1
          tr.className = 'even'
        else
          tr.className = ''

        # Update cells — only textContent, the cheapest DOM mutation
        ci = 0
        while ci < numCols
          td = tr.children[ci]
          c = @columns[ci]
          v = row[c.key]
          fmt = cellFormat(di, ci)
          display = if fmt then fmt.format(v) else v

          td.style.textAlign = c.align or 'left'

          if c.type is 'checkbox'
            # Checkbox cells need an <input> — reuse if present
            inp = td.firstChild
            unless inp and inp.tagName is 'INPUT'
              td.textContent = ''
              inp = document.createElement('input')
              inp.type = 'checkbox'
              inp.className = 'cell-checkbox'
              inp.style.pointerEvents = 'none'
              inp.style.margin = '0'
              inp.style.verticalAlign = 'middle'
              td.appendChild(inp)
            inp.checked = !!v
          else
            # Plain text — fastest path
            if td.firstChild and td.firstChild.nodeType is 3
              td.firstChild.nodeValue = if display? then String(display) else ''
            else
              td.textContent = if display? then String(display) else ''

          ci++
        rows.push(tr)
        i++
      n++

    rows.push(_botSpacer)
    tbody.replaceChildren(...rows)

  # Selection overlay — applies data-* attributes to active/selected cells
  ~>
    return unless _ready
    tbody = @_body
    return unless tbody
    trs = tbody.querySelectorAll('tr:not(.spacer)')

    for el in tbody.querySelectorAll('td[data-active], td[data-selected]')
      delete el.dataset.active
      delete el.dataset.selected
      el.style.borderColor = ''

    unless _selBorder
      _selBorder = document.createElement('div')
      _selBorder.style.cssText = 'position:absolute;pointer-events:none;border:0;outline:1px solid #3b82f6;outline-offset:-1px;z-index:1;display:none'
      @_container.appendChild(_selBorder)

    if anchorRow >= 0 and activeRow >= 0 and anchorCol >= 0 and activeCol >= 0
      minR = Math.min(anchorRow, activeRow)
      maxR = Math.max(anchorRow, activeRow)
      minC = Math.min(anchorCol, activeCol)
      maxC = Math.max(anchorCol, activeCol)
      multi = minR isnt maxR or minC isnt maxC
      r = Math.max(minR, startRow)
      while r <= Math.min(maxR, endRow)
        tr = trs[r - startRow]
        if tr
          c = minC
          while c <= maxC
            td = tr.children[c]
            if td
              td.dataset.selected = ''
              td.style.borderColor = '#bfdbfe'
            c++
        r++

      if multi and not selecting
        topTr = trs[Math.max(minR, startRow) - startRow]
        botTr = trs[Math.min(maxR, endRow) - startRow]
        if topTr and botTr
          topTd = topTr.children[minC]
          botTd = botTr.children[maxC]
          if topTd and botTd
            cRect = @_container.getBoundingClientRect()
            tl = topTd.getBoundingClientRect()
            br = botTd.getBoundingClientRect()
            _selBorder.style.top = "#{tl.top - cRect.top + @_container.scrollTop}px"
            _selBorder.style.left = "#{tl.left - cRect.left + @_container.scrollLeft}px"
            _selBorder.style.width = "#{br.right - tl.left}px"
            _selBorder.style.height = "#{br.bottom - tl.top}px"
            _selBorder.style.display = 'block'
          else
            _selBorder.style.display = 'none'
        else
          _selBorder.style.display = 'none'
      else
        _selBorder.style.display = 'none'
    else
      _selBorder.style.display = 'none'

    cursorRow = if selecting then anchorRow else activeRow
    cursorCol = if selecting then anchorCol else activeCol
    if cursorRow >= startRow and cursorRow <= endRow and cursorCol >= 0
      tr = trs[cursorRow - startRow]
      if tr
        td = tr.children[cursorCol]
        if td
          td.dataset.active = ''

  _removeEditor: ->
    @_container.querySelector('.rip-grid-editor')?.remove()

  openEditor: (initial, cursorEnd) ->
    if activeRow < 0 or activeCol < 0 or editing
      return
    col = @columns[activeCol]
    if col.type is 'checkbox'
      @data[sortIndex[activeRow]][col.key] = not @data[sortIndex[activeRow]][col.key]
      dataVersion++
      return
    val = @data[sortIndex[activeRow]][col.key]
    orig = String(val ?? '')
    if initial isnt undefined and col.type isnt 'select'
      editValue = initial
    else
      editValue = orig
    editing = true
    @_removeEditor()
    el = undefined
    if col.type is 'select'
      el = document.createElement('select')
      el.className = 'rip-grid-editor'
      el.style.borderRadius = '0'
      for opt in (col.source or [])
        o = document.createElement('option')
        o.value = opt
        o.textContent = opt
        o.selected = (opt is editValue)
        el.appendChild(o)
      el.addEventListener 'change', (e) =>
        editValue = e.target.value
        @commitEditor()
      el.addEventListener 'keydown', (e) => @_editorKeydown(e)
    else
      el = document.createElement('input')
      el.className = 'rip-grid-editor'
      el.type = 'text'
      el.value = editValue
      align = col.align or 'left'
      el.style.textAlign = align
      el.addEventListener 'input', (e) =>
        editValue = e.target.value
      el.addEventListener 'keydown', (e) => @_editorKeydown(e)
    @_container.appendChild(el)
    requestAnimationFrame =>
      trs = @_body.querySelectorAll('tr:not(.spacer)')
      tr = trs[activeRow - startRow]
      if tr
        td = tr.children[activeCol]
        if td
          tdRect = td.getBoundingClientRect()
          cRect = @_container.getBoundingClientRect()
          el.style.top = "#{tdRect.top - cRect.top + @_container.scrollTop}px"
          el.style.left = "#{tdRect.left - cRect.left + @_container.scrollLeft}px"
          el.style.width = "#{tdRect.width}px"
          el.style.height = "#{tdRect.height}px"
          if el.selectionStart isnt undefined
            el.selectionStart = el.value.length
            el.selectionEnd = el.value.length
          el.focus()

  commitEditor: ->
    if editing
      di = sortIndex[activeRow]
      col = @columns[activeCol]
      oldVal = @data[di][col.key]
      val = editValue
      fmt = cellFormat(di, activeCol)
      if fmt and fmt.parse
        val = fmt.parse(val)
      if @beforeEdit
        val = @beforeEdit(di, activeCol, oldVal, val)
        if val is false then return @cancelEditor()
      @data[di][col.key] = val
      dataVersion++
      editing = false
      @_removeEditor()
      @_container.focus() if @_container
      if @afterEdit
        @afterEdit(di, activeCol, oldVal, val)

  cancelEditor: ->
    editing = false
    @_removeEditor()
    anchorRow = activeRow
    anchorCol = activeCol
    @_container.focus() if @_container

  _editorKeydown: (e) ->
    numCols = @columns.length
    switch e.key
      when 'Enter'
        e.preventDefault()
        e.stopPropagation()
        @commitEditor()
        _enterCommit = true
      when 'Tab'
        e.preventDefault()
        e.stopPropagation()
        @commitEditor()
        if e.shiftKey
          if activeCol > 0
            activeCol = activeCol - 1
          else if activeRow > 0
            activeRow = activeRow - 1
            activeCol = numCols - 1
            @scrollToRow(activeRow)
        else
          if activeCol < numCols - 1
            activeCol = activeCol + 1
          else if activeRow < totalRows - 1
            activeRow = activeRow + 1
            activeCol = 0
            @scrollToRow(activeRow)
        anchorRow = activeRow
        anchorCol = activeCol
      when 'Escape'
        e.preventDefault()
        e.stopPropagation()
        @cancelEditor()

  _resizeStart: (e) ->
    e.stopPropagation()
    e.preventDefault()
    th = e.target.closest('th')
    if not th then return
    ci = th.cellIndex
    startX = e.clientX
    startW = @columns[ci].width
    handle = e.target
    handle.classList.add('active')
    colEl = @_container.querySelector("colgroup").children[ci]
    onMove = (ev) =>
      w = Math.max(40, startW + ev.clientX - startX)
      colEl.style.width = "#{w}px"
    onUp = (ev) =>
      document.removeEventListener('mousemove', onMove)
      document.removeEventListener('mouseup', onUp)
      handle.classList.remove('active')
      w = Math.max(40, startW + ev.clientX - startX)
      @columns = @columns.map (c, i) -> if i is ci then Object.assign({}, c, { width: w }) else c
    document.addEventListener('mousemove', onMove)
    document.addEventListener('mouseup', onUp)

  _headerClick: (e) ->
    if e.target.classList.contains('resize-handle') then return
    th = e.target.closest('th')
    if not th then return
    ci = th.cellIndex
    if e.shiftKey
      existing = sortKeys.findIndex (sk) -> sk.col is ci
      if existing >= 0
        if sortKeys[existing].dir is 'asc'
          sortKeys = sortKeys.map (sk, i) -> if i is existing then { col: sk.col, dir: 'desc' } else sk
        else if sortKeys.length > 1
          sortKeys = sortKeys.map (sk, i) -> if i is existing then { col: sk.col, dir: 'asc' } else sk
        else
          sortKeys = sortKeys.filter (_, i) -> i isnt existing
      else
        sortKeys = sortKeys.concat({ col: ci, dir: 'asc' })
    else
      if sortKeys.length is 1 and sortKeys[0].col is ci
        if sortKeys[0].dir is 'asc'
          sortKeys = [{ col: ci, dir: 'desc' }]
        else
          sortKeys = []
      else
        sortKeys = [{ col: ci, dir: 'asc' }]
    dataVersion++

  onDblclick: (e) ->
    td = e.target.closest('td')
    if td and not td.parentElement.classList.contains('spacer')
      @openEditor()

  onScroll: (e) ->
    @commitEditor() if editing
    @_nextST = e.currentTarget.scrollTop
    if not @_rafPending
      @_rafPending = true
      requestAnimationFrame =>
        scrollTop = @_nextST
        @_rafPending = false

  scrollToRow: (row) ->
    el = @_container
    if el
      top = row * @rowHeight
      if top < el.scrollTop
        el.scrollTop = top
      else if top + @rowHeight + @headerHeight > el.scrollTop + containerHeight
        el.scrollTop = top + @rowHeight + @headerHeight - containerHeight

  onMousedown: (e) ->
    if e.button isnt 0
      return
    if e.target.classList.contains('rip-grid-editor')
      return
    @commitEditor() if editing
    td = e.target.closest('td')
    if td and not td.parentElement.classList.contains('spacer')
      e.preventDefault()
      @_container.focus() if @_container
      selecting = true
      el = @_container
      rect = el.getBoundingClientRect()
      ri = Math.floor((e.clientY - rect.top + el.scrollTop - @headerHeight) / @rowHeight)
      if ri >= 0 and ri < totalRows
        activeRow = ri
        activeCol = td.cellIndex
        if not e.shiftKey
          anchorRow = ri
          anchorCol = td.cellIndex

  onMouseup: (e) ->
    if selecting
      r = anchorRow; c = anchorCol
      anchorRow = activeRow; anchorCol = activeCol
      activeRow = r; activeCol = c
    selecting = false

  onMousemove: (e) ->
    if not selecting or e.buttons isnt 1
      return
    el = @_container
    rect = el.getBoundingClientRect()
    ri = Math.floor((e.clientY - rect.top + el.scrollTop - @headerHeight) / @rowHeight)
    ri = Math.max(0, Math.min(totalRows - 1, ri))
    td = e.target.closest('td')
    ci = if td and not td.parentElement.classList.contains('spacer') then td.cellIndex else activeCol
    activeRow = ri
    activeCol = ci
    @scrollToRow(ri)

  _jumpToEdge: (row, col, dr, dc) ->
    key = @columns[col]?.key
    return { row, col } unless key
    cur = @data[sortIndex[row]]?[key]
    empty = not cur? or cur is ''
    r = row + dr
    c = col + dc
    while r >= 0 and r < totalRows and c >= 0 and c < @columns.length
      k = @columns[c].key
      v = @data[sortIndex[r]]?[k]
      ve = not v? or v is ''
      if empty
        return { row: r, col: c } unless ve
      else
        if ve then return { row: r - dr, col: c - dc }
      r += dr
      c += dc
    row: Math.max(0, Math.min(totalRows - 1, r - dr))
    col: Math.max(0, Math.min(@columns.length - 1, c - dc))

  onKeydown: (e) ->
    if editing
      return
    numCols = @columns.length
    key = e.key
    _enterCommit = false if key isnt 'Enter'
    switch key
      when 'ArrowDown'
        e.preventDefault()
        if activeRow < 0
          activeRow = 0
          activeCol = 0 if activeCol < 0
        else if e.ctrlKey or e.metaKey
          pos = @_jumpToEdge(activeRow, activeCol, 1, 0)
          activeRow = pos.row
        else if activeRow < totalRows - 1
          activeRow = activeRow + 1
        @scrollToRow(activeRow)
      when 'ArrowUp'
        e.preventDefault()
        if e.ctrlKey or e.metaKey
          pos = @_jumpToEdge(activeRow, activeCol, -1, 0)
          activeRow = pos.row
          activeCol = 0 if activeCol < 0
        else if activeRow > 0
          activeRow = activeRow - 1
        @scrollToRow(activeRow)
      when 'ArrowRight'
        e.preventDefault()
        if activeCol < 0
          activeCol = 0
          activeRow = 0 if activeRow < 0
        else if e.ctrlKey or e.metaKey
          pos = @_jumpToEdge(activeRow, activeCol, 0, 1)
          activeCol = pos.col
        else if activeCol < numCols - 1
          activeCol = activeCol + 1
      when 'ArrowLeft'
        e.preventDefault()
        if e.ctrlKey or e.metaKey
          pos = @_jumpToEdge(activeRow, activeCol, 0, -1)
          activeCol = pos.col
          activeRow = 0 if activeRow < 0
        else if activeCol > 0
          activeCol = activeCol - 1
      when 'Home'
        e.preventDefault()
        if e.ctrlKey or e.metaKey
          activeRow = 0
          activeCol = 0
          @scrollToRow(0)
        else
          activeRow = 0 if activeRow < 0
          activeCol = 0
      when 'End'
        e.preventDefault()
        if e.ctrlKey or e.metaKey
          activeRow = totalRows - 1
          activeCol = numCols - 1
          @scrollToRow(activeRow)
        else
          activeRow = 0 if activeRow < 0
          activeCol = numCols - 1
      when 'Tab'
        e.preventDefault()
        if activeRow < 0 or activeCol < 0
          activeRow = 0
          activeCol = 0
        else if e.shiftKey
          if activeCol > 0
            activeCol = activeCol - 1
          else if activeRow > 0
            activeRow = activeRow - 1
            activeCol = numCols - 1
            @scrollToRow(activeRow)
        else
          if activeCol < numCols - 1
            activeCol = activeCol + 1
          else if activeRow < totalRows - 1
            activeRow = activeRow + 1
            activeCol = 0
            @scrollToRow(activeRow)
      when 'Enter'
        e.preventDefault()
        if activeRow < 0 or activeCol < 0
          activeRow = 0
          activeCol = 0
        else if _enterCommit
          _enterCommit = false
          if e.shiftKey
            if activeRow > 0
              activeRow = activeRow - 1
              anchorRow = activeRow
              @scrollToRow(activeRow)
          else
            if activeRow < totalRows - 1
              activeRow = activeRow + 1
              anchorRow = activeRow
              @scrollToRow(activeRow)
          @openEditor()
          return
        else
          @openEditor()
          return
      when 'F2'
        e.preventDefault()
        if activeRow >= 0 and activeCol >= 0
          @openEditor(undefined, true)
        return
      when 'PageDown'
        e.preventDefault()
        rows = Math.floor(containerHeight / @rowHeight)
        activeRow = Math.min(totalRows - 1, activeRow + rows)
        activeCol = 0 if activeCol < 0
        @scrollToRow(activeRow)
      when 'PageUp'
        e.preventDefault()
        rows = Math.floor(containerHeight / @rowHeight)
        activeRow = Math.max(0, activeRow - rows)
        activeCol = 0 if activeCol < 0
        @scrollToRow(activeRow)
      when 'Escape'
        if anchorRow isnt activeRow or anchorCol isnt activeCol
          anchorRow = activeRow
          anchorCol = activeCol
        else
          activeRow = -1
          activeCol = -1
          anchorRow = -1
          anchorCol = -1
        return
      when ' '
        if activeRow >= 0 and activeCol >= 0
          col = @columns[activeCol]
          if col.type is 'checkbox'
            e.preventDefault()
            @data[sortIndex[activeRow]][col.key] = not @data[sortIndex[activeRow]][col.key]
            dataVersion++
        return
      when 'Delete', 'Backspace'
        if activeRow >= 0 and activeCol >= 0 and not e.ctrlKey and not e.metaKey
          e.preventDefault()
          col = @columns[activeCol]
          if col.type is 'checkbox'
            @data[sortIndex[activeRow]][col.key] = false
          else
            fmt = cellFormat(sortIndex[activeRow], activeCol)
            @data[sortIndex[activeRow]][col.key] = if fmt and fmt.parse then fmt.parse('') else ''
          dataVersion++
        return
      when 'a'
        if e.ctrlKey or e.metaKey
          e.preventDefault()
          anchorRow = 0
          anchorCol = 0
          activeRow = totalRows - 1
          activeCol = numCols - 1
        return
      when 'c'
        if (e.ctrlKey or e.metaKey) and activeRow >= 0 and activeCol >= 0
          e.preventDefault()
          @copySelection()
        return
      when 'v'
        if e.ctrlKey or e.metaKey
          e.preventDefault()
          @pasteAtActive()
        return
      when 'x'
        if (e.ctrlKey or e.metaKey) and activeRow >= 0 and activeCol >= 0
          e.preventDefault()
          @cutSelection()
        return
      else
        if key.length is 1 and not e.ctrlKey and not e.metaKey and not e.altKey
          if activeRow >= 0 and activeCol >= 0
            ct = @columns[activeCol].type
            if ct isnt 'checkbox' and ct isnt 'select'
              e.preventDefault()
              @openEditor(key)
        return

    extending = e.shiftKey and key isnt 'Tab' and key isnt 'Enter'
    if not extending
      anchorRow = activeRow
      anchorCol = activeCol

  # --------------------------------------------------------------------------
  # Clipboard — copy, cut, paste via navigator.clipboard (TSV format)
  # --------------------------------------------------------------------------

  _selectionBounds: ->
    minR = Math.min(anchorRow, activeRow)
    maxR = Math.max(anchorRow, activeRow)
    minC = Math.min(anchorCol, activeCol)
    maxC = Math.max(anchorCol, activeCol)
    { minR, maxR, minC, maxC }

  _selectionToTSV: ->
    { minR, maxR, minC, maxC } = @_selectionBounds()
    lines = []
    r = minR
    while r <= maxR
      di = sortIndex[r]
      row = @data[di]
      cells = []
      c = minC
      while c <= maxC
        v = row[@columns[c].key]
        s = if v? then String(v) else ''
        if s.includes('\t') or s.includes('\n') or s.includes('"')
          s = '"' + s.replace(/"/g, '""') + '"'
        cells.push(s)
        c++
      lines.push(cells.join('\t'))
      r++
    lines.join('\n')

  _parseTSV: (text) ->
    rows = []
    lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
    for line, li in lines
      continue if line is '' and li is lines.length - 1
      if line.includes('"')
        cells = []
        i = 0
        while i < line.length
          if line[i] is '"'
            j = i + 1
            val = ''
            while j < line.length
              if line[j] is '"'
                if line[j + 1] is '"'
                  val += '"'
                  j += 2
                else
                  j++
                  break
              else
                val += line[j]
                j++
            cells.push(val)
            i = j + 1
          else
            j = line.indexOf('\t', i)
            if j is -1
              cells.push(line.slice(i))
              break
            else
              cells.push(line.slice(i, j))
              i = j + 1
        rows.push(cells)
      else
        rows.push(line.split('\t'))
    rows

  copySelection: ->
    return if activeRow < 0 or activeCol < 0
    tsv = @_selectionToTSV()
    navigator.clipboard.writeText(tsv) if navigator.clipboard

  cutSelection: ->
    return if activeRow < 0 or activeCol < 0
    @copySelection()
    { minR, maxR, minC, maxC } = @_selectionBounds()
    r = minR
    while r <= maxR
      di = sortIndex[r]
      c = minC
      while c <= maxC
        col = @columns[c]
        if col.type is 'checkbox'
          @data[di][col.key] = false
        else
          fmt = cellFormat(di, c)
          @data[di][col.key] = if fmt and fmt.parse then fmt.parse('') else ''
        c++
      r++
    dataVersion++

  pasteAtActive: ->
    return if activeRow < 0 or activeCol < 0
    return unless navigator.clipboard
    navigator.clipboard.readText().then (text) =>
      return unless text
      rows = @_parseTSV(text)
      return unless rows.length
      numCols = @columns.length
      r = 0
      while r < rows.length
        ri = activeRow + r
        break if ri >= totalRows
        di = sortIndex[ri]
        c = 0
        while c < rows[r].length
          ci = activeCol + c
          break if ci >= numCols
          col = @columns[ci]
          val = rows[r][c]
          if col.type is 'checkbox'
            @data[di][col.key] = val is 'true' or val is '1' or val is 'yes'
          else
            fmt = cellFormat(di, ci)
            @data[di][col.key] = if fmt and fmt.parse then fmt.parse(val) else val
          c++
        r++
      dataVersion++

  # Public API
  getCell: (row, col) -> @data[row][@columns[col].key]
  setCell: (row, col, value) -> @data[row][@columns[col].key] = value; dataVersion++
  getData: -> @data
  setData: (d) -> @data = d; dataVersion++
  sort: (col, dir) -> sortKeys = if dir then [{ col, dir }] else []; dataVersion++

  mounted: ->
    _topSpacer = @_createSpacer()
    _botSpacer = @_createSpacer()
    _ready = true
    if @_container
      handler = (entries) =>
        for entry in entries
          containerHeight = entry.contentRect.height
      @_resizeObs = new ResizeObserver(handler)
      @_resizeObs.observe(@_container)

  unmounted: ->
    if @_resizeObs
      @_resizeObs.disconnect()

  render
    div.grid-container ref: "_container", role: "grid", tabindex: "0",
      $editing: editing?!,
      $selecting: selecting?!
      table.rip-grid
        colgroup
          for c in @columns
            col style: "width: #{c.width}px"
        thead
          tr @click: @_headerClick
            for c, ci in @columns
              th style: "text-align: #{c.align or 'left'}; cursor: pointer",
                $sorted: (if sortInfo.arrows[ci] then (if sortInfo.arrows[ci].includes('↓') then 'asc' else 'desc') else undefined)
                = c.title
                span.sort-arrow
                  sortInfo.arrows[ci]
                if sortKeys.length > 1 and sortInfo.ranks[ci] > 0
                  span.sort-badge
                    "#{sortInfo.ranks[ci]}"
                span.resize-handle @mousedown: @_resizeStart
        tbody ref: "_body"
