diff --git a/.gitignore b/.gitignore index c40cd56..a349cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ .svn node_modules -jquery-turtle.min.js diff --git a/.travis.yml b/.travis.yml index 35048b6..cf003a3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ language: node_js node_js: - '0.10' +install: PHANTOMJS_CDNURL=http://cnpmjs.org/downloads npm install diff --git a/BUILD.txt b/BUILD.txt index 6208a91..c22f4af 100644 --- a/BUILD.txt +++ b/BUILD.txt @@ -29,5 +29,3 @@ Testing Unit tests use a headless webkit. When setting it up on a system without GUI, you may find that you need to install font support. On debian. "apt-get libfontconfig1" provides enough font support. - - diff --git a/Gruntfile.js b/Gruntfile.js index c0e8f94..12f17ea 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,3 +1,13 @@ +var serveNoDottedFiles = function(connect, options, middlewares) { + // Avoid leaking .git/.svn or other dotted files from test servers. + middlewares.unshift(function(req, res, next) { + if (req.url.indexOf('/.') < 0) { return next(); } + res.statusCode = 404 + res.end("Cannot GET " + req.url) + }); + return middlewares; +}; + module.exports = function(grunt) { "use strict"; @@ -21,7 +31,8 @@ module.exports = function(grunt) { connect: { testserver: { options: { - hostname: '0.0.0.0' + hostname: '0.0.0.0', + middleware: serveNoDottedFiles } } }, @@ -42,7 +53,8 @@ module.exports = function(grunt) { qunit: { all: ["test/*.html"], options: { - timeout: 100000 + timeout: 100000, + noGlobals: true } }, release: { diff --git a/LICENSE.txt b/LICENSE.txt index fca7187..f997494 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -2,7 +2,7 @@ jQuery-turtle version 2.0 LICENSE (MIT): -Copyright (c) 2013 Pencil Code Foundation, Google, and other contributors. +Copyright (c) 2013 Pencil Code Foundation, Google Inc., and other contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 91bace6..fe5db8e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ jQuery-turtle ============= -version 2.0.8 +version 2.0.9 jQuery-turtle is a jQuery plugin for turtle graphics. @@ -254,7 +254,7 @@ the functions:
 eval $.turtle()  # Create the default turtle and global functions.
 
-defaultspeed Infinity
+speed Infinity
 write "Catch blue before red gets you."
 bk 100
 r = hatch red
@@ -264,7 +264,7 @@ tick 10, ->
   fd 6
   r.turnto turtle
   r.fd 4
-  b.turnto bearing b
+  b.turnto direction b
   b.fd 3
   if b.touches(turtle)
     write "You win!"
@@ -331,7 +331,7 @@ element.
 License (MIT)
 -------------
 
-Copyright (c) 2014 Pencil Code, Google, and other contributors
+Copyright (c) 2014 Pencil Code, Google Inc., and other contributors
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/bower.json b/bower.json
index f2e71d7..4eca85a 100644
--- a/bower.json
+++ b/bower.json
@@ -2,7 +2,7 @@
   "name": "jquery-turtle",
   "main": "jquery-turtle.js",
   "ignore": [],
-  "version": "2.0.8",
+  "version": "2.0.9",
   "description": "Turtle graphics plugin for jQuery.",
   "devDependencies": {
     "jquery": "latest",
diff --git a/jquery-turtle.js b/jquery-turtle.js
index 3613692..9008141 100644
--- a/jquery-turtle.js
+++ b/jquery-turtle.js
@@ -4,11 +4,12 @@
 jQuery-turtle
 =============
 
-version 2.0.8
+version 2.0.9
 
 jQuery-turtle is a jQuery plugin for turtle graphics.
 
 With jQuery-turtle, every DOM element is a turtle that can be
+
 moved using turtle graphics methods like fd (forward), bk (back),
 rt (right turn), and lt (left turn).  The pen function allows
 a turtle to draw on a full-document canvas as it moves.
@@ -61,8 +62,8 @@ $(q).fd(100)      // Forward relative motion in local coordinates.
 $(q).bk(50)       // Back.
 $(q).rt(90)       // Right turn.  Optional second arg is turning radius.
 $(q).lt(45)       // Left turn.  Optional second arg is turning radius.
-$(q).slide(x, y)  // Slide right by x while sliding forward by y.
-$(q).jump(x, y)   // Like slide, but without drawing.
+$(q).slide(x, y)  // Move right by x while moving forward by y.
+$(q).leap(x, y)   // Like slide, but without drawing.
 $(q).moveto({pageX:x,pageY:y} | [x,y])  // Absolute motion on page.
 $(q).jumpto({pageX:x,pageY:y} | [x,y])  // Like moveto, without drawing.
 $(q).turnto(direction || position)      // Absolute direction adjustment.
@@ -216,6 +217,7 @@ $.turtle() are as follows:
 
 
 lastclick             // Event object of the last click event in the doc.
+lastdblclick          // The last double-click event.
 lastmousemove         // The last mousemove event.
 lastmouseup           // The last mouseup event.
 lastmousedown         // The last mousedown event.
@@ -231,6 +233,7 @@ timer(secs, fn)       // Calls back fn once after secs seconds.
 tick([perSec,] fn)    // Repeatedly calls fn at the given rate (null clears).
 done(fn)              // Calls back fn after all turtle animation is complete.
 random(n)             // Returns a random number [0..n-1].
+random(n,m)           // Returns a random number [n..m-1].
 random(list)          // Returns a random element of the list.
 random('normal')      // Returns a gaussian random (mean 0 stdev 1).
 random('uniform')     // Returns a uniform random [0...1).
@@ -258,7 +261,7 @@ the functions:
 
 eval $.turtle()  # Create the default turtle and global functions.
 
-defaultspeed Infinity
+speed Infinity
 write "Catch blue before red gets you."
 bk 100
 r = new Turtle red
@@ -365,11 +368,28 @@ THE SOFTWARE.
 //////////////////////////////////////////////////////////////////////////
 
 var undefined = void 0,
+    global = this,
     __hasProp = {}.hasOwnProperty,
     rootjQuery = jQuery(function() {}),
     interrupted = false,
     async_pending = 0,
-    global_plan_counter = 0;
+    global_plan_depth = 0,
+    global_plan_queue = [];
+
+function nonrecursive_dequeue(elem, qname) {
+  if (global_plan_depth > 5) {
+    global_plan_queue.push({elem: elem, qname: qname});
+  } else {
+    global_plan_depth += 1;
+    $.dequeue(elem, qname);
+    while (global_plan_queue.length > 0) {
+      var task = global_plan_queue.shift();
+      $.dequeue(task.elem, task.qname);
+      checkForHungLoop()
+    }
+    global_plan_depth -= 1;
+  }
+}
 
 function __extends(child, parent) {
   for (var key in parent) {
@@ -427,13 +447,15 @@ if (!transform || !hasGetBoundingClientRect()) {
 // and that first value will be interpreted as defaultProp:value1.
 // Some rudimentary quoting can be done, e.g., value:"prop", etc.
 function parseOptionString(str, defaultProp) {
-  if (str == null) {
-    return {};
-  }
-  if ($.isPlainObject(str)) {
-    return str;
+  if (typeof(str) != 'string') {
+    if (str == null) {
+      return {};
+    }
+    if ($.isPlainObject(str)) {
+      return str;
+    }
+    str = '' + str;
   }
-  str = '' + str;
   // Each token is an identifier, a quoted or parenthesized string,
   // a run of whitespace, or any other non-matching character.
   var token = str.match(/[-a-zA-Z_][-\w]*|"[^"]*"|'[^']'|\([^()]*\)|\s+|./g),
@@ -731,8 +753,8 @@ function getElementTranslation(elem) {
 
 // Reads out the 2x3 transform matrix of the given element.
 function readTransformMatrix(elem) {
-  var ts = (window.getComputedStyle ?
-      window.getComputedStyle(elem)[transform] :
+  var ts = (global.getComputedStyle ?
+      global.getComputedStyle(elem)[transform] :
       $.css(elem, 'transform'));
   if (!ts || ts === 'none') {
     return null;
@@ -750,7 +772,7 @@ function readTransformMatrix(elem) {
 // Reads out the css transformOrigin property, if present.
 function readTransformOrigin(elem, wh) {
   var hidden = ($.css(elem, 'display') === 'none'),
-      swapout, old, name, gcs, origin;
+      swapout, old, name;
   if (hidden) {
     // IE GetComputedStyle doesn't give pixel values for transformOrigin
     // unless the element is unhidden.
@@ -761,13 +783,13 @@ function readTransformOrigin(elem, wh) {
       elem.style[name] = swapout[name];
     }
   }
-  var gcs = (window.getComputedStyle ?  window.getComputedStyle(elem) : null),
-      origin = (gcs && gcs[transformOrigin] || $.css(elem, 'transformOrigin'));
+  var gcs = (global.getComputedStyle ?  global.getComputedStyle(elem) : null);
   if (hidden) {
     for (name in swapout) {
       elem.style[name] = old[name];
     }
   }
+  var origin = (gcs && gcs[transformOrigin] || $.css(elem, 'transformOrigin'));
   if (origin && origin.indexOf('%') < 0) {
     return $.map(origin.split(' '), parseFloat);
   }
@@ -929,7 +951,8 @@ function cleanedStyle(trans) {
 // center of rotation when no transforms are applied) in page coordinates.
 function getTurtleOrigin(elem, inverseParent, extra) {
   var state = $.data(elem, 'turtleData');
-  if (state && state.quickhomeorigin && state.down && state.style && !extra) {
+  if (state && state.quickhomeorigin && state.down && state.style && !extra
+      && elem.classList && elem.classList.contains('turtle')) {
     return state.quickhomeorigin;
   }
   var hidden = ($.css(elem, 'display') === 'none'),
@@ -937,7 +960,7 @@ function getTurtleOrigin(elem, inverseParent, extra) {
         { position: "absolute", visibility: "hidden", display: "block" } : {},
       substTransform = swapout[transform] = (inverseParent ? 'matrix(' +
           $.map(inverseParent, cssNum).join(', ') + ', 0, 0)' : 'none'),
-      old = {}, name, gbcr, transformOrigin, result;
+      old = {}, name, gbcr, transformOrigin;
   for (name in swapout) {
     old[name] = elem.style[name];
     elem.style[name] = swapout[name];
@@ -958,23 +981,14 @@ function getTurtleOrigin(elem, inverseParent, extra) {
   return result;
 }
 
-function unattached(elt) {
-  // Unattached if not part of a document.
-  while (elt) {
-    if (elt.nodeType === 9) return false;
-    elt = elt.parentNode;
-  }
-  return true;
-}
-
 function wh() {
   // Quirks-mode compatible window height.
-  return window.innerHeight || $(window).height();
+  return global.innerHeight || $(global).height();
 }
 
 function ww() {
   // Quirks-mode compatible window width.
-  return window.innerWidth || $(window).width();
+  return global.innerWidth || $(global).width();
 }
 
 function dh() {
@@ -985,6 +999,10 @@ function dw() {
   return document.body ? $(document).width() : document.width;
 }
 
+function invisible(elem) {
+  return elem.offsetHeight <= 0 && elem.offsetWidth <= 0;
+}
+
 function makeGbcrLTWH(left, top, width, height) {
   return {
     left: left, top: top, right: left + width, bottom: top + height,
@@ -997,7 +1015,7 @@ function getPageGbcr(elem) {
     return makeGbcrLTWH(elem.pageX, elem.pageY, 0, 0);
   } else if ($.isWindow(elem)) {
     return makeGbcrLTWH(
-        $(window).scrollLeft(), $(window).scrollTop(), ww(), wh());
+        $(global).scrollLeft(), $(global).scrollTop(), ww(), wh());
   } else if (elem.nodeType === 9) {
     return makeGbcrLTWH(0, 0, dw(), dh());
   } else if (!('getBoundingClientRect' in elem)) {
@@ -1040,23 +1058,11 @@ function polyMatchesGbcr(poly, gbcr) {
 
 function readPageGbcr() {
   var raw = this.getBoundingClientRect();
-  if (raw.width === 0 && raw.height === 0 &&
-     raw.top === 0 && raw.left === 0 && unattached(this)) {
-    // Prentend unattached images have a size.
-    return {
-      top: 0,
-      bottom: this.height || 0,
-      left: 0,
-      right: this.width || 0,
-      width: this.width || 0,
-      height: this.height || 0
-    }
-  }
   return {
-    top: raw.top + window.pageYOffset,
-    bottom: raw.bottom + window.pageYOffset,
-    left: raw.left + window.pageXOffset,
-    right: raw.right + window.pageXOffset,
+    top: raw.top + global.pageYOffset,
+    bottom: raw.bottom + global.pageYOffset,
+    left: raw.left + global.pageXOffset,
+    right: raw.right + global.pageXOffset,
     width: raw.width,
     height: raw.height
   };
@@ -1084,8 +1090,7 @@ function computeTargetAsTurtlePosition(elem, target, limit, localx, localy) {
   localTarget = matrixVectorProduct(inverseParent,
       subtractVector([target.pageX, target.pageY], origin));
   if (localx || localy) {
-    var ts = readTurtleTransform(elem, true),
-        sy = ts ? ts.sy : 1;
+    var sy = elemOldScale(elem);
     localTarget[0] += localx * sy;
     localTarget[1] -= localy * sy;
   }
@@ -1113,7 +1118,7 @@ function computePositionAsLocalOffset(elem, home) {
       ts = readTurtleTransform(elem, true),
       localHome = inverseParent && matrixVectorProduct(inverseParent,
           subtractVector([home.pageX, home.pageY], origin)),
-      isy = ts && 1 / ts.sy;
+      isy = 1 / elemOldScale(elem);
   if (!inverseParent) { return; }
   return [(ts.tx - localHome[0]) * isy, (localHome[1] - ts.ty) * isy];
 }
@@ -1122,11 +1127,12 @@ function convertLocalXyToPageCoordinates(elem, localxy) {
   var totalParentTransform = totalTransform2x2(elem.parentElement),
       ts = readTurtleTransform(elem, true),
       center = $(homeContainer(elem)).pagexy(),
+      sy = elemOldScale(elem),
       result = [],
       pageOffset, j;
   for (j = 0; j < localxy.length; j++) {
     pageOffset = matrixVectorProduct(
-        totalParentTransform, [localxy[j][0] * ts.sy, -localxy[j][1] * ts.sy]);
+        totalParentTransform, [localxy[j][0] * sy, -localxy[j][1] * sy]);
     result.push({ pageX: center.pageX + pageOffset[0],
                   pageY: center.pageY + pageOffset[1] });
   }
@@ -1141,7 +1147,7 @@ function convertLocalXyToPageCoordinates(elem, localxy) {
 function getCenterInPageCoordinates(elem) {
   if ($.isWindow(elem)) {
     return getRoundedCenterLTWH(
-        $(window).scrollLeft(), $(window).scrollTop(), ww(), wh());
+        $(global).scrollLeft(), $(global).scrollTop(), ww(), wh());
   } else if (elem.nodeType === 9 || elem == document.body) {
     return getRoundedCenterLTWH(0, 0, dw(), dh());
   }
@@ -1156,12 +1162,28 @@ function getCenterInPageCoordinates(elem) {
       origin = getTurtleOrigin(elem, inverseParent),
       pos = addVector(matrixVectorProduct(totalParentTransform, tr), origin),
       result = { pageX: pos[0], pageY: pos[1] };
-  if (state && simple && state.down && state.style) {
+  if (state && simple && state.down && state.style && elem.classList &&
+      elem.classList.contains('turtle')) {
     state.quickpagexy = result;
   }
   return result;
 }
 
+// The quickpagexy variable is an optimization that assumes
+// parent coordinates do not change.  This function will clear
+// the cache, and is used when we have a container that is moving.
+function clearChildQuickLocations(elem) {
+  if (elem.tagName != 'CANVAS' && elem.tagName != 'IMG') {
+    $(elem).find('.turtle').each(function(j, e) {
+      var s = $.data(e, 'turtleData');
+      if (s) {
+        s.quickpagexy = null;
+        s.quickhomeorigin = null;
+      }
+    });
+  }
+}
+
 function polyToVectorsOffset(poly, offset) {
   if (!poly) { return null; }
   var result = [], j = 0;
@@ -1176,7 +1198,7 @@ function polyToVectorsOffset(poly, offset) {
 function getCornersInPageCoordinates(elem, untransformed) {
   if ($.isWindow(elem)) {
     return getStraightRectLTWH(
-        $(window).scrollLeft(), $(window).scrollTop(), ww(), wh());
+        $(global).scrollLeft(), $(global).scrollTop(), ww(), wh());
   } else if (elem.nodeType === 9) {
     return getStraightRectLTWH(0, 0, dw(), dh());
   }
@@ -1223,12 +1245,12 @@ function scrollWindowToDocumentPosition(pos, limit) {
       b = $('body'),
       dw = b.width(),
       dh = b.height(),
-      w = $(window);
+      w = $(global);
   if (tx > dw - ww2) { tx = dw - ww2; }
   if (tx < ww2) { tx = ww2; }
   if (ty > dh - wh2) { ty = dh - wh2; }
   if (ty < wh2) { ty = wh2; }
-  targ = { pageX: tx, pageY: ty };
+  var targ = { pageX: tx, pageY: ty };
   if ($.isNumeric(limit)) {
     targ = limitMovement(w.origin(), targ, limit);
   }
@@ -1366,6 +1388,7 @@ function convexHull(points) {
 
 function parseTurtleHull(text) {
   if (!text) return null;
+  if ($.isArray(text)) return text;
   var nums = $.map(text.trim().split(/\s+/), parseFloat), points = [], j = 0;
   while (j + 1 < nums.length) {
     points.push({ pageX: nums[j], pageY: nums[j + 1] });
@@ -1450,7 +1473,7 @@ function readTurtleTransform(elem, computed) {
 
 function cssNum(n) {
   var r = n.toString();
-  if (r.indexOf('e') >= 0) {
+  if (~r.indexOf('e')) {
     r = Number(n).toFixed(17);
   }
   return r;
@@ -1481,6 +1504,8 @@ function writeTurtleTransform(ts) {
   return result.join(' ');
 }
 
+function modulo(n, m) { return (+n % (m = +m) + m) % m; }
+
 function radiansToDegrees(r) {
   var d = r * 180 / Math.PI;
   if (d > 180) { d -= 360; }
@@ -1488,7 +1513,7 @@ function radiansToDegrees(r) {
 }
 
 function convertToRadians(d) {
-  return d * Math.PI / 180;
+  return d / 180 * Math.PI;
 }
 
 function normalizeRotation(x) {
@@ -1521,6 +1546,8 @@ var globalDrawing = {
   ctx: null,
   canvas: null,
   timer: null,
+  fieldMouse: false,  // if a child of the field listens to mouse events.
+  fieldHook: false,   // once the body-event-forwarding logic is set up.
   subpixel: 1
 };
 
@@ -1558,50 +1585,43 @@ function createSurfaceAndField() {
       // fixes a "center" point in page coordinates that
       // will not change even if the document resizes.
       transformOrigin: cw + "px " + ch + "px",
+      pointerEvents: 'none',
       overflow: 'hidden'
-    });
-  $(field).attr('id', 'field')
+    }).addClass('turtlefield');
+  $(field).attr('id', 'origin')
     .css({
       position: 'absolute',
       display: 'inline-block',
-      top: ch, left: cw, width: '100%', height: '100%',
+      // Setting with to 100% allows label text to not wrap.
+      top: ch, left: cw, width: '100%', height: '0',
       font: 'inherit',
       // Setting transform origin for the turtle field
       // fixes a "center" point in page coordinates that
       // will not change even if the document resizes.
       transformOrigin: "0px 0px",
+      pointerEvents: 'all',
+      // Setting turtleSpeed to Infinity by default allows
+      // moving the origin instantly without sync.
+      turtleSpeed: Infinity
     }).appendTo(surface);
   globalDrawing.surface = surface;
   globalDrawing.field = field;
   attachClipSurface();
+  // Now that we have a surface, the upward-center cartesian coordinate
+  // system based on that exists, so we can hook mouse events to add x, y.
+  addMouseEventHooks();
 }
 
 function attachClipSurface() {
   if (document.body) {
+    if ($('html').attr('style') == null) {
+      // This prevents the body from shrinking.
+      $('html').css('min-height', '100%');
+    }
     $(globalDrawing.surface).prependTo('body');
     // Attach an event handler to forward mouse events from the body
     // to turtles in the turtle field layer.
-    $('body').on('click.turtle ' +
-      'mouseup.turtle mousedown.turtle mousemove.turtle', function(e) {
-      if (e.target === this && !e.isTrigger) {
-        // Only forward events directly on the body that (geometrically)
-        // touch a turtle directly within the turtlefield.
-        var warn = $.turtle.nowarn;
-        $.turtle.nowarn = true;
-        var sel = $(globalDrawing.surface)
-            .find('.turtle').within('touch', e).eq(0);
-        $.turtle.nowarn = warn;
-        if (sel.length === 1) {
-          // Erase portions of the event that are wrong for the turtle.
-          e.target = null;
-          e.relatedTarget = null;
-          e.fromElement = null;
-          e.toElement = null;
-          sel.trigger(e);
-          return false;
-        }
-      }
-    });
+    forwardBodyMouseEventsIfNeeded();
   } else {
     $(document).ready(attachClipSurface);
   }
@@ -1637,7 +1657,7 @@ function getTurtleDrawingCanvas() {
   surface.insertBefore(globalDrawing.canvas, surface.firstChild);
   resizecanvas();
   pollbodysize(resizecanvas);
-  $(window).resize(resizecanvas);
+  $(global).resize(resizecanvas);
   return globalDrawing.canvas;
 }
 
@@ -1685,8 +1705,8 @@ function sizexy() {
   // Using innerHeight || $(window).height() deals with quirks-mode.
   var b = $('body');
   return [
-    Math.max(b.outerWidth(true), window.innerWidth || $(window).width()),
-    Math.max(b.outerHeight(true), window.innerHeight || $(window).height())
+    Math.max(b.outerWidth(true), global.innerWidth || $(global).width()),
+    Math.max(b.outerHeight(true), global.innerHeight || $(global).height())
   ];
 }
 
@@ -1769,14 +1789,16 @@ function getTurtleData(elem) {
   if (!state) {
     state = $.data(elem, 'turtleData', {
       style: null,
+      corners: [[]],
       path: [[]],
-      down: true,
+      down: false,
       speed: 'turtle',
       easing: 'swing',
       turningRadius: 0,
       drawOnCanvas: null,
       quickpagexy: null,
       quickhomeorigin: null,
+      oldscale: 1,
       instrument: null,
       stream: null
     });
@@ -1822,10 +1844,16 @@ function makePenStyleHook() {
       return writePenStyle(getTurtleData(elem).style);
     },
     set: function(elem, value) {
-      var style = parsePenStyle(value, 'strokeStyle');
-      getTurtleData(elem).style = style;
+      var style = parsePenStyle(value, 'strokeStyle'),
+          state = getTurtleData(elem);
+      if (state.style) {
+        // Switch to an empty pen first, to terminate paths.
+        state.style = null;
+        flushPenState(elem, state, true);
+      }
+      state.style = style;
       elem.style.turtlePenStyle = writePenStyle(style);
-      flushPenState(elem);
+      flushPenState(elem, state, true);
     }
   };
 }
@@ -1844,7 +1872,7 @@ function makePenDownHook() {
         state.quickpagexy = null;
         state.quickhomeorigin = null;
         elem.style.turtlePenDown = writePenDown(style);
-        flushPenState(elem);
+        flushPenState(elem, state, true);
       }
     }
   };
@@ -1855,6 +1883,11 @@ function isPointNearby(a, b) {
          Math.round(a.pageY - b.pageY) === 0;
 }
 
+function isPointVeryNearby(a, b) {
+  return Math.round(1000 * (a.pageX - b.pageX)) === 0 &&
+         Math.round(1000 * (a.pageY - b.pageY)) === 0;
+}
+
 function isBezierTiny(a, b) {
   return isPointNearby(a, b) &&
          Math.round(a.pageX - b.pageX1) === 0 &&
@@ -1875,13 +1908,18 @@ function applyPenStyle(ctx, ps, scale) {
   scale = scale || 1;
   var extraWidth = ps.eraseMode ? 1 : 0;
   if (!ps || !('strokeStyle' in ps)) { ctx.strokeStyle = 'black'; }
-  if (!ps || !('lineWidth' in ps)) { ctx.lineWidth = 1.62 * scale + extraWidth; }
+  if (!ps || !('lineWidth' in ps)) {
+    ctx.lineWidth = 1.62 * scale + extraWidth;
+  }
   if (!ps || !('lineCap' in ps)) { ctx.lineCap = 'round'; }
+  if (!ps || !('lineJoin' in ps)) { ctx.lineJoin = 'round'; }
   if (ps) {
     for (var a in ps) {
       if (a === 'savePath' || a === 'eraseMode') { continue; }
       if (scale && a === 'lineWidth') {
         ctx[a] = scale * ps[a] + extraWidth;
+      } else if (a === 'lineDash') {
+        ctx.setLineDash(('' + ps[a]).split(/[,\s]/g));
       } else {
         ctx[a] = ps[a];
       }
@@ -1939,7 +1977,9 @@ function setCanvasPageTransform(ctx, canvas) {
   }
 }
 
-function drawAndClearPath(drawOnCanvas, path, style, scale) {
+var buttOverlap = 0.67;
+
+function drawAndClearPath(drawOnCanvas, path, style, scale, truncateTo) {
   var ctx = drawOnCanvas.getContext('2d'),
       isClosed, skipLast,
       j = path.length,
@@ -1952,10 +1992,13 @@ function drawAndClearPath(drawOnCanvas, path, style, scale) {
   while (j--) {
     if (path[j].length > 1) {
       segment = path[j];
-      isClosed = segment.length > 2 && isPointNearby(
-          segment[0], segment[segment.length - 1]);
+      isClosed = segment.length > 2 &&
+          isPointNearby(segment[0], segment[segment.length - 1]) &&
+          !isPointNearby(segment[0], segment[Math.floor(segment.length / 2)]);
       skipLast = isClosed && (!('pageX2' in segment[segment.length - 1]));
-      ctx.moveTo(segment[0].pageX, segment[0].pageY);
+      var startx = segment[0].pageX;
+      var starty = segment[0].pageY;
+      ctx.moveTo(startx, starty);
       for (var k = 1; k < segment.length - (skipLast ? 1 : 0); ++k) {
         if ('pageX2' in segment[k] &&
             !isBezierTiny(segment[k - 1], segment[k])) {
@@ -1974,7 +2017,7 @@ function drawAndClearPath(drawOnCanvas, path, style, scale) {
   if ('strokeStyle' in style) { ctx.stroke(); }
   ctx.restore();
   path.length = 1;
-  path[0].splice(0, path[0].length - 1);
+  path[0].splice(0, Math.max(0, path[0].length - truncateTo));
 }
 
 function addBezierToPath(path, start, triples) {
@@ -1989,38 +2032,70 @@ function addBezierToPath(path, start, triples) {
   }
 }
 
-function flushPenState(elem) {
-  var state = getTurtleData(elem);
-  if (!state.style || (!state.down && !state.style.savePath)) {
-    if (state.path.length > 1) { state.path.length = 1; }
-    if (state.path[0].length) { state.path[0].length = 0; }
+function addToPathList(pathList, point) {
+  if (pathList.length &&
+      (point.corner ? isPointVeryNearby(point, pathList[pathList.length - 1]) :
+                      isPointNearby(point, pathList[pathList.length - 1]))) {
+    return;
+  }
+  pathList.push(point);
+}
+
+function flushPenState(elem, state, corner) {
+  clearChildQuickLocations(elem);
+  if (!state) {
+    // Default is no pen and no path, so nothing to do.
     return;
   }
-  if (!state.down) {
-    // Penup when saving path will start a new segment if one isn't started.
-    if (state.path.length && state.path[0].length) {
-      state.path.unshift([]);
+  var path = state.path,
+      style = state.style,
+      corners = state.corners;
+  if (!style || !state.down) {
+    // pen up or pen null will clear the tracing path.
+    if (path.length > 1) { path.length = 1; }
+    if (path[0].length) { path[0].length = 0; }
+    if (corner) {
+      if (!style) {
+        // pen null will clear the retracing path too.
+        if (corners.length > 1) corners.length = 1;
+        if (corners[0].length) corners[0].length = 0;
+      } else {
+        // pen up with a non-null pen will start a new discontinuous segment.
+        if (corners.length && corners[0].length) {
+          if (corners[0].length == 1) {
+            corners[0].length = 0;
+          } else {
+            corners.unshift([]);
+          }
+        }
+      }
     }
     return;
   }
+  if (!corner && style.savePath) return;
+  // Accumulate retracing path using only corners.
   var center = getCenterInPageCoordinates(elem);
-  if (!state.path[0].length ||
-      !isPointNearby(center, state.path[0][state.path[0].length - 1])) {
-    state.path[0].push(center);
-  }
-  if (!state.style.savePath) {
-    var ts = readTurtleTransform(elem, true);
-    drawAndClearPath(getDrawOnCanvas(state), state.path, state.style, ts.sx);
+  if (corner) {
+    center.corner = true;
+    addToPathList(corners[0], center);
   }
+  if (style.savePath) return;
+  // Add to tracing path, and trace it right away.
+  addToPathList(path[0], center);
+  var scale = drawingScale(elem);
+  // Last argument 2 means that the last two points are saved, which
+  // allows us to draw corner miters and also avoid 'butt' lineCap gaps.
+  drawAndClearPath(getDrawOnCanvas(state), state.path, style, scale, 2);
 }
 
 function endAndFillPenPath(elem, style) {
-  var ts = readTurtleTransform(elem, true),
-      state = getTurtleData(elem);
-  drawAndClearPath(getDrawOnCanvas(state), state.path, style);
-  if (state.style && state.style.savePath) {
-    $.style(elem, 'turtlePenStyle', 'none');
+  var state = getTurtleData(elem);
+  if (state.style) {
+    // Apply a default style.
+    style = $.extend({}, state.style, style);
   }
+  var scale = drawingScale(elem);
+  drawAndClearPath(getDrawOnCanvas(state), state.corners, style, scale, 1);
 }
 
 function clearField(arg) {
@@ -2034,21 +2109,24 @@ function clearField(arg) {
   }
   if (!arg || /\bturtles\b/.test(arg)) {
     if (globalDrawing.surface) {
-      var sel = $(globalDrawing.surface).find('.turtle');
+      var sel = $(globalDrawing.surface).find('.turtle').not('.turtlefield');
       if (global_turtle) {
         sel = sel.not(global_turtle);
       }
       sel.remove();
     }
   }
-  if (!arg || /\btext\b/.test(arg)) {
-    // "turtlefield" is a CSS class to use to mark top-level
-    // elements that should not be deleted by clearscreen.
-    var keep = $('.turtlefield');
+  if (!arg || /\blabels\b/.test(arg)) {
     if (globalDrawing.surface) {
-      keep = keep.add(globalDrawing.surface);
+      var sel = $(globalDrawing.surface).find('.turtlelabel')
+                .not('.turtlefield');
+      sel.remove();
     }
-    $('body').contents().not(keep).remove();
+  }
+  if (!arg || /\btext\b/.test(arg)) {
+    // "turtlefield" is a CSS class to use to mark elements that
+    // should not be deleted by clearscreen.
+    $('body').contents().not('.turtlefield').remove();
   }
 }
 
@@ -2100,8 +2178,7 @@ function touchesPixel(elem, color) {
       h = (bb.bottom - bb.top),
       osc = getOffscreenCanvas(w, h),
       octx = osc.getContext('2d'),
-      rgba = rgbaForColor(color),
-      j = 1, k, data;
+      j = 1, k;
   if (!c || c.length < 3 || !w || !h) { return false; }
   octx.drawImage(canvas,
       bb.left, bb.top, w, h, 0, 0, w, h);
@@ -2129,7 +2206,7 @@ function touchesPixel(elem, color) {
   }
   octx.restore();
   // Now examine the results and look for alpha > 0%.
-  data = octx.getImageData(0, 0, w, h).data;
+  var data = octx.getImageData(0, 0, w, h).data;
   if (!rgba || rgba[3] == 0) {
     // Handle the "looking for any color" and "transparent" cases.
     var wantcolor = !rgba;
@@ -2156,13 +2233,14 @@ function touchesPixel(elem, color) {
 // Functions in direct support of exported methods.
 //////////////////////////////////////////////////////////////////////////
 
-function applyImg(sel, img) {
+function applyImg(sel, img, cb) {
   if (img.img) {
     if (sel[0].tagName == 'CANVAS' || sel[0].tagName == img.img.tagName) {
       applyLoadedImage(img.img, sel[0], img.css);
     }
   } else if (sel[0].tagName == 'IMG' || sel[0].tagName == 'CANVAS') {
-    setImageWithStableOrigin(sel[0], img.url, img.css);
+    setImageWithStableOrigin(sel[0], img.url, img.css, cb);
+    cb = null;
   } else {
     var props = {
       backgroundImage: 'url(' + img.url + ')',
@@ -2174,13 +2252,17 @@ function applyImg(sel, img) {
     }
     sel.css(props);
   }
+  if (cb) {
+    cb();
+  }
 }
 
 function doQuickMove(elem, distance, sideways) {
   var ts = readTurtleTransform(elem, true),
       r = ts && convertToRadians(ts.rot),
-      scaledDistance = ts && (distance * ts.sy),
-      scaledSideways = ts && ((sideways || 0) * ts.sy),
+      sy = elemOldScale(elem),
+      scaledDistance = ts && (distance * sy),
+      scaledSideways = ts && ((sideways || 0) * sy),
       dy = -Math.cos(r) * scaledDistance,
       dx = Math.sin(r) * scaledDistance,
       state = $.data(elem, 'turtleData'),
@@ -2199,7 +2281,7 @@ function doQuickMove(elem, distance, sideways) {
   ts.tx += dx;
   ts.ty += dy;
   elem.style[transform] = writeTurtleTransform(ts);
-  flushPenState(elem);
+  flushPenState(elem, state, true);
 }
 
 function doQuickMoveXY(elem, dx, dy) {
@@ -2216,7 +2298,7 @@ function doQuickMoveXY(elem, dx, dy) {
   ts.tx += dx;
   ts.ty -= dy;
   elem.style[transform] = writeTurtleTransform(ts);
-  flushPenState(elem);
+  flushPenState(elem, state, true);
 }
 
 function doQuickRotate(elem, degrees) {
@@ -2227,13 +2309,14 @@ function doQuickRotate(elem, degrees) {
 }
 
 function displacedPosition(elem, distance, sideways) {
-  var ts = readTurtleTransform(elem, true),
-      r = ts && convertToRadians(ts.rot),
-      scaledDistance = ts && (distance * ts.sy),
-      scaledSideways = ts && ((sideways || 0) * ts.sy),
+  var ts = readTurtleTransform(elem, true);
+  if (!ts) { return; }
+  var s = elemOldScale(elem),
+      r = convertToRadians(ts.rot),
+      scaledDistance = distance * s,
+      scaledSideways = (sideways || 0) * s,
       dy = -Math.cos(r) * scaledDistance,
       dx = Math.sin(r) * scaledDistance;
-  if (!ts) { return; }
   if (scaledSideways) {
     dy += Math.sin(r) * scaledSideways;
     dx += Math.cos(r) * scaledSideways;
@@ -2274,12 +2357,14 @@ function makeTurtleEasingHook() {
   }
 }
 
-function animTime(elem) {
+function animTime(elem, intick) {
   var state = $.data(elem, 'turtleData');
-  if (!state) return 'turtle';
+  intick = intick || insidetick;
+  if (!state) return (intick ? 0 : 'turtle');
   if ($.isNumeric(state.speed) || state.speed == 'Infinity') {
     return 1000 / state.speed;
   }
+  if (state.speed == 'turtle' && intick) return 0;
   return state.speed;
 }
 
@@ -2302,16 +2387,18 @@ function makeTurtleForwardHook() {
       if (ts) {
         var r = convertToRadians(ts.rot),
             c = Math.cos(r),
-            s = Math.sin(r);
+            s = Math.sin(r),
+            sy = elemOldScale(elem);
         return cssNum(((ts.tx + middle[0]) * s - (ts.ty + middle[1]) * c)
-            / ts.sy) + 'px';
+            / sy) + 'px';
       }
     },
     set: function(elem, value) {
       var ts = readTurtleTransform(elem, true) ||
               {tx: 0, ty: 0, rot: 0, sx: 1, sy: 1, twi: 0},
           middle = readTransformOrigin(elem),
-          v = parseFloat(value) * ts.sy,
+          sy = elemOldScale(elem),
+          v = parseFloat(value) * sy,
           r = convertToRadians(ts.rot),
           c = Math.cos(r),
           s = Math.sin(r),
@@ -2329,7 +2416,7 @@ function makeTurtleForwardHook() {
       ts.tx = ntx;
       ts.ty = nty;
       elem.style[transform] = writeTurtleTransform(ts);
-      flushPenState(elem);
+      flushPenState(elem, state);
     }
   };
 }
@@ -2356,82 +2443,129 @@ function makeTurtleHook(prop, normalize, unit, displace) {
             pageY: qpxy.pageY + (ts.ty - oty)
           };
         }
-        flushPenState(elem);
+        flushPenState(elem, state);
+      } else {
+        clearChildQuickLocations(elem);
       }
     }
   };
 }
 
+// Given a starting direction, angle change, and turning radius,
+// this computes the side-radius (with a sign flip indicating
+// the other side), the coordinates of the center dc, and the dx/dy
+// displacement of the final location after the arc.
+function setupArc(
+    r0,         // starting direction in local coordinates
+    r1,         // ending direction local coordinates
+    turnradius  // turning radius in local coordinates
+) {
+  var delta = normalizeRotationDelta(r1 - r0),
+      sradius = delta > 0 ? turnradius : -turnradius,
+      r0r = convertToRadians(r0),
+      dc = [Math.cos(r0r) * sradius, Math.sin(r0r) * sradius],
+      r1r = convertToRadians(r1);
+  return {
+    delta: delta,
+    sradius: sradius,
+    dc: dc,
+    dx: dc[0] - Math.cos(r1r) * sradius,
+    dy: dc[1] - Math.sin(r1r) * sradius
+  };
+}
+
+// Given a path array, a pageX/pageY starting position,
+// arc information in local coordinates, and a 2d transform
+// between page and local coordinates, this function adds to
+// the path scaled page-coorindate beziers following the arc.
+function addArcBezierPaths(
+    path,       // path to add on to in page coordinates
+    start,      // starting location in page coordinates
+    r0,         // starting direction in local coordinates
+    end,        // ending direction in local coordinates
+    turnradius, // turning radius in local coordinates
+    transform   // linear distortion between page and local
+) {
+  var a = setupArc(r0, end, turnradius),
+      sradius = a.sradius, dc = a.dc,
+      r1, a1r, a2r, j, r, pts, triples,
+      splits, splita, absang, relative, points;
+  // Decompose an arc into equal arcs, all 45 degrees or less.
+  splits = 1;
+  splita = a.delta;
+  absang = Math.abs(a.delta);
+  if (absang > 45) {
+    splits = Math.ceil(absang / 45);
+    splita = a.delta / splits;
+  }
+  // Relative traces out the unit-radius arc centered at the origin.
+  relative = [];
+  while (--splits >= 0) {
+    r1 = splits === 0 ? end : r0 + splita;
+    a1r = convertToRadians(r0 + 180);
+    a2r = convertToRadians(r1 + 180);
+    relative.push.apply(relative, approxBezierUnitArc(a1r, a2r));
+    r0 = r1;
+  }
+  points = [];
+  for (j = 0; j < relative.length; j++) {
+    // Multiply each coordinate by radius scale up to the right
+    // turning radius and add to dc to center the turning radius
+    // at the right local coordinate position; then apply parent
+    // distortions to get page-coordinate relative offsets to the
+    // turtle's original position.
+    r = matrixVectorProduct(transform,
+        addVector(scaleVector(relative[j], sradius), dc));
+    // Finally add these to the turtle's actual original position
+    // to get page-coordinate control points for the bezier curves.
+    // (start is the starting position in absolute coordinates,
+    // and dc is the local coordinate offset from the starting
+    // position to the center of the turning radius.)
+    points.push({
+      pageX: r[0] + start.pageX,
+      pageY: r[1] + start.pageY});
+  }
+  // Divide control points into triples again to form bezier curves.
+  triples = [];
+  for (j = 0; j < points.length; j += 3) {
+    triples.push(points.slice(j, j + 3));
+  }
+  addBezierToPath(path, start, triples);
+  return a;
+}
+
+// An animation driver for rotation, including the possibility of
+// tracing out an arc.  Reads an element's turningRadius to see if
+// changing ts.rot should also sweep out an arc.  If so, calls
+// addArcBezierPath to directly add that arc to the drawing path.
 function maybeArcRotation(end, elem, ts, opt) {
   end = parseFloat(end);
   var state = $.data(elem, 'turtleData'),
       tradius = state ? state.turningRadius : 0;
-  if (tradius === 0) {
+  if (tradius === 0 || ts.rot == end) {
     // Avoid drawing a line if zero turning radius.
     opt.displace = false;
-    return normalizeRotation(end);
+    return tradius === 0 ? normalizeRotation(end) : end;
   }
   var tracing = (state && state.style && state.down),
-      r0 = ts.rot, r1, r1r, a1r, a2r, j, r, pts, triples,
-      r0r = convertToRadians(ts.rot),
-      delta = normalizeRotationDelta(end - r0),
-      radius = (delta > 0 ? tradius : -tradius) * ts.sy,
-      dc = [Math.cos(r0r) * radius, Math.sin(r0r) * radius],
-      splits, splita, absang, dx, dy, qpxy,
-      path, totalParentTransform, start, relative, points;
+      sy = (state && state.oldscale) ? ts.sy : 1,
+      turnradius = tradius * sy, a;
   if (tracing) {
-    // Decompose an arc into equal arcs, all 45 degrees or less.
-    splits = 1;
-    splita = delta;
-    absang = Math.abs(delta);
-    if (absang > 45) {
-      splits = Math.ceil(absang / 45);
-      splita = delta / splits;
-    }
-    path = state.path[0];
-    totalParentTransform = totalTransform2x2(elem.parentElement);
-    // Relative traces out the unit-radius arc centered at the origin.
-    relative = [];
-    while (--splits >= 0) {
-      r1 = splits === 0 ? end : r0 + splita;
-      a1r = convertToRadians(r0 + 180);
-      a2r = convertToRadians(r1 + 180);
-      relative.push.apply(relative, approxBezierUnitArc(a1r, a2r));
-      r0 = r1;
-    }
-    points = [];
-    // start is the starting position in absolute coordinates,
-    // and dc is the local coordinate offset from the starting
-    // position to the center of the turning radius.
-    start = getCenterInPageCoordinates(elem);
-    for (j = 0; j < relative.length; j++) {
-      // Multiply each coordinate by radius scale up to the right
-      // turning radius and add to dc to center the turning radius
-      // at the right local coordinate position; then apply parent
-      // distortions to get page-coordinate relative offsets to the
-      // turtle's original position.
-      r = matrixVectorProduct(totalParentTransform,
-          addVector(scaleVector(relative[j], radius), dc));
-      // Finally add these to the turtle's actual original position
-      // to get page-coordinate control points for the bezier curves.
-      points.push({
-        pageX: r[0] + start.pageX,
-        pageY: r[1] + start.pageY});
-    }
-    // Divide control points into triples again to form bezier curves.
-    triples = [];
-    for (j = 0; j < points.length; j += 3) {
-      triples.push(points.slice(j, j + 3));
-    }
-    addBezierToPath(path, start, triples);
-  }
-  // Now move turtle to its final position: in local coordinates,
-  // translate to the turning center plus the vector to the arc end.
-  r1r = convertToRadians(end);
-  dx = dc[0] - Math.cos(r1r) * radius;
-  dy = dc[1] - Math.sin(r1r) * radius;
-  ts.tx += dx;
-  ts.ty += dy;
+    a = addArcBezierPaths(
+      state.path[0],                            // path to add to
+      getCenterInPageCoordinates(elem),         // starting location
+      ts.rot,                                   // starting direction
+      end,                                      // ending direction
+      turnradius,                               // scaled turning radius
+      totalTransform2x2(elem.parentElement));   // totalParentTransform
+  } else {
+    a = setupArc(
+      ts.rot,                                   // starting direction
+      end,                                      // degrees change
+      turnradius);                              // scaled turning radius
+  }
+  ts.tx += a.dx;
+  ts.ty += a.dy;
   opt.displace = true;
   return end;
 }
@@ -2504,12 +2638,73 @@ function makeTurtleXYHook(publicname, propx, propy, displace) {
             pageY: qpxy.pageY + (ts.ty - oty)
           };
         }
-        flushPenState(elem);
+        flushPenState(elem, state);
+      } else {
+        clearChildQuickLocations(elem);
       }
     }
   };
 }
 
+var absoluteUrlAnchor = document.createElement('a');
+function absoluteUrlObject(url) {
+  absoluteUrlAnchor.href = url;
+  return absoluteUrlAnchor;
+}
+function absoluteUrl(url) {
+  return absoluteUrlObject(url).href;
+}
+
+// Pencil-code specific function: detects whether a domain appears to
+// be a pencilcode site.
+function isPencilHost(hostname) {
+  return /(?:^|\.)pencil(?:code)?\./i.test(hostname);
+}
+// Returns a pencilcode username from the URL, if any.
+function pencilUserFromUrl(url) {
+  var hostname = absoluteUrlObject(url == null ? '' : url).hostname,
+      match = /^(\w+)\.pencil(?:code)?\./i.exec(hostname);
+  if (match) return match[1];
+  return null;
+}
+// Rewrites a url to have the top directory name given.
+function apiUrl(url, topdir) {
+  var link = absoluteUrlObject(url == null ? '' : url),
+      result = link.href;
+  if (isPencilHost(link.hostname)) {
+    if (/^\/(?:edit|home|code|load|save)(?:\/|$)/.test(link.pathname)) {
+      // Replace a special topdir name.
+      result = link.protocol + '//' + link.host + '/' + topdir + '/' +
+        link.pathname.replace(/\/[^\/]*(?:\/|$)/, '') + link.search + link.hash;
+    }
+  } else if (isPencilHost(global.location.hostname)) {
+    // Proxy offdomain requests to avoid CORS issues.
+    result = '/proxy/' + result;
+  }
+  return result;
+}
+// Creates an image url from a potentially short name.
+function imgUrl(url) {
+  if (/\//.test(url)) { return url; }
+  url = '/img/' + url;
+  if (isPencilHost(global.location.hostname)) { return url; }
+  return '//pencilcode.net' + url;
+}
+// Retrieves the pencil code login cookie, if there is one.
+function loginCookie() {
+  if (!document.cookie) return null;
+  var cookies = document.cookie.split(/;\s*/);
+  for (var j = 0; j < cookies.length; ++j) {
+    if (/^login=/.test(cookies[j])) {
+      var val = unescape(cookies[j].substr(6)).split(':');
+      if (val && val.length == 2) {
+        return { user: val[0], key: val[1] };
+      }
+    }
+  }
+  return null;
+}
+
 // A map of url to {img: Image, queue: [{elem: elem, css: css, cb: cb}]}.
 var stablyLoadedImages = {};
 
@@ -2530,13 +2725,14 @@ var stablyLoadedImages = {};
 // @param css is a dictionary of css props to set when the image is loaded.
 // @param cb is an optional callback, called after the loading is done.
 function setImageWithStableOrigin(elem, url, css, cb) {
-  var record;
+  var record, urlobj = absoluteUrlObject(url);
+  url = urlobj.href;
   // The data-loading attr will always reflect the last URL requested.
   elem.setAttribute('data-loading', url);
   if (url in stablyLoadedImages) {
     // Already requested this image?
     record = stablyLoadedImages[url];
-    if (record.img.complete) {
+    if (record.queue === null) {
       // If already complete, then flip the image right away.
       finishSet(record.img, elem, css, cb);
     } else {
@@ -2551,18 +2747,15 @@ function setImageWithStableOrigin(elem, url, css, cb) {
       img: new Image(),
       queue: [{elem: elem, css: css, cb: cb}]
     };
+    if (isPencilHost(urlobj.hostname)) {
+      // When requesting through pencilcode, always make a
+      // cross-origin request.
+      record.img.crossOrigin = 'Anonymous';
+    }
     // Pop the element to the right dimensions early if possible.
     resizeEarlyIfPossible(url, elem, css);
     // First set up the onload callback, then start loading.
-    function poll() {
-      if (!record.img.complete) {
-        // Guard against browsers that may fire onload too early or never.
-        setTimeout(poll, 100);
-        return;
-      }
-      record.img.removeEventListener('load', poll);
-      record.img.removeEventListener('error', poll);
-      // TODO: compute the convex hull of the image.
+    afterImageLoadOrError(record.img, url, function() {
       var j, queue = record.queue;
       record.queue = null;
       if (queue) {
@@ -2571,12 +2764,7 @@ function setImageWithStableOrigin(elem, url, css, cb) {
           finishSet(record.img, queue[j].elem, queue[j].css, queue[j].cb);
         }
       }
-    }
-    record.img.addEventListener('load', poll);
-    record.img.addEventListener('error', poll);
-    record.img.src = url;
-    // Start polling immediately, because some browser may never fire onload.
-    poll();
+    });
   }
   // This is the second step, done after the async load is complete:
   // the parameter "loaded" contains the fully loaded Image.
@@ -2595,6 +2783,34 @@ function setImageWithStableOrigin(elem, url, css, cb) {
   }
 }
 
+function afterImageLoadOrError(img, url, fn) {
+  if (url == null) { url = img.src; }
+  // If already loaded, then just call fn.
+  if (url == img.src && (!url || img.complete)) {
+    fn();
+    return;
+  }
+  // Otherwise, set up listeners and wait.
+  var timeout = null;
+  function poll(e) {
+    // If we get a load or error event, notice img.complete
+    // or see that the src was changed, we're done here.
+    if (e || img.complete || img.src != url) {
+      img.removeEventListener('load', poll);
+      img.removeEventListener('error', poll);
+      clearTimeout(timeout);
+      fn();
+    } else {
+      // Otherwise, continue waiting and also polling.
+      timeout = setTimeout(poll, 100);
+    }
+  }
+  img.addEventListener('load', poll);
+  img.addEventListener('error', poll);
+  img.src = url;
+  poll();
+}
+
 // In the special case of loading a data: URL onto an element
 // where we also have an explicit css width and height to apply,
 // we go ahead and synchronously apply the CSS properties even if
@@ -2645,10 +2861,12 @@ function applyLoadedImage(loaded, elem, css) {
       elem.height = loaded.height;
       if (!isCanvas) {
         elem.src = loaded.src;
-      } else {
-        ctx = elem.getContext('2d');
-        ctx.clearRect(0, 0, loaded.width, loaded.height);
-        ctx.drawImage(loaded, 0, 0);
+      } else if (loaded.width > 0 && loaded.height > 0) {
+        try {
+          ctx = elem.getContext('2d');
+          ctx.clearRect(0, 0, loaded.width, loaded.height);
+          ctx.drawImage(loaded, 0, 0);
+        } catch (e) { }
       }
     }
   }
@@ -2656,19 +2874,24 @@ function applyLoadedImage(loaded, elem, css) {
     sel.css(css);
   }
   var newOrigin = readTransformOrigin(elem);
-  if(loaded){
-    var hull = cutTransparent(loaded);
-    var turtleHull = '';
-    for(var i = 0; i < hull.length; i++){
-      if(i > 0) turtleHull += ' ';
-      // Scale coordinates to the size of elem
-      hull[i].pageX = Math.floor(hull[i].pageX * sel.css('height').slice(0, -2) / loaded.height);
-      hull[i].pageY = Math.floor(hull[i].pageY * sel.css('width').slice(0, -2) / loaded.width);
-      turtleHull += (hull[i].pageX - newOrigin[0]) + ' ' + (hull[i].pageY - newOrigin[1]);
-    }
-    sel.css('turtleHull', turtleHull);
-    console.log(turtleHull);
+  if (loaded && !css.turtleHull) {
+    try {
+      var hull = transparentHull(loaded);
+      scalePolygon(hull,
+            parseFloat(sel.css('width')) / loaded.width,
+            parseFloat(sel.css('height')) / loaded.height,
+            -newOrigin[0], -newOrigin[1]);
+      sel.css('turtleHull', hull);
+    } catch (e) {
+      // Do not do this if the image can't be loaded.
+    }
   }
+  moveToPreserveOrigin(elem, oldOrigin, newOrigin);
+}
+
+function moveToPreserveOrigin(elem, oldOrigin, newOrigin) {
+  var sel = $(elem);
+  if (!sel.hasClass('turtle')) return;
   // If there was a change, then translate the element to keep the origin
   // in the same location on the screen.
   if (newOrigin[0] != oldOrigin[0] || newOrigin[1] != oldOrigin[1]) {
@@ -2689,7 +2912,6 @@ function applyLoadedImage(loaded, elem, css) {
   }
 }
 
-
 function withinOrNot(obj, within, distance, x, y) {
   var sel, elem, gbcr, pos, d2;
   if (x === undefined && y === undefined) {
@@ -2791,6 +3013,46 @@ var Sprite = (function(_super) {
 
 })(jQuery.fn.init);
 
+// Pencil extends Sprite, and is invisible and fast by default.
+var Pencil = (function(_super) {
+  __extends(Pencil, _super);
+
+  function Pencil(canvas) {
+    // A pencil draws on a canvas.  Allow a selector or element.
+    if (canvas && canvas.jquery && $.isFunction(canvas.canvas)) {
+      canvas = canvas.canvas();
+    }
+    if (canvas && (canvas.tagName != 'CANVAS' ||
+        typeof canvas.getContext != 'function')) {
+      canvas = $(canvas).filter('canvas').get(0);
+    }
+    if (!canvas || canvas.tagName != 'CANVAS' ||
+        typeof canvas.getContext != 'function') {
+      canvas = null;
+    }
+    // The pencil is a sprite that just defaults to zero size.
+    var context = canvas ? canvas.parentElement : null;
+    var settings = { width: 0, height: 0, color: 'transparent' };
+    Pencil.__super__.constructor.call(this, settings, context);
+    // Set the pencil to hidden, infinite speed,
+    // and drawing on the specifed canvas.
+    this.each(function() {
+      var state = getTurtleData(this);
+      state.speed = Infinity;
+      state.drawOnCanvas = canvas;
+      this.style.display = 'none';
+      if (canvas) {
+        this.style[transform] = writeTurtleTransform(
+            readTurtleTransform(canvas, true));
+      }
+    });
+  }
+
+  return Pencil;
+
+})(Sprite);
+
+
 // Turtle extends Sprite, and draws a turtle by default.
 var Turtle = (function(_super) {
   __extends(Turtle, _super);
@@ -2810,7 +3072,7 @@ var Webcam = (function(_super) {
   function Webcam(opts, context) {
     var attrs = "", hassrc = false, hasautoplay = false, hasdims = false;
     if ($.isPlainObject(opts)) {
-      for (key in opts) {
+      for (var key in opts) {
         attrs += ' ' + key + '="' + escapeHtml(opts[key]) + '"';
       }
       hassrc = ('src' in opts);
@@ -2853,7 +3115,7 @@ var Webcam = (function(_super) {
             $(v).off('play.capture' + k);
             next();
           });
-          v.src = window.URL.createObjectURL(stream);
+          v.src = global.URL.createObjectURL(stream);
         }
       }, function() {
         next();
@@ -3055,12 +3317,12 @@ var Piano = (function(_super) {
 
   // Converts a midi number to a white key position (black keys round left).
   function wcp(n) {
-    return floor((n + 7) / 12 * 7);
+    return Math.floor((n + 7) / 12 * 7);
   };
 
   // Converts from a white key position to a midi number.
   function mcp(n) {
-    return ceil(n / 7 * 12) - 7;
+    return Math.ceil(n / 7 * 12) - 7;
   };
 
   // True if midi #n is a black key.
@@ -3162,20 +3424,21 @@ var Piano = (function(_super) {
 // Implementation of the "pressed" function
 //////////////////////////////////////////////////////////////////////////
 
-// The implementation of the "pressed" function is captured in a closure.
-var pressedKey = (function() {
-  var focusTakenOnce = false;
-  function focusWindowIfFirst() {
-    if (focusTakenOnce) return;
-    focusTakenOnce = true;
-    try {
-      // If we are in a frame with access to a parent with an activeElement,
-      // then try to blur it (as is common inside the pencilcode IDE).
-      window.parent.document.activeElement.blur();
-    } catch (e) {}
-    window.focus();
-  }
-  var ua = typeof window !== 'undefined' ? window.navigator.userAgent : '',
+var focusTakenOnce = false;
+function focusWindowIfFirst() {
+  if (focusTakenOnce) return;
+  focusTakenOnce = true;
+  try {
+    // If we are in a frame with access to a parent with an activeElement,
+    // then try to blur it (as is common inside the pencilcode IDE).
+    global.parent.document.activeElement.blur();
+  } catch (e) {}
+  global.focus();
+}
+
+// Construction of keyCode names.
+var keyCodeName = (function() {
+  var ua = typeof global !== 'undefined' ? global.navigator.userAgent : '',
       isOSX = /OS X/.test(ua),
       isOpera = /Opera/.test(ua),
       maybeFirefox = !/like Gecko/.test(ua) && !isOpera,
@@ -3183,6 +3446,7 @@ var pressedKey = (function() {
       preventable = 'contextmenu',
       events = 'mousedown mouseup keydown keyup blur ' + preventable,
       keyCodeName = {
+    0:  'null',
     1:  'mouse1',
     2:  'mouse2',
     3:  'break',
@@ -3290,48 +3554,50 @@ var pressedKey = (function() {
     254: 'clear'
   };
   // :-@, 0-9, a-z(lowercased)
-  for (i = 48; i < 91; ++i) {
+  for (var i = 48; i < 91; ++i) {
     keyCodeName[i] = String.fromCharCode(i).toLowerCase();
   }
   // num-0-9 numeric keypad
   for (i = 96; i < 106; ++i) {
-    keyCodeName[i] = 'num ' +  (i - 96);
+    keyCodeName[i] = 'numpad' +  (i - 96);
   }
   // f1-f24
   for (i = 112; i < 136; ++i) {
     keyCodeName[i] = 'f' + (i-111);
   }
+  return keyCodeName;
+})();
+
+var pressedKey = (function() {
   // Listener for keyboard, mouse, and focus events that updates pressedState.
-  function pressListener(event) {
-    var name, simplified, down;
-    if (event.type == 'mousedown' || event.type == 'mouseup') {
-      name = 'mouse ' + event.which;
-      down = (event.type == 'mousedown');
-    } else if (event.type == 'keydown' || event.type == 'keyup') {
-      name = keyCodeName[event.which];
-      down = (event.type == 'keydown');
-      if (event.which >= 160 && event.which <= 165) {
-        // For "shift left", also trigger "shift"; same for control and alt.
-        simplified = name.replace(/(?:left|right)$/, '');
-      }
-    } else if (event.type == 'blur' || event.type == 'contextmenu') {
-      // When losing focus, clear all keyboard state.
-      if (!event.isDefaultPrevented() || preventable != event.type) {
-        resetPressedState();
+  function makeEventListener(mouse, down) {
+    return (function(event) {
+      var name, simplified, which = event.which;
+      if (mouse) {
+        name = 'mouse' + which;
+      } else {
+        // For testability, support whichSynth when which is zero, because
+        // it is impossible to simulate .which on phantom.
+        if (!which && event.whichSynth) { which = event.whichSynth; }
+        name = keyCodeName[which];
+        if (which >= 160 && which <= 165) {
+          // For "shift left", also trigger "shift"; same for control and alt.
+          updatePressedState(name.replace(/(?:left|right)$/, ''), down);
+        }
       }
-      return;
-    }
-    updatePressedState(name, down);
-    updatePressedState(simplified, down);
-    if (down) {
-      // After any down event, unlisten and relisten to contextmenu,
-      // to put oursleves last.  This allows us to test isDefaultPrevented.
-      $(window).off(preventable, pressListener);
-      $(window).on(preventable, pressListener);
-    }
-  }
+      updatePressedState(name, down);
+    });
+  };
+  var eventMap = {
+    'mousedown': makeEventListener(1, 1),
+    'mouseup': makeEventListener(1, 0),
+    'keydown': makeEventListener(0, 1),
+    'keyup': makeEventListener(0, 0),
+    'blur': resetPressedState
+  };
   // The pressedState map just has an entry for each pressed key.
   // Unpressing a key will delete the actual key from the map.
+  var pressedState = {};
   function updatePressedState(name, down) {
     if (name != null) {
       if (!down) {
@@ -3350,10 +3616,12 @@ var pressedKey = (function() {
   // The pressed listener can be turned on and off using pressed.enable(flag).
   function enablePressListener(turnon) {
     resetPressedState();
-    if (turnon) {
-      $(window).on(events, pressListener);
-    } else {
-      $(window).off(events, pressListener);
+    for (var name in eventMap) {
+      if (turnon) {
+        global.addEventListener(name, eventMap[name], true);
+      } else {
+        global.removeEventListener(name, eventMap[name]);
+      }
     }
   }
   // All pressed keys known can be listed using pressed.list().
@@ -3367,16 +3635,172 @@ var pressedKey = (function() {
   // The pressed function just polls the given keyname.
   function pressed(keyname) {
     focusWindowIfFirst();
-    // Canonical names are lowercase and have no spaces.
-    keyname = keyname.replace(/\s/g, '').toLowerCase();
-    if (pressedState[keyname]) return true;
-    return false;
+    if (keyname) {
+      // Canonical names are lowercase and have no spaces.
+      keyname = keyname.replace(/\s/g, '').toLowerCase();
+      if (pressedState[keyname]) return true;
+      return false;
+    } else {
+      return listPressedKeys();
+    }
   }
   pressed.enable = enablePressListener;
   pressed.list = listPressedKeys;
   return pressed;
 })();
 
+
+//////////////////////////////////////////////////////////////////////////
+// JQUERY EVENT ENHANCEMENT
+//  - Keyboard events get the .key property.
+//  - Keyboard event listening with a string first (data) arg
+//    automatically filter out events that don't match the keyname.
+//  - Mouse events get .x and .y (center-up) if there is a turtle field.
+//  - If a turtle in the field is listening to mouse events, unhandled
+//    body mouse events are manually forwarded to turtles.
+//////////////////////////////////////////////////////////////////////////
+
+function addEventHook(hookobj, field, defobj, name, fn) {
+  var names = name.split(/\s+/);
+  for (var j = 0; j < names.length; ++j) {
+    name = names[j];
+    var hooks = hookobj[name];
+    if (!hooks) {
+      hooks = hookobj[name] = $.extend({}, defobj);
+    }
+    if (typeof hooks[field] != 'function') {
+      hooks[field] = fn;
+    } else if (hooks[field] != fn) {
+      // Multiple event hooks just listed in an array.
+      if (hooks[field].hooklist) {
+        if (hooks[field].hooklist.indexOf(fn) < 0) {
+          hooks[field].hooklist.push(fn);
+        }
+      } else {
+        (function() {
+          var hooklist = [hooks[field], fn];
+          (hooks[field] = function(event, original) {
+            var current = event;
+            for (var j = 0; j < hooklist.length; ++j) {
+              current = hooklist[j](current, original) || current;
+            }
+            return current;
+          }).hooklist = hooklist;
+        })();
+      }
+    }
+  }
+}
+
+function mouseFilterHook(event, original) {
+  if (globalDrawing.field && 'pageX' in event && 'pageY' in event) {
+    var origin = $(globalDrawing.field).offset();
+    if (origin) {
+      event.x = event.pageX - origin.left;
+      event.y = origin.top - event.pageY;
+    }
+  }
+  return event;
+}
+
+function mouseSetupHook(data, ns, fn) {
+  if (globalDrawing.field && !globalDrawing.fieldMouse &&
+      this.parentElement === globalDrawing.field ||
+      /(?:^|\s)turtle(?:\s|$)/.test(this.class)) {
+    globalDrawing.fieldMouse = true;
+    forwardBodyMouseEventsIfNeeded();
+  }
+  return false;
+}
+
+function forwardBodyMouseEventsIfNeeded() {
+  if (globalDrawing.fieldHook) return;
+  if (globalDrawing.surface && globalDrawing.fieldMouse) {
+    globalDrawing.fieldHook = true;
+    setTimeout(function() {
+      // TODO: check both globalDrawing.surface and
+      // globalDrawing.turtleMouseListener
+      $('body').on('click.turtle dblclick.turtle ' +
+        'mouseup.turtle mousedown.turtle mousemove.turtle', function(e) {
+        if (e.target === this && !e.isTrigger) {
+          // Only forward events directly on the body that (geometrically)
+          // touch a turtle directly within the turtlefield.
+          var warn = $.turtle.nowarn;
+          $.turtle.nowarn = true;
+          var sel = $(globalDrawing.surface)
+              .find('.turtle,.turtlelabel').within('touch', e).eq(0);
+          $.turtle.nowarn = warn;
+          if (sel.length === 1) {
+            // Erase portions of the event that are wrong for the turtle.
+            e.target = null;
+            e.relatedTarget = null;
+            e.fromElement = null;
+            e.toElement = null;
+            sel.trigger(e);
+            return false;
+          }
+        }
+      });
+    }, 0);
+  }
+}
+
+function addMouseEventHooks() {
+  var hookedEvents = 'mousedown mouseup mousemove click dblclick';
+  addEventHook($.event.fixHooks, 'filter', $.event.mouseHooks,
+       hookedEvents, mouseFilterHook);
+  addEventHook($.event.special, 'setup', {}, hookedEvents, mouseSetupHook);
+}
+
+function keyFilterHook(event, original) {
+  var which = event.which;
+  if (!which) {
+    which = (original || event.originalEvent).whichSynth;
+  }
+  var name = keyCodeName[which];
+  if (!name && which) {
+    name = String.fromCharCode(which);
+  }
+  event.key = name;
+  return event;
+}
+
+// Add .key to each keyboard event.
+function keypressFilterHook(event, original) {
+  if (event.charCode != null) {
+    event.key = String.fromCharCode(event.charCode);
+  }
+}
+
+// Intercept on('keydown/keyup/keypress')
+function keyAddHook(handleObj) {
+  if (typeof(handleObj.data) != 'string') return;
+  var choices = handleObj.data.replace(/\s/g, '').toLowerCase().split(',');
+  var original = handleObj.handler;
+  var wrapped = function(event) {
+    if (choices.indexOf(event.key) < 0) return;
+    return original.apply(this, arguments);
+  }
+  if (original.guid) { wrapped.guid = original.guid; }
+  handleObj.handler = wrapped;
+}
+
+function addKeyEventHooks() {
+  // Add the "key" field to keydown and keyup events - this uses
+  // the lowercase key names listed in the pressedKey utility.
+  addEventHook($.event.fixHooks, 'filter', $.event.keyHooks,
+      'keydown keyup', keyFilterHook);
+  // Add "key" to keypress also.  This is just the unicode character
+  // corresponding to event.charCode.
+  addEventHook($.event.fixHooks, 'filter', $.event.keyHooks,
+      'keypress', keypressFilterHook);
+  // Finally, add special forms for the keyup/keydown/keypress events
+  // where the first argument can be the comma-separated name of keys
+  // to target (instead of just data)
+  addEventHook($.event.special, 'add', {},
+      'keydown keyup keypress', keyAddHook);
+}
+
 //////////////////////////////////////////////////////////////////////////
 // WEB AUDIO SUPPORT
 // Definition of play("ABC") - uses ABC music note syntax.
@@ -3438,9 +3862,11 @@ function getGlobalInstrument() {
   return global_instrument;
 }
 
+// Beginning of musical.js copy
+
 // Tests for the presence of HTML5 Web Audio (or webkit's version).
 function isAudioPresent() {
-  return !!(window.AudioContext || window.webkitAudioContext);
+  return !!(global.AudioContext || global.webkitAudioContext);
 }
 
 // All our audio funnels through the same AudioContext with a
@@ -3451,7 +3877,7 @@ function getAudioTop() {
   if (!isAudioPresent()) {
     return null;
   }
-  var ac = new (window.AudioContext || window.webkitAudioContext);
+  var ac = new (global.AudioContext || global.webkitAudioContext);
   getAudioTop.audioTop = {
     ac: ac,
     wavetable: makeWavetable(ac),
@@ -3473,11 +3899,16 @@ function resetAudio() {
       atop.out = null;
       atop.currentStart = null;
     }
-    var dcn = atop.ac.createDynamicsCompressor();
-    dcn.ratio = 16;
-    dcn.attack = 0.0005;
-    dcn.connect(atop.ac.destination);
-    atop.out = dcn;
+    // If resetting due to interrupt after AudioContext closed, this can fail.
+    try {
+      var dcn = atop.ac.createDynamicsCompressor();
+      dcn.ratio = 16;
+      dcn.attack = 0.0005;
+      dcn.connect(atop.ac.destination);
+      atop.out = dcn;
+    } catch (e) {
+      getAudioTop.audioTop = null;
+    }
   }
 }
 
@@ -3496,6 +3927,48 @@ function audioCurrentStartTime() {
   return atop.currentStart;
 }
 
+// Converts a midi note number to a frequency in Hz.
+function midiToFrequency(midi) {
+  return 440 * Math.pow(2, (midi - 69) / 12);
+}
+// Some constants.
+var noteNum =
+    {C:0,D:2,E:4,F:5,G:7,A:9,B:11,c:12,d:14,e:16,f:17,g:19,a:21,b:23};
+var accSym =
+    { '^':1, '': 0, '=':0, '_':-1 };
+var noteName =
+    ['C', '^C', 'D', '_E', 'E', 'F', '^F', 'G', '_A', 'A', '_B', 'B',
+     'c', '^c', 'd', '_e', 'e', 'f', '^f', 'g', '_a', 'a', '_b', 'b'];
+// Converts a frequency in Hz to the closest midi number.
+function frequencyToMidi(freq) {
+  return Math.round(69 + Math.log(freq / 440) * 12 / Math.LN2);
+}
+// Converts an ABC pitch (such as "^G,,") to a midi note number.
+function pitchToMidi(pitch) {
+  var m = /^(\^+|_+|=|)([A-Ga-g])([,']*)$/.exec(pitch);
+  if (!m) { return null; }
+  var octave = m[3].replace(/,/g, '').length - m[3].replace(/'/g, '').length;
+  var semitone =
+      noteNum[m[2]] + accSym[m[1].charAt(0)] * m[1].length + 12 * octave;
+  return semitone + 60; // 60 = midi code middle "C".
+}
+// Converts a midi number to an ABC notation pitch.
+function midiToPitch(midi) {
+  var index = ((midi - 72) % 12);
+  if (midi > 60 || index != 0) { index += 12; }
+  var octaves = Math.round((midi - index - 60) / 12),
+      result = noteName[index];
+  while (octaves != 0) {
+    result += octaves > 0 ? "'" : ",";
+    octaves += octaves > 0 ? -1 : 1;
+  }
+  return result;
+}
+// Converts an ABC pitch to a frequency in Hz.
+function pitchToFrequency(pitch) {
+  return midiToFrequency(pitchToMidi(pitch));
+}
+
 // All further details of audio handling are encapsulated in the Instrument
 // class, which knows how to synthesize a basic timbre; how to play and
 // schedule a tone; and how to parse and sequence a song written in ABC
@@ -3535,9 +4008,10 @@ var Instrument = (function() {
     }
   }
 
+  Instrument.timeOffset = 0.0625;// Seconds to delay all audiable timing.
   Instrument.dequeueTime = 0.5;  // Seconds before an event to reexamine queue.
   Instrument.bufferSecs = 2;     // Seconds ahead to put notes in WebAudio.
-  Instrument.toneLength = 10;    // Default duration of a tone.
+  Instrument.toneLength = 1;     // Default duration of a tone.
   Instrument.cleanupDelay = 0.1; // Silent time before disconnecting nodes.
 
   // Sets the default timbre for the instrument.  See defaultTimbre.
@@ -3671,7 +4145,7 @@ var Instrument = (function() {
   // node graph for the tone generators and filters for the tone.
   Instrument.prototype._makeSound = function(record) {
     var timbre = record.timbre || this._timbre,
-        starttime = record.time,
+        starttime = record.time + Instrument.timeOffset,
         releasetime = starttime + record.duration,
         attacktime = Math.min(releasetime, starttime + timbre.attack),
         decaytime = timbre.decay *
@@ -3748,12 +4222,13 @@ var Instrument = (function() {
   // Truncates a sound previously scheduled by _makeSound by using
   // cancelScheduledValues and directly ramping down to zero.
   // Can only be used to shorten a sound.
-  Instrument.prototype._truncateSound = function(record, releasetime) {
-    if (releasetime < record.time + record.duration) {
-      record.duration = Math.max(0, releasetime - record.time);
+  Instrument.prototype._truncateSound = function(record, truncatetime) {
+    if (truncatetime < record.time + record.duration) {
+      record.duration = Math.max(0, truncatetime - record.time);
       if (record.gainNode) {
         var timbre = record.timbre || this._timbre,
-            starttime = record.time,
+            starttime = record.time + Instrument.timeOffset,
+            releasetime = truncatetime + Instrument.timeOffset,
             attacktime = Math.min(releasetime, starttime + timbre.attack),
             decaytime = timbre.decay *
                 Math.pow(440 / record.frequency, timbre.decayfollow),
@@ -3770,7 +4245,8 @@ var Instrument = (function() {
         } else if (releasetime <= attacktime) {
           // Release before attack is done?  Interrupt ramp up.
           g.gain.linearRampToValueAtTime(
-            amp * (releasetime - starttime) / (attacktime - starttime));
+            amp * (releasetime - starttime) / (attacktime - starttime),
+            releasetime);
         } else {
           // Release during decay?  Interrupt decay down.
           g.gain.setValueAtTime(amp * (timbre.sustain + (1 - timbre.sustain) *
@@ -3824,10 +4300,14 @@ var Instrument = (function() {
       this.silence();
       return;
     }
-    var now = this._atop.ac.currentTime, callbacks = [],
+    // The shortest time we can delay is 1 / 1000 secs, so if an event
+    // is within the next 0.5 ms, now is the closest moment, and we go
+    // ahead and process it.
+    var instant = this._atop.ac.currentTime + (1 / 2000),
+        callbacks = [],
         j, work, when, freq, record, conflict, save, cb;
     // Schedule a batch of notes
-    if (this._minQueueTime - now <= Instrument.bufferSecs) {
+    if (this._minQueueTime - instant <= Instrument.bufferSecs) {
       if (this._unsortedQueue) {
         this._queue.sort(function(a, b) {
           if (a.time != b.time) { return a.time - b.time; }
@@ -3837,7 +4317,7 @@ var Instrument = (function() {
         this._unsortedQueue = false;
       }
       for (j = 0; j < this._queue.length; ++j) {
-        if (this._queue[j].time - now > Instrument.bufferSecs) { break; }
+        if (this._queue[j].time - instant > Instrument.bufferSecs) { break; }
       }
       if (j > 0) {
         work = this._queue.splice(0, j);
@@ -3851,7 +4331,7 @@ var Instrument = (function() {
     // Disconnect notes from the cleanup set.
     for (j = 0; j < this._cleanupSet.length; ++j) {
       record = this._cleanupSet[j];
-      if (record.cleanuptime < now) {
+      if (record.cleanuptime < instant) {
         if (record.gainNode) {
           // This explicit disconnect is needed or else Chrome's WebAudio
           // starts getting overloaded after a couple thousand notes.
@@ -3866,7 +4346,7 @@ var Instrument = (function() {
     for (freq in this._finishSet) {
       record = this._finishSet[freq];
       when = record.time + record.duration;
-      if (when <= now) {
+      if (when <= instant) {
         callbacks.push({
           order: [when, 0],
           f: this._trigger, t: this, a: ['noteoff', record]});
@@ -3880,7 +4360,7 @@ var Instrument = (function() {
     for (j = 0; j < this._callbackSet.length; ++j) {
       cb = this._callbackSet[j];
       when = cb.time;
-      if (when <= now) {
+      if (when <= instant) {
         callbacks.push({
           order: [when, 1],
           f: cb.callback, t: null, a: []});
@@ -3890,7 +4370,7 @@ var Instrument = (function() {
     }
     // Notify about any notes starting.
     for (j = 0; j < this._startSet.length; ++j) {
-      if (this._startSet[j].time <= now) {
+      if (this._startSet[j].time <= instant) {
         save = record = this._startSet[j];
         freq = record.frequency;
         conflict = null;
@@ -3986,7 +4466,7 @@ var Instrument = (function() {
     earliest = Math.min(
        earliest, this._minQueueTime - Instrument.dequeueTime);
 
-    delay = Math.max(0, earliest - this._atop.ac.currentTime);
+    delay = Math.max(0.001, earliest - this._atop.ac.currentTime);
 
     // If there are no future events, then we do not need a timer.
     if (isNaN(delay) || delay == Infinity) { return; }
@@ -3997,7 +4477,7 @@ var Instrument = (function() {
 
   // The low-level tone function.
   Instrument.prototype.tone =
-  function(pitch, velocity, duration, delay, timbre) {
+  function(pitch, duration, velocity, delay, timbre, origin) {
     // If audio is not present, this is a no-op.
     if (!this._atop) { return; }
 
@@ -4007,6 +4487,7 @@ var Instrument = (function() {
       if (duration == null) duration = pitch.duration;
       if (delay == null) delay = pitch.delay;
       if (timbre == null) timbre = pitch.timbre;
+      if (origin == null) origin = pitch.origin;
       pitch = pitch.pitch;
     }
 
@@ -4037,7 +4518,7 @@ var Instrument = (function() {
         if (key in given) {
           timbre[key] = given[key];
         } else {
-          timbre[key] = defaulTimbre[key];
+          timbre[key] = defaultTimbre[key];
         }
       }
     }
@@ -4057,7 +4538,8 @@ var Instrument = (function() {
           instrument: this,
           gainNode: null,
           oscillators: null,
-          cleanuptime: Infinity
+          cleanuptime: Infinity,
+          origin: origin             // save the origin of the tone for visible feedback
         };
 
     if (time < now + Instrument.bufferSecs) {
@@ -4166,10 +4648,12 @@ var Instrument = (function() {
             // This is innsermost part of the inner loop!
             this.tone(                     // Play the tone:
               note.pitch,                  // at the given pitch
-              v,                           // with the given volume
               secs,                        // for the given duration
+              v,                           // with the given volume
               delay,                       // starting at the proper time
-              timbre);                     // with the selected timbre
+              timbre,                      // with the selected timbre
+              note                         // the origin object for visual feedback
+              );
           }
           delay += stem.time * beatsecs;   // Advance the sequenced time.
         }
@@ -4184,295 +4668,269 @@ var Instrument = (function() {
     }
   };
 
-  // Parses an ABC file to an object with the following structure:
-  // {
-  //   X: value from the X: lines in header (\n separated for multiple values)
-  //   V: value from the V:myname lines that appear before K:
-  //   (etc): for all the one-letter header-names.
-  //   K: value from the K: lines in header.
-  //   tempo: Q: line parsed as beatsecs
-  //   timbre: ... I:timbre line as parsed by makeTimbre
-  //   voice: {
-  //     myname: { // voice with id "myname"
-  //       V: value from the V:myname lines (from the body)
-  //       stems: [...] as parsed by parseABCstems
-  //    }
-  //  }
-  // }
-  // ABC files are idiosyncratic to parse: the written specifications
-  // do not necessarily reflect the defacto standard implemented by
-  // ABC content on the web.  This implementation is designed to be
-  // practical, working on content as it appears on the web, and only
-  // using the written standard as a guideline.
-  var ABCheader = /^([A-Za-z]):\s*(.*)$/;
-  function parseABCFile(str) {
-    var lines = str.split('\n'),
-        result = {
-          voice: {}
-        },
-        context = result, timbre,
-        j, k, header, stems, key = {}, accent = {}, voiceid, out;
-    // Shifts context to a voice with the given id given.  If no id
-    // given, then just sticks with the current voice.  If the current
-    // voice is unnamed and empty, renames the current voice.
-    function startVoiceContext(id) {
-      id = id || '';
-      if (!id && context !== result) {
-        return;
-      }
-      if (result.voice.hasOwnProperty(id)) {
-        // Resume a named voice.
-        context = result.voice[id];
-        accent = context.accent;
-      } else {
-        // Start a new voice.
-        context = { id: id, accent: { slurred: 0 } };
-        result.voice[id] = context;
-        accent = context.accent;
-      }
-    }
-    // For picking a default voice, looks for the first voice name.
-    function firstVoiceName() {
-      if (result.V) {
-        return result.V.split(/\s+/)[0];
-      } else {
-        return '';
-      }
+
+  // The default sound is a square wave with a pretty quick decay to zero.
+  var defaultTimbre = Instrument.defaultTimbre = {
+    wave: 'square',   // Oscillator type.
+    gain: 0.1,        // Overall gain at maximum attack.
+    attack: 0.002,    // Attack time at the beginning of a tone.
+    decay: 0.4,       // Rate of exponential decay after attack.
+    decayfollow: 0,   // Amount of decay shortening for higher notes.
+    sustain: 0,       // Portion of gain to sustain indefinitely.
+    release: 0.1,     // Release time after a tone is done.
+    cutoff: 0,        // Low-pass filter cutoff frequency.
+    cutfollow: 0,     // Cutoff adjustment, a multiple of oscillator freq.
+    resonance: 0,     // Low-pass filter resonance.
+    detune: 0         // Detune factor for a second oscillator.
+  };
+
+  // Norrmalizes a timbre object by making a copy that has exactly
+  // the right set of timbre fields, defaulting when needed.
+  // A timbre can specify any of the fields of defaultTimbre; any
+  // unspecified fields are treated as they are set in defaultTimbre.
+  function makeTimbre(options, atop) {
+    if (!options) {
+      options = {};
     }
-    // ABC files are parsed one line at a time.
-    for (j = 0; j < lines.length; ++j) {
-      // First, check to see if the line is a header line.
-      header = ABCheader.exec(lines[j]);
-      if (header) {
-        // The following headers are recognized and processed.
-        switch(header[1]) {
-          case 'V':
-            // A V: header switches voices if in the body.
-            // If in the header, then it is just advisory.
-            if (context !== result) {
-              startVoiceContext(header[2].split(' ')[0]);
-            }
-            break;
-          case 'M':
-            parseMeter(header[2], context);
-            break;
-          case 'L':
-            parseUnitNote(header[2], context);
-            break;
-          case 'Q':
-            parseTempo(header[2], context);
-            break;
-        }
-        // All headers (including unrecognized ones) are
-        // just accumulated as properties. Repeated header
-        // lines are accumulated as multiline properties.
-        if (context.hasOwnProperty(header[1])) {
-          context[header[1]] += '\n' + header[2];
-        } else {
-          context[header[1]] = header[2];
-        }
-        // The K header is special: it should be the last one
-        // before the voices and notes begin.
-        if (header[1] == 'K' && context === result) {
-          key = keysig(header[2]);
-          startVoiceContext(firstVoiceName());
-        }
-      } else if (/^\s*(?:%.*)?$/.test(lines[j])) {
-        // Skip blank and comment lines.
-        continue;
-      } else {
-        // A non-blank non-header line should have notes.
-        voiceid = peekABCVoice(lines[j]);
-        if (voiceid) {
-          // If it declares a voice id, respect it.
-          startVoiceContext(voiceid);
-        } else {
-          // Otherwise, start a default voice.
-          if (context === result) {
-            startVoiceContext(firstVoiceName());
-          }
-        }
-        // Parse the notes.
-        stems = parseABCNotes(lines[j], key, accent);
-        if (stems && stems.length) {
-          // Push the line of stems into the voice.
-          if (!('stems' in context)) { context.stems = []; }
-          context.stems.push.apply(context.stems, stems);
-        }
-      }
+    if (typeof(options) == 'string') {
+      // Abbreviation: name a wave to get a default timbre for that wave.
+      options = { wave: options };
     }
-    var infer = ['unitnote', 'unitbeat', 'tempo'];
-    if (result.voice) {
-      out = [];
-      for (j in result.voice) {
-        if (result.voice[j].stems && result.voice[j].stems.length) {
-          // Calculate times for all the tied notes.  This happens at the end
-          // because in principle, the first note of a song could be tied all
-          // the way through to the last note.
-          processTies(result.voice[j].stems);
-          // Bring up inferred tempo values from voices if not specified
-          // in the header.
-          for (k = 0; k < infer.length; ++k) {
-            if (!(infer[k] in result) && (infer[k] in result.voice[j])) {
-              result[infer[k]] = result.voice[j][infer[k]];
-            }
-          }
-        } else {
-          out.push(j);
-        }
-      }
-      // Delete any voices that had no stems.
-      for (j = 0; j < out.length; ++j) {
-        delete result.voice[out[j]];
+    var result = {}, key,
+        wt = atop && atop.wavetable && atop.wavetable[options.wave];
+    for (key in defaultTimbre) {
+      if (options.hasOwnProperty(key)) {
+        result[key] = options[key];
+      } else if (wt && wt.defs && wt.defs.hasOwnProperty(key)) {
+        result[key] = wt.defs[key];
+      } else{
+        result[key] = defaultTimbre[key];
       }
     }
     return result;
   }
-  // Parse M: lines.  "3/4" is 3/4 time and "C" is 4/4 (common) time.
-  function parseMeter(mline, beatinfo) {
-    var d = /^C/.test(mline) ? 4/4 : durationToTime(mline);
-    if (!d) { return; }
-    if (!beatinfo.unitnote) {
-      if (d < 0.75) {
-        beatinfo.unitnote = 1/16;
+
+  function getWhiteNoiseBuf() {
+    var ac = getAudioTop().ac,
+      bufferSize = 2 * ac.sampleRate,
+      whiteNoiseBuf = ac.createBuffer(1, bufferSize, ac.sampleRate),
+      output = whiteNoiseBuf.getChannelData(0);
+    for (var i = 0; i < bufferSize; i++) {
+      output[i] = Math.random() * 2 - 1;
+    }
+    return whiteNoiseBuf;
+  }
+
+  // This utility function creates an oscillator at the given frequency
+  // and the given wavename.  It supports lookups in a static wavetable,
+  // defined right below.
+  function makeOscillator(atop, wavename, freq) {
+    if (wavename == 'noise') {
+      var whiteNoise = atop.ac.createBufferSource();
+      whiteNoise.buffer = getWhiteNoiseBuf();
+      whiteNoise.loop = true;
+      return whiteNoise;
+    }
+    var wavetable = atop.wavetable, o = atop.ac.createOscillator(),
+        k, pwave, bwf, wf;
+    try {
+      if (wavetable.hasOwnProperty(wavename)) {
+        // Use a customized wavetable.
+        pwave = wavetable[wavename].wave;
+        if (wavetable[wavename].freq) {
+          bwf = 0;
+          // Look for a higher-frequency variant.
+          for (k in wavetable[wavename].freq) {
+            wf = Number(k);
+            if (freq > wf && wf > bwf) {
+              bwf = wf;
+              pwave = wavetable[wavename].freq[bwf];
+            }
+          }
+        }
+        if (!o.setPeriodicWave && o.setWaveTable) {
+          // The old API name: Safari 7 still uses this.
+          o.setWaveTable(pwave);
+        } else {
+          // The new API name.
+          o.setPeriodicWave(pwave);
+        }
       } else {
-        beatinfo.unitnote = 1/8;
+        o.type = wavename;
       }
+    } catch(e) {
+      if (window.console) { window.console.log(e); }
+      // If unrecognized, just use square.
+      // TODO: support "noise" or other wave shapes.
+      o.type = 'square';
     }
+    o.frequency.value = freq;
+    return o;
   }
-  // Parse L: lines, e.g., "1/8".
-  function parseUnitNote(lline, beatinfo) {
-    var d = durationToTime(lline);
-    if (!d) { return; }
-    beatinfo.unitnote = d;
+
+  // Accepts either an ABC pitch or a midi number and converts to midi.
+  Instrument.pitchToMidi = function(n) {
+    if (typeof(n) == 'string') { return pitchToMidi(n); }
+    return n;
   }
-  // Parse Q: line, e.g., "1/4=66".
-  function parseTempo(qline, beatinfo) {
-    var parts = qline.split(/\s+|=/), j, unit = null, tempo = null;
-    for (j = 0; j < parts.length; ++j) {
-      // It could be reversed, like "66=1/4", or just "120", so
-      // determine what is going on by looking for a slash etc.
-      if (parts[j].indexOf('/') >= 0 || /^[1-4]$/.test(parts[j])) {
-        // The note-unit (e.g., 1/4).
-        unit = unit || durationToTime(parts[j]);
+
+  // Accepts either an ABC pitch or a midi number and converts to ABC pitch.
+  Instrument.midiToPitch = function(n) {
+    if (typeof(n) == 'number') { return midiToPitch(n); }
+    return n;
+  }
+
+  return Instrument;
+})();
+
+// Parses an ABC file to an object with the following structure:
+// {
+//   X: value from the X: lines in header (\n separated for multiple values)
+//   V: value from the V:myname lines that appear before K:
+//   (etc): for all the one-letter header-names.
+//   K: value from the K: lines in header.
+//   tempo: Q: line parsed as beatsecs
+//   timbre: ... I:timbre line as parsed by makeTimbre
+//   voice: {
+//     myname: { // voice with id "myname"
+//       V: value from the V:myname lines (from the body)
+//       stems: [...] as parsed by parseABCstems
+//    }
+//  }
+// }
+// ABC files are idiosyncratic to parse: the written specifications
+// do not necessarily reflect the defacto standard implemented by
+// ABC content on the web.  This implementation is designed to be
+// practical, working on content as it appears on the web, and only
+// using the written standard as a guideline.
+var ABCheader = /^([A-Za-z]):\s*(.*)$/;
+var ABCtoken = /(?:\[[A-Za-z]:[^\]]*\])|\s+|%[^\n]*|![^\s!:|\[\]]*!|\+[^+|!]*\+|[_<>@^]?"[^"]*"|\[|\]|>+|<+|(?:(?:\^+|_+|=|)[A-Ga-g](?:,+|'+|))|\(\d+(?::\d+){0,2}|\d*\/\d+|\d+\/?|\/+|[xzXZ]|\[?\|\]?|:?\|:?|::|./g;
+function parseABCFile(str) {
+  var lines = str.split('\n'),
+      result = {},
+      context = result, timbre,
+      j, k, header, stems, key = {}, accent = { slurred: 0 }, voiceid, out;
+  // ABC files are parsed one line at a time.
+  for (j = 0; j < lines.length; ++j) {
+    // First, check to see if the line is a header line.
+    header = ABCheader.exec(lines[j]);
+    if (header) {
+      handleInformation(header[1], header[2].trim());
+    } else if (/^\s*(?:%.*)?$/.test(lines[j])) {
+      // Skip blank and comment lines.
+      continue;
+    } else {
+      // Parse the notes.
+      parseABCNotes(lines[j]);
+    }
+  }
+  var infer = ['unitnote', 'unitbeat', 'tempo'];
+  if (result.voice) {
+    out = [];
+    for (j in result.voice) {
+      if (result.voice[j].stems && result.voice[j].stems.length) {
+        // Calculate times for all the tied notes.  This happens at the end
+        // because in principle, the first note of a song could be tied all
+        // the way through to the last note.
+        processTies(result.voice[j].stems);
+        // Bring up inferred tempo values from voices if not specified
+        // in the header.
+        for (k = 0; k < infer.length; ++k) {
+          if (!(infer[k] in result) && (infer[k] in result.voice[j])) {
+            result[infer[k]] = result.voice[j][infer[k]];
+          }
+        }
+        // Remove this internal state variable;
+        delete result.voice[j].accent;
       } else {
-        // The tempo-number (e.g., 120)
-        tempo = tempo || Number(parts[j]);
+        out.push(j);
       }
     }
-    if (unit) {
-      beatinfo.unitbeat = unit;
-    }
-    if (tempo) {
-      beatinfo.tempo = tempo;
+    // Delete any voices that had no stems.
+    for (j = 0; j < out.length; ++j) {
+      delete result.voice[out[j]];
     }
   }
-  // Run through all the notes, adding up time for tied notes,
-  // and marking notes that were held over with holdover = true.
-  function processTies(stems) {
-    var tied = {}, nextTied, j, k, note, firstNote;
-    for (j = 0; j < stems.length; ++j) {
-      nextTied = {};
-      for (k = 0; k < stems[j].notes.length; ++k) {
-        firstNote = note = stems[j].notes[k];
-        if (tied.hasOwnProperty(note.pitch)) {  // Pitch was tied from before.
-          firstNote = tied[note.pitch];   // Get the earliest note in the tie.
-          firstNote.time += note.time;    // Extend its time.
-          note.holdover = true;           // Silence this note as a holdover.
-        }
-        if (note.tie) {                   // This note is tied with the next.
-          nextTied[note.pitch] = firstNote;  // Save it away.
+  return result;
+
+
+  ////////////////////////////////////////////////////////////////////////
+  // Parsing helper functions below.
+  ////////////////////////////////////////////////////////////////////////
+
+
+  // Processes header fields such as V: voice, which may appear at the
+  // top of the ABC file, or in the ABC body in a [V:voice] directive.
+  function handleInformation(field, value) {
+    // The following headers are recognized and processed.
+    switch(field) {
+      case 'V':
+        // A V: header switches voices if in the body.
+        // If in the header, then it is just advisory.
+        if (context !== result) {
+          startVoiceContext(value.split(' ')[0]);
         }
-      }
-      tied = nextTied;
+        break;
+      case 'M':
+        parseMeter(value, context);
+        break;
+      case 'L':
+        parseUnitNote(value, context);
+        break;
+      case 'Q':
+        parseTempo(value, context);
+        break;
     }
-  }
-  // Returns a map of A-G -> accidentals, according to the key signature.
-  // When n is zero, there are no accidentals (e.g., C major or A minor).
-  // When n is positive, there are n sharps (e.g., for G major, n = 1).
-  // When n is negative, there are -n flats (e.g., for F major, n = -1).
-  function accidentals(n) {
-    var sharps = 'FCGDAEB',
-        result = {}, j;
-    if (!n) {
-      return result;
+    // All headers (including unrecognized ones) are
+    // just accumulated as properties. Repeated header
+    // lines are accumulated as multiline properties.
+    if (context.hasOwnProperty(field)) {
+      context[field] += '\n' + value;
+    } else {
+      context[field] = value;
     }
-    if (n > 0) {  // Handle sharps.
-      for (j = 0; j < n && j < 7; ++j) {
-        result[sharps.charAt(j)] = '^';
-      }
-    } else {  // Flats are in the opposite order.
-      for (j = 0; j > n && j > -7; --j) {
-        result[sharps.charAt(6 + j)] = '_';
+    // The K header is special: it should be the last one
+    // before the voices and notes begin.
+    if (field == 'K') {
+      key = keysig(value);
+      if (context === result) {
+        startVoiceContext(firstVoiceName());
       }
     }
-    return result;
   }
-  // Decodes the key signature line (e.g., K: C#m) at the front of an ABC tune.
-  // Supports the whole range of scale systems listed in the ABC spec.
-  function keysig(keyname) {
-    if (!keyname) { return {}; }
-    var key, sigcodes = {
-      // Major
-      'c#':7, 'f#':6, 'b':5, 'e':4, 'a':3, 'd':2, 'g':1, 'c':0,
-      'f':-1, 'bb':-2, 'eb':-3, 'ab':-4, 'db':-5, 'gb':-6, 'cb':-7,
-      // Minor
-      'a#m':7, 'd#m':6, 'g#m':5, 'c#m':4, 'f#m':3, 'bm':2, 'em':1, 'am':0,
-      'dm':-1, 'gm':-2, 'cm':-3, 'fm':-4, 'bbm':-5, 'ebm':-6, 'abm':-7,
-      // Mixolydian
-      'g#mix':7, 'c#mix':6, 'f#mix':5, 'bmix':4, 'emix':3,
-      'amix':2, 'dmix':1, 'gmix':0, 'cmix':-1, 'fmix':-2,
-      'bbmix':-3, 'ebmix':-4, 'abmix':-5, 'dbmix':-6, 'gbmix':-7,
-      // Dorian
-      'd#dor':7, 'g#dor':6, 'c#dor':5, 'f#dor':4, 'bdor':3,
-      'edor':2, 'ador':1, 'ddor':0, 'gdor':-1, 'cdor':-2,
-      'fdor':-3, 'bbdor':-4, 'ebdor':-5, 'abdor':-6, 'dbdor':-7,
-      // Phrygian
-      'e#phr':7, 'a#phr':6, 'd#phr':5, 'g#phr':4, 'c#phr':3,
-      'f#phr':2, 'bphr':1, 'ephr':0, 'aphr':-1, 'dphr':-2,
-      'gphr':-3, 'cphr':-4, 'fphr':-5, 'bbphr':-6, 'ebphr':-7,
-      // Lydian
-      'f#lyd':7, 'blyd':6, 'elyd':5, 'alyd':4, 'dlyd':3,
-      'glyd':2, 'clyd':1, 'flyd':0, 'bblyd':-1, 'eblyd':-2,
-      'ablyd':-3, 'dblyd':-4, 'gblyd':-5, 'cblyd':-6, 'fblyd':-7,
-      // Locrian
-      'b#loc':7, 'e#loc':6, 'a#loc':5, 'd#loc':4, 'g#loc':3,
-      'c#loc':2, 'f#loc':1, 'bloc':0, 'eloc':-1, 'aloc':-2,
-      'dloc':-3, 'gloc':-4, 'cloc':-5, 'floc':-6, 'bbloc':-7
-    };
-    var k = keyname.replace(/\s+/g, '').toLowerCase().substr(0, 5);
-    var scale = k.match(/maj|min|mix|dor|phr|lyd|loc|m/);
-    if (scale) {
-      if (scale == 'maj') {
-        key = k.substr(0, scale.index);
-      } else if (scale == 'min') {
-        key = k.substr(0, scale.index + 1);
-      } else {
-        key = k.substr(0, scale.index + scale[0].length);
-      }
-    } else {
-      key = /^[a-g][#b]?/.exec(k) || '';
+
+  // Shifts context to a voice with the given id given.  If no id
+  // given, then just sticks with the current voice.  If the current
+  // voice is unnamed and empty, renames the current voice.
+  function startVoiceContext(id) {
+    id = id || '';
+    if (!id && context !== result) {
+      return;
     }
-    var result = accidentals(sigcodes[key]);
-    var extras = keyname.substr(key.length).match(/(_+|=|\^+)[a-g]/ig);
-    if (extras) {
-      for (j = 0; j < extras.length; ++j) {
-        var note = extras[j].charAt(extras[j].length - 1).toUpperCase();
-        if (extras[j].charAt(0) == '=') {
-          delete result[note];
-        } else {
-          result[note] = extras[j].substr(0, extras[j].length - 1);
-        }
-      }
+    if (!result.voice) {
+      result.voice = {};
+    }
+    if (result.voice.hasOwnProperty(id)) {
+      // Resume a named voice.
+      context = result.voice[id];
+      accent = context.accent;
+    } else {
+      // Start a new voice.
+      context = { id: id, accent: { slurred: 0 } };
+      result.voice[id] = context;
+      accent = context.accent;
     }
-    return result;
   }
-  // Peeks and looks for a prefix of the form [V:voiceid].
-  function peekABCVoice(line) {
-    var match = /^\[V:([^\]\s]*)\]/.exec(line);
-    if (!match) return null;
-    return match[1];
+
+  // For picking a default voice, looks for the first voice name.
+  function firstVoiceName() {
+    if (result.V) {
+      return result.V.split(/\s+/)[0];
+    } else {
+      return '';
+    }
   }
+
   // Parses a single line of ABC notes (i.e., not a header line).
   //
   // We process an ABC song stream by dividing it into tokens, each of
@@ -4494,9 +4952,8 @@ var Instrument = (function() {
   //
   // Then a song is just a sequence of stems interleaved with other
   // decorations such as dynamics markings and measure delimiters.
-  var ABCtoken = /(?:^\[V:[^\]\s]*\])|\s+|%[^\n]*|![^\s!:|\[\]]*!|\+[^+|!]*\+|[_<>@^]?"[^"]*"|\[|\]|>+|<+|(?:(?:\^+|_+|=|)[A-Ga-g](?:,+|'+|))|\(\d+(?::\d+){0,2}|\d*\/\d+|\d+\/?|\/+|[xzXZ]|\[?\|\]?|:?\|:?|::|./g;
-  function parseABCNotes(str, key, accent) {
-    var tokens = str.match(ABCtoken), result = [], parsed = null,
+  function parseABCNotes(str) {
+    var tokens = str.match(ABCtoken), parsed = null,
         index = 0, dotted = 0, beatlet = null, t;
     if (!tokens) {
       return null;
@@ -4504,8 +4961,12 @@ var Instrument = (function() {
     while (index < tokens.length) {
       // Ignore %comments and !markings!
       if (/^[\s%]/.test(tokens[index])) { index++; continue; }
-      if (/^\[V:\S*\]$/.test(tokens[index])) {
-        // Voice id from [V:id] is handled in peekABCVoice.
+      // Handle inline [X:...] information fields
+      if (/^\[[A-Za-z]:[^\]]*\]$/.test(tokens[index])) {
+        handleInformation(
+          tokens[index].substring(1, 2),
+          tokens[index].substring(3, tokens[index].length - 1).trim()
+        );
         index++;
         continue;
       }
@@ -4538,9 +4999,9 @@ var Instrument = (function() {
           accent.slurred -= 1;
           if (accent.slurred <= 0) {
             accent.slurred = 0;
-            if (result.length >= 1) {
+            if (context.stems && context.stems.length >= 1) {
               // The last notes in a slur are not slurred.
-              slurStem(result[result.length - 1], false);
+              slurStem(context.stems[context.stems.length - 1], false);
             }
           }
         }
@@ -4571,26 +5032,171 @@ var Instrument = (function() {
           beatlet = null;
         }
       }
-      // If syncopated with > or < notation, shift part of a beat
-      // between this stem and the previous one.
-      if (dotted && result.length) {
-        if (dotted > 0) {
-          t = (1 - Math.pow(0.5, dotted)) * parsed.stem.time;
+      // If syncopated with > or < notation, shift part of a beat
+      // between this stem and the previous one.
+      if (dotted && context.stems && context.stems.length) {
+        if (dotted > 0) {
+          t = (1 - Math.pow(0.5, dotted)) * parsed.stem.time;
+        } else {
+          t = (Math.pow(0.5, -dotted) - 1) *
+              context.stems[context.stems.length - 1].time;
+        }
+        syncopateStem(context.stems[context.stems.length - 1], t);
+        syncopateStem(parsed.stem, -t);
+      }
+      dotted = 0;
+      // Slur all the notes contained within a strem.
+      if (accent.slurred) {
+        slurStem(parsed.stem, true);
+      }
+      // Start a default voice if we're not in a voice yet.
+      if (context === result) {
+        startVoiceContext(firstVoiceName());
+      }
+      if (!('stems' in context)) { context.stems = []; }
+      // Add the stem to the sequence of stems for this voice.
+      context.stems.push(parsed.stem);
+      // Advance the parsing index since a stem is multiple tokens.
+      index = parsed.index;
+    }
+  }
+
+  // Parse M: lines.  "3/4" is 3/4 time and "C" is 4/4 (common) time.
+  function parseMeter(mline, beatinfo) {
+    var d = /^C/.test(mline) ? 4/4 : durationToTime(mline);
+    if (!d) { return; }
+    if (!beatinfo.unitnote) {
+      if (d < 0.75) {
+        beatinfo.unitnote = 1/16;
+      } else {
+        beatinfo.unitnote = 1/8;
+      }
+    }
+  }
+  // Parse L: lines, e.g., "1/8".
+  function parseUnitNote(lline, beatinfo) {
+    var d = durationToTime(lline);
+    if (!d) { return; }
+    beatinfo.unitnote = d;
+  }
+  // Parse Q: line, e.g., "1/4=66".
+  function parseTempo(qline, beatinfo) {
+    var parts = qline.split(/\s+|=/), j, unit = null, tempo = null;
+    for (j = 0; j < parts.length; ++j) {
+      // It could be reversed, like "66=1/4", or just "120", so
+      // determine what is going on by looking for a slash etc.
+      if (parts[j].indexOf('/') >= 0 || /^[1-4]$/.test(parts[j])) {
+        // The note-unit (e.g., 1/4).
+        unit = unit || durationToTime(parts[j]);
+      } else {
+        // The tempo-number (e.g., 120)
+        tempo = tempo || Number(parts[j]);
+      }
+    }
+    if (unit) {
+      beatinfo.unitbeat = unit;
+    }
+    if (tempo) {
+      beatinfo.tempo = tempo;
+    }
+  }
+  // Run through all the notes, adding up time for tied notes,
+  // and marking notes that were held over with holdover = true.
+  function processTies(stems) {
+    var tied = {}, nextTied, j, k, note, firstNote;
+    for (j = 0; j < stems.length; ++j) {
+      nextTied = {};
+      for (k = 0; k < stems[j].notes.length; ++k) {
+        firstNote = note = stems[j].notes[k];
+        if (tied.hasOwnProperty(note.pitch)) {  // Pitch was tied from before.
+          firstNote = tied[note.pitch];   // Get the earliest note in the tie.
+          firstNote.time += note.time;    // Extend its time.
+          note.holdover = true;           // Silence this note as a holdover.
+        }
+        if (note.tie) {                   // This note is tied with the next.
+          nextTied[note.pitch] = firstNote;  // Save it away.
+        }
+      }
+      tied = nextTied;
+    }
+  }
+  // Returns a map of A-G -> accidentals, according to the key signature.
+  // When n is zero, there are no accidentals (e.g., C major or A minor).
+  // When n is positive, there are n sharps (e.g., for G major, n = 1).
+  // When n is negative, there are -n flats (e.g., for F major, n = -1).
+  function accidentals(n) {
+    var sharps = 'FCGDAEB',
+        result = {}, j;
+    if (!n) {
+      return result;
+    }
+    if (n > 0) {  // Handle sharps.
+      for (j = 0; j < n && j < 7; ++j) {
+        result[sharps.charAt(j)] = '^';
+      }
+    } else {  // Flats are in the opposite order.
+      for (j = 0; j > n && j > -7; --j) {
+        result[sharps.charAt(6 + j)] = '_';
+      }
+    }
+    return result;
+  }
+  // Decodes the key signature line (e.g., K: C#m) at the front of an ABC tune.
+  // Supports the whole range of scale systems listed in the ABC spec.
+  function keysig(keyname) {
+    if (!keyname) { return {}; }
+    var kkey, sigcodes = {
+      // Major
+      'c#':7, 'f#':6, 'b':5, 'e':4, 'a':3, 'd':2, 'g':1, 'c':0,
+      'f':-1, 'bb':-2, 'eb':-3, 'ab':-4, 'db':-5, 'gb':-6, 'cb':-7,
+      // Minor
+      'a#m':7, 'd#m':6, 'g#m':5, 'c#m':4, 'f#m':3, 'bm':2, 'em':1, 'am':0,
+      'dm':-1, 'gm':-2, 'cm':-3, 'fm':-4, 'bbm':-5, 'ebm':-6, 'abm':-7,
+      // Mixolydian
+      'g#mix':7, 'c#mix':6, 'f#mix':5, 'bmix':4, 'emix':3,
+      'amix':2, 'dmix':1, 'gmix':0, 'cmix':-1, 'fmix':-2,
+      'bbmix':-3, 'ebmix':-4, 'abmix':-5, 'dbmix':-6, 'gbmix':-7,
+      // Dorian
+      'd#dor':7, 'g#dor':6, 'c#dor':5, 'f#dor':4, 'bdor':3,
+      'edor':2, 'ador':1, 'ddor':0, 'gdor':-1, 'cdor':-2,
+      'fdor':-3, 'bbdor':-4, 'ebdor':-5, 'abdor':-6, 'dbdor':-7,
+      // Phrygian
+      'e#phr':7, 'a#phr':6, 'd#phr':5, 'g#phr':4, 'c#phr':3,
+      'f#phr':2, 'bphr':1, 'ephr':0, 'aphr':-1, 'dphr':-2,
+      'gphr':-3, 'cphr':-4, 'fphr':-5, 'bbphr':-6, 'ebphr':-7,
+      // Lydian
+      'f#lyd':7, 'blyd':6, 'elyd':5, 'alyd':4, 'dlyd':3,
+      'glyd':2, 'clyd':1, 'flyd':0, 'bblyd':-1, 'eblyd':-2,
+      'ablyd':-3, 'dblyd':-4, 'gblyd':-5, 'cblyd':-6, 'fblyd':-7,
+      // Locrian
+      'b#loc':7, 'e#loc':6, 'a#loc':5, 'd#loc':4, 'g#loc':3,
+      'c#loc':2, 'f#loc':1, 'bloc':0, 'eloc':-1, 'aloc':-2,
+      'dloc':-3, 'gloc':-4, 'cloc':-5, 'floc':-6, 'bbloc':-7
+    };
+    var k = keyname.replace(/\s+/g, '').toLowerCase().substr(0, 5);
+    var scale = k.match(/maj|min|mix|dor|phr|lyd|loc|m/);
+    if (scale) {
+      if (scale == 'maj') {
+        kkey = k.substr(0, scale.index);
+      } else if (scale == 'min') {
+        kkey = k.substr(0, scale.index + 1);
+      } else {
+        kkey = k.substr(0, scale.index + scale[0].length);
+      }
+    } else {
+      kkey = /^[a-g][#b]?/.exec(k) || '';
+    }
+    var result = accidentals(sigcodes[kkey]);
+    var extras = keyname.substr(kkey.length).match(/(_+|=|\^+)[a-g]/ig);
+    if (extras) {
+      for (var j = 0; j < extras.length; ++j) {
+        var note = extras[j].charAt(extras[j].length - 1).toUpperCase();
+        if (extras[j].charAt(0) == '=') {
+          delete result[note];
         } else {
-          t = (Math.pow(0.5, -dotted) - 1) * result[result.length - 1].time;
+          result[note] = extras[j].substr(0, extras[j].length - 1);
         }
-        syncopateStem(result[result.length - 1], t);
-        syncopateStem(parsed.stem, -t);
-      }
-      dotted = 0;
-      // Slur all the notes contained within a strem.
-      if (accent.slurred) {
-        slurStem(parsed.stem, true);
       }
-      // Add the stem to the sequence of stems for this voice.
-      result.push(parsed.stem);
-      // Advance the parsing index since a stem is multiple tokens.
-      index = parsed.index;
     }
     return result;
   }
@@ -4598,7 +5204,6 @@ var Instrument = (function() {
   function syncopateStem(stem, t) {
     var j, note, stemtime = stem.time, newtime = stemtime + t;
     stem.time = newtime;
-    syncopateStem
     for (j = 0; j < stem.notes.length; ++j) {
       note = stem.notes[j];
       // Only adjust a note's duration if it matched the stem's duration.
@@ -4826,47 +5431,6 @@ var Instrument = (function() {
     }
     return stripNatural(pitch);
   }
-  // Converts a midi note number to a frequency in Hz.
-  function midiToFrequency(midi) {
-    return 440 * Math.pow(2, (midi - 69) / 12);
-  }
-  // Some constants.
-  var noteNum =
-      {C:0,D:2,E:4,F:5,G:7,A:9,B:11,c:12,d:14,e:16,f:17,g:19,a:21,b:23};
-  var accSym =
-      { '^':1, '': 0, '=':0, '_':-1 };
-  var noteName =
-      ['C', '^C', 'D', '_E', 'E', 'F', '^F', 'G', '_A', 'A', '_B', 'B',
-       'c', '^c', 'd', '_e', 'e', 'f', '^f', 'g', '_a', 'a', '_b', 'b'];
-  // Converts a frequency in Hz to the closest midi number.
-  function frequencyToMidi(freq) {
-    return Math.round(69 + Math.log(freq / 440) * 12 / Math.LN2);
-  }
-  // Converts an ABC pitch (such as "^G,,") to a midi note number.
-  function pitchToMidi(pitch) {
-    var m = /^(\^+|_+|=|)([A-Ga-g])([,']*)$/.exec(pitch);
-    if (!m) { return null; }
-    var octave = m[3].replace(/,/g, '').length - m[3].replace(/'/g, '').length;
-    var semitone =
-        noteNum[m[2]] + accSym[m[1].charAt(0)] * m[1].length + 12 * octave;
-    return semitone + 60; // 60 = midi code middle "C".
-  }
-  // Converts a midi number to an ABC notation pitch.
-  function midiToPitch(midi) {
-    var index = ((midi - 72) % 12);
-    if (midi > 60 || index != 0) { index += 12; }
-    var octaves = Math.round((midi - index - 60) / 12),
-        result = noteName[index];
-    while (octaves != 0) {
-      result += octaves > 0 ? "'" : ",";
-      octaves += octaves > 0 ? -1 : 1;
-    }
-    return result;
-  }
-  // Converts an ABC pitch to a frequency in Hz.
-  function pitchToFrequency(pitch) {
-    return midiToFrequency(pitchToMidi(pitch));
-  }
   // Converts an ABC duration to a number (e.g., "/3"->0.333 or "11/2"->1.5).
   function durationToTime(duration) {
     var m = /^(\d*)(?:\/(\d*))?$|^(\/+)$/.exec(duration), n, d, i = 0, ilen;
@@ -4885,123 +5449,7 @@ var Instrument = (function() {
     }
     return i + (n / d);
   }
-
-  // The default sound is a square wave with a pretty quick decay to zero.
-  var defaultTimbre = Instrument.defaultTimbre = {
-    wave: 'square',   // Oscillator type.
-    gain: 0.1,        // Overall gain at maximum attack.
-    attack: 0.002,    // Attack time at the beginning of a tone.
-    decay: 0.4,       // Rate of exponential decay after attack.
-    decayfollow: 0,   // Amount of decay shortening for higher notes.
-    sustain: 0,       // Portion of gain to sustain indefinitely.
-    release: 0.1,     // Release time after a tone is done.
-    cutoff: 0,        // Low-pass filter cutoff frequency.
-    cutfollow: 0,     // Cutoff adjustment, a multiple of oscillator freq.
-    resonance: 0,     // Low-pass filter resonance.
-    detune: 0         // Detune factor for a second oscillator.
-  };
-
-  // Norrmalizes a timbre object by making a copy that has exactly
-  // the right set of timbre fields, defaulting when needed.
-  // A timbre can specify any of the fields of defaultTimbre; any
-  // unspecified fields are treated as they are set in defaultTimbre.
-  function makeTimbre(options, atop) {
-    if (!options) {
-      options = {};
-    }
-    if (typeof(options) == 'string') {
-      // Abbreviation: name a wave to get a default timbre for that wave.
-      options = { wave: options };
-    }
-    var result = {}, key,
-        wt = atop && atop.wavetable && atop.wavetable[options.wave];
-    for (key in defaultTimbre) {
-      if (options.hasOwnProperty(key)) {
-        result[key] = options[key];
-      } else if (wt && wt.defs && wt.defs.hasOwnProperty(key)) {
-        result[key] = wt.defs[key];
-      } else{
-        result[key] = defaultTimbre[key];
-      }
-    }
-    return result;
-  }
-
-  var whiteNoiseBuf = null;
-  function getWhiteNoiseBuf() {
-    if (whiteNoiseBuf == null) {
-      var ac = getAudioTop().ac,
-          bufferSize = 2 * ac.sampleRate,
-          whiteNoiseBuf = ac.createBuffer(1, bufferSize, ac.sampleRate),
-          output = whiteNoiseBuf.getChannelData(0);
-      for (var i = 0; i < bufferSize; i++) {
-        output[i] = Math.random() * 2 - 1;
-      }
-    }
-    return whiteNoiseBuf;
-  }
-
-  // This utility function creates an oscillator at the given frequency
-  // and the given wavename.  It supports lookups in a static wavetable,
-  // defined right below.
-  function makeOscillator(atop, wavename, freq) {
-    if (wavename == 'noise') {
-      var whiteNoise = atop.ac.createBufferSource();
-      whiteNoise.buffer = getWhiteNoiseBuf();
-      whiteNoise.loop = true;
-      return whiteNoise;
-    }
-    var wavetable = atop.wavetable, o = atop.ac.createOscillator(),
-        k, pwave, bwf, wf;
-    try {
-      if (wavetable.hasOwnProperty(wavename)) {
-        // Use a customized wavetable.
-        pwave = wavetable[wavename].wave;
-        if (wavetable[wavename].freq) {
-          bwf = 0;
-          // Look for a higher-frequency variant.
-          for (k in wavetable[wavename].freq) {
-            wf = Number(k);
-            if (freq > wf && wf > bwf) {
-              bwf = wf;
-              pwave = wavetable[wavename].freq[bwf];
-            }
-          }
-        }
-        if (!o.setPeriodicWave && o.setWaveTable) {
-          // The old API name: Safari 7 still uses this.
-          o.setWaveTable(pwave);
-        } else {
-          // The new API name.
-          o.setPeriodicWave(pwave);
-        }
-      } else {
-        o.type = wavename;
-      }
-    } catch(e) {
-      if (window.console) { window.console.log(e); }
-      // If unrecognized, just use square.
-      // TODO: support "noise" or other wave shapes.
-      o.type = 'square';
-    }
-    o.frequency.value = freq;
-    return o;
-  }
-
-  // Accepts either an ABC pitch or a midi number and converts to midi.
-  Instrument.pitchToMidi = function(n) {
-    if (typeof(n) == 'string') { return pitchToMidi(n); }
-    return n;
-  }
-
-  // Accepts either an ABC pitch or a midi number and converts to ABC pitch.
-  Instrument.midiToPitch = function(n) {
-    if (typeof(n) == 'number') { return midiToPitch(n); }
-    return n;
-  }
-
-  return Instrument;
-})();
+}
 
 // wavetable is a table of names for nonstandard waveforms.
 // The table maps names to objects that have wave: and freq:
@@ -5094,13 +5542,15 @@ function makeWavetable(ac) {
       // TODO: this approach attenuates low notes too much -
       // this should be fixed.
       defs: { wave: 'piano', gain: 0.5,
-              attack: 0.002, decay: 0.4, sustain: 0.005, release: 0.1,
+              attack: 0.002, decay: 0.25, sustain: 0.03, release: 0.1,
               decayfollow: 0.7,
-              cutoff: 800, cutfollow: 0.1, resonance: 1, detune: 1.001 }
+              cutoff: 800, cutfollow: 0.1, resonance: 1, detune: 0.9994 }
     }
   });
 }
 
+// End of musical.js copy.
+
 
 //////////////////////////////////////////////////////////////////////////
 // SYNC, REMOVE SUPPORT
@@ -5115,7 +5565,9 @@ function gatherelts(args) {
   }
   // Gather elements passed as arguments.
   for (j = 0; j < argcount; ++j) {
-    if (args[j].constructor === $) {
+    if (!args[j]) {
+      continue;  // Skip null args.
+    } else if (args[j].constructor === $) {
       elts.push.apply(elts, args[j].toArray());  // Unpack jQuery.
     } else if ($.isArray(args[j])) {
       elts.push.apply(elts, args[j]);  // Accept an array.
@@ -5140,7 +5592,8 @@ function sync() {
     // Unblock all animation queues.
     for (j = 0; j < cb.length; ++j) { cb[j](); }
   }
-  for (j = 0; j < elts.length; ++j) {
+  if (elts.length > 1) for (j = 0; j < elts.length; ++j) {
+    queueWaitIfLoadingImg(elts[j]);
     $(elts[j]).queue(function(next) {
       if (ready) {
         ready.push(next);
@@ -5259,7 +5712,7 @@ function globalhelp(obj) {
     helpwrite('This is an unassigned value.');
     return helpok;
   }
-  if (obj === window) {
+  if (obj === global) {
     helpwrite('The global window object represents the browser window.');
     return helpok;
   }
@@ -5279,7 +5732,7 @@ function globalhelp(obj) {
   helplist = [];
   for (var name in helptable) {
     if (helptable[name].helptext && helptable[name].helptext.length &&
-        (!(name in window) || typeof(window[name]) == 'function')) {
+        (!(name in global) || typeof(global[name]) == 'function')) {
       helplist.push(name);
     }
   }
@@ -5310,6 +5763,7 @@ function canElementMoveInstantly(elem) {
   // moving at speed Infinity.
   var atime;
   return (elem && $.queue(elem).length == 0 &&
+      (!elem.parentElement || !elem.parentElement.style.transform) &&
       ((atime = animTime(elem)) === 0 || $.fx.speeds[atime] === 0));
 }
 
@@ -5324,28 +5778,8 @@ function visiblePause(elem, seconds) {
     ms = seconds * 1000;
   }
   var thissel = $(elem);
-  if (ms) {
-    if (thissel.is(':visible')) {
-      // Generic indication of taking some time for an action
-      // A visual indicator of a pen color change.
-      var circle = new Turtle('gray radius');
-      circle.css({
-        zIndex: 1,
-        turtlePosition: thissel.css('turtlePosition'),
-        turtleRotation: thissel.css('turtleRotation')
-      });
-      circle.animate({
-        turtleRotation: '+=360'
-      }, ms, 'linear');
-      thissel.queue(function(next) {
-        circle.done(function() {
-          circle.remove();
-          next();
-        });
-      });
-    } else {
-      thissel.delay(ms);
-    }
+  if (ms > 0) {
+    thissel.delay(ms);
   }
 }
 
@@ -5361,10 +5795,10 @@ function doNothing() {}
 // if the argument list is longer than argcount, or null otherwise.
 function continuationArg(args, argcount) {
   argcount = argcount || 0;
-  if (args.length <= argcount || typeof(args[args.length - 1]) != 'function') {
-    return null;
-  }
-  return args[args.length - 1];
+  if (args.length <= argcount) { return null; }
+  var lastarg = args[args.length - 1];
+  if (typeof(lastarg) === 'function' && !lastarg.helpname) { return lastarg; }
+  return null;
 }
 
 // This function helps implement the continuation-passing-style
@@ -5380,6 +5814,7 @@ function continuationArg(args, argcount) {
 //        as each of the elements' animations completes; when the jth
 //        element completes, resolve(j) should be called.  The last time
 //        it is called, it will trigger the continuation callback, if any.
+//        Call resolve(j, true) if a corner pen state should be marked.
 //    resolver: a function that returns a closure that calls resolve(j).
 //    start: a function to be called once to enable triggering of the callback.
 // the last argument in an argument list if it is a function, and if the
@@ -5392,10 +5827,13 @@ function setupContinuation(thissel, name, args, argcount) {
       countdown = length + 1,
       sync = true,
       debugId = debug.nextId();
-  function resolve(j) {
+  function resolve(j, corner) {
     if (j != null) {
-      debug.reportEvent('resolve',
-          [name, debugId, length, j, thissel && thissel[j]]);
+      var elem = thissel && thissel[j];
+      if (corner && elem) {
+        flushPenState(elem, $.data(elem, 'turtleData'), true);
+      }
+      debug.reportEvent('resolve', [name, debugId, length, j, elem]);
     }
     if ((--countdown) == 0) {
       // A subtlety: if we still have not yet finished setting things up
@@ -5428,7 +5866,7 @@ function setupContinuation(thissel, name, args, argcount) {
     args: mainargs,
     appear: appear,
     resolve: resolve,
-    resolver: function(j) { return function() { resolve(j); }; },
+    resolver: function(j, c) { return function() { resolve(j, c); }; },
     exit: function exit() {
       debug.reportEvent('exit', [name, debugId, length, mainargs]);
       // Resolve one extra countdown; this is needed for a done callback
@@ -5481,29 +5919,72 @@ function wrappredicate(name, helptext, fn) {
 // Wrapglobalcommand does boilerplate setup for global commands that should
 // queue on the main turtle queue when there is a main turtle, but that
 // should execute immediately otherwise.
-function wrapglobalcommand(name, helptext, fn) {
+function wrapglobalcommand(name, helptext, fn, fnfilter) {
   var wrapper = function globalcommandwrapper() {
     checkForHungLoop(name);
     if (interrupted) { throw new Error(name + ' interrupted'); }
-    if (global_turtle) {
+    var early = null;
+    var argcount = 0;
+    var animate = global_turtle;
+    if (fnfilter) {
+      early = fnfilter.apply(null, arguments);
+      argcount = arguments.length;
+      animate = global_turtle_animating();
+    }
+    if (animate) {
       var thissel = $(global_turtle).eq(0),
           args = arguments,
-          cc = setupContinuation(thissel, name, arguments, 0);
+          cc = setupContinuation(thissel, name, arguments, argcount);
       thissel.plan(function(j, elem) {
         cc.appear(j);
-        fn.apply(null, args);
+        fn.apply(early, args);
         this.plan(cc.resolver(j));
       });
       cc.exit();
     } else {
-      cc = setupContinuation(null, name, arguments, 0);
-      fn.apply(null, arguments);
+      cc = setupContinuation(null, name, arguments, argcount);
+      fn.apply(early, arguments);
       cc.exit();
     }
+    if (early) {
+      if (early.result && early.result.constructor === jQuery && global_turtle) {
+        sync(global_turtle, early.result);
+      }
+      return early.result;
+    }
   };
   return wrapraw(name, helptext, wrapper);
 }
 
+function wrapwindowevent(name, helptext) {
+  return wrapraw(name, helptext, function(d, fn) {
+    var forKey = /^key/.test(name),
+        forMouse = /^mouse|click$/.test(name),
+        filter = forMouse ? 'input,button' : forKey ?
+            'textarea,input:not([type]),input[type=text],input[type=password]'
+            : null;
+    if (forKey) { focusWindowIfFirst(); }
+    if (fn == null && typeof(d) == 'function') { fn = d; d = null; }
+    $(global).on(name + '.turtleevent', null, d, !filter ? fn : function(e) {
+      if (interrupted) return;
+      if ($(e.target).closest(filter).length) { return; }
+      return fn.apply(this, arguments);
+    });
+  });
+}
+
+function windowhasturtleevent() {
+  var events = $._data(global, 'events');
+  if (!events) return false;
+  for (var type in events) {
+    var entries = events[type];
+    for (var j = 0; j < entries.length; ++j) {
+      if (entries[j].namespace == 'turtleevent') return true;
+    }
+  }
+  return false;
+}
+
 // Wrapraw sets up help text for a function (such as "sqrt") that does
 // not need any other setup.
 function wrapraw(name, helptext, fn) {
@@ -5521,8 +6002,10 @@ function wrapraw(name, helptext, fn) {
 function rtlt(cc, degrees, radius) {
   if (degrees == null) {
     degrees = 90;  // zero-argument default.
+  } else {
+    degrees = normalizeRotationDelta(degrees);
   }
-  var elem, left = (cc.name === 'lt');
+  var elem, left = (cc.name === 'lt'), intick = insidetick;
   if ((elem = canMoveInstantly(this)) &&
       (radius === 0 || (radius == null && getTurningRadius(elem) === 0))) {
     cc.appear(0);
@@ -5535,44 +6018,215 @@ function rtlt(cc, degrees, radius) {
     this.plan(function(j, elem) {
       cc.appear(j);
       this.animate({turtleRotation: operator + cssNum(degrees || 0) + 'deg'},
-          animTime(elem), animEasing(elem), cc.resolver(j));
+          animTime(elem, intick), animEasing(elem), cc.resolver(j));
     });
     return this;
   } else {
     this.plan(function(j, elem) {
       cc.appear(j);
-      var oldRadius = this.css('turtleTurningRadius');
-      this.css({turtleTurningRadius: (degrees < 0) ? -radius : radius});
+      var state = getTurtleData(elem),
+          oldRadius = state.turningRadius,
+          newRadius = (degrees < 0) ? -radius : radius,
+          addCorner = null;
+      if (state.style && state.down) {
+        addCorner = (function() {
+          var oldPos = getCenterInPageCoordinates(elem),
+              oldTs = readTurtleTransform(elem, true),
+              oldTransform = totalTransform2x2(elem.parentElement);
+          return (function() {
+            addArcBezierPaths(
+              state.corners[0],
+              oldPos,
+              oldTs.rot,
+              oldTs.rot + (left ? -degrees : degrees),
+              newRadius * (state.oldscale ? oldTs.sy : 1),
+              oldTransform);
+          });
+        })();
+      }
+      state.turningRadius = newRadius;
       this.animate({turtleRotation: operator + cssNum(degrees) + 'deg'},
-          animTime(elem), animEasing(elem));
+          animTime(elem, intick), animEasing(elem));
+      this.plan(function() {
+        if (addCorner) addCorner();
+        state.turningRadius = oldRadius;
+        cc.resolve(j, true);
+      });
+    });
+    return this;
+  }
+}
+
+// Deals with both fd and bk by negating amount if cc.name is 'bk'.
+function fdbk(cc, amount) {
+  if (amount == null) {
+    amount = 100;  // zero-argument default.
+  }
+  if (cc.name === 'bk') {
+    amount = -amount;
+  }
+  var elem, intick = insidetick;
+  if ((elem = canMoveInstantly(this))) {
+    cc.appear(0);
+    doQuickMove(elem, amount, 0);
+    cc.resolve(0, true);
+    return this;
+  }
+  this.plan(function(j, elem) {
+    cc.appear(j);
+    this.animate({turtleForward: '+=' + cssNum(amount || 0) + 'px'},
+        animTime(elem, intick), animEasing(elem), cc.resolver(j, true));
+  });
+  return this;
+}
+
+//////////////////////////////////////////////////////////////////////////
+// CARTESIAN MOVEMENT FUNCTIONS
+//////////////////////////////////////////////////////////////////////////
+
+function slide(cc, x, y) {
+  if ($.isArray(x)) {
+    y = x[1];
+    x = x[0];
+  }
+  if (!y) { y = 0; }
+  if (!x) { x = 0; }
+  var intick = insidetick;
+  this.plan(function(j, elem) {
+    cc && cc.appear(j);
+    this.animate({turtlePosition: displacedPosition(elem, y, x)},
+        animTime(elem, intick), animEasing(elem), cc && cc.resolver(j, true));
+  });
+  return this;
+}
+
+function movexy(cc, x, y) {
+  if ($.isArray(x)) {
+    y = x[1];
+    x = x[0];
+  }
+  if (!y) { y = 0; }
+  if (!x) { x = 0; }
+  var elem, intick = insidetick;
+  if ((elem = canMoveInstantly(this))) {
+    cc && cc.appear(0);
+    doQuickMoveXY(elem, x, y);
+    cc && cc.resolve(0);
+    return this;
+  }
+  this.plan(function(j, elem) {
+    cc && cc.appear(j);
+    var tr = getElementTranslation(elem);
+    this.animate(
+      { turtlePosition: cssNum(tr[0] + x) + ' ' + cssNum(tr[1] - y) },
+      animTime(elem, intick), animEasing(elem), cc && cc.resolver(j, true));
+  });
+  return this;
+}
+
+function moveto(cc, x, y) {
+  var position = x, localx = 0, localy = 0, limit = null, intick = insidetick;
+  if ($.isNumeric(position) && $.isNumeric(y)) {
+    // moveto x, y: use local coordinates.
+    localx = parseFloat(position);
+    localy = parseFloat(y);
+    position = null;
+    limit = null;
+  } else if ($.isArray(position)) {
+    // moveto [x, y], limit: use local coordinates (limit optional).
+    localx = position[0];
+    localy = position[1];
+    position = null;
+    limit = y;
+  } else if ($.isNumeric(y)) {
+    // moveto obj, limit: limited motion in the direction of obj.
+    limit = y;
+  }
+  // Otherwise moveto {pos}, limit: absolute motion with optional limit.
+  this.plan(function(j, elem) {
+    var pos = position;
+    if (pos === null) {
+      pos = $(homeContainer(elem)).pagexy();
+    }
+    if (pos && !isPageCoordinate(pos)) {
+      try {
+        pos = $(pos).pagexy();
+      } catch (e) {
+        return;
+      }
+    }
+    if (!pos || !isPageCoordinate(pos)) return;
+    if ($.isWindow(elem)) {
+      cc && cc.appear(j);
+      scrollWindowToDocumentPosition(pos, limit);
+      cc && cc.resolve(j);
+      return;
+    } else if (elem.nodeType === 9) {
+      return;
+    }
+    cc && cc.appear(j);
+    this.animate({turtlePosition:
+        computeTargetAsTurtlePosition(elem, pos, limit, localx, localy)},
+        animTime(elem, intick), animEasing(elem), cc && cc.resolver(j, true));
+  });
+  return this;
+}
+
+// Deals with jump, jumpxy, and jumpto functions
+function makejump(move) {
+  return (function(cc, x, y) {
+    this.plan(function(j, elem) {
+      cc.appear(j);
+      var down = this.css('turtlePenDown');
+      this.css({turtlePenDown: 'up'});
+      move.call(this, null, x, y);
       this.plan(function() {
-        this.css({turtleTurningRadius: oldRadius});
-        cc.resolve(j);
+        this.css({turtlePenDown: down});
+        cc.resolve(j, true);
       });
     });
     return this;
-  }
+  });
 }
 
-// Deals with both fd and bk by negating amount if cc.name is 'bk'.
-function fdbk(cc, amount) {
-  if (amount == null) {
-    amount = 100;  // zero-argument default.
-  }
-  if (cc.name === 'bk') {
-    amount = -amount;
-  }
-  var elem;
-  if ((elem = canMoveInstantly(this))) {
-    cc.appear(0);
-    doQuickMove(elem, amount, 0);
-    cc.resolve(0);
-    return this;
-  }
+//////////////////////////////////////////////////////////////////////////
+// SCALING FUNCTIONS
+// Support for old-fashioned scaling and new.
+//////////////////////////////////////////////////////////////////////////
+
+function elemOldScale(elem) {
+  var state = $.data(elem, 'turtleData');
+  return state && (state.oldscale != null) ? state.oldscale : 1;
+}
+
+function scaleCmd(cc, valx, valy) {
+  growImpl.call(this, true, cc, valx, valy);
+}
+
+function grow(cc, valx, valy) {
+  growImpl.call(this, false, cc, valx, valy);
+}
+
+function growImpl(oldscale, cc, valx, valy) {
+  if (valy === undefined) { valy = valx; }
+  // Disallow scaling to zero using this method.
+  if (!valx || !valy) { valx = valy = 1; }
+  var intick = insidetick;
   this.plan(function(j, elem) {
-    cc.appear(0);
-    this.animate({turtleForward: '+=' + cssNum(amount || 0) + 'px'},
-        animTime(elem), animEasing(elem), cc.resolver(0));
+    if (oldscale) {
+      getTurtleData(elem).oldscale *= valy;
+    }
+    cc.appear(j);
+    if ($.isWindow(elem) || elem.nodeType === 9) {
+      cc.resolve(j);
+      return;
+    }
+    var c = $.map($.css(elem, 'turtleScale').split(' '), parseFloat);
+    if (c.length === 1) { c.push(c[0]); }
+    c[0] *= valx;
+    c[1] *= valy;
+    this.animate({turtleScale: $.map(c, cssNum).join(' ')},
+          animTime(elem, intick), animEasing(elem), cc.resolver(j));
   });
   return this;
 }
@@ -5582,7 +6236,15 @@ function fdbk(cc, amount) {
 // Support for animated drawing of dots and boxes.
 //////////////////////////////////////////////////////////////////////////
 
+function drawingScale(elem, oldscale) {
+  var totalParentTransform = totalTransform2x2(elem.parentElement),
+      simple = isone2x2(totalParentTransform),
+      scale = simple ? 1 : decomposeSVD(totalParentTransform)[1];
+  return scale * elemOldScale(elem);
+}
+
 function animatedDotCommand(fillShape) {
+  var intick = insidetick;
   return (function(cc, style, diameter) {
     if ($.isNumeric(style)) {
       // Allow for parameters in either order.
@@ -5591,33 +6253,41 @@ function animatedDotCommand(fillShape) {
       diameter = t;
     }
     if (diameter == null) { diameter = 8.8; }
-    if (!style) { style = 'black'; }
-    var ps = parsePenStyle(style, 'fillStyle');
     this.plan(function(j, elem) {
+      var state = getTurtleData(elem),
+          penStyle = state.style;
+      if (!style) {
+        // If no color is specified, default to pen color, or black if no pen.
+        style = (penStyle && (penStyle.fillStyle || penStyle.strokeStyle)) ||
+            'black';
+      }
       cc.appear(j);
       var c = this.pagexy(),
           ts = readTurtleTransform(elem, true),
-          state = getTurtleData(elem),
+          ps = parsePenStyle(style, 'fillStyle'),
           drawOnCanvas = getDrawOnCanvas(state),
-          // Scale by sx.  (TODO: consider parent transforms.)
-          targetDiam = diameter * ts.sx,
+          sx = drawingScale(elem),
+          targetDiam = diameter * sx,
           animDiam = Math.max(0, targetDiam - 2),
           finalDiam = targetDiam + (ps.eraseMode ? 2 : 0),
           hasAlpha = /rgba|hsla/.test(ps.fillStyle);
+      if (null == ps.lineWidth && penStyle && penStyle.lineWidth) {
+        ps.lineWidth = penStyle.lineWidth;
+      }
       if (canMoveInstantly(this)) {
-        fillShape(drawOnCanvas, c, finalDiam, ts.rot, ps);
+        fillShape(drawOnCanvas, c, finalDiam, ts.rot, ps, true);
         cc.resolve(j);
       } else {
         this.queue(function(next) {
           $({radius: 0}).animate({radius: animDiam}, {
-            duration: animTime(elem),
+            duration: animTime(elem, intick),
             step: function() {
               if (!hasAlpha) {
-                fillShape(drawOnCanvas, c, this.radius, ts.rot, ps);
+                fillShape(drawOnCanvas, c, this.radius, ts.rot, ps, false);
               }
             },
             complete: function() {
-              fillShape(drawOnCanvas, c, finalDiam, ts.rot, ps);
+              fillShape(drawOnCanvas, c, finalDiam, ts.rot, ps, true);
               cc.resolve(j);
               next();
             }
@@ -5676,6 +6346,117 @@ function fillBox(drawOnCanvas, position, diameter, rot, style) {
   ctx.restore();
 }
 
+function fillArrow(drawOnCanvas, position, diameter, rot, style, drawhead) {
+  var ctx = drawOnCanvas.getContext('2d');
+  ctx.save();
+  applyPenStyle(ctx, style);
+  if (!style.strokeStyle && style.fillStyle) {
+    ctx.strokeStyle = style.fillStyle;
+  }
+  if (diameter !== Infinity) {
+    var c = Math.sin(rot / 180 * Math.PI),
+        s = -Math.cos(rot / 180 * Math.PI),
+        w = style.lineWidth || 1.62,
+        hx = position.pageX + diameter * c,
+        hy = position.pageY + diameter * s,
+        m = calcArrow(w, hx, hy, c, s),
+        ds = diameter - m.hs,
+        dx = ds * c,
+        dy = ds * s;
+    setCanvasPageTransform(ctx, drawOnCanvas);
+    if (ds > 0) {
+      ctx.beginPath();
+      ctx.moveTo(position.pageX, position.pageY);
+      ctx.lineTo(position.pageX + dx, position.pageY + dy);
+      ctx.stroke();
+    }
+    if (drawhead) {
+      drawArrowHead(ctx, m);
+    }
+  }
+  ctx.restore();
+}
+
+//////////////////////////////////////////////////////////////////////////
+// ARROW GEOMETRY
+//////////////////////////////////////////////////////////////////////////
+function calcArrow(w, x1, y1, cc, ss) {
+  var hw = Math.max(w * 1.25, w + 2),
+      hh = hw * 2,
+      hs = hh - hw / 2;
+  return {
+      hs: hs,
+      x1: x1,
+      y1: y1,
+      xm: x1 - cc * hs,
+      ym: y1 - ss * hs,
+      x2: x1 - ss * hw - cc * hh,
+      y2: y1 + cc * hw - ss * hh,
+      x3: x1 + ss * hw - cc * hh,
+      y3: y1 - cc * hw - ss * hh
+  };
+}
+
+function drawArrowHead(c, m) {
+  c.beginPath();
+  c.moveTo(m.x2, m.y2);
+  c.lineTo(m.x1, m.y1);
+  c.lineTo(m.x3, m.y3);
+  c.quadraticCurveTo(m.xm, m.ym, m.x2, m.y2);
+  c.closePath();
+  c.fill();
+}
+
+function drawArrowLine(c, w, x0, y0, x1, y1) {
+  var dx = x1 - x0,
+      dy = y1 - y0,
+      dd = Math.sqrt(dx * dx + dy * dy),
+      cc = dx / dd,
+      ss = dy / dd;
+  var m = calcArrow(w, x1, y1, cc, ss);
+  if (dd > m.hs) {
+    c.beginPath();
+    c.moveTo(x0, y0);
+    c.lineTo(m.xm, m.ym);
+    c.lineWidth = w;
+    c.stroke();
+  }
+  drawArrowHead(c, m);
+}
+
+//////////////////////////////////////////////////////////////////////////
+// VOICE SYNTHESIS
+// Method for uttering words.
+//////////////////////////////////////////////////////////////////////////
+function utterSpeech(words, cb) {
+  var pollTimer = null;
+  function complete() {
+    if (pollTimer) { clearInterval(pollTimer); }
+    if (cb) { cb(); }
+  }
+  if (!global.speechSynthesis) {
+    console.log('No speech synthesis: ' + words);
+    complete();
+    return;
+  }
+  try {
+    var msg = new global.SpeechSynthesisUtterance(words);
+    msg.addEventListener('end', complete);
+    msg.addEventListener('error', complete);
+    msg.lang = navigator.language || 'en-GB';
+    global.speechSynthesis.speak(msg);
+    pollTimer = setInterval(function() {
+      // Chrome speech synthesis fails to deliver an 'end' event
+      // sometimes, so we also poll every 250ms.
+      if (global.speechSynthesis.pending || global.speechSynthesis.speaking) return;
+      complete();
+    }, 250);
+  } catch (e) {
+    if (global.console) { global.console.log(e); }
+    complete();
+  }
+}
+
 //////////////////////////////////////////////////////////////////////////
 // TURTLE FUNCTIONS
 // Turtle methods to be registered as jquery instance methods.
@@ -5699,133 +6480,26 @@ var turtlefn = {
   ["bk(pixels) Back. Moves in reverse by some pixels: " +
       "bk 100"], fdbk),
   slide: wrapcommand('slide', 1,
-  ["slide(x, y) Slides right x and forward y pixels without turning: " +
-      "slide 50, 100"],
-  function slide(cc, x, y) {
-    if ($.isArray(x)) {
-      y = x[1];
-      x = x[0];
-    }
-    if (!y) { y = 0; }
-    if (!x) { x = 0; }
-    this.plan(function(j, elem) {
-      cc.appear(j);
-      this.animate({turtlePosition: displacedPosition(elem, y, x)},
-          animTime(elem), animEasing(elem), cc.resolver(j));
-    });
-    return this;
-  }),
+  ["move(x, y) Slides right x and forward y pixels without turning: " +
+      "slide 50, 100"], slide),
   movexy: wrapcommand('movexy', 1,
   ["movexy(x, y) Changes graphing coordinates by x and y: " +
-      "movexy 50, 100"],
-  function movexy(cc, x, y) {
-    if ($.isArray(x)) {
-      y = x[1];
-      x = x[0];
-    }
-    if (!y) { y = 0; }
-    if (!x) { x = 0; }
-    var elem;
-    if ((elem = canMoveInstantly(this))) {
-      cc.appear(0);
-      doQuickMoveXY(elem, x, y);
-      cc.resolve(0);
-      return this;
-    }
-    this.plan(function(j, elem) {
-      cc.appear(j);
-      var tr = getElementTranslation(elem);
-      this.animate(
-        { turtlePosition: cssNum(tr[0] + x) + ' ' + cssNum(tr[1] - y) },
-        animTime(elem), animEasing(elem), cc.resolver(j));
-    });
-    return this;
-  }),
+      "movexy 50, 100"], movexy),
   moveto: wrapcommand('moveto', 1,
   ["moveto(x, y) Move to graphing coordinates (see getxy): " +
       "moveto 50, 100",
    "moveto(obj) Move to page coordinates " +
       "or an object on the page (see pagexy): " +
-      "moveto lastmousemove"],
-  function moveto(cc, x, y) {
-    var position = x, localx = 0, localy = 0, limit = null;
-    if ($.isNumeric(position) && $.isNumeric(y)) {
-      // moveto x, y: use local coordinates.
-      localx = parseFloat(position);
-      localy = parseFloat(y);
-      position = null;
-      limit = null;
-    } else if ($.isArray(position)) {
-      // moveto [x, y], limit: use local coordinates (limit optional).
-      localx = position[0];
-      localy = position[1];
-      position = null;
-      limit = y;
-    } else if ($.isNumeric(y)) {
-      // moveto obj, limit: limited motion in the direction of obj.
-      limit = y;
-    }
-    // Otherwise moveto {pos}, limit: absolute motion with optional limit.
-    this.plan(function(j, elem) {
-      var pos = position;
-      if (pos === null) {
-        pos = $(homeContainer(elem)).pagexy();
-      }
-      if (pos && !isPageCoordinate(pos)) {
-        try {
-          pos = $(pos).pagexy();
-        } catch (e) {
-          return;
-        }
-      }
-      if (!pos || !isPageCoordinate(pos)) return;
-      if ($.isWindow(elem)) {
-        cc.appear(j);
-        scrollWindowToDocumentPosition(pos, limit);
-        cc.resolve(j);
-        return;
-      } else if (elem.nodeType === 9) {
-        return;
-      }
-      cc.appear(j);
-      this.animate({turtlePosition:
-          computeTargetAsTurtlePosition(elem, pos, limit, localx, localy)},
-          animTime(elem), animEasing(elem), cc.resolver(j));
-    });
-    return this;
-  }),
+      "moveto lastmousemove"], moveto),
   jump: wrapcommand('jump', 1,
   ["jump(x, y) Move without drawing (compare to slide): " +
-      "jump 0, 50"],
-  function jump(cc, x, y) {
-    this.plan(function(j, elem) {
-      cc.appear(j);
-      var down = this.css('turtlePenDown');
-      this.css({turtlePenDown: 'up'});
-      this.slide.apply(this, cc.args);
-      this.plan(function() {
-        this.css({turtlePenDown: down});
-        cc.resolve(j);
-      });
-    });
-    return this;
-  }),
+      "jump 0, 50"], makejump(slide)),
+  jumpxy: wrapcommand('jumpxy', 1,
+  ["jumpxy(x, y) Move without drawing (compare to movexy): " +
+      "jump 0, 50"], makejump(movexy)),
   jumpto: wrapcommand('jumpto', 1,
   ["jumpto(x, y) Move without drawing (compare to moveto): " +
-      "jumpto 50, 100"],
-  function jumpto(cc, x, y) {
-    this.plan(function(j, elem) {
-      cc.appear(j);
-      var down = this.css('turtlePenDown');
-      this.css({turtlePenDown: 'up'});
-      this.moveto.apply(this, cc.args);
-      this.plan(function() {
-        this.css({turtlePenDown: down});
-        cc.resolve(j);
-      });
-    });
-    return this;
-  }),
+      "jumpto 50, 100"], makejump(moveto)),
   turnto: wrapcommand('turnto', 1,
   ["turnto(degrees) Turn to a direction. " +
       "North is 0, East is 90: turnto 270",
@@ -5839,6 +6513,7 @@ var turtlefn = {
       bearing = [bearing, y];
       y = null;
     }
+    var intick = insidetick;
     this.plan(function(j, elem) {
       cc.appear(j);
       if ($.isWindow(elem) || elem.nodeType === 9) {
@@ -5873,15 +6548,21 @@ var turtlefn = {
       if (!nlocalxy) {
         nlocalxy = computePositionAsLocalOffset(elem, targetpos);
       }
-      dir = radiansToDegrees(Math.atan2(-nlocalxy[0], -nlocalxy[1]));
+      var dir = radiansToDegrees(Math.atan2(-nlocalxy[0], -nlocalxy[1]));
       ts = readTurtleTransform(elem, true);
       if (!(limit === null)) {
         r = convertToRadians(ts.rot);
         dir = limitRotation(ts.rot, dir, limit === null ? 360 : limit);
       }
       dir = ts.rot + normalizeRotation(dir - ts.rot);
+      var oldRadius = this.css('turtleTurningRadius');
+      this.css({turtleTurningRadius: 0});
       this.animate({turtleRotation: dir},
-          animTime(elem), animEasing(elem), cc.resolver(j));
+          animTime(elem, intick), animEasing(elem));
+      this.plan(function() {
+        this.css({turtleTurningRadius: oldRadius});
+        cc.resolve(j);
+      });
     });
     return this;
   }),
@@ -5906,6 +6587,52 @@ var turtlefn = {
     });
     return this;
   }),
+  copy: wrapcommand('copy', 0,
+  ["copy() makes a new turtle that is a copy of this turtle."],
+  function copy(cc) {
+    var t2 =  this.clone().insertAfter(this);
+    t2.hide();
+    // t2.plan doesn't work here.
+    this.plan(function(j, elem) {
+      cc.appear(j);
+
+      //copy over turtle data:
+      var olddata = getTurtleData(this);
+      var newdata = getTurtleData(t2);
+      for (var k in olddata) { newdata[k] = olddata[k]; }
+
+      // copy over style attributes:
+      t2.attr('style', this.attr('style'));
+
+      // copy each thing listed in css hooks:
+      for (var property in $.cssHooks) {
+        var value = this.css(property);
+        t2.css(property, value);
+      }
+
+      // copy attributes, just in case:
+      var attrs = this.prop("attributes");
+      for (var i in attrs) {
+        t2.attr(attrs[i].name, attrs[i].value);
+      }
+
+      // copy the canvas:
+      var t2canvas = t2.canvas();
+      var tcanvas = this.canvas();
+      if (t2canvas && tcanvas) {
+        t2canvas.width = tcanvas.width;
+        t2canvas.height = tcanvas.height;
+        var newCanvasContext = t2canvas.getContext('2d');
+        newCanvasContext.drawImage(tcanvas, 0, 0)
+      }
+
+      t2.show();
+
+      cc.resolve(j);
+    }); // pass in our current clone, otherwise things apply to the wrong clone
+    sync(t2, this);
+    return t2;
+  }),
   pen: wrapcommand('pen', 1,
   ["pen(color, size) Selects a pen. " +
       "Chooses a color and/or size for the pen: " +
@@ -5917,66 +6644,74 @@ var turtlefn = {
       "pen off; pen on."
   ],
   function pen(cc, penstyle, lineWidth) {
+    var args = autoArgs(arguments, 1, {
+      lineCap: /^(?:butt|square|round)$/,
+      lineJoin: /^(?:bevel|round|miter)$/,
+      lineWidth: $.isNumeric,
+      penStyle: '*'
+    });
+    penstyle = args.penStyle;
     if (penstyle && (typeof(penstyle) == "function") && (
         penstyle.helpname || penstyle.name)) {
       // Deal with "tan" and "fill".
       penstyle = (penstyle.helpname || penstyle.name);
     }
-    if (typeof(penstyle) == "number" && typeof(lineWidth) != "number") {
-      // Deal with swapped argument order.
-      var swap = penstyle;
-      penstyle = lineWidth;
-      lineWidth = swap;
-    }
-    if (lineWidth === 0) {
+    if (args.lineWidth === 0 || penstyle === null) {
       penstyle = "none";
-    }
-    if (penstyle === undefined) {
+    } else if (penstyle === undefined) {
       penstyle = 'black';
-    } else if (penstyle === null) {
-      penstyle = 'none';
     } else if ($.isPlainObject(penstyle)) {
       penstyle = writePenStyle(penstyle);
     }
+    var intick = insidetick;
     this.plan(function(j, elem) {
       cc.appear(j);
-      var animate = !canMoveInstantly(this) && this.is(':visible'),
+      var animate = !invisible(elem) && !canMoveInstantly(this),
           oldstyle = animate && parsePenStyle(this.css('turtlePenStyle')),
-          olddown = animate && this.css('turtlePenDown'),
+          olddown = oldstyle && ('down' == this.css('turtlePenDown')),
           moved = false;
       if (penstyle === false || penstyle === true ||
           penstyle == 'down' || penstyle == 'up') {
         this.css('turtlePenDown', penstyle);
         moved = true;
       } else {
-        if (lineWidth) {
-          penstyle += ";lineWidth:" + lineWidth;
+        if (args.lineWidth) {
+          penstyle += ";lineWidth:" + args.lineWidth;
+        }
+        if (args.lineCap) {
+          penstyle += ";lineCap:" + args.lineCap;
+        }
+        if (args.lineJoin) {
+          penstyle += ";lineJoin:" + args.lineJoin;
         }
         this.css('turtlePenStyle', penstyle);
+        this.css('turtlePenDown', penstyle == 'none' ? 'up' : 'down');
       }
       if (animate) {
         // A visual indicator of a pen color change.
         var style = parsePenStyle(this.css('turtlePenStyle')),
-            color = (style && style.strokeStyle) ||
+            color = (style && (style.strokeStyle ||
+                        (style.savePath && 'gray'))) ||
                     (oldstyle && oldstyle.strokeStyle) || 'gray',
             target = {},
-            newdown = this.css('turtlePenDown'),
-            pencil = new Turtle(color + ' pencil'),
+            newdown = style && 'down' == this.css('turtlePenDown'),
+            pencil = new Turtle(color + ' pencil', this.parent()),
             distance = this.height();
         pencil.css({
           zIndex: 1,
-          turtlePosition: this.css('turtlePosition'),
+          turtlePosition: computeTargetAsTurtlePosition(
+              pencil.get(0), this.pagexy(), null, 0, 0),
           turtleRotation: this.css('turtleRotation'),
           turtleSpeed: Infinity
         });
-        if (olddown == "up") {
+        if (!olddown) {
           pencil.css({ turtleForward: "+=" + distance, opacity: 0 });
-          if (newdown == "down") {
+          if (newdown) {
             target.turtleForward = "-=" + distance;
             target.opacity = 1;
           }
         } else {
-          if (newdown == "up") {
+          if (!newdown) {
             target.turtleForward = "+=" + distance;
             target.opacity = 0;
           }
@@ -5989,7 +6724,7 @@ var turtlefn = {
           pencil.css({ opacity: 0 });
           target.opacity = 1;
         }
-        pencil.animate(target, animTime(elem));
+        pencil.animate(target, animTime(elem, intick));
         this.queue(function(next) {
           pencil.done(function() {
             pencil.remove();
@@ -6008,7 +6743,7 @@ var turtlefn = {
       "pen path: " +
       "pen path; rt 100, 90; fill blue"],
   function fill(cc, style) {
-    if (!style) { style = 'black'; }
+    if (!style) { style = 'none'; }
     else if ($.isPlainObject(style)) {
       style = writePenStyle(style);
     }
@@ -6028,6 +6763,9 @@ var turtlefn = {
   ["box(color, size) Draws a box. " +
       "Color and size are optional: " +
       "dot blue"], animatedDotCommand(fillBox)),
+  arrow: wrapcommand('arrow', 0,
+  ["arrow(color, size) Draws an arrow. " +
+      "arrow red, 100"], animatedDotCommand(fillArrow)),
   mirror: wrapcommand('mirror', 1,
   ["mirror(flipped) Mirrors the turtle across its main axis, or " +
       "unmirrors if flipped if false. " +
@@ -6060,26 +6798,10 @@ var turtlefn = {
   }),
   scale: wrapcommand('scale', 1,
   ["scale(factor) Scales all motion up or down by a factor. " +
-      "To double all drawing: scale(2)"],
-  function scale(cc, valx, valy) {
-    if (valy === undefined) { valy = valx; }
-    // Disallow scaling to zero using this method.
-    if (!valx || !valy) { valx = valy = 1; }
-    this.plan(function(j, elem) {
-      cc.appear(j);
-      if ($.isWindow(elem) || elem.nodeType === 9) {
-        cc.resolve(j);
-        return;
-      }
-      var c = $.map($.css(elem, 'turtleScale').split(' '), parseFloat);
-      if (c.length === 1) { c.push(c[0]); }
-      c[0] *= valx;
-      c[1] *= valy;
-      this.animate({turtleScale: $.map(c, cssNum).join(' ')},
-            animTime(elem), animEasing(elem), cc.resolver(j));
-    });
-    return this;
-  }),
+      "To double all drawing: scale(2)"], scaleCmd),
+  grow: wrapcommand('grow', 1,
+  ["grow(factor) Changes the size of the element by a factor. " +
+      "To double the size: grow(2)"], grow),
   pause: wrapcommand('pause', 1,
   ["pause(seconds) Pauses some seconds before proceeding. " +
       "fd 100; pause 2.5; bk 100",
@@ -6154,6 +6876,44 @@ var turtlefn = {
   function pf() {
     return this.pen('path', continuationArg(arguments, 0));
   },
+  clip: wrapcommand('clip', 1,
+  ["Clips tranparent bits out of the image of the sprite, " +
+      "and sets the hit region."],
+  function clip(cc, threshold) {
+    if (threshold == null) {
+      threshold = 0.125;
+    }
+    return this.plan(function(j, elem) {
+      cc.appear(j);
+      if (elem.tagName == 'CANVAS') {
+        var hull = transparentHull(elem, threshold),
+            sel = $(elem),
+            origin = readTransformOrigin(elem);
+        eraseOutsideHull(elem, hull);
+        scalePolygon(hull,
+          parseFloat(sel.css('width')) / elem.width,
+          parseFloat(sel.css('height')) / elem.height,
+          -origin[0], -origin[1]);
+        sel.css('turtleHull', hull);
+      }
+      cc.resolve(j);
+    });
+  }),
+  say: wrapcommand('say', 1,
+  ["say(words) Say something. Use English words." +
+      "say \"Let's go!\""],
+  function say(cc, words) {
+    this.plan(function(j, elem) {
+      cc.appear(j);
+      this.queue(function(next) {
+        utterSpeech(words, function() {
+          cc.resolve(j);
+          next();
+        });
+      });
+    });
+    return this;
+  }),
   play: wrapcommand('play', 1,
   ["play(notes) Play notes. Notes are specified in " +
       "" +
@@ -6219,12 +6979,16 @@ var turtlefn = {
    "wear(url) Sets the turtle image url: " +
       "wear 'http://bit.ly/1bgrQ0p'"],
   function wear(cc, name, css) {
-    if (typeof(name) == 'object' && typeof(css) == 'string') {
+    if ((typeof(name) == 'object' || typeof(name) == 'number') &&
+         typeof(css) == 'string') {
       var t = css;
       css = name;
       name = t;
     }
-    var img = nameToImg(name, 'turtle');
+    if (typeof(css) == 'number') {
+      css = { height: css };
+    }
+    var img = nameToImg(name, 'turtle'), intick = insidetick;
     if (!img) return this;
     if (css) {
       $.extend(img.css, css);
@@ -6237,9 +7001,19 @@ var turtlefn = {
       this.css({
         backgroundImage: 'none',
       });
-      applyImg(this, img);
+      var loaded = false, waiting = null;
+      applyImg(this, img, function() {
+        loaded = true;
+        var callback = waiting;
+        if (callback) { waiting = null; callback(); }
+      });
       if (!canMoveInstantly(this)) {
-        this.delay(animTime(elem));
+        this.delay(animTime(elem, intick));
+      }
+      if (!loaded) {
+        this.pause({done: function(cb) {
+          if (loaded) { cb(); } else { waiting = cb; }
+        }});
       }
       this.plan(function() {
         cc.resolve(j);
@@ -6247,20 +7021,62 @@ var turtlefn = {
     });
     return this;
   }),
+  saveimg: wrapcommand('saveimg', 1,
+  ["saveimg(filename) Saves the turtle's image as a file. " +
+      "t.saveimg 'mypicture.png'"],
+  function saveimg(cc, filename) {
+    return this.plan(function(j, elem) {
+      cc.appear(j);
+      var ok = false;
+      if (!filename) { filename = 'img'; }
+      var canvas = this.canvas();
+      if (!canvas) {
+        see.html('Cannot saveimg: not a canvas');
+      } else {
+        var dataurl = canvas.toDataURL();
+        var dparts = /^data:image\/(\w+);base64,(.*)$/i.exec(dataurl);
+        if (!dparts) {
+          see.html('Cannot saveimg: ' +
+              'canvas toDataURL did not work as expected.');
+        } else {
+          if (dparts[1] && filename.toLowerCase().lastIndexOf(
+                '.' + dparts[1].toLowerCase()) !=
+                    Math.max(0, filename.length - dparts[1].length - 1)) {
+            filename += '.' + dparts[1];
+          }
+          ok = true;
+          dollar_turtle_methods.save(filename, atob(dparts[2]), function() {
+            cc.resolve(j);
+          });
+        }
+      }
+      if (!ok) {
+        cc.resolve(j);
+      }
+    });
+  }),
   drawon: wrapcommand('drawon', 1,
   ["drawon(canvas) Switches to drawing on the specified canvas. " +
       "A = new Sprite('100x100'); " +
       "drawon A; pen red; fd 50; done -> A.rt 360"],
   function drawon(cc, canvas) {
+    this.each(function() {
+      var state = getTurtleData(this);
+      if (state.drawOnCanvasSync) sync(this, state.drawOnCanvasSync);
+      state.drawOnCanvasSync = canvas;
+    });
+    sync(canvas, this);
     return this.plan(function(j, elem) {
       cc.appear(j);
       var state = getTurtleData(elem);
-      if (!canvas) {
+      if (!canvas || canvas === global) {
         state.drawOnCanvas = null;
       } else if (canvas.jquery && $.isFunction(canvas.canvas)) {
         state.drawOnCanvas = canvas.canvas();
       } else if (canvas.tagName && canvas.tagName == 'CANVAS') {
         state.drawOnCanvas = canvas;
+      } else if (canvas.nodeType == 1 || canvas.nodeType == 9) {
+        state.drawOnCanvas = $(canvas).canvas();
       }
       cc.resolve(j);
     });
@@ -6268,20 +7084,37 @@ var turtlefn = {
   label: wrapcommand('label', 1,
   ["label(text) Labels the current position with HTML: " +
       "label 'remember'",
-   "label(text, styles) Apply CSS styles to the label: " +
-      "label 'big', { fontSize: 100 }"],
-  function label(cc, html, styles) {
+   "label(text, styles, labelsite) Optional position specifies " +
+      "'top', 'bottom', 'left', 'right', and optional styles is a size " +
+      "or CSS object: " +
+      "label 'big', { color: red, fontSize: 100 }, 'bottom'"],
+  function label(cc, html, side, styles) {
+    if ((!styles || typeof(styles) == 'string') &&
+        ($.isNumeric(side) || $.isPlainObject(side))) {
+      // Handle switched second and third argument order.
+      var t = styles;
+      styles = side;
+      side = t;
+    }
     if ($.isNumeric(styles)) {
       styles = { fontSize: styles };
     }
+    if (side == null) {
+      side =
+        styles && 'labelSide' in styles ? styles.labelSide :
+        styles && 'label-side' in styles ? styles['label-side'] :
+        side = 'rotated scaled';
+    }
+    var intick = insidetick;
     return this.plan(function(j, elem) {
       cc.appear(j);
-      var applyStyles = {}, currentStyles = this.prop('style');
+      var applyStyles = {},
+          currentStyles = this.prop('style');
       // For defaults, copy inline styles of the turtle itself except for
       // properties in the following list (these are the properties used to
       // make the turtle look like a turtle).
-      for (var j = 0; j < currentStyles.length; ++j) {
-        var styleProperty = currentStyles[j];
+      for (var j2 = 0; j2 < currentStyles.length; ++j2) {
+        var styleProperty = currentStyles[j2];
         if (/^(?:width|height|opacity|background-image|background-size)$/.test(
           styleProperty) || /transform/.test(styleProperty)) {
           continue;
@@ -6298,20 +7131,42 @@ var turtlefn = {
         left: 0
       }, styles);
       // Place the label on the screen using the figured styles.
-      var out = output(html, 'label').css(applyStyles)
-          .addClass('turtle').appendTo(getTurtleField());
+      var out = prepareOutput(html, 'label').result.css(applyStyles)
+          .addClass('turtlelabel').appendTo(getTurtleField());
+      // If the output has a turtleinput, then forward mouse events.
+      if (out.hasClass('turtleinput') || out.find('.turtleinput').length) {
+        mouseSetupHook.apply(out.get(0));
+      }
+      if (styles && 'id' in styles) {
+        out.attr('id', styles.id);
+      }
+      if (styles && 'class' in styles) {
+        out.addClass(styles.class);
+      }
+      var rotated = /\brotated\b/.test(side),
+          scaled = /\bscaled\b/.test(side);
       // Mimic the current position and rotation and scale of the turtle.
       out.css({
         turtlePosition: computeTargetAsTurtlePosition(
             out.get(0), this.pagexy(), null, 0, 0),
-        turtleRotation: this.css('turtleRotation'),
-        turtleScale: this.css('turtleScale')
+        turtleRotation: rotated ? this.css('turtleRotation') : 0,
+        turtleScale: scaled ? this.css('turtleScale') : 1
       });
+      var gbcr = out.get(0).getBoundingClientRect();
+      // Modify top-left to slide to the given corner, if requested.
+      if (/\b(?:top|bottom)\b/.test(side)) {
+        applyStyles.top =
+            (/\btop\b/.test(side) ? -1 : 1) * gbcr.height / 2;
+      }
+      if (/\b(?:left|right)\b/.test(side)) {
+        applyStyles.left =
+            (/\bleft\b/.test(side) ? -1 : 1) * gbcr.width / 2;
+      }
       // Then finally apply styles (turtle styles may be overridden here).
       out.css(applyStyles);
       // Add a delay.
       if (!canMoveInstantly(this)) {
-        this.delay(animTime(elem));
+        this.delay(animTime(elem, intick));
       }
       this.plan(function() {
         cc.resolve(j);
@@ -6325,7 +7180,7 @@ var turtlefn = {
     this.plan(function(j, elem) {
       cc.appear(j);
       if ($.isWindow(elem) || elem.nodeType === 9) {
-        window.location.reload();
+        global.location.reload();
         cc.resolve(j);
         return;
       }
@@ -6379,7 +7234,7 @@ var turtlefn = {
   }),
   getxy: wrappredicate('getxy',
   ["getxy() Graphing coordinates [x, y], center-based: " +
-      "v = getxy(); slide -v[0], -v[1]"],
+      "v = getxy(); move -v[0], -v[1]"],
   function getxy() {
     if (!this.length) return;
     return computePositionAsLocalOffset(this[0]);
@@ -6438,7 +7293,58 @@ var turtlefn = {
       "c = turtle.canvas().getContext('2d'); c.fillStyle = red; " +
       "c.fillRect(10, 10, 30, 30)"],
   function canvas() {
-    return this.filter('canvas').get(0);
+    return this.filter('canvas').get(0) || this.find('canvas').get(0);
+  }),
+  imagedata: wrapraw('imagedata',
+  ["imagedata() Returns the image data for the turtle. " +
+      "imdat = imagedata(); write imdat.data.length, 'bytes'",
+   "imagedata(imdat) Sets the image data for the turtle. " +
+      "imagedata({width: 1, height:1, data:[255,0,0,255]});",
+  ],
+  function imagedata(val) {
+    var canvas = this.canvas();
+    if (!canvas) {
+      if (val) throw new Error(
+        'can only set imagedata on a canvas like a Sprite');
+      var img = this.filter('img').get(0);
+      if (!img) return;
+      canvas = getOffscreenCanvas(img.naturalWidth, img.naturalHeight);
+      canvas.getContext('2d').drawImage(img, 0, 0);
+    }
+    var ctx = canvas.getContext('2d');
+    if (!val) {
+      // The read case: return the image data for the whole canvas.
+      return ctx.getImageData(0, 0, canvas.width, canvas.height);
+    }
+    // The write case: if it's not an ImageData, convert it to one.
+    if (!(val instanceof ImageData)) {
+      if (typeof val != 'object' ||
+          !$.isNumeric(val.width) || !$.isNumeric(val.height) ||
+          !($.isArray(val.data) || val.data instanceof Uint8ClampedArray ||
+            val.data instanceof Uint8Array)) {
+        return;
+      }
+      var imdat = ctx.createImageData(
+          Math.round(val.width), Math.round(val.height));
+      var minlen = Math.min(val.data.length, imdat.data.length);
+      for (var j = 0; j < minlen; ++j) { imdat.data[j] = val.data[j]; }
+      val = imdat;
+    }
+    // If the size must be changed, resize it.
+    if (val.width != canvas.width ||
+        val.height != canvas.height) {
+      var oldOrigin = readTransformOrigin(canvas);
+      canvas.width = val.width;
+      canvas.height = val.height;
+      var newOrigin = readTransformOrigin(canvas);
+      // Preserve the origin if it's a turtle.
+      moveToPreserveOrigin(canvas, oldOrigin, newOrigin);
+      // Drop the turtle hull, if any.
+      $(canvas).css('turtleHull', 'auto');
+      ctx = canvas.getContext('2d');
+    }
+    // Finally put the image data into the canvas.
+    ctx.putImageData(val, 0, 0);
   }),
   cell: wrapraw('cell',
   ["cell(r, c) Row r and column c in a table. " +
@@ -6454,13 +7360,15 @@ var turtlefn = {
   ["shown() True if turtle is shown, false if hidden: " +
       "do ht; write shown()"],
   function shown() {
-    return this.is(':visible');
+    var elem = this.get(0);
+    return elem && !invisible(elem);
   }),
   hidden: wrappredicate('hidden',
   ["hidden() True if turtle is hidden: " +
       "do ht; write hidden()"],
   function hidden() {
-    return !this.is(':visible');
+    var elem = this.get(0);
+    return !elem || invisible(elem);
   }),
   inside: wrappredicate('inside',
   ["inside(obj) True if the turtle is encircled by obj: " +
@@ -6471,7 +7379,7 @@ var turtlefn = {
       elem = $(elem);
     }
     if (elem.jquery) {
-      if (!elem.length || !elem.is(':visible')) return false;
+      if (!elem.length || invisible(elem[0])) return false;
       elem = elem[0];
     }
     var gbcr0 = getPageGbcr(elem),
@@ -6506,7 +7414,10 @@ var turtlefn = {
    "touches(color) True if the turtle touches a drawn color: " +
       "touches red"],
   function touches(arg, y) {
-    if (!this.is(':visible') || !this.length) { return false; }
+    if (!this.length || invisible(this[0])) { return false; }
+    if (typeof(arg) == "function" && isCSSColor(arg.helpname)) {
+      arg = arg.helpname;
+    }
     if (arg == 'color' || isCSSColor(arg)) {
       return touchesPixel(this[0], arg == 'color' ? null : arg);
     }
@@ -6520,15 +7431,23 @@ var turtlefn = {
     if (!arg) return false;
     if (typeof arg === 'string') { arg = $(arg); }
     if (!arg.jquery && !$.isArray(arg)) { arg = [arg]; }
-    var anyok = false, k = 0, j, obj, elem, gbcr0, toucher;
+    var anyok = false, k = 0, j, obj, elem, gbcr0, toucher, gbcr1;
     for (;!anyok && k < this.length; ++k) {
       elem = this[k];
       gbcr0 = getPageGbcr(elem);
+      // hidden elements do not touch anything
+      if (gbcr0.width == 0) { continue; }
       toucher = null;
       for (j = 0; !anyok && j < arg.length; ++j) {
         obj = arg[j];
         // Optimize the outside-bounding-box case.
-        if (isDisjointGbcr(gbcr0, getPageGbcr(obj))) {
+        gbcr1 = getPageGbcr(obj);
+        if (isDisjointGbcr(gbcr0, gbcr1)) {
+          continue;
+        }
+        // Do not touch removed or hidden elements, or points without
+        // a pageX/pageY coordinate.
+        if (gbcr1.width == 0 && (obj.pageX == null || obj.pageY == null)) {
           continue;
         }
         if (!toucher) {
@@ -6613,7 +7532,6 @@ var turtlefn = {
         callback.apply(this, arguments);
       }
     });
-    sync = null;
   }),
   plan: wrapraw('plan',
   ["plan(fn) Runs fn in the animation queue. For planning logic: " +
@@ -6624,7 +7542,7 @@ var turtlefn = {
       callback = qname;
       qname = 'fx';
     }
-    // If animation is active, then direct will queue the callback.
+    // If animation is active, then plan will queue the callback.
     // It will also arrange things so that if the callback enqueues
     // further animations, they are inserted at the same location,
     // so that the callback can expand into several animations,
@@ -6635,34 +7553,26 @@ var turtlefn = {
             (function() { callback.call($(elem), index, elem); })),
           lastanim = elemqueue.length && elemqueue[elemqueue.length - 1],
           animation = (function() {
-          var saved = $.queue(this, qname),
-              subst = [], inserted;
-          if (saved[0] === 'inprogress') {
-            subst.unshift(saved.shift());
-          }
-          $.queue(elem, qname, subst);
-          action();
-          // The Array.prototype.push is faster.
-          // $.merge($.queue(elem, qname), saved);
-          Array.prototype.push.apply($.queue(elem, qname), saved);
-          if (global_plan_counter++ % 64) {
-            $.dequeue(elem, qname);
-          } else {
-            // Insert a timeout after executing a batch of plans,
-            // to avoid deep recursion.
-            async_pending += 1;
-            setTimeout(function() {
-              async_pending -= 1;
-              $.dequeue(elem, qname);
-            }, 0);
-          }
-        });
+            var saved = $.queue(this, qname),
+                subst = [], inserted;
+            if (saved[0] === 'inprogress') {
+              subst.unshift(saved.shift());
+            }
+            $.queue(elem, qname, subst);
+            action();
+            // The Array.prototype.push is faster.
+            // $.merge($.queue(elem, qname), saved);
+            Array.prototype.push.apply($.queue(elem, qname), saved);
+            nonrecursive_dequeue(elem, qname);
+          });
       animation.finish = action;
       $.queue(elem, qname, animation);
     }
     var elem, sel, length = this.length, j = 0;
     for (; j < length; ++j) {
       elem = this[j];
+      // Special case: first wait for an unloaded image to load.
+      queueWaitIfLoadingImg(elem, qname);
       // Queue an animation if there is a queue.
       var elemqueue = $.queue(elem, qname);
       if (elemqueue.length) {
@@ -6677,6 +7587,51 @@ var turtlefn = {
   })
 };
 
+//////////////////////////////////////////////////////////////////////////
+// QUEUING SUPPORT
+//////////////////////////////////////////////////////////////////////////
+
+function queueShowHideToggle() {
+  $.each(['toggle', 'show', 'hide'], function(i, name) {
+
+
+    var builtInFn = $.fn[name];
+    // Change show/hide/toggle to queue their behavior by default.
+    // Since animating show/hide will call the zero-argument
+    // form synchronously at the end of animation, we avoid
+    // infinite recursion by examining jQuery's internal fxshow
+    // state and avoiding the recursion if the animation is calling
+    // show/hide.
+    $.fn[name] = function(speed, easing, callback) {
+      var a = arguments;
+      // TODO: file a bug in jQuery to allow solving this without _data.
+      if (!a.length && this.hasClass('turtle') &&
+          (this.length > 1 || !$._data(this[0], 'fxshow'))) {
+        a = [0];
+      }
+      builtInFn.apply(this, a);
+    }
+  });
+}
+
+// If the queue for an image is empty, starts by queuing a wait-for-load.
+function queueWaitIfLoadingImg(img, qname) {
+  if (!qname) qname = 'fx';
+  if (img.tagName == 'IMG' && img.src && !img.complete) {
+    var queue = $.queue(img, qname);
+    if (queue.length == 0) {
+      $.queue(img, qname, function(next) {
+        afterImageLoadOrError(img, null, next);
+      });
+      nonrecursive_dequeue(img, qname);
+    }
+  }
+}
+
+//////////////////////////////////////////////////////////////////////////
+// HUNG LOOP DETECTION
+//////////////////////////////////////////////////////////////////////////
+
 var warning_shown = {},
     loopCounter = 0,
     hungTimer = null,
@@ -6689,7 +7644,7 @@ var warning_shown = {},
 // 100th turtle motion.  If it takes more than a few seconds to receive it,
 // our script is blocking message dispatch, and an interrupt is triggered.
 function checkForHungLoop(fname) {
-  if ($.turtle.hungtimeout == Infinity || loopCounter++ < 100) {
+  if ($.turtle.hangtime == Infinity || loopCounter++ < 100) {
     return;
   }
   loopCounter = 0;
@@ -6704,8 +7659,8 @@ function checkForHungLoop(fname) {
     }, 0);
     return;
   }
-  // Timeout after which we interrupt the program: 6 seconds.
-  if (now - hangStartTime > $.turtle.hungtimeout) {
+  // Timeout after which we interrupt the program.
+  if (now - hangStartTime > $.turtle.hangtime) {
     if (see.visible()) {
       see.html('Oops: program ' +
         'interrupted because it was hanging the browser. ' +
@@ -6714,7 +7669,7 @@ function checkForHungLoop(fname) {
         'tick ' +
         'to make an animation.');
     }
-    $.turtle.interrupt();
+    $.turtle.interrupt('hung');
   }
 }
 
@@ -6765,6 +7720,7 @@ function deprecate(map, oldname, newname) {
     __extends(map[oldname], map[newname]);
   }
 }
+deprecate(turtlefn, 'move', 'slide');
 deprecate(turtlefn, 'direct', 'plan');
 deprecate(turtlefn, 'enclosedby', 'inside');
 deprecate(turtlefn, 'bearing', 'direction');
@@ -6784,10 +7740,13 @@ $.fn.extend(turtlefn);
 // * Sets up a global "hatch" function to make a new turtle.
 //////////////////////////////////////////////////////////////////////////
 
-var turtleGIFUrl = "data:image/gif;base64,R0lGODlhKAAwAPIFAAAAAAFsOACSRTCuSICAgP///wAAAAAAACH5BAlkAAYAIf8LTkVUU0NBUEUyLjADAQAAACwAAAAAKAAwAAAD72i6zATEgBCAebHpzUnxhDAMAvhxKOoV3ziuZyo3RO26dTbvgXj/gsCO9ysOhENZz+gKJmcUkmA6PSKfSqrWieVtuU+KGNXbXofLEZgR/VHCgdua4isGz9mbmM6U7/94BmlyfUZ1fhqDhYuGgYqMkCOBgo+RfWsNlZZ3ewIpcZaIYaF6XaCkR6aokqqrk0qrqVinpK+fsbZkuK2ouRy0ob4bwJbCibthh6GYebGcY7/EsWqTbdNG1dd9jnXPyk2d38y0Z9Yub2yA6AvWPYk+zEnkv6xdCoPuw/X2gLqy9vJIGAN4b8pAgpQOIlzI8EkCACH5BAlkAAYALAAAAAAoADAAAAPuaLrMBMSAEIB5senNSfGEMAwC+HEo6hXfOK5nKjdE7bp1Nu+BeP+CwI73Kw6EQ1nP6AomZxSSYDo9Ip9KqtaJ5W25Xej3qqGYsdEfZbMcgZXtYpActzLMeLOP6c7f3nVNfEZ7TXSFg4lyZAYBio+LZYiQfHMbc3iTlG9ilGpdjp4ujESiI6RQpqegqkesqqhKrbEpoaa0KLaiuBy6nrxss6+3w7tomo+cDXmBnsoLza2nsb7SN2tl1nyozVOZTJhxysxnd9XYCrrAtT7KQaPruavBo2HQ8xrvffaN+GV5/JbE45fOG8Ek5Q4qXHgwAQA7"
+var turtleGIFUrl = "data:image/gif;base64,R0lGODlhKAAwAPIFAAAAAAFsOACSRTCuSICAgP///wAAAAAAACH5BAlkAAYAIf8LTkVUU0NBUEUyLjADAQAAACwAAAAAKAAwAAAD72i6zATEgBCAebHpzUnxhDAMAvhxKOoV3ziuZyo3RO26dTbvgXj/gsCO9ysOhENZz+gKJmcUkmA6PSKfSqrWieVtuU+KGNXbXofLEZgR/VHCgdua4isGz9mbmM6U7/94BmlyfUZ1fhqDhYuGgYqMkCOBgo+RfWsNlZZ3ewIpcZaIYaF6XaCkR6aokqqrk0qrqVinpK+fsbZkuK2ouRy0ob4bwJbCibthh6GYebGcY7/EsWqTbdNG1dd9jnXPyk2d38y0Z9Yub2yA6AvWPYk+zEnkv6xdCoPuw/X2gLqy9vJIGAN4b8pAgpQOIlzI8EkCACH5BAlkAAYALAAAAAAoADAAAAPuaLrMBMSAEIB5senNSfGEMAwC+HEo6hXfOK5nKjdE7bp1Nu+BeP+CwI73Kw6EQ1nP6AomZxSSYDo9Ip9KqtaJ5W25Xej3qqGYsdEfZbMcgZXtYpActzLMeLOP6c7f3nVNfEZ7TXSFg4lyZAYBio+LZYiQfHMbc3iTlG9ilGpdjp4ujESiI6RQpqegqkesqqhKrbEpoaa0KLaiuBy6nrxss6+3w7tomo+cDXmBnsoLza2nsb7SN2tl1nyozVOZTJhxysxnd9XYCrrAtT7KQaPruavBo2HQ8xrvffaN+GV5/JbE45fOG8Ek5Q4qXHgwAQA7";
+
+var eventfn = { click:1, dblclick:1, mouseup:1, mousedown:1, mousemove:1 };
 
-var eventfn = { click:1, mouseup:1, mousedown:1, mousemove:1,
-    keydown:1, keypress:1, keyup:1 };
+function global_turtle_animating() {
+  return (global_turtle && $.queue(global_turtle).length > 0);
+}
 
 var global_turtle = null;
 var global_turtle_methods = [];
@@ -6810,21 +7769,34 @@ var dollar_turtle_methods = {
       if (interrupted) return false;
       if (tickinterval) return true;
       if ($.timers.length) return true;
+      if (forever_timers.length) return true;
       if (async_pending) return true;
-      if (global_turtle && $.queue(global_turtle).length > 0) return true;
+      if (global_turtle_animating()) return true;
       if ($(':animated').length) return true;
-      return ($('.turtle').filter(function() {
-        return $.queue(this).length > 0;
-      }).length > 0);
+      if ($('.turtle').filter(function() {
+        return $.queue(this).length > 0; }).length > 0) return true;
+      if ($('.turtleinput').filter(function() {
+        return !$(this).prop('disabled')}).length > 0) return true;
+      if (windowhasturtleevent()) return true;
+      return false;
     }
     // Stop all animations.
     $(':animated,.turtle').clearQueue().stop();
     // Stop our audio.
     resetAudio();
+    // Disable all input.
+    $('.turtleinput').prop('disabled', true);
+    // Detach all event handlers on the window.
+    $(global).off('.turtleevent');
+    // Low-level detach all jQuery events
+    $('*').not('#_testpanel *').map(
+       function(i, e) { $._data(e, 'events', null) });
     // Set a flag that will cause all commands to throw.
     interrupted = true;
     // Turn off the global tick interval timer.
     globaltick(null, null);
+    // Turn off timers for 'forever'
+    clearForever();
     // Run through any remaining timers, stopping each one.
     // This handles the case of animations (like "dot") that
     // are not attached to an HTML element.
@@ -6834,7 +7806,8 @@ var dollar_turtle_methods = {
       }
     }
     // Throw an interrupt exception.
-    throw new Error('interrupt() called');
+    var msg = option ? "'" + option + "'" : '';
+    throw new Error('interrupt(' + msg + ') called');
   }),
   cs: wrapglobalcommand('cs',
   ["cs() Clear screen. Erases both graphics canvas and " +
@@ -6846,7 +7819,7 @@ var dollar_turtle_methods = {
   ["cg() Clear graphics. Does not alter body text: " +
       "do cg"],
   function cg() {
-    clearField('canvas');
+    clearField('canvas labels');
   }),
   ct: wrapglobalcommand('ct',
   ["ct() Clear text. Does not alter graphics canvas: " +
@@ -6865,12 +7838,24 @@ var dollar_turtle_methods = {
   ["sizexy() Get the document pixel [width, height]. " +
       "[w, h] = sizexy(); canvas('2d').fillRect(0, 0, w, h)"],
   sizexy),
+  forever: wrapraw('forever',
+  ["forever(fn) Calls fn repeatedly, forever. " +
+      "forever -> fd 2; rt 2",
+   "forever(fps, fn) Calls fn repeating fps per second. " +
+      "forever 2, -> fd 25; dot blue"],
+  forever),
+  stop: wrapraw('stop',
+  ["stop() stops the current forever loop. " +
+      "forever -> fd 10; if not inside window then stop()",
+   "stop(fn) stops the forever loop corresponding to fn.",
+   "Use break to stop a for or while loop."],
+  stop),
   tick: wrapraw('tick',
   ["tick(fps, fn) Calls fn fps times per second until " +
       "tick is called again: " +
       "c = 10; tick 1, -> c and write(c--) or tick()"],
   function tick(tps, fn) {
-    if (global_turtle) {
+    if (global_turtle_animating()) {
       var sel = $(global_turtle);
       sel.plan(function() {
         globaltick(tps, fn);
@@ -6885,6 +7870,20 @@ var dollar_turtle_methods = {
   function globalspeed(mps) {
     globaldefaultspeed(mps);
   }),
+  say: wrapraw('say',
+  ["say(words) Say something. Use English words." +
+      "say \"Let's go!\""],
+  function say(words) {
+    if (global_turtle) {
+      var sel = $(global_turtle);
+      sel.say.call(sel, words);
+    } else {
+      var cc = setupContinuation(null, 'say', arguments, 0);
+      cc.appear(null);
+      utterSpeech(words, function() { cc.resolve(null); });
+      cc.exit();
+    }
+  }),
   play: wrapraw('play',
   ["play(notes) Play notes. Notes are specified in " +
       "" +
@@ -6916,7 +7915,7 @@ var dollar_turtle_methods = {
       sel.tone.apply(sel, arguments);
     } else {
       var instrument = getGlobalInstrument();
-      instrument.play.apply(instrument, args);
+      instrument.play.apply(instrument);
     }
   }),
   silence: wrapraw('silence',
@@ -6955,44 +7954,150 @@ var dollar_turtle_methods = {
         callback.apply(this, arguments);
       }
     });
-    sync = null;
   }),
-  append: wrapraw('append',
+  load: wrapraw('load',
+  ["load(url, cb) Loads data from the url and passes it to cb. " +
+      "load 'intro', (t) -> write 'intro contains', t"],
+  function(url, cb) {
+    var val;
+    $.ajax(apiUrl(url, 'load'), { async: !!cb, complete: function(xhr) {
+      try {
+        val = xhr.responseObject = JSON.parse(xhr.responseText);
+        if (typeof(val.data) == 'string' && typeof(val.file) == 'string') {
+          val = val.data;
+          if (/\.json(?:$|\?|\#)/.test(url)) {
+            try { val = JSON.parse(val); } catch(e) {}
+          }
+        } else if ($.isArray(val.list) && typeof(val.directory) == 'string') {
+          val = val.list;
+        } else if (val.error) {
+          val = null;
+        }
+      } catch(e) {
+        if (val == null && xhr && xhr.responseText) {
+          val = xhr.responseText;
+        }
+      }
+      if (cb) {
+        cb(val, xhr);
+      }
+    }});
+    return val;
+  }),
+  save: wrapraw('save',
+  ["save(url, data, cb) Posts data to the url and calls when done. " +
+      "save 'intro', 'pen gold, 20\\nfd 100\\n'"],
+  function(url, data, cb) {
+    if (!url) throw new Error('Missing url for save');
+    var payload = { }, key;
+    url = apiUrl(url, 'save');
+    if (/\.json(?:$|\?|\#)/.test(url)) {
+      data = JSON.stringify(data, null, 2);
+    }
+    if (typeof(data) == 'string' || typeof(data) == 'number') {
+      payload.data = data;
+    } else {
+      for (key in data) if (data.hasOwnProperty(key)) {
+        if (typeof data[key] == 'string') {
+          payload[key] = data[key];
+        } else {
+          payload[key] = JSON.stringify(data[key]);
+        }
+      }
+    }
+    if (payload && !payload.key) {
+      var login = loginCookie();
+      if (login && login.key && login.user == pencilUserFromUrl(url)) {
+        payload.key = login.key;
+      }
+    }
+    $.ajax(apiUrl(url, 'save'), {
+      type: 'POST',
+      data: payload,
+      complete: function(xhr) {
+        var val
+        try {
+          val = JSON.parse(xhr.responseText);
+        } catch(e) {
+          if (val == null && xhr && xhr.responseText) {
+            val = xhr.responseText;
+          }
+        }
+        if (cb) {
+          cb(val, xhr);
+        }
+      }
+    });
+  }),
+  append: wrapglobalcommand('append',
   ["append(html) Appends text to the document without a new line. " +
       "append 'try this twice...'"],
   function append(html) {
     $.fn.append.apply($('body'), arguments);
   }),
-  write: wrapraw('write',
-  ["write(html) Writes a line of text. Arbitrary HTML may be written: " +
-      "write 'Hello, world!'"],
-  function write(html) {
-    return output(Array.prototype.join.call(arguments, ' '), 'div');
-  }),
-  type: wrapraw('type',
+  type: wrapglobalcommand('type',
   ["type(text) Types preformatted text like a typewriter. " +
       "type 'Hello!\n'"], plainTextPrint),
-  read: wrapraw('read',
+  typebox: wrapglobalcommand('typebox',
+  ["typebox(clr) Draws a colored box as typewriter output. " +
+      "typebox red"], function(c, t) {
+    if (t == null && c != null && !isCSSColor(c)) { t = c; c = null; }
+    plainBoxPrint(c, t);
+  }),
+  typeline: wrapglobalcommand('typebox',
+  ["typeline() Same as type '\\n'. " +
+      "typeline()"], function(t) {
+    plainTextPrint((t || '') + '\n');
+  }),
+  write: wrapglobalcommand('write',
+  ["write(html) Writes a line of text. Arbitrary HTML may be written: " +
+      "write 'Hello, world!'"], doOutput, function() {
+    return prepareOutput(Array.prototype.join.call(arguments, ' '), 'div');
+  }),
+  read: wrapglobalcommand('read',
   ["read(fn) Reads text or numeric input. " +
       "Calls fn once: " +
       "read (x) -> write x",
    "read(html, fn) Prompts for input: " +
       "read 'Your name?', (v) -> write 'Hello ' + v"],
-  function read(a, b) { return input(a, b, 0); }),
-  readnum: wrapraw('readnum',
+  doOutput, function read(a, b) { return prepareInput(a, b, 0); }),
+  readnum: wrapglobalcommand('readnum',
   ["readnum(html, fn) Reads numeric input. Only numbers allowed: " +
       "readnum 'Amount?', (v) -> write 'Tip: ' + (0.15 * v)"],
-  function readnum(a, b) { return input(a, b, 1); }),
-  readstr: wrapraw('readstr',
+  doOutput, function readnum(a, b) { return prepareInput(a, b, 'number'); }),
+  readstr: wrapglobalcommand('readstr',
   ["readstr(html, fn) Reads text input. Never " +
       "converts input to a number: " +
       "readstr 'Enter code', (v) -> write v.length + ' long'"],
-  function readstr(a, b) { return input(a, b, -1); }),
-  menu: wrapraw('menu',
+  doOutput, function readstr(a, b) { return prepareInput(a, b, 'text'); }),
+  listen: wrapglobalcommand('listen',
+  ["listen(html, fn) Reads voice input, if the browser supports it:" +
+      "listen 'Say something', (v) -> write v"],
+  doOutput, function readstr(a, b) { return prepareInput(a, b, 'voice'); }),
+  menu: wrapglobalcommand('menu',
   ["menu(map) shows a menu of choices and calls a function " +
       "based on the user's choice: " +
       "menu {A: (-> write 'chose A'), B: (-> write 'chose B')}"],
-  menu),
+  doOutput, prepareMenu),
+  button: wrapglobalcommand('button',
+  ["button(text, fn) Writes a button. Calls " +
+      "fn whenever the button is clicked: " +
+      "button 'GO', -> fd 100"],
+  doOutput, prepareButton),
+  table: wrapglobalcommand('table',
+  ["table(m, n) Writes m rows and c columns. " +
+      "Access cells using cell: " +
+      "g = table 8, 8; g.cell(2,3).text 'hello'",
+   "table(array) Writes tabular data. " +
+      "Each nested array is a row: " +
+      "table [[1,2,3],[4,5,6]]"],
+  doOutput, prepareTable),
+  img: wrapglobalcommand('img',
+  ["img(url) Writes an image with the given address. " +
+      "Any URL can be provided.  A name without slashes will be " +
+      "treated as '/img/name'." +
+      "t = img 'tree'"],
+  doOutput, prepareImage),
   random: wrapraw('random',
   ["random(n) Random non-negative integer less than n: " +
       "write random 10",
@@ -7003,23 +8108,6 @@ var dollar_turtle_methods = {
    "random('color') Random color: " +
       "pen random 'color'"],
   random),
-  hatch:
-  function hatch(count, spec) {
-    return $(document).hatch(count, spec);
-  },
-  button: wrapraw('button',
-  ["button(text, fn) Writes a button. Calls " +
-      "fn whenever the button is clicked: " +
-      "button 'GO', -> fd 100"],
-  button),
-  table: wrapraw('table',
-  ["table(m, n) Writes m rows and c columns. " +
-      "Access cells using cell: " +
-      "g = table 8, 8; g.cell(2,3).text 'hello'",
-   "table(array) Writes tabular data. " +
-      "Each nested array is a row: " +
-      "table [[1,2,3],[4,5,6]]"],
-  table),
   rgb: wrapraw('rgb',
   ["rgb(r,g,b) Makes a color out of red, green, and blue parts. " +
       "pen rgb(150,88,255)"],
@@ -7027,6 +8115,10 @@ var dollar_turtle_methods = {
       Math.max(0, Math.min(255, Math.floor(r))),
       Math.max(0, Math.min(255, Math.floor(g))),
       Math.max(0, Math.min(255, Math.floor(b))) ]); }),
+  hatch: // Deprecated - no docs.
+  function hatch(count, spec) {
+    return $(document).hatch(count, spec);
+  },
   rgba: wrapraw('rgba',
   ["rgba(r,g,b,a) Makes a color out of red, green, blue, and alpha. " +
       "pen rgba(150,88,255,0.5)"],
@@ -7050,48 +8142,30 @@ var dollar_turtle_methods = {
      (s * 100).toFixed(0) + '%',
      (l * 100).toFixed(0) + '%',
      a]); }),
-  click: wrapraw('click',
+  click: wrapwindowevent('click',
   ["click(fn) Calls fn(event) whenever the mouse is clicked. " +
-      "click (e) -> moveto e; label 'clicked'"],
-  function(fn) {
-    $(window).click(fn);
-  }),
-  mouseup: wrapraw('mouseup',
+      "click (e) -> moveto e; label 'clicked'"]),
+  dblclick: wrapwindowevent('dblclick',
+  ["dblclick(fn) Calls fn(event) whenever the mouse is double-clicked. " +
+      "dblclick (e) -> moveto e; label 'double'"]),
+  mouseup: wrapwindowevent('mouseup',
   ["mouseup(fn) Calls fn(event) whenever the mouse is released. " +
-      "mouseup (e) -> moveto e; label 'up'"],
-  function(fn) {
-    $(window).mouseup(fn);
-  }),
-  mousedown: wrapraw('mousedown',
+      "mouseup (e) -> moveto e; label 'up'"]),
+  mousedown: wrapwindowevent('mousedown',
   ["mousedown(fn) Calls fn(event) whenever the mouse is pressed. " +
-      "mousedown (e) -> moveto e; label 'down'"],
-  function(fn) {
-    $(window).mousedown(fn);
-  }),
-  mousemove: wrapraw('mousemove',
-  ["mousedown(fn) Calls fn(event) whenever the mouse is moved. " +
-      "mousemove (e) -> moveto e"],
-  function(fn) {
-    $(window).mousemove(fn);
-  }),
-  keydown: wrapraw('keydown',
+      "mousedown (e) -> moveto e; label 'down'"]),
+  mousemove: wrapwindowevent('mousemove',
+  ["mousemove(fn) Calls fn(event) whenever the mouse is moved. " +
+      "mousemove (e) -> write 'at ', e.x, ',', e.y"]),
+  keydown: wrapwindowevent('keydown',
   ["keydown(fn) Calls fn(event) whenever a key is pushed down. " +
-      "keydown (e) -> write 'down ' + e.which"],
-  function(fn) {
-    $(window).keydown(fn);
-  }),
-  keyup: wrapraw('keyup',
+      "keydown (e) -> write 'down ' + e.key"]),
+  keyup: wrapwindowevent('keyup',
   ["keyup(fn) Calls fn(event) whenever a key is released. " +
-      "keyup (e) -> write 'up ' + e.which"],
-  function(fn) {
-    $(window).keyup(fn);
-  }),
-  keypress: wrapraw('keypress',
-  ["keypress(fn) Calls fn(event) whenever a letter is typed. " +
-      "keypress (e) -> write 'press ' + e.which"],
-  function(fn) {
-    $(window).keypress(fn);
-  }),
+      "keyup (e) -> write 'up ' + e.key"]),
+  keypress: wrapwindowevent('keypress',
+  ["keypress(fn) Calls fn(event) whenever a character key is pressed. " +
+      "keypress (e) -> write 'press ' + e.key"]),
   send: wrapraw('send',
   ["send(name) Sends a message to be received by recv. " +
       "send 'go'; recv 'go', -> fd 100"],
@@ -7116,41 +8190,129 @@ var dollar_turtle_methods = {
   ["abs(x) The absolute value of x. " +
       "see abs -5"], Math.abs),
   acos: wrapraw('acos',
-  ["acos(degreees) Trigonometric arccosine, in degrees. " +
-      "see acos 0.5"],
-  function acos(x) { return roundEpsilon(Math.acos(x) * 180 / Math.PI); }
-  ),
+  ["acos(x) Trigonometric arccosine, in radians. " +
+      "see acos 0.5"], Math.acos),
   asin: wrapraw('asin',
-  ["asin(degreees) Trigonometric arcsine, in degrees. " +
-      "see asin 0.5"],
-  function asin(x) { return roundEpsilon(Math.asin(x) * 180 / Math.PI); }
-  ),
+  ["asin(y) Trigonometric arcsine, in radians. " +
+      "see asin 0.5"], Math.asin),
   atan: wrapraw('atan',
-  ["atan(degreees) Trigonometric arctangent, in degrees. " +
+  ["atan(y, x = 1) Trigonometric arctangent, in radians. " +
       "see atan 0.5"],
-  function atan(x) { return roundEpsilon(Math.atan(x) * 180 / Math.PI); }
+  function atan(y, x) { return Math.atan2(y, (x == undefined) ? 1 : x); }
   ),
-  atan2: wrapraw('atan2',
-  ["atan2(degreees) Trigonometric two-argument arctangent, " +
-      "in degrees. see atan -1, 0"],
-  function atan2(x, y) {
-    return roundEpsilon(Math.atan2(x, y) * 180 / Math.PI);
-  }),
   cos: wrapraw('cos',
-  ["cos(degreees) Trigonometric cosine, in degrees. " +
-      "see cos 45"],
-  function cos(x) { return roundEpsilon(Math.cos((x % 360) * Math.PI / 180)); }
-  ),
+  ["cos(radians) Trigonometric cosine, in radians. " +
+      "see cos 0"], Math.cos),
   sin: wrapraw('sin',
-  ["sin(degreees) Trigonometric sine, in degrees. " +
-      "see sin 45"],
-  function sin(x) { return roundEpsilon(Math.sin((x % 360) * Math.PI / 180)); }
-  ),
+  ["sin(radians) Trigonometric sine, in radians. " +
+      "see sin 0"], Math.sin),
   tan: wrapraw('tan',
-  ["tan(degreees) Trigonometric tangent, in degrees. " +
-      "see tan 45"],
-  function tan(x) { return roundEpsilon(Math.tan((x % 360) * Math.PI / 180)); }
-  ),
+  ["tan(radians) Trigonometric tangent, in radians. " +
+      "see tan 0"], Math.tan),
+
+  // For degree versions of trig functions, make sure we return exact
+  // results when possible. The set of values we have to consider is
+  // fortunately very limited. See "Rational Values of Trigonometric
+  // Functions." http://www.jstor.org/stable/2304540
+
+  acosd: wrapraw('acosd',
+  ["acosd(x) Trigonometric arccosine, in degrees. " +
+      "see acosd 0.5"],
+   function acosd(x) {
+     switch (x) {
+       case   1: return   0;
+       case  .5: return  60;
+       case   0: return  90;
+       case -.5: return 120;
+       case  -1: return 180;
+     }
+     return Math.acos(x) * 180 / Math.PI;
+  }),
+  asind: wrapraw('asind',
+  ["asind(x) Trigonometric arcsine, in degrees. " +
+      "see asind 0.5"],
+  function asind(x) {
+    switch (x) {
+      case   1: return  90;
+      case  .5: return  30;
+      case   0: return   0;
+      case -.5: return -30;
+      case  -1: return -90;
+    }
+    return Math.asin(x) * 180 / Math.PI;
+  }),
+  atand: wrapraw('atand',
+  ["atand(y, x = 1) Trigonometric arctangent, " +
+      "in degrees. see atand -1, 0/mark>"],
+  function atand(y, x) {
+    if (x == undefined) { x = 1; }
+    if (y == 0) {
+      return (x == 0) ? NaN : ((x > 0) ? 0 : 180);
+    } else if (x == 0) {
+      return (y > 0) ? Infinity : -Infinity;
+    } else if (Math.abs(y) == Math.abs(x)) {
+      return (y > 0) ? ((x > 0) ? 45 : 135) :
+                       ((x > 0) ? -45 : -135);
+    }
+    return Math.atan2(y, x) * 180 / Math.PI;
+  }),
+  cosd: wrapraw('cosd',
+  ["cosd(degrees) Trigonometric cosine, in degrees. " +
+      "see cosd 45"],
+  function cosd(x) {
+    x = modulo(x, 360);
+    if (x % 30 === 0) {
+      switch ((x < 0) ? x + 360 : x) {
+        case   0: return   1;
+        case  60: return  .5;
+        case  90: return   0;
+        case 120: return -.5;
+        case 180: return  -1;
+        case 240: return -.5;
+        case 270: return   0;
+        case 300: return  .5;
+      }
+    }
+    return Math.cos(x / 180 * Math.PI);
+  }),
+  sind: wrapraw('sind',
+  ["sind(degrees) Trigonometric sine, in degrees. " +
+      "see sind 45"],
+  function sind(x) {
+    x = modulo(x, 360);
+    if (x % 30 === 0) {
+      switch ((x < 0) ? x + 360 : x) {
+        case   0: return   0;
+        case  30: return  .5;
+        case  90: return   1;
+        case 150: return  .5;
+        case 180: return   0;
+        case 210: return -.5;
+        case 270: return  -1;
+        case 330: return -.5;
+      }
+    }
+    return Math.sin(x / 180 * Math.PI);
+  }),
+  tand: wrapraw('tand',
+  ["tand(degrees) Trigonometric tangent, in degrees. " +
+      "see tand 45"],
+  function tand(x) {
+    x = modulo(x, 360);
+    if (x % 45 === 0) {
+      switch ((x < 0) ? x + 360 : x) {
+        case   0: return 0;
+        case  45: return 1;
+        case  90: return Infinity;
+        case 135: return -1;
+        case 180: return 0;
+        case 225: return 1;
+        case 270: return -Infinity;
+        case 315: return -1
+      }
+    }
+    return Math.tan(x / 180 * Math.PI);
+  }),
   ceil: wrapraw('ceil',
   ["ceil(x) Round up. " +
       "see ceil 1.9"], Math.ceil),
@@ -7183,6 +8345,12 @@ var dollar_turtle_methods = {
   min: wrapraw('min',
   ["min(x, y, ...) The minimum of a set of values. " +
       "see min 2, -5, 1"], Math.min),
+  Pencil: wrapraw('Pencil',
+  ["new Pencil(canvas) " +
+      "Make an invisble pencil for drawing on a canvas. " +
+      "s = new Sprite; p = new Pencil(s); " +
+      "p.pen red; p.fd 100; remove p"],
+      Pencil),
   Turtle: wrapraw('Turtle',
   ["new Turtle(color) Make a new turtle. " +
       "t = new Turtle; t.fd 100"], Turtle),
@@ -7202,7 +8370,7 @@ var dollar_turtle_methods = {
   ["loadscript(url, callback) Loads Javascript or Coffeescript from " +
        "the given URL, calling callback when done."],
   function loadscript(url, callback) {
-    if (window.CoffeeScript && /\.(?:coffee|cs)$/.test(url)) {
+    if (global.CoffeeScript && /\.(?:coffee|cs)$/.test(url)) {
       CoffeeScript.load(url, callback);
     } else {
       $.getScript(url, callback);
@@ -7249,6 +8417,8 @@ function pollSendRecv(name) {
 
 deprecate(dollar_turtle_methods, 'defaultspeed', 'speed');
 
+dollar_turtle_methods.save.loginCookie = loginCookie;
+
 var helpok = {};
 
 var colors = [
@@ -7256,29 +8426,31 @@ var colors = [
   "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown",
   "burlywood", "cadetblue", "chartreuse", "chocolate", "coral",
   "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan",
-  "darkgoldenrod", "darkgray", "darkgreen", "darkkhaki", "darkmagenta",
-  "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon",
-  "darkseagreen", "darkslateblue", "darkslategray", "darkturquoise",
-  "darkviolet", "deeppink", "deepskyblue", "dimgray", "dodgerblue",
-  "firebrick", "floralwhite", "forestgreen", "fuchsia", "gainsboro",
-  "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow",
-  "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki",
-  "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue",
-  "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray",
-  "lightgreen", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue",
-  "lightslategray", "lightsteelblue", "lightyellow", "lime", "limegreen",
-  "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue",
-  "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue",
-  "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue",
-  "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace",
-  "olive", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod",
-  "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff",
-  "peru", "pink", "plum", "powderblue", "purple", "red", "rosybrown",
-  "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen",
-  "seashell", "sienna", "silver", "skyblue", "slateblue", "slategray",
-  "snow", "springgreen", "steelblue", "tan", "teal", "thistle", "tomato",
+  "darkgoldenrod", "darkgray", "darkgrey", "darkgreen", "darkkhaki",
+  "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred",
+  "darksalmon", "darkseagreen", "darkslateblue", "darkslategray",
+  "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue",
+  "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite",
+  "forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod",
+  "gray", "grey", "green", "greenyellow", "honeydew", "hotpink",
+  "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush",
+  "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan",
+  "lightgoldenrodyellow", "lightgray", "lightgrey", "lightgreen",
+  "lightpink", "lightsalmon", "lightseagreen", "lightskyblue",
+  "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow",
+  "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine",
+  "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen",
+  "mediumslateblue", "mediumspringgreen", "mediumturquoise",
+  "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin",
+  "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange",
+  "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise",
+  "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum",
+  "powderblue", "purple", "rebeccapurple", "red", "rosybrown", "royalblue",
+  "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna",
+  "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow",
+  "springgreen", "steelblue", "tan", "teal", "thistle", "tomato",
   "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow",
-  "yellowgreen", "transparent",
+  "yellowgreen", "transparent"
 ];
 
 (function() {
@@ -7295,6 +8467,7 @@ var colors = [
   }
   dollar_turtle_methods.PI = Math.PI;
   dollar_turtle_methods.E = Math.E;
+  dollar_turtle_methods.print = dollar_turtle_methods.write
   extrahelp.colors = {helptext:
       ["Defined colors: " + colors.join(" ")]};
   extrahelp.see = {helptext:
@@ -7314,15 +8487,16 @@ var colors = [
 
 $.turtle = function turtle(id, options) {
   var exportedsee = false;
-  if (!arguments.length) {
-    id = 'turtle';
-  }
   if (arguments.length == 1 && typeof(id) == 'object' && id &&
       !id.hasOwnProperty('length')) {
     options = id;
     id = 'turtle';
   }
+  id = id || 'turtle';
   options = options || {};
+  if ('turtle' in options) {
+    id = options.turtle;
+  }
   // Clear any previous turtle methods.
   clearGlobalTurtle();
   // Expand any 
+
+
+
+
+
+ diff --git a/test/autoscroll.html b/test/autoscroll.html index 81ff91b..f9cf865 100644 --- a/test/autoscroll.html +++ b/test/autoscroll.html @@ -26,6 +26,7 @@ if (/phantom/i.test(navigator.userAgent)) { // PhantomJS doesn't correctly deal with full-window scrolling. // https://github.com/ariya/phantomjs/issues/10619 + delete parent.window.dotest; start(); } else { var urect = fw.$('.last').get(0).getBoundingClientRect(); diff --git a/test/boxfill.html b/test/boxfill.html index cc07f56..8289c36 100644 --- a/test/boxfill.html +++ b/test/boxfill.html @@ -6,7 +6,7 @@
+ + + + +
+ + + + diff --git a/test/continuations.html b/test/continuations.html index 5f2d440..2dbf83d 100644 --- a/test/continuations.html +++ b/test/continuations.html @@ -28,7 +28,7 @@ ok(!touches(red)); turnto(0, function() { ok(!touches(red)); - slide(0, -100, function() { + move(0, -100, function() { ok(touches(red)); moveto(0, 0, function() { ok(touches(red)); diff --git a/test/copy.html b/test/copy.html new file mode 100644 index 0000000..d659aea --- /dev/null +++ b/test/copy.html @@ -0,0 +1,48 @@ + + + + + +
+ diff --git a/test/debugevents.html b/test/debugevents.html index 64d3a57..b63142f 100644 --- a/test/debugevents.html +++ b/test/debugevents.html @@ -64,6 +64,7 @@ window.ide = { bindframe: function() { verify('bindframe'); + return true; }, reportEvent: function(eventname, args) { verify([eventname, args[0]]); diff --git a/test/dotfill.html b/test/dotfill.html index 44e40d2..6f4a89b 100644 --- a/test/dotfill.html +++ b/test/dotfill.html @@ -52,7 +52,7 @@ ok(!touches(green)); ok(touches(red)); ok(touches(transparent)); - slide(50); + move(50); ok(!touches('tan')); ok(!touches(green)); ok(touches(red)); @@ -72,7 +72,7 @@ ok(touches(green)); ok(!touches(red)); ok(!touches(transparent)); - slide(50); + move(50); ok(!touches('tan')); ok(touches(green)); ok(!touches(red)); diff --git a/test/drawon.html b/test/drawon.html index 8e8fbec..bf68802 100644 --- a/test/drawon.html +++ b/test/drawon.html @@ -10,7 +10,7 @@ asyncTest("Draws on another turtle.", function() { speed(Infinity); css({zIndex:1}); - var B = hatch('1000x1000/5'); + var B = hatch({width: 1000, height: 1000, subpixel: 5}); drawon(B); ok(!touches(blue)); ok(touches(transparent)); @@ -61,13 +61,15 @@ ok(!touches(transparent)); ok(!touches(red)); ok(touches(yellow)); - drawon(null); + drawon(window); ok(!touches(blue)); ok(touches(transparent)); ok(!touches(red)); ok(!touches(yellow)); pen(red); - fd(50); + fd(25); + drawon(null) + fd(25); drawon(canvas()); fd(50); ok(!touches(blue)); @@ -79,4 +81,18 @@ start(); }); }); + +asyncTest("Drawon does not deadlock with sync.", function() { + speed(100); + var trt = new Sprite(); + drawon(trt); + dot(red, 100); + drawon(); + sync(turtle, trt); + trt.bk(100); + trt.done(function() { + ok(touches(red)); + start(); + }); +}); diff --git a/test/fern.html b/test/fern.html index 37592f9..d3d505a 100644 --- a/test/fern.html +++ b/test/fern.html @@ -6,9 +6,9 @@
+ + + + +
+ diff --git a/test/globals.html b/test/globals.html new file mode 100644 index 0000000..a19cb14 --- /dev/null +++ b/test/globals.html @@ -0,0 +1,348 @@ + + + + + + +
+ diff --git a/test/hide.html b/test/hide.html new file mode 100644 index 0000000..7af0f92 --- /dev/null +++ b/test/hide.html @@ -0,0 +1,29 @@ + + + + + +
+ diff --git a/test/img.html b/test/img.html new file mode 100644 index 0000000..3f3d734 --- /dev/null +++ b/test/img.html @@ -0,0 +1,43 @@ + + + + + + + +
+ diff --git a/test/inputoutput.html b/test/inputoutput.html new file mode 100644 index 0000000..b3b33b4 --- /dev/null +++ b/test/inputoutput.html @@ -0,0 +1,64 @@ + + + + + +
+ diff --git a/test/inside.html b/test/inside.html index ff1df20..513402d 100644 --- a/test/inside.html +++ b/test/inside.html @@ -12,7 +12,7 @@ ok(inside(window)); var p = write('hello'); ok(p.inside(window)); - p.slide(-25, 0); + p.move(-25, 0); ok(!p.inside(window)); fd(2000); ok(!inside(window)); diff --git a/test/instrument.html b/test/instrument.html index d6a52a7..c0b9ff4 100644 --- a/test/instrument.html +++ b/test/instrument.html @@ -361,12 +361,12 @@ ins.on('noteoff', function(e) { notelog.push('off' + e.midi + '-' + (currentTick() - startTick)); }); - ins.tone('C'); + ins.tone('C', 10); setTimeout(function() { - ins.tone('G'); - ins.tone('E'); + ins.tone('G', 10); + ins.tone('E', 10); setTimeout(function() { - ins.tone('C'); + ins.tone('C', 10); setTimeout(function() { ins.tone('G', 0); setTimeout(function() { @@ -467,64 +467,64 @@ "on57-3845", "on60-3845", "on63-3845", - "off54-5805", - "off57-5805", - "off60-5805", - "off63-5805", - "on55-5805", - "on59-5805", - "on62-5805", - "off55-6743", - "off59-6743", - "off62-6743", - "on44-7227", - "off44-7680", - "on56-7696", - "on59-7696", - "on62-7696", - "on65-7696", - "off56-10358", - "off59-10358", - "off62-10358", - "off65-10358", - "on56-10358", - "on59-10358", - "on62-10358", - "on65-10358", - "off56-10546", - "off59-10546", - "off62-10546", - "off65-10546", - "on55-10562", - "on59-10562", - "on62-10562", - "on67-10562", - "off55-11281", - "off59-11281", - "off62-11281", - "off67-11281", - "on53-11281", - "on59-11281", - "on62-11281", - "on68-11281", - "off53-11500", - "off59-11500", - "off62-11500", - "off68-11500", - "on51-11531", - "on59-11531", - "on62-11531", - "on68-11531", - "off59-13491", - "off62-13491", - "off68-13491", - "on60-13491", - "on63-13491", - "on67-13491", - "off51-14413", - "off60-14413", - "off63-14413", - "off67-14413" + "off54-5804", + "off57-5804", + "off60-5804", + "off63-5804", + "on55-5804", + "on59-5804", + "on62-5804", + "off55-6742", + "off59-6742", + "off62-6742", + "on44-7226", + "off44-7679", + "on56-7695", + "on59-7695", + "on62-7695", + "on65-7695", + "off56-10357", + "off59-10357", + "off62-10357", + "off65-10357", + "on56-10357", + "on59-10357", + "on62-10357", + "on65-10357", + "off56-10545", + "off59-10545", + "off62-10545", + "off65-10545", + "on55-10576", + "on59-10576", + "on62-10576", + "on67-10576", + "off55-11279", + "off59-11279", + "off62-11279", + "off67-11279", + "on53-11295", + "on59-11295", + "on62-11295", + "on68-11295", + "off53-11514", + "off59-11514", + "off62-11514", + "off68-11514", + "on51-11530", + "on59-11530", + "on62-11530", + "on68-11530", + "off59-13474", + "off62-13474", + "off68-13474", + "on60-13474", + "on63-13474", + "on67-13474", + "off51-14412", + "off60-14412", + "off63-14412", + "off67-14412" ]); ins.remove(); start(); diff --git a/test/interrupt.html b/test/interrupt.html index 3ddf2b0..19f018c 100644 --- a/test/interrupt.html +++ b/test/interrupt.html @@ -5,7 +5,7 @@
diff --git a/test/label.html b/test/label.html index 66ec7ae..927b318 100644 --- a/test/label.html +++ b/test/label.html @@ -36,25 +36,36 @@ label('big'); bk(100) rt(90); - label('mid', { fontSize: 50 }); + label('mid', { fontSize: 50, class: "mid" }); fd(150) label('giant', 150); - label('upside down', { turtleRotation: 180, fontSize: 8 }); + label('upside down', { turtleRotation: 180, fontSize: 8, id: 'upside' }); + label('bottomleft', 'bottom-left'); done(function() { ok($('label:contains(small)').width() < $('label:contains(mid)').width()); ok($('label:contains(mid)').width() < $('label:contains(big)').width()); ok($('label:contains(big)').width() < $('label:contains(giant)').width()); - ok($('label:contains(upside)').width() < $('label:contains(mid)').width()); + ok($('label:contains(upside)').width() <$('label:contains(mid)').width()); + equal($('.mid').length, 1); + equal($('.mid').text(), 'mid'); + equal($('#upside').text(), 'upside down'); deepEqual(rounded($('label:contains(small)').getxy()), [0, 0]); deepEqual(rounded($('label:contains(big)').getxy()), [0, 50]); deepEqual(rounded($('label:contains(mid)').getxy()), [0, -50]); deepEqual(rounded($('label:contains(giant)').getxy()), [150, -50]); + ok($('label:contains(bottomleft)').getxy()[0] < -50); + ok($('label:contains(bottomleft)').getxy()[1] < -100); equal(rounded($('label:contains(small)').direction()), 0); equal(rounded($('label:contains(big)').direction()), 0); equal(rounded($('label:contains(mid)').direction()), 90); equal(rounded($('label:contains(giant)').direction()), 90); + equal(rounded($('label:contains(bottomleft)').direction()), 0); equal(direction(), 90); equal(rounded($('label:contains(upside down)').direction()), 180); + var b; + label(b = button('hello')); + ok(touches(b)); + equal(b.direction(), 90); start(); }); }); diff --git a/test/newturtle.html b/test/newturtle.html index 0832a38..832212d 100644 --- a/test/newturtle.html +++ b/test/newturtle.html @@ -50,8 +50,19 @@ } done(function() { - $('#radius').remove(); equal(expected, 4); + $('#radius').remove(); + speed(Infinity); + ok(touches(targets[0])); + fd(100); + ok(touches(targets[1])); + remove(targets); + ok(!touches(targets[1])); + rt(90) + fd(100); + ok(!touches(targets[2])); + moveto({pageX: 0, pageY: 0}); + ok(!touches(targets[2])); start(); }); }); diff --git a/test/numeric_functions.html b/test/numeric_functions.html new file mode 100644 index 0000000..49ee2df --- /dev/null +++ b/test/numeric_functions.html @@ -0,0 +1,77 @@ + + + + + +
+ diff --git a/test/field.html b/test/origin.html similarity index 85% rename from test/field.html rename to test/origin.html index 1674406..0f0534e 100644 --- a/test/field.html +++ b/test/origin.html @@ -7,7 +7,7 @@ -
+
diff --git a/test/piano.html b/test/piano.html new file mode 100644 index 0000000..c85681b --- /dev/null +++ b/test/piano.html @@ -0,0 +1,51 @@ + + + + + +
+ + + + diff --git a/test/pressed.html b/test/pressed.html index e523b54..0829718 100644 --- a/test/pressed.html +++ b/test/pressed.html @@ -7,15 +7,50 @@ + + + + + +
+ diff --git a/test/remove.html b/test/remove.html index 6050a37..98e7166 100644 --- a/test/remove.html +++ b/test/remove.html @@ -33,6 +33,10 @@ ok(completed); equal(t.parent().length, 0); equal(typeof(window.turtle), 'undefined'); + // Restore globals to an initial state. + $.turtle({ids: false}); + window.turtle = $('#turtle'); + equal(typeof(window.turtle), 'object'); start(); }); }); diff --git a/test/revolve.html b/test/revolve.html new file mode 100644 index 0000000..49f6bbc --- /dev/null +++ b/test/revolve.html @@ -0,0 +1,43 @@ + + + + + +
+ diff --git a/test/say.html b/test/say.html new file mode 100644 index 0000000..a8aed1c --- /dev/null +++ b/test/say.html @@ -0,0 +1,25 @@ + + + + + +
+ + + + diff --git a/test/stop.html b/test/stop.html new file mode 100644 index 0000000..3e77d67 --- /dev/null +++ b/test/stop.html @@ -0,0 +1,31 @@ + + + + + +
+ diff --git a/test/sync.html b/test/sync.html index 87e4d1a..4808c51 100644 --- a/test/sync.html +++ b/test/sync.html @@ -46,4 +46,22 @@ start(); }); }); + +asyncTest("Moves a written object and verifies that sync works.", function() { + speed(10); + var w = write('hello'); + w.bk(100); + sync(w, turtle); + w.plan(function() { + // When w is done moving, w2 should not have moved yet. + var xy2 = w2.getxy(); + var xy = w.getxy(); + ok(xy2[1] > xy[1]); + }); + var w2 = write('hello2'); + w2.bk(100); + done(function() { + start(); + }); +}); diff --git a/test/touches.html b/test/touches.html new file mode 100644 index 0000000..659887d --- /dev/null +++ b/test/touches.html @@ -0,0 +1,31 @@ + + + + + +
+ diff --git a/test/twist.html b/test/twist.html index f62ff23..b3e3f21 100644 --- a/test/twist.html +++ b/test/twist.html @@ -9,16 +9,18 @@ module("Twist test."); asyncTest("Test of twisting a skinny shape.", function() { speed(Infinity); - slide(100); + move(100); dot(yellow); - slide(-200); + move(-200); dot(pink); - slide(100); + move(100); fd(100); dot(red); bk(200) dot(blue); - var s = new Sprite({ width: 10, height: 200, color: green }); + var s = new Sprite({ width: 10, height: 200, color: green, id: "s" }); + equal($('#s').length, 1); + equal(s.filter('#s').length, 1); ok(s.touches(red)); ok(s.touches(blue)); ok(!s.touches(pink)); diff --git a/test/wear.html b/test/wear.html index 37a581c..12a9d3f 100644 --- a/test/wear.html +++ b/test/wear.html @@ -3,10 +3,41 @@ -
+
diff --git a/test/write.html b/test/write.html new file mode 100644 index 0000000..248e0f5 --- /dev/null +++ b/test/write.html @@ -0,0 +1,76 @@ + + + + + +
+ diff --git a/turtle.jquery.json b/turtle.jquery.json index be5c732..cd8b8a3 100644 --- a/turtle.jquery.json +++ b/turtle.jquery.json @@ -14,7 +14,7 @@ "audio", "collision" ], - "version": "2.0.8", + "version": "2.0.9", "author": { "name": "David Bau", "url": "http://davidbau.com/"