//
// Copyright (c) 2015, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
// 22 Sep 2015 Andy Frank Creation
//
using dom
using graphics
**
** Table displays a grid of rows and columns.
**
** See also: [docDomkit]`docDomkit::Controls#table`
**
@Js class Table : Elem
{
** Constructor.
new make() : super("div")
{
this->tabIndex = 0
this.view = TableView(this)
this.sel = TableSelection(view)
this.style.addClass("domkit-Table").addClass("domkit-border")
this.onEvent("wheel", false) |e|
{
// don't consume vertical scroll if not required; this
// allows the parent container to scroll the table within
// its own viewport; this is a little tricky without
// consume horiz events, so for now just assume it was a
// "vertical" event if y-delta is greater than x-delta
if (!hasScrolly && e.delta != null && e.delta.y.abs > e.delta.x.abs) return
onScroll(e.delta)
e.stop
}
this.onEvent("mousedown", false) |e| { onMouseEvent(e) }
this.onEvent("mouseup", false) |e| { onMouseEvent(e) }
this.onEvent("mousemove", false) |e| { onMouseEvent(e) }
this.onEvent("dblclick", false) |e| { onMouseEvent(e) }
this.onEvent("keydown", false) |e| { onKeyEvent(e) }
// manually track focus so we can detect when
// the browser window becomes unactive while
// maintaining focus internally in document
this.onEvent("focus", false) |e| { if (!manFocus) { manFocus=true; refresh }}
this.onEvent("blur", false) |e| { manFocus=false; refresh }
// rebuild if size changes
DomListener.cur.onResize(this) { rebuild }
}
** Model for this table.
TableModel model := TableModel()
{
set { &model=it; view.refresh }
}
** Is the table header visible.
Bool showHeader := true
** List of CSS classes applied to rows in sequence, looping as required.
Str[] stripeClasses := ["even", "odd"]
** Callback to display header popup. When non-null, a button will be
** placed on the right-hand side of the table header to indicate the
** popup is available.
Void onHeaderPopup(|Table->Popup| f) { this.cbHeaderPopup = f }
** The view wraps the table model to implement the row/col mapping
** from the view coordinate space to the model coordinate space based
** on column visibility and row sort order.
@NoDoc TableView view { private set }
** The column index by which the table is currently sorted, or null
** if the table is not currently sorted by a column. See `sort`.
Int? sortCol() { view.sortCol }
** Return if the table is currently sorting up or down. See `sort`.
Dir sortDir() { view.sortDir }
** Sort a table by the given column index. If col is null, then
** the table is ordered by its natural order of the table model.
** Sort order is determined by `TableModel.sortCompare`. Sorting
** does not modify the indexing of TableModel, it only changes how
** the model is viewed. Also see `sortCol` and `sortDir`. Table
** automatically refreshed.
Void sort(Int? col, Dir dir := Dir.up)
{
if (!sortEnabled) return
pivot = null
view.sort(col, dir)
model.onSort(col, dir)
refresh
cbSort?.call(this)
}
** Flag to enable/disable column header sort
@NoDoc Bool sortEnabled := true
** Scroll to the given row and column in table. Pass 'null' to
** maintain the current scroll position for that axis.
Void scrollTo(Int? col, Int? row)
{
// force viewport bounds
if (numCols == 0 || colx.last + colw.last <= tbodyw) col = null
if (numRows < numVisRows) row = null
// update horiz scroll and row position
if (col != null)
{
col = col.max(0).min(numCols-1)
rx := colx[col]
rw := colw[col]
maxx := maxScrollx - tbodyw
if (hasScrolly) maxx += overScroll
// echo("# scollTo: $col -> $rx:$rw $maxx [$scrollx:$maxScrollx]")
col = col.min(numCols - numVisCols).max(0)
scrollx = rx.min(maxx)
}
// update vert scroll and row position
if (row != null)
{
row = row.max(0).min(numRows-1)
ry := row * rowh
miny := scrolly
maxy := scrolly + tbodyh - rowh
if (hasScrollx) maxy -= overScroll
// echo("# scollTo: $row -> $ry min:$miny max:$maxy [$scrolly]")
if (ry >= miny && ry <= maxy)
{
// already in view
row = null
}
else if (ry < scrolly)
{
// scroll row into view
scrolly = ry
}
else
{
// set first visible row, scroll last row into view
scrolly = scrolly + (ry - maxy)
row = (scrolly / rowh).min(numRows - numVisRows).max(0)
}
}
// update content
onUpdate(col ?: firstVisCol, row ?: firstVisRow)
}
** Selection for table
Selection sel { private set }
** Callback when selection has changed but before taking effect.
@NoDoc Void onBeforeSelect(|Int[]->Bool| f) { cbBeforeSelect = f }
** Callback when selection has changed.
Void onSelect(|This| f) { cbSelect = f }
** Callback when row is double-clicked.
Void onAction(|This| f) { cbAction = f }
** Callback when table is sorted by a column
Void onSort(|This| f) { cbSort = f }
** Callback when a key is pressed in table.
// TODO: need to fix to take |This,Event| arg...
@NoDoc Void onKeyDown(|Event| f) { cbKeyDown = f }
** Callback when a event occurs inside a table cell.
Void onTableEvent(Str type, |TableEvent| f) { cbTableEvent[type] = f }
//////////////////////////////////////////////////////////////////////////
// Update
//////////////////////////////////////////////////////////////////////////
** Subclass hook to run when `rebuild` is invoked.
@NoDoc protected virtual Void onBeforeRebuild() {}
** Rebuild table layout.
Void rebuild()
{
if (this.size.w > 0f) doRebuild
else Win.cur.setTimeout(16ms) |->| { rebuild }
}
** Refresh table cell content.
Void refresh()
{
refreshHeaders
numVisRows.times |r|
{
row := firstVisRow + r
refreshRow(row)
}
}
** Refresh all header content.
private Void refreshHeaders()
{
numVisCols.times |c|
{
col := firstVisCol + c
header := headers[col]
if (header == null) return
refreshHeader(header, col)
}
}
** Refresh single header content.
private Void refreshHeader(Elem? header, Int col)
{
header = header ?: headers[col]
if (header == null) throw Err("Header not found: $col")
// update static style
header.style.removeClass("last")
if (col == numCols-1) header.style.addClass("last")
// update sort icon
if (col < numCols && view.colViewToModel(col) == view.sortCol)
{
header.style
.addClass("domkit-Table-header-sort")
.removeClass("down up popup")
.addClass(sortDir == Dir.up ? "up" : "down")
if (col == numCols-1 && hasHpbut) header.style.addClass("popup")
}
else
{
header.style
.removeClass("domkit-Table-header-sort")
.removeClass("down up popup")
}
// update model content
if (col < numCols) view.onHeader(header, col)
}
** Refresh single row content.
internal Void refreshRow(Int row)
{
// short-circuit if not in view
if (row < firstVisRow || row > firstVisRow + numVisRows) return
numVisCols.times |c|
{
col := firstVisCol + c
pos := TablePos(col, row)
cell := cells[pos]
if (cell == null) return
refreshCell(cell, pos.col, pos.row)
}
}
** Refresh single cell content.
private Void refreshCell(Elem? cell, Int col, Int row)
{
// get cell
cell = cell ?: cells[TablePos(col, row)]
if (cell == null) throw Err("Cell not found: $col,$row")
// update static view style
cell.style.removeClass("last").removeClass("domkit-sel")
if (stripeClasses.size > 0)
{
stripeClasses.each |c| { cell.style.removeClass(c) }
cell.style.addClass(stripeClasses[row % stripeClasses.size])
}
if (col == numCols-1) cell.style.addClass("last")
// update model content
if (col < numCols && row < numRows)
{
rowSel := sel.indexes.binarySearch(view.rowViewToModel(row)) >= 0
if (rowSel) cell.style.addClass("domkit-sel")
flags := TableFlags
{
it.focused = manFocus
it.selected = rowSel
}
view.onCell(cell, col, row, flags)
}
}
** Callback from refresh with valid layout dimensions.
private Void doRebuild()
{
// subclass rebuild hook
onBeforeRebuild
// update view first so downstream checks work properly
view.refresh
view.sort(view.sortCol, view.sortDir)
this.numCols = view.numCols
this.numRows = view.numRows
// refresh sel and reset dom caches
sel.refresh
headers.clear
cells.clear
// get container dims
tbodysz := this.size
this.theadh = showHeader ? view.headerHeight : 0
this.tbodyw = tbodysz.w.toInt
this.tbodyh = tbodysz.h.toInt - theadh
// cache layout vars
cx := 0
this.colx.clear
this.colw.clear
this.numCols.times |c|
{
cw := ucolw[c] ?: view.colWidth(c)
this.colx.add(cx)
this.colw.add(cw)
cx += cw
}
this.rowh = view.rowHeight
this.numVisCols = findMaxVisCols + 2
this.numVisRows = tbodyh / rowh + 2
// setup scrollbox
this.scrollx = 0
this.scrolly = 0
this.maxScrollx = colw.reduce(0) |Int r, Int w->Int| { r + w }
this.maxScrolly = numRows * rowh
this.firstVisCol = 0
this.firstVisRow = 0
// setup scrollbars
this.hasScrollx = maxScrollx > tbodyw
this.hasScrolly = maxScrolly > tbodyh
this.hbar = makeScrollBar(Dir.right)
this.vbar = makeScrollBar(Dir.down)
// expand to table width if needed
if (maxScrollx <= tbodyw)
{
if (numCols == 0) this.numVisCols = 0
else
{
this.numVisCols = numCols
this.colw[-1] = tbodyw - colx.last
}
}
else if (hasScrolly)
{
this.colw[-1] += overScroll
}
// // debug
// echo("# Table.refresh
// # tbodyw: $tbodyw
// # tbodyh: $tbodyh
// # numCols: $numCols
// # numRows: $numRows
// # rowh: $rowh
// # numVisCols: $numVisCols
// # numVisRows: $numVisRows
// # maxScrollx: $maxScrollx
// # maxScrolly: $maxScrolly
// ")
// setup thead
this.thead = Elem
{
it.style.addClass("domkit-Table-thead")
it.style->height = "${theadh}px"
if (theadh == 0) it.style->display = "none"
}
numVisCols.times |c|
{
header := Elem
{
it.style.addClass("domkit-Table-header")
it.style->width = "${colwSafe(c)}px"
it.style->lineHeight = "${theadh+1}px"
if (c == numCols-1) it.style.addClass("last")
}
headers[c] = header
refreshHeader(header, c)
thead.add(header)
}
// setup header popup
if (cbHeaderPopup == null)
{
this.hpbut = null
this.hasHpbut = false
}
else
{
this.hpbut = Elem
{
mtop := ((theadh-21) / 2) + 3
it.style.addClass("domkit-Table-header-popup")
it.style->height = "${theadh}px"
it.add(Elem { it.style->marginTop="${mtop}px" })
it.add(Elem {})
it.add(Elem {})
}
this.hasHpbut = true
thead.add(hpbut)
}
// setup tbody
this.tbody = Elem
{
it.style.addClass("domkit-Table-tbody")
it.style->top = "${theadh}px"
}
numVisRows.times |r|
{
numVisCols.times |c|
{
// TODO FIXIT: seems like an awful lot of overlap of
// refreshCell - should look at collapsing behavoir here
rowSel := false
cell := Elem
{
it.style.addClass("domkit-Table-cell")
if (stripeClasses.size > 0)
it.style.addClass(stripeClasses[r % stripeClasses.size])
if (c == numCols-1) it.style.addClass("last")
if (c < numCols && r < numRows)
{
if (sel.indexes.binarySearch(view.rowViewToModel(r)) >= 0)
{
it.style.addClass("domkit-sel")
rowSel = true
}
}
it.style->width = "${colwSafe(c)}px"
it.style->height = "${rowh}px"
it.style->lineHeight = "${rowh+1}px"
}
flags := TableFlags
{
it.focused = manFocus
it.selected = rowSel
}
cells[TablePos(c, r)] = cell
if (c < numCols && r < numRows) view.onCell(cell, c, r, flags)
tbody.add(cell)
}
}
// update dom
removeAll
add(thead)
add(tbody)
add(hbar)
add(vbar)
onUpdate(0,0)
// 16-Jul-2024 - Andy Frank
// Safari 17.5 introduced a regression where the dom is not rendered
// on the initial display; so for now force a repaint by toggling
// the display style on the first table cell
if (Win.cur.isSafari && cells.size > 0)
{
x := cells.vals.first
Win.cur.setTimeout(10ms) {
x.style->display = "none"
Win.cur.setTimeout(10ms) {
x.style->display = "block"
}
}
}
}
** Create scrollbar
private Elem makeScrollBar(Dir dir)
{
Elem
{
xsz := sbarsz - thumbMargin - thumbMargin - 1
it.style.addClass("domkit-Table-scrollbar")
if (dir == Dir.right)
{
if (!hasScrollx) it.style->visibility = "hidden"
this.htrackw = tbodyw - (hasScrolly ? sbarsz : 0) - 2
this.hthumbw = (tbodyw.toFloat / maxScrollx.toFloat * htrackw.toFloat).toInt.max(xsz)
it.style->left = "0px"
it.style->bottom = "0px"
it.style->width = "${htrackw}px"
it.style->height = "${sbarsz}px"
it.style->borderTopWidth = "1px"
it.onEvent("dblclick", false) |e| { e.stop }
it.onEvent("mouseup", false) |e| { hbarPageId = stopScrollPage(hbarPageId) }
it.onEvent("mouseout", false) |e| { hbarPageId = stopScrollPage(hbarPageId) }
it.onEvent("mousedown", false) |e| {
e.stop
p := e.target.relPos(e.pagePos)
thumb := e.target.firstChild
if (p.x < thumb.pos.x) hbarPageId = startScrollPage(Point(-tbodyw, 0))
else if (p.x > thumb.pos.x + thumb.size.w.toInt) hbarPageId = startScrollPage(Point(tbodyw, 0))
}
Elem {
it.style->margin = "${thumbMargin}px"
it.style->top = "0px"
it.style->left = "0px"
it.style->width = "${hthumbw}px"
it.style->height = "${xsz}px"
it.onEvent("dblclick", false) |e| { e.stop }
it.onEvent("mousedown", false) |e| {
e.stop
hthumbDragOff = hbar.firstChild.relPos(e.pagePos).x.toInt
doc := Win.cur.doc
Obj? fmove
Obj? fup
fmove = doc.onEvent("mousemove", true) |de| {
dx := hbar.relPos(de.pagePos).x - hthumbDragOff
sx := (dx.toFloat / htrackw.toFloat * maxScrollx).toInt
onScroll(Point.makeInt(sx - scrollx, 0))
}
fup = doc.onEvent("mouseup", true) |de| {
de.stop
hthumbDragOff = null
doc.removeEvent("mousemove", true, fmove)
doc.removeEvent("mouseup", true, fup)
}
}
},
}
else
{
if (!hasScrolly) it.style->visibility = "hidden"
this.vtrackh = tbodyh - (hasScrollx ? sbarsz : 0) - 2
this.vthumbh = (tbodyh.toFloat / maxScrolly.toFloat * vtrackh.toFloat).toInt.max(xsz)
it.style->top = "${theadh}px"
it.style->right = "0px"
it.style->width = "${sbarsz}px"
it.style->height = "${vtrackh}px"
it.style->borderLeftWidth = "1px"
it.onEvent("dblclick", false) |e| { e.stop }
it.onEvent("mouseup", false) |e| { vbarPageId = stopScrollPage(vbarPageId) }
it.onEvent("mouseout", false) |e| { vbarPageId = stopScrollPage(vbarPageId) }
it.onEvent("mousedown", false) |e| {
e.stop
p := e.target.relPos(e.pagePos)
thumb := e.target.firstChild
if (p.y < thumb.pos.y) vbarPageId = startScrollPage(Point(0, -tbodyh))
else if (p.y > thumb.pos.y + thumb.size.h.toInt) vbarPageId = startScrollPage(Point(0, tbodyh))
}
Elem {
it.style->margin = "${thumbMargin}px"
it.style->top = "0px"
it.style->left = "0px"
it.style->width = "${xsz}px"
it.style->height = "${vthumbh}px"
it.onEvent("dblclick", false) |e| { e.stop }
it.onEvent("mousedown", false) |e| {
e.stop
vthumbDragOff = vbar.firstChild.relPos(e.pagePos).y.toInt
doc := Win.cur.doc
Obj? fmove
Obj? fup
fmove = doc.onEvent("mousemove", true) |de| {
dy := vbar.relPos(de.pagePos).y - vthumbDragOff
sy := (dy.toFloat / vtrackh.toFloat * maxScrolly).toInt
onScroll(Point.makeInt(0, sy - scrolly))
}
fup = doc.onEvent("mouseup", true) |de| {
de.stop
vthumbDragOff = null
doc.removeEvent("mousemove", true, fmove)
doc.removeEvent("mouseup", true, fup)
}
}
},
}
}
}
** Start scroll page event.
private Int? startScrollPage(Point delta)
{
onScroll(delta)
return Win.cur.setInterval(scrollPageFreq) { onScroll(delta) }
}
** Cancel scroll page event.
private Int? stopScrollPage(Int? fid)
{
if (fid != null) Win.cur.clearInterval(fid)
return null
}
** Pulse scrollbar.
private Void pulseScrollBar(Dir dir)
{
if (dir == Dir.right)
{
hbar.style.addClass("active")
if (hbarPulseId != null) Win.cur.clearTimeout(hbarPulseId)
hbarPulseId = Win.cur.setTimeout(scrollPulseDir) { hbar.style.removeClass("active") }
}
else
{
vbar.style.addClass("active")
if (vbarPulseId != null) Win.cur.clearTimeout(vbarPulseId)
vbarPulseId = Win.cur.setTimeout(scrollPulseDir) { vbar.style.removeClass("active") }
}
}
** Callback to update table to given starting cell position.
private Void onUpdate(Int col, Int row)
{
// echo("# onUpdate($col, $row)")
// no-op if no cols
if (numCols == 0) return
// update scrollbars
if (hasScrollx)
{
sw := maxScrollx - tbodyw + (hasScrolly ? overScroll : 0)
sp := scrollx.toFloat / sw.toFloat
hw := htrackw - hthumbw - (thumbMargin * 2)
hx := (sp * hw.toFloat).toInt
ox := hbar.firstChild.style->left.toStr[0..-3].toInt
// echo("# $scrollx:$sw @ $sp -- $hx:$hw [$ox]")
if (ox != hx)
{
pulseScrollBar(Dir.right)
hbar.firstChild.style->left = "${hx}px"
}
}
if (hasScrolly)
{
sh := maxScrolly - tbodyh + (hasScrollx ? overScroll : 0)
sp := scrolly.toFloat / sh.toFloat
vh := vtrackh - vthumbh - (thumbMargin * 2)
vy := (sp * vh.toFloat).toInt
oy := vbar.firstChild.style->top.toStr[0..-3].toInt
// echo("# $scrolly:$sh @ $sp -- $vy:$vh [$oy]")
if (oy != vy)
{
pulseScrollBar(Dir.down)
vbar.firstChild.style->top = "${vy}px"
}
}
// update cells
thead.style->display = "none"
tbody.style->display = "none"
onMoveX(col)
onMoveY(row)
thead.style->display = theadh==0 ? "none" : ""
tbody.style->display = ""
// update transforms
headers.each |h,c| {
tx := colxSafe(c) - scrollx
h.style->transform = "translate(${tx}px, 0)"
}
cells.each |c,p| {
tx := colxSafe(p.col) - scrollx
ty := (p.row * rowh) - scrolly
c.style->transform = "translate(${tx}px, ${ty}px)"
}
}
** Callback to move table to given starting column.
private Void onMoveX(Int col)
{
// short-circuit if nothing todo
if (firstVisCol == col) return
oldFirstCol := firstVisCol
delta := col - oldFirstCol // delta b/w old and new first col
shift := delta.abs.max(numVisCols) // offset to shift cols when delta > 0
count := delta.abs.min(numVisCols) // num of cols to move
// echo("# onMoveX
// # oldFirstCol: $oldFirstCol
// # col: $col
// # delta: $delta
// # shift: $shift
// # count: $count
// ")
count.abs.times |c|
{
oldCol := delta > 0 ? oldFirstCol + c : oldFirstCol + numVisCols - c - 1
newCol := delta > 0 ? oldFirstCol + shift + c : oldFirstCol + delta + c
newColw := "${colwSafe(newCol)}px"
// echo("# $oldCol => $newCol")
header := headers.remove(oldCol)
header.style->width = newColw
headers[newCol] = header
refreshHeader(header, newCol)
numVisRows.times |r|
{
row := r + firstVisRow
op := TablePos(oldCol, row)
cell := cells.remove(op)
cell.style->width = newColw
cells[TablePos(newCol, row)] = cell
refreshCell(cell, newCol, row)
}
}
// echo("# >>> firstVisCol: $col")
firstVisCol = col
}
** Callback to move table to given starting row.
private Void onMoveY(Int row)
{
// short-circuit if nothing todo
if (firstVisRow == row) return
oldFirstRow := firstVisRow
delta := row - oldFirstRow // delta b/w old and new first row
shift := delta.abs.max(numVisRows) // offset to shift rows when delta > 0
count := delta.abs.min(numVisRows) // num of rows to move
// echo("# onMoveY
// # oldFirstRow: $oldFirstRow
// # row: $row
// # delta: $delta
// # shift: $shift
// # count: $count
// ")
count.abs.times |r|
{
oldRow := delta > 0 ? oldFirstRow + r : oldFirstRow + numVisRows - r - 1
newRow := delta > 0 ? oldFirstRow + shift + r : oldFirstRow + delta + r
// echo("# $oldRow => $newRow")
numVisCols.times |c|
{
col := c + firstVisCol
op := TablePos(col, oldRow)
cell := cells.remove(op)
cells[TablePos(col, newRow)] = cell
refreshCell(cell, col, newRow)
}
}
// echo("# >>> firstVisRow: $row")
firstVisRow = row
}
private Int findMaxVisCols()
{
vis := 0
colw.each |w,i|
{
dw := 0
di := i
while (dw < tbodyw && di < colw.size) dw += colw[di++]
vis = vis.max(di-i)
}
return vis
}
//////////////////////////////////////////////////////////////////////////
// Events
//////////////////////////////////////////////////////////////////////////
** Callback to display header popup.
private Void openHeaderPopup(Elem button, Popup popup)
{
x := button.pagePos.x
y := button.pagePos.y + button.size.h.toInt
w := button.size.w.toInt
// // adjust popup origin if haligned
// switch (popup.halign)
// {
// case Align.center: x += w / 2
// case Align.right: x += w
// }
popup.open(x, y)
}
** Callback to handle scroll event.
private Void onScroll(Point? delta)
{
// short-circuit if no data
if (delta == null) return
// find scroll bounds
scrollBoundx := maxScrollx - tbodyw
scrollBoundy := maxScrolly - tbodyh
if (hasScrollx && hasScrolly)
{
scrollBoundx += overScroll
scrollBoundy += overScroll
}
// update scroll offset
scrollx = (scrollx + delta.x.toInt).min(scrollBoundx).max(0)
scrolly = (scrolly + delta.y.toInt).min(scrollBoundy).max(0)
// update content
col := (colx.binarySearch(scrollx).not - 1).max(0).min(numCols - numVisCols).max(0)
row := (scrolly / rowh).min(numRows - numVisRows).max(0)
onUpdate(col, row)
}
** Callback to handle mouse events.
private Void onMouseEvent(Event e)
{
if (numCols == 0) return
p := this.relPos(e.pagePos)
mx := p.x.toInt + scrollx
my := p.y.toInt + scrolly - theadh
this.style->cursor = null
if (mx > colx.last + colw.last) return
col := colx.binarySearch(mx)
if (col < 0) col = col.not - 1
cx := mx - colx[col]
canResize := (col > 0 && cx < 5) || (col < numCols-1 && colw[col]-cx < 5)
if (p.y.toInt < theadh)
{
if (e.type == "mousemove")
{
if (canResize) this.style->cursor = "col-resize"
}
else if (e.type == "mousedown")
{
if (canResize)
{
this.resizeCol = cx < 5 ? col-1 : col
this.style->cursor = "col-resize"
this.add(resizeElem = Elem { it.style.addClass("domkit-resize-splitter") }
{
it.style->left = "${p.x-2}px"
it.style->width = "5px"
it.style->height = "100%"
})
doc := Win.cur.doc
Obj? fmove
Obj? fup
fmove = doc.onEvent("mousemove", true) |de| {
de.stop
dex := this.relPos(de.pagePos).x.toInt
resizeElem.style->left = "${dex-2}px"
}
fup = doc.onEvent("mouseup", true) |de| {
// register user colw and cache scroll pos
demx := this.relPos(de.pagePos).x.toInt + scrollx
ucolw[resizeCol] = 20.max(demx - colx[resizeCol])
oldscroll := Point(scrollx, scrolly)
// remove splitter
this.remove(resizeElem)
resizeElem = null
// rebuild table and restore scrollpos
doRebuild
onScroll(oldscroll)
de.stop
doc.removeEvent("mousemove", true, fmove)
doc.removeEvent("mouseup", true, fup)
}
}
else if (hasHpbut && p.x.toInt > tbodyw-hpbutw)
{
// header popup
Popup hp := cbHeaderPopup.call(this)
openHeaderPopup(hpbut, hp)
}
else
{
// sort column
col = view.colViewToModel(col)
sort(col, sortCol==col ? (sortDir==Dir.up ? Dir.down : Dir.up) : Dir.up)
}
}
}
else
{
// short-circuit if out of bounds
row := my / rowh
if (row >= numRows)
{
// click in backbground clears selection
if (e.type == "mousedown") updateSel(Int[,])
return
}
// find pos relative to cell (cx calc above)
cy := my - (row * rowh)
// map to model rows
vcol := col
vrow := row
col = view.colViewToModel(col)
row = view.rowViewToModel(row)
// check selection
if (e.type == "mousedown") onMouseEventSelect(e, row, vrow)
// check action
if (e.type == "dblclick") cbAction?.call(this)
// delegate to cell handlers
cb := cbTableEvent[e.type]
if (cb != null)
{
cb.call(TableEvent(this) {
it.type = e.type
it.col = col
it.row = row
it.pagePos = e.pagePos
it.cellPos = Point(cx, cy)
it.size = Size(colw[vcol], rowh)
it._event = e
})
}
}
}
** Callback to handle selection changes from a mouse event.
private Void onMouseEventSelect(Event e, Int row, Int vrow)
{
// always force focus for mousedown
manFocus = true
// short-circuit if we initiated a hyperlink so that
// the event can bubble properly down to the <a> tag
if (e.target.tagName == "a")
{
// Chrome seems to be doing some weird stuff here; forcing
// an onblur call on the Table <div> inbetween firing the
// hyperlink. Technically that might be correct but complicates
// how we manage focus. So if we detect this manually invoke
// the click to skip over that behavoir
e.target->click
e.stop
return
}
cur := sel.indexes
newsel := cur.dup
// check multi-selection
if (e.shift && pivot != null)
{
if (vrow < pivot)
{
(vrow..pivot).each |i| { newsel.add(view.rowViewToModel(i)) }
newsel = newsel.unique.sort
}
else if (vrow > pivot)
{
(pivot..vrow).each |i| { newsel.add(view.rowViewToModel(i)) }
newsel = newsel.unique.sort
}
}
else if (e.meta || e.ctrl)
{
if (cur.contains(row)) newsel.remove(row)
else newsel.add(row).sort
pivot = view.rowModelToView(row)
}
else
{
newsel = [row]
pivot = view.rowModelToView(row)
}
updateSel(newsel)
}
** Callback to handle key events.
private Void onKeyEvent(Event e)
{
// just handle keydown for now
if (e.type != "keydown") return
// short-circuit if no cells
if (numCols==0 || numRows==0) return
// updateSel takes model rows; but scrollTo takes view rows, so
// pre-map some of the selection indexs here to simplify things
selTop := view.rowViewToModel(0)
selBottom := view.rowViewToModel(numRows-1)
selFirstVis := view.rowViewToModel(firstVisRow)
pivot := view.rowModelToView(sel.indexes.first ?: selTop)
// page commands
if (e.meta)
{
if (e.key == Key.up) { e.stop; updateSel([selTop]); scrollTo(null, 0); return }
if (e.key == Key.down) { e.stop; updateSel([selBottom]); scrollTo(null, numRows-1); return }
if (e.key == Key.left) { e.stop; scrollTo(0, null); return }
if (e.key == Key.right) { e.stop; scrollTo(numCols-1, null); return }
}
// page up/down
if (e.key == Key.pageUp)
{
e.stop
prev := (pivot - (numVisRows-3)).max(0)
updateSel([view.rowViewToModel(prev)])
scrollTo(null, prev)
return
}
if (e.key == Key.pageDown)
{
e.stop
next := (pivot + (numVisRows-3)).max(0).min(numRows-1)
updateSel([view.rowViewToModel(next)])
scrollTo(null, next)
return
}
// selection commands
switch (e.key)
{
case Key.left:
cur := colx.binarySearch(scrollx)
if (cur < 0) cur = cur.not - 1
pre := colx[cur] == scrollx ? cur-1 : cur
scrollTo(0.max(pre), null)
return
case Key.right:
cur := colx.binarySearch(scrollx)
if (cur < 0) cur = cur.not - 1
scrollTo((numCols-1).min(cur+1), null)
return
case Key.up:
if (sel.indexes.isEmpty)
{
updateSel([selFirstVis])
scrollTo(null, firstVisRow)
return
}
else
{
if (pivot == 0) return scrollTo(null, 0)
prev := pivot - 1
updateSel([view.rowViewToModel(prev)])
scrollTo(null, prev)
return
}
case Key.down:
if (sel.indexes.isEmpty)
{
updateSel([selFirstVis])
scrollTo(null, firstVisRow)
return
}
else
{
if (pivot == numRows-1) return scrollTo(null, numRows-1)
next := pivot + 1
updateSel([view.rowViewToModel(next)])
scrollTo(null, next)
return
}
}
// onAction
if (e.key == Key.space || e.key == Key.enter)
{
cbAction?.call(this)
return
}
// else bubble up to callback
if (e.type == "keydown") return cbKeyDown?.call(e)
}
@NoDoc Void updateSel(Int[] newsel)
{
if (!sel.enabled) return
if (sel.indexes == newsel) return
if (cbBeforeSelect?.call(newsel) == false) return
sel.indexes = newsel
cbSelect?.call(this)
}
private Int colxSafe(Int c) { colx.getSafe(c) ?: colx.last + colw.last + ((c-colx.size+1) * 100)}
private Int colwSafe(Int c) { colw.getSafe(c) ?: 100 }
private Str ts() { "${(Duration.now - Duration.boot).toMillis}ms" }
//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////
private static const Str[] cellEvents := [
"mousedown",
"mouseup",
"click",
"dblclick",
// TODO: opt into these events?
// "mousemove",
// "mouseover",
// "mouseout",
]
private Func? cbBeforeSelect
private Func? cbSelect
private Func? cbAction
private Func? cbSort
private Func? cbKeyDown
private Str:Func cbTableEvent := [:]
private Func? cbHeaderPopup
// scrollbars
private const Int sbarsz := 15
private const Int thumbMargin := 2
private const Int overScroll := sbarsz + 2
private const Duration scrollPageFreq := 100ms
private const Duration scrollPulseDir := 300ms
// refresh
private Elem? thead
private Elem? tbody
private Elem? hbar
private Elem? vbar
private Int:Elem headers := [:]
private TablePos:Elem cells := [:]
private Int theadh // thead height
private Int tbodyw // tbody width
private Int tbodyh // tbody height
private Int numCols // num cols in model
private Int numRows // num rows in model
private Int[] colx := [,] // col x offsets
private Int[] colw := [,] // col widths
private Int:Int ucolw := [:] // user defined col width (via resize)
private Int rowh // row height
private Int numVisCols // num of visible cols
private Int numVisRows // num of visible rows
private Int maxScrollx // max scroll x value
private Int maxScrolly // max scroll y value
private Bool hasScrollx // is horiz scolling
private Bool hasScrolly // is vert scolling
private Int htrackw // hbar track width
private Int hthumbw // hbar thumb width
private Int vtrackh // vbar track height
private Int vthumbh // vbar thumb height
// resize
private Int? resizeCol // column being resized
private Elem? resizeElem // visual indication of resize col size
// headerPopup
private Elem? hpbut // header popup button
private Bool hasHpbut // hpbut != null
private const Int hpbutw := 22 // width of header popup button
// scroll
private Int scrollx // current x scroll pos
private Int scrolly // current y scroll pos
private Int? hbarPulseId // hbar pulse timeout func id
private Int? vbarPulseId // vbar pulse timeout func id
private Int? hbarPageId // hbar page interval func id
private Int? vbarPageId // vbar page interval func id
private Int? hthumbDragOff // offset of hthumb drag pos
private Int? vthumbDragOff // offset of vthumb drag pos
// update
private Int firstVisCol // first visible col
private Int firstVisRow // first visible row
// onSelect (always in view refrence; not model)
private Int? pivot
// focus/blur
private Bool manFocus := false
}
**************************************************************************
** TablePos
**************************************************************************
**
** TablePos provides an JS optimized hash key for col,row cell position
**
@Js
internal const class TablePos
{
new make(Int c, Int r) { col = c; row = r; toStr = "$c,$r"; hash = toStr.hash }
const Int col
const Int row
const override Int hash
const override Str toStr
override Bool equals(Obj? that) { toStr == that.toStr }
}
**************************************************************************
** TableModel
**************************************************************************
**
** TableModel backs the data model for a `Table`
**
@Js class TableModel
{
** Number of columns in table.
virtual Int numCols() { 0 }
** Number of rows in table.
virtual Int numRows() { 0 }
** Return height of header.
virtual Int headerHeight() { 20 }
** Return width of given column.
virtual Int colWidth(Int col) { 100 }
** Return height of rows.
virtual Int rowHeight() { 20 }
** Return item for the given row to be used with selection.
virtual Obj item(Int row) { row }
** Callback to update content for column header at given index.
virtual Void onHeader(Elem header, Int col)
{
header.text = "Col $col"
}
** Return default visible/hidden state for column
virtual Bool isVisibleDef(Int col) { true }
** Callback to update the cell content at given location.
virtual Void onCell(Elem cell, Int col, Int row, TableFlags flags)
{
cell.text = "C$col:R$row"
}
** Compare two cells when sorting the given col. Return -1,
** 0, or 1 according to the same semanatics as `sys::Obj.compare`.
** See `domkit::Table.sort`.
virtual Int sortCompare(Int col, Int row1, Int row2) { 0 }
** Callback when table is resorted
@NoDoc virtual Void onSort(Int? col, Dir dir) {}
}
**************************************************************************
** TableFlags
**************************************************************************
** Table specific flags for eventing
@Js const class TableFlags
{
** Default value with all flags cleared
static const TableFlags defVal := make {}
new make(|This| f) { f(this) }
** Table has focus.
const Bool focused
** Row is selected.
const Bool selected
override Str toStr()
{
"TableFlags { focused=$focused; selected=$selected }"
}
}
**************************************************************************
** TableEvent
**************************************************************************
**
** TableEvents are generated by `Table` cells.
**
@Js class TableEvent
{
internal new make(Table t, |This| f)
{
this.table = t
f(this)
}
Table table { private set }
** Event type.
const Str type
** Column index for this event.
const Int col
** Row index for this event.
const Int row
** Mouse position relative to page.
const Point pagePos
** Mouse position relative to cell.
const Point cellPos
** Size of cell for this event.
const Size size
// TODO: not sure how this works yet
@NoDoc Event? _event
override Str toStr()
{
"TableEvent { type=$type row=$row col=$col pagePos=$pagePos cellPos=$cellPos size=$size }"
}
}
**************************************************************************
** TableSelection
**************************************************************************
@Js internal class TableSelection : IndexSelection
{
new make(TableView view) { this.view = view }
override Int max() { view.numRows }
// TODO FIXIT: selection is always kept in original order
override Obj toItem(Int index) { view.table.model.item(index) }
override Int? toIndex(Obj item)
{
numRows := view.numRows
for (row := 0; row < numRows; ++row)
if (view.table.model.item(row) == item) return row
return null
}
// override Obj toItem(Int index) { view.item(index) }
// override Int? toIndex(Obj item)
// {
// numRows := view.numRows
// for (row := 0; row < numRows; ++row)
// if (view.item(row) == item) return row
// return null
// }
override Void onUpdate(Int[] oldIndexes, Int[] newIndexes)
{
oldIndexes.each |i| { if (i < max) view.table.refreshRow(view.rowModelToView(i)) }
newIndexes.each |i| { if (i < max) view.table.refreshRow(view.rowModelToView(i)) }
}
private TableView view
}
**************************************************************************
** TableView
**************************************************************************
**
** TableView wraps the table model to implement the row/col mapping
** from the view coordinate space to the model coordinate space based
** on column visibility and row sort order.
**
@NoDoc @Js class TableView : TableModel
{
new make(Table table) { this.table = table }
//////////////////////////////////////////////////////////////////////////
// TableModel overrides
//////////////////////////////////////////////////////////////////////////
override Int numCols() { cols.size }
override Int numRows() { rows.size }
override Int headerHeight() { table.model.headerHeight }
override Int colWidth(Int c) { table.model.colWidth(cols[c]) }
override Int rowHeight() { table.model.rowHeight }
override Obj item(Int r) { table.model.item(rows[r]) }
override Void onHeader(Elem e, Int c) { table.model.onHeader(e, cols[c]) }
override Void onCell(Elem e, Int c, Int r, TableFlags f) { table.model.onCell(e, cols[c], rows[r], f) }
//////////////////////////////////////////////////////////////////////////
// View Methods
//////////////////////////////////////////////////////////////////////////
Bool isColVisible(Int col) { vis[col] }
This setColVisible(Int col, Bool visible)
{
// if not changing anything then short circuit
if (vis[col] == visible) return this
// update column mappings
vis[col] = visible
cols.clear
vis.each |v, i| { if (v) cols.add(i) }
return this
}
Void sort(Int? col, Dir dir := Dir.up)
{
model := table.model
sortCol = col
sortDir = dir
if (col == null)
{
rows.each |val, i| { rows[i] = i }
}
else
{
if (dir === Dir.up)
rows.sort |a, b| { model.sortCompare(col, a, b) }
else
rows.sortr |a, b| { model.sortCompare(col, a, b) }
}
}
Void refresh()
{
model := table.model
if (rows.size != model.numRows) refreshRows
if (vis.size != model.numCols) refreshCols
}
private Void refreshRows()
{
// rebuild from scratch using base model order
model := table.model
rows.clear
rows.capacity = model.numRows
model.numRows.times |i| { rows.add(i) }
// if sort was in-place, then resort
if (sortCol != null && sortCol < model.numCols) sort(sortCol, sortDir)
}
private Void refreshCols()
{
// rebuild from scratch
model := table.model
cols.clear; cols.capacity = model.numCols
vis.clear; vis.capacity = model.numCols
model.numCols.times |i|
{
visDef := model.isVisibleDef(i)
vis.add(visDef)
if (visDef) cols.add(i)
}
}
// View -> Model
Int rowViewToModel(Int i) { rows[i] }
Int colViewToModel(Int i) { cols[i] }
Int[] rowsViewToModel(Int[] i) { i.map |x->Int| { rows[x] } }
Int[] colsViewToModel(Int[] i) { i.map |x->Int| { cols[x] } }
// Model -> View (need to optimize linear scan)
Int rowModelToView(Int i) { rows.findIndex |x| { x == i } }
Int colModelToView(Int i) { cols.findIndex |x| { x == i } }
Int[] rowsModelToView(Int[] i) { i.map |x->Int| { rowModelToView(x) } }
Int[] colsModelToView(Int[] i) { i.map |x->Int| { colModelToView(x) } }
internal Table table
private Int[] rows := [,] // view to model row index mapping
private Int[] cols := [,] // view to model col index mapping
private Bool[] vis := [,] // visible
internal Int? sortCol { private set } // model based index
internal Dir sortDir := Dir.up { private set }
}