go.talks: refactor Playground JavaScript API

These changes will be propagated to the go-tour, go-playground, and go core.

R=dsymonds
CC=golang-dev
https://golang.org/cl/10868045
diff --git a/present/appengine.go b/present/appengine.go
index 98b412c..ae93a5a 100644
--- a/present/appengine.go
+++ b/present/appengine.go
@@ -14,6 +14,6 @@
 var basePath = "./present/"
 
 func init() {
-	playScript(basePath, "playground.js")
+	playScript(basePath, "HTTPTransport")
 	present.PlayEnabled = true
 }
diff --git a/present/js/play.js b/present/js/play.js
index a30ca74..fc15ce5 100644
--- a/present/js/play.js
+++ b/present/js/play.js
@@ -2,115 +2,97 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-(function() {
-  "use strict";
+function initPlayground(transport) {
+	"use strict";
 
-  var runFunc;
-  var count = 0;
+	function text(node) {
+		var s = "";
+		for (var i = 0; i < node.childNodes.length; i++) {
+			var n = node.childNodes[i];
+			if (n.nodeType === 1 && n.tagName === "SPAN" && n.className != "number") {
+				var innerText = n.innerText === undefined ? "textContent" : "innerText";
+				s += n[innerText] + "\n";
+				continue;
+			}
+			if (n.nodeType === 1 && n.tagName !== "BUTTON") {
+				s += text(n);
+			}
+		}
+		return s;
+	}
 
-  function getId() {
-    return "code" + (count++);
-  }
+	function init(code) {
+		var output = document.createElement('div');
+		var outpre = document.createElement('pre');
+		var running;
 
-  function text(node) {
-    var s = "";
-    for (var i = 0; i < node.childNodes.length; i++) {
-      var n = node.childNodes[i];
-      if (n.nodeType === 1 && n.tagName === "SPAN" && n.className != "number") {
-        var innerText = n.innerText === undefined ? "textContent" : "innerText";
-        s += n[innerText] + "\n";
-        continue;
-      }
-      if (n.nodeType === 1 && n.tagName !== "BUTTON") {
-        s += text(n);
-      }
-    }
-    return s;
-  }
+		// TODO(adg): check that jquery etc is loaded.
+		$(output).resizable({
+			handles: "n,w,nw",
+			minHeight:	27,
+			minWidth:	135,
+			maxHeight: 608,
+			maxWidth:	990
+		});
 
-  function init(code) {
-    var id = getId();
+		function onKill() {
+			if (running) running.Kill();
+		}
 
-    var output = document.createElement('div');
-    var outpre = document.createElement('pre');
-    var stopFunc;
+		function onRun(e) {
+			onKill();
+			output.style.display = "block";
+			outpre.innerHTML = "";
+			run1.style.display = "none";
+			var options = {Race: e.shiftKey};
+			running = transport.Run(text(code), PlaygroundOutput(outpre), options);
+		}
 
-    $(output).resizable({
-      handles: "n,w,nw",
-      minHeight:  27,
-      minWidth:  135,
-      maxHeight: 608,
-      maxWidth:  990
-    });
+		function onClose() {
+			onKill();
+			output.style.display = "none";
+			run1.style.display = "inline-block";
+		}
 
-    function onKill() {
-      if (stopFunc) {
-        stopFunc();
-      }
-    }
+		var run1 = document.createElement('button');
+		run1.innerHTML = 'Run';
+		run1.className = 'run';
+		run1.addEventListener("click", onRun, false);
+		var run2 = document.createElement('button');
+		run2.className = 'run';
+		run2.innerHTML = 'Run';
+		run2.addEventListener("click", onRun, false);
+		var kill = document.createElement('button');
+		kill.className = 'kill';
+		kill.innerHTML = 'Kill';
+		kill.addEventListener("click", onKill, false);
+		var close = document.createElement('button');
+		close.className = 'close';
+		close.innerHTML = 'Close';
+		close.addEventListener("click", onClose, false);
 
-    function onRun(e) {
-      onKill();
-      outpre.innerHTML = "";
-      output.style.display = "block";
-      run.style.display = "none";
-      var options = {Race: e.shiftKey};
-      stopFunc = runFunc(text(code), outpre, options);
-    }
+		var button = document.createElement('div');
+		button.classList.add('buttons');
+		button.appendChild(run1);
+		// Hack to simulate insertAfter
+		code.parentNode.insertBefore(button, code.nextSibling);
 
-    function onClose() {
-      onKill();
-      output.style.display = "none";
-      run.style.display = "inline-block";
-    }
+		var buttons = document.createElement('div');
+		buttons.classList.add('buttons');
+		buttons.appendChild(run2);
+		buttons.appendChild(kill);
+		buttons.appendChild(close);
 
-    var run = document.createElement('button');
-    run.innerHTML = 'Run';
-    run.className = 'run';
-    run.addEventListener("click", onRun, false);
-    var run2 = document.createElement('button');
-    run2.className = 'run';
-    run2.innerHTML = 'Run';
-    run2.addEventListener("click", onRun, false);
-    var kill = document.createElement('button');
-    kill.className = 'kill';
-    kill.innerHTML = 'Kill';
-    kill.addEventListener("click", onKill, false);
-    var close = document.createElement('button');
-    close.className = 'close';
-    close.innerHTML = 'Close';
-    close.addEventListener("click", onClose, false);
+		output.classList.add('output');
+		output.appendChild(buttons);
+		output.appendChild(outpre);
+		output.style.display = "none";
+		code.parentNode.insertBefore(output, button.nextSibling);
+	}
 
-    var button = document.createElement('div');
-    button.classList.add('buttons');
-    button.appendChild(run);
-    // Hack to simulate insertAfter
-    code.parentNode.insertBefore(button, code.nextSibling);
+	var play = document.querySelectorAll('div.playground');
+	for (var i = 0; i < play.length; i++) {
+		init(play[i]);
+	}
+}
 
-    var buttons = document.createElement('div');
-    buttons.classList.add('buttons');
-    buttons.appendChild(run2);
-    buttons.appendChild(kill);
-    buttons.appendChild(close);
-
-    output.classList.add('output');
-    output.appendChild(buttons);
-    output.appendChild(outpre);
-    output.style.display = "none";
-    code.parentNode.insertBefore(output, button.nextSibling);
-  }
-
-  var play = document.querySelectorAll('div.playground');
-  for (var i = 0; i < play.length; i++) {
-    init(play[i]);
-  }
-  if (play.length > 0) {
-    if (window.connectPlayground) {
-      runFunc = window.connectPlayground("ws://" + window.location.host + "/socket");
-    } else {
-      // If this message is logged,
-      // we have neglected to include socket.js or playground.js.
-      console.log("No playground transport available.");
-    }
-  }
-})();
diff --git a/present/js/playground.js b/present/js/playground.js
index 67961c2..d2be24c 100644
--- a/present/js/playground.js
+++ b/present/js/playground.js
@@ -2,9 +2,210 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-(function() {
+/*
+In the absence of any formal way to specify interfaces in JavaScript,
+here's a skeleton implementation of a playground transport.
 
-  // TODO(adg): make these functions operate only on a specific code div
+        function Transport() {
+                // Set up any transport state (eg, make a websocket connnection).
+                return {
+                        Run: function(body, output, options) {
+                                // Compile and run the program 'body' with 'options'.
+				// Call the 'output' callback to display program output.
+                                return {
+                                        Kill: function() {
+                                                // Kill the running program.
+                                        }
+                                };
+                        }
+                };
+        }
+
+	// The output callback is called multiple times, and each time it is
+	// passed an object of this form.
+        var write = {
+                Kind: 'string', // 'start', 'stdout', 'stderr', 'end'
+                Body: 'string'  // content of write or end status message
+        }
+
+	// The first call must be of Kind 'start' with no body.
+	// Subsequent calls may be of Kind 'stdout' or 'stderr'
+	// and must have a non-null Body string.
+	// The final call should be of Kind 'end' with an optional
+	// Body string, signifying a failure ("killed", for example).
+
+	// The output callback must be of this form.
+	// See PlaygroundOutput (below) for an implementation.
+        function outputCallback(write) {
+                // Append writes to 
+        }
+*/
+
+function HTTPTransport() {
+	'use strict';
+
+	// TODO(adg): support stderr
+
+	function playback(output, events) {
+		var timeout;
+		output({Kind: 'start'});
+		function next() {
+			if (events.length === 0) {
+				output({Kind: 'end'});
+				return;
+			}
+			var e = events.shift();
+			if (e.Delay === 0) {
+				output({Kind: 'stdout', Body: e.Message});
+				next();
+				return;
+			}
+			timeout = setTimeout(function() {
+				output({Kind: 'stdout', Body: e.Message});
+				next();
+			}, e.Delay / 1000000);
+		}
+		next();
+		return {
+			Stop: function() {
+				clearTimeout(timeout);
+			}
+		}
+	}
+
+	function error(output, msg) {
+		output({Kind: 'start'});
+		output({Kind: 'stderr', Body: msg});
+		output({Kind: 'end'});
+	}
+
+	var seq = 0;
+	return {
+		Run: function(body, output, options) {
+			seq++;
+			var cur = seq;
+			var playing;
+			$.ajax('/compile', {
+				type: 'POST',
+				data: {'version': 2, 'body': body},
+				dataType: 'json',
+				success: function(data) {
+					if (seq != cur) return;
+					if (!data) return;
+					if (playing != null) playing.Stop();
+					if (data.Errors) {
+						error(output, data.Errors);
+						return;
+					}
+					playing = playback(output, data.Events);
+				},
+				error: function() {
+					error(output, 'Error communicating with remote server.');
+				}
+			});
+			return {
+				Kill: function() {
+					if (playing != null) playing.Stop();
+					output({Kind: 'end', Body: 'killed'});
+				}
+			};
+		}
+	};
+}
+
+function SocketTransport() {
+	'use strict';
+
+	var id = 0;
+	var outputs = {};
+	var started = {};
+	var websocket = new WebSocket('ws://' + window.location.host + '/socket');
+
+	websocket.onclose = function() {
+		console.log('websocket connection closed');
+	}
+
+	websocket.onmessage = function(e) {
+		var m = JSON.parse(e.data);
+		var output = outputs[m.Id];
+		if (output === null)
+			return;
+		if (!started[m.Id]) {
+			output({Kind: 'start'});
+			started[m.Id] = true;
+		}
+		output({Kind: m.Kind, Body: m.Body});
+	}
+
+	function send(m) {
+		websocket.send(JSON.stringify(m));
+	}
+
+	return {
+		Run: function(body, output, options) {
+			var thisID = id+'';
+			id++;
+			outputs[thisID] = output;
+			send({Id: thisID, Kind: 'run', Body: body, Options: options});
+			return {
+				Kill: function() {
+					send({Id: thisID, Kind: 'kill'});
+				}
+			};
+		}
+	};
+}
+
+function PlaygroundOutput(el) {
+	'use strict';
+
+	return function(write) {
+		if (write.Kind == 'start') {
+			el.innerHTML = '';
+			return;
+		}
+
+		var cl = 'system';
+		if (write.Kind == 'stdout' || write.Kind == 'stderr')
+			cl = write.Kind;
+
+		var m = write.Body;
+		if (write.Kind == 'end') 
+			m = '\nProgram exited' + (m?(': '+m):'.');
+
+		if (m.indexOf('IMAGE:') === 0) {
+			// TODO(adg): buffer all writes before creating image
+			var url = 'data:image/png;base64,' + m.substr(6);
+			var img = document.createElement('img');
+			img.src = url;
+			el.appendChild(img);
+			return;
+		}
+
+		// ^L clears the screen.
+		var s = m.split('\x0c');
+		if (s.length > 1) {
+			el.innerHTML = '';
+			m = s.pop();
+		}
+
+		m = m.replace(/&/g, '&amp;');
+		m = m.replace(/</g, '&lt;');
+		m = m.replace(/>/g, '&gt;');
+
+		var needScroll = (el.scrollTop + el.offsetHeight) == el.scrollHeight;
+
+		var span = document.createElement('span');
+		span.className = cl;
+		span.innerHTML = m;
+		el.appendChild(span);
+
+		if (needScroll)
+			el.scrollTop = el.scrollHeight - el.offsetHeight;
+	}
+}
+
+(function() {
   function lineHighlight(error) {
     var regex = /prog.go:([0-9]+)/g;
     var r = regex.exec(error);
@@ -13,115 +214,16 @@
       r = regex.exec(error);
     }
   }
+  function highlightOutput(wrappedOutput) {
+    return function(write) {
+      if (write.Body) lineHighlight(write.Body);
+      wrappedOutput(write);
+    }
+  }
   function lineClear() {
     $(".lineerror").removeClass("lineerror");
   }
 
-  function connectPlayground() {
-    var playbackTimeout;
-
-    function playback(pre, events) {
-      function show(msg) {
-        // ^L clears the screen.
-        var msgs = msg.split("\x0c");
-        if (msgs.length == 1) {
-          pre.text(pre.text() + msg);
-          return;
-        }
-        pre.text(msgs.pop());
-      }
-      function next() {
-        if (events.length === 0) {
-          var exit = $('<span class="exit"/>');
-          exit.text("\nProgram exited.");
-          exit.appendTo(pre);
-          return;
-        }
-        var e = events.shift();
-        if (e.Delay === 0) {
-          show(e.Message);
-          next();
-        } else {
-          playbackTimeout = setTimeout(function() {
-            show(e.Message);
-            next();
-          }, e.Delay / 1000000);
-        }
-      }
-      next();
-    }
-
-    function stopPlayback() {
-      clearTimeout(playbackTimeout);
-    }
-
-    function setOutput(output, events, error) {
-      stopPlayback();
-      output.empty();
-      lineClear();
-  
-      // Display errors.
-      if (error) {
-        lineHighlight(error);
-        output.addClass("error").text(error);
-        return;
-      }
-  
-      // Display image output.
-      if (events.length > 0 && events[0].Message.indexOf("IMAGE:") === 0) {
-        var out = "";
-        for (var i = 0; i < events.length; i++) {
-          out += events[i].Message;
-        }
-        var url = "data:image/png;base64," + out.substr(6);
-        $("<img/>").attr("src", url).appendTo(output);
-        return;
-      }
-  
-      // Play back events.
-      if (events !== null) {
-        playback(output, events);
-      }
-    }
-
-    var seq = 0;
-    function runFunc(body, output) {
-      output = $(output);
-      seq++;
-      var cur = seq;
-      var data = {
-        "version": 2,
-        "body": body
-      };
-      $.ajax("/compile", {
-        data: data,
-        type: "POST",
-        dataType: "json",
-        success: function(data) {
-          if (seq != cur) {
-            return;
-          }
-          if (!data) {
-            return;
-          }
-          if (data.Errors) {
-            setOutput(output, null, data.Errors);
-            return;
-          }
-          setOutput(output, data.Events, false);
-        },
-        error: function() {
-          output.addClass("error").text(
-            "Error communicating with remote server."
-          );
-        }
-      });
-      return stopPlayback;
-    }
-
-    return runFunc;
-  }
-
   // opts is an object with these keys
   //  codeEl - code editor element
   //  outputEl - program output element
@@ -132,10 +234,11 @@
   //  shareRedirect - base URL to redirect to on share (optional)
   //  toysEl - toys select element (optional)
   //  enableHistory - enable using HTML5 history API (optional)
+  //  transport - playground transport to use (default is HTTPTransport)
   function playground(opts) {
     var code = $(opts.codeEl);
-    var runFunc = connectPlayground();
-    var stopFunc;
+    var transport = opts['transport'] || new HTTPTransport();
+    var running;
   
     // autoindent helpers.
     function insertTabs(n) {
@@ -227,18 +330,19 @@
     }
 
     function setError(error) {
-      if (stopFunc) stopFunc();
+      if (running) running.Kill();
       lineClear();
       lineHighlight(error);
       output.empty().addClass("error").text(error);
     }
     function loading() {
-      if (stopFunc) stopFunc();
+      lineClear();
+      if (running) running.Kill();
       output.removeClass("error").text('Waiting for remote server...');
     }
     function run() {
       loading();
-      stopFunc = runFunc(body(), output);
+      running = transport.Run(body(), highlightOutput(PlaygroundOutput(output[0])));
     }
     function fmt() {
       loading();
@@ -317,7 +421,5 @@
     }
   }
 
-  window.connectPlayground = connectPlayground;
   window.playground = playground;
-
 })();
diff --git a/present/js/socket.js b/present/js/socket.js
deleted file mode 100644
index 6e29c05..0000000
--- a/present/js/socket.js
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright 2012 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-(function() {
-  "use strict";
-
-  var websocket, outputs = {};
-
-  function onClose() {
-    console.log('websocket connection closed');
-  }
-
-  function sendMessage(m) {
-    websocket.send(JSON.stringify(m));
-  }
-
-  function onMessage(e) {
-    var m = JSON.parse(e.data);
-    var o = outputs[m.Id];
-    if (o === null) {
-      return;
-    }
-    if (m.Kind === "stdout" || m.Kind === "stderr") {
-      showMessage(o, m.Body, m.Kind);
-    }
-    if (m.Kind === "end") {
-      var s = "Program exited";
-      if (m.Body !== "") {
-        s += ": " + m.Body;
-      } else {
-        s += ".";
-      }
-      s += "\n";
-      showMessage(o, s, "system");
-    }
-  }
-
-  function showMessage(o, m, className) {
-    var span = document.createElement("span");
-    span.className = className;
-    if (m.indexOf("IMAGE:") === 0) {
-      var url = "data:image/png;base64," + m.substr(6);
-      var img = document.createElement("img");
-      img.src = url;
-      span.appendChild(img);
-    } else {
-      m = m.replace(/&/g, "&amp;");
-      m = m.replace(/</g, "&lt;");
-      span.innerHTML = m;
-    }
-    var needScroll = (o.scrollTop + o.offsetHeight) == o.scrollHeight;
-    o.appendChild(span);
-    if (needScroll)
-        o.scrollTop = o.scrollHeight - o.offsetHeight;
-  }
-
-  function run(body, output, options) {
-    var id = output.id;
-    outputs[id] = output;
-    options = options || {};
-    options.Race = !!options.Race; // force boolean
-    sendMessage({Id: id, Kind: "run", Body: body, Options: options});
-    return function() {
-      sendMessage({Id: id, Kind: "kill"});
-    };
-  }
-
-  window.connectPlayground = function(addr) {
-    websocket = new WebSocket(addr);
-    websocket.onmessage = onMessage;
-    websocket.onclose = onClose;
-    return run;
-  };
-})();
diff --git a/present/local.go b/present/local.go
index c800f78..5478ef0 100644
--- a/present/local.go
+++ b/present/local.go
@@ -40,7 +40,7 @@
 	}
 
 	if present.PlayEnabled {
-		playScript(basePath, "socket.js")
+		playScript(basePath, "SocketTransport")
 		http.Handle("/socket", socket.Handler)
 	}
 	http.Handle("/static/", http.FileServer(http.Dir(basePath)))
diff --git a/present/play.go b/present/play.go
index f7a8a3e..268919f 100644
--- a/present/play.go
+++ b/present/play.go
@@ -6,24 +6,29 @@
 
 import (
 	"bytes"
+	"fmt"
 	"io/ioutil"
 	"net/http"
 	"path/filepath"
 	"time"
 )
 
-// playScript registers an HTTP handler at /play.js that contains all the
-// scripts specified by path, relative to basePath.
-func playScript(root string, path ...string) {
+var scripts = []string{"jquery.js", "jquery-ui.js", "playground.js", "play.js"}
+
+// playScript registers an HTTP handler at /play.js that serves all the
+// scripts specified by the variable above, and appends a line that
+// initializes the playground with the specified transport.
+func playScript(root, transport string) {
 	modTime := time.Now()
 	var buf bytes.Buffer
-	for _, p := range append(path, "jquery.js", "jquery-ui.js", "play.js") {
+	for _, p := range scripts {
 		b, err := ioutil.ReadFile(filepath.Join(root, "js", p))
 		if err != nil {
 			panic(err)
 		}
 		buf.Write(b)
 	}
+	fmt.Fprintf(&buf, "\ninitPlayground(new %v());\n", transport)
 	b := buf.Bytes()
 	http.HandleFunc("/play.js", func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Content-type", "application/javascript")