瀏覽代碼

Cleanup: Display Helper

File: display.js
Tests Added: True (preliminary)

Changes:
- De-crockford-ified the file

NOTE: the tests included for display.js cover basic functionality, but
are by no means nearly as comprehensive as the ones presented for
rfb.js.
Solly Ross 11 年之前
父節點
當前提交
1e13775bd5
共有 2 個文件被更改,包括 1009 次插入713 次删除
  1. 677 713
      include/display.js
  2. 332 0
      tests/test.display.js

+ 677 - 713
include/display.js

@@ -6,765 +6,729 @@
  * See README.md for usage and integration instructions.
  */
 
-/*jslint browser: true, white: false, bitwise: false */
+/*jslint browser: true, white: false */
 /*global Util, Base64, changeCursor */
 
-function Display(defaults) {
-"use strict";
-
-var that           = {},  // Public API methods
-    conf           = {},  // Configuration attributes
-
-    // Private Display namespace variables
-    c_ctx          = null,
-    c_forceCanvas  = false,
-
-    // Queued drawing actions for in-order rendering
-    renderQ        = [],
-
-    // Predefine function variables (jslint)
-    imageDataGet, rgbImageData, bgrxImageData, cmapImageData,
-    setFillColor, rescale, scan_renderQ,
-
-    // The full frame buffer (logical canvas) size
-    fb_width        = 0,
-    fb_height       = 0,
-    // The visible "physical canvas" viewport
-    viewport       = {'x': 0, 'y': 0, 'w' : 0, 'h' : 0 },
-    cleanRect      = {'x1': 0, 'y1': 0, 'x2': -1, 'y2': -1},
-
-    c_prevStyle    = "",
-    tile           = null,
-    tile16x16      = null,
-    tile_x         = 0,
-    tile_y         = 0;
-
-
-// Configuration attributes
-Util.conf_defaults(conf, that, defaults, [
-    ['target',      'wo', 'dom',  null, 'Canvas element for rendering'],
-    ['context',     'ro', 'raw',  null, 'Canvas 2D context for rendering (read-only)'],
-    ['logo',        'rw', 'raw',  null, 'Logo to display when cleared: {"width": width, "height": height, "data": data}'],
-    ['true_color',  'rw', 'bool', true, 'Use true-color pixel data'],
-    ['colourMap',   'rw', 'arr',  [], 'Colour map array (when not true-color)'],
-    ['scale',       'rw', 'float', 1.0, 'Display area scale factor 0.0 - 1.0'],
-    ['viewport',    'rw', 'bool', false, 'Use a viewport set with viewportChange()'],
-    ['width',       'rw', 'int', null, 'Display area width'],
-    ['height',      'rw', 'int', null, 'Display area height'],
-
-    ['render_mode', 'ro', 'str', '', 'Canvas rendering mode (read-only)'],
-
-    ['prefer_js',   'rw', 'str', null, 'Prefer Javascript over canvas methods'],
-    ['cursor_uri',  'rw', 'raw', null, 'Can we render cursor using data URI']
-    ]);
+var Display;
 
-// Override some specific getters/setters
-that.get_context = function () { return c_ctx; };
+(function () {
+    "use strict";
 
-that.set_scale = function(scale) { rescale(scale); };
+    Display = function (defaults) {
+        this._drawCtx = null;
+        this._c_forceCanvas = false;
 
-that.set_width = function (val) { that.resize(val, fb_height); };
-that.get_width = function() { return fb_width; };
+        this._renderQ = [];  // queue drawing actions for in-oder rendering
 
-that.set_height = function (val) { that.resize(fb_width, val); };
-that.get_height = function() { return fb_height; };
+        // the full frame buffer (logical canvas) size
+        this._fb_width = 0;
+        this._fb_height = 0;
 
+        // the visible "physical canvas" viewport
+        this._viewportLoc = { 'x': 0, 'y': 0, 'w': 0, 'h': 0 };
+        this._cleanRect = { 'x1': 0, 'y1': 0, 'x2': -1, 'y2': -1 };
 
+        this._prevDrawStyle = "";
+        this._tile = null;
+        this._tile16x16 = null;
+        this._tile_x = 0;
+        this._tile_y = 0;
 
-//
-// Private functions
-//
+        Util.set_defaults(this, defaults, {
+            'true_color': true,
+            'colourMap': [],
+            'scale': 1.0,
+            'viewport': false,
+            'render_mode': ''
+        });
 
-// Create the public API interface
-function constructor() {
-    Util.Debug(">> Display.constructor");
+        Util.Debug(">> Display.constructor");
 
-    var c, func, i, curDat, curSave,
-        has_imageData = false, UE = Util.Engine;
+        if (!this._target) {
+            throw new Error("Target must be set");
+        }
 
-    if (! conf.target) { throw("target must be set"); }
+        if (typeof this._target === 'string') {
+            throw new Error('target must be a DOM element');
+        }
 
-    if (typeof conf.target === 'string') {
-        throw("target must be a DOM element");
-    }
+        if (!this._target.getContext) {
+            throw new Error("no getContext method");
+        }
 
-    c = conf.target;
+        if (!this._drawCtx) {
+            this._drawCtx = this._target.getContext('2d');
+        }
 
-    if (! c.getContext) { throw("no getContext method"); }
+        Util.Debug("User Agent: " + navigator.userAgent);
+        if (Util.Engine.gecko) { Util.Debug("Browser: gecko " + Util.Engine.gecko); }
+        if (Util.Engine.webkit) { Util.Debug("Browser: webkit " + Util.Engine.webkit); }
+        if (Util.Engine.trident) { Util.Debug("Browser: trident " + Util.Engine.trident); }
+        if (Util.Engine.presto) { Util.Debug("Browser: presto " + Util.Engine.presto); }
 
-    if (! c_ctx) { c_ctx = c.getContext('2d'); }
+        this.clear();
 
-    Util.Debug("User Agent: " + navigator.userAgent);
-    if (UE.gecko) { Util.Debug("Browser: gecko " + UE.gecko); }
-    if (UE.webkit) { Util.Debug("Browser: webkit " + UE.webkit); }
-    if (UE.trident) { Util.Debug("Browser: trident " + UE.trident); }
-    if (UE.presto) { Util.Debug("Browser: presto " + UE.presto); }
+        // Check canvas features
+        if ('createImageData' in this._drawCtx) {
+            this._render_mode = 'canvas rendering';
+        } else {
+            throw new Error("Canvas does not support createImageData");
+        }
 
-    that.clear();
+        if (this._prefer_js === null) {
+            Util.Info("Prefering javascript operations");
+            this._prefer_js = true;
+        }
 
-    // Check canvas features
-    if ('createImageData' in c_ctx) {
-        conf.render_mode = "canvas rendering";
-    } else {
-        throw("Canvas does not support createImageData");
-    }
-    if (conf.prefer_js === null) {
-        Util.Info("Prefering javascript operations");
-        conf.prefer_js = true;
-    }
+        // Determine browser support for setting the cursor via data URI scheme
+        var curDat = [];
+        for (var i = 0; i < 8 * 8 * 4; i++) {
+            curDat.push(255);
+        }
+        try {
+            var curSave = this._target.style.cursor;
+            Display.changeCursor(this._target, curDat, curDat, 2, 2, 8, 8);
+            if (this._target.style.cursor) {
+                if (this._cursor_uri === null) {
+                    this._cursor_uri = true;
+                }
+                Util.Info("Data URI scheme cursor supported");
+            } else {
+                if (this._cursor_uri === null) {
+                    this._cursor_uri = false;
+                }
+                Util.Warn("Data URI scheme cursor not supported");
+            }
+            this._target.style.cursor = curSave;
+        } catch (exc) {
+            Util.Error("Data URI scheme cursor test exception: " + exc);
+            this._cursor_uri = false;
+        }
 
-    // Initialize cached tile imageData
-    tile16x16 = c_ctx.createImageData(16, 16);
+        Util.Debug("<< Display.constructor");
+    };
 
-    /*
-     * Determine browser support for setting the cursor via data URI
-     * scheme
-     */
-    curDat = [];
-    for (i=0; i < 8 * 8 * 4; i += 1) {
-        curDat.push(255);
-    }
-    try {
-        curSave = c.style.cursor;
-        changeCursor(conf.target, curDat, curDat, 2, 2, 8, 8);
-        if (c.style.cursor) {
-            if (conf.cursor_uri === null) {
-                conf.cursor_uri = true;
+    Display.prototype = {
+        // Public methods
+        viewportChange: function (deltaX, deltaY, width, height) {
+            var vp = this._viewportLoc;
+            var cr = this._cleanRect;
+            var canvas = this._target;
+
+            if (!this._viewport) {
+                Util.Debug("Setting viewport to full display region");
+                deltaX = -vp.w;  // clamped later of out of bounds
+                deltaY = -vp.h;
+                width = this._fb_width;
+                height = this._fb_height;
             }
-            Util.Info("Data URI scheme cursor supported");
-        } else {
-            if (conf.cursor_uri === null) {
-                conf.cursor_uri = false;
+
+            if (typeof(deltaX) === "undefined") { deltaX = 0; }
+            if (typeof(deltaY) === "undefined") { deltaY = 0; }
+            if (typeof(width) === "undefined") { width = vp.w; }
+            if (typeof(height) === "undefined") { height = vp.h; }
+
+            // Size change
+            if (width > this._fb_width) { width = this._fb_width; }
+            if (height > this._fb_height) { height = this._fb_height; }
+
+            if (vp.w !== width || vp.h !== height) {
+                // Change width
+                if (width < vp.w &&  cr.x2 > vp.x + width - 1) {
+                    cr.x2 = vp.x + width - 1;
+                }
+                vp.w = width;
+
+                // Change height
+                if (height < vp.h &&  cr.y2 > vp.y + height - 1) {
+                    cr.y2 = vp.y + height - 1;
+                }
+                vp.h = height;
+
+                var saveImg = null;
+                if (vp.w > 0 && vp.h > 0 && canvas.width > 0 && canvas.height > 0) {
+                    var img_width = canvas.width < vp.w ? canvas.width : vp.w;
+                    var img_height = canvas.height < vp.h ? canvas.height : vp.h;
+                    saveImg = this._drawCtx.getImageData(0, 0, img_width, img_height);
+                }
+
+                canvas.width = vp.w;
+                canvas.height = vp.h;
+
+                if (saveImg) {
+                    this._drawCtx.putImageData(saveImg, 0, 0);
+                }
             }
-            Util.Warn("Data URI scheme cursor not supported");
-        }
-        c.style.cursor = curSave;
-    } catch (exc2) { 
-        Util.Error("Data URI scheme cursor test exception: " + exc2);
-        conf.cursor_uri = false;
-    }
-
-    Util.Debug("<< Display.constructor");
-    return that ;
-}
-
-rescale = function(factor) {
-    var c, tp, x, y, 
-        properties = ['transform', 'WebkitTransform', 'MozTransform', null];
-    c = conf.target;
-    tp = properties.shift();
-    while (tp) {
-        if (typeof c.style[tp] !== 'undefined') {
-            break;
-        }
-        tp = properties.shift();
-    }
-
-    if (tp === null) {
-        Util.Debug("No scaling support");
-        return;
-    }
-
-
-    if (typeof(factor) === "undefined") {
-        factor = conf.scale;
-    } else if (factor > 1.0) {
-        factor = 1.0;
-    } else if (factor < 0.1) {
-        factor = 0.1;
-    }
-
-    if (conf.scale === factor) {
-        //Util.Debug("Display already scaled to '" + factor + "'");
-        return;
-    }
-
-    conf.scale = factor;
-    x = c.width - c.width * factor;
-    y = c.height - c.height * factor;
-    c.style[tp] = "scale(" + conf.scale + ") translate(-" + x + "px, -" + y + "px)";
-};
-
-setFillColor = function(color) {
-    var bgr, newStyle;
-    if (conf.true_color) {
-        bgr = color;
-    } else {
-        bgr = conf.colourMap[color[0]];
-    }
-    newStyle = "rgb(" + bgr[2] + "," + bgr[1] + "," + bgr[0] + ")";
-    if (newStyle !== c_prevStyle) {
-        c_ctx.fillStyle = newStyle;
-        c_prevStyle = newStyle;
-    }
-};
-
-
-//
-// Public API interface functions
-//
-
-// Shift and/or resize the visible viewport
-that.viewportChange = function(deltaX, deltaY, width, height) {
-    var c = conf.target, v = viewport, cr = cleanRect,
-        saveImg = null, saveStyle, x1, y1, vx2, vy2, w, h;
-
-    if (!conf.viewport) {
-        Util.Debug("Setting viewport to full display region");
-        deltaX = -v.w; // Clamped later if out of bounds
-        deltaY = -v.h; // Clamped later if out of bounds
-        width = fb_width;
-        height = fb_height;
-    }
-
-    if (typeof(deltaX) === "undefined") { deltaX = 0; }
-    if (typeof(deltaY) === "undefined") { deltaY = 0; }
-    if (typeof(width) === "undefined") { width = v.w; }
-    if (typeof(height) === "undefined") { height = v.h; }
-
-    // Size change
-
-    if (width > fb_width) { width = fb_width; }
-    if (height > fb_height) { height = fb_height; }
-
-    if ((v.w !== width) || (v.h !== height)) {
-        // Change width
-        if ((width < v.w) && (cr.x2 > v.x + width -1)) {
-            cr.x2 = v.x + width - 1;
-        }
-        v.w = width;
 
-        // Change height
-        if ((height < v.h) && (cr.y2 > v.y + height -1)) {
-            cr.y2 = v.y + height - 1;
-        }
-        v.h = height;
+            var vx2 = vp.x + vp.w - 1;
+            var vy2 = vp.y + vp.h - 1;
 
+            // Position change
 
-        if (v.w > 0 && v.h > 0 && c.width > 0 && c.height > 0) {
-            saveImg = c_ctx.getImageData(0, 0,
-                    (c.width < v.w) ? c.width : v.w,
-                    (c.height < v.h) ? c.height : v.h);
-        }
+            if (deltaX < 0 && vp.x + deltaX < 0) {
+                deltaX = -vp.x;
+            }
+            if (vx2 + deltaX >= this._fb_width) {
+                deltaX -= vx2 + deltaX - this._fb_width + 1;
+            }
 
-        c.width = v.w;
-        c.height = v.h;
+            if (vp.y + deltaY < 0) {
+                deltaY = -vp.y;
+            }
+            if (vy2 + deltaY >= this._fb_height) {
+                deltaY -= (vy2 + deltaY - this._fb_height + 1);
+            }
 
-        if (saveImg) {
-            c_ctx.putImageData(saveImg, 0, 0);
-        }
-    }
-
-    vx2 = v.x + v.w - 1;
-    vy2 = v.y + v.h - 1;
-
-
-    // Position change
-
-    if ((deltaX < 0) && ((v.x + deltaX) < 0)) {
-        deltaX = - v.x;
-    }
-    if ((vx2 + deltaX) >= fb_width) {
-        deltaX -= ((vx2 + deltaX) - fb_width + 1);
-    }
-
-    if ((v.y + deltaY) < 0) {
-        deltaY = - v.y;
-    }
-    if ((vy2 + deltaY) >= fb_height) {
-        deltaY -= ((vy2 + deltaY) - fb_height + 1);
-    }
-
-    if ((deltaX === 0) && (deltaY === 0)) {
-        //Util.Debug("skipping viewport change");
-        return;
-    }
-    Util.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
-
-    v.x += deltaX;
-    vx2 += deltaX;
-    v.y += deltaY;
-    vy2 += deltaY;
-
-    // Update the clean rectangle
-    if (v.x > cr.x1) {
-        cr.x1 = v.x;
-    }
-    if (vx2 < cr.x2) {
-        cr.x2 = vx2;
-    }
-    if (v.y > cr.y1) {
-        cr.y1 = v.y;
-    }
-    if (vy2 < cr.y2) {
-        cr.y2 = vy2;
-    }
-
-    if (deltaX < 0) {
-        // Shift viewport left, redraw left section
-        x1 = 0;
-        w = - deltaX;
-    } else {
-        // Shift viewport right, redraw right section
-        x1 = v.w - deltaX;
-        w = deltaX;
-    }
-    if (deltaY < 0) {
-        // Shift viewport up, redraw top section
-        y1 = 0;
-        h = - deltaY;
-    } else {
-        // Shift viewport down, redraw bottom section
-        y1 = v.h - deltaY;
-        h = deltaY;
-    }
-
-    // Copy the valid part of the viewport to the shifted location
-    saveStyle = c_ctx.fillStyle;
-    c_ctx.fillStyle = "rgb(255,255,255)";
-    if (deltaX !== 0) {
-        //that.copyImage(0, 0, -deltaX, 0, v.w, v.h);
-        //that.fillRect(x1, 0, w, v.h, [255,255,255]);
-        c_ctx.drawImage(c, 0, 0, v.w, v.h, -deltaX, 0, v.w, v.h);
-        c_ctx.fillRect(x1, 0, w, v.h);
-    }
-    if (deltaY !== 0) {
-        //that.copyImage(0, 0, 0, -deltaY, v.w, v.h);
-        //that.fillRect(0, y1, v.w, h, [255,255,255]);
-        c_ctx.drawImage(c, 0, 0, v.w, v.h, 0, -deltaY, v.w, v.h);
-        c_ctx.fillRect(0, y1, v.w, h);
-    }
-    c_ctx.fillStyle = saveStyle;
-};
-
-
-// Return a map of clean and dirty areas of the viewport and reset the
-// tracking of clean and dirty areas.
-//
-// Returns: {'cleanBox':   {'x': x, 'y': y, 'w': w, 'h': h},
-//           'dirtyBoxes': [{'x': x, 'y': y, 'w': w, 'h': h}, ...]}
-that.getCleanDirtyReset = function() {
-    var v = viewport, c = cleanRect, cleanBox, dirtyBoxes = [],
-        vx2 = v.x + v.w - 1, vy2 = v.y + v.h - 1;
-
-
-    // Copy the cleanRect
-    cleanBox = {'x': c.x1, 'y': c.y1,
-                'w': c.x2 - c.x1 + 1, 'h': c.y2 - c.y1 + 1};
-
-    if ((c.x1 >= c.x2) || (c.y1 >= c.y2)) {
-        // Whole viewport is dirty
-        dirtyBoxes.push({'x': v.x, 'y': v.y, 'w': v.w, 'h': v.h});
-    } else {
-        // Redraw dirty regions
-        if (v.x < c.x1) {
-            // left side dirty region
-            dirtyBoxes.push({'x': v.x, 'y': v.y,
-                             'w': c.x1 - v.x + 1, 'h': v.h});
-        }
-        if (vx2 > c.x2) {
-            // right side dirty region
-            dirtyBoxes.push({'x': c.x2 + 1, 'y': v.y,
-                             'w': vx2 - c.x2, 'h': v.h});
-        }
-        if (v.y < c.y1) {
-            // top/middle dirty region
-            dirtyBoxes.push({'x': c.x1, 'y': v.y,
-                             'w': c.x2 - c.x1 + 1, 'h': c.y1 - v.y});
-        }
-        if (vy2 > c.y2) {
-            // bottom/middle dirty region
-            dirtyBoxes.push({'x': c.x1, 'y': c.y2 + 1,
-                             'w': c.x2 - c.x1 + 1, 'h': vy2 - c.y2});
-        }
-    }
-
-    // Reset the cleanRect to the whole viewport
-    cleanRect = {'x1': v.x, 'y1': v.y,
-                 'x2': v.x + v.w - 1, 'y2': v.y + v.h - 1};
-
-    return {'cleanBox': cleanBox, 'dirtyBoxes': dirtyBoxes};
-};
-
-// Translate viewport coordinates to absolute coordinates
-that.absX = function(x) {
-    return x + viewport.x;
-};
-that.absY = function(y) {
-    return y + viewport.y;
-};
-
-
-that.resize = function(width, height) {
-    c_prevStyle    = "";
-
-    fb_width = width;
-    fb_height = height;
-
-    rescale(conf.scale);
-    that.viewportChange();
-};
-
-that.clear = function() {
-
-    if (conf.logo) {
-        that.resize(conf.logo.width, conf.logo.height);
-        that.blitStringImage(conf.logo.data, 0, 0);
-    } else {
-        that.resize(640, 20);
-        c_ctx.clearRect(0, 0, viewport.w, viewport.h);
-    }
-
-    renderQ = [];
-
-    // No benefit over default ("source-over") in Chrome and firefox
-    //c_ctx.globalCompositeOperation = "copy";
-};
-
-that.fillRect = function(x, y, width, height, color) {
-    setFillColor(color);
-    c_ctx.fillRect(x - viewport.x, y - viewport.y, width, height);
-};
-
-that.copyImage = function(old_x, old_y, new_x, new_y, w, h) {
-    var x1 = old_x - viewport.x, y1 = old_y - viewport.y,
-        x2 = new_x - viewport.x, y2 = new_y  - viewport.y;
-    c_ctx.drawImage(conf.target, x1, y1, w, h, x2, y2, w, h);
-};
-
-
-// Start updating a tile
-that.startTile = function(x, y, width, height, color) {
-    var data, bgr, red, green, blue, i;
-    tile_x = x;
-    tile_y = y;
-    if ((width === 16) && (height === 16)) {
-        tile = tile16x16;
-    } else {
-        tile = c_ctx.createImageData(width, height);
-    }
-    data = tile.data;
-    if (conf.prefer_js) {
-        if (conf.true_color) {
-            bgr = color;
-        } else {
-            bgr = conf.colourMap[color[0]];
-        }
-        red = bgr[2];
-        green = bgr[1];
-        blue = bgr[0];
-        for (i = 0; i < (width * height * 4); i+=4) {
-            data[i    ] = red;
-            data[i + 1] = green;
-            data[i + 2] = blue;
-            data[i + 3] = 255;
-        }
-    } else {
-        that.fillRect(x, y, width, height, color);
-    }
-};
-
-// Update sub-rectangle of the current tile
-that.subTile = function(x, y, w, h, color) {
-    var data, p, bgr, red, green, blue, width, j, i, xend, yend;
-    if (conf.prefer_js) {
-        data = tile.data;
-        width = tile.width;
-        if (conf.true_color) {
-            bgr = color;
-        } else {
-            bgr = conf.colourMap[color[0]];
-        }
-        red = bgr[2];
-        green = bgr[1];
-        blue = bgr[0];
-        xend = x + w;
-        yend = y + h;
-        for (j = y; j < yend; j += 1) {
-            for (i = x; i < xend; i += 1) {
-                p = (i + (j * width) ) * 4;
-                data[p    ] = red;
-                data[p + 1] = green;
-                data[p + 2] = blue;
-                data[p + 3] = 255;
-            }   
-        } 
-    } else {
-        that.fillRect(tile_x + x, tile_y + y, w, h, color);
-    }
-};
-
-// Draw the current tile to the screen
-that.finishTile = function() {
-    if (conf.prefer_js) {
-        c_ctx.putImageData(tile, tile_x - viewport.x, tile_y - viewport.y);
-    }
-    // else: No-op, if not prefer_js then already done by setSubTile
-};
-
-rgbImageData = function(x, y, vx, vy, width, height, arr, offset) {
-    var img, i, j, data;
-    /*
-    if ((x - v.x >= v.w) || (y - v.y >= v.h) ||
-        (x - v.x + width < 0) || (y - v.y + height < 0)) {
-        // Skipping because outside of viewport
-        return;
-    }
-    */
-    img = c_ctx.createImageData(width, height);
-    data = img.data;
-    for (i=0, j=offset; i < (width * height * 4); i=i+4, j=j+3) {
-        data[i    ] = arr[j    ];
-        data[i + 1] = arr[j + 1];
-        data[i + 2] = arr[j + 2];
-        data[i + 3] = 255; // Set Alpha
-    }
-    c_ctx.putImageData(img, x - vx, y - vy);
-};
-
-bgrxImageData = function(x, y, vx, vy, width, height, arr, offset) {
-    var img, i, j, data;
-    /*
-    if ((x - v.x >= v.w) || (y - v.y >= v.h) ||
-        (x - v.x + width < 0) || (y - v.y + height < 0)) {
-        // Skipping because outside of viewport
-        return;
-    }
-    */
-    img = c_ctx.createImageData(width, height);
-    data = img.data;
-    for (i=0, j=offset; i < (width * height * 4); i=i+4, j=j+4) {
-        data[i    ] = arr[j + 2];
-        data[i + 1] = arr[j + 1];
-        data[i + 2] = arr[j    ];
-        data[i + 3] = 255; // Set Alpha
-    }
-    c_ctx.putImageData(img, x - vx, y - vy);
-};
-
-cmapImageData = function(x, y, vx, vy, width, height, arr, offset) {
-    var img, i, j, data, bgr, cmap;
-    img = c_ctx.createImageData(width, height);
-    data = img.data;
-    cmap = conf.colourMap;
-    for (i=0, j=offset; i < (width * height * 4); i+=4, j+=1) {
-        bgr = cmap[arr[j]];
-        data[i    ] = bgr[2];
-        data[i + 1] = bgr[1];
-        data[i + 2] = bgr[0];
-        data[i + 3] = 255; // Set Alpha
-    }
-    c_ctx.putImageData(img, x - vx, y - vy);
-};
-
-that.blitImage = function(x, y, width, height, arr, offset) {
-    if (conf.true_color) {
-        bgrxImageData(x, y, viewport.x, viewport.y, width, height, arr, offset);
-    } else {
-        cmapImageData(x, y, viewport.x, viewport.y, width, height, arr, offset);
-    }
-};
-
-that.blitRgbImage = function(x, y, width, height, arr, offset) {
-    if (conf.true_color) {
-        rgbImageData(x, y, viewport.x, viewport.y, width, height, arr, offset);
-    } else {
-        // prolly wrong...
-        cmapImageData(x, y, viewport.x, viewport.y, width, height, arr, offset);
-    }
-};
-
-that.blitStringImage = function(str, x, y) {
-    var img = new Image();
-    img.onload = function () {
-        c_ctx.drawImage(img, x - viewport.x, y - viewport.y);
-    };
-    img.src = str;
-};
-
-// Wrap ctx.drawImage but relative to viewport
-that.drawImage = function(img, x, y) {
-    c_ctx.drawImage(img, x - viewport.x, y - viewport.y);
-};
-
-that.renderQ_push = function(action) {
-    renderQ.push(action);
-    if (renderQ.length === 1) {
-        // If this can be rendered immediately it will be, otherwise
-        // the scanner will start polling the queue (every
-        // requestAnimationFrame interval)
-        scan_renderQ();
-    }
-};
-
-scan_renderQ = function() {
-    var a, ready = true;
-    while (ready && renderQ.length > 0) {
-        a = renderQ[0];
-        switch (a.type) {
-            case 'copy':
-                that.copyImage(a.old_x, a.old_y, a.x, a.y, a.width, a.height);
-                break;
-            case 'fill':
-                that.fillRect(a.x, a.y, a.width, a.height, a.color);
-                break;
-            case 'blit':
-                that.blitImage(a.x, a.y, a.width, a.height, a.data, 0);
-                break;
-            case 'blitRgb':
-                that.blitRgbImage(a.x, a.y, a.width, a.height, a.data, 0);
-                break;
-            case 'img':    
-                if (a.img.complete) {
-                    that.drawImage(a.img, a.x, a.y);
+            if (deltaX === 0 && deltaY === 0) {
+                return;
+            }
+            Util.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
+
+            vp.x += deltaX;
+            vx2 += deltaX;
+            vp.y += deltaY;
+            vy2 += deltaY;
+
+            // Update the clean rectangle
+            if (vp.x > cr.x1) {
+                cr.x1 = vp.x;
+            }
+            if (vx2 < cr.x2) {
+                cr.x2 = vx2;
+            }
+            if (vp.y > cr.y1) {
+                cr.y1 = vp.y;
+            }
+            if (vy2 < cr.y2) {
+                cr.y2 = vy2;
+            }
+
+            var x1, w;
+            if (deltaX < 0) {
+                // Shift viewport left, redraw left section
+                x1 = 0;
+                w = -deltaX;
+            } else {
+                // Shift viewport right, redraw right section
+                x1 = vp.w - deltaX;
+                w = deltaX;
+            }
+
+            var y1, h;
+            if (deltaY < 0) {
+                // Shift viewport up, redraw top section
+                y1 = 0;
+                h = -deltaY;
+            } else {
+                // Shift viewport down, redraw bottom section
+                y1 = vp.h - deltaY;
+                h = deltaY;
+            }
+
+            // Copy the valid part of the viewport to the shifted location
+            var saveStyle = this._drawCtx.fillStyle;
+            this._drawCtx.fillStyle = "rgb(255,255,255)";
+            if (deltaX !== 0) {
+                this._drawCtx.drawImage(canvas, 0, 0, vp.w, vp.h, -deltaX, 0, vp.w, vp.h);
+                this._drawCtx.fillRect(x1, 0, w, vp.h);
+            }
+            if (deltaY !== 0) {
+                this._drawCtx.drawImage(canvas, 0, 0, vp.w, vp.h, 0, -deltaY, vp.w, vp.h);
+                this._drawCtx.fillRect(0, y1, vp.w, h);
+            }
+            this._drawCtx.fillStyle = saveStyle;
+        },
+
+        // Return a map of clean and dirty areas of the viewport and reset the
+        // tracking of clean and dirty areas
+        //
+        // Returns: { 'cleanBox': { 'x': x, 'y': y, 'w': w, 'h': h},
+        //            'dirtyBoxes': [{ 'x': x, 'y': y, 'w': w, 'h': h }, ...] }
+        getCleanDirtyReset: function () {
+            var vp = this._viewportLoc;
+            var cr = this._cleanRect;
+
+            var cleanBox = { 'x': cr.x1, 'y': cr.y1,
+                             'w': cr.x2 - cr.x1 + 1, 'h': cr.y2 - cr.y1 + 1 };
+
+            var dirtyBoxes = [];
+            if (cr.x1 >= cr.x2 || cr.y1 >= cr.y2) {
+                // Whole viewport is dirty
+                dirtyBoxes.push({ 'x': vp.x, 'y': vp.y, 'w': vp.w, 'h': vp.h });
+            } else {
+                // Redraw dirty regions
+                var vx2 = vp.x + vp.w - 1;
+                var vy2 = vp.y + vp.h - 1;
+
+                if (vp.x < cr.x1) {
+                    // left side dirty region
+                    dirtyBoxes.push({'x': vp.x, 'y': vp.y,
+                                     'w': cr.x1 - vp.x + 1, 'h': vp.h});
+                }
+                if (vx2 > cr.x2) {
+                    // right side dirty region
+                    dirtyBoxes.push({'x': cr.x2 + 1, 'y': vp.y,
+                                     'w': vx2 - cr.x2, 'h': vp.h});
+                }
+                if(vp.y < cr.y1) {
+                    // top/middle dirty region
+                    dirtyBoxes.push({'x': cr.x1, 'y': vp.y,
+                                     'w': cr.x2 - cr.x1 + 1, 'h': cr.y1 - vp.y});
+                }
+                if (vy2 > cr.y2) {
+                    // bottom/middle dirty region
+                    dirtyBoxes.push({'x': cr.x1, 'y': cr.y2 + 1,
+                                     'w': cr.x2 - cr.x1 + 1, 'h': vy2 - cr.y2});
+                }
+            }
+
+            this._cleanRect = {'x1': vp.x, 'y1': vp.y,
+                               'x2': vp.x + vp.w - 1, 'y2': vp.y + vp.h - 1};
+
+            return {'cleanBox': cleanBox, 'dirtyBoxes': dirtyBoxes};
+        },
+
+        absX: function (x) {
+            return x + this._viewportLoc.x;
+        },
+
+        absY: function (y) {
+            return y + this._viewportLoc.y;
+        },
+
+        resize: function (width, height) {
+            this._prevDrawStyle = "";
+
+            this._fb_width = width;
+            this._fb_height = height;
+
+            this._rescale(this._scale);
+
+            this.viewportChange();
+        },
+
+        clear: function () {
+            if (this._logo) {
+                this.resize(this._logo.width, this._logo.height);
+                this.blitStringImage(this._logo.data, 0, 0);
+            } else {
+                this.resize(640, 20);
+                this._drawCtx.clearRect(0, 0, this._viewportLoc.w, this._viewportLoc.h);
+            }
+
+            this._renderQ = [];
+        },
+
+        fillRect: function (x, y, width, height, color) {
+            this._setFillColor(color);
+            this._drawCtx.fillRect(x - this._viewportLoc.x, y - this._viewportLoc.y, width, height);
+        },
+
+        copyImage: function (old_x, old_y, new_x, new_y, w, h) {
+            var x1 = old_x - this._viewportLoc.x;
+            var y1 = old_y - this._viewportLoc.y;
+            var x2 = new_x - this._viewportLoc.x;
+            var y2 = new_y - this._viewportLoc.y;
+
+            this._drawCtx.drawImage(this._target, x1, y1, w, h, x2, y2, w, h);
+        },
+
+        // start updating a tile
+        startTile: function (x, y, width, height, color) {
+            this._tile_x = x;
+            this._tile_y = y;
+            if (width === 16 && height === 16) {
+                this._tile = this._tile16x16;
+            } else {
+                this._tile = this._drawCtx.createImageData(width, height);
+            }
+
+            if (this._prefer_js) {
+                var bgr;
+                if (this._true_color) {
+                    bgr = color;
                 } else {
-                    // We need to wait for this image to 'load'
-                    // to keep things in-order
-                    ready = false;
+                    bgr = this._colourMap[color[0]];
                 }
-                break;
-        }
-        if (ready) {
-            a = renderQ.shift();
-        }
-    }
-    if (renderQ.length > 0) {
-        requestAnimFrame(scan_renderQ);
-    }
-};
+                var red = bgr[2];
+                var green = bgr[1];
+                var blue = bgr[0];
+
+                var data = this._tile.data;
+                for (var i = 0; i < width * height * 4; i += 4) {
+                    data[i] = red;
+                    data[i + 1] = green;
+                    data[i + 2] = blue;
+                    data[i + 3] = 255;
+                }
+            } else {
+                this.fillRect(x, y, width, height, color);
+            }
+        },
+
+        // update sub-rectangle of the current tile
+        subTile: function (x, y, w, h, color) {
+            if (this._prefer_js) {
+                var bgr;
+                if (this._true_color) {
+                    bgr = color;
+                } else {
+                    bgr = this._colourMap[color[0]];
+                }
+                var red = bgr[2];
+                var green = bgr[1];
+                var blue = bgr[0];
+                var xend = x + w;
+                var yend = y + h;
+
+                var data = this._tile.data;
+                var width = this._tile.width;
+                for (var j = y; j < yend; j++) {
+                    for (var i = x; i < xend; i++) {
+                        var p = (i + (j * width)) * 4;
+                        data[p] = red;
+                        data[p + 1] = green;
+                        data[p + 2] = blue;
+                        data[p + 3] = 255;
+                    }
+                }
+            } else {
+                this.fillRect(this._tile_x + x, this._tile_y + y, w, h, color);
+            }
+        },
 
+        // draw the current tile to the screen
+        finishTile: function () {
+            if (this._prefer_js) {
+                this._drawCtx.putImageData(this._tile, this._tile_x - this._viewportLoc.x,
+                                           this._tile_y - this._viewportLoc.y);
+            }
+            // else: No-op -- already done by setSubTile
+        },
 
-that.changeCursor = function(pixels, mask, hotx, hoty, w, h) {
-    if (conf.cursor_uri === false) {
-        Util.Warn("changeCursor called but no cursor data URI support");
-        return;
-    }
+        blitImage: function (x, y, width, height, arr, offset) {
+            if (this._true_color) {
+                this._bgrxImageData(x, y, this._viewportLoc.x, this._viewportLoc.y, width, height, arr, offset);
+            } else {
+                this._cmapImageData(x, y, this._viewportLoc.x, this._viewportLoc.y, width, height, arr, offset);
+            }
+        },
 
-    if (conf.true_color) {
-        changeCursor(conf.target, pixels, mask, hotx, hoty, w, h);
-    } else {
-        changeCursor(conf.target, pixels, mask, hotx, hoty, w, h, conf.colourMap);
-    }
-};
+        blitRgbImage: function (x, y , width, height, arr, offset) {
+            if (this._true_color) {
+                this._rgbImageData(x, y, this._viewportLoc.x, this._viewportLoc.y, width, height, arr, offset);
+            } else {
+                // probably wrong?
+                this._cmapImageData(x, y, this._viewportLoc.x, this._viewportLoc.y, width, height, arr, offset);
+            }
+        },
+
+        blitStringImage: function (str, x, y) {
+            var img = new Image();
+            img.onload = function () {
+                this._drawCtx.drawImage(img, x - this._viewportLoc.x, y - this._viewportLoc.y);
+            }.bind(this);
+            img.src = str;
+            return img; // for debugging purposes
+        },
+
+        // wrap ctx.drawImage but relative to viewport
+        drawImage: function (img, x, y) {
+            this._drawCtx.drawImage(img, x - this._viewportLoc.x, y - this._viewportLoc.y);
+        },
+
+        renderQ_push: function (action) {
+            this._renderQ.push(action);
+            if (this._renderQ.length === 1) {
+                // If this can be rendered immediately it will be, otherwise
+                // the scanner will start polling the queue (every
+                // requestAnimationFrame interval)
+                this._scan_renderQ();
+            }
+        },
 
-that.defaultCursor = function() {
-    conf.target.style.cursor = "default";
-};
+        changeCursor: function (pixels, mask, hotx, hoty, w, h) {
+            if (this._cursor_uri === false) {
+                Util.Warn("changeCursor called but no cursor data URI support");
+                return;
+            }
 
-return constructor();  // Return the public API interface
+            if (this._true_color) {
+                Display.changeCursor(this._target, pixels, mask, hotx, hoty, w, h);
+            } else {
+                Display.changeCursor(this._target, pixels, mask, hotx, hoty, w, h, this._colourMap);
+            }
+        },
+
+        defaultCursor: function () {
+            this._target.style.cursor = "default";
+        },
+
+        // Overridden getters/setters
+        get_context: function () {
+            return this._drawCtx;
+        },
+
+        set_scale: function (scale) {
+            this._rescale(scale);
+        },
+
+        set_width: function (w) {
+            this.resize(w, this._fb_height);
+        },
+        get_width: function () {
+            return this._fb_width;
+        },
+
+        set_height: function (h) {
+            this.resize(this._fb_width, h);
+        },
+        get_height: function () {
+            return this._fb_height;
+        },
+
+        // Private Methods
+        _rescale: function (factor) {
+            var canvas = this._target;
+            var properties = ['transform', 'WebkitTransform', 'MozTransform'];
+            var transform_prop;
+            while ((transform_prop = properties.shift())) {
+                if (typeof canvas.style[transform_prop] !== 'undefined') {
+                    break;
+                }
+            }
 
-}  // End of Display()
+            if (transform_prop === null) {
+                Util.Debug("No scaling support");
+                return;
+            }
 
+            if (typeof(factor) === "undefined") {
+                factor = this._scale;
+            } else if (factor > 1.0) {
+                factor = 1.0;
+            } else if (factor < 0.1) {
+                factor = 0.1;
+            }
 
-/* Set CSS cursor property using data URI encoded cursor file */
-function changeCursor(target, pixels, mask, hotx, hoty, w0, h0, cmap) {
-    "use strict";
-    var cur = [], rgb, IHDRsz, RGBsz, ANDsz, XORsz, url, idx, alpha, x, y;
-    //Util.Debug(">> changeCursor, x: " + hotx + ", y: " + hoty + ", w0: " + w0 + ", h0: " + h0);
-
-    var w = w0;
-    var h = h0;
-    if (h < w)
-        h = w;                 // increase h to make it square
-    else
-        w = h;                 // increace w to make it square
-
-    // Push multi-byte little-endian values
-    cur.push16le = function (num) {
-        this.push((num     ) & 0xFF,
-                  (num >> 8) & 0xFF  );
-    };
-    cur.push32le = function (num) {
-        this.push((num      ) & 0xFF,
-                  (num >>  8) & 0xFF,
-                  (num >> 16) & 0xFF,
-                  (num >> 24) & 0xFF  );
-    };
+            if (this._scale === factor) {
+                return;
+            }
+
+            this._scale = factor;
+            var x = canvas.width - (canvas.width * factor);
+            var y = canvas.height - (canvas.height * factor);
+            canvas.style[transform_prop] = 'scale(' + this._scale + ') translate(-' + x + 'px, -' + y + 'px)';
+        },
 
-    IHDRsz = 40;
-    RGBsz = w * h * 4;
-    XORsz = Math.ceil( (w * h) / 8.0 );
-    ANDsz = Math.ceil( (w * h) / 8.0 );
-
-    // Main header
-    cur.push16le(0);      // 0: Reserved
-    cur.push16le(2);      // 2: .CUR type
-    cur.push16le(1);      // 4: Number of images, 1 for non-animated ico
-
-    // Cursor #1 header (ICONDIRENTRY)
-    cur.push(w);          // 6: width
-    cur.push(h);          // 7: height
-    cur.push(0);          // 8: colors, 0 -> true-color
-    cur.push(0);          // 9: reserved
-    cur.push16le(hotx);   // 10: hotspot x coordinate
-    cur.push16le(hoty);   // 12: hotspot y coordinate
-    cur.push32le(IHDRsz + RGBsz + XORsz + ANDsz);
-                          // 14: cursor data byte size
-    cur.push32le(22);     // 18: offset of cursor data in the file
-
-
-    // Cursor #1 InfoHeader (ICONIMAGE/BITMAPINFO)
-    cur.push32le(IHDRsz); // 22: Infoheader size
-    cur.push32le(w);      // 26: Cursor width
-    cur.push32le(h*2);    // 30: XOR+AND height
-    cur.push16le(1);      // 34: number of planes
-    cur.push16le(32);     // 36: bits per pixel
-    cur.push32le(0);      // 38: Type of compression
-
-    cur.push32le(XORsz + ANDsz); // 43: Size of Image
-                                 // Gimp leaves this as 0
-
-    cur.push32le(0);      // 46: reserved
-    cur.push32le(0);      // 50: reserved
-    cur.push32le(0);      // 54: reserved
-    cur.push32le(0);      // 58: reserved
-
-    // 62: color data (RGBQUAD icColors[])
-    for (y = h-1; y >= 0; y -= 1) {
-        for (x = 0; x < w; x += 1) {
-            if (x >= w0 || y >= h0) {
-                cur.push(0);          // blue
-                cur.push(0);          // green
-                cur.push(0);          // red
-                cur.push(0);          // alpha
+        _setFillColor: function (color) {
+            var bgr;
+            if (this._true_color) {
+                bgr = color;
             } else {
-                idx = y * Math.ceil(w0 / 8) + Math.floor(x/8);
-                alpha = (mask[idx] << (x % 8)) & 0x80 ? 255 : 0;
-                if (cmap) {
-                    idx = (w0 * y) + x;
-                    rgb = cmap[pixels[idx]];
-                    cur.push(rgb[2]);          // blue
-                    cur.push(rgb[1]);          // green
-                    cur.push(rgb[0]);          // red
-                    cur.push(alpha);           // alpha
+                bgr = this._colourMap[color[0]];
+            }
+
+            var newStyle = 'rgb(' + bgr[2] + ',' + bgr[1] + ',' + bgr[0] + ')';
+            if (newStyle !== this._prevDrawStyle) {
+                this._drawCtx.fillStyle = newStyle;
+                this._prevDrawStyle = newStyle;
+            }
+        },
+
+        _rgbImageData: function (x, y, vx, vy, width, height, arr, offset) {
+            var img = this._drawCtx.createImageData(width, height);
+            var data = img.data;
+            for (var i = 0, j = offset; i < width * height * 4; i += 4, j += 3) {
+                data[i]     = arr[j];
+                data[i + 1] = arr[j + 1];
+                data[i + 2] = arr[j + 2];
+                data[i + 3] = 255;  // Alpha
+            }
+            this._drawCtx.putImageData(img, x - vx, y - vy);
+        },
+
+        _bgrxImageData: function (x, y, vx, vy, width, height, arr, offset) {
+            var img = this._drawCtx.createImageData(width, height);
+            var data = img.data;
+            for (var i = 0, j = offset; i < width * height * 4; i += 4, j += 4) {
+                data[i]     = arr[j + 2];
+                data[i + 1] = arr[j + 1];
+                data[i + 2] = arr[j];
+                data[i + 3] = 255;  // Alpha
+            }
+            this._drawCtx.putImageData(img, x - vx, y - vy);
+        },
+
+        _cmapImageData: function (x, y, vx, vy, width, height, arr, offset) {
+            var img = this._drawCtx.createImageData(width, height);
+            var data = img.data;
+            var cmap = this._colourMap;
+            for (var i = 0, j = offset; i < width * height * 4; i += 4, j++) {
+                var bgr = cmap[arr[j]];
+                data[i]     = bgr[2];
+                data[i + 1] = bgr[1];
+                data[i + 2] = bgr[0];
+                data[i + 3] = 255;  // Alpha
+            }
+            this._drawCtx.putImageData(img, x - vx, y - vy);
+        },
+
+        _scan_renderQ: function () {
+            var ready = true;
+            while (ready && this._renderQ.length > 0) {
+                var a = this._renderQ[0];
+                switch (a.type) {
+                    case 'copy':
+                        this.copyImage(a.old_x, a.old_y, a.x, a.y, a.width, a.height);
+                        break;
+                    case 'fill':
+                        this.fillRect(a.x, a.y, a.width, a.height, a.color);
+                        break;
+                    case 'blit':
+                        this.blitImage(a.x, a.y, a.width, a.height, a.data, 0);
+                        break;
+                    case 'blitRgb':
+                        this.blitRgbImage(a.x, a.y, a.width, a.height, a.data, 0);
+                        break;
+                    case 'img':
+                        if (a.img.complete) {
+                            this.drawImage(a.img, a.x, a.y);
+                        } else {
+                            // We need to wait for this image to 'load'
+                            // to keep things in-order
+                            ready = false;
+                        }
+                        break;
+                }
+
+                if (ready) {
+                    this._renderQ.shift();
+                }
+            }
+
+            if (this._renderQ.length > 0) {
+                requestAnimFrame(this._scan_renderQ.bind(this));
+            }
+        },
+    };
+
+    Util.make_properties(Display, [
+        ['target', 'wo', 'dom'],       // Canvas element for rendering
+        ['context', 'ro', 'raw'],      // Canvas 2D context for rendering (read-only)
+        ['logo', 'rw', 'raw'],         // Logo to display when cleared: {"width": w, "height": h, "data": data}
+        ['true_color', 'rw', 'bool'],  // Use true-color pixel data
+        ['colourMap', 'rw', 'arr'],    // Colour map array (when not true-color)
+        ['scale', 'rw', 'float'],      // Display area scale factor 0.0 - 1.0
+        ['viewport', 'rw', 'bool'],    // Use a viewport set with viewportChange()
+        ['width', 'rw', 'int'],        // Display area width
+        ['height', 'rw', 'int'],       // Display area height
+
+        ['render_mode', 'ro', 'str'],  // Canvas rendering mode (read-only)
+
+        ['prefer_js', 'rw', 'str'],    // Prefer Javascript over canvas methods
+        ['cursor_uri', 'rw', 'raw']    // Can we render cursor using data URI
+    ]);
+
+    // Class Methods
+    Display.changeCursor = function (target, pixels, mask, hotx, hoty, w0, h0, cmap) {
+        var w = w0;
+        var h = h0;
+        if (h < w) {
+            h = w;  // increase h to make it square
+        } else {
+            w = h;  // increase w to make it square
+        }
+
+        var cur = [];
+
+        // Push multi-byte little-endian values
+        cur.push16le = function (num) {
+            this.push(num & 0xFF, (num >> 8) & 0xFF);
+        };
+        cur.push32le = function (num) {
+            this.push(num & 0xFF,
+                      (num >> 8) & 0xFF,
+                      (num >> 16) & 0xFF,
+                      (num >> 24) & 0xFF);
+        };
+
+        var IHDRsz = 40;
+        var RGBsz = w * h * 4;
+        var XORsz = Math.ceil((w * h) / 8.0);
+        var ANDsz = Math.ceil((w * h) / 8.0);
+
+        cur.push16le(0);        // 0: Reserved
+        cur.push16le(2);        // 2: .CUR type
+        cur.push16le(1);        // 4: Number of images, 1 for non-animated ico
+
+        // Cursor #1 header (ICONDIRENTRY)
+        cur.push(w);            // 6: width
+        cur.push(h);            // 7: height
+        cur.push(0);            // 8: colors, 0 -> true-color
+        cur.push(0);            // 9: reserved
+        cur.push16le(hotx);     // 10: hotspot x coordinate
+        cur.push16le(hoty);     // 12: hotspot y coordinate
+        cur.push32le(IHDRsz + RGBsz + XORsz + ANDsz);
+                                // 14: cursor data byte size
+        cur.push32le(22);       // 18: offset of cursor data in the file
+
+        // Cursor #1 InfoHeader (ICONIMAGE/BITMAPINFO)
+        cur.push32le(IHDRsz);   // 22: InfoHeader size
+        cur.push32le(w);        // 26: Cursor width
+        cur.push32le(h * 2);    // 30: XOR+AND height
+        cur.push16le(1);        // 34: number of planes
+        cur.push16le(32);       // 36: bits per pixel
+        cur.push32le(0);        // 38: Type of compression
+
+        cur.push32le(XORsz + ANDsz);
+                                // 42: Size of Image
+        cur.push32le(0);        // 46: reserved
+        cur.push32le(0);        // 50: reserved
+        cur.push32le(0);        // 54: reserved
+        cur.push32le(0);        // 58: reserved
+
+        // 62: color data (RGBQUAD icColors[])
+        var y, x;
+        for (y = h - 1; y >= 0; y--) {
+            for (x = 0; x < w; x++) {
+                if (x >= w0 || y >= h0) {
+                    cur.push(0);  // blue
+                    cur.push(0);  // green
+                    cur.push(0);  // red
+                    cur.push(0);  // alpha
                 } else {
-                    idx = ((w0 * y) + x) * 4;
-                    cur.push(pixels[idx + 2]); // blue
-                    cur.push(pixels[idx + 1]); // green
-                    cur.push(pixels[idx    ]); // red
-                    cur.push(alpha);           // alpha
+                    var idx = y * Math.ceil(w0 / 8) + Math.floor(x / 8);
+                    var alpha = (mask[idx] << (x % 8)) & 0x80 ? 255 : 0;
+                    if (cmap) {
+                        idx = (w0 * y) + x;
+                        var rgb = cmap[pixels[idx]];
+                        cur.push(rgb[2]);  // blue
+                        cur.push(rgb[1]);  // green
+                        cur.push(rgb[0]);  // red
+                        cur.push(alpha);   // alpha
+                    }
                 }
             }
         }
-    }
 
-    // XOR/bitmask data (BYTE icXOR[])
-    // (ignored, just needs to be right size)
-    for (y = 0; y < h; y += 1) {
-        for (x = 0; x < Math.ceil(w / 8); x += 1) {
-            cur.push(0x00);
+        // XOR/bitmask data (BYTE icXOR[])
+        // (ignored, just needs to be the right size)
+        for (y = 0; y < h; y++) {
+            for (x = 0; x < Math.ceil(w / 8); x++) {
+                cur.push(0);
+            }
         }
-    }
 
-    // AND/bitmask data (BYTE icAND[])
-    // (ignored, just needs to be right size)
-    for (y = 0; y < h; y += 1) {
-        for (x = 0; x < Math.ceil(w / 8); x += 1) {
-            cur.push(0x00);
+        // AND/bitmask data (BYTE icAND[])
+        // (ignored, just needs to be the right size)
+        for (y = 0; y < h; y++) {
+            for (x = 0; x < Math.ceil(w / 8); x++) {
+                cur.push(0);
+            }
         }
-    }
 
-    url = "data:image/x-icon;base64," + Base64.encode(cur);
-    target.style.cursor = "url(" + url + ") " + hotx + " " + hoty + ", default";
-    //Util.Debug("<< changeCursor, cur.length: " + cur.length);
-}
+        var url = 'data:image/x-icon;base64,' + Base64.encode(cur);
+        target.style.cursor = 'url(' + url + ')' + hotx + ' ' + hoty + ', default';
+    };
+})();

+ 332 - 0
tests/test.display.js

@@ -0,0 +1,332 @@
+// requires local modules: util, base64, display
+/* jshint expr: true */
+var expect = chai.expect;
+
+chai.use(function (_chai, utils) {
+    _chai.Assertion.addMethod('displayed', function (target_data) {
+        var obj = this._obj;
+        var data_cl = obj._drawCtx.getImageData(0, 0, obj._viewportLoc.w, obj._viewportLoc.h).data;
+        // NB(directxman12): PhantomJS 1.x doesn't implement Uint8ClampedArray, so work around that
+        var data = new Uint8Array(data_cl);
+        this.assert(utils.eql(data, target_data),
+            "expected #{this} to have displayed the image #{exp}, but instead it displayed #{act}",
+            "expected #{this} not to have displayed the image #{act}",
+            target_data,
+            data);
+    });
+});
+
+describe('Display/Canvas Helper', function () {
+    var checked_data = [
+        0x00, 0x00, 0xff, 255, 0x00, 0x00, 0xff, 255, 0x00, 0xff, 0x00, 255, 0x00, 0xff, 0x00, 255,
+        0x00, 0x00, 0xff, 255, 0x00, 0x00, 0xff, 255, 0x00, 0xff, 0x00, 255, 0x00, 0xff, 0x00, 255,
+        0x00, 0xff, 0x00, 255, 0x00, 0xff, 0x00, 255, 0x00, 0x00, 0xff, 255, 0x00, 0x00, 0xff, 255,
+        0x00, 0xff, 0x00, 255, 0x00, 0xff, 0x00, 255, 0x00, 0x00, 0xff, 255, 0x00, 0x00, 0xff, 255
+    ];
+    checked_data = new Uint8Array(checked_data);
+
+    var basic_data = [0xff, 0x00, 0x00, 255, 0x00, 0xff, 0x00, 255, 0x00, 0x00, 0xff, 255, 0xff, 0xff, 0xff, 255];
+    basic_data = new Uint8Array(basic_data);
+
+    function make_image_canvas (input_data) {
+        var canvas = document.createElement('canvas');
+        canvas.width = 4;
+        canvas.height = 4;
+        var ctx = canvas.getContext('2d');
+        var data = ctx.createImageData(4, 4);
+        for (var i = 0; i < checked_data.length; i++) { data.data[i] = input_data[i]; }
+        ctx.putImageData(data, 0, 0);
+        return canvas;
+    }
+
+    describe('viewport handling', function () {
+        var display;
+        beforeEach(function () {
+            display = new Display({ target: document.createElement('canvas'), prefer_js: false, viewport: true });
+            display.resize(5, 5);
+            display.viewportChange(1, 1, 3, 3);
+            display.getCleanDirtyReset();
+        });
+
+        it('should take viewport location into consideration when drawing images', function () {
+            display.resize(4, 4);
+            display.viewportChange(0, 0, 2, 2);
+            display.drawImage(make_image_canvas(basic_data), 1, 1);
+
+            var expected = new Uint8Array(16);
+            var i;
+            for (i = 0; i < 8; i++) { expected[i] = basic_data[i]; }
+            for (i = 8; i < 16; i++) { expected[i] = 0; }
+            expect(display).to.have.displayed(expected);
+        });
+
+        it('should redraw the left side when shifted left', function () {
+            display.viewportChange(-1, 0, 3, 3);
+            var cdr = display.getCleanDirtyReset();
+            expect(cdr.cleanBox).to.deep.equal({ x: 1, y: 1, w: 2, h: 3 });
+            expect(cdr.dirtyBoxes).to.have.length(1);
+            expect(cdr.dirtyBoxes[0]).to.deep.equal({ x: 0, y: 1, w: 2, h: 3 });
+        });
+
+        it('should redraw the right side when shifted right', function () {
+            display.viewportChange(1, 0, 3, 3);
+            var cdr = display.getCleanDirtyReset();
+            expect(cdr.cleanBox).to.deep.equal({ x: 2, y: 1, w: 2, h: 3 });
+            expect(cdr.dirtyBoxes).to.have.length(1);
+            expect(cdr.dirtyBoxes[0]).to.deep.equal({ x: 4, y: 1, w: 1, h: 3 });
+        });
+
+        it('should redraw the top part when shifted up', function () {
+            display.viewportChange(0, -1, 3, 3);
+            var cdr = display.getCleanDirtyReset();
+            expect(cdr.cleanBox).to.deep.equal({ x: 1, y: 1, w: 3, h: 2 });
+            expect(cdr.dirtyBoxes).to.have.length(1);
+            expect(cdr.dirtyBoxes[0]).to.deep.equal({ x: 1, y: 0, w: 3, h: 1 });
+        });
+
+        it('should redraw the bottom part when shifted down', function () {
+            display.viewportChange(0, 1, 3, 3);
+            var cdr = display.getCleanDirtyReset();
+            expect(cdr.cleanBox).to.deep.equal({ x: 1, y: 2, w: 3, h: 2 });
+            expect(cdr.dirtyBoxes).to.have.length(1);
+            expect(cdr.dirtyBoxes[0]).to.deep.equal({ x: 1, y: 4, w: 3, h: 1 });
+        });
+
+        it('should reset the entire viewport to being clean after calculating the clean/dirty boxes', function () {
+            display.viewportChange(0, 1, 3, 3);
+            var cdr1 = display.getCleanDirtyReset();
+            var cdr2 = display.getCleanDirtyReset();
+            expect(cdr1).to.not.deep.equal(cdr2);
+            expect(cdr2.cleanBox).to.deep.equal({ x: 1, y: 2, w: 3, h: 3 });
+            expect(cdr2.dirtyBoxes).to.be.empty;
+        });
+
+        it('should simply mark the whole display area as dirty if not using viewports', function () {
+            display = new Display({ target: document.createElement('canvas'), prefer_js: false, viewport: false });
+            display.resize(5, 5);
+            var cdr = display.getCleanDirtyReset();
+            expect(cdr.cleanBox).to.deep.equal({ x: 0, y: 0, w: 0, h: 0 });
+            expect(cdr.dirtyBoxes).to.have.length(1);
+            expect(cdr.dirtyBoxes[0]).to.deep.equal({ x: 0, y: 0, w: 5, h: 5 });
+        });
+    });
+
+    describe('resizing', function () {
+        var display;
+        beforeEach(function () {
+            display = new Display({ target: document.createElement('canvas'), prefer_js: false, viewport: true });
+            display.resize(4, 3);
+        });
+
+        it('should change the size of the logical canvas', function () {
+            display.resize(5, 7);
+            expect(display._fb_width).to.equal(5);
+            expect(display._fb_height).to.equal(7);
+        });
+
+        it('should update the viewport dimensions', function () {
+            sinon.spy(display, 'viewportChange');
+            display.resize(2, 2);
+            expect(display.viewportChange).to.have.been.calledOnce;
+        });
+    });
+
+    describe('drawing', function () {
+
+        // TODO(directxman12): improve the tests for each of the drawing functions to cover more than just the
+        //                     basic cases
+        function drawing_tests (pref_js) {
+            var display;
+            beforeEach(function () {
+                display = new Display({ target: document.createElement('canvas'), prefer_js: pref_js });
+                display.resize(4, 4);
+            });
+
+            it('should clear the screen on #clear without a logo set', function () {
+                display.fillRect(0, 0, 4, 4, [0x00, 0x00, 0xff]);
+                display._logo = null;
+                display.clear();
+                display.resize(4, 4);
+                var empty = [];
+                for (var i = 0; i < 4 * display._fb_width * display._fb_height; i++) { empty[i] = 0; }
+                expect(display).to.have.displayed(new Uint8Array(empty));
+            });
+
+            it('should draw the logo on #clear with a logo set', function (done) {
+                display._logo = { width: 4, height: 4, data: make_image_canvas(checked_data).toDataURL() };
+                display._drawCtx._act_drawImg = display._drawCtx.drawImage;
+                display._drawCtx.drawImage = function (img, x, y) {
+                    this._act_drawImg(img, x, y);
+                    expect(display).to.have.displayed(checked_data);
+                    done();
+                };
+                display.clear();
+                expect(display._fb_width).to.equal(4);
+                expect(display._fb_height).to.equal(4);
+            });
+
+            it('should support filling a rectangle with particular color via #fillRect', function () {
+                display.fillRect(0, 0, 4, 4, [0, 0xff, 0]);
+                display.fillRect(0, 0, 2, 2, [0xff, 0, 0]);
+                display.fillRect(2, 2, 2, 2, [0xff, 0, 0]);
+                expect(display).to.have.displayed(checked_data);
+            });
+
+            it('should support copying an portion of the canvas via #copyImage', function () {
+                display.fillRect(0, 0, 4, 4, [0, 0xff, 0]);
+                display.fillRect(0, 0, 2, 2, [0xff, 0, 0x00]);
+                display.copyImage(0, 0, 2, 2, 2, 2);
+                expect(display).to.have.displayed(checked_data);
+            });
+
+            it('should support drawing tile data with a background color and sub tiles', function () {
+                display.startTile(0, 0, 4, 4, [0, 0xff, 0]);
+                display.subTile(0, 0, 2, 2, [0xff, 0, 0]);
+                display.subTile(2, 2, 2, 2, [0xff, 0, 0]);
+                display.finishTile();
+                expect(display).to.have.displayed(checked_data);
+            });
+
+            it('should support drawing BGRX blit images with true color via #blitImage', function () {
+                var data = [];
+                for (var i = 0; i < 16; i++) {
+                    data[i * 4] = checked_data[i * 4 + 2];
+                    data[i * 4 + 1] = checked_data[i * 4 + 1];
+                    data[i * 4 + 2] = checked_data[i * 4];
+                    data[i * 4 + 3] = checked_data[i * 4 + 3];
+                }
+                display.blitImage(0, 0, 4, 4, data, 0);
+                expect(display).to.have.displayed(checked_data);
+            });
+
+            it('should support drawing RGB blit images with true color via #blitRgbImage', function () {
+                var data = [];
+                for (var i = 0; i < 16; i++) {
+                    data[i * 3] = checked_data[i * 4];
+                    data[i * 3 + 1] = checked_data[i * 4 + 1];
+                    data[i * 3 + 2] = checked_data[i * 4 + 2];
+                }
+                display.blitRgbImage(0, 0, 4, 4, data, 0);
+                expect(display).to.have.displayed(checked_data);
+            });
+
+            it('should support drawing blit images from a data URL via #blitStringImage', function (done) {
+                var img_url = make_image_canvas(checked_data).toDataURL();
+                display._drawCtx._act_drawImg = display._drawCtx.drawImage;
+                display._drawCtx.drawImage = function (img, x, y) {
+                    this._act_drawImg(img, x, y);
+                    expect(display).to.have.displayed(checked_data);
+                    done();
+                };
+                display.blitStringImage(img_url, 0, 0);
+            });
+
+            it('should support drawing solid colors with color maps', function () {
+                display._true_color = false;
+                display.set_colourMap({ 0: [0xff, 0, 0], 1: [0, 0xff, 0] });
+                display.fillRect(0, 0, 4, 4, [1]);
+                display.fillRect(0, 0, 2, 2, [0]);
+                display.fillRect(2, 2, 2, 2, [0]);
+                expect(display).to.have.displayed(checked_data);
+            });
+
+            it('should support drawing blit images with color maps', function () {
+                display._true_color = false;
+                display.set_colourMap({ 1: [0xff, 0, 0], 0: [0, 0xff, 0] });
+                var data = [1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1].map(function (elem) { return [elem]; });
+                display.blitImage(0, 0, 4, 4, data, 0);
+                expect(display).to.have.displayed(checked_data);
+            });
+
+            it('should support drawing an image object via #drawImage', function () {
+                var img = make_image_canvas(checked_data);
+                display.drawImage(img, 0, 0);
+                expect(display).to.have.displayed(checked_data);
+            });
+        }
+
+        describe('(prefering native methods)', function () { drawing_tests.call(this, false); });
+        describe('(prefering JavaScript)', function () { drawing_tests.call(this, true); });
+    });
+
+    describe('the render queue processor', function () {
+        var display;
+        beforeEach(function () {
+            display = new Display({ target: document.createElement('canvas'), prefer_js: false });
+            display.resize(4, 4);
+            sinon.spy(display, '_scan_renderQ');
+            this.old_requestAnimFrame = window.requestAnimFrame;
+            window.requestAnimFrame = function (cb) {
+                this.next_frame_cb = cb;
+            }.bind(this);
+            this.next_frame = function () { this.next_frame_cb(); };
+        });
+
+        afterEach(function () {
+            window.requestAnimFrame = this.old_requestAnimFrame;
+        });
+
+        it('should try to process an item when it is pushed on, if nothing else is on the queue', function () {
+            display.renderQ_push({ type: 'noop' });  // does nothing
+            expect(display._scan_renderQ).to.have.been.calledOnce;
+        });
+
+        it('should not try to process an item when it is pushed on if we are waiting for other items', function () {
+            display._renderQ.length = 2;
+            display.renderQ_push({ type: 'noop' });
+            expect(display._scan_renderQ).to.not.have.been.called;
+        });
+
+        it('should wait until an image is loaded to attempt to draw it and the rest of the queue', function () {
+            var img = { complete: false };
+            display._renderQ = [{ type: 'img', x: 3, y: 4, img: img },
+                                { type: 'fill', x: 1, y: 2, width: 3, height: 4, color: 5 }];
+            display.drawImage = sinon.spy();
+            display.fillRect = sinon.spy();
+
+            display._scan_renderQ();
+            expect(display.drawImage).to.not.have.been.called;
+            expect(display.fillRect).to.not.have.been.called;
+
+            display._renderQ[0].img.complete = true;
+            this.next_frame();
+            expect(display.drawImage).to.have.been.calledOnce;
+            expect(display.fillRect).to.have.been.calledOnce;
+        });
+
+        it('should draw a blit image on type "blit"', function () {
+            display.blitImage = sinon.spy();
+            display.renderQ_push({ type: 'blit', x: 3, y: 4, width: 5, height: 6, data: [7, 8, 9] });
+            expect(display.blitImage).to.have.been.calledOnce;
+            expect(display.blitImage).to.have.been.calledWith(3, 4, 5, 6, [7, 8, 9], 0);
+        });
+
+        it('should draw a blit RGB image on type "blitRgb"', function () {
+            display.blitRgbImage = sinon.spy();
+            display.renderQ_push({ type: 'blitRgb', x: 3, y: 4, width: 5, height: 6, data: [7, 8, 9] });
+            expect(display.blitRgbImage).to.have.been.calledOnce;
+            expect(display.blitRgbImage).to.have.been.calledWith(3, 4, 5, 6, [7, 8, 9], 0);
+        });
+
+        it('should copy a region on type "copy"', function () {
+            display.copyImage = sinon.spy();
+            display.renderQ_push({ type: 'copy', x: 3, y: 4, width: 5, height: 6, old_x: 7, old_y: 8 });
+            expect(display.copyImage).to.have.been.calledOnce;
+            expect(display.copyImage).to.have.been.calledWith(7, 8, 3, 4, 5, 6);
+        });
+
+        it('should fill a rect with a given color on type "fill"', function () {
+            display.fillRect = sinon.spy();
+            display.renderQ_push({ type: 'fill', x: 3, y: 4, width: 5, height: 6, color: [7, 8, 9]});
+            expect(display.fillRect).to.have.been.calledOnce;
+            expect(display.fillRect).to.have.been.calledWith(3, 4, 5, 6, [7, 8, 9]);
+        });
+
+        it('should draw an image from an image object on type "img" (if complete)', function () {
+            display.drawImage = sinon.spy();
+            display.renderQ_push({ type: 'img', x: 3, y: 4, img: { complete: true } });
+            expect(display.drawImage).to.have.been.calledOnce;
+            expect(display.drawImage).to.have.been.calledWith({ complete: true }, 3, 4);
+        });
+    });
+});