//
// Copyright (c) 2011, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
// 3 Oct 2011 Andy Frank Creation
//
using fwt
using gfx
using web
**
** CanvasTable renders an entire table in an Canvas widget, allowing
** each cell to be fully customized by painting with a Graphics
** context.
**
@Js
abstract class CanvasTable : Canvas
{
//////////////////////////////////////////////////////////////////////////
// Construction
/////////////////////////////////////////////////////////////////////////
** Constructor.
new make(|This|? f)
{
if (f != null) f(this)
this.headerhDef = 22.max(headerFont.height + headerFont.ascent + headerFont.descent)
this.ay = (headerhDef - 7) / 2 + 1
this.onKeyDown.add |e| { handleKeyDown(e) }
this.onMouseMove.add |e| { handleMouse(e) }
this.onMouseDown.add |e| { handleMouse(e) }
this.onMouseUp.add |e| { handleMouse(e) }
this.onMouseWheel.add |e| { handleMouse(e) }
}
** Column names.
const Str[] colNames := [,]
** Column widths. Integer values represent exact widths. Float
** values represent a percentage of remaining space (0..1).
const Num[] colWidths := [,]
** Wrap the column names
const Bool colNameWrap
** Is selection enabled for this table.
Bool selectionEnabled := true
** Selected table index.
Int[] selected := [,]
** Number of pixels to scroll when scrollbar track is paged.
Int scrollPage := 120
** Callback when a row is double clicked or Space is pressed.
** - id: EventId.action
** - index: the row index
once EventListeners onAction() { EventListeners() }
** Callback when a column is sorted.
virtual Void onSort(Int col, SortMode mode) {}
** Callback when mouse moved in cell.
virtual Void onCellMoved(Event e, Int col, Int row, Point pos, Size cellSize) {}
** Callback when mouse pressed in cell.
virtual Void onCellPressed(Event e, Int col, Int row, Point pos, Size cellSize) {}
** Get cell position relative to table.
Point cellPosToTable(Point pos, Int col, Int row)
{
x := rowsetBounds.x - hscroll.cur + pos.x
y := rowsetBounds.y - vscroll.cur + pos.y
col.times |i| { x += colw[i] }
row.times |i| { y += rowb[i].h }
return Point(x, y)
}
//////////////////////////////////////////////////////////////////////////
// Overrides
//////////////////////////////////////////////////////////////////////////
** Get number of rows in table.
abstract Int numRows()
** Get height for row at given index.
abstract Int rowHeight(Int i)
** Paint the cell at given index.
abstract Void paintCell(Graphics g, Int col, Int row, Bool selected, Size cellSize)
** Paint the cell overlay at given index.
virtual Void paintCellOverlay(Graphics g, Int col, Int row, Bool selected, Size cellSize) {}
** Repaint only the overaly layers.
Void repaintOverlay() { repaint }
//////////////////////////////////////////////////////////////////////////
// Config
//////////////////////////////////////////////////////////////////////////
** Background color of odd rows.
const Color rowOddBg := Color.white
** Background color of even rows.
const Color rowEvenBg := Color("#f1f5fa")
** Background color of selected row.
const Color rowSelectedBg := Color("#3d80df")
** Color of border between rows.
const Color rowBorder := Color("#bbb")
** Color of border between cells.
const Color cellBorder := Color("#d9d9d9")
** Color of border between cells when row is selected.
const Color cellSelectedBorder := Color("#346dbe")
** Font for header columns.
private static const Font defHeaderFont
static
{
if ("js" === Env.cur.runtime) defHeaderFont = Desktop.sysFontSmall.toBold
else defHeaderFont = Font("bold 8pt Helvetica")
}
const Font headerFont := defHeaderFont
//////////////////////////////////////////////////////////////////////////
// Layout
//////////////////////////////////////////////////////////////////////////
@NoDoc Void onLayout()
{
// layout header assuming no wrap
headerh = headerhDef
dw := layoutHeader
// if we have a wrap, then we need to compute how col names fit
colText = Str[][,]
if (colNameWrap)
{
// compute each column
maxLines := 1
colNames.each |colName, i|
{
lines := wrapCol(colName, colw[i])
maxLines = maxLines.max(lines.size)
colText.add(lines)
}
// update heaederh and relayout header
headerh = headerhDef + (maxLines-1) * headerFont.height
dw = layoutHeader
}
// if no wrap, then we have one line per column
else
{
colNames.each |n| { colText.add([n]) }
}
// layout rows
dy := 0
rowb.clear
numRows.times |r|
{
dh := rowHeight(r)
rowb.add(Rect(0, dy, size.w, dh))
dy += dh
}
// max scroll bounds
vscroll.max = dy - rowsetBounds.h - 1; vscroll.layout
hscroll.max = dw - rowsetBounds.w - 1; hscroll.layout
}
private Int layoutHeader()
{
w := size.w-1
h := size.h-1
headerBounds = Rect(0, 0, w, headerh)
vscroll.bounds = Rect(w-scrollsz, headerh+1, scrollsz, h-headerh-scrollsz-2)
hscroll.bounds = Rect(0, h-scrollsz, w-scrollsz-1, scrollsz)
rowsetBounds = Rect(0, headerh+1, w-scrollsz-1, h-headerh-scrollsz-2)
// layout cols
cs := (Int)colWidths.reduce(0) |Int v,c| { v + (c as Int ?: 0) }
rw := rowsetBounds.w - cs
dw := 0
fc := false
colw.clear
colWidths.each |c,i|
{
cw := 0
if (c is Int) cw = c
else { cw = (rw * c.toFloat).toInt; fc=true }
if (fc && i == colWidths.size-1) cw = rowsetBounds.w - dw + 1
colw.add(cw)
dw += cw
}
return dw
}
private Str[] wrapCol(Str colName, Int colw)
{
colw = colw - 8 // margin is 4
words := colName.split(' ')
first := words.first
// first word goes on first line
cur := StrBuf().add(first)
curw := headerFont.width(first)
spacew := headerFont.width(" ")
// add rest of the words
lines := Str[,]
for (i:=1; i<words.size; ++i)
{
word := words[i]
wordw := headerFont.width(word)
neww := curw + spacew + wordw
if (neww <= colw)
{
// fits on current line
cur.add(" ").add(word)
curw = neww
}
else
{
// wrap to next line
lines.add(cur.toStr)
cur = StrBuf().add(word)
curw = wordw
}
}
lines.add(cur.toStr)
return lines
}
//////////////////////////////////////////////////////////////////////////
// Render
//////////////////////////////////////////////////////////////////////////
override Void onPaint(Graphics g)
{
if (!repaintNoLayout) onLayout
repaintNoLayout = false
g.brush = Color.white
g.fillRect(0, 0, size.w, size.h)
paintHeader(g)
paintScrollBars(g)
g.push
clip(g, rowsetBounds)
g.translate(0, headerh+1)
nrows := numRows
rx := -hscroll.cur
ry := -vscroll.cur
g.translate(rx, ry)
for (r:=0; r<nrows; r++)
{
rh := rowHeight(r)
if (ry + rh > 0) paintRow(g, r, rh, nrows)
ry += rh
if (ry > rowsetBounds.h) break
g.translate(0, rh)
}
g.pop
g.brush = border
g.drawRect(0, 0, size.w-1, size.h-1)
}
** Paint column headers.
private Void paintHeader(Graphics g)
{
g.font = headerFont
w := size.w
h := headerBounds.h
dx := 0
// header background
// TODO: cache brush based on size
g.brush = Gradient { x1=0; y1=0; x2=0; y2=h; stops=headerStops }
g.fillRect(0, 0, w, h)
// cols
g.push
g.translate(-hscroll.cur, 0)
colw.each |cw,i|
{
tx := dx+4+(i==0?1:0)
ty := (headerhDef - headerFont.height) / 2
sort := sortCol == i
g.brush = Color.black
g.push
g.clip(Rect(dx, 0, sort ? cw-16 : cw, h))
colLines := colText[i]
colLines.each |line, j|
{
g.drawText(line, tx, ty)
ty += headerFont.height
}
g.pop
if (sort)
{
ax := dx + cw - 12
g.brush = headerArrow
g.translate(ax, ay)
g.fillPolygon(sortMode == SortMode.up ? upArrow : downArrow)
g.translate(-ax, -ay)
}
dx += cw
if (i < colw.size-1)
{
g.brush = headerBorder
g.drawLine(dx-1, 0, dx-1, h)
}
}
g.pop
// border
g.brush = border
g.drawRect(0, 0, w-1, h) // hide bottom
}
** Paint scrollbars.
private Void paintScrollBars(Graphics g)
{
vb := vscroll.bounds
hb := hscroll.bounds
g.brush = scrollBoxBg
g.fillRect(vb.x, hb.y, vb.w, hb.h)
// vert
g.brush = Gradient { x1=vb.x+1; y1=0; x2=vb.x+vb.w-2; y2=0; stops=scrollTrackStops }
g.fillRect(vb.x, vb.y, vb.w, vb.h+1)
if (vscroll.max > 0)
{
tb := vscroll.thumb
ty := tb.y + (tb.h / 2)
g.brush = Gradient { x1=vb.x+1; y1=0; x2=vb.x+vb.w-2; y2=0; stops=scrollThumbStops }
g.fillRoundRect(vb.x+1, vb.y+ty+1, vb.w-1, tb.h-2, 5, 5)
}
g.brush = border
g.drawLine(vb.x, vb.y, vb.x, size.h)
// horiz
g.brush = Gradient { x1=0; y1=hb.y+1; x2=0; y2=hb.y+hb.h-2; stops=scrollTrackStops }
g.fillRect(hb.x, hb.y, hb.w+1, hb.h)
if (hscroll.max > 0)
{
tb := hscroll.thumb
tx := tb.x + (tb.w / 2)
g.brush = Gradient { x1=0; y1=hb.y+1; x2=0; y2=hb.y+hb.h-2; stops=scrollThumbStops }
g.fillRoundRect(hb.x+tx+2, hb.y+1, tb.w-3, hb.h-1, 5, 5)
}
g.brush = border
g.drawLine(hb.x, hb.y, size.w+1, hb.y)
}
** Paint given row.
private Void paintRow(Graphics g, Int r, Int rowh, Int nrows)
{
sel := r == selected.first
w := rowsetBounds.w + hscroll.max + 1
h := rowh
g.push
g.clip(Rect(0, 0, w, h))
// background
g.brush = sel ? rowSelectedBg : (r.isOdd ? rowOddBg : rowEvenBg)
g.fillRect(0, 0, w, h)
// paint cells
dx := 0
colw.each |cw, c|
{
// skip if not visible
if (dx-hscroll.cur > rowsetBounds.w) return
// paint cell if visible
if (dx+cw > hscroll.cur)
{
g.push
g.translate(dx, 0)
g.clip(Rect(0, 0, cw, h))
csz := Size(cw, h)
paintCell(g, c, r, sel, csz)
paintCellOverlay(g, c, r, sel, csz)
// border
if (c < colw.size-1)
{
g.brush = sel ? cellSelectedBorder : cellBorder
g.drawLine(cw-1, 0, cw-1, h-1)
}
g.pop
}
// advance col
dx += cw
}
// border
if (r < nrows-1 || vscroll.max == 0)
{
g.brush = rowBorder
g.drawLine(0, h-1, w, h-1)
}
g.pop
}
//////////////////////////////////////////////////////////////////////////
// Events
//////////////////////////////////////////////////////////////////////////
private Void handleMouse(Event e)
{
switch (e.id)
{
case EventId.mouseWheel:
if (e.delta.x == 0) vscroll.scroll(e.delta.y)
else hscroll.scroll(e.delta.x)
e.consumed = true
case EventId.mouseMove:
if (vscroll.dragDelta != null) mouseDragVScroll(e)
else if (hscroll.dragDelta != null) mouseDragHScroll(e)
else if (contains(rowsetBounds, e.pos)) mouseMoveRowset(e)
case EventId.mouseDown:
if (contains(vscroll.bounds, e.pos)) mouseDownVScroll(e)
else if (contains(hscroll.bounds, e.pos)) mouseDownHScroll(e)
else if (contains(headerBounds, e.pos)) mouseDownHeader(e)
else if (contains(rowsetBounds, e.pos)) mouseDownRowset(e)
case EventId.mouseUp:
if (vscroll.dragDelta != null) vscroll.dragDelta = null
else if (hscroll.dragDelta != null) hscroll.dragDelta = null
else if (contains(headerBounds, e.pos)) mouseUpHeader(e)
else if (contains(rowsetBounds, e.pos)) mouseUpRowset(e)
}
}
private Void handleKeyDown(Event e)
{
switch (e.key)
{
case Key.up:
i := ((selected.first ?: 1) - 1).max(0)
updateSelected(i)
e.consume
case Key.down:
i := ((selected.first ?: -1) + 1).min(numRows-1)
updateSelected(i)
e.consume
case Key.space: fireAction
}
}
//////////////////////////////////////////////////////////////////////////
// Scroll Events
//////////////////////////////////////////////////////////////////////////
private Void mouseDownVScroll(Event e)
{
pos := vscroll.toPos(e.pos.y, true)
cy := vscroll.bounds.y + vscroll.thumb.y + vscroll.thumb.h
if (pos != null) vscroll.dragDelta = e.pos.y - cy
else vscroll.scroll(e.pos.y < cy ? -scrollPage : scrollPage)
e.consumed = true
}
private Void mouseDragVScroll(Event e)
{
pos := vscroll.toPos(e.pos.y - vscroll.dragDelta)
vscroll.pos(pos)
e.consumed = true
}
private Void mouseDownHScroll(Event e)
{
pos := hscroll.toPos(e.pos.x, true)
cx := hscroll.bounds.x + hscroll.thumb.x + hscroll.thumb.w
if (pos != null) hscroll.dragDelta = e.pos.x - cx
else hscroll.scroll(e.pos.x < cx ? -scrollPage : scrollPage)
e.consumed = true
}
private Void mouseDragHScroll(Event e)
{
pos := hscroll.toPos(e.pos.x - hscroll.dragDelta)
hscroll.pos(pos)
e.consumed = true
}
//////////////////////////////////////////////////////////////////////////
// Header
//////////////////////////////////////////////////////////////////////////
private Void mouseDownHeader(Event e)
{
}
private Void mouseUpHeader(Event e)
{
col := toColIndex(e.pos.x)
sortMode = sortCol==col ? sortMode.toggle : SortMode.up
sortCol = col
onSort(col, sortMode)
relayout
}
//////////////////////////////////////////////////////////////////////////
// Rowset
//////////////////////////////////////////////////////////////////////////
private Void mouseMoveRowset(Event e)
{
row := toRowIndex(e)
if (row == null) return
col := toColIndex(e.pos.x)
pos := toCellPos(e.pos, col, row)
size := toCellSize(col, row)
onCellMoved(e, col, row, pos, size)
}
private Void mouseDownRowset(Event e)
{
if (e.count == 2) fireAction
}
private Void mouseUpRowset(Event e)
{
row := toRowIndex(e)
if (row == null)
{
// clear selection
updateSelected(null)
}
else
{
updateSelected(row)
// pos relative to cell
col := toColIndex(e.pos.x)
pos := toCellPos(e.pos, col, row)
size := toCellSize(col, row)
onCellPressed(e, col, row, pos, size)
}
}
private Void updateSelected(Int? newSelected)
{
if (!selectionEnabled) return
if (selected.first == newSelected) return
selected = newSelected==null ? [,] : [newSelected]
repaintNoLayout = true
repaint
}
private Void fireAction()
{
i := selected.first
if (i == null) return
onAction.fire(Event { id=EventId.action; widget=this; index=i })
}
private Int? toRowIndex(Event e)
{
my := vscroll.cur + e.pos.y - rowsetBounds.y + 2
return rowb.findIndex |r| { r.contains(0, my) }
}
private Int? toColIndex(Int x)
{
dx := 0
x += hscroll.cur
c := colw.findIndex |w|
{
dx += w
return x < dx
}
return c==null ? colw.size-1 : c
}
private Point toCellPos(Point p, Int col, Int row)
{
x := rowsetBounds.x - hscroll.cur
y := rowsetBounds.y - vscroll.cur
col.times |i| { x += colw[i] }
row.times |i| { y += rowb[i].h }
return Point(p.x-x, p.y-y)
}
private Size toCellSize(Int col, Int row)
{
Size(colw[col], rowHeight(row))
}
//////////////////////////////////////////////////////////////////////////
// GxUtil
//////////////////////////////////////////////////////////////////////////
** Clip graphics cx to allow for full Rect bounds.
private Void clip(Graphics g, Rect r)
{
g.clip(Rect(r.x, r.y, r.w+1, r.h+1))
}
** Returns true of Rect contains Point.
private Bool contains(Rect r, Point p)
{
r.contains(p.x, p.y)
}
//////////////////////////////////////////////////////////////////////////
// Constants
//////////////////////////////////////////////////////////////////////////
private static const Color border := Color("#9f9f9f")
private static const Color headerBorder := Color("#bdbdbd")
private static const Color headerArrow := Color("#666")
private static const GradientStop[] headerStops := [
GradientStop(Color("#f9f9f9"), 0f),
GradientStop(Color("#eee"), 0.5f),
GradientStop(Color("#e1e1e1"), 0.5f),
GradientStop(Color("#f5f5f5"), 1f),
]
private static const Point[] upArrow := [
Point(0, 7),
Point(4, 0),
Point(8, 7)
]
private static const Point[] downArrow := [
Point(0, 0),
Point(4, 7),
Point(8, 0)
]
private static const Int scrollsz := 12
private static const Color scrollBoxBg := Color("#eee")
private static const GradientStop[] scrollTrackStops := [
GradientStop(Color("#eaeaea"), 0f),
GradientStop(Color("#f8f8f8"), 1f)
]
private static const Color scrollThumbBorder := Color("#848fa6")
private static const GradientStop[] scrollThumbStops := [
GradientStop(Color("#b5bfcd"), 0f),
GradientStop(Color("#8b99b2"), 1f)
]
//////////////////////////////////////////////////////////////////////////
// HTML
//////////////////////////////////////////////////////////////////////////
** Write HTML markup for this cell.
virtual Void writeHtml(WebOutStream out, Int col, Int row) {}
** Render cell as PNG image encoded as Base64 and write to HTML.
Void writePng(WebOutStream out, Int col, Int row)
{
if ("js" === Env.cur.runtime) ColUtil.writePng(this, out, col, row)
}
//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////
const private Int headerhDef
const private Int ay
private Int[] colw := [,]
private Rect[] rowb := [,]
private Int? sortCol := null
private SortMode sortMode := SortMode.up
private Rect headerBounds := Rect.defVal
internal Rect rowsetBounds := Rect.defVal
private CTScrollBar vscroll := CTScrollBar { table=this }
private CTScrollBar hscroll := CTScrollBar { table=this; orient=Orientation.horizontal }
private Int headerh // header height with wrap applied
private Str[][]? colText // col name lines with wrap applied
// repaint with no relayout
internal Bool repaintNoLayout := false
}
**************************************************************************
** CTScrollBar
**************************************************************************
@Js
internal class CTScrollBar
{
Void scroll(Int ds)
{
// short-circuit if not scrollable, or if already at min/max
if (max < 0) return
if (cur == 0 && ds < 0) return
if (cur == max && ds > 0) return
cur += ds
cur = cur.max(0).min(max)
layout
table.repaintNoLayout = true
table.repaint
}
Int? toPos(Int pixel, Bool inThumb := false)
{
if (isVert)
{
dy := pixel - bounds.y - thumb.h / 2
if (inThumb && !thumb.contains(0, dy)) return null
return (dy.toFloat / (table.rowsetBounds.h - thumb.h).toFloat * max).toInt
}
else
{
dx := pixel - bounds.x - thumb.w / 2
if (inThumb && !thumb.contains(dx, 0)) return null
return (dx.toFloat / (table.rowsetBounds.w - thumb.w).toFloat * max).toInt
}
}
Void pos(Int p)
{
// short-ciruct if not scrollbale, or if already at min/max
if (max < 0) return
if (cur == 0 && p < 0) return
if (cur == max && p > max) return
cur = p.max(0).min(max)
layout
table.repaintNoLayout = true
table.repaint
}
Bool isVert() { orient == Orientation.vertical }
Void layout()
{
if (isVert)
{
rh := table.rowsetBounds.h
vh := (rh.toFloat / (rh + max).toFloat * rh).toInt.max(20)
vy := (cur.toFloat / max.toFloat * (rh-vh)).toInt.max(0) - (vh / 2)
thumb = Rect(0, vy, bounds.w, vh)
}
else
{
rw := table.rowsetBounds.w
hw := (rw.toFloat / (rw + max).toFloat * rw).toInt.max(20)
hx := (cur.toFloat / max.toFloat * (rw-hw)).toInt.max(0) - (hw / 2)
thumb = Rect(hx, 0, hw, bounds.h)
}
}
CanvasTable? table
Orientation orient := Orientation.vertical
Rect bounds := Rect.defVal // scrollbar bounds
Rect thumb := Rect.defVal // thumb bounds
Int cur := 0 // cur scroll pos
Int max := 0 // max scroll pos
Int? dragDelta // if dragging delta to apply
}
**************************************************************************
** ColUtil
**************************************************************************
@Js
internal final class ColUtil
{
native static Void writePng(CanvasTable table, WebOutStream out, Int col, Int row)
}