]> git.donarmstrong.com Git - dactyl.git/commitdiff
Initial import of 1.0~b6
authorMichael Schutte <michi@uiae.at>
Wed, 20 Jul 2011 14:55:01 +0000 (16:55 +0200)
committerMichael Schutte <michi@uiae.at>
Wed, 20 Jul 2011 14:55:01 +0000 (16:55 +0200)
200 files changed:
.gitignore [new file with mode: 0644]
.hg_archival.txt [new file with mode: 0644]
.hgignore [new file with mode: 0644]
.hgtags [new file with mode: 0644]
BREAKING_CHANGES [new file with mode: 0644]
HACKING [new file with mode: 0644]
LICENSE.txt [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.E4X [new file with mode: 0644]
common/Makefile [new file with mode: 0644]
common/bootstrap.js [new file with mode: 0755]
common/chrome.manifest [new file with mode: 0644]
common/components/commandline-handler.js [new file with mode: 0644]
common/components/protocols.js [new file with mode: 0644]
common/content/abbreviations.js [new file with mode: 0644]
common/content/about.xul [new file with mode: 0644]
common/content/autocommands.js [new file with mode: 0644]
common/content/bindings.xml [new file with mode: 0644]
common/content/bookmarks.js [new file with mode: 0644]
common/content/browser.js [new file with mode: 0644]
common/content/buffer.js [new file with mode: 0644]
common/content/buffer.xhtml [new file with mode: 0644]
common/content/commandline.js [new file with mode: 0644]
common/content/dactyl.js [new file with mode: 0644]
common/content/disable-acr.jsm [new file with mode: 0644]
common/content/editor.js [new file with mode: 0644]
common/content/eval.js [new file with mode: 0644]
common/content/events.js [new file with mode: 0644]
common/content/help.css [new file with mode: 0644]
common/content/help.js [new file with mode: 0644]
common/content/help.xsl [new file with mode: 0644]
common/content/hints.js [new file with mode: 0644]
common/content/history.js [new file with mode: 0644]
common/content/mappings.js [new file with mode: 0644]
common/content/marks.js [new file with mode: 0644]
common/content/modes.js [new file with mode: 0644]
common/content/mow.js [new file with mode: 0644]
common/content/preferences.xul [new file with mode: 0644]
common/content/quickmarks.js [new file with mode: 0644]
common/content/statusline.js [new file with mode: 0644]
common/content/tabs.js [new file with mode: 0644]
common/contrib/fix_symlinks.py [new file with mode: 0644]
common/javascript.vim [new file with mode: 0644]
common/locale/en-US/all.xml [new file with mode: 0644]
common/locale/en-US/autocommands.xml [new file with mode: 0644]
common/locale/en-US/browsing.xml [new file with mode: 0644]
common/locale/en-US/buffer.xml [new file with mode: 0644]
common/locale/en-US/cmdline.xml [new file with mode: 0644]
common/locale/en-US/developer.xml [new file with mode: 0644]
common/locale/en-US/editing.xml [new file with mode: 0644]
common/locale/en-US/eval.xml [new file with mode: 0644]
common/locale/en-US/faq.xml [new file with mode: 0644]
common/locale/en-US/gui.xml [new file with mode: 0644]
common/locale/en-US/hints.xml [new file with mode: 0644]
common/locale/en-US/index.xml [new file with mode: 0644]
common/locale/en-US/intro.xml [new file with mode: 0644]
common/locale/en-US/map.xml [new file with mode: 0644]
common/locale/en-US/marks.xml [new file with mode: 0644]
common/locale/en-US/message.xml [new file with mode: 0644]
common/locale/en-US/messages.properties [new file with mode: 0644]
common/locale/en-US/options.xml [new file with mode: 0644]
common/locale/en-US/pattern.xml [new file with mode: 0644]
common/locale/en-US/print.xml [new file with mode: 0644]
common/locale/en-US/privacy.xml [new file with mode: 0644]
common/locale/en-US/repeat.xml [new file with mode: 0644]
common/locale/en-US/starting.xml [new file with mode: 0644]
common/locale/en-US/styling.xml [new file with mode: 0644]
common/locale/en-US/tabs.xml [new file with mode: 0644]
common/locale/en-US/various.xml [new file with mode: 0644]
common/make_jar.sh [new file with mode: 0644]
common/modules/addons.jsm [new file with mode: 0644]
common/modules/base.jsm [new file with mode: 0644]
common/modules/bookmarkcache.jsm [new file with mode: 0644]
common/modules/bootstrap.jsm [new file with mode: 0644]
common/modules/commands.jsm [new file with mode: 0644]
common/modules/completion.jsm [new file with mode: 0644]
common/modules/config.jsm [new file with mode: 0644]
common/modules/contexts.jsm [new file with mode: 0644]
common/modules/downloads.jsm [new file with mode: 0644]
common/modules/finder.jsm [new file with mode: 0644]
common/modules/highlight.jsm [new file with mode: 0644]
common/modules/io.jsm [new file with mode: 0644]
common/modules/javascript.jsm [new file with mode: 0644]
common/modules/messages.jsm [new file with mode: 0644]
common/modules/options.jsm [new file with mode: 0644]
common/modules/overlay.jsm [new file with mode: 0644]
common/modules/prefs.jsm [new file with mode: 0644]
common/modules/sanitizer.jsm [new file with mode: 0644]
common/modules/services.jsm [new file with mode: 0644]
common/modules/storage.jsm [new file with mode: 0644]
common/modules/styles.jsm [new file with mode: 0644]
common/modules/template.jsm [new file with mode: 0644]
common/modules/util.jsm [new file with mode: 0644]
common/process_manifest.awk [new file with mode: 0644]
common/skin/dactyl.css [new file with mode: 0644]
common/tests/functional/dactyl.jsm [new file with mode: 0644]
common/tests/functional/data/find.html [new file with mode: 0644]
common/tests/functional/shared-modules/addons.js [new file with mode: 0644]
common/tests/functional/shared-modules/dom-utils.js [new file with mode: 0644]
common/tests/functional/shared-modules/downloads.js [new file with mode: 0644]
common/tests/functional/shared-modules/localization.js [new file with mode: 0644]
common/tests/functional/shared-modules/modal-dialog.js [new file with mode: 0644]
common/tests/functional/shared-modules/performance.js [new file with mode: 0644]
common/tests/functional/shared-modules/places.js [new file with mode: 0644]
common/tests/functional/shared-modules/prefs.js [new file with mode: 0644]
common/tests/functional/shared-modules/private-browsing.js [new file with mode: 0644]
common/tests/functional/shared-modules/readme.txt [new file with mode: 0644]
common/tests/functional/shared-modules/screenshot.js [new file with mode: 0644]
common/tests/functional/shared-modules/search.js [new file with mode: 0644]
common/tests/functional/shared-modules/sessionstore.js [new file with mode: 0644]
common/tests/functional/shared-modules/software-update.js [new file with mode: 0644]
common/tests/functional/shared-modules/tabs.js [new file with mode: 0644]
common/tests/functional/shared-modules/tabview.js [new file with mode: 0644]
common/tests/functional/shared-modules/toolbars.js [new file with mode: 0644]
common/tests/functional/shared-modules/utils.js [new file with mode: 0644]
common/tests/functional/shared-modules/widgets.js [new file with mode: 0644]
common/tests/functional/testAboutPage.js [new file with mode: 0644]
common/tests/functional/testCommands.js [new file with mode: 0644]
common/tests/functional/testEchoCommands.js [new file with mode: 0644]
common/tests/functional/testFindCommands.js [new file with mode: 0644]
common/tests/functional/testHelpCommands.js [new file with mode: 0644]
common/tests/functional/testOptions.js [new file with mode: 0644]
common/tests/functional/testShellCommands.js [new file with mode: 0644]
common/tests/functional/testVersionCommand.js [new file with mode: 0644]
common/tests/functional/utils.js [new file with mode: 0644]
common/tests/functional/utils.jsm [new file with mode: 0644]
melodactyl/AUTHORS [new file with mode: 0644]
melodactyl/Makefile [new file with mode: 0644]
melodactyl/NEWS [new file with mode: 0755]
melodactyl/TODO [new file with mode: 0644]
melodactyl/chrome.manifest [new symlink]
melodactyl/components/commandline-handler.js [new symlink]
melodactyl/components/protocols.js [new symlink]
melodactyl/content/config.js [new file with mode: 0644]
melodactyl/content/library.js [new file with mode: 0644]
melodactyl/content/logo.png [new file with mode: 0644]
melodactyl/content/player.js [new file with mode: 0644]
melodactyl/contrib/vim/Makefile [new file with mode: 0644]
melodactyl/contrib/vim/ftdetect/melodactyl.vim [new file with mode: 0644]
melodactyl/contrib/vim/mkvimball.txt [new file with mode: 0644]
melodactyl/defaults/preferences/dactyl.js [new file with mode: 0644]
melodactyl/install.rdf [new file with mode: 0644]
melodactyl/locale/en-US/all.xml [new file with mode: 0644]
melodactyl/locale/en-US/autocommands.xml [new file with mode: 0644]
melodactyl/locale/en-US/browsing.xml [new file with mode: 0644]
melodactyl/locale/en-US/gui.xml [new file with mode: 0644]
melodactyl/locale/en-US/intro.xml [new file with mode: 0644]
melodactyl/locale/en-US/map.xml [new file with mode: 0644]
melodactyl/locale/en-US/player.xml [new file with mode: 0644]
melodactyl/locale/en-US/tabs.xml [new file with mode: 0644]
melodactyl/skin/icon.png [new file with mode: 0644]
pentadactyl/AUTHORS [new file with mode: 0644]
pentadactyl/Donors [new file with mode: 0644]
pentadactyl/Makefile [new file with mode: 0644]
pentadactyl/NEWS [new file with mode: 0644]
pentadactyl/TODO [new file with mode: 0644]
pentadactyl/bootstrap.js [new symlink]
pentadactyl/chrome.manifest [new symlink]
pentadactyl/components/commandline-handler.js [new symlink]
pentadactyl/components/protocols.js [new symlink]
pentadactyl/content/config.js [new file with mode: 0644]
pentadactyl/content/logo.png [new file with mode: 0644]
pentadactyl/contrib/vim/Makefile [new file with mode: 0644]
pentadactyl/contrib/vim/ftdetect/pentadactyl.vim [new file with mode: 0644]
pentadactyl/contrib/vim/mkvimball.txt [new file with mode: 0644]
pentadactyl/install.rdf [new file with mode: 0644]
pentadactyl/locale/en-US/all.xml [new file with mode: 0644]
pentadactyl/locale/en-US/autocommands.xml [new file with mode: 0644]
pentadactyl/locale/en-US/gui.xml [new file with mode: 0644]
pentadactyl/locale/en-US/intro.xml [new file with mode: 0644]
pentadactyl/locale/en-US/map.xml [new file with mode: 0644]
pentadactyl/locale/en-US/tutorial.xml [new file with mode: 0644]
pentadactyl/skin/about.css [new file with mode: 0644]
pentadactyl/skin/icon.png [new file with mode: 0644]
teledactyl/AUTHORS [new file with mode: 0644]
teledactyl/Makefile [new file with mode: 0644]
teledactyl/NEWS [new file with mode: 0644]
teledactyl/TODO [new file with mode: 0644]
teledactyl/chrome.manifest [new symlink]
teledactyl/components/commandline-handler.js [new symlink]
teledactyl/components/protocols.js [new symlink]
teledactyl/content/addressbook.js [new file with mode: 0644]
teledactyl/content/compose/compose.js [new file with mode: 0644]
teledactyl/content/compose/compose.xul [new file with mode: 0644]
teledactyl/content/compose/dactyl.xul [new file with mode: 0644]
teledactyl/content/config.js [new file with mode: 0644]
teledactyl/content/logo.png [new file with mode: 0644]
teledactyl/content/mail.js [new file with mode: 0644]
teledactyl/contrib/vim/Makefile [new file with mode: 0644]
teledactyl/contrib/vim/ftdetect/teledactyl.vim [new file with mode: 0644]
teledactyl/contrib/vim/mkvimball.txt [new file with mode: 0644]
teledactyl/defaults/preferences/dactyl.js [new file with mode: 0644]
teledactyl/install.rdf [new file with mode: 0644]
teledactyl/locale/en-US/Makefile [new file with mode: 0644]
teledactyl/locale/en-US/all.xml [new file with mode: 0644]
teledactyl/locale/en-US/autocommands.xml [new file with mode: 0644]
teledactyl/locale/en-US/gui.xml [new file with mode: 0644]
teledactyl/locale/en-US/intro.xml [new file with mode: 0644]
teledactyl/locale/en-US/map.xml [new file with mode: 0644]
teledactyl/skin/icon.png [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..50e5bc7
--- /dev/null
@@ -0,0 +1,32 @@
+## To see if new rules exclude any existing files, run
+##
+##   git ls-files -i --exclude-standard
+##
+## after modifying this file.
+
+## Generated by the build process
+*.xpi
+*/locale/*/*.html
+*/chrome
+*/contrib/vim/*.vba
+
+## Editor backup and swap files
+*~
+.\#*
+\#**\#
+.*.sw[op]
+.sw[op]
+
+## Generated by Mac filesystem
+.DS_Store
+
+## For rejects
+*.orig
+*.rej
+*.ancestor
+*.current
+*.patched
+
+## Generated by StGit
+patches-*
+.stgit-*.txt
diff --git a/.hg_archival.txt b/.hg_archival.txt
new file mode 100644 (file)
index 0000000..a19df6b
--- /dev/null
@@ -0,0 +1,4 @@
+repo: 373f1649c80dea9be7b5bc9c57e8395f94f93ab1
+node: b83bb8e6d273f71b1278c4ee03376594ad6dd039
+branch: pentadactyl-1.0b6
+tag: pentadactyl-1.0b6
diff --git a/.hgignore b/.hgignore
new file mode 100644 (file)
index 0000000..9cb94cb
--- /dev/null
+++ b/.hgignore
@@ -0,0 +1,34 @@
+## To see if new rules exclude any existing files, run
+##
+##   hg status -i
+##
+## after modifying this file.
+
+syntax: glob
+
+## Generated by the build process
+*.xpi
+*/locale/*/*.html
+*/chrome
+*/contrib/vim/*.vba
+*/bak/*
+downloads/*
+.git/*
+
+*.py[co]
+
+## Editor backup and swap files
+*~
+.#*
+\#**\#
+.*.sw[op]
+.sw[op]
+
+## Generated by Mac filesystem
+.DS_Store
+
+syntax: regexp
+
+## For rejects
+\.(orig|rej|bak|diff)$
+
diff --git a/.hgtags b/.hgtags
new file mode 100644 (file)
index 0000000..e33bca1
--- /dev/null
+++ b/.hgtags
@@ -0,0 +1,24 @@
+c5d6d5cd0dd426585f8e5afa314183208a610e59 muttator-0.5
+fe9ffcfb48c7050f5e6872285ff0197eb54cf213 vimperator-0.4.1
+d063e0e23d668dcf807e2032f032a3d23486ec48 vimperator-0.5
+ce28bfd79dcc119abf59e9318d524144f45d53f5 vimperator-0.5-branch-HEAD-merge-1
+019506ce1fa1a647f27fb4aed293b6e0fe629d31 vimperator-0.5.1
+605db9f5c6cb5a1d65c95211f78d0a480a1566fd vimperator-0.5.2
+3b8c0bcbbf8663fde9f959efbc1ad5fda3d9d1f6 vimperator-0.5.3
+c9273973fc735678c3930bc15fb4915414907d23 vimperator-1.0
+96b65677b4c5a29525fd791aaadbc51a4ba7b7c2 vimperator-1.1
+ff456886599eb7ad2cdd718eac498b6c8a9efc75 vimperator-1.2
+e34e4c4a9c24488f570d08200182542134e05f29 vimperator-2.0
+e34e4c4a9c24488f570d08200182542134e05f29 vimperator-2.0
+f65383ca4fc4872827f3f23350ae503c93cd66a4 vimperator-2.0a1
+75873caffbe7327bfd1c27e615378d2a196a9286 vimperator-2.1
+7777c3e4d29893701a64f46adbd60b3b06cda7a6 vimperator-2.2b1
+d4c91b705d551c63c5a0d929248ec873e978a188 vimperator-2.2
+ca14d185b1506c77b748925770731b66d3dddfbe xulmus-0.1
+a82034b6ed8fe0c5bc43123969b655423f95511d pentadactyl-1.0b1
+15d6abbda4221b30181f8459a3627e00b42f93d6 pentadactyl-1.0b2
+ffa3aaf7882250fb665d8ead4375f529f3e99070 pentadactyl-1.0b3
+2c21fc6135f832c7bbadf43586d2ffe585f02f60 pentadactyl-1.0b4
+1f1342f58d8e7d3972928f193e3f40cbf4425230 pentadactyl-1.0b4.1
+962d8a1e823d0855e14aecaa294ef02e718401b1 pentadactyl-1.0b4.2
+d783bcace8c6a24bee7bd22ead22fe44baaebe90 pentadactyl-1.0b4.3
diff --git a/BREAKING_CHANGES b/BREAKING_CHANGES
new file mode 100644 (file)
index 0000000..8b13789
--- /dev/null
@@ -0,0 +1 @@
+
diff --git a/HACKING b/HACKING
new file mode 100644 (file)
index 0000000..6b4ca69
--- /dev/null
+++ b/HACKING
@@ -0,0 +1,152 @@
+= Hacking =
+
+If you've taken to hacking Pentadactyl source code, we hope that you'll share
+your changes. In case you do, please keep the following in mind, and we'll be
+happy to accept your patches.
+
+== Documentation ==
+
+First of all, all new features and all user-visible changes to existing
+features need to be documented. That means editing the appropriate help files
+and adding a NEWS entry where appropriate. When editing the NEWS file, you
+should add your change to the top of the list of changes. If your change
+alters an interface (key binding, command) and is likely to cause trouble,
+prefix it with 'IMPORTANT:', otherwise, place it below the other 'IMPORTANT'
+entries. If you're not sure if your change merits a news entry, or if it's
+important, please ask.
+
+== Coding Style ==
+
+In general: Just look at the existing source code!
+
+=== The most important style issues are: ===
+
+* Use 4 spaces to indent things, no tabs, not 2, nor 8 spaces. If you use Vim,
+  this should be taken care of automatically by the modeline (like the
+  one below).
+
+* No trailing whitespace.
+
+* Use " for enclosing strings instead of ', unless using ' avoids escaping of lots of "
+  Example: alert("foo") instead of alert('foo');
+
+* Use // regexp literals rather than RegExp constructors, unless
+  you're constructing an expression on the fly, or RegExp
+  constructors allow you to escape less /s than the additional
+  escaping of special characters required by string quoting.
+
+  Good: /application\/xhtml\+xml/
+  Bad:  RegExp("application/xhtml\\+xml")
+  Good: RegExp("http://(www\\.)vimperator.org/(.*)/(.*)")
+  Bad:  /http:\/\/(www\.)vimperator.org\/(.*)\/(.*)/
+
+* Exactly one space after if/for/while/catch etc. and after a comma, but none
+  after a parenthesis or after a function call:
+      for (pre; condition; post)
+  but:
+      alert("foo");
+
+* Bracing is formatted as follows:
+    function myFunction () {
+        if (foo)
+            return bar;
+        else {
+            baz = false;
+            return baz;
+        }
+    }
+    var quux = frob("you",
+        {
+            a: 1,
+            b: 42,
+            c: {
+                hoopy: "frood"
+            }
+        });
+
+  When in doubt, look for similar code.
+
+* No braces for one-line conditional statements:
+  Right:
+     if (foo)
+         frob();
+     else
+         unfrob();
+
+* Prefer lambda-style functions where suitable:
+  Right: list.filter(function (elem) elem.good != elem.BAD);
+  Wrong: list.filter(function (elem) { return elem.good != elem.BAD });
+  Right: list.forEach(function (elem) { window.alert(elem); });
+  Wrong: list.forEach(function (elem) window.alert(elem));
+
+* Anonymous function definitions should be formatted with a space after the
+  keyword "function". Example: function () {}, not function() {}.
+
+* Prefer the use of let over var i.e. only use var when required.
+  For more details, see
+  https://developer.mozilla.org/en/New_in_JavaScript_1.7#Block_scope_with_let
+
+* Reuse common local variable names E.g. "elem" is generally used for element,
+  "win" for windows, "func" for functions,  "ret" for return values etc.
+
+* Prefer // over /* */ comments (exceptions for big comments are usually OK)
+  Right: if (HACK) // TODO: remove hack
+  Wrong: if (HACK) /* TODO: remove hack */
+
+* Documentation comment blocks use /** ... */ Wrap these lines at 80
+  characters.
+
+* Only wrap lines if it makes the code obviously clearer. Lines longer than 132
+  characters should probably be broken up rather than wrapped anyway.
+
+* Use UNIX new lines (\n), not windows (\r\n) or old Mac ones (\r)
+
+* Use Iterators or Array#forEach to iterate over arrays. for (let i
+  in ary) and for each (let i in ary) include members in an
+  Array.prototype, which some extensions alter.
+  Right:
+    for (let [,elem] in Iterator(ary))
+    for (let [k, v] in Iterator(obj))
+    ary.forEach(function (elem) { ...
+  Wrong:
+    for each (let elem in ary)
+
+  The exceptions to this rule are for objects with __iterator__ set,
+  and for XML objects (see README.E4X).
+
+* Avoid using 'new' with constructors where possible, and use [] and
+  {} rather than new Array/new Object.
+  Right:
+    RegExp("^" + foo + "$")
+    Function(code)
+    new Date
+  Wrong:
+    new RegExp("^" + foo + "$")
+    new Function(code)
+    Date() // Right if you want a string-representation of the date
+
+* Don't use abbreviations for public methods
+  Right:
+    function splitString()...
+    let commands = ...;
+    let cmds = ...; // Since it's only used locally, abbreviations are ok, but so are the full names
+  Wrong:
+    function splitStr()
+
+== Testing ==
+
+Functional tests are implemented using the Mozmill automated testing framework
+-- https://developer.mozilla.org/en/Mozmill_Tests.
+
+A fresh profile is created for the duration of the test run, however, passing
+arguments to the host application won't be supported until Mozmill 1.5.2, the
+next release, so any user RC and plugin files should be temporarily disabled.
+This can be done by adding the following to the head of the RC file:
+set loadplugins=
+finish
+
+The host application binary tested can be overridden via the HOSTAPP_PATH
+makefile variable. E.g.,
+$ HOSTAPP_PATH=/path/to/firefox make -e -C pentadactyl test
+
+// vim: fdm=marker sw=4 ts=4 et ai:
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644 (file)
index 0000000..d8fcf81
--- /dev/null
@@ -0,0 +1,23 @@
+Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+              2007-2009 by Doug Kearns <dougkearns@gmail.com>
+              2008-2010 by Kris Maglione <maglione.k at Gmail>
+
+For a full list of authors, refer the AUTHORS file.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..637f33c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+DIRS = teledactyl pentadactyl melodactyl
+TARGETS = clean distclean doc help info jar release xpi
+.SILENT:
+
+all: xpi ;
+
+$(TARGETS:%=\%.%):
+       echo MAKE $@
+       $(MAKE) -C $* $(@:$*.%=%)
+
+$(TARGETS):
+       $(MAKE) $(DIRS:%=%.$@)
+
diff --git a/README.E4X b/README.E4X
new file mode 100644 (file)
index 0000000..5e0b8a9
--- /dev/null
@@ -0,0 +1,149 @@
+               A terse introduction to E4X
+                      Public Domain
+
+The inline XML literals in this code are part of E4X, a standard
+XML processing interface for ECMAScript. In addition to syntax
+for XML literals, E4X provides a new kind of native object,
+"xml", and a syntax, similar to XPath, for accessing and
+modifying the tree. Here is a brief synopsis of the kind of
+usage you'll see herein:
+
+> let xml =
+        <foo bar="baz" baz="qux">
+            <bar>
+                <baz id="1"/>
+            </bar>
+            <baz id="2"/>
+        </foo>;
+
+ // Select all bar elements of the root foo element
+> xml.bar
+ <bar><baz id="1"/></bar>
+
+ // Select all baz elements anywhere beneath the root
+> xml..baz
+ <baz id="1"/>
+ <baz id="2"/>
+
+ // Select all of the immediate children of the root
+> xml.*
+ <bar><baz id="1"/></bar>
+ <baz id="2"/>
+
+ // Select the bar attribute of the root node
+> xml.@bar
+ baz
+
+ // Select all id attributes in the tree
+> xml..@id
+ 1
+ 2
+
+ // Select all attributes of the root node
+> xml.@*
+ baz
+ quz
+
+// Add a quux elemend beneath the first baz
+> xml..baz[0] += <quux/>
+  <baz id="1"/>
+  <quux/>
+> xml
+  <foo bar="baz" baz="qux">
+      <bar>
+          <baz id="1"/>
+          <quux/>
+      </bar>
+      <baz id="2"/>
+  </foo>
+
+  // and beneath the second
+> xml.baz[1] = <quux id="1"/>
+> xml
+  <foo bar="baz" baz="qux">
+      <bar>
+          <baz id="1"/>
+          <quux/>
+      </bar>
+      <baz id="2"/>
+      <quux id="1"/>
+  </foo>
+
+  // Replace bar's subtree with a foo element
+> xml.bar.* = <foo id="1"/>
+> xml
+  <foo bar="baz" baz="qux">
+      <bar>
+          <foo id="1"/>
+      </bar>
+      <baz id="2"/>
+      <quux id="1"/>
+  </foo>
+
+  // Add a bar below bar
+> xml.bar.* += <bar id="1"/>
+  <foo id="1"/>
+  <bar id="1"/>
+> xml
+  <foo bar="baz" baz="qux">
+      <bar>
+          <foo id="1"/>
+          <bar id="1"/>
+      </bar>
+      <baz id="2"/>
+      <quux id="1"/>
+  </foo>
+
+  // Adding a quux attribute to the root
+> xml.@quux = "foo"
+  foo
+> xml
+  <foo bar="baz" baz="qux" quux="foo">
+      <bar>
+          <foo id="1"/>
+          <bar id="1"/>
+      </bar>
+      <baz id="2"/>
+      <quux id="1"/>
+  </foo>
+
+> xml.bar.@id = "0"
+> xml..foo[0] = "Foo"
+  Foo
+> xml..bar[1] = "Bar"
+  Bar
+> xml
+js> xml
+<foo bar="baz" baz="qux" quux="foo" id="0">
+    <bar id="0">
+        <foo id="1">Foo</foo>
+        <bar id="1">Bar</bar>
+    </bar>
+    <baz id="2"/>
+    <quux id="1"/>
+</foo>
+
+  // Selecting all bar elements where id="1"
+> xml..bar.(@id == 1)
+  Bar
+
+  // Literals:
+  // XMLList literal. No root node.
+> <>Foo<br/>Baz</>
+  Foo
+  <br/>
+  Baz
+
+// Interpolation.
+> let x = "<foo/>"
+> <foo bar={x}>{x + "<?>"}</foo>
+  <foo/><?>
+> <foo bar={x}>{x + "<?>"}</foo>.toXMLString()
+  <foo bar="&lt;foo/>">&lt;foo/&gt;&lt;?&gt;</foo>
+
+> let x = <foo/>
+> <foo bar={x}>{x}</foo>.toXMLString()
+  <foo bar="">
+      <foo/>
+  </foo>
+
diff --git a/common/Makefile b/common/Makefile
new file mode 100644 (file)
index 0000000..955d358
--- /dev/null
@@ -0,0 +1,203 @@
+#### configuration
+
+TOP           = $(shell pwd)
+OS            = $(shell uname -s)
+BUILD_DATE    = $(shell date "+%Y/%m/%d %H:%M:%S")
+BASE          = $(TOP)/../common
+GOOGLE_PROJ   = dactyl
+GOOGLE       = https://$(GOOGLE_PROJ).googlecode.com/files
+VERSION             ?= $(shell sed -n 's/.*em:version\(>\|="\)\(.*\)["<].*/\2/p' $(TOP)/install.rdf | sed 1q)
+UUID                := $(shell sed -n 's/.*em:id\(>\|="\)\(.*\)["<].*/\2/p' $(TOP)/install.rdf | sed 1q)
+MANGLE      := $(shell date '+%s' | awk '{ printf "%x", $$1 }')
+MOZMILL       = mozmill
+HOSTAPP_PATH  = $(shell which $(HOSTAPP))
+TEST_DIR      = $(BASE)/tests/functional
+TEST_LOG      = $(TEST_DIR)/log
+
+IDNAME       := $(shell echo "$(NAME)" | tr a-z A-Z)
+
+LOCALEDIR     = locale
+DOC_FILES     = $(wildcard $(LOCALEDIR)/*/*.xml)
+
+export VERSION BUILD_DATE
+MAKE_JAR      = sh $(BASE)/make_jar.sh
+
+# TODO: specify source files manually?
+JAR_BASES     = $(TOP) $(BASE)
+JAR_DIRS      = content skin locale modules
+JAR_TEXTS     = js jsm css dtd xml xul html xhtml xsl properties
+JAR_BINS      = png
+
+CHROME       = $(MANGLE)/
+JAR           = $(CHROME)$(NAME).jar
+
+XPI_BASES     = $(JAR_BASES) $(TOP)/..
+XPI_FILES     = bootstrap.js TODO AUTHORS Donors NEWS LICENSE.txt
+XPI_DIRS      = components $(MANGLE) defaults
+XPI_TEXTS     = js jsm $(JAR_TEXTS)
+XPI_BINS      = $(JAR_BINS)
+
+XPI_NAME      = $(NAME)-$(VERSION)
+XPI           =  ../downloads/$(XPI_NAME).xpi
+XPI_PATH      = $(TOP)/$(XPI:%.xpi=%)
+
+RDF           = ../downloads/update.rdf
+RDF_IN        = $(RDF).in
+
+BUILD_DIR     = build.$(VERSION).$(OS)
+
+AWK       ?= awk
+B64ENCODE ?= base64
+CURL      ?= curl
+SED       := $(shell if [ "xoo" = x$$(echo foo | sed -E 's/f(o)/\1/' 2>/dev/null) ]; \
+                    then echo sed -E; else echo sed -r;                             \
+                    fi)
+
+.SILENT:
+
+#### rules
+
+TARGETS = all help info jar xpi install clean distclean install installxpi test $(CHROME) $(JAR)
+$(TARGETS:%=\%.%):
+       echo MAKE $* $(@:$*.%=%)
+       $(MAKE) -C $* $(@:$*.%=%)
+
+.PHONY: $(TARGETS)
+all: help
+
+help:
+       @echo "$(NAME) $(VERSION) build"
+       @echo
+       @echo "  make help       - display this help"
+       @echo "  make info       - show some info about the system"
+       @echo "  make jar        - build a JAR ($(JAR))"
+       @echo "  make xpi        - build an XPI ($(XPI_NAME))"
+       @echo "  make installxpi - build an XPI and install it to your profile"
+       @echo "  make install    - installs this source tree directly to your $(HOSTAPP) profile"
+       @echo '                    set $$PROFILE to select a profile by name and $$PROFILEPATHS'
+       @echo '                    to change the directory where profiles are searched'
+       @echo "  make release    - updates update.rdf (this is not for you)"
+       @echo "  make dist       - uploads to Google Code (this is not for you)"
+       @echo "  make clean      - clean up"
+       @echo "  make distclean  - clean up more"
+       @echo "  make test       - run functional tests"
+       @echo
+       @echo "running some commands with V=1 will show more build details"
+
+info:
+       @echo "version             $(VERSION)"
+       @echo "release file        $(XPI)"
+       @echo "doc files           $(DOC_FILES)"
+       @echo "xpi files           $(XPI_FILES)"
+
+jar: $(JAR)
+
+release: $(XPI) $(RDF)
+
+# This is not for you!
+dist: $(XPI)
+       @echo DIST $(XPI) $(GOOGLE)
+       set -e;                                                                 \
+                                                                               \
+       proj=$$(echo -n $(NAME) | sed 's/\(.\).*/\1/' | tr a-z A-Z);            \
+       proj="$$proj$$(echo $(NAME) | sed 's/.//')";                            \
+       [ -z "$$summary" ] && summary="$$proj $(VERSION) Release";              \
+       labels="Project-$$proj,$(labels)";                                      \
+       [ -n "$(featured)" ] && labels="$$labels,Featured";                     \
+                                                                               \
+       IFS=,; for l in $$labels; do                                            \
+               set -- "$$@" --form-string "label=$$l";                         \
+       done;                                                                   \
+       auth=$$(echo -n "$(GOOGLE_USER):$(GOOGLE_PASS)" | $(B64ENCODE));        \
+       $(CURL) "$$@" --form-string "summary=$$summary"                         \
+               -F "filename=@$(XPI)"                                           \
+               -H "Authorization: Basic $$auth"                                \
+               -i "$(GOOGLE)" | sed -n '/^Location/{p;q;}'
+
+install:
+       export dir;                                                             \
+       for dir in $(PROFILEPATHS); do                                          \
+               test -f "$$dir/profiles.ini" && break;                          \
+       done;                                                                   \
+                                                                               \
+       profile=$$(sed 's/^$$/\#/' "$$dir/profiles.ini" |                       \
+               awk -v"profile=$(PROFILE)"                                      \
+                       'BEGIN { RS="#" }                                       \
+                       index($$0, "\nName=" profile "\n") { print; exit }      \
+                       !profile && /\nName=default\n/ { args["name=default"] = $$0 }   \
+                       !profile && /\nDefault=1/ { args["default=1"] = $$0 }   \
+                       END {                                                   \
+                               if (args["default=1"]) print args["default=1"]; \
+                               else print args["name=default"]                 \
+                       }' |                                                    \
+               awk -F= '{ args[$$1] = $$2 }                                    \
+               END {                                                           \
+                       if (args["IsRelative"]) print ENVIRON["dir"] "/" args["Path"];  \
+                       else print args["Path"]                                 \
+               }');                                                            \
+                                                                               \
+       if ! test -d "$$profile"; then                                          \
+               echo >&2 "Can't locate profile directory";                      \
+               exit 1;                                                         \
+       fi;                                                                     \
+                                                                               \
+       ext="$$profile/extensions/$(UUID)";                                     \
+       mkdir -p "$$(dirname "$$ext")";                                         \
+       rm -rf "$$ext.xpi" "$$ext";                                             \
+       echo "Installing to $$ext";                                             \
+       if which cygpath >/dev/null 2>&1;                                       \
+       then cygpath -wa .;                                                     \
+       else pwd;                                                               \
+       fi >"$$ext"
+installxpi: xpi
+       $(HOSTAPP) $(XPI)
+
+$(RDF): $(RDF_IN) Makefile
+       @echo "Preparing release..."
+       $(SED) -e "s,@VERSION@,$(VERSION),g" \
+                  -e "s,@DATE@,$(BUILD_DATE),g" \
+                  < $< > $@
+       @echo "SUCCESS: $@"
+
+clean:
+       @echo "General $(NAME) cleanup..."
+       rm -f $(JAR) $(XPI)
+
+distclean:
+       @echo "More $(NAME) cleanup..."
+       rm -rf $(BUILD_DIR)
+
+test: xpi
+       @echo "Running $(NAME) functional tests..."
+       $(IDNAME)_INIT="set loadplugins=" \
+               $(MOZMILL) --show-all -l $(TEST_LOG) -b $(HOSTAPP_PATH) --addons $(XPI) -t $(TEST_DIR)
+
+#### xpi
+
+xpi: $(CHROME)
+       @echo "Building XPI..."
+       mkdir -p "$(XPI_PATH)"
+       
+       $(AWK) -v 'name=$(NAME)' -v 'suffix=$(MANGLE)' \
+              -f $(BASE)/process_manifest.awk \
+              "$(TOP)/chrome.manifest" >"$(XPI_PATH)/chrome.manifest"
+       
+       version="$(VERSION)";                   \
+       hg root >/dev/null 2>&1 &&              \
+       case "$$version" in                     \
+       *pre) version="$$version-hg$$(hg log -r . --template '{rev}')-$$(hg branch)";; \
+       esac;                                   \
+       $(SED) -e 's/(em:version(>|="))([^"<]+)/\1'"$$version/" \
+               <"$(TOP)/install.rdf" >"$(XPI_PATH)/install.rdf"
+       
+       $(MAKE_JAR) "$(XPI)" "$(XPI_BASES)" "$(XPI_DIRS)" "$(XPI_TEXTS)" "$(XPI_BINS)" "$(XPI_FILES)"
+       rm -r -- $(CHROME)
+       @echo "Built XPI: $(XPI)"
+
+#### jar
+
+$(CHROME) $(JAR):
+       @echo "Packaging chrome..."
+       $(MAKE_JAR) -r "$(@)" "$(JAR_BASES)" "$(JAR_DIRS)" "$(JAR_TEXTS)" "$(JAR_BINS)" "$(JAR_FILES)"
+       @echo "SUCCESS: $@"
+
diff --git a/common/bootstrap.js b/common/bootstrap.js
new file mode 100755 (executable)
index 0000000..b17cba4
--- /dev/null
@@ -0,0 +1,262 @@
+// Copyright (c) 2010-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+//
+// See https://wiki.mozilla.org/Extension_Manager:Bootstrapped_Extensions
+// for details.
+
+const NAME = "bootstrap";
+const global = this;
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+function module(uri) {
+    let obj = {};
+    Cu.import(uri, obj);
+    return obj;
+}
+
+const { AddonManager } = module("resource://gre/modules/AddonManager.jsm");
+const { XPCOMUtils }   = module("resource://gre/modules/XPCOMUtils.jsm");
+const { Services }     = module("resource://gre/modules/Services.jsm");
+
+const resourceProto = Services.io.getProtocolHandler("resource")
+                              .QueryInterface(Ci.nsIResProtocolHandler);
+const categoryManager = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
+const manager = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+
+const BOOTSTRAP_CONTRACT = "@dactyl.googlecode.com/base/bootstrap";
+JSMLoader = JSMLoader || BOOTSTRAP_CONTRACT in Cc && Cc[BOOTSTRAP_CONTRACT].getService().wrappedJSObject.loader;
+
+var JSMLoader = BOOTSTRAP_CONTRACT in Components.classes &&
+    Components.classes[BOOTSTRAP_CONTRACT].getService().wrappedJSObject.loader;
+
+// Temporary migration code.
+if (!JSMLoader && "@mozilla.org/fuel/application;1" in Components.classes)
+    JSMLoader = Components.classes["@mozilla.org/fuel/application;1"]
+                          .getService(Components.interfaces.extIApplication)
+                          .storage.get("dactyl.JSMLoader", null);
+
+
+function reportError(e) {
+    dump("\ndactyl: bootstrap: " + e + "\n" + (e.stack || Error().stack) + "\n");
+    Cu.reportError(e);
+}
+
+function httpGet(url) {
+    let xmlhttp = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
+    xmlhttp.overrideMimeType("text/plain");
+    xmlhttp.open("GET", url, false);
+    xmlhttp.send(null);
+    return xmlhttp;
+}
+
+let initialized = false;
+let addon = null;
+let addonData = null;
+let basePath = null;
+let categories = [];
+let components = {};
+let resources = [];
+let getURI = null;
+
+function updateVersion() {
+    try {
+        function isDev(ver) /^hg|pre$/.test(ver);
+        if (typeof require === "undefined" || addon === addonData)
+            return;
+
+        require(global, "config");
+        require(global, "prefs");
+        config.lastVersion = localPrefs.get("lastVersion", null);
+
+        localPrefs.set("lastVersion", addon.version);
+
+        if (!config.lastVersion || isDev(config.lastVersion) != isDev(addon.version))
+            addon.applyBackgroundUpdates = AddonManager[isDev(addon.version) ? "AUTOUPDATE_DISABLE" : "AUTOUPDATE_DEFAULT"];
+    }
+    catch (e) {
+        reportError(e);
+    }
+}
+
+function startup(data, reason) {
+    dump("dactyl: bootstrap: startup " + reasonToString(reason) + "\n");
+    basePath = data.installPath;
+
+    if (!initialized) {
+        initialized = true;
+
+        dump("dactyl: bootstrap: init" + " " + data.id + "\n");
+
+        addonData = data;
+        addon = data;
+        AddonManager.getAddonByID(addon.id, function (a) {
+            addon = a;
+            updateVersion();
+        });
+
+        if (basePath.isDirectory())
+            getURI = function getURI(path) {
+                let uri = Services.io.newFileURI(basePath);
+                uri.path += path;
+                return Services.io.newFileURI(uri.QueryInterface(Ci.nsIFileURL).file);
+            };
+        else
+            getURI = function getURI(path)
+                Services.io.newURI("jar:" + Services.io.newFileURI(basePath).spec + "!/" + path, null, null);
+
+        try {
+            init();
+        }
+        catch (e) {
+            reportError(e);
+        }
+    }
+}
+
+function FactoryProxy(url, classID) {
+    this.url = url;
+    this.classID = Components.ID(classID);
+}
+FactoryProxy.prototype = {
+    QueryInterface: XPCOMUtils.generateQI(Ci.nsIFactory),
+    register: function () {
+        dump("dactyl: bootstrap: register: " + this.classID + " " + this.contractID + "\n");
+
+        JSMLoader.registerFactory(this);
+    },
+    get module() {
+        dump("dactyl: bootstrap: create module: " + this.contractID + "\n");
+
+        Object.defineProperty(this, "module", { value: {}, enumerable: true });
+        JSMLoader.load(this.url, this.module);
+        return this.module;
+    },
+    createInstance: function (iids) {
+        return let (factory = this.module.NSGetFactory(this.classID))
+            factory.createInstance.apply(factory, arguments);
+    }
+}
+
+function init() {
+    dump("dactyl: bootstrap: init\n");
+
+    let manifestURI = getURI("chrome.manifest");
+    let manifest = httpGet(manifestURI.spec)
+            .responseText
+            .replace(/^\s*|\s*$|#.*/g, "")
+            .replace(/^\s*\n/gm, "");
+
+    let suffix = "-";
+    let chars = "0123456789abcdefghijklmnopqrstuv";
+    for (let n = Date.now(); n; n = Math.round(n / chars.length))
+        suffix += chars[n % chars.length];
+    suffix = "";
+
+    for each (let line in manifest.split("\n")) {
+        let fields = line.split(/\s+/);
+        switch(fields[0]) {
+        case "category":
+            categoryManager.addCategoryEntry(fields[1], fields[2], fields[3], false, true);
+            categories.push([fields[1], fields[2]]);
+            break;
+        case "component":
+            components[fields[1]] = new FactoryProxy(getURI(fields[2]).spec, fields[1]);
+            break;
+        case "contract":
+            components[fields[2]].contractID = fields[1];
+            break;
+
+        case "resource":
+            resources.push(fields[1], fields[1] + suffix);
+            resourceProto.setSubstitution(fields[1], getURI(fields[2]));
+            resourceProto.setSubstitution(fields[1] + suffix, getURI(fields[2]));
+        }
+    }
+
+    try {
+        module("resource://dactyl-content/disable-acr.jsm").init(addon.id);
+    }
+    catch (e) {
+        reportError(e);
+    }
+
+    if (JSMLoader && JSMLoader.bump !== 4) // Temporary hack
+        Services.scriptloader.loadSubScript("resource://dactyl" + suffix + "/bootstrap.jsm",
+            Cu.import("resource://dactyl/bootstrap.jsm", global));
+
+    if (!JSMLoader || JSMLoader.bump !== 4)
+        Cu.import("resource://dactyl/bootstrap.jsm", global);
+
+    JSMLoader.bootstrap = this;
+
+    JSMLoader.load("resource://dactyl/bootstrap.jsm", global);
+
+    JSMLoader.init(suffix);
+    JSMLoader.load("base.jsm", global);
+
+    if (!(BOOTSTRAP_CONTRACT in Cc))
+        manager.registerFactory(Components.ID("{f541c8b0-fe26-4621-a30b-e77d21721fb5}"),
+                                String("{f541c8b0-fe26-4621-a30b-e77d21721fb5}"),
+                                BOOTSTRAP_CONTRACT, {
+            QueryInterface: XPCOMUtils.generateQI([Ci.nsIFactory]),
+            instance: {
+                QueryInterface: XPCOMUtils.generateQI([]),
+                contractID: BOOTSTRAP_CONTRACT,
+                wrappedJSObject: {}
+            },
+            createInstance: function () this.instance
+        })
+
+    Cc[BOOTSTRAP_CONTRACT].getService().wrappedJSObject.loader = JSMLoader;
+
+    for each (let component in components)
+        component.register();
+
+    Services.obs.notifyObservers(null, "dactyl-rehash", null);
+    updateVersion();
+    require(global, "overlay");
+}
+
+function shutdown(data, reason) {
+    dump("dactyl: bootstrap: shutdown " + reasonToString(reason) + "\n");
+    if (reason != APP_SHUTDOWN) {
+        try {
+            module("resource://dactyl-content/disable-acr.jsm").cleanup();
+        }
+        catch (e) {
+            reportError(e);
+        }
+
+        if ([ADDON_UPGRADE, ADDON_DOWNGRADE, ADDON_UNINSTALL].indexOf(reason) >= 0)
+            Services.obs.notifyObservers(null, "dactyl-purge", null);
+
+        Services.obs.notifyObservers(null, "dactyl-cleanup", null);
+        Services.obs.notifyObservers(null, "dactyl-cleanup-modules", null);
+
+        JSMLoader.purge();
+        for each (let [category, entry] in categories)
+            categoryManager.deleteCategoryEntry(category, entry, false);
+        for each (let resource in resources)
+            resourceProto.setSubstitution(resource, null);
+    }
+}
+
+function reasonToString(reason) {
+    for each (let name in ["disable", "downgrade", "enable",
+                           "install", "shutdown", "startup",
+                           "uninstall", "upgrade"])
+        if (reason == global["ADDON_" + name.toUpperCase()] ||
+            reason == global["APP_" + name.toUpperCase()])
+            return name;
+}
+
+function install(data, reason) { dump("dactyl: bootstrap: install " + reasonToString(reason) + "\n"); }
+function uninstall(data, reason) { dump("dactyl: bootstrap: uninstall " + reasonToString(reason) + "\n"); }
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/chrome.manifest b/common/chrome.manifest
new file mode 100644 (file)
index 0000000..4995b0e
--- /dev/null
@@ -0,0 +1,22 @@
+resource dactyl-local-content content/
+resource dactyl-local-skin    skin/
+resource dactyl-local-locale  locale/
+
+resource dactyl             ../common/modules/
+resource dactyl-content     ../common/content/
+resource dactyl-skin        ../common/skin/
+resource dactyl-locale      ../common/locale/
+
+content  dactyl  ../common/content/
+
+component {16dc34f7-6d22-4aa4-a67f-2921fb5dcb69} components/commandline-handler.js
+contract  @mozilla.org/commandlinehandler/general-startup;1?type=dactyl {16dc34f7-6d22-4aa4-a67f-2921fb5dcb69}
+category  command-line-handler m-dactyl @mozilla.org/commandlinehandler/general-startup;1?type=dactyl
+
+component {c1b67a07-18f7-4e13-b361-2edcc35a5a0d}           components/protocols.js
+contract  @mozilla.org/network/protocol;1?name=chrome-data {c1b67a07-18f7-4e13-b361-2edcc35a5a0d}
+component {9c8f2530-51c8-4d41-b356-319e0b155c44}           components/protocols.js
+contract  @mozilla.org/network/protocol;1?name=dactyl      {9c8f2530-51c8-4d41-b356-319e0b155c44}
+component {f4506a17-5b4d-4cd9-92d4-2eb4630dc388}           components/protocols.js
+contract  @dactyl.googlecode.com/base/xpc-interface-shim   {f4506a17-5b4d-4cd9-92d4-2eb4630dc388}
+
diff --git a/common/components/commandline-handler.js b/common/components/commandline-handler.js
new file mode 100644 (file)
index 0000000..918d7bf
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (c) 2009 by Doug Kearns
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+function reportError(e) {
+    dump("dactyl: command-line-handler: " + e + "\n" + (e.stack || Error().stack));
+    Cu.reportError(e);
+}
+
+var global = this;
+var NAME = "command-line-handler";
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+function CommandLineHandler() {
+    this.wrappedJSObject = this;
+
+    Cu.import("resource://dactyl/base.jsm");
+    require(global, "util");
+    require(global, "config");
+}
+CommandLineHandler.prototype = {
+
+    classDescription: "Dactyl Command-line Handler",
+
+    classID: Components.ID("{16dc34f7-6d22-4aa4-a67f-2921fb5dcb69}"),
+
+    contractID: "@mozilla.org/commandlinehandler/general-startup;1?type=dactyl",
+
+    _xpcom_categories: [{
+        category: "command-line-handler",
+        entry: "m-dactyl"
+    }],
+
+    QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsICommandLineHandler]),
+
+    handle: function (commandLine) {
+
+        // TODO: handle remote launches differently?
+        try {
+            this.optionValue = commandLine.handleFlagWithParam(config.name, false);
+        }
+        catch (e) {
+            util.dump("option '-" + config.name + "' requires an argument\n");
+        }
+    },
+
+    get helpInfo() "  -" + config.name + " <opts>" + "             Additional options for " + config.appName + " startup\n".substr(config.name.length)
+};
+
+if (XPCOMUtils.generateNSGetFactory)
+    var NSGetFactory = XPCOMUtils.generateNSGetFactory([CommandLineHandler]);
+else
+    var NSGetModule = XPCOMUtils.generateNSGetModule([CommandLineHandler]);
+var EXPORTED_SYMBOLS = ["NSGetFactory", "global"];
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/components/protocols.js b/common/components/protocols.js
new file mode 100644 (file)
index 0000000..d14b3e4
--- /dev/null
@@ -0,0 +1,385 @@
+// Copyright (c) 2008-2010 Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+function reportError(e) {
+    dump("dactyl: protocols: " + e + "\n" + (e.stack || Error().stack));
+    Cu.reportError(e);
+}
+
+/* Adds support for data: URIs with chrome privileges
+ * and fragment identifiers.
+ *
+ * "chrome-data:" <content-type> [; <flag>]* "," [<data>]
+ *
+ * By Kris Maglione, ideas from Ed Anuff's nsChromeExtensionHandler.
+ */
+
+var NAME = "protocols";
+var global = this;
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+var ioService = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService);
+var systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].getService(Ci.nsIPrincipal);
+
+var DNE = "resource://dactyl/content/does/not/exist";
+var _DNE;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+function makeChannel(url, orig) {
+    try {
+        if (url == null)
+            return fakeChannel(orig);
+
+        if (typeof url === "function")
+            return let ([type, data] = url(orig)) StringChannel(data, type, orig);
+
+        if (isArray(url))
+            return let ([type, data] = url) StringChannel(data, type, orig);
+
+        let uri = ioService.newURI(url, null, null);
+        return (new XMLChannel(uri)).channel;
+    }
+    catch (e) {
+        util.reportError(e);
+        throw e;
+    }
+}
+function fakeChannel(orig) {
+    let channel = ioService.newChannel(DNE, null, null);
+    channel.originalURI = orig;
+    return channel;
+}
+function redirect(to, orig, time) {
+    let html = <html><head><meta http-equiv="Refresh" content={(time || 0) + ";" + to}/></head></html>.toXMLString();
+    return StringChannel(html, "text/html", ioService.newURI(to, null, null));
+}
+
+function Factory(clas) ({
+    __proto__: clas.prototype,
+    createInstance: function (outer, iid) {
+        try {
+            if (outer != null)
+                throw Components.results.NS_ERROR_NO_AGGREGATION;
+            if (!clas.instance)
+                clas.instance = new clas();
+            return clas.instance.QueryInterface(iid);
+        }
+        catch (e) {
+            reportError(e);
+            throw e;
+        }
+    }
+});
+
+function ChromeData() {}
+ChromeData.prototype = {
+    contractID:       "@mozilla.org/network/protocol;1?name=chrome-data",
+    classID:          Components.ID("{c1b67a07-18f7-4e13-b361-2edcc35a5a0d}"),
+    classDescription: "Data URIs with chrome privileges",
+    QueryInterface:   XPCOMUtils.generateQI([Ci.nsIProtocolHandler]),
+    _xpcom_factory:   Factory(ChromeData),
+
+    scheme: "chrome-data",
+    defaultPort: -1,
+    allowPort: function (port, scheme) false,
+    protocolFlags: Ci.nsIProtocolHandler.URI_NORELATIVE
+         | Ci.nsIProtocolHandler.URI_NOAUTH
+         | Ci.nsIProtocolHandler.URI_IS_UI_RESOURCE,
+
+    newURI: function (spec, charset, baseURI) {
+        var uri = Components.classes["@mozilla.org/network/standard-url;1"]
+                            .createInstance(Components.interfaces.nsIStandardURL)
+                            .QueryInterface(Components.interfaces.nsIURI);
+        uri.init(uri.URLTYPE_STANDARD, this.defaultPort, spec, charset, null);
+        return uri;
+    },
+
+    newChannel: function (uri) {
+        try {
+            if (uri.scheme == this.scheme) {
+                let channel = ioService.newChannel(uri.spec.replace(/^.*?:\/*(.*)(?:#.*)?/, "data:$1"),
+                                                   null, null);
+                channel.contentCharset = "UTF-8";
+                channel.owner = systemPrincipal;
+                channel.originalURI = uri;
+                return channel;
+            }
+        }
+        catch (e) {}
+        return fakeChannel(uri);
+    }
+};
+
+function Dactyl() {
+    // Kill stupid validator warning.
+    this["wrapped" + "JSObject"] = this;
+
+    this.HELP_TAGS = {};
+    this.FILE_MAP = {};
+    this.OVERLAY_MAP = {};
+
+    this.pages = {};
+    this.providers = {};
+
+    Cu.import("resource://dactyl/bootstrap.jsm");
+    if (!JSMLoader.initialized)
+        JSMLoader.init();
+    JSMLoader.load("base.jsm", global);
+    require(global, "config");
+    require(global, "services");
+    require(global, "util");
+    _DNE = ioService.newChannel(DNE, null, null).name;
+
+    // Doesn't belong here:
+    AboutHandler.prototype.register();
+}
+Dactyl.prototype = {
+    contractID:       "@mozilla.org/network/protocol;1?name=dactyl",
+    classID:          Components.ID("{9c8f2530-51c8-4d41-b356-319e0b155c44}"),
+    classDescription: "Dactyl utility protocol",
+    QueryInterface:   XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsIProtocolHandler]),
+    _xpcom_factory:   Factory(Dactyl),
+
+    init: function (obj) {
+        for each (let prop in ["HELP_TAGS", "FILE_MAP", "OVERLAY_MAP"]) {
+            this[prop] = this[prop].constructor();
+            for (let [k, v] in Iterator(obj[prop] || {}))
+                this[prop][k] = v;
+        }
+        this.initialized = true;
+    },
+
+    scheme: "dactyl",
+    defaultPort: -1,
+    allowPort: function (port, scheme) false,
+    protocolFlags: 0
+         | Ci.nsIProtocolHandler.URI_IS_UI_RESOURCE
+         | Ci.nsIProtocolHandler.URI_IS_LOCAL_RESOURCE,
+
+    newURI: function newURI(spec, charset, baseURI) {
+        var uri = Cc["@mozilla.org/network/standard-url;1"]
+                        .createInstance(Ci.nsIStandardURL)
+                        .QueryInterface(Ci.nsIURI);
+        if (baseURI && baseURI.host === "data")
+            baseURI = null;
+        uri.init(uri.URLTYPE_STANDARD, this.defaultPort, spec, charset, baseURI);
+        return uri;
+    },
+
+    newChannel: function newChannel(uri) {
+        try {
+            if (/^help/.test(uri.host) && !("all" in this.FILE_MAP))
+                return redirect(uri.spec, uri, 1);
+
+            if (uri.host in this.providers)
+                return makeChannel(this.providers[uri.host](uri), uri);
+
+            let path = decodeURIComponent(uri.path.replace(/^\/|#.*/g, ""));
+            switch(uri.host) {
+            case "content":
+                return makeChannel(this.pages[path] || "resource://dactyl-content/" + path, uri);
+            case "data":
+                try {
+                    var channel = ioService.newChannel(uri.path.replace(/^\/(.*)(?:#.*)?/, "data:$1"),
+                                                       null, null);
+                }
+                catch (e) {
+                    var error = e;
+                    break;
+                }
+                channel.contentCharset = "UTF-8";
+                channel.owner = systemPrincipal;
+                channel.originalURI = uri;
+                return channel;
+            case "help":
+                return makeChannel(this.FILE_MAP[path], uri);
+            case "help-overlay":
+                return makeChannel(this.OVERLAY_MAP[path], uri);
+            case "help-tag":
+                let tag = decodeURIComponent(uri.path.substr(1));
+                if (tag in this.FILE_MAP)
+                    return redirect("dactyl://help/" + tag, uri);
+                if (tag in this.HELP_TAGS)
+                    return redirect("dactyl://help/" + this.HELP_TAGS[tag] + "#" + tag.replace(/#/g, encodeURIComponent), uri);
+                break;
+            case "locale":
+                return LocaleChannel("dactyl-locale", path, uri);
+            case "locale-local":
+                return LocaleChannel("dactyl-local-locale", path, uri);
+            }
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+        if (error)
+            throw error;
+        return fakeChannel(uri);
+    },
+
+    // FIXME: Belongs elsewhere
+    _xpcom_categories: [{
+        category: "profile-after-change",
+        entry: "m-dactyl"
+    }],
+
+    observe: function observe(subject, topic, data) {
+        if (topic === "profile-after-change") {
+            Cu.import("resource://dactyl/bootstrap.jsm");
+            JSMLoader.init();
+            require(global, "overlay");
+        }
+    }
+};
+
+function LocaleChannel(pkg, path, orig) {
+    for each (let locale in [config.locale, "en-US"])
+        for each (let sep in "-/") {
+            var channel = makeChannel(["resource:/", pkg + sep + config.locale, path].join("/"), orig);
+            if (channel.name !== _DNE)
+                return channel;
+        }
+    return channel;
+}
+
+function StringChannel(data, contentType, uri) {
+    let channel = services.StreamChannel(uri);
+    channel.contentStream = services.StringStream(data);
+    if (contentType)
+        channel.contentType = contentType;
+    channel.contentCharset = "UTF-8";
+    channel.owner = systemPrincipal;
+    if (uri)
+        channel.originalURI = uri;
+    return channel;
+}
+
+function XMLChannel(uri, contentType) {
+    try {
+        var channel = services.io.newChannelFromURI(uri);
+        var channelStream = channel.open();
+    }
+    catch (e) {
+        this.channel = fakeChannel(uri);
+        return;
+    }
+
+    this.uri = uri;
+    this.sourceChannel = services.io.newChannelFromURI(uri);
+    this.pipe = services.Pipe(true, true, 0, 0, null);
+    this.writes = [];
+
+    this.channel = services.StreamChannel(uri);
+    this.channel.contentStream = this.pipe.inputStream;
+    this.channel.contentType = contentType || channel.contentType;
+    this.channel.contentCharset = "UTF-8";
+    this.channel.owner = systemPrincipal;
+
+    let stream = services.InputStream(channelStream);
+    let [, pre, doctype, url, open, post] = util.regexp(<![CDATA[
+            ^ ([^]*?)
+            (?:
+                (<!DOCTYPE \s+ \S+ \s+) SYSTEM \s+ "([^"]*)"
+                (\s+ \[)?
+                ([^]*)
+            )?
+            $
+        ]]>, "x").exec(stream.read(4096));
+    this.writes.push(pre);
+    if (doctype) {
+        this.writes.push(doctype + "[\n");
+        try {
+            this.writes.push(services.io.newChannel(url, null, null).open())
+        }
+        catch (e) {}
+        if (!open)
+            this.writes.push("\n]");
+        this.writes.push(post)
+    }
+    this.writes.push(channelStream);
+
+    this.writeNext();
+}
+XMLChannel.prototype = {
+    QueryInterface:   XPCOMUtils.generateQI([Ci.nsIRequestObserver]),
+    writeNext: function () {
+        try {
+            if (!this.writes.length)
+                this.pipe.outputStream.close();
+            else {
+                let stream = this.writes.shift();
+                if (isString(stream))
+                    stream = services.StringStream(stream);
+
+                services.StreamCopier(stream, this.pipe.outputStream, null,
+                                      false, true, 4096, true, false)
+                        .asyncCopy(this, null);
+            }
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+    },
+
+    onStartRequest: function (request, context) {},
+    onStopRequest: function (request, context, statusCode) {
+        this.writeNext();
+    }
+};
+
+function AboutHandler() {}
+AboutHandler.prototype = {
+    register: function () {
+        try {
+            JSMLoader.registerFactory(Factory(AboutHandler));
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+    },
+
+    get classDescription() "About " + config.appName + " Page",
+
+    classID: Components.ID("81495d80-89ee-4c36-a88d-ea7c4e5ac63f"),
+
+    get contractID() "@mozilla.org/network/protocol/about;1?what=" + config.name,
+
+    QueryInterface: XPCOMUtils.generateQI([Ci.nsIAboutModule]),
+
+    newChannel: function (uri) {
+        let channel = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService)
+                          .newChannel("dactyl://content/about.xul", null, null);
+        channel.originalURI = uri;
+        return channel;
+    },
+
+    getURIFlags: function (uri) Ci.nsIAboutModule.ALLOW_SCRIPT,
+};
+
+// A hack to get information about interfaces.
+// Doesn't belong here.
+function Shim() {}
+Shim.prototype = {
+    contractID:       "@dactyl.googlecode.com/base/xpc-interface-shim",
+    classID:          Components.ID("{f4506a17-5b4d-4cd9-92d4-2eb4630dc388}"),
+    classDescription: "XPCOM empty interface shim",
+    QueryInterface:   function (iid) {
+        if (iid.equals(Ci.nsISecurityCheckedComponent))
+            throw Components.results.NS_ERROR_NO_INTERFACE;
+        return this;
+    },
+    getHelperForLanguage: function () null,
+    getInterfaces: function (count) { count.value = 0; }
+};
+
+if (XPCOMUtils.generateNSGetFactory)
+    var NSGetFactory = XPCOMUtils.generateNSGetFactory([ChromeData, Dactyl, Shim]);
+else
+    var NSGetModule = XPCOMUtils.generateNSGetModule([ChromeData, Dactyl, Shim]);
+var EXPORTED_SYMBOLS = ["NSGetFactory", "global"];
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/abbreviations.js b/common/content/abbreviations.js
new file mode 100644 (file)
index 0000000..9a76fa3
--- /dev/null
@@ -0,0 +1,320 @@
+// Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2010 by anekos <anekos@snca.net>
+// Copyright (c) 2010-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+var Abbreviation = Class("Abbreviation", {
+    init: function (modes, lhs, rhs) {
+        this.modes = modes.sort();
+        this.lhs = lhs;
+        this.rhs = rhs;
+    },
+
+    equals: function (other) this.lhs == other.lhs && this.rhs == other.rhs,
+
+    expand: function (editor) String(callable(this.rhs) ? this.rhs(editor) : this.rhs),
+
+    modesEqual: function (modes) array.equals(this.modes, modes),
+
+    inMode: function (mode) this.modes.some(function (_mode) _mode == mode),
+
+    inModes: function (modes) modes.some(function (mode) this.inMode(mode), this),
+
+    removeMode: function (mode) {
+        this.modes = this.modes.filter(function (m) m != mode).sort();
+    },
+
+    get modeChar() Abbreviation.modeChar(this.modes)
+}, {
+    modeChar: function (_modes) {
+        let result = array.uniq(_modes.map(function (m) m.char)).join("");
+        if (result == "ci")
+            result = "!";
+        return result;
+    }
+});
+
+var AbbrevHive = Class("AbbrevHive", Contexts.Hive, {
+    init: function init(group) {
+        init.superapply(this, arguments);
+        this._store = {};
+    },
+
+    get empty() !values(this._store).nth(util.identity, 0),
+
+    /**
+     * Adds a new abbreviation.
+     *
+     * @param {Abbreviation} abbr The abbreviation to add.
+     */
+    add: function (abbr) {
+        if (!(abbr instanceof Abbreviation))
+            abbr = Abbreviation.apply(null, arguments);
+
+        for (let [, mode] in Iterator(abbr.modes)) {
+            if (!this._store[mode])
+                this._store[mode] = {};
+            this._store[mode][abbr.lhs] = abbr;
+        }
+    },
+
+    /**
+     * Returns the abbreviation with *lhs* in the given *mode*.
+     *
+     * @param {Mode} mode The mode of the abbreviation.
+     * @param {string} lhs The LHS of the abbreviation.
+     */
+    get: function (mode, lhs) {
+        let abbrevs = this._store[mode];
+        return abbrevs && set.has(abbrevs, lhs) ? abbrevs[lhs] : null;
+    },
+
+    /**
+     * @property {Abbreviation[]} The list of the abbreviations merged from
+     *     each mode.
+     */
+    get merged() {
+        // Wth? --Kris;
+        let map = values(this._store).map(Iterator).map(iter.toArray)
+                                     .flatten().toObject();
+        return Object.keys(map).sort().map(function (k) map[k]);
+    },
+
+    /**
+     * Remove the specified abbreviations.
+     *
+     * @param {Array} modes List of modes.
+     * @param {string} lhs The LHS of the abbreviation.
+     * @returns {boolean} Did the deleted abbreviation exist?
+     */
+    remove: function (modes, lhs) {
+        let result = false;
+        for (let [, mode] in Iterator(modes)) {
+            if ((mode in this._store) && (lhs in this._store[mode])) {
+                result = true;
+                this._store[mode][lhs].removeMode(mode);
+                delete this._store[mode][lhs];
+            }
+        }
+        return result;
+    },
+
+    /**
+     * Removes all abbreviations specified in *modes*.
+     *
+     * @param {Array} modes List of modes.
+     */
+    clear: function (modes) {
+        for (let mode in values(modes)) {
+            for (let abbr in values(this._store[mode]))
+                abbr.removeMode(mode);
+            delete this._store[mode];
+        }
+    }
+});
+
+var Abbreviations = Module("abbreviations", {
+    init: function () {
+
+        // (summarized from Vim's ":help abbreviations")
+        //
+        // There are three types of abbreviations.
+        //
+        // full-id: Consists entirely of keyword characters.
+        //          ("foo", "g3", "-1")
+        //
+        // end-id: Ends in a keyword character, but all other
+        //         are not keyword characters.
+        //         ("#i", "..f", "$/7")
+        //
+        // non-id: Ends in a non-keyword character, but the
+        //         others can be of any type other than space
+        //         and tab.
+        //         ("def#", "4/7$")
+        //
+        // Example strings that cannot be abbreviations:
+        //         "a.b", "#def", "a b", "_$r"
+        //
+        // For now, a keyword character is anything except for \s, ", or '
+        // (i.e., whitespace and quotes). In Vim, a keyword character is
+        // specified by the 'iskeyword' setting and is a much less inclusive
+        // list.
+        //
+        // TODO: Make keyword definition closer to Vim's default keyword
+        //       definition (which differs across platforms).
+
+        let params = { // This is most definitely not Vim compatible.
+            keyword:    /[^\s"']/,
+            nonkeyword: /[   "']/
+        };
+
+        this._match = util.regexp(<><![CDATA[
+            (^ | \s | <nonkeyword>) (<keyword>+             )$ | // full-id
+            (^ | \s | <keyword>   ) (<nonkeyword>+ <keyword>)$ | // end-id
+            (^ | \s               ) (\S* <nonkeyword>       )$   // non-id
+        ]]></>, "x", params);
+        this._check = util.regexp(<><![CDATA[
+            ^ (?:
+              <keyword>+              | // full-id
+              <nonkeyword>+ <keyword> | // end-id
+              \S* <nonkeyword>          // non-id
+            ) $
+        ]]></>, "x", params);
+    },
+
+    get: deprecated("group.abbrevs.get", { get: function get() this.user.closure.get }),
+    set: deprecated("group.abbrevs.set", { get: function set() this.user.closure.set }),
+    remove: deprecated("group.abbrevs.remove", { get: function remove() this.user.closure.remove }),
+    removeAll: deprecated("group.abbrevs.clear", { get: function removeAll() this.user.closure.clear }),
+
+    /**
+     * Returns the abbreviation for the given *mode* if *text* matches the
+     * abbreviation expansion criteria.
+     *
+     * @param {Mode} mode The mode to search.
+     * @param {string} text The string to test against the expansion criteria.
+     *
+     * @returns {Abbreviation}
+     */
+    match: function (mode, text) {
+        let match = this._match.exec(text);
+        if (match)
+            return this.hives.map(function (h) h.get(mode, match[2] || match[4] || match[6])).nth(util.identity, 0);
+        return null;
+    },
+
+    /**
+     * Lists all abbreviations matching *modes* and *lhs*.
+     *
+     * @param {Array} modes List of modes.
+     * @param {string} lhs The LHS of the abbreviation.
+     */
+    list: function (modes, lhs) {
+        let hives = contexts.allGroups.abbrevs.filter(function (h) !h.empty);
+
+        function abbrevs(hive)
+            hive.merged.filter(function (abbr) (abbr.inModes(modes) && abbr.lhs.indexOf(lhs) == 0));
+
+        let list = <table>
+                <tr highlight="Title">
+                    <td/>
+                    <td style="padding-right: 1em;">Mode</td>
+                    <td style="padding-right: 1em;">Abbrev</td>
+                    <td style="padding-right: 1em;">Replacement</td>
+                </tr>
+                <col style="min-width: 6em; padding-right: 1em;"/>
+                {
+                    template.map(hives, function (hive) let (i = 0)
+                        <tr style="height: .5ex;"/> +
+                        template.map(abbrevs(hive), function (abbrev)
+                            <tr>
+                                <td highlight="Title">{!i++ ? hive.name : ""}</td>
+                                <td>{abbrev.modeChar}</td>
+                                <td>{abbrev.lhs}</td>
+                                <td>{abbrev.rhs}</td>
+                            </tr>) +
+                        <tr style="height: .5ex;"/>)
+                }
+                </table>;
+
+        // TODO: Move this to an ItemList to show this automatically
+        if (list.*.length() === list.text().length() + 2)
+            dactyl.echomsg(_("abbrev.none"));
+        else
+            commandline.commandOutput(list);
+    }
+
+}, {
+}, {
+    contexts: function initContexts(dactyl, modules, window) {
+        update(Abbreviations.prototype, {
+            hives: contexts.Hives("abbrevs", AbbrevHive),
+            user: contexts.hives.abbrevs.user
+        });
+    },
+    completion: function () {
+        completion.abbreviation = function abbreviation(context, modes, group) {
+            group = group || abbreviations.user;
+            let fn = modes ? function (abbr) abbr.inModes(modes) : util.identity;
+            context.keys = { text: "lhs" , description: "rhs" };
+            context.completions = group.merged.filter(fn);
+        };
+    },
+
+    commands: function () {
+        function addAbbreviationCommands(modes, ch, modeDescription) {
+            modes.sort();
+            modeDescription = modeDescription ? " in " + modeDescription + " mode" : "";
+
+            commands.add([ch ? ch + "a[bbreviate]" : "ab[breviate]"],
+                "Abbreviate a key sequence" + modeDescription,
+                function (args) {
+                    let [lhs, rhs] = args;
+                    dactyl.assert(!args.length || abbreviations._check.test(lhs),
+                                  _("error.invalidArgument"));
+
+                    if (!rhs)
+                        abbreviations.list(modes, lhs || "");
+                    else {
+                        if (args["-javascript"])
+                            rhs = contexts.bindMacro({ literalArg: rhs }, "-javascript", ["editor"]);
+                        args["-group"].add(modes, lhs, rhs);
+                    }
+                }, {
+                    completer: function (context, args) {
+                        if (args.length == 1)
+                            return completion.abbreviation(context, modes, args["-group"]);
+                        else if (args["-javascript"])
+                            return completion.javascript(context);
+                    },
+                    hereDoc: true,
+                    literal: 1,
+                    options: [
+                        contexts.GroupFlag("abbrevs"),
+                        {
+                            names: ["-javascript", "-js", "-j"],
+                            description: "Expand this abbreviation by evaluating its right-hand-side as JavaScript"
+                        }
+                    ],
+                    serialize: function () [
+                        {
+                            command: this.name,
+                            arguments: [abbr.lhs],
+                            literalArg: abbr.rhs,
+                            options: callable(abbr.rhs) ? {"-javascript": null} : {}
+                        }
+                        for ([, abbr] in Iterator(abbreviations.user.merged))
+                        if (abbr.modesEqual(modes))
+                    ]
+                });
+
+            commands.add([ch + "una[bbreviate]"],
+                "Remove an abbreviation" + modeDescription,
+                function (args) {
+                    util.assert(args.bang ^ !!args[0], _("error.argumentOrBang"));
+
+                    if (args.bang)
+                        args["-group"].clear(modes);
+                    else if (!args["-group"].remove(modes, args[0]))
+                        return dactyl.echoerr(_("abbrev.noSuch"));
+                }, {
+                    argCount: "?",
+                    bang: true,
+                    completer: function (context, args) completion.abbreviation(context, modes, args["-group"]),
+                    literal: 0,
+                    options: [contexts.GroupFlag("abbrevs")]
+                });
+        }
+
+        addAbbreviationCommands([modes.INSERT, modes.COMMAND_LINE], "", "");
+        addAbbreviationCommands([modes.INSERT], "i", "insert");
+        addAbbreviationCommands([modes.COMMAND_LINE], "c", "command line");
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/about.xul b/common/content/about.xul
new file mode 100644 (file)
index 0000000..433402c
--- /dev/null
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
+<?xml-stylesheet href="resource://dactyl-local-skin/about.css" type="text/css"?>
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<page id="about-&dactyl.name;" orient="vertical" title="About &dactyl.appName;"
+      xmlns="&xmlns.xul;" xmlns:html="&xmlns.html;">
+
+  <html:link rel="icon" href="chrome://&dactyl.name;/skin/icon.png"
+             type="image/png" style="display: none;"/>
+
+  <spring flex="1"/>
+  <hbox>
+    <spring flex="1"/>
+      <div xmlns="&xmlns.html;" style="text-align: center" id="text-container">
+<img src="chrome://&dactyl.name;/content/logo.png" alt="&dactyl.appName;" />
+
+version &dactyl.version;
+by Kris Maglione, Doug Kearns, et al.
+&dactyl.appName; is open source and freely distributable
+
+type :q&lt;<span class="key">Enter</span>>                 to exit         <!---->
+type :help&lt;<span class="key">Enter</span>>  or  &lt;<span class="key">F1</span>>    for on-line help
+type :help faq&lt;<span class="key">Enter</span>>          for the FAQ page
+type :help versions&lt;<span class="key">Enter</span>>     for version info
+      </div>
+    <spring flex="1"/>
+  </hbox>
+  <spring flex="1"/>
+</page>
+
+<!-- vim: set sw=2 sts=2 et: -->
diff --git a/common/content/autocommands.js b/common/content/autocommands.js
new file mode 100644 (file)
index 0000000..2d960c8
--- /dev/null
@@ -0,0 +1,293 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+var AutoCommand = Struct("event", "filter", "command");
+update(AutoCommand.prototype, {
+    eventName: Class.memoize(function () this.event.toLowerCase()),
+
+    match: function (event, pattern) {
+        return (!event || this.eventName == event.toLowerCase()) && (!pattern || String(this.filter) === pattern);
+    }
+});
+
+var AutoCmdHive = Class("AutoCmdHive", Contexts.Hive, {
+    init: function init(group) {
+        init.supercall(this, group);
+        this._store = [];
+    },
+
+    __iterator__: function () array.iterValues(this._store),
+
+    /**
+     * Adds a new autocommand. *cmd* will be executed when one of the specified
+     * *events* occurs and the URL of the applicable buffer matches *regexp*.
+     *
+     * @param {Array} events The array of event names for which this
+     *     autocommand should be executed.
+     * @param {string} pattern The URL pattern to match against the buffer URL.
+     * @param {string} cmd The Ex command to run.
+     */
+    add: function (events, pattern, cmd) {
+        if (!callable(pattern))
+            pattern = Group.compileFilter(pattern);
+
+        for (let event in values(events))
+            this._store.push(AutoCommand(event, pattern, cmd));
+    },
+
+    /**
+     * Returns all autocommands with a matching *event* and *regexp*.
+     *
+     * @param {string} event The event name filter.
+     * @param {string} pattern The URL pattern filter.
+     * @returns {AutoCommand[]}
+     */
+    get: function (event, pattern) {
+        return this._store.filter(function (autoCmd) autoCmd.match(event, regexp));
+    },
+
+    /**
+     * Deletes all autocommands with a matching *event* and *regexp*.
+     *
+     * @param {string} event The event name filter.
+     * @param {string} regexp The URL pattern filter.
+     */
+    remove: function (event, regexp) {
+        this._store = this._store.filter(function (autoCmd) !autoCmd.match(event, regexp));
+    },
+});
+
+/**
+ * @instance autocommands
+ */
+var AutoCommands = Module("autocommands", {
+    init: function () {
+        update(this, {
+            hives: contexts.Hives("autocmd", AutoCmdHive),
+            user: contexts.hives.autocmd.user,
+            allHives: contexts.allGroups.autocmd,
+            matchingHives: function matchingHives(uri, doc) contexts.matchingGroups(uri, doc).autocmd
+        });
+    },
+
+    get activeHives() contexts.allGroups.autocmd.filter(function (h) h._store.length),
+
+    add: deprecated("group.autocmd.add", { get: function add() autocommands.user.closure.add }),
+    get: deprecated("group.autocmd.get", { get: function get() autocommands.user.closure.get }),
+    remove: deprecated("group.autocmd.remove", { get: function remove() autocommands.user.closure.remove }),
+
+    /**
+     * Lists all autocommands with a matching *event* and *regexp*.
+     *
+     * @param {string} event The event name filter.
+     * @param {string} regexp The URL pattern filter.
+     */
+    list: function (event, regexp) {
+
+        function cmds(hive) {
+            let cmds = {};
+            hive._store.forEach(function (autoCmd) {
+                if (autoCmd.match(event, regexp)) {
+                    cmds[autoCmd.event] = cmds[autoCmd.event] || [];
+                    cmds[autoCmd.event].push(autoCmd);
+                }
+            });
+            return cmds;
+        }
+
+        commandline.commandOutput(
+            <table>
+                <tr highlight="Title">
+                    <td colspan="3">----- Auto Commands -----</td>
+                </tr>
+                {
+                    template.map(this.activeHives, function (hive)
+                        <tr highlight="Title">
+                            <td colspan="3">{hive.name}</td>
+                        </tr> +
+                        <tr style="height: .5ex;"/> +
+                        template.map(cmds(hive), function ([event, items])
+                            <tr style="height: .5ex;"/> +
+                            template.map(items, function (item, i)
+                                <tr>
+                                    <td highlight="Title" style="padding-right: 1em;">{i == 0 ? event : ""}</td>
+                                    <td>{item.filter.toXML ? item.filter.toXML() : item.filter}</td>
+                                    <td>{item.command}</td>
+                                </tr>) +
+                            <tr style="height: .5ex;"/>) +
+                        <tr style="height: .5ex;"/>)
+                }
+            </table>);
+    },
+
+    /**
+     * Triggers the execution of all autocommands registered for *event*. A map
+     * of *args* is passed to each autocommand when it is being executed.
+     *
+     * @param {string} event The event to fire.
+     * @param {Object} args The args to pass to each autocommand.
+     */
+    trigger: function (event, args) {
+        if (options.get("eventignore").has(event))
+            return;
+
+        dactyl.echomsg(_("autocmd.executing", event, "*".quote()), 8);
+
+        let lastPattern = null;
+        var { url, doc } = args;
+        if (url)
+            uri = util.newURI(url);
+        else
+            var { uri, doc } = buffer;
+
+        event = event.toLowerCase();
+        for (let hive in values(this.matchingHives(uri, doc))) {
+            let args = update({},
+                              hive.argsExtra(arguments[1]),
+                              arguments[1]);
+
+            for (let autoCmd in values(hive._store))
+                if (autoCmd.eventName === event && autoCmd.filter(uri, doc)) {
+                    if (!lastPattern || lastPattern !== String(autoCmd.filter))
+                        dactyl.echomsg(_("autocmd.executing", event, autoCmd.filter), 8);
+
+                    lastPattern = String(autoCmd.filter);
+                    dactyl.echomsg(_("autocmd.autocommand", autoCmd.command), 9);
+
+                    dactyl.trapErrors(autoCmd.command, autoCmd, args);
+                }
+        }
+    }
+}, {
+}, {
+    commands: function () {
+        commands.add(["au[tocmd]"],
+            "Execute commands automatically on events",
+            function (args) {
+                let [event, regexp, cmd] = args;
+                let events = [];
+
+                if (event) {
+                    // NOTE: event can only be a comma separated list for |:au {event} {pat} {cmd}|
+                    let validEvents = Object.keys(config.autocommands).map(String.toLowerCase);
+                    validEvents.push("*");
+
+                    events = Option.parse.stringlist(event);
+                    dactyl.assert(events.every(function (event) validEvents.indexOf(event.toLowerCase()) >= 0),
+                                  _("autocmd.noGroup", event));
+                }
+
+                if (args.length > 2) { // add new command, possibly removing all others with the same event/pattern
+                    if (args.bang)
+                        args["-group"].remove(event, regexp);
+                    cmd = contexts.bindMacro(args, "-ex", function (params) params);
+                    args["-group"].add(events, regexp, cmd);
+                }
+                else {
+                    if (event == "*")
+                        event = null;
+
+                    if (args.bang) {
+                        // TODO: "*" only appears to work in Vim when there is a {group} specified
+                        if (args[0] != "*" || args.length > 1)
+                            args["-group"].remove(event, regexp); // remove all
+                    }
+                    else
+                        autocommands.list(event, regexp); // list all
+                }
+            }, {
+                bang: true,
+                completer: function (context, args) {
+                    if (args.length == 1)
+                        return completion.autocmdEvent(context);
+                    if (args.length == 3)
+                        return args["-javascript"] ? completion.javascript(context) : completion.ex(context);
+                },
+                hereDoc: true,
+                keepQuotes: true,
+                literal: 2,
+                options: [
+                    contexts.GroupFlag("autocmd"),
+                    {
+                        names: ["-javascript", "-js"],
+                        description: "Interpret the action as JavaScript code rather than an Ex command"
+                    }
+                ]
+            });
+
+        [
+            {
+                name: "do[autocmd]",
+                description: "Apply the autocommands matching the specified URL pattern to the current buffer"
+            }, {
+                name: "doautoa[ll]",
+                description: "Apply the autocommands matching the specified URL pattern to all buffers"
+            }
+        ].forEach(function (command) {
+            commands.add([command.name],
+                command.description,
+                // TODO: Perhaps this should take -args to pass to the command?
+                function (args) {
+                    // Vim compatible
+                    if (args.length == 0)
+                        return void dactyl.echomsg(_("autocmd.noMatching"));
+
+                    let [event, url] = args;
+                    let defaultURL = url || buffer.uri.spec;
+                    let validEvents = Object.keys(config.autocommands);
+
+                    // TODO: add command validators
+                    dactyl.assert(event != "*",
+                                  _("autocmd.cantExecuteAll"));
+                    dactyl.assert(validEvents.indexOf(event) >= 0,
+                                  _("autocmd.noGroup", args));
+                    dactyl.assert(autocommands.get(event).some(function (c) c.patterns.some(function (re) re.test(defaultURL) ^ !re.result)),
+                                  _("autocmd.noMatching"));
+
+                    if (this.name == "doautoall" && dactyl.has("tabs")) {
+                        let current = tabs.index();
+
+                        for (let i = 0; i < tabs.count; i++) {
+                            tabs.select(i);
+                            // if no url arg is specified use the current buffer's URL
+                            autocommands.trigger(event, { url: url || buffer.uri.spec });
+                        }
+
+                        tabs.select(current);
+                    }
+                    else
+                        autocommands.trigger(event, { url: defaultURL });
+                }, {
+                    argCount: "*", // FIXME: kludged for proper error message should be "1".
+                    completer: function (context) completion.autocmdEvent(context),
+                    keepQuotes: true
+                });
+        });
+    },
+    completion: function () {
+        completion.autocmdEvent = function autocmdEvent(context) {
+            context.completions = Iterator(config.autocommands);
+        };
+    },
+    javascript: function () {
+        JavaScript.setCompleter(autocommands.user.get, [function () Iterator(config.autocommands)]);
+    },
+    options: function () {
+        options.add(["eventignore", "ei"],
+            "List of autocommand event names which should be ignored",
+            "stringlist", "",
+            {
+                values: iter(update({ all: "All Events" }, config.autocommands)).toArray(),
+                has: Option.has.toggleAll
+            });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/bindings.xml b/common/content/bindings.xml
new file mode 100644 (file)
index 0000000..800230d
--- /dev/null
@@ -0,0 +1,73 @@
+<?xml version="1.0"?>
+
+<bindings xmlns="http://www.mozilla.org/xbl"
+          xmlns:dactyl="http://vimperator.org/namespaces/liberator"
+          xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+          xmlns:xbl="http://www.mozilla.org/xbl"
+          xmlns:html="http://www.w3.org/1999/xhtml">
+
+    <binding id="frame" inheritstyle="false">
+        <content>
+            <children/>
+            <html:div dactyl:highlight="FrameIndicator"/>
+        </content>
+    </binding>
+
+    <binding id="hints" inheritstyle="false">
+        <content>
+            <html:div anonid="hints"/>
+        </content>
+    </binding>
+
+    <binding id="compitem-td">
+        <!-- No white space. The table is white-space: pre; :( -->
+        <content><html:span class="td-strut"/><html:span class="td-span"><children/></html:span></content>
+    </binding>
+
+    <binding id="tab" display="xul:hbox"
+             extends="chrome://browser/content/tabbrowser.xml#tabbrowser-tab">
+        <content closetabtext="Close Tab">
+            <xul:stack class="tab-icon dactyl-tab-stack">
+                <xul:image xbl:inherits="validate,src=image" role="presentation" class="tab-icon-image"/>
+                <xul:vbox>
+                    <xul:spring flex="1"/>
+                    <xul:label xbl:inherits="value=dactylOrdinal" dactyl:highlight="TabIconNumber"/>
+                    <xul:spring flex="1"/>
+                </xul:vbox>
+            </xul:stack>
+            <xul:label xbl:inherits="value=dactylOrdinal" dactyl:highlight="TabNumber"/>
+            <xul:label xbl:inherits="value=label,crop,accesskey"
+                       flex="1" class="tab-text" role="presentation"/>
+            <xul:toolbarbutton anonid="close-button" tabindex="-1"
+                               class="tab-close-button"/>
+        </content>
+    </binding>
+
+    <binding id="tab-mac"
+             extends="chrome://browser/content/tabbrowser.xml#tabbrowser-tab">
+        <content chromedir="ltr" closetabtext="Close Tab">
+            <xul:hbox class="tab-image-left" xbl:inherits="selected"/>
+            <xul:hbox class="tab-image-middle" flex="1" align="center" xbl:inherits="selected">
+                <xul:stack class="tab-icon dactyl-tab-stack">
+                    <xul:image xbl:inherits="validate,src=image" class="tab-icon-image"/>
+                    <xul:image class="tab-extra-status"/>
+                    <xul:vbox>
+                        <xul:spring flex="1"/>
+                        <xul:label xbl:inherits="value=dactylOrdinal" align="center" highlight="TabIconNumber" class="dactyl-tab-icon-number"/>
+                        <xul:spring flex="1"/>
+                    </xul:vbox>
+                </xul:stack>
+                <xul:stack class="tab-text-stack">
+                    <xul:label xbl:inherits="value=dactylOrdinal" class="tab-text" dactyl:highlight="TabNumber"/>
+                </xul:stack>
+                <xul:stack class="tab-text-stack" flex="1">
+                    <xul:label flex="1" xbl:inherits="value=label,crop,accesskey" crop="right" class="tab-text"/>
+                </xul:stack>
+            </xul:hbox>
+            <xul:toolbarbutton anonid="close-button" tabindex="-1" class="tab-close-button"/>
+            <xul:hbox class="tab-image-right" xbl:inherits="selected"/>
+        </content>
+    </binding>
+</bindings>
+
+<!-- vim:se ft=xbl sw=4 sts=4 tw=0 et: -->
diff --git a/common/content/bookmarks.js b/common/content/bookmarks.js
new file mode 100644 (file)
index 0000000..ade210d
--- /dev/null
@@ -0,0 +1,676 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+var DEFAULT_FAVICON = "chrome://mozapps/skin/places/defaultFavicon.png";
+
+// also includes methods for dealing with keywords and search engines
+var Bookmarks = Module("bookmarks", {
+    init: function () {
+        storage.addObserver("bookmark-cache", function (key, event, arg) {
+            if (["add", "change", "remove"].indexOf(event) >= 0)
+                autocommands.trigger("Bookmark" + event[0].toUpperCase() + event.substr(1),
+                     iter({
+                         bookmark: {
+                             toString: function () "bookmarkcache.bookmarks[" + arg.id + "]",
+                             valueOf: function () arg
+                         }
+                     }, arg));
+            statusline.updateStatus();
+        }, window);
+    },
+
+    get format() ({
+        anchored: false,
+        title: ["URL", "Info"],
+        keys: { text: "url", description: "title", icon: "icon", extra: "extra", tags: "tags" },
+        process: [template.icon, template.bookmarkDescription]
+    }),
+
+    // TODO: why is this a filter? --djk
+    get: function get(filter, tags, maxItems, extra) {
+        return completion.runCompleter("bookmark", filter, maxItems, tags, extra);
+    },
+
+    /**
+     * Adds a new bookmark. The first parameter should be an object with
+     * any of the following properties:
+     *
+     * @param {boolean} unfiled If true, the bookmark is added to the
+     *      Unfiled Bookmarks Folder.
+     * @param {string} title The title of the new bookmark.
+     * @param {string} url The URL of the new bookmark.
+     * @param {string} keyword The keyword of the new bookmark.
+     *      @optional
+     * @param {[string]} tags The tags for the new bookmark.
+     *      @optional
+     * @param {boolean} force If true, a new bookmark is always added.
+     *      Otherwise, if a bookmark for the given URL exists it is
+     *      updated instead.
+     *      @optional
+     * @returns {boolean} True if the bookmark was added or updated
+     *      successfully.
+     */
+    add: function add(unfiled, title, url, keyword, tags, force) {
+        // FIXME
+        if (isObject(unfiled))
+            var { unfiled, title, url, keyword, tags, post, charset, force } = unfiled;
+
+        try {
+            let uri = util.createURI(url);
+            if (!force && bookmarkcache.isBookmarked(uri))
+                for (var bmark in bookmarkcache)
+                    if (bmark.url == uri.spec) {
+                        if (title)
+                            bmark.title = title;
+                        break;
+                    }
+
+            if (tags) {
+                PlacesUtils.tagging.untagURI(uri, null);
+                PlacesUtils.tagging.tagURI(uri, tags);
+            }
+            if (bmark == undefined)
+                bmark = bookmarkcache.bookmarks[
+                    services.bookmarks.insertBookmark(
+                         services.bookmarks[unfiled ? "unfiledBookmarksFolder" : "bookmarksMenuFolder"],
+                         uri, -1, title || url)];
+            if (!bmark)
+                return false;
+
+            if (charset !== undefined)
+                bmark.charset = charset;
+            if (post !== undefined)
+                bmark.post = post;
+            if (keyword)
+                bmark.keyword = keyword;
+        }
+        catch (e) {
+            util.reportError(e);
+            return false;
+        }
+
+        return true;
+    },
+
+    /**
+     * Opens the command line in Ex mode pre-filled with a :bmark
+     * command to add a new search keyword for the given form element.
+     *
+     * @param {Element} elem A form element for which to add a keyword.
+     */
+    addSearchKeyword: function (elem) {
+        if (elem instanceof HTMLFormElement || elem.form)
+            var [url, post, charset] = util.parseForm(elem);
+        else
+            var [url, post, charset] = [elem.href || elem.src, null, elem.ownerDocument.characterSet];
+
+        let options = { "-title": "Search " + elem.ownerDocument.title };
+        if (post != null)
+            options["-post"] = post;
+        if (charset != null && charset !== "UTF-8")
+            options["-charset"] = charset;
+
+        CommandExMode().open(
+            commands.commandToString({ command: "bmark", options: options, arguments: [url] }) + " -keyword ");
+    },
+
+    /**
+     * Toggles the bookmarked state of the given URL. If the URL is
+     * bookmarked, all bookmarks for said URL are removed.
+     * If it is not, a new bookmark is added to the Unfiled Bookmarks
+     * Folder. The new bookmark has the title of the current buffer if
+     * its URL is identical to *url*, otherwise its title will be the
+     * value of *url*.
+     *
+     * @param {string} url The URL of the bookmark to toggle.
+     */
+    toggle: function toggle(url) {
+        if (!url)
+            return;
+
+        let count = this.remove(url);
+        if (count > 0)
+            dactyl.echomsg({ domains: [util.getHost(url)], message: _("bookmark.removed", url) });
+        else {
+            let title = buffer.uri.spec == url && buffer.title || url;
+            let extra = "";
+            if (title != url)
+                extra = " (" + title + ")";
+            this.add({ unfiled: true, title: title, url: url });
+            dactyl.echomsg({ domains: [util.getHost(url)], message: _("bookmark.added", url + extra) });
+        }
+    },
+
+    isBookmarked: deprecated("bookmarkcache.isBookmarked", { get: function isBookmarked() bookmarkcache.closure.isBookmarked }),
+
+    /**
+     * Remove a bookmark or bookmarks. If *ids* is an array, removes the
+     * bookmarks with those IDs. If it is a string, removes all
+     * bookmarks whose URLs match that string.
+     *
+     * @param {string|[number]} ids The IDs or URL of the bookmarks to
+     *      remove.
+     * @returns {number} The number of bookmarks removed.
+     */
+    remove: function remove(ids) {
+        try {
+            if (!isArray(ids)) {
+                let uri = util.newURI(ids);
+                ids = services.bookmarks
+                              .getBookmarkIdsForURI(uri, {})
+                              .filter(bookmarkcache.closure.isRegularBookmark);
+            }
+            ids.forEach(function (id) {
+                let bmark = bookmarkcache.bookmarks[id];
+                if (bmark) {
+                    PlacesUtils.tagging.untagURI(bmark.uri, null);
+                    bmark.charset = null;
+                }
+                services.bookmarks.removeItem(id);
+            });
+            return ids.length;
+        }
+        catch (e) {
+            dactyl.reportError(e, true);
+            return 0;
+        }
+    },
+
+    getSearchEngines: deprecated("bookmarks.searchEngines", function getSearchEngines() this.searchEngines),
+    /**
+     * Returns a list of all visible search engines in the search
+     * services, augmented with keyword, title, and icon properties for
+     * use in completion functions.
+     */
+    get searchEngines() {
+        let searchEngines = [];
+        let aliases = {};
+        return iter(services.browserSearch.getVisibleEngines({})).map(function ([, engine]) {
+            let alias = engine.alias;
+            if (!alias || !/^[a-z0-9-]+$/.test(alias))
+                alias = engine.name.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/, "").toLowerCase();
+            if (!alias)
+                alias = "search"; // for search engines which we can't find a suitable alias
+
+            if (set.has(aliases, alias))
+                alias += ++aliases[alias];
+            else
+                aliases[alias] = 0;
+
+            return [alias, { keyword: alias, __proto__: engine, title: engine.description, icon: engine.iconURI && engine.iconURI.spec }];
+        }).toObject();
+    },
+
+    /**
+     * Retrieves a list of search suggestions from the named search
+     * engine based on the given *query*. The results are always in the
+     * form of an array of strings. If *callback* is provided, the
+     * request is executed asynchronously and *callback* is called on
+     * completion. Otherwise, the request is executed synchronously and
+     * the results are returned.
+     *
+     * @param {string} engineName The name of the search engine from
+     *      which to request suggestions.
+     * @param {string} query The query string for which to request
+     *      suggestions.
+     * @param {function([string])} callback The function to call when
+     *      results are returned.
+     * @returns {[string] | null}
+     */
+    getSuggestions: function getSuggestions(engineName, query, callback) {
+        const responseType = "application/x-suggestions+json";
+
+        let engine = set.has(this.searchEngines, engineName) && this.searchEngines[engineName];
+        if (engine && engine.supportsResponseType(responseType))
+            var queryURI = engine.getSubmission(query, responseType).uri.spec;
+        if (!queryURI)
+            return (callback || util.identity)([]);
+
+        function process(req) {
+            let results = [];
+            try {
+                results = JSON.parse(req.responseText)[1].filter(isString);
+            }
+            catch (e) {}
+            if (callback)
+                return callback(results);
+            return results;
+        }
+
+        let req = util.httpGet(queryURI, callback && process);
+        if (callback)
+            return req;
+        return process(req);
+    },
+
+    /**
+     * Returns an array containing a search URL and POST data for the
+     * given search string. If *useDefsearch* is true, the string is
+     * always passed to the default search engine. If it is not, the
+     * search engine name is retrieved from the first space-separated
+     * token of the given string.
+     *
+     * Returns null if no search engine is found for the passed string.
+     *
+     * @param {string} text The text for which to retrieve a search URL.
+     * @param {boolean} useDefsearch Whether to use the default search
+     *      engine.
+     * @returns {[string, string | null] | null}
+     */
+    getSearchURL: function getSearchURL(text, useDefsearch) {
+        let query = (useDefsearch ? options["defsearch"] + " " : "") + text;
+
+        // ripped from Firefox
+        var keyword = query;
+        var param = "";
+        var offset = query.indexOf(" ");
+        if (offset > 0) {
+            keyword = query.substr(0, offset);
+            param = query.substr(offset + 1);
+        }
+
+        var engine = set.has(bookmarks.searchEngines, keyword) && bookmarks.searchEngines[keyword];
+        if (engine) {
+            if (engine.searchForm && !param)
+                return engine.searchForm;
+            let submission = engine.getSubmission(param, null);
+            return [submission.uri.spec, submission.postData];
+        }
+
+        let [url, postData] = PlacesUtils.getURLAndPostDataForKeyword(keyword);
+        if (!url)
+            return null;
+
+        let data = window.unescape(postData || "");
+        if (/%s/i.test(url) || /%s/i.test(data)) {
+            var charset = "";
+            var matches = url.match(/^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/);
+            if (matches)
+                [, url, charset] = matches;
+            else
+                try {
+                    charset = services.history.getCharsetForURI(util.newURI(url));
+                }
+                catch (e) {}
+
+            if (charset)
+                var encodedParam = escape(window.convertFromUnicode(charset, param));
+            else
+                encodedParam = bookmarkcache.keywords[keyword].encodeURIComponent(param);
+
+            url = url.replace(/%s/g, encodedParam).replace(/%S/g, param);
+            if (/%s/i.test(data))
+                postData = window.getPostDataStream(data, param, encodedParam, "application/x-www-form-urlencoded");
+        }
+        else if (param)
+            postData = null;
+
+        if (postData)
+            return [url, postData];
+        return url;
+    },
+
+    /**
+     * Lists all bookmarks whose URLs match *filter*, tags match *tags*,
+     * and other properties match the properties of *extra*. If
+     * *openItems* is true, the items are opened in tabs rather than
+     * listed.
+     *
+     * @param {string} filter A URL filter string which the URLs of all
+     *      matched items must contain.
+     * @param {[string]} tags An array of tags each of which all matched
+     *      items must contain.
+     * @param {boolean} openItems If true, items are opened rather than
+     *      listed.
+     * @param {object} extra Extra properties which must be matched.
+     */
+    list: function list(filter, tags, openItems, maxItems, extra) {
+        // FIXME: returning here doesn't make sense
+        //   Why the hell doesn't it make sense? --Kris
+        // Because it unconditionally bypasses the final error message
+        // block and does so only when listing items, not opening them. In
+        // short it breaks the :bmarks command which doesn't make much
+        // sense to me but I'm old-fashioned. --djk
+        if (!openItems)
+            return completion.listCompleter("bookmark", filter, maxItems, tags, extra);
+        let items = completion.runCompleter("bookmark", filter, maxItems, tags, extra);
+
+        if (items.length)
+            return dactyl.open(items.map(function (i) i.url), dactyl.NEW_TAB);
+
+        if (filter.length > 0 && tags.length > 0)
+            dactyl.echoerr(_("bookmark.noMatching", tags.map(String.quote), filter.quote()));
+        else if (filter.length > 0)
+            dactyl.echoerr(_("bookmark.noMatchingString", filter.quote()));
+        else if (tags.length > 0)
+            dactyl.echoerr(_("bookmark.noMatchingTags", tags.map(String.quote)));
+        else
+            dactyl.echoerr(_("bookmark.none"));
+        return null;
+    }
+}, {
+}, {
+    commands: function () {
+        commands.add(["ju[mps]"],
+            "Show jumplist",
+            function () {
+                let sh = history.session;
+                commandline.commandOutput(template.jumps(sh.index, sh));
+            },
+            { argCount: "0" });
+
+        // TODO: Clean this up.
+        const tags = {
+            names: ["-tags", "-T"],
+            description: "A comma-separated list of tags",
+            completer: function tags(context, args) {
+                context.generate = function () array(b.tags for (b in bookmarkcache) if (b.tags)).flatten().uniq().array;
+                context.keys = { text: util.identity, description: util.identity };
+            },
+            type: CommandOption.LIST
+        };
+
+        const title = {
+            names: ["-title", "-t"],
+            description: "Bookmark page title or description",
+            completer: function title(context, args) {
+                let frames = buffer.allFrames();
+                if (!args.bang)
+                    return  [
+                        [win.document.title, frames.length == 1 ? "Current Location" : "Frame: " + win.location.href]
+                        for ([, win] in Iterator(frames))];
+                context.keys.text = "title";
+                context.keys.description = "url";
+                return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: args["-keyword"], title: context.filter });
+            },
+            type: CommandOption.STRING
+        };
+
+        const post = {
+            names: ["-post", "-p"],
+            description: "Bookmark POST data",
+            completer: function post(context, args) {
+                context.keys.text = "post";
+                context.keys.description = "url";
+                return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: args["-keyword"], post: context.filter });
+            },
+            type: CommandOption.STRING
+        };
+
+        const keyword = {
+            names: ["-keyword", "-k"],
+            description: "Keyword by which this bookmark may be opened (:open {keyword})",
+            completer: function keyword(context, args) {
+                context.keys.text = "keyword";
+                return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: context.filter, title: args["-title"] });
+            },
+            type: CommandOption.STRING,
+            validator: function (arg) /^\S+$/.test(arg)
+        };
+
+        commands.add(["bma[rk]"],
+            "Add a bookmark",
+            function (args) {
+                let opts = {
+                    force: args.bang,
+                    unfiled: false,
+                    keyword: args["-keyword"] || null,
+                    charset: args["-charset"],
+                    post: args["-post"],
+                    tags: args["-tags"] || [],
+                    title: args["-title"] || (args.length === 0 ? buffer.title : null),
+                    url: args.length === 0 ? buffer.uri.spec : args[0]
+                };
+
+                if (bookmarks.add(opts)) {
+                    let extra = (opts.title == opts.url) ? "" : " (" + opts.title + ")";
+                    dactyl.echomsg({ domains: [util.getHost(opts.url)], message: _("bookmark.added", opts.url + extra) },
+                                   1, commandline.FORCE_SINGLELINE);
+                }
+                else
+                    dactyl.echoerr(_("bookmark.cantAdd", opts.title.quote()));
+            }, {
+                argCount: "?",
+                bang: true,
+                completer: function (context, args) {
+                    if (!args.bang) {
+                        context.title = ["Page URL"];
+                        let frames = buffer.allFrames();
+                        context.completions = [
+                            [win.document.documentURI, frames.length == 1 ? "Current Location" : "Frame: " + win.document.title]
+                            for ([, win] in Iterator(frames))];
+                        return;
+                    }
+                    completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] });
+                },
+                options: [keyword, title, tags, post,
+                    {
+                        names: ["-charset", "-c"],
+                        description: "The character encoding of the bookmark",
+                        type: CommandOption.STRING,
+                        completer: function (context) completion.charset(context),
+                        validator: Option.validateCompleter
+                    }
+                ]
+            });
+
+        commands.add(["bmarks"],
+            "List or open multiple bookmarks",
+            function (args) {
+                bookmarks.list(args.join(" "), args["-tags"] || [], args.bang, args["-max"],
+                               { keyword: args["-keyword"], title: args["-title"] });
+            },
+            {
+                bang: true,
+                completer: function completer(context, args) {
+                    context.filter = args.join(" ");
+                    completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] });
+                },
+                options: [tags, keyword, title,
+                    {
+                        names: ["-max", "-m"],
+                        description: "The maximum number of items to list or open",
+                        type: CommandOption.INT
+                    }
+                ]
+                // Not privateData, since we don't treat bookmarks as private
+            });
+
+        commands.add(["delbm[arks]"],
+            "Delete a bookmark",
+            function (args) {
+                if (args.bang)
+                    commandline.input("This will delete all bookmarks. Would you like to continue? (yes/[no]) ",
+                        function (resp) {
+                            if (resp && resp.match(/^y(es)?$/i)) {
+                                bookmarks.remove(Object.keys(bookmarkcache.bookmarks));
+                                dactyl.echomsg(_("bookmark.allGone"));
+                            }
+                        });
+                else {
+                    if (!(args.length || args["-tags"] || args["-keyword"] || args["-title"]))
+                        var deletedCount = bookmarks.remove(buffer.uri.spec);
+                    else {
+                        let context = CompletionContext(args.join(" "));
+                        context.fork("bookmark", 0, completion, "bookmark",
+                                     args["-tags"], { keyword: args["-keyword"], title: args["-title"] });
+                        var deletedCount = bookmarks.remove(context.allItems.items.map(function (item) item.item.id));
+                    }
+
+                    dactyl.echomsg({ message: _("bookmark.deleted", deletedCount) });
+                }
+
+            },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function completer(context, args)
+                    completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] }),
+                domains: function (args) array.compact(args.map(util.getHost)),
+                literal: 0,
+                options: [tags, title, keyword],
+                privateData: true
+            });
+    },
+    mappings: function () {
+        var myModes = config.browserModes;
+
+        mappings.add(myModes, ["a"],
+            "Open a prompt to bookmark the current URL",
+            function () {
+                let options = {};
+
+                let url = buffer.uri.spec;
+                let bmarks = bookmarks.get(url).filter(function (bmark) bmark.url == url);
+
+                if (bmarks.length == 1) {
+                    let bmark = bmarks[0];
+
+                    options["-title"] = bmark.title;
+                    if (bmark.charset)
+                        options["-charset"] = bmark.charset;
+                    if (bmark.keyword)
+                        options["-keyword"] = bmark.keyword;
+                    if (bmark.post)
+                        options["-post"] = bmark.post;
+                    if (bmark.tags.length > 0)
+                        options["-tags"] = bmark.tags;
+                }
+                else {
+                    if (buffer.title != buffer.uri.spec)
+                        options["-title"] = buffer.title;
+                    if (content.document.characterSet !== "UTF-8")
+                        options["-charset"] = content.document.characterSet;
+                }
+
+                CommandExMode().open(
+                    commands.commandToString({ command: "bmark", options: options, arguments: [buffer.uri.spec] }));
+            });
+
+        mappings.add(myModes, ["A"],
+            "Toggle bookmarked state of current URL",
+            function () { bookmarks.toggle(buffer.uri.spec); });
+    },
+    options: function () {
+        options.add(["defsearch", "ds"],
+            "The default search engine",
+            "string", "google",
+            {
+                completer: function completer(context) {
+                    completion.search(context, true);
+                    context.completions = [{ keyword: "", title: "Don't perform searches by default" }].concat(context.completions);
+                }
+            });
+
+        options.add(["suggestengines"],
+             "Search engines used for search suggestions",
+             "stringlist", "google",
+             { completer: function completer(context) completion.searchEngine(context, true), });
+    },
+
+    completion: function () {
+        completion.bookmark = function bookmark(context, tags, extra) {
+            context.title = ["Bookmark", "Title"];
+            context.format = bookmarks.format;
+            iter(extra || {}).forEach(function ([k, v]) {
+                if (v != null)
+                    context.filters.push(function (item) item.item[k] != null && this.matchString(v, item.item[k]));
+            });
+            context.generate = function () values(bookmarkcache.bookmarks);
+            completion.urls(context, tags);
+        };
+
+        completion.search = function search(context, noSuggest) {
+            let [, keyword, space, args] = context.filter.match(/^\s*(\S*)(\s*)(.*)$/);
+            let keywords = bookmarkcache.keywords;
+            let engines = bookmarks.searchEngines;
+
+            context.title = ["Search Keywords"];
+            context.completions = iter(values(keywords), values(engines));
+            context.keys = { text: "keyword", description: "title", icon: "icon" };
+
+            if (!space || noSuggest)
+                return;
+
+            context.fork("suggest", keyword.length + space.length, this, "searchEngineSuggest",
+                         keyword, true);
+
+            let item = keywords[keyword];
+            if (item && item.url.indexOf("%s") > -1)
+                context.fork("keyword/" + keyword, keyword.length + space.length, null, function (context) {
+                    context.format = history.format;
+                    context.title = [keyword + " Quick Search"];
+                    // context.background = true;
+                    context.compare = CompletionContext.Sort.unsorted;
+                    context.generate = function () {
+                        let [begin, end] = item.url.split("%s");
+
+                        return history.get({ uri: util.newURI(begin), uriIsPrefix: true }).map(function (item) {
+                            let rest = item.url.length - end.length;
+                            let query = item.url.substring(begin.length, rest);
+                            if (item.url.substr(rest) == end && query.indexOf("&") == -1)
+                                try {
+                                    item.url = decodeURIComponent(query.replace(/#.*/, "").replace(/\+/g, " "));
+                                    return item;
+                                }
+                                catch (e) {}
+                            return null;
+                        }).filter(util.identity);
+                    };
+                });
+        };
+
+        completion.searchEngine = function searchEngine(context, suggest) {
+             context.title = ["Suggest Engine", "Description"];
+             context.keys = { text: "keyword", description: "title", icon: "icon" };
+             context.completions = values(bookmarks.searchEngines);
+             if (suggest)
+                 context.filters.push(function ({ item }) item.supportsResponseType("application/x-suggestions+json"));
+
+        };
+
+        completion.searchEngineSuggest = function searchEngineSuggest(context, engineAliases, kludge) {
+            if (!context.filter)
+                return;
+
+            let engineList = (engineAliases || options["suggestengines"].join(",") || "google").split(",");
+
+            engineList.forEach(function (name) {
+                let engine = bookmarks.searchEngines[name];
+                if (!engine)
+                    return;
+                let [, word] = /^\s*(\S+)/.exec(context.filter) || [];
+                if (!kludge && word == name) // FIXME: Check for matching keywords
+                    return;
+                let ctxt = context.fork(name, 0);
+
+                ctxt.title = [engine.description + " Suggestions"];
+                ctxt.keys = { text: util.identity, description: function () "" };
+                ctxt.compare = CompletionContext.Sort.unsorted;
+                ctxt.filterFunc = null;
+
+                let words = ctxt.filter.toLowerCase().split(/\s+/g);
+                ctxt.completions = ctxt.completions.filter(function (i) words.every(function (w) i.toLowerCase().indexOf(w) >= 0));
+
+                ctxt.hasItems = ctxt.completions.length;
+                ctxt.incomplete = true;
+                ctxt.cache.request = bookmarks.getSuggestions(name, ctxt.filter, function (compl) {
+                    ctxt.incomplete = false;
+                    ctxt.completions = array.uniq(ctxt.completions.filter(function (c) compl.indexOf(c) >= 0)
+                                                      .concat(compl), true);
+                });
+            });
+        };
+
+        completion.addUrlCompleter("S", "Suggest engines", completion.searchEngineSuggest);
+        completion.addUrlCompleter("b", "Bookmarks", completion.bookmark);
+        completion.addUrlCompleter("s", "Search engines and keyword URLs", completion.search);
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/browser.js b/common/content/browser.js
new file mode 100644 (file)
index 0000000..746b46e
--- /dev/null
@@ -0,0 +1,244 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+/**
+ * @instance browser
+ */
+var Browser = Module("browser", XPCOM(Ci.nsISupportsWeakReference, ModuleBase), {
+    init: function init() {
+        this.cleanupProgressListener = util.overlayObject(window.XULBrowserWindow,
+                                                          this.progressListener);
+        util.addObserver(this);
+    },
+
+    destroy: function () {
+        this.cleanupProgressListener();
+        this.observe.unregister();
+    },
+
+    observers: {
+        "chrome-document-global-created": function (win, uri) { this.observe(win, "content-document-global-created", uri); },
+        "content-document-global-created": function (win, uri) {
+            let top = util.topWindow(win);
+
+            if (top == window)
+                this._triggerLoadAutocmd("PageLoadPre", win.document, win.location.href != "null" ? window.location.href : uri);
+        }
+    },
+
+    _triggerLoadAutocmd: function _triggerLoadAutocmd(name, doc, uri) {
+        if (!(uri || doc.location))
+            return;
+
+        uri = isObject(uri) ? uri : util.newURI(uri || doc.location.href);
+        let args = {
+            url: { toString: function () uri.spec, valueOf: function () uri },
+            title: doc.title
+        };
+
+        if (dactyl.has("tabs")) {
+            args.tab = tabs.getContentIndex(doc) + 1;
+            args.doc = {
+                valueOf: function () doc,
+                toString: function () "tabs.getTab(" + (args.tab - 1) + ").linkedBrowser.contentDocument"
+            };
+        }
+
+        autocommands.trigger(name, args);
+    },
+
+    events: {
+        DOMContentLoaded: function onDOMContentLoaded(event) {
+            let doc = event.originalTarget;
+            if (doc instanceof HTMLDocument)
+                this._triggerLoadAutocmd("DOMLoad", doc);
+        },
+
+        // TODO: see what can be moved to onDOMContentLoaded()
+        // event listener which is is called on each page load, even if the
+        // page is loaded in a background tab
+        load: function onLoad(event) {
+            let doc = event.originalTarget;
+            if (doc instanceof Document)
+                dactyl.initDocument(doc);
+
+            if (doc instanceof HTMLDocument) {
+                if (doc.defaultView.frameElement) {
+                    // document is part of a frameset
+
+                    // hacky way to get rid of "Transferring data from ..." on sites with frames
+                    // when you click on a link inside a frameset, because asyncUpdateUI
+                    // is not triggered there (Gecko bug?)
+                    this.timeout(function () { statusline.updateStatus(); }, 10);
+                }
+                else {
+                    // code which should happen for all (also background) newly loaded tabs goes here:
+                    if (doc != config.browser.contentDocument)
+                        dactyl.echomsg({ domains: [util.getHost(doc.location)], message: _("buffer.backgroundLoaded", (doc.title || doc.location.href)) }, 3);
+
+                    this._triggerLoadAutocmd("PageLoad", doc);
+                }
+            }
+        }
+    },
+
+    /**
+     * @property {Object} The document loading progress listener.
+     */
+    progressListener: {
+        // XXX: function may later be needed to detect a canceled synchronous openURL()
+        onStateChange: util.wrapCallback(function onStateChange(webProgress, request, flags, status) {
+            onStateChange.superapply(this, arguments);
+            // STATE_IS_DOCUMENT | STATE_IS_WINDOW is important, because we also
+            // receive statechange events for loading images and other parts of the web page
+            if (flags & (Ci.nsIWebProgressListener.STATE_IS_DOCUMENT | Ci.nsIWebProgressListener.STATE_IS_WINDOW)) {
+                dactyl.applyTriggerObserver("browser.stateChange", arguments);
+                // This fires when the load event is initiated
+                // only thrown for the current tab, not when another tab changes
+                if (flags & Ci.nsIWebProgressListener.STATE_START) {
+                    while (document.commandDispatcher.focusedWindow == webProgress.DOMWindow
+                           && modes.have(modes.INPUT))
+                        modes.pop();
+
+                }
+                else if (flags & Ci.nsIWebProgressListener.STATE_STOP) {
+                    // Workaround for bugs 591425 and 606877, dactyl bug #81
+                    config.browser.mCurrentBrowser.collapsed = false;
+                    if (!dactyl.focusedElement || dactyl.focusedElement === document.documentElement)
+                        dactyl.focusContent();
+                }
+            }
+        }),
+        onSecurityChange: util.wrapCallback(function onSecurityChange(webProgress, request, state) {
+            onSecurityChange.superapply(this, arguments);
+            dactyl.applyTriggerObserver("browser.securityChange", arguments);
+        }),
+        onStatusChange: util.wrapCallback(function onStatusChange(webProgress, request, status, message) {
+            onStatusChange.superapply(this, arguments);
+            dactyl.applyTriggerObserver("browser.statusChange", arguments);
+        }),
+        onProgressChange: util.wrapCallback(function onProgressChange(webProgress, request, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) {
+            onProgressChange.superapply(this, arguments);
+            dactyl.applyTriggerObserver("browser.progressChange", arguments);
+        }),
+        // happens when the users switches tabs
+        onLocationChange: util.wrapCallback(function onLocationChange(webProgress, request, uri) {
+            onLocationChange.superapply(this, arguments);
+
+            dactyl.applyTriggerObserver("browser.locationChange", arguments);
+
+            let win = webProgress.DOMWindow;
+            if (win && uri) {
+                let oldURI = win.document.dactylURI;
+                if (win.document.dactylLoadIdx === webProgress.loadedTransIndex
+                    || !oldURI || uri.spec.replace(/#.*/, "") !== oldURI.replace(/#.*/, ""))
+                    for (let frame in values(buffer.allFrames(win)))
+                        frame.document.dactylFocusAllowed = false;
+                win.document.dactylURI = uri.spec;
+                win.document.dactylLoadIdx = webProgress.loadedTransIndex;
+            }
+
+            // Workaround for bugs 591425 and 606877, dactyl bug #81
+            let collapse = uri && uri.scheme === "dactyl" && webProgress.isLoadingDocument;
+            if (collapse)
+                dactyl.focus(document.documentElement);
+            config.browser.mCurrentBrowser.collapsed = collapse;
+
+            util.timeout(function () {
+                browser._triggerLoadAutocmd("LocationChange",
+                                            (win || content).document,
+                                            uri);
+            });
+        }),
+        // called at the very end of a page load
+        asyncUpdateUI: util.wrapCallback(function asyncUpdateUI() {
+            asyncUpdateUI.superapply(this, arguments);
+            util.timeout(function () { statusline.updateStatus(); }, 100);
+        }),
+        setOverLink: util.wrapCallback(function setOverLink(link, b) {
+            setOverLink.superapply(this, arguments);
+            dactyl.triggerObserver("browser.overLink", link);
+        }),
+    }
+}, {
+}, {
+    events: function initEvents(dactyl, modules, window) {
+        events.listen(config.browser, browser, "events", true);
+    },
+    commands: function initCommands(dactyl, modules, window) {
+        commands.add(["o[pen]"],
+            "Open one or more URLs in the current tab",
+            function (args) { dactyl.open(args[0] || "about:blank"); },
+            {
+                completer: function (context) completion.url(context),
+                domains: function (args) array.compact(dactyl.parseURLs(args[0] || "").map(
+                    function (url) util.getHost(url))),
+                literal: 0,
+                privateData: true
+            });
+
+        commands.add(["redr[aw]"],
+            "Redraw the screen",
+            function () {
+                window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils)
+                      .redraw();
+                statusline.updateStatus();
+                commandline.clear();
+            },
+            { argCount: "0" });
+    },
+    mappings: function initMappings(dactyl, modules, window) {
+        // opening websites
+        mappings.add([modes.NORMAL],
+            ["o"], "Open one or more URLs",
+            function () { CommandExMode().open("open "); });
+
+        mappings.add([modes.NORMAL], ["O"],
+            "Open one or more URLs, based on current location",
+            function () { CommandExMode().open("open " + buffer.uri.spec); });
+
+        mappings.add([modes.NORMAL], ["t"],
+            "Open one or more URLs in a new tab",
+            function () { CommandExMode().open("tabopen "); });
+
+        mappings.add([modes.NORMAL], ["T"],
+            "Open one or more URLs in a new tab, based on current location",
+            function () { CommandExMode().open("tabopen " + buffer.uri.spec); });
+
+        mappings.add([modes.NORMAL], ["w"],
+            "Open one or more URLs in a new window",
+            function () { CommandExMode().open("winopen "); });
+
+        mappings.add([modes.NORMAL], ["W"],
+            "Open one or more URLs in a new window, based on current location",
+            function () { CommandExMode().open("winopen " + buffer.uri.spec); });
+
+        mappings.add([modes.NORMAL], ["~"],
+            "Open home directory",
+            function () { dactyl.open("~"); });
+
+        mappings.add([modes.NORMAL], ["gh"],
+            "Open homepage",
+            function () { BrowserHome(); });
+
+        mappings.add([modes.NORMAL], ["gH"],
+            "Open homepage in a new tab",
+            function () {
+                let homepages = gHomeButton.getHomePage();
+                dactyl.open(homepages, { from: "homepage", where: dactyl.NEW_TAB });
+            });
+
+        mappings.add([modes.MAIN], ["<C-l>"],
+            "Redraw the screen",
+            function () { ex.redraw(); });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/buffer.js b/common/content/buffer.js
new file mode 100644 (file)
index 0000000..af099e7
--- /dev/null
@@ -0,0 +1,1861 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+/**
+ * A class to manage the primary web content buffer. The name comes
+ * from Vim's term, 'buffer', which signifies instances of open
+ * files.
+ * @instance buffer
+ */
+var Buffer = Module("buffer", {
+    init: function init() {
+        this.evaluateXPath = util.evaluateXPath;
+        this.pageInfo = {};
+
+        this.addPageInfoSection("f", "Feeds", function (verbose) {
+            const feedTypes = {
+                "application/rss+xml": "RSS",
+                "application/atom+xml": "Atom",
+                "text/xml": "XML",
+                "application/xml": "XML",
+                "application/rdf+xml": "XML"
+            };
+
+            function isValidFeed(data, principal, isFeed) {
+                if (!data || !principal)
+                    return false;
+
+                if (!isFeed) {
+                    var type = data.type && data.type.toLowerCase();
+                    type = type.replace(/^\s+|\s*(?:;.*)?$/g, "");
+
+                    isFeed = ["application/rss+xml", "application/atom+xml"].indexOf(type) >= 0 ||
+                             // really slimy: general XML types with magic letters in the title
+                             type in feedTypes && /\brss\b/i.test(data.title);
+                }
+
+                if (isFeed) {
+                    try {
+                        window.urlSecurityCheck(data.href, principal,
+                                Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL);
+                    }
+                    catch (e) {
+                        isFeed = false;
+                    }
+                }
+
+                if (type)
+                    data.type = type;
+
+                return isFeed;
+            }
+
+            let nFeed = 0;
+            for (let [i, win] in Iterator(buffer.allFrames())) {
+                let doc = win.document;
+
+                for (let link in util.evaluateXPath(["link[@href and (@rel='feed' or (@rel='alternate' and @type))]"], doc)) {
+                    let rel = link.rel.toLowerCase();
+                    let feed = { title: link.title, href: link.href, type: link.type || "" };
+                    if (isValidFeed(feed, doc.nodePrincipal, rel == "feed")) {
+                        nFeed++;
+                        let type = feedTypes[feed.type] || "RSS";
+                        if (verbose)
+                            yield [feed.title, template.highlightURL(feed.href, true) + <span class="extra-info">&#xa0;({type})</span>];
+                    }
+                }
+
+            }
+
+            if (!verbose && nFeed)
+                yield nFeed + " feed" + (nFeed > 1 ? "s" : "");
+        });
+
+        this.addPageInfoSection("g", "General Info", function (verbose) {
+            let doc = buffer.focusedFrame.document;
+
+            // get file size
+            const ACCESS_READ = Ci.nsICache.ACCESS_READ;
+            let cacheKey = doc.documentURI;
+
+            for (let proto in array.iterValues(["HTTP", "FTP"])) {
+                try {
+                    var cacheEntryDescriptor = services.cache.createSession(proto, 0, true)
+                                                       .openCacheEntry(cacheKey, ACCESS_READ, false);
+                    break;
+                }
+                catch (e) {}
+            }
+
+            let pageSize = []; // [0] bytes; [1] kbytes
+            if (cacheEntryDescriptor) {
+                pageSize[0] = util.formatBytes(cacheEntryDescriptor.dataSize, 0, false);
+                pageSize[1] = util.formatBytes(cacheEntryDescriptor.dataSize, 2, true);
+                if (pageSize[1] == pageSize[0])
+                    pageSize.length = 1; // don't output "xx Bytes" twice
+            }
+
+            let lastModVerbose = new Date(doc.lastModified).toLocaleString();
+            let lastMod = new Date(doc.lastModified).toLocaleFormat("%x %X");
+
+            if (lastModVerbose == "Invalid Date" || new Date(doc.lastModified).getFullYear() == 1970)
+                lastModVerbose = lastMod = null;
+
+            if (!verbose) {
+                if (pageSize[0])
+                    yield (pageSize[1] || pageSize[0]) + " bytes";
+                yield lastMod;
+                return;
+            }
+
+            yield ["Title", doc.title];
+            yield ["URL", template.highlightURL(doc.location.href, true)];
+
+            let ref = "referrer" in doc && doc.referrer;
+            if (ref)
+                yield ["Referrer", template.highlightURL(ref, true)];
+
+            if (pageSize[0])
+                yield ["File Size", pageSize[1] ? pageSize[1] + " (" + pageSize[0] + ")"
+                                                : pageSize[0]];
+
+            yield ["Mime-Type", doc.contentType];
+            yield ["Encoding", doc.characterSet];
+            yield ["Compatibility", doc.compatMode == "BackCompat" ? "Quirks Mode" : "Full/Almost Standards Mode"];
+            if (lastModVerbose)
+                yield ["Last Modified", lastModVerbose];
+        });
+
+        this.addPageInfoSection("m", "Meta Tags", function (verbose) {
+            // get meta tag data, sort and put into pageMeta[]
+            let metaNodes = buffer.focusedFrame.document.getElementsByTagName("meta");
+
+            return Array.map(metaNodes, function (node) [(node.name || node.httpEquiv), template.highlightURL(node.content)])
+                        .sort(function (a, b) util.compareIgnoreCase(a[0], b[0]));
+        });
+
+        dactyl.commands["buffer.viewSource"] = function (event) {
+            let elem = event.originalTarget;
+            buffer.viewSource([elem.getAttribute("href"), Number(elem.getAttribute("line"))]);
+        };
+    },
+
+    // called when the active document is scrolled
+    _updateBufferPosition: function _updateBufferPosition() {
+        statusline.updateBufferPosition();
+        commandline.clear(true);
+    },
+
+    /**
+     * @property {Array} The alternative style sheets for the current
+     *     buffer. Only returns style sheets for the 'screen' media type.
+     */
+    get alternateStyleSheets() {
+        let stylesheets = window.getAllStyleSheets(this.focusedFrame);
+
+        return stylesheets.filter(
+            function (stylesheet) /^(screen|all|)$/i.test(stylesheet.media.mediaText) && !/^\s*$/.test(stylesheet.title)
+        );
+    },
+
+    climbUrlPath: function climbUrlPath(count) {
+        let url = buffer.documentURI.clone();
+        dactyl.assert(url instanceof Ci.nsIURL);
+
+        while (count-- && url.path != "/")
+            url.path = url.path.replace(/[^\/]+\/*$/, "");
+
+        dactyl.assert(!url.equals(buffer.documentURI));
+        dactyl.open(url.spec);
+    },
+
+    incrementURL: function incrementURL(count) {
+        let matches = buffer.uri.spec.match(/(.*?)(\d+)(\D*)$/);
+        dactyl.assert(matches);
+        let oldNum = matches[2];
+
+        // disallow negative numbers as trailing numbers are often proceeded by hyphens
+        let newNum = String(Math.max(parseInt(oldNum, 10) + count, 0));
+        if (/^0/.test(oldNum))
+            while (newNum.length < oldNum.length)
+                newNum = "0" + newNum;
+
+        matches[2] = newNum;
+        dactyl.open(matches.slice(1).join(""));
+    },
+
+    /**
+     * @property {Object} A map of page info sections to their
+     *     content generating functions.
+     */
+    pageInfo: null,
+
+    /**
+     * @property {number} True when the buffer is fully loaded.
+     */
+    get loaded() Math.min.apply(null,
+        this.allFrames()
+            .map(function (frame) ["loading", "interactive", "complete"]
+                                      .indexOf(frame.document.readyState))),
+
+    /**
+     * @property {Object} The local state store for the currently selected
+     *     tab.
+     */
+    get localStore() {
+        if (!content.document.dactylStore)
+            content.document.dactylStore = {};
+        return content.document.dactylStore;
+    },
+
+    /**
+     * @property {Node} The last focused input field in the buffer. Used
+     *     by the "gi" key binding.
+     */
+    get lastInputField() {
+        let field = this.localStore.lastInputField && this.localStore.lastInputField.get();
+        let doc = field && field.ownerDocument;
+        let win = doc && doc.defaultView;
+        return win && doc === win.document ? field : null;
+    },
+    set lastInputField(value) { this.localStore.lastInputField = value && Cu.getWeakReference(value); },
+
+    /**
+     * @property {nsIURI} The current top-level document.
+     */
+    get doc() window.content.document,
+
+    /**
+     * @property {nsIURI} The current top-level document's URI.
+     */
+    get uri() util.newURI(content.location.href),
+
+    /**
+     * @property {nsIURI} The current top-level document's URI, sans any
+     *     fragment identifier.
+     */
+    get documentURI() let (doc = content.document) doc.documentURIObject || util.newURI(doc.documentURI),
+
+    /**
+     * @property {string} The current top-level document's URL.
+     */
+    get URL() update(new String(content.location.href), util.newURI(content.location.href)),
+
+    /**
+     * @property {number} The buffer's height in pixels.
+     */
+    get pageHeight() content.innerHeight,
+
+    /**
+     * @property {number} The current browser's zoom level, as a
+     *     percentage with 100 as 'normal'.
+     */
+    get zoomLevel() config.browser.markupDocumentViewer[this.fullZoom ? "fullZoom" : "textZoom"] * 100,
+    set zoomLevel(value) { this.setZoom(value, this.fullZoom); },
+
+    /**
+     * @property {boolean} Whether the current browser is using full
+     *     zoom, as opposed to text zoom.
+     */
+    get fullZoom() ZoomManager.useFullZoom,
+    set fullZoom(value) { this.setZoom(this.zoomLevel, value); },
+
+    /**
+     * @property {string} The current document's title.
+     */
+    get title() content.document.title,
+
+    /**
+     * @property {number} The buffer's horizontal scroll percentile.
+     */
+    get scrollXPercent() {
+        let elem = this.findScrollable(0, true);
+        if (elem.scrollWidth - elem.clientWidth === 0)
+            return 0;
+        return elem.scrollLeft * 100 / (elem.scrollWidth - elem.clientWidth);
+    },
+
+    /**
+     * @property {number} The buffer's vertical scroll percentile.
+     */
+    get scrollYPercent() {
+        let elem = this.findScrollable(0, false);
+        if (elem.scrollHeight - elem.clientHeight === 0)
+            return 0;
+        return elem.scrollTop * 100 / (elem.scrollHeight - elem.clientHeight);
+    },
+
+    /**
+     * Adds a new section to the page information output.
+     *
+     * @param {string} option The section's value in 'pageinfo'.
+     * @param {string} title The heading for this section's
+     *     output.
+     * @param {function} func The function to generate this
+     *     section's output.
+     */
+    addPageInfoSection: function addPageInfoSection(option, title, func) {
+        this.pageInfo[option] = Buffer.PageInfo(option, title, func);
+    },
+
+    /**
+     * Returns a list of all frames in the given window or current buffer.
+     */
+    allFrames: function allFrames(win, focusedFirst) {
+        let frames = [];
+        (function rec(frame) {
+            if (frame.document.body instanceof HTMLBodyElement)
+                frames.push(frame);
+            Array.forEach(frame.frames, rec);
+        })(win || content);
+        if (focusedFirst)
+            return frames.filter(function (f) f === buffer.focusedFrame).concat(
+                    frames.filter(function (f) f !== buffer.focusedFrame));
+        return frames;
+    },
+
+    /**
+     * @property {Window} Returns the currently focused frame.
+     */
+    get focusedFrame() {
+        let frame = this.localStore.focusedFrame;
+        return frame && frame.get() || content;
+    },
+    set focusedFrame(frame) {
+        this.localStore.focusedFrame = Cu.getWeakReference(frame);
+    },
+
+    /**
+     * Returns the currently selected word. If the selection is
+     * null, it tries to guess the word that the caret is
+     * positioned in.
+     *
+     * @returns {string}
+     */
+    get currentWord() Buffer.currentWord(this.focusedFrame),
+    getCurrentWord: deprecated("buffer.currentWord", function getCurrentWord() this.currentWord),
+
+    /**
+     * Returns true if a scripts are allowed to focus the given input
+     * element or input elements in the given window.
+     *
+     * @param {Node|Window}
+     * @returns {boolean}
+     */
+    focusAllowed: function focusAllowed(elem) {
+        if (elem instanceof Window && !Editor.getEditor(elem))
+            return true;
+        let doc = elem.ownerDocument || elem.document || elem;
+        return !options["strictfocus"] || doc.dactylFocusAllowed;
+    },
+
+    /**
+     * Focuses the given element. In contrast to a simple
+     * elem.focus() call, this function works for iframes and
+     * image maps.
+     *
+     * @param {Node} elem The element to focus.
+     */
+    focusElement: function focusElement(elem) {
+        let win = elem.ownerDocument && elem.ownerDocument.defaultView || elem;
+        win.document.dactylFocusAllowed = true;
+
+        if (isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]))
+            elem = elem.contentWindow;
+        if (elem.document)
+            elem.document.dactylFocusAllowed = true;
+
+        if (elem instanceof HTMLInputElement && elem.type == "file") {
+            Buffer.openUploadPrompt(elem);
+            this.lastInputField = elem;
+        }
+        else {
+            if (isinstance(elem, [HTMLInputElement, XULTextBoxElement]))
+                var flags = services.focus.FLAG_BYMOUSE;
+            else
+                flags = services.focus.FLAG_SHOWRING;
+            dactyl.focus(elem, flags);
+
+            if (elem instanceof Window) {
+                let sel = elem.getSelection();
+                if (sel && !sel.rangeCount)
+                    sel.addRange(RangeFind.endpoint(
+                        RangeFind.nodeRange(elem.document.body || elem.document.documentElement),
+                        true));
+            }
+            else {
+                let range = RangeFind.nodeRange(elem);
+                let sel = (elem.ownerDocument || elem).defaultView.getSelection();
+                if (!sel.rangeCount || !RangeFind.intersects(range, sel.getRangeAt(0))) {
+                    range.collapse(true);
+                    sel.removeAllRanges();
+                    sel.addRange(range);
+                }
+            }
+
+            // for imagemap
+            if (elem instanceof HTMLAreaElement) {
+                try {
+                    let [x, y] = elem.getAttribute("coords").split(",").map(parseFloat);
+
+                    events.dispatch(elem, events.create(elem.ownerDocument, "mouseover", { screenX: x, screenY: y }));
+                }
+                catch (e) {}
+            }
+        }
+    },
+
+    /**
+     * Find the counth last link on a page matching one of the given
+     * regular expressions, or with a @rel or @rev attribute matching
+     * the given relation. Each frame is searched beginning with the
+     * last link and progressing to the first, once checking for
+     * matching @rel or @rev properties, and then once for each given
+     * regular expression. The first match is returned. All frames of
+     * the page are searched, beginning with the currently focused.
+     *
+     * If follow is true, the link is followed.
+     *
+     * @param {string} rel The relationship to look for.
+     * @param {[RegExp]} regexps The regular expressions to search for.
+     * @param {number} count The nth matching link to follow.
+     * @param {bool} follow Whether to follow the matching link.
+     * @param {string} path The CSS to use for the search. @optional
+     */
+    followDocumentRelationship: deprecated("buffer.findLink",
+        function followDocumentRelationship(rel) {
+            this.findLink(rel, options[rel + "pattern"], 0, true);
+        }),
+    findLink: function findLink(rel, regexps, count, follow, path) {
+        let selector = path || options.get("hinttags").stringDefaultValue;
+
+        function followFrame(frame) {
+            function iter(elems) {
+                for (let i = 0; i < elems.length; i++)
+                    if (elems[i].rel.toLowerCase() === rel || elems[i].rev.toLowerCase() === rel)
+                        yield elems[i];
+            }
+
+            let elems = frame.document.getElementsByTagName("link");
+            for (let elem in iter(elems))
+                yield elem;
+
+            elems = frame.document.getElementsByTagName("a");
+            for (let elem in iter(elems))
+                yield elem;
+
+            let res = frame.document.querySelectorAll(selector);
+            for (let regexp in values(regexps)) {
+                for (let i in util.range(res.length, 0, -1)) {
+                    let elem = res[i];
+                    if (regexp.test(elem.textContent) === regexp.result || regexp.test(elem.title) === regexp.result ||
+                            Array.some(elem.childNodes, function (child) regexp.test(child.alt) === regexp.result))
+                        yield elem;
+                }
+            }
+        }
+
+        for (let frame in values(this.allFrames(null, true)))
+            for (let elem in followFrame(frame))
+                if (count-- === 0) {
+                    if (follow)
+                        this.followLink(elem, dactyl.CURRENT_TAB);
+                    return elem;
+                }
+
+        if (follow)
+            dactyl.beep();
+    },
+
+    /**
+     * Fakes a click on a link.
+     *
+     * @param {Node} elem The element to click.
+     * @param {number} where Where to open the link. See
+     *     {@link dactyl.open}.
+     */
+    followLink: function followLink(elem, where) {
+        let doc = elem.ownerDocument;
+        let view = doc.defaultView;
+        let { left: offsetX, top: offsetY } = elem.getBoundingClientRect();
+
+        if (isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]))
+            return this.focusElement(elem);
+        if (isinstance(elem, HTMLLinkElement))
+            return dactyl.open(elem.href, where);
+
+        if (elem instanceof HTMLAreaElement) { // for imagemap
+            let coords = elem.getAttribute("coords").split(",");
+            offsetX = Number(coords[0]) + 1;
+            offsetY = Number(coords[1]) + 1;
+        }
+        else if (elem instanceof HTMLInputElement && elem.type == "file") {
+            Buffer.openUploadPrompt(elem);
+            return;
+        }
+
+        let ctrlKey = false, shiftKey = false;
+        switch (where) {
+        case dactyl.NEW_TAB:
+        case dactyl.NEW_BACKGROUND_TAB:
+            ctrlKey = true;
+            shiftKey = (where != dactyl.NEW_BACKGROUND_TAB);
+            break;
+        case dactyl.NEW_WINDOW:
+            shiftKey = true;
+            break;
+        case dactyl.CURRENT_TAB:
+            break;
+        }
+
+        this.focusElement(elem);
+
+        prefs.withContext(function () {
+            prefs.set("browser.tabs.loadInBackground", true);
+            ["mousedown", "mouseup", "click"].slice(0, util.haveGecko("2b") ? 2 : 3)
+                .forEach(function (event) {
+                events.dispatch(elem, events.create(doc, event, {
+                    screenX: offsetX, screenY: offsetY,
+                    ctrlKey: ctrlKey, shiftKey: shiftKey, metaKey: ctrlKey
+                }));
+            });
+        });
+    },
+
+    /**
+     * @property {nsISelectionController} The current document's selection
+     *     controller.
+     */
+    get selectionController() config.browser.docShell
+            .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
+            .QueryInterface(Ci.nsISelectionController),
+
+    /**
+     * Opens the appropriate context menu for *elem*.
+     *
+     * @param {Node} elem The context element.
+     */
+    openContextMenu: function openContextMenu(elem) {
+        document.popupNode = elem;
+        let menu = document.getElementById("contentAreaContextMenu");
+        menu.showPopup(elem, -1, -1, "context", "bottomleft", "topleft");
+    },
+
+    /**
+     * Saves a page link to disk.
+     *
+     * @param {HTMLAnchorElement} elem The page link to save.
+     */
+    saveLink: function saveLink(elem) {
+        let doc      = elem.ownerDocument;
+        let uri      = util.newURI(elem.href || elem.src, null, util.newURI(elem.baseURI));
+        let referrer = util.newURI(doc.documentURI, doc.characterSet);
+
+        try {
+            window.urlSecurityCheck(uri.spec, doc.nodePrincipal);
+
+            io.CommandFileMode("Save link: ", {
+                onSubmit: function (path) {
+                    let file = io.File(path);
+                    if (file.exists() && file.isDirectory())
+                        file.append(Buffer.getDefaultNames(elem)[0][0]);
+
+                    try {
+                        if (!file.exists())
+                            file.create(File.NORMAL_FILE_TYPE, octal(644));
+                    }
+                    catch (e) {
+                        util.assert(false, _("save.invalidDestination", e.name));
+                    }
+
+                    buffer.saveURI(uri, file);
+                },
+
+                completer: function (context) completion.savePage(context, elem)
+            }).open();
+        }
+        catch (e) {
+            dactyl.echoerr(e);
+        }
+    },
+
+    /**
+     * Saves the contents of a URI to disk.
+     *
+     * @param {nsIURI} uri The URI to save
+     * @param {nsIFile} file The file into which to write the result.
+     */
+    saveURI: function saveURI(uri, file, callback, self) {
+        var persist = services.Persist();
+        persist.persistFlags = persist.PERSIST_FLAGS_FROM_CACHE
+                             | persist.PERSIST_FLAGS_REPLACE_EXISTING_FILES;
+
+        let downloadListener = new window.DownloadListener(window,
+                services.Transfer(uri, services.io.newFileURI(file), "",
+                                  null, null, null, persist));
+
+        persist.progressListener = update(Object.create(downloadListener), {
+            onStateChange: function onStateChange(progress, request, flag, status) {
+                if (callback && (flag & Ci.nsIWebProgressListener.STATE_STOP) && status == 0)
+                    dactyl.trapErrors(callback, self, uri, file, progress, request, flag, status);
+
+                return onStateChange.superapply(this, arguments);
+            }
+        });
+
+        persist.saveURI(uri, null, null, null, null, file);
+    },
+
+    /**
+     * Scrolls the currently active element horizontally. See
+     * {@link Buffer.scrollHorizontal} for parameters.
+     */
+    scrollHorizontal: function scrollHorizontal(increment, number)
+        Buffer.scrollHorizontal(this.findScrollable(number, true), increment, number),
+
+    /**
+     * Scrolls the currently active element vertically. See
+     * {@link Buffer.scrollVertical} for parameters.
+     */
+    scrollVertical: function scrollVertical(increment, number)
+        Buffer.scrollVertical(this.findScrollable(number, false), increment, number),
+
+    /**
+     * Scrolls the currently active element to the given horizontal and
+     * vertical percentages. See {@link Buffer.scrollToPercent} for
+     * parameters.
+     */
+    scrollToPercent: function scrollToPercent(horizontal, vertical)
+        Buffer.scrollToPercent(this.findScrollable(0, vertical == null), horizontal, vertical),
+
+    _scrollByScrollSize: function _scrollByScrollSize(count, direction) {
+        if (count > 0)
+            options["scroll"] = count;
+        this.scrollByScrollSize(direction);
+    },
+
+    /**
+     * Scrolls the buffer vertically 'scroll' lines.
+     *
+     * @param {boolean} direction The direction to scroll. If true then
+     *     scroll up and if false scroll down.
+     * @param {number} count The multiple of 'scroll' lines to scroll.
+     * @optional
+     */
+    scrollByScrollSize: function scrollByScrollSize(direction, count) {
+        direction = direction ? 1 : -1;
+        count = count || 1;
+
+        if (options["scroll"] > 0)
+            this.scrollVertical("lines", options["scroll"] * direction);
+        else
+            this.scrollVertical("pages", direction / 2);
+    },
+
+    /**
+     * Find the best candidate scrollable element for the given
+     * direction and orientation.
+     *
+     * @param {number} dir The direction in which the element must be
+     *   able to scroll. Negative numbers represent up or left, while
+     *   positive numbers represent down or right.
+     * @param {boolean} horizontal If true, look for horizontally
+     *   scrollable elements, otherwise look for vertically scrollable
+     *   elements.
+     */
+    findScrollable: function findScrollable(dir, horizontal) {
+        function find(elem) {
+            while (!(elem instanceof Element) && elem.parentNode)
+                elem = elem.parentNode;
+            for (; elem && elem.parentNode instanceof Element; elem = elem.parentNode)
+                if (Buffer.isScrollable(elem, dir, horizontal))
+                    break;
+            return elem;
+        }
+
+        try {
+            var elem = this.focusedFrame.document.activeElement;
+            if (elem == elem.ownerDocument.body)
+                elem = null;
+        }
+        catch (e) {}
+
+        try {
+            var sel = this.focusedFrame.getSelection();
+        }
+        catch (e) {}
+        if (!elem && sel && sel.rangeCount)
+            elem = sel.getRangeAt(0).startContainer;
+        if (elem)
+            elem = find(elem);
+
+        if (!(elem instanceof Element)) {
+            let doc = this.findScrollableWindow().document;
+            elem = find(doc.body || doc.getElementsByTagName("body")[0] ||
+                        doc.documentElement);
+        }
+        let doc = this.focusedFrame.document;
+        return elem || doc.body || doc.documentElement;
+    },
+
+    /**
+     * Find the best candidate scrollable frame in the current buffer.
+     */
+    findScrollableWindow: function findScrollableWindow() {
+        win = window.document.commandDispatcher.focusedWindow;
+        if (win && (win.scrollMaxX > 0 || win.scrollMaxY > 0))
+            return win;
+
+        let win = this.focusedFrame;
+        if (win && (win.scrollMaxX > 0 || win.scrollMaxY > 0))
+            return win;
+
+        win = content;
+        if (win.scrollMaxX > 0 || win.scrollMaxY > 0)
+            return win;
+
+        for (let frame in array.iterValues(win.frames))
+            if (frame.scrollMaxX > 0 || frame.scrollMaxY > 0)
+                return frame;
+
+        return win;
+    },
+
+    // TODO: allow callback for filtering out unwanted frames? User defined?
+    /**
+     * Shifts the focus to another frame within the buffer. Each buffer
+     * contains at least one frame.
+     *
+     * @param {number} count The number of frames to skip through.  A negative
+     *     count skips backwards.
+     */
+    shiftFrameFocus: function shiftFrameFocus(count) {
+        if (!(content.document instanceof HTMLDocument))
+            return;
+
+        let frames = this.allFrames();
+
+        if (frames.length == 0) // currently top is always included
+            return;
+
+        // remove all hidden frames
+        frames = frames.filter(function (frame) !(frame.document.body instanceof HTMLFrameSetElement))
+                       .filter(function (frame) !frame.frameElement ||
+            let (rect = frame.frameElement.getBoundingClientRect())
+                rect.width && rect.height);
+
+        // find the currently focused frame index
+        let current = Math.max(0, frames.indexOf(this.focusedFrame));
+
+        // calculate the next frame to focus
+        let next = current + count;
+        if (next < 0 || next >= frames.length)
+            dactyl.beep();
+        next = Math.constrain(next, 0, frames.length - 1);
+
+        // focus next frame and scroll into view
+        dactyl.focus(frames[next]);
+        if (frames[next] != content)
+            frames[next].frameElement.scrollIntoView(false);
+
+        // add the frame indicator
+        let doc = frames[next].document;
+        let indicator = util.xmlToDom(<div highlight="FrameIndicator"/>, doc);
+        (doc.body || doc.documentElement || doc).appendChild(indicator);
+
+        util.timeout(function () { doc.body.removeChild(indicator); }, 500);
+
+        // Doesn't unattach
+        //doc.body.setAttributeNS(NS.uri, "activeframe", "true");
+        //util.timeout(function () { doc.body.removeAttributeNS(NS.uri, "activeframe"); }, 500);
+    },
+
+    // similar to pageInfo
+    // TODO: print more useful information, just like the DOM inspector
+    /**
+     * Displays information about the specified element.
+     *
+     * @param {Node} elem The element to query.
+     */
+    showElementInfo: function showElementInfo(elem) {
+        dactyl.echo(<>Element:<br/>{util.objectToString(elem, true)}</>, commandline.FORCE_MULTILINE);
+    },
+
+    /**
+     * Displays information about the current buffer.
+     *
+     * @param {boolean} verbose Display more verbose information.
+     * @param {string} sections A string limiting the displayed sections.
+     * @default The value of 'pageinfo'.
+     */
+    showPageInfo: function showPageInfo(verbose, sections) {
+        // Ctrl-g single line output
+        if (!verbose) {
+            let file = content.location.pathname.split("/").pop() || "[No Name]";
+            let title = content.document.title || "[No Title]";
+
+            let info = template.map("gf",
+                function (opt) template.map(buffer.pageInfo[opt].action(), util.identity, ", "),
+                ", ");
+
+            if (bookmarkcache.isBookmarked(this.URL))
+                info += ", bookmarked";
+
+            let pageInfoText = <>{file.quote()} [{info}] {title}</>;
+            dactyl.echo(pageInfoText, commandline.FORCE_SINGLELINE);
+            return;
+        }
+
+        let list = template.map(sections || options["pageinfo"], function (option) {
+            let { action, title } = buffer.pageInfo[option];
+            return template.table(title, action(true));
+        }, <br/>);
+        dactyl.echo(list, commandline.FORCE_MULTILINE);
+    },
+
+    /**
+     * Stops loading and animations in the current content.
+     */
+    stop: function stop() {
+        if (config.stop)
+            config.stop();
+        else
+            config.browser.mCurrentBrowser.stop();
+    },
+
+    /**
+     * Opens a viewer to inspect the source of the currently selected
+     * range.
+     */
+    viewSelectionSource: function viewSelectionSource() {
+        // copied (and tuned somewhat) from browser.jar -> nsContextMenu.js
+        let win = document.commandDispatcher.focusedWindow;
+        if (win == window)
+            win = this.focusedFrame;
+
+        let charset = win ? "charset=" + win.document.characterSet : null;
+
+        window.openDialog("chrome://global/content/viewPartialSource.xul",
+                          "_blank", "scrollbars,resizable,chrome,dialog=no",
+                          null, charset, win.getSelection(), "selection");
+    },
+
+    /**
+     * Opens a viewer to inspect the source of the current buffer or the
+     * specified *url*. Either the default viewer or the configured external
+     * editor is used.
+     *
+     * @param {string} url The URL of the source.
+     * @default The current buffer.
+     * @param {boolean} useExternalEditor View the source in the external editor.
+     */
+    viewSource: function viewSource(url, useExternalEditor) {
+        let doc = this.focusedFrame.document;
+
+        if (isArray(url)) {
+            if (options.get("editor").has("line"))
+                this.viewSourceExternally(url[0] || doc, url[1]);
+            else
+                window.openDialog("chrome://global/content/viewSource.xul",
+                                  "_blank", "all,dialog=no",
+                                  url[0], null, null, url[1]);
+        }
+        else {
+            if (useExternalEditor)
+                this.viewSourceExternally(url || doc);
+            else {
+                url = url || doc.location.href;
+                const PREFIX = "view-source:";
+                if (url.indexOf(PREFIX) == 0)
+                    url = url.substr(PREFIX.length);
+                else
+                    url = PREFIX + url;
+
+                let sh = history.session;
+                if (sh[sh.index].URI.spec == url)
+                    window.getWebNavigation().gotoIndex(sh.index);
+                else
+                    dactyl.open(url, { hide: true });
+            }
+        }
+    },
+
+    /**
+     * Launches an editor to view the source of the given document. The
+     * contents of the document are saved to a temporary local file and
+     * removed when the editor returns. This function returns
+     * immediately.
+     *
+     * @param {Document} doc The document to view.
+     */
+    viewSourceExternally: Class("viewSourceExternally",
+        XPCOM([Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]), {
+        init: function init(doc, callback) {
+            this.callback = callable(callback) ? callback :
+                function (file, temp) {
+                    editor.editFileExternally({ file: file.path, line: callback },
+                                              function () { temp && file.remove(false); });
+                    return true;
+                };
+
+            let uri = isString(doc) ? util.newURI(doc) : util.newURI(doc.location.href);
+
+            if (!isString(doc))
+                return io.withTempFiles(function (temp) {
+                    let encoder = services.HtmlEncoder();
+                    encoder.init(doc, "text/unicode", encoder.OutputRaw|encoder.OutputPreformatted);
+                    temp.write(encoder.encodeToString(), ">");
+                    return this.callback(temp, true);
+                }, this, true);
+
+            let file = util.getFile(uri);
+            if (file)
+                this.callback(file, false);
+            else {
+                this.file = io.createTempFile();
+                var persist = services.Persist();
+                persist.persistFlags = persist.PERSIST_FLAGS_REPLACE_EXISTING_FILES;
+                persist.progressListener = this;
+                persist.saveURI(uri, null, null, null, null, this.file);
+            }
+            return null;
+        },
+
+        onStateChange: function onStateChange(progress, request, flag, status) {
+            if ((flag & this.STATE_STOP) && status == 0) {
+                try {
+                    var ok = this.callback(this.file, true);
+                }
+                finally {
+                    if (ok !== true)
+                        this.file.remove(false);
+                }
+            }
+            return 0;
+        }
+    }),
+
+    /**
+     * Increases the zoom level of the current buffer.
+     *
+     * @param {number} steps The number of zoom levels to jump.
+     * @param {boolean} fullZoom Whether to use full zoom or text zoom.
+     */
+    zoomIn: function zoomIn(steps, fullZoom) {
+        this.bumpZoomLevel(steps, fullZoom);
+    },
+
+    /**
+     * Decreases the zoom level of the current buffer.
+     *
+     * @param {number} steps The number of zoom levels to jump.
+     * @param {boolean} fullZoom Whether to use full zoom or text zoom.
+     */
+    zoomOut: function zoomOut(steps, fullZoom) {
+        this.bumpZoomLevel(-steps, fullZoom);
+    },
+
+    /**
+     * Adjusts the page zoom of the current buffer to the given absolute
+     * value.
+     *
+     * @param {number} value The new zoom value as a possibly fractional
+     *   percentage of the page's natural size.
+     * @param {boolean} fullZoom If true, zoom all content of the page,
+     *   including raster images. If false, zoom only text. If omitted,
+     *   use the current zoom function. @optional
+     * @throws {FailedAssertion} if the given *value* is not within the
+     *   closed range [Buffer.ZOOM_MIN, Buffer.ZOOM_MAX].
+     */
+    setZoom: function setZoom(value, fullZoom) {
+        dactyl.assert(value >= Buffer.ZOOM_MIN || value <= Buffer.ZOOM_MAX,
+                      _("zoom.outOfRange", Buffer.ZOOM_MIN, Buffer.ZOOM_MAX));
+
+        if (fullZoom !== undefined)
+            ZoomManager.useFullZoom = fullZoom;
+        try {
+            ZoomManager.zoom = value / 100;
+        }
+        catch (e if e == Cr.NS_ERROR_ILLEGAL_VALUE) {
+            return dactyl.echoerr(_("zoom.illegal"));
+        }
+
+        if ("FullZoom" in window)
+            FullZoom._applySettingToPref();
+
+        statusline.updateZoomLevel(value, ZoomManager.useFullZoom);
+    },
+
+    /**
+     * Adjusts the page zoom of the current buffer relative to the
+     * current zoom level.
+     *
+     * @param {number} steps The integral number of natural fractions by
+     *   which to adjust the current page zoom. If positive, the zoom
+     *   level is increased, if negative it is decreased.
+     * @param {boolean} fullZoom If true, zoom all content of the page,
+     *   including raster images. If false, zoom only text. If omitted,
+     *   use the current zoom function. @optional
+     * @throws {FailedAssertion} if the buffer's zoom level is already
+     *  at its extreme in the given direction.
+     */
+    bumpZoomLevel: function bumpZoomLevel(steps, fullZoom) {
+        if (fullZoom === undefined)
+            fullZoom = ZoomManager.useFullZoom;
+
+        let values = ZoomManager.zoomValues;
+        let cur = values.indexOf(ZoomManager.snap(ZoomManager.zoom));
+        let i = Math.constrain(cur + steps, 0, values.length - 1);
+
+        dactyl.assert(i != cur || fullZoom != ZoomManager.useFullZoom);
+
+        this.setZoom(Math.round(values[i] * 100), fullZoom);
+    },
+
+    getAllFrames: deprecated("buffer.allFrames", function getAllFrames() buffer.getAllFrames.apply(buffer, arguments)),
+    scrollTop: deprecated("buffer.scrollToPercent", function scrollTop() buffer.scrollToPercent(null, 0)),
+    scrollBottom: deprecated("buffer.scrollToPercent", function scrollBottom() buffer.scrollToPercent(null, 100)),
+    scrollStart: deprecated("buffer.scrollToPercent", function scrollStart() buffer.scrollToPercent(0, null)),
+    scrollEnd: deprecated("buffer.scrollToPercent", function scrollEnd() buffer.scrollToPercent(100, null)),
+    scrollColumns: deprecated("buffer.scrollHorizontal", function scrollColumns(cols) buffer.scrollHorizontal("columns", cols)),
+    scrollPages: deprecated("buffer.scrollHorizontal", function scrollPages(pages) buffer.scrollVertical("pages", pages)),
+    scrollTo: deprecated("Buffer.scrollTo", function scrollTo(x, y) content.scrollTo(x, y)),
+    textZoom: deprecated("buffer.zoomValue and buffer.fullZoom", function textZoom() config.browser.markupDocumentViewer.textZoom * 100)
+}, {
+    PageInfo: Struct("PageInfo", "name", "title", "action")
+                        .localize("title"),
+
+    ZOOM_MIN: Class.memoize(function () prefs.get("zoom.minPercent")),
+    ZOOM_MAX: Class.memoize(function () prefs.get("zoom.maxPercent")),
+
+    setZoom: deprecated("buffer.setZoom", function setZoom() buffer.setZoom.apply(buffer, arguments)),
+    bumpZoomLevel: deprecated("buffer.bumpZoomLevel", function bumpZoomLevel() buffer.bumpZoomLevel.apply(buffer, arguments)),
+
+    /**
+     * Returns the currently selected word in *win*. If the selection is
+     * null, it tries to guess the word that the caret is positioned in.
+     *
+     * @returns {string}
+     */
+    currentWord: function currentWord(win) {
+        let selection = win.getSelection();
+        if (selection.rangeCount == 0)
+            return "";
+
+        let range = selection.getRangeAt(0).cloneRange();
+        if (range.collapsed) {
+            let re = options.get("iskeyword").regexp;
+            Editor.extendRange(range, true,  re, true);
+            Editor.extendRange(range, false, re, true);
+        }
+        return util.domToString(range);
+    },
+
+    getDefaultNames: function getDefaultNames(node) {
+        let url = node.href || node.src || node.documentURI;
+        let currExt = url.replace(/^.*?(?:\.([a-z0-9]+))?$/i, "$1").toLowerCase();
+
+        if (isinstance(node, [Document, HTMLImageElement])) {
+            let type = node.contentType || node.QueryInterface(Ci.nsIImageLoadingContent)
+                                               .getRequest(0).mimeType;
+
+            if (type === "text/plain")
+                var ext = "." + (currExt || "txt");
+            else
+                ext = "." + services.mime.getPrimaryExtension(type, currExt);
+        }
+        else if (currExt)
+            ext = "." + currExt;
+        else
+            ext = "";
+        let re = ext ? RegExp("(\\." + currExt + ")?$", "i") : /$/;
+
+        var names = [];
+        if (node.title)
+            names.push([node.title, "Page Name"]);
+
+        if (node.alt)
+            names.push([node.alt, "Alternate Text"]);
+
+        if (!isinstance(node, Document) && node.textContent)
+            names.push([node.textContent, "Link Text"]);
+
+        names.push([decodeURIComponent(url.replace(/.*?([^\/]*)\/*$/, "$1")), "File Name"]);
+
+        return names.filter(function ([leaf, title]) leaf)
+                    .map(function ([leaf, title]) [leaf.replace(util.OS.illegalCharacters, encodeURIComponent)
+                                                       .replace(re, ext), title]);
+    },
+
+    findScrollableWindow: deprecated("buffer.findScrollableWindow", function findScrollableWindow() buffer.findScrollableWindow.apply(buffer, arguments)),
+    findScrollable: deprecated("buffer.findScrollable", function findScrollable() buffer.findScrollable.apply(buffer, arguments)),
+
+    isScrollable: function isScrollable(elem, dir, horizontal) {
+        let pos = "scrollTop", size = "clientHeight", max = "scrollHeight", layoutSize = "offsetHeight",
+            overflow = "overflowX", border1 = "borderTopWidth", border2 = "borderBottomWidth";
+        if (horizontal)
+            pos = "scrollLeft", size = "clientWidth", max = "scrollWidth", layoutSize = "offsetWidth",
+            overflow = "overflowX", border1 = "borderLeftWidth", border2 = "borderRightWidth";
+
+        let style = util.computedStyle(elem);
+        let borderSize = Math.round(parseFloat(style[border1]) + parseFloat(style[border2]));
+        let realSize = elem[size];
+        // Stupid Gecko eccentricities. May fail for quirks mode documents.
+        if (elem[size] + borderSize == elem[max] || elem[size] == 0) // Stupid, fallible heuristic.
+            return false;
+        if (style[overflow] == "hidden")
+            realSize += borderSize;
+        return dir < 0 && elem[pos] > 0 || dir > 0 && elem[pos] + realSize < elem[max] || !dir && realSize < elem[max];
+    },
+
+    /**
+     * Scroll the contents of the given element to the absolute *left*
+     * and *top* pixel offsets.
+     *
+     * @param {Element} elem The element to scroll.
+     * @param {number|null} left The left absolute pixel offset. If
+     *   null, to not alter the horizontal scroll offset.
+     * @param {number|null} top The top absolute pixel offset. If
+     *   null, to not alter the vertical scroll offset.
+     */
+    scrollTo: function scrollTo(elem, left, top) {
+        // Temporary hack. Should be done better.
+        if (elem.ownerDocument == buffer.focusedFrame.document)
+            marks.add("'");
+        if (left != null)
+            elem.scrollLeft = left;
+        if (top != null)
+            elem.scrollTop = top;
+    },
+
+    /**
+     * Scrolls the currently given element horizontally.
+     *
+     * @param {Element} elem The element to scroll.
+     * @param {string} increment The increment by which to scroll.
+     *   Possible values are: "columns", "pages"
+     * @param {number} number The possibly fractional number of
+     *   increments to scroll. Positive values scroll to the right while
+     *   negative values scroll to the left.
+     * @throws {FailedAssertion} if scrolling is not possible in the
+     *   given direction.
+     */
+    scrollHorizontal: function scrollHorizontal(elem, increment, number) {
+        let fontSize = parseInt(util.computedStyle(elem).fontSize);
+        if (increment == "columns")
+            increment = fontSize; // Good enough, I suppose.
+        else if (increment == "pages")
+            increment = elem.clientWidth - fontSize;
+        else
+            throw Error();
+
+        let left = elem.dactylScrollDestX !== undefined ? elem.dactylScrollDestX : elem.scrollLeft;
+        elem.dactylScrollDestX = undefined;
+
+        dactyl.assert(number < 0 ? left > 0 : left < elem.scrollWidth - elem.clientWidth);
+        Buffer.scrollTo(elem, left + number * increment, null);
+    },
+
+    /**
+     * Scrolls the currently given element vertically.
+     *
+     * @param {Element} elem The element to scroll.
+     * @param {string} increment The increment by which to scroll.
+     *   Possible values are: "lines", "pages"
+     * @param {number} number The possibly fractional number of
+     *   increments to scroll. Positive values scroll upward while
+     *   negative values scroll downward.
+     * @throws {FailedAssertion} if scrolling is not possible in the
+     *   given direction.
+     */
+    scrollVertical: function scrollVertical(elem, increment, number) {
+        let fontSize = parseInt(util.computedStyle(elem).fontSize);
+        if (increment == "lines")
+            increment = fontSize;
+        else if (increment == "pages")
+            increment = elem.clientHeight - fontSize;
+        else
+            throw Error();
+
+        let top = elem.dactylScrollDestY !== undefined ? elem.dactylScrollDestY : elem.scrollTop;
+        elem.dactylScrollDestY = undefined;
+
+        dactyl.assert(number < 0 ? top > 0 : top < elem.scrollHeight - elem.clientHeight);
+        Buffer.scrollTo(elem, null, top + number * increment);
+    },
+
+    /**
+     * Scrolls the currently active element to the given horizontal and
+     * vertical percentages.
+     *
+     * @param {Element} elem The element to scroll.
+     * @param {number|null} horizontal The possibly fractional
+     *   percentage of the current viewport width to scroll to. If null,
+     *   do not scroll horizontally.
+     * @param {number|null} vertical The possibly fractional percentage
+     *   of the current viewport height to scroll to. If null, do not
+     *   scroll vertically.
+     */
+    scrollToPercent: function scrollToPercent(elem, horizontal, vertical) {
+        Buffer.scrollTo(elem,
+                        horizontal == null ? null
+                                           : (elem.scrollWidth - elem.clientWidth) * (horizontal / 100),
+                        vertical   == null ? null
+                                           : (elem.scrollHeight - elem.clientHeight) * (vertical / 100));
+    },
+
+    openUploadPrompt: function openUploadPrompt(elem) {
+        io.CommandFileMode("Upload file: ", {
+            onSubmit: function onSubmit(path) {
+                let file = io.File(path);
+                dactyl.assert(file.exists());
+
+                elem.value = file.path;
+                events.dispatch(elem, events.create(elem.ownerDocument, "change", {}));
+            }
+        }).open(elem.value);
+    }
+}, {
+    commands: function initCommands(dactyl, modules, window) {
+        commands.add(["frameo[nly]"],
+            "Show only the current frame's page",
+            function (args) {
+                dactyl.open(buffer.focusedFrame.location.href);
+            },
+            { argCount: "0" });
+
+        commands.add(["ha[rdcopy]"],
+            "Print current document",
+            function (args) {
+                let arg = args[0];
+
+                // FIXME: arg handling is a bit of a mess, check for filename
+                dactyl.assert(!arg || arg[0] == ">" && !util.OS.isWindows,
+                              _("error.trailing"));
+
+                prefs.withContext(function () {
+                    if (arg) {
+                        prefs.set("print.print_to_file", "true");
+                        prefs.set("print.print_to_filename", io.File(arg.substr(1)).path);
+                        dactyl.echomsg(_("print.toFile", arg.substr(1)));
+                    }
+                    else
+                        dactyl.echomsg(_("print.sending"));
+
+                    prefs.set("print.always_print_silent", args.bang);
+                    prefs.set("print.show_print_progress", !args.bang);
+
+                    config.browser.contentWindow.print();
+                });
+
+                if (arg)
+                    dactyl.echomsg(_("print.printed", arg.substr(1)));
+                else
+                    dactyl.echomsg(_("print.sent"));
+            },
+            {
+                argCount: "?",
+                bang: true,
+                literal: 0
+            });
+
+        commands.add(["pa[geinfo]"],
+            "Show various page information",
+            function (args) {
+                let arg = args[0];
+                let opt = options.get("pageinfo");
+
+                dactyl.assert(!arg || opt.validator(opt.parse(arg)),
+                              _("error.invalidArgument", arg));
+                buffer.showPageInfo(true, arg);
+            },
+            {
+                argCount: "?",
+                completer: function (context) {
+                    completion.optionValue(context, "pageinfo", "+", "");
+                    context.title = ["Page Info"];
+                }
+            });
+
+        commands.add(["pagest[yle]", "pas"],
+            "Select the author style sheet to apply",
+            function (args) {
+                let arg = args[0] || "";
+
+                let titles = buffer.alternateStyleSheets.map(function (stylesheet) stylesheet.title);
+
+                dactyl.assert(!arg || titles.indexOf(arg) >= 0,
+                              _("error.invalidArgument", arg));
+
+                if (options["usermode"])
+                    options["usermode"] = false;
+
+                window.stylesheetSwitchAll(buffer.focusedFrame, arg);
+            },
+            {
+                argCount: "?",
+                completer: function (context) completion.alternateStyleSheet(context),
+                literal: 0
+            });
+
+        commands.add(["re[load]"],
+            "Reload the current web page",
+            function (args) { tabs.reload(config.browser.mCurrentTab, args.bang); },
+            {
+                argCount: "0",
+                bang: true
+            });
+
+        // TODO: we're prompted if download.useDownloadDir isn't set and no arg specified - intentional?
+        commands.add(["sav[eas]", "w[rite]"],
+            "Save current document to disk",
+            function (args) {
+                let doc = content.document;
+                let chosenData = null;
+                let filename = args[0];
+
+                let command = commandline.command;
+                if (filename) {
+                    if (filename[0] == "!")
+                        return buffer.viewSourceExternally(buffer.focusedFrame.document,
+                            function (file) {
+                                let output = io.system(filename.substr(1), file);
+                                commandline.command = command;
+                                commandline.commandOutput(<span highlight="CmdOutput">{output}</span>);
+                            });
+
+                    if (/^>>/.test(filename)) {
+                        let file = io.File(filename.replace(/^>>\s*/, ""));
+                        dactyl.assert(args.bang || file.exists() && file.isWritable(),
+                                      _("io.notWriteable", file.path.quote()));
+                        return buffer.viewSourceExternally(buffer.focusedFrame.document,
+                            function (tmpFile) {
+                                try {
+                                    file.write(tmpFile, ">>");
+                                }
+                                catch (e) {
+                                    dactyl.echoerr(_("io.notWriteable", file.path.quote()));
+                                }
+                            });
+                    }
+
+                    let file = io.File(filename.replace(RegExp(File.PATH_SEP + "*$"), ""));
+
+                    if (filename.substr(-1) === File.PATH_SEP || file.exists() && file.isDirectory())
+                        file.append(Buffer.getDefaultNames(doc)[0][0]);
+
+                    dactyl.assert(args.bang || !file.exists(), _("io.exists"));
+
+                    chosenData = { file: file, uri: util.newURI(doc.location.href) };
+                }
+
+                // if browser.download.useDownloadDir = false then the "Save As"
+                // dialog is used with this as the default directory
+                // TODO: if we're going to do this shouldn't it be done in setCWD or the value restored?
+                prefs.set("browser.download.lastDir", io.cwd.path);
+
+                try {
+                    var contentDisposition = content.QueryInterface(Ci.nsIInterfaceRequestor)
+                                                    .getInterface(Ci.nsIDOMWindowUtils)
+                                                    .getDocumentMetadata("content-disposition");
+                }
+                catch (e) {}
+
+                window.internalSave(doc.location.href, doc, null, contentDisposition,
+                                    doc.contentType, false, null, chosenData,
+                                    doc.referrer ? window.makeURI(doc.referrer) : null,
+                                    true);
+            },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function (context) {
+                    if (context.filter[0] == "!")
+                        return;
+                    if (/^>>/.test(context.filter))
+                        context.advance(/^>>\s*/.exec(context.filter)[0].length);
+
+                    completion.savePage(context, content.document);
+                    context.fork("file", 0, completion, "file");
+                },
+                literal: 0
+            });
+
+        commands.add(["st[op]"],
+            "Stop loading the current web page",
+            function () { buffer.stop(); },
+            { argCount: "0" });
+
+        commands.add(["vie[wsource]"],
+            "View source code of current document",
+            function (args) { buffer.viewSource(args[0], args.bang); },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function (context) completion.url(context, "bhf")
+            });
+
+        commands.add(["zo[om]"],
+            "Set zoom value of current web page",
+            function (args) {
+                let arg = args[0];
+                let level;
+
+                if (!arg)
+                    level = 100;
+                else if (/^\d+$/.test(arg))
+                    level = parseInt(arg, 10);
+                else if (/^[+-]\d+$/.test(arg)) {
+                    level = Math.round(buffer.zoomLevel + parseInt(arg, 10));
+                    level = Math.constrain(level, Buffer.ZOOM_MIN, Buffer.ZOOM_MAX);
+                }
+                else
+                    dactyl.assert(false, _("error.trailing"));
+
+                buffer.setZoom(level, args.bang);
+            },
+            {
+                argCount: "?",
+                bang: true
+            });
+    },
+    completion: function initCompletion(dactyl, modules, window) {
+        completion.alternateStyleSheet = function alternateStylesheet(context) {
+            context.title = ["Stylesheet", "Location"];
+
+            // unify split style sheets
+            let styles = iter([s.title, []] for (s in values(buffer.alternateStyleSheets))).toObject();
+
+            buffer.alternateStyleSheets.forEach(function (style) {
+                styles[style.title].push(style.href || "inline");
+            });
+
+            context.completions = [[title, href.join(", ")] for ([title, href] in Iterator(styles))];
+        };
+
+        completion.buffer = function buffer(context) {
+            let filter = context.filter.toLowerCase();
+            let defItem = { parent: { getTitle: function () "" } };
+            let tabGroups = {};
+            tabs.getGroups();
+            tabs.allTabs.forEach(function (tab, i) {
+                let group = (tab.tabItem || tab._tabViewTabItem || defItem).parent || defItem.parent;
+                if (!set.has(tabGroups, group.id))
+                    tabGroups[group.id] = [group.getTitle(), []];
+                group = tabGroups[group.id];
+                group[1].push([i, tab.linkedBrowser]);
+            });
+
+            context.pushProcessor(0, function (item, text, next) <>
+                <span highlight="Indicator" style="display: inline-block;">{item.indicator}</span>
+                { next.call(this, item, text) }
+            </>);
+            context.process[1] = function (item, text) template.bookmarkDescription(item, template.highlightFilter(text, this.filter));
+
+            context.anchored = false;
+            context.keys = {
+                text: "text",
+                description: "url",
+                indicator: function (item) item.tab === tabs.getTab()  ? "%" :
+                                           item.tab === tabs.alternate ? "#" : " ",
+                icon: "icon",
+                id: "id",
+                command: function () "tabs.select"
+            };
+            context.compare = CompletionContext.Sort.number;
+            context.filters = [CompletionContext.Filter.textDescription];
+
+            for (let [id, vals] in Iterator(tabGroups))
+                context.fork(id, 0, this, function (context, [name, browsers]) {
+                    context.title = [name || "Buffers"];
+                    context.generate = function ()
+                        Array.map(browsers, function ([i, browser]) {
+                            let indicator = " ";
+                            if (i == tabs.index())
+                                indicator = "%";
+                            else if (i == tabs.index(tabs.alternate))
+                                indicator = "#";
+
+                            let tab = tabs.getTab(i);
+                            let url = browser.contentDocument.location.href;
+                            i = i + 1;
+
+                            return {
+                                text: [i + ": " + (tab.label || "(Untitled)"), i + ": " + url],
+                                tab: tab,
+                                id: i - 1,
+                                url: url,
+                                icon: tab.image || DEFAULT_FAVICON
+                            };
+                        });
+                }, vals);
+        };
+
+        completion.savePage = function savePage(context, node) {
+            context.fork("generated", context.filter.replace(/[^/]*$/, "").length,
+                         this, function (context) {
+                context.completions = Buffer.getDefaultNames(node);
+            });
+        };
+    },
+    events: function initEvents(dactyl, modules, window) {
+        events.listen(config.browser, "scroll", buffer.closure._updateBufferPosition, false);
+    },
+    mappings: function initMappings(dactyl, modules, window) {
+        mappings.add([modes.NORMAL],
+            ["y", "<yank-location>"], "Yank current location to the clipboard",
+            function () { dactyl.clipboardWrite(buffer.uri.spec, true); });
+
+        mappings.add([modes.NORMAL],
+            ["<C-a>"], "Increment last number in URL",
+            function (args) { buffer.incrementURL(Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.NORMAL],
+            ["<C-x>"], "Decrement last number in URL",
+            function (args) { buffer.incrementURL(-Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.NORMAL], ["gu"],
+            "Go to parent directory",
+            function (args) { buffer.climbUrlPath(Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.NORMAL], ["gU"],
+            "Go to the root of the website",
+            function () { buffer.climbUrlPath(-1); });
+
+        mappings.add([modes.COMMAND], [".", "<repeat-key>"],
+            "Repeat the last key event",
+            function (args) {
+                if (mappings.repeat) {
+                    for (let i in util.interruptibleRange(0, Math.max(args.count, 1), 100))
+                        mappings.repeat();
+                }
+            },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["i", "<Insert>"],
+            "Start caret mode",
+            function () { modes.push(modes.CARET); });
+
+        mappings.add([modes.COMMAND], ["<C-c>"],
+            "Stop loading the current web page",
+            function () { ex.stop(); });
+
+        // scrolling
+        mappings.add([modes.COMMAND], ["j", "<Down>", "<C-e>", "<scroll-down-line>"],
+            "Scroll document down",
+            function (args) { buffer.scrollVertical("lines", Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["k", "<Up>", "<C-y>", "<scroll-up-line>"],
+            "Scroll document up",
+            function (args) { buffer.scrollVertical("lines", -Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], dactyl.has("mail") ? ["h", "<scroll-left-column>"] : ["h", "<Left>", "<scroll-left-column>"],
+            "Scroll document to the left",
+            function (args) { buffer.scrollHorizontal("columns", -Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], dactyl.has("mail") ? ["l", "<scroll-right-column>"] : ["l", "<Right>", "<scroll-right-column>"],
+            "Scroll document to the right",
+            function (args) { buffer.scrollHorizontal("columns", Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["0", "^", "<scroll-begin>"],
+            "Scroll to the absolute left of the document",
+            function () { buffer.scrollToPercent(0, null); });
+
+        mappings.add([modes.COMMAND], ["$", "<scroll-end>"],
+            "Scroll to the absolute right of the document",
+            function () { buffer.scrollToPercent(100, null); });
+
+        mappings.add([modes.COMMAND], ["gg", "<Home>"],
+            "Go to the top of the document",
+            function (args) { buffer.scrollToPercent(null, args.count != null ? args.count : 0); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["G", "<End>"],
+            "Go to the end of the document",
+            function (args) { buffer.scrollToPercent(null, args.count != null ? args.count : 100); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["%", "<scroll-percent>"],
+            "Scroll to {count} percent of the document",
+            function (args) {
+                dactyl.assert(args.count > 0 && args.count <= 100);
+                buffer.scrollToPercent(null, args.count);
+            },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["<C-d>", "<scroll-down>"],
+            "Scroll window downwards in the buffer",
+            function (args) { buffer._scrollByScrollSize(args.count, true); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["<C-u>", "<scroll-up>"],
+            "Scroll window upwards in the buffer",
+            function (args) { buffer._scrollByScrollSize(args.count, false); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["<C-b>", "<PageUp>", "<S-Space>", "<scroll-page-up>"],
+            "Scroll up a full page",
+            function (args) { buffer.scrollVertical("pages", -Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["<C-f>", "<PageDown>", "<Space>", "<scroll-page-down>"],
+            "Scroll down a full page",
+            function (args) { buffer.scrollVertical("pages", Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["]f", "<previous-frame>"],
+            "Focus next frame",
+            function (args) { buffer.shiftFrameFocus(Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["[f", "<next-frame>"],
+            "Focus previous frame",
+            function (args) { buffer.shiftFrameFocus(-Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["]]", "<next-page>"],
+            "Follow the link labeled 'next' or '>' if it exists",
+            function (args) {
+                buffer.findLink("next", options["nextpattern"], (args.count || 1) - 1, true);
+            },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["[[", "<previous-page>"],
+            "Follow the link labeled 'prev', 'previous' or '<' if it exists",
+            function (args) {
+                buffer.findLink("previous", options["previouspattern"], (args.count || 1) - 1, true);
+            },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["gf", "<view-source>"],
+            "Toggle between rendered and source view",
+            function () { buffer.viewSource(null, false); });
+
+        mappings.add([modes.COMMAND], ["gF", "<view-source-externally>"],
+            "View source with an external editor",
+            function () { buffer.viewSource(null, true); });
+
+        mappings.add([modes.COMMAND], ["gi", "<focus-input>"],
+            "Focus last used input field",
+            function (args) {
+                let elem = buffer.lastInputField;
+
+                if (args.count >= 1 || !elem || !events.isContentNode(elem)) {
+                    let xpath = ["frame", "iframe", "input", "textarea[not(@disabled) and not(@readonly)]"];
+
+                    let frames = buffer.allFrames(null, true);
+
+                    let elements = array.flatten(frames.map(function (win) [m for (m in util.evaluateXPath(xpath, win.document))]))
+                                        .filter(function (elem) {
+                        if (isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]))
+                            return Editor.getEditor(elem.contentWindow);
+
+                        if (elem.readOnly || elem instanceof HTMLInputElement && !set.has(util.editableInputs, elem.type))
+                            return false;
+
+                        let computedStyle = util.computedStyle(elem);
+                        let rect = elem.getBoundingClientRect();
+                        return computedStyle.visibility != "hidden" && computedStyle.display != "none" &&
+                            computedStyle.MozUserFocus != "ignore" && rect.width && rect.height;
+                    });
+
+                    dactyl.assert(elements.length > 0);
+                    elem = elements[Math.constrain(args.count, 1, elements.length) - 1];
+                }
+                buffer.focusElement(elem);
+                util.scrollIntoView(elem);
+            },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["gP"],
+            "Open (]put) a URL based on the current clipboard contents in a new buffer",
+            function () {
+                let url = dactyl.clipboardRead();
+                dactyl.assert(url, _("error.clipboardEmpty"));
+                dactyl.open(url, { from: "paste", where: dactyl.NEW_TAB, background: true });
+            });
+
+        mappings.add([modes.COMMAND], ["p", "<MiddleMouse>", "<open-clipboard-url>"],
+            "Open (put) a URL based on the current clipboard contents in the current buffer",
+            function () {
+                let url = dactyl.clipboardRead();
+                dactyl.assert(url, _("error.clipboardEmpty"));
+                dactyl.open(url);
+            });
+
+        mappings.add([modes.COMMAND], ["P", "<tab-open-clipboard-url>"],
+            "Open (put) a URL based on the current clipboard contents in a new buffer",
+            function () {
+                let url = dactyl.clipboardRead();
+                dactyl.assert(url, _("error.clipboardEmpty"));
+                dactyl.open(url, { from: "paste", where: dactyl.NEW_TAB });
+            });
+
+        // reloading
+        mappings.add([modes.COMMAND], ["r", "<reload>"],
+            "Reload the current web page",
+            function () { tabs.reload(tabs.getTab(), false); });
+
+        mappings.add([modes.COMMAND], ["R", "<full-reload>"],
+            "Reload while skipping the cache",
+            function () { tabs.reload(tabs.getTab(), true); });
+
+        // yanking
+        mappings.add([modes.COMMAND], ["Y", "<yank-word>"],
+            "Copy selected text or current word",
+            function () {
+                let sel = buffer.currentWord;
+                dactyl.assert(sel);
+                dactyl.clipboardWrite(sel, true);
+            });
+
+        // zooming
+        mappings.add([modes.COMMAND], ["zi", "+", "<text-zoom-in>"],
+            "Enlarge text zoom of current web page",
+            function (args) { buffer.zoomIn(Math.max(args.count, 1), false); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["zm", "<text-zoom-more>"],
+            "Enlarge text zoom of current web page by a larger amount",
+            function (args) { buffer.zoomIn(Math.max(args.count, 1) * 3, false); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["zo", "-", "<text-zoom-out>"],
+            "Reduce text zoom of current web page",
+            function (args) { buffer.zoomOut(Math.max(args.count, 1), false); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["zr", "<text-zoom-reduce>"],
+            "Reduce text zoom of current web page by a larger amount",
+            function (args) { buffer.zoomOut(Math.max(args.count, 1) * 3, false); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["zz", "<text-zoom>"],
+            "Set text zoom value of current web page",
+            function (args) { buffer.setZoom(args.count > 1 ? args.count : 100, false); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["ZI", "zI", "<full-zoom-in>"],
+            "Enlarge full zoom of current web page",
+            function (args) { buffer.zoomIn(Math.max(args.count, 1), true); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["ZM", "zM", "<full-zoom-more>"],
+            "Enlarge full zoom of current web page by a larger amount",
+            function (args) { buffer.zoomIn(Math.max(args.count, 1) * 3, true); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["ZO", "zO", "<full-zoom-out>"],
+            "Reduce full zoom of current web page",
+            function (args) { buffer.zoomOut(Math.max(args.count, 1), true); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["ZR", "zR", "<full-zoom-reduce>"],
+            "Reduce full zoom of current web page by a larger amount",
+            function (args) { buffer.zoomOut(Math.max(args.count, 1) * 3, true); },
+            { count: true });
+
+        mappings.add([modes.COMMAND], ["zZ", "<full-zoom>"],
+            "Set full zoom value of current web page",
+            function (args) { buffer.setZoom(args.count > 1 ? args.count : 100, true); },
+            { count: true });
+
+        // page info
+        mappings.add([modes.COMMAND], ["<C-g>", "<page-info>"],
+            "Print the current file name",
+            function () { buffer.showPageInfo(false); });
+
+        mappings.add([modes.COMMAND], ["g<C-g>", "<more-page-info>"],
+            "Print file information",
+            function () { buffer.showPageInfo(true); });
+    },
+    options: function initOptions(dactyl, modules, window) {
+        options.add(["encoding", "enc"],
+            "The current buffer's character encoding",
+            "string", "UTF-8",
+            {
+                scope: Option.SCOPE_LOCAL,
+                getter: function () config.browser.docShell.QueryInterface(Ci.nsIDocCharset).charset,
+                setter: function (val) {
+                    if (options["encoding"] == val)
+                        return val;
+
+                    // Stolen from browser.jar/content/browser/browser.js, more or less.
+                    try {
+                        config.browser.docShell.QueryInterface(Ci.nsIDocCharset).charset = val;
+                        PlacesUtils.history.setCharsetForURI(getWebNavigation().currentURI, val);
+                        getWebNavigation().reload(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE);
+                    }
+                    catch (e) { dactyl.reportError(e); }
+                    return null;
+                },
+                completer: function (context) completion.charset(context)
+            });
+
+        options.add(["iskeyword", "isk"],
+            "Regular expression defining which characters constitute word characters",
+            "string", '[^\\s.,!?:;/"\'^$%&?()[\\]{}<>#*+|=~_-]',
+            {
+                setter: function (value) {
+                    this.regexp = util.regexp(value);
+                    return value;
+                },
+                validator: function (value) RegExp(value)
+            });
+
+        options.add(["nextpattern"],
+            "Patterns to use when guessing the next page in a document sequence",
+            "regexplist", UTF8("'\\bnext\\b',^>$,^(>>|»)$,^(>|»),(>|»)$,'\\bmore\\b'"),
+            { regexpFlags: "i" });
+
+        options.add(["previouspattern"],
+            "Patterns to use when guessing the previous page in a document sequence",
+            "regexplist", UTF8("'\\bprev|previous\\b',^<$,^(<<|«)$,^(<|«),(<|«)$"),
+            { regexpFlags: "i" });
+
+        options.add(["pageinfo", "pa"],
+            "Define which sections are shown by the :pageinfo command",
+            "charlist", "gfm",
+            { get values() values(buffer.pageInfo).toObject() });
+
+        options.add(["scroll", "scr"],
+            "Number of lines to scroll with <C-u> and <C-d> commands",
+            "number", 0,
+            { validator: function (value) value >= 0 });
+
+        options.add(["showstatuslinks", "ssli"],
+            "Where to show the destination of the link under the cursor",
+            "string", "status",
+            {
+                values: {
+                    "": "Don't show link destinations",
+                    "status": "Show link destinations in the status line",
+                    "command": "Show link destinations in the command line"
+                }
+            });
+
+        options.add(["usermode", "um"],
+            "Show current website without styling defined by the author",
+            "boolean", false,
+            {
+                setter: function (value) config.browser.markupDocumentViewer.authorStyleDisabled = value,
+                getter: function () config.browser.markupDocumentViewer.authorStyleDisabled
+            });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/buffer.xhtml b/common/content/buffer.xhtml
new file mode 100644 (file)
index 0000000..6e05b7e
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
+    "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" id="dactyl-buffer">
+  <head>
+      <title/>
+  </head>
+  <body/>
+</html>
diff --git a/common/content/commandline.js b/common/content/commandline.js
new file mode 100644 (file)
index 0000000..69e6a3c
--- /dev/null
@@ -0,0 +1,1818 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+var CommandWidgets = Class("CommandWidgets", {
+    depends: ["statusline"],
+
+    init: function init() {
+        let s = "dactyl-statusline-field-";
+
+        XML.ignoreWhitespace = true;
+        util.overlayWindow(window, {
+            objects: {
+                eventTarget: commandline
+            },
+            append: <e4x xmlns={XUL} xmlns:dactyl={NS}>
+                <vbox id={config.commandContainer}>
+                    <vbox class="dactyl-container" hidden="false" collapsed="true">
+                        <iframe class="dactyl-completions" id="dactyl-completions-dactyl-commandline" src="dactyl://content/buffer.xhtml"
+                                contextmenu="dactyl-contextmenu"
+                                flex="1" hidden="false" collapsed="false"
+                                highlight="Events" events="mowEvents" />
+                    </vbox>
+
+                    <stack orient="horizontal" align="stretch" class="dactyl-container" id="dactyl-container" highlight="CmdLine CmdCmdLine">
+                        <textbox class="plain" id="dactyl-strut"   flex="1" crop="end" collapsed="true"/>
+                        <textbox class="plain" id="dactyl-mode"    flex="1" crop="end"/>
+                        <textbox class="plain" id="dactyl-message" flex="1" readonly="true"/>
+
+                        <hbox id="dactyl-commandline" hidden="false" class="dactyl-container" highlight="Normal CmdNormal" collapsed="true">
+                            <label   id="dactyl-commandline-prompt"  class="dactyl-commandline-prompt  plain" flex="0" crop="end" value="" collapsed="true"/>
+                            <textbox id="dactyl-commandline-command" class="dactyl-commandline-command plain" flex="1" type="input" timeout="100"
+                                     highlight="Events" />
+                        </hbox>
+                    </stack>
+
+                    <vbox class="dactyl-container" hidden="false" collapsed="false" highlight="CmdLine">
+                        <textbox id="dactyl-multiline-input" class="plain" flex="1" rows="1" hidden="false" collapsed="true" multiline="true"
+                                 highlight="Normal Events" events="multilineInputEvents" />
+                    </vbox>
+                </vbox>
+
+                <stack id="dactyl-statusline-stack">
+                    <hbox id={s + "commandline"} hidden="false" class="dactyl-container" highlight="Normal StatusNormal" collapsed="true">
+                        <label id={s + "commandline-prompt"}    class="dactyl-commandline-prompt  plain" flex="0" crop="end" value="" collapsed="true"/>
+                        <textbox id={s + "commandline-command"} class="dactyl-commandline-command plain" flex="1" type="text" timeout="100"
+                                 highlight="Events" />
+                    </hbox>
+                </stack>
+            </e4x>.elements(),
+
+            before: <e4x xmlns={XUL} xmlns:dactyl={NS}>
+                <toolbar id={statusline.statusBar.id}>
+                    <vbox id={"dactyl-completions-" + s + "commandline-container"} class="dactyl-container" hidden="false" collapsed="true">
+                        <iframe class="dactyl-completions" id={"dactyl-completions-" + s + "commandline"} src="dactyl://content/buffer.xhtml"
+                                contextmenu="dactyl-contextmenu" flex="1" hidden="false" collapsed="false"
+                                highlight="Events" events="mowEvents" />
+                    </vbox>
+                </toolbar>
+            </e4x>.elements()
+        });
+
+        this.elements = {};
+
+        this.addElement({
+            name: "container",
+            noValue: true
+        });
+
+        this.addElement({
+            name: "commandline",
+            getGroup: function () options.get("guioptions").has("C") ? this.commandbar : this.statusbar,
+            getValue: function () this.command
+        });
+
+        this.addElement({
+            name: "strut",
+            defaultGroup: "Normal",
+            getGroup: function () this.commandbar,
+            getValue: function () options.get("guioptions").has("c")
+        });
+
+        this.addElement({
+            name: "command",
+            test: function test(stack, prev) stack.pop && !isinstance(prev.main, modes.COMMAND_LINE),
+            id: "commandline-command",
+            get: function command_get(elem) {
+                // The long path is because of complications with the
+                // completion preview.
+                try {
+                    return elem.inputField.editor.rootElement.firstChild.textContent;
+                }
+                catch (e) {
+                    return elem.value;
+                }
+            },
+            getElement: CommandWidgets.getEditor,
+            getGroup: function (value) this.activeGroup.commandline,
+            onChange: function command_onChange(elem, value) {
+                if (elem.inputField != dactyl.focusedElement)
+                    try {
+                        elem.selectionStart = elem.value.length;
+                        elem.selectionEnd = elem.value.length;
+                    }
+                    catch (e) {}
+
+                if (!elem.collapsed)
+                    dactyl.focus(elem);
+            },
+            onVisibility: function command_onVisibility(elem, visible) {
+                if (visible)
+                    dactyl.focus(elem);
+            }
+        });
+
+        this.addElement({
+            name: "prompt",
+            id: "commandline-prompt",
+            defaultGroup: "CmdPrompt",
+            getGroup: function () this.activeGroup.commandline
+        });
+
+        this.addElement({
+            name: "message",
+            defaultGroup: "Normal",
+            getElement: CommandWidgets.getEditor,
+            getGroup: function (value) {
+                if (this.command && !options.get("guioptions").has("M"))
+                    return this.statusbar;
+
+                let statusElem = this.statusbar.message;
+                if (value && !value[2] && statusElem.editor && statusElem.editor.rootElement.scrollWidth > statusElem.scrollWidth)
+                    return this.commandbar;
+                return this.activeGroup.mode;
+            }
+        });
+
+        this.addElement({
+            name: "mode",
+            defaultGroup: "ModeMsg",
+            getGroup: function (value) {
+                if (!options.get("guioptions").has("M"))
+                    if (this.commandbar.container.clientHeight == 0 ||
+                            value && !this.commandbar.commandline.collapsed)
+                        return this.statusbar;
+                return this.commandbar;
+            }
+        });
+    },
+    addElement: function addElement(obj) {
+        const self = this;
+        this.elements[obj.name] = obj;
+
+        function get(prefix, map, id) (obj.getElement || util.identity)(map[id] || document.getElementById(prefix + id));
+
+        this.active.__defineGetter__(obj.name, function () self.activeGroup[obj.name][obj.name]);
+        this.activeGroup.__defineGetter__(obj.name, function () self.getGroup(obj.name));
+
+        memoize(this.statusbar, obj.name, function () get("dactyl-statusline-field-", statusline.widgets, (obj.id || obj.name)));
+        memoize(this.commandbar, obj.name, function () get("dactyl-", {}, (obj.id || obj.name)));
+
+        if (!(obj.noValue || obj.getValue)) {
+            Object.defineProperty(this, obj.name, Modes.boundProperty({
+                test: obj.test,
+
+                get: function get_widgetValue() {
+                    let elem = self.getGroup(obj.name, obj.value)[obj.name];
+                    if (obj.value != null)
+                        return [obj.value[0],
+                                obj.get ? obj.get.call(this, elem) : elem.value]
+                                .concat(obj.value.slice(2))
+                    return null;
+                },
+
+                set: function set_widgetValue(val) {
+                    if (val != null && !isArray(val))
+                        val = [obj.defaultGroup || "", val];
+                    obj.value = val;
+
+                    [this.commandbar, this.statusbar].forEach(function (nodeSet) {
+                        let elem = nodeSet[obj.name];
+                        if (val == null)
+                            elem.value = "";
+                        else {
+                            highlight.highlightNode(elem,
+                                (val[0] != null ? val[0] : obj.defaultGroup)
+                                    .split(/\s/).filter(util.identity)
+                                    .map(function (g) g + " " + nodeSet.group + g)
+                                    .join(" "));
+                            elem.value = val[1];
+                            if (obj.onChange)
+                                obj.onChange.call(this, elem, val);
+                        }
+                    }, this);
+
+                    this.updateVisibility();
+                    return val;
+                }
+            }).init(obj.name));
+        }
+        else if (obj.defaultGroup) {
+            [this.commandbar, this.statusbar].forEach(function (nodeSet) {
+                let elem = nodeSet[obj.name];
+                if (elem)
+                    highlight.highlightNode(elem, obj.defaultGroup.split(/\s/)
+                                                     .map(function (g) g + " " + nodeSet.group + g).join(" "));
+            });
+        }
+    },
+
+    getGroup: function getgroup(name, value) {
+        if (!statusline.visible)
+            return this.commandbar;
+        return this.elements[name].getGroup.call(this, arguments.length > 1 ? value : this[name]);
+    },
+
+    updateVisibility: function updateVisibility() {
+        for (let elem in values(this.elements))
+            if (elem.getGroup) {
+                let value = elem.getValue ? elem.getValue.call(this)
+                          : elem.noValue || this[elem.name];
+
+                let activeGroup = this.getGroup(elem.name, value);
+                for (let group in values([this.commandbar, this.statusbar])) {
+                    let meth, node = group[elem.name];
+                    let visible = (value && group === activeGroup);
+                    if (node && !node.collapsed == !visible) {
+                        node.collapsed = !visible;
+                        if (elem.onVisibility)
+                            elem.onVisibility.call(this, node, visible);
+                    }
+                }
+            }
+
+        // Hack. Collapse hidden elements in the stack.
+        // Might possibly be better to use a deck and programmatically
+        // choose which element to select.
+        function check(node) {
+            if (util.computedStyle(node).display === "-moz-stack") {
+                let nodes = Array.filter(node.children, function (n) !n.collapsed && n.boxObject.height);
+                nodes.forEach(function (node, i) node.style.opacity = (i == nodes.length - 1) ? "" : "0");
+            }
+            Array.forEach(node.children, check);
+        }
+        [this.commandbar.container, this.statusbar.container].forEach(check);
+    },
+
+    active: Class.memoize(Object),
+    activeGroup: Class.memoize(Object),
+    commandbar: Class.memoize(function () ({ group: "Cmd" })),
+    statusbar: Class.memoize(function ()  ({ group: "Status" })),
+
+    _ready: function _ready(elem) {
+        return elem.contentDocument.documentURI === elem.getAttribute("src") &&
+               ["viewable", "complete"].indexOf(elem.contentDocument.readyState) >= 0;
+    },
+
+    _whenReady: function _whenReady(id, init) {
+        let elem = document.getElementById(id);
+        while (!this._ready(elem))
+            yield 10;
+
+        if (init)
+            init.call(this, elem);
+        yield elem;
+    },
+
+    completionContainer: Class.memoize(function () this.completionList.parentNode),
+
+    contextMenu: Class.memoize(function () {
+        ["copy", "copylink", "selectall"].forEach(function (tail) {
+            // some host apps use "hostPrefixContext-copy" ids
+            let xpath = "//xul:menuitem[contains(@id, '" + "ontext-" + tail + "') and not(starts-with(@id, 'dactyl-'))]";
+            document.getElementById("dactyl-context-" + tail).style.listStyleImage =
+                util.computedStyle(util.evaluateXPath(xpath, document).snapshotItem(0)).listStyleImage;
+        });
+        return document.getElementById("dactyl-contextmenu");
+    }),
+
+    multilineOutput: Class.memoize(function () this._whenReady("dactyl-multiline-output", function (elem) {
+        elem.contentWindow.addEventListener("unload", function (event) { event.preventDefault(); }, true);
+        elem.contentDocument.documentElement.id = "dactyl-multiline-output-top";
+        elem.contentDocument.body.id = "dactyl-multiline-output-content";
+    }), true),
+
+    multilineInput: Class.memoize(function () document.getElementById("dactyl-multiline-input")),
+
+    mowContainer: Class.memoize(function () document.getElementById("dactyl-multiline-output-container"))
+}, {
+    getEditor: function getEditor(elem) {
+        elem.inputField.QueryInterface(Ci.nsIDOMNSEditableElement);
+        return elem;
+    }
+});
+
+var CommandMode = Class("CommandMode", {
+    init: function init() {
+        this.keepCommand = userContext.hidden_option_command_afterimage;
+    },
+
+    get command() this.widgets.command[1],
+    set command(val) this.widgets.command = val,
+
+    get prompt() this.widgets.prompt,
+    set prompt(val) this.widgets.prompt = val,
+
+    open: function (command) {
+        dactyl.assert(isinstance(this.mode, modes.COMMAND_LINE),
+                      "Not opening command line in non-command-line mode.");
+
+        this.messageCount = commandline.messageCount;
+        modes.push(this.mode, this.extendedMode, this.closure);
+
+        this.widgets.active.commandline.collapsed = false;
+        this.widgets.prompt = this.prompt;
+        this.widgets.command = command || "";
+
+        this.input = this.widgets.active.command.inputField;
+        if (this.historyKey)
+            this.history = CommandLine.History(this.input, this.historyKey, this);
+
+        if (this.complete)
+            this.completions = CommandLine.Completions(this.input, this);
+
+        if (this.completions && command && commandline.commandSession === this)
+            this.completions.autocompleteTimer.flush(true);
+    },
+
+    get active() this === commandline.commandSession,
+
+    get holdFocus() this.widgets.active.command.inputField,
+
+    get mappingSelf() this,
+
+    get widgets() commandline.widgets,
+
+    enter: function (stack) {
+        commandline.commandSession = this;
+        if (stack.pop && commandline.command) {
+            this.onChange(commandline.command);
+            if (this.completions && stack.pop)
+                this.completions.complete(true, false);
+        }
+    },
+
+    leave: function (stack) {
+        if (!stack.push) {
+            commandline.commandSession = null;
+            this.input.dactylKeyPress = undefined;
+
+            if (this.completions)
+                this.completions.cleanup();
+
+            if (this.history)
+                this.history.save();
+
+            this.resetCompletions();
+            commandline.hideCompletions();
+
+            modes.delay(function () {
+                if (!this.keepCommand || commandline.silent || commandline.quiet)
+                    commandline.hide();
+                this[this.accepted ? "onSubmit" : "onCancel"](commandline.command);
+                if (commandline.messageCount === this.messageCount)
+                    commandline.clearMessage();
+            }, this);
+        }
+    },
+
+    events: {
+        input: function onInput(event) {
+            if (this.completions) {
+                this.resetCompletions();
+
+                this.completions.autocompleteTimer.tell(false);
+            }
+            this.onChange(commandline.command);
+        },
+        keyup: function onKeyUp(event) {
+            let key = events.toString(event);
+            if (/-?Tab>$/.test(key) && this.completions)
+                this.completions.tabTimer.flush();
+        }
+    },
+
+    keepCommand: false,
+
+    onKeyPress: function onKeyPress(events) {
+        if (this.completions)
+            this.completions.previewClear();
+
+        return true; /* Pass event */
+    },
+
+    onCancel: function (value) {
+    },
+
+    onChange: function (value) {
+    },
+
+    onSubmit: function (value) {
+    },
+
+    resetCompletions: function resetCompletions() {
+        if (this.completions) {
+            this.completions.context.cancelAll();
+            this.completions.wildIndex = -1;
+            this.completions.previewClear();
+        }
+        if (this.history)
+            this.history.reset();
+    },
+});
+
+var CommandExMode = Class("CommandExMode", CommandMode, {
+
+    get mode() modes.EX,
+
+    historyKey: "command",
+
+    prompt: ["Normal", ":"],
+
+    complete: function complete(context) {
+        context.fork("ex", 0, completion, "ex");
+    },
+
+    onSubmit: function onSubmit(command) {
+        contexts.withContext({ file: "[Command Line]", line: 1 },
+                             function _onSubmit() {
+            io.withSavedValues(["readHeredoc"], function _onSubmit() {
+                this.readHeredoc = commandline.readHeredoc;
+                commands.repeat = command;
+                dactyl.execute(command);
+            });
+        });
+    }
+});
+
+var CommandPromptMode = Class("CommandPromptMode", CommandMode, {
+    init: function init(prompt, params) {
+        this.prompt = isArray(prompt) ? prompt : ["Question", prompt];
+        update(this, params);
+        init.supercall(this);
+    },
+
+    complete: function (context) {
+        if (this.completer)
+            context.forkapply("prompt", 0, this, "completer", Array.slice(arguments, 1));
+    },
+
+    get mode() modes.PROMPT
+});
+
+/**
+ * This class is used for prompting of user input and echoing of messages.
+ *
+ * It consists of a prompt and command field be sure to only create objects of
+ * this class when the chrome is ready.
+ */
+var CommandLine = Module("commandline", {
+    init: function init() {
+        const self = this;
+
+        this._callbacks = {};
+
+        memoize(this, "_store", function () storage.newMap("command-history", { store: true, privateData: true }));
+
+        for (let name in values(["command", "search"]))
+            if (storage.exists("history-" + name)) {
+                let ary = storage.newArray("history-" + name, { store: true, privateData: true });
+
+                this._store.set(name, [v for ([k, v] in ary)]);
+                ary.delete();
+                this._store.changed();
+            }
+
+        this._messageHistory = { //{{{
+            _messages: [],
+            get messages() {
+                let max = options["messages"];
+
+                // resize if 'messages' has changed
+                if (this._messages.length > max)
+                    this._messages = this._messages.splice(this._messages.length - max);
+
+                return this._messages;
+            },
+
+            get length() this._messages.length,
+
+            clear: function clear() {
+                this._messages = [];
+            },
+
+            filter: function filter(fn, self) {
+                this._messages = this._messages.filter(fn, self);
+            },
+
+            add: function add(message) {
+                if (!message)
+                    return;
+
+                if (this._messages.length >= options["messages"])
+                    this._messages.shift();
+
+                this._messages.push(update({
+                    timestamp: Date.now()
+                }, message));
+            }
+        }; //}}}
+    },
+
+    signals: {
+        "browser.locationChange": function (webProgress, request, uri) {
+            this.clear();
+        }
+    },
+
+    /**
+     * Determines whether the command line should be visible.
+     *
+     * @returns {boolean}
+     */
+    get commandVisible() !!this.commandSession,
+
+    /**
+     * Ensure that the multiline input widget is the correct size.
+     */
+    _autosizeMultilineInputWidget: function _autosizeMultilineInputWidget() {
+        let lines = this.widgets.multilineInput.value.split("\n").length - 1;
+
+        this.widgets.multilineInput.setAttribute("rows", Math.max(lines, 1));
+    },
+
+    HL_NORMAL:     "Normal",
+    HL_ERRORMSG:   "ErrorMsg",
+    HL_MODEMSG:    "ModeMsg",
+    HL_MOREMSG:    "MoreMsg",
+    HL_QUESTION:   "Question",
+    HL_INFOMSG:    "InfoMsg",
+    HL_WARNINGMSG: "WarningMsg",
+    HL_LINENR:     "LineNr",
+
+    FORCE_MULTILINE    : 1 << 0,
+    FORCE_SINGLELINE   : 1 << 1,
+    DISALLOW_MULTILINE : 1 << 2, // If an echo() should try to use the single line
+                                 // but output nothing when the MOW is open; when also
+                                 // FORCE_MULTILINE is given, FORCE_MULTILINE takes precedence
+    APPEND_TO_MESSAGES : 1 << 3, // Add the string to the message history.
+    ACTIVE_WINDOW      : 1 << 4, // Only echo in active window.
+
+    get completionContext() this._completions.context,
+
+    _silent: false,
+    get silent() this._silent,
+    set silent(val) {
+        this._silent = val;
+        this.quiet = this.quiet;
+    },
+
+    _quite: false,
+    get quiet() this._quiet,
+    set quiet(val) {
+        this._quiet = val;
+        ["commandbar", "statusbar"].forEach(function (nodeSet) {
+            Array.forEach(this.widgets[nodeSet].commandline.children, function (node) {
+                node.style.opacity = this._quiet || this._silent ? "0" : "";
+            }, this);
+        }, this);
+    },
+
+    widgets: Class.memoize(function () CommandWidgets()),
+
+    runSilently: function runSilently(func, self) {
+        this.withSavedValues(["silent"], function () {
+            this.silent = true;
+            func.call(self);
+        });
+    },
+
+    get completionList() {
+        let node = this.widgets.active.commandline;
+        if (!node.completionList) {
+            let elem = document.getElementById("dactyl-completions-" + node.id);
+            util.waitFor(bind(this.widgets._ready, null, elem));
+
+            node.completionList = ItemList(elem.id);
+        }
+        return node.completionList;
+    },
+
+    hideCompletions: function hideCompletions() {
+        for (let nodeSet in values([this.widgets.statusbar, this.widgets.commandbar]))
+            if (nodeSet.commandline.completionList)
+                nodeSet.commandline.completionList.visible = false;
+    },
+
+    _lastClearable: Modes.boundProperty(),
+    messages: Modes.boundProperty(),
+
+    multilineInputVisible: Modes.boundProperty({
+        set: function set_miwVisible(value) { this.widgets.multilineInput.collapsed = !value; }
+    }),
+
+    get command() {
+        if (this.commandVisible && this.widgets.command)
+            return commands.lastCommand = this.widgets.command[1];
+        return commands.lastCommand;
+    },
+    set command(val) {
+        if (this.commandVisible && (modes.extended & modes.EX))
+            return this.widgets.command = val;
+        return commands.lastCommand = val;
+    },
+
+    clear: function clear(scroll) {
+        if (!scroll || Date.now() - this._lastEchoTime > 5000)
+            this.clearMessage();
+        this._lastEchoTime = 0;
+
+        if (!this.commandSession) {
+            this.widgets.command = null;
+            this.hideCompletions();
+        }
+
+        if (modes.main == modes.OUTPUT_MULTILINE && !mow.isScrollable(1))
+            modes.pop();
+
+        if (!modes.have(modes.OUTPUT_MULTILINE))
+            mow.visible = false;
+    },
+
+    clearMessage: function clearMessage() {
+        if (this.widgets.message && this.widgets.message[1] === this._lastClearable)
+            this.widgets.message = null;
+    },
+
+    /**
+     * Displays the multi-line output of a command, preceded by the last
+     * executed ex command string.
+     *
+     * @param {XML} xml The output as an E4X XML object.
+     */
+    commandOutput: function commandOutput(xml) {
+        XML.ignoreWhitespace = false;
+        XML.prettyPrinting = false;
+        if (this.command)
+            this.echo(<>:{this.command}</>, this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
+        this.echo(xml, this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
+        this.command = null;
+    },
+
+    /**
+     * Hides the command line, and shows any status messages that
+     * are under it.
+     */
+    hide: function hide() {
+        this.widgets.command = null;
+    },
+
+    /**
+     * Display a message in the command-line area.
+     *
+     * @param {string} str
+     * @param {string} highlightGroup
+     * @param {boolean} forceSingle If provided, don't let over-long
+     *     messages move to the MOW.
+     */
+    _echoLine: function echoLine(str, highlightGroup, forceSingle, silent) {
+        this.widgets.message = str ? [highlightGroup, str, forceSingle] : null;
+
+        dactyl.triggerObserver("echoLine", str, highlightGroup, null, forceSingle);
+
+        if (!this.commandVisible)
+            this.hide();
+
+        let field = this.widgets.active.message.inputField;
+        if (field.value && !forceSingle && field.editor.rootElement.scrollWidth > field.scrollWidth) {
+            this.widgets.message = null;
+            mow.echo(<span highlight="Message">{str}</span>, highlightGroup, true);
+        }
+    },
+
+    _lastEcho: null,
+
+    /**
+     * Output the given string onto the command line. With no flags, the
+     * message will be shown in the status line if it's short enough to
+     * fit, and contains no new lines, and isn't XML. Otherwise, it will be
+     * shown in the MOW.
+     *
+     * @param {string} str
+     * @param {string} highlightGroup The Highlight group for the
+     *     message.
+     * @default "Normal"
+     * @param {number} flags Changes the behavior as follows:
+     *   commandline.APPEND_TO_MESSAGES - Causes message to be added to the
+     *          messages history, and shown by :messages.
+     *   commandline.FORCE_SINGLELINE   - Forbids the command from being
+     *          pushed to the MOW if it's too long or of there are already
+     *          status messages being shown.
+     *   commandline.DISALLOW_MULTILINE - Cancels the operation if the MOW
+     *          is already visible.
+     *   commandline.FORCE_MULTILINE    - Forces the message to appear in
+     *          the MOW.
+     */
+    messageCount: 0,
+    echo: function echo(data, highlightGroup, flags) {
+        // dactyl.echo uses different order of flags as it omits the highlight group, change commandline.echo argument order? --mst
+        if (this._silent || !this.widgets)
+            return;
+
+        this.messageCount++;
+
+        highlightGroup = highlightGroup || this.HL_NORMAL;
+
+        if (flags & this.APPEND_TO_MESSAGES) {
+            let message = isObject(data) ? data : { message: data };
+            this._messageHistory.add(update({ highlight: highlightGroup }, message));
+            data = message.message;
+        }
+
+        if ((flags & this.ACTIVE_WINDOW) &&
+            window != services.windowWatcher.activeWindow &&
+            services.windowWatcher.activeWindow.dactyl)
+            return;
+
+        if ((flags & this.DISALLOW_MULTILINE) && !this.widgets.mowContainer.collapsed)
+            return;
+
+        let single = flags & (this.FORCE_SINGLELINE | this.DISALLOW_MULTILINE);
+        let action = this._echoLine;
+
+        if ((flags & this.FORCE_MULTILINE) || (/\n/.test(data) || !isString(data)) && !(flags & this.FORCE_SINGLELINE))
+            action = mow.closure.echo;
+
+        if (single)
+            this._lastEcho = null;
+        else {
+            if (this.widgets.message && this.widgets.message[1] == this._lastEcho)
+                mow.echo(<span highlight="Message">{this._lastEcho}</span>,
+                         this.widgets.message[0], true);
+
+            if (action === this._echoLine && !(flags & this.FORCE_MULTILINE)
+                && !(dactyl.fullyInitialized && this.widgets.mowContainer.collapsed)) {
+                highlightGroup += " Message";
+                action = mow.closure.echo;
+            }
+            this._lastEcho = (action == this._echoLine) && data;
+        }
+
+        this._lastClearable = action === this._echoLine && String(data);
+        this._lastEchoTime = (flags & this.FORCE_SINGLELINE) && Date.now();
+
+        if (action)
+            action.call(this, data, highlightGroup, single);
+    },
+    _lastEchoTime: 0,
+
+    /**
+     * Prompt the user. Sets modes.main to COMMAND_LINE, which the user may
+     * pop at any time to close the prompt.
+     *
+     * @param {string} prompt The input prompt to use.
+     * @param {function(string)} callback
+     * @param {Object} extra
+     * @... {function} onChange - A function to be called with the current
+     *     input every time it changes.
+     * @... {function(CompletionContext)} completer - A completion function
+     *     for the user's input.
+     * @... {string} promptHighlight - The HighlightGroup used for the
+     *     prompt. @default "Question"
+     * @... {string} default - The initial value that will be returned
+     *     if the user presses <CR> straightaway. @default ""
+     */
+    input: function _input(prompt, callback, extra) {
+        extra = extra || {};
+
+        CommandPromptMode(prompt, update({ onSubmit: callback }, extra)).open();
+    },
+
+    readHeredoc: function readHeredoc(end) {
+        let args;
+        commandline.inputMultiline(end, function (res) { args = res; });
+        util.waitFor(function () args !== undefined);
+        return args;
+    },
+
+    /**
+     * Get a multi-line input from a user, up to but not including the line
+     * which matches the given regular expression. Then execute the
+     * callback with that string as a parameter.
+     *
+     * @param {string} end
+     * @param {function(string)} callback
+     */
+    // FIXME: Buggy, especially when pasting.
+    inputMultiline: function inputMultiline(end, callback) {
+        let cmd = this.command;
+        modes.push(modes.INPUT_MULTILINE, null, {
+            mappingSelf: {
+                end: "\n" + end + "\n",
+                callback: callback
+            }
+        });
+        if (cmd != false)
+            this._echoLine(cmd, this.HL_NORMAL);
+
+        // save the arguments, they are needed in the event handler onKeyPress
+
+        this.multilineInputVisible = true;
+        this.widgets.multilineInput.value = "";
+        this._autosizeMultilineInputWidget();
+
+        this.timeout(function () { dactyl.focus(this.widgets.multilineInput); }, 10);
+    },
+
+    get commandMode() this.commandSession && isinstance(modes.main, modes.COMMAND_LINE),
+
+    events: update(
+        iter(CommandMode.prototype.events).map(
+            function ([event, handler]) [
+                event, function (event) {
+                    if (this.commandMode)
+                        handler.call(this.commandSession, event);
+                }
+            ]).toObject(),
+        {
+            focus: function onFocus(event) {
+                if (!this.commandSession
+                        && event.originalTarget === this.widgets.active.command.inputField) {
+                    event.target.blur();
+                    dactyl.beep();
+                }
+            },
+        }
+    ),
+
+    get mowEvents() mow.events,
+
+    /**
+     * Multiline input events, they will come straight from
+     * #dactyl-multiline-input in the XUL.
+     *
+     * @param {Event} event
+     */
+    multilineInputEvents: {
+        blur: function onBlur(event) {
+            if (modes.main == modes.INPUT_MULTILINE)
+                this.timeout(function () {
+                    dactyl.focus(this.widgets.multilineInput.inputField);
+                });
+        },
+        input: function onInput(event) {
+            this._autosizeMultilineInputWidget();
+        }
+    },
+
+    updateOutputHeight: deprecated("mow.resize", function updateOutputHeight(open, extra) mow.resize(open, extra)),
+
+    withOutputToString: function withOutputToString(fn, self) {
+        dactyl.registerObserver("echoLine", observe, true);
+        dactyl.registerObserver("echoMultiline", observe, true);
+
+        let output = [];
+        function observe(str, highlight, dom) {
+            output.push(dom && !isString(str) ? dom : str);
+        }
+
+        this.savingOutput = true;
+        dactyl.trapErrors.apply(dactyl, [fn, self].concat(Array.slice(arguments, 2)));
+        this.savingOutput = false;
+        return output.map(function (elem) elem instanceof Node ? util.domToString(elem) : elem)
+                     .join("\n");
+    }
+}, {
+    /**
+     * A class for managing the history of an input field.
+     *
+     * @param {HTMLInputElement} inputField
+     * @param {string} mode The mode for which we need history.
+     */
+    History: Class("History", {
+        init: function init(inputField, mode, session) {
+            this.mode = mode;
+            this.input = inputField;
+            this.reset();
+            this.session = session;
+        },
+        get store() commandline._store.get(this.mode, []),
+        set store(ary) { commandline._store.set(this.mode, ary); },
+        /**
+         * Reset the history index to the first entry.
+         */
+        reset: function reset() {
+            this.index = null;
+        },
+        /**
+         * Save the last entry to the permanent store. All duplicate entries
+         * are removed and the list is truncated, if necessary.
+         */
+        save: function save() {
+            if (events.feedingKeys)
+                return;
+            let str = this.input.value;
+            if (/^\s*$/.test(str))
+                return;
+            this.store = this.store.filter(function (line) (line.value || line) != str);
+            try {
+                this.store.push({ value: str, timestamp: Date.now()*1000, privateData: this.checkPrivate(str) });
+            }
+            catch (e) {
+                dactyl.reportError(e);
+            }
+            this.store = this.store.slice(-options["history"]);
+        },
+        /**
+         * @property {function} Returns whether a data item should be
+         * considered private.
+         */
+        checkPrivate: function checkPrivate(str) {
+            // Not really the ideal place for this check.
+            if (this.mode == "command")
+                return commands.hasPrivateData(str);
+            return false;
+        },
+        /**
+         * Replace the current input field value.
+         *
+         * @param {string} val The new value.
+         */
+        replace: function replace(val) {
+            this.input.dactylKeyPress = undefined;
+            if (this.completions)
+                this.completions.previewClear();
+            this.input.value = val;
+        },
+
+        /**
+         * Move forward or backward in history.
+         *
+         * @param {boolean} backward Direction to move.
+         * @param {boolean} matchCurrent Search for matches starting
+         *      with the current input value.
+         */
+        select: function select(backward, matchCurrent) {
+            // always reset the tab completion if we use up/down keys
+            if (this.session.completions)
+                this.session.completions.reset();
+
+            let diff = backward ? -1 : 1;
+
+            if (this.index == null) {
+                this.original = this.input.value;
+                this.index = this.store.length;
+            }
+
+            // search the history for the first item matching the current
+            // command-line string
+            while (true) {
+                this.index += diff;
+                if (this.index < 0 || this.index > this.store.length) {
+                    this.index = Math.constrain(this.index, 0, this.store.length);
+                    dactyl.beep();
+                    // I don't know why this kludge is needed. It
+                    // prevents the caret from moving to the end of
+                    // the input field.
+                    if (this.input.value == "") {
+                        this.input.value = " ";
+                        this.input.value = "";
+                    }
+                    break;
+                }
+
+                let hist = this.store[this.index];
+                // user pressed DOWN when there is no newer history item
+                if (!hist)
+                    hist = this.original;
+                else
+                    hist = (hist.value || hist);
+
+                if (!matchCurrent || hist.substr(0, this.original.length) == this.original) {
+                    this.replace(hist);
+                    break;
+                }
+            }
+        }
+    }),
+
+    /**
+     * A class for tab completions on an input field.
+     *
+     * @param {Object} input
+     */
+    Completions: Class("Completions", {
+        init: function init(input, session) {
+            this.context = CompletionContext(input.QueryInterface(Ci.nsIDOMNSEditableElement).editor);
+            this.context.onUpdate = this.closure._reset;
+            this.editor = input.editor;
+            this.input = input;
+            this.session = session;
+            this.selected = null;
+            this.wildmode = options.get("wildmode");
+            this.wildtypes = this.wildmode.value;
+            this.itemList = commandline.completionList;
+            this.itemList.setItems(this.context);
+
+            dactyl.registerObserver("events.doneFeeding", this.closure.onDoneFeeding, true);
+
+            this.autocompleteTimer = Timer(200, 500, function autocompleteTell(tabPressed) {
+                if (events.feedingKeys)
+                    this.ignoredCount++;
+                if (options["autocomplete"].length) {
+                    this.itemList.visible = true;
+                    this.complete(true, false);
+                }
+            }, this);
+            this.tabTimer = Timer(0, 0, function tabTell(event) {
+                this.tab(event.shiftKey, event.altKey && options["altwildmode"]);
+            }, this);
+        },
+
+        cleanup: function () {
+            dactyl.unregisterObserver("events.doneFeeding", this.closure.onDoneFeeding);
+            this.previewClear();
+            this.tabTimer.reset();
+            this.autocompleteTimer.reset();
+            this.itemList.visible = false;
+            this.input.dactylKeyPress = undefined;
+        },
+
+        ignoredCount: 0,
+        onDoneFeeding: function onDoneFeeding() {
+            if (this.ignoredCount)
+                this.autocompleteTimer.flush(true);
+            this.ignoredCount = 0;
+        },
+
+        UP: {},
+        DOWN: {},
+        PAGE_UP: {},
+        PAGE_DOWN: {},
+        RESET: null,
+
+        lastSubstring: "",
+
+        get completion() {
+            let str = commandline.command;
+            return str.substring(this.prefix.length, str.length - this.suffix.length);
+        },
+        set completion(completion) {
+            this.previewClear();
+
+            // Change the completion text.
+            // The second line is a hack to deal with some substring
+            // preview corner cases.
+            let value = this.prefix + completion + this.suffix;
+            commandline.widgets.active.command.value = value;
+            this.editor.selection.focusNode.textContent = value;
+
+            // Reset the caret to one position after the completion.
+            this.caret = this.prefix.length + completion.length;
+            this._caret = this.caret;
+
+            this.input.dactylKeyPress = undefined;
+        },
+
+        get caret() this.editor.selection.getRangeAt(0).startOffset,
+        set caret(offset) {
+            this.editor.selection.getRangeAt(0).setStart(this.editor.rootElement.firstChild, offset);
+            this.editor.selection.getRangeAt(0).setEnd(this.editor.rootElement.firstChild, offset);
+        },
+
+        get start() this.context.allItems.start,
+
+        get items() this.context.allItems.items,
+
+        get substring() this.context.longestAllSubstring,
+
+        get wildtype() this.wildtypes[this.wildIndex] || "",
+
+        complete: function complete(show, tabPressed) {
+            this.context.reset();
+            this.context.tabPressed = tabPressed;
+            this.session.complete(this.context);
+            if (!this.session.active)
+                return;
+            this.context.updateAsync = true;
+            this.reset(show, tabPressed);
+            this.wildIndex = 0;
+            this._caret = this.caret;
+        },
+
+        haveType: function haveType(type)
+            this.wildmode.checkHas(this.wildtype, type == "first" ? "" : type),
+
+        preview: function preview() {
+            this.previewClear();
+            if (this.wildIndex < 0 || this.suffix || !this.items.length)
+                return;
+
+            let substring = "";
+            switch (this.wildtype.replace(/.*:/, "")) {
+            case "":
+                substring = this.items[0].result;
+                break;
+            case "longest":
+                if (this.items.length > 1) {
+                    substring = this.substring;
+                    break;
+                }
+                // Fallthrough
+            case "full":
+                let item = this.items[this.selected != null ? this.selected + 1 : 0];
+                if (item)
+                    substring = item.result;
+                break;
+            }
+
+            // Don't show 1-character substrings unless we've just hit backspace
+            if (substring.length < 2 && this.lastSubstring.indexOf(substring) !== 0)
+                return;
+
+            this.lastSubstring = substring;
+
+            let value = this.completion;
+            if (util.compareIgnoreCase(value, substring.substr(0, value.length)))
+                return;
+            substring = substring.substr(value.length);
+            this.removeSubstring = substring;
+
+            let node = util.xmlToDom(<span highlight="Preview">{substring}</span>,
+                document);
+            let start = this.caret;
+            this.editor.insertNode(node, this.editor.rootElement, 1);
+            this.caret = start;
+        },
+
+        previewClear: function previewClear() {
+            let node = this.editor.rootElement.firstChild;
+            if (node && node.nextSibling) {
+                try {
+                    this.editor.deleteNode(node.nextSibling);
+                }
+                catch (e) {
+                    node.nextSibling.textContent = "";
+                }
+            }
+            else if (this.removeSubstring) {
+                let str = this.removeSubstring;
+                let cmd = commandline.widgets.active.command.value;
+                if (cmd.substr(cmd.length - str.length) == str)
+                    commandline.widgets.active.command.value = cmd.substr(0, cmd.length - str.length);
+            }
+            delete this.removeSubstring;
+        },
+
+        reset: function reset(show) {
+            this.wildIndex = -1;
+
+            this.prefix = this.context.value.substring(0, this.start);
+            this.value  = this.context.value.substring(this.start, this.caret);
+            this.suffix = this.context.value.substring(this.caret);
+
+            if (show) {
+                this.itemList.reset();
+                if (this.haveType("list"))
+                    this.itemList.visible = true;
+                this.selected = null;
+                this.wildIndex = 0;
+            }
+
+            this.preview();
+        },
+
+        _reset: function _reset() {
+            let value = this.editor.selection.focusNode.textContent;
+            this.prefix = value.substring(0, this.start);
+            this.value  = value.substring(this.start, this.caret);
+            this.suffix = value.substring(this.caret);
+
+            this.itemList.reset();
+            this.itemList.selectItem(this.selected);
+
+            this.preview();
+        },
+
+        select: function select(idx) {
+            switch (idx) {
+            case this.UP:
+                if (this.selected == null)
+                    idx = -2;
+                else
+                    idx = this.selected - 1;
+                break;
+            case this.DOWN:
+                if (this.selected == null)
+                    idx = 0;
+                else
+                    idx = this.selected + 1;
+                break;
+            case this.RESET:
+                idx = null;
+                break;
+            default:
+                idx = Math.constrain(idx, 0, this.items.length - 1);
+                break;
+            }
+
+            if (idx == -1 || this.items.length && idx >= this.items.length || idx == null) {
+                // Wrapped. Start again.
+                this.selected = null;
+                this.completion = this.value;
+            }
+            else {
+                // Wait for contexts to complete if necessary.
+                // FIXME: Need to make idx relative to individual contexts.
+                let list = this.context.contextList;
+                if (idx == -2)
+                    list = list.slice().reverse();
+                let n = 0;
+                try {
+                    this.waiting = true;
+                    for (let [, context] in Iterator(list)) {
+                        let done = function done() !(idx >= n + context.items.length || idx == -2 && !context.items.length);
+
+                        util.waitFor(function () !context.incomplete || done())
+                        if (done())
+                            break;
+
+                        n += context.items.length;
+                    }
+                }
+                finally {
+                    this.waiting = false;
+                }
+
+                // See previous FIXME. This will break if new items in
+                // a previous context come in.
+                if (idx < 0)
+                    idx = this.items.length - 1;
+                if (this.items.length == 0)
+                    return;
+
+                this.selected = idx;
+                this.completion = this.items[idx].result;
+            }
+
+            this.itemList.selectItem(idx);
+        },
+
+        tabs: [],
+
+        tab: function tab(reverse, wildmode) {
+            this.autocompleteTimer.flush();
+
+            if (this._caret != this.caret)
+                this.reset();
+            this._caret = this.caret;
+
+            // Check if we need to run the completer.
+            if (this.context.waitingForTab || this.wildIndex == -1)
+                this.complete(true, true);
+
+            this.tabs.push([reverse, wildmode || options["wildmode"]]);
+            if (this.waiting)
+                return;
+
+            while (this.tabs.length) {
+                [reverse, this.wildtypes] = this.tabs.shift();
+
+                this.wildIndex = Math.min(this.wildIndex, this.wildtypes.length - 1);
+                switch (this.wildtype.replace(/.*:/, "")) {
+                case "":
+                    this.select(0);
+                    break;
+                case "longest":
+                    if (this.items.length > 1) {
+                        if (this.substring && this.substring.length > this.completion.length)
+                            this.completion = this.substring;
+                        break;
+                    }
+                    // Fallthrough
+                case "full":
+                    this.select(reverse ? this.UP : this.DOWN);
+                    break;
+                }
+
+                if (this.haveType("list"))
+                    this.itemList.visible = true;
+
+                this.wildIndex++;
+                this.preview();
+
+                if (this.selected == null)
+                    statusline.progress = "";
+                else
+                    statusline.progress = "match " + (this.selected + 1) + " of " + this.items.length;
+            }
+
+            if (this.items.length == 0)
+                dactyl.beep();
+        }
+    }),
+
+    /**
+     * Evaluate a JavaScript expression and return a string suitable
+     * to be echoed.
+     *
+     * @param {string} arg
+     * @param {boolean} useColor When true, the result is a
+     *     highlighted XML object.
+     */
+    echoArgumentToString: function (arg, useColor) {
+        if (!arg)
+            return "";
+
+        arg = dactyl.userEval(arg);
+        if (isObject(arg))
+            arg = util.objectToString(arg, useColor);
+        else
+            arg = String(arg);
+        return arg;
+    }
+}, {
+    commands: function init_commands() {
+        [
+            {
+                name: "ec[ho]",
+                description: "Echo the expression",
+                action: dactyl.echo
+            },
+            {
+                name: "echoe[rr]",
+                description: "Echo the expression as an error message",
+                action: dactyl.echoerr
+            },
+            {
+                name: "echom[sg]",
+                description: "Echo the expression as an informational message",
+                action: dactyl.echomsg
+            }
+        ].forEach(function (command) {
+            commands.add([command.name],
+                command.description,
+                function (args) {
+                    command.action(CommandLine.echoArgumentToString(args[0] || "", true));
+                }, {
+                    completer: function (context) completion.javascript(context),
+                    literal: 0
+                });
+        });
+
+        commands.add(["mes[sages]"],
+            "Display previously shown messages",
+            function () {
+                // TODO: are all messages single line? Some display an aggregation
+                //       of single line messages at least. E.g. :source
+                if (commandline._messageHistory.length == 1) {
+                    let message = commandline._messageHistory.messages[0];
+                    commandline.echo(message.message, message.highlight, commandline.FORCE_SINGLELINE);
+                }
+                else if (commandline._messageHistory.length > 1) {
+                    XML.ignoreWhitespace = false;
+                    commandline.commandOutput(
+                        template.map(commandline._messageHistory.messages, function (message)
+                            <div highlight={message.highlight + " Message"}>{message.message}</div>));
+                }
+            },
+            { argCount: "0" });
+
+        commands.add(["messc[lear]"],
+            "Clear the message history",
+            function () { commandline._messageHistory.clear(); },
+            { argCount: "0" });
+
+        commands.add(["sil[ent]"],
+            "Run a command silently",
+            function (args) {
+                commandline.runSilently(function () commands.execute(args[0] || "", null, true));
+            }, {
+                completer: function (context) completion.ex(context),
+                literal: 0,
+                subCommand: 0
+            });
+    },
+    modes: function initModes() {
+        modes.addMode("COMMAND_LINE", {
+            char: "c",
+            description: "Active when the command line is focused",
+            insert: true,
+            ownsFocus: true,
+            get mappingSelf() commandline.commandSession
+        });
+        // this._extended modes, can include multiple modes, and even main modes
+        modes.addMode("EX", {
+            description: "Ex command mode, active when the command line is open for Ex commands",
+            bases: [modes.COMMAND_LINE]
+        });
+        modes.addMode("PROMPT", {
+            description: "Active when a prompt is open in the command line",
+            bases: [modes.COMMAND_LINE]
+        });
+
+        modes.addMode("INPUT_MULTILINE", {
+            bases: [modes.INSERT]
+        });
+    },
+    mappings: function init_mappings() {
+
+        mappings.add([modes.COMMAND],
+            [":"], "Enter command-line mode",
+            function () { CommandExMode().open(""); });
+
+        mappings.add([modes.INPUT_MULTILINE],
+            ["<Return>", "<C-j>", "<C-m>"], "Begin a new line",
+            function ({ self }) {
+                let text = "\n" + commandline.widgets.multilineInput
+                                             .value.substr(0, commandline.widgets.multilineInput.selectionStart)
+                         + "\n";
+
+                let index = text.indexOf(self.end);
+                if (index >= 0) {
+                    text = text.substring(1, index);
+                    modes.pop();
+
+                    return function () self.callback.call(commandline, text);
+                }
+                return Events.PASS;
+            });
+
+        let bind = function bind()
+            mappings.add.apply(mappings, [[modes.COMMAND_LINE]].concat(Array.slice(arguments)))
+
+        // Any "non-keyword" character triggers abbreviation expansion
+        // TODO: Add "<CR>" and "<Tab>" to this list
+        //       At the moment, adding "<Tab>" breaks tab completion. Adding
+        //       "<CR>" has no effect.
+        // TODO: Make non-keyword recognition smarter so that there need not
+        //       be two lists of the same characters (one here and a regexp in
+        //       mappings.js)
+        bind(["<Space>", '"', "'"], "Expand command line abbreviation",
+             function ({ self }) {
+                 self.resetCompletions();
+                 editor.expandAbbreviation(modes.COMMAND_LINE);
+                 return Events.PASS;
+             });
+
+        bind(["<Return>", "<C-j>", "<C-m>"], "Accept the current input",
+             function ({ self }) {
+                 let command = commandline.command;
+
+                 self.accepted = true;
+                 return function () { modes.pop(); };
+             });
+
+        [
+            [["<Up>", "<A-p>"],                   "previous matching", true,  true],
+            [["<S-Up>", "<C-p>", "<PageUp>"],     "previous",          true,  false],
+            [["<Down>", "<A-n>"],                 "next matching",     false, true],
+            [["<S-Down>", "<C-n>", "<PageDown>"], "next",              false, false]
+        ].forEach(function ([keys, desc, up, search]) {
+            bind(keys, "Recall the " + desc + " command line from the history list",
+                 function ({ self }) {
+                     dactyl.assert(self.history);
+                     self.history.select(up, search);
+                 });
+        });
+
+        bind(["<A-Tab>", "<Tab>"], "Select the next matching completion item",
+             function ({ keypressEvents, self }) {
+                 dactyl.assert(self.completions);
+                 self.completions.tabTimer.tell(keypressEvents[0]);
+             });
+
+        bind(["<A-S-Tab>", "<S-Tab>"], "Select the previous matching completion item",
+             function ({ keypressEvents, self }) {
+                 dactyl.assert(self.completions);
+                 self.completions.tabTimer.tell(keypressEvents[0]);
+             });
+
+        bind(["<BS>", "<C-h>"], "Delete the previous character",
+             function () {
+                 if (!commandline.command)
+                     modes.pop();
+                 else
+                     return Events.PASS;
+             });
+
+        bind(["<C-]>", "<C-5>"], "Expand command line abbreviation",
+             function () { editor.expandAbbreviation(modes.COMMAND_LINE); });
+    },
+    options: function init_options() {
+        options.add(["history", "hi"],
+            "Number of Ex commands and search patterns to store in the command-line history",
+            "number", 500,
+            { validator: function (value) value >= 0 });
+
+        options.add(["maxitems"],
+            "Maximum number of completion items to display at once",
+            "number", 20,
+            { validator: function (value) value >= 1 });
+
+        options.add(["messages", "msgs"],
+            "Number of messages to store in the :messages history",
+            "number", 100,
+            { validator: function (value) value >= 0 });
+    },
+    sanitizer: function init_sanitizer() {
+        sanitizer.addItem("commandline", {
+            description: "Command-line and search history",
+            persistent: true,
+            action: function (timespan, host) {
+                let store = commandline._store;
+                for (let [k, v] in store) {
+                    if (k == "command")
+                        store.set(k, v.filter(function (item)
+                            !(timespan.contains(item.timestamp) && (!host || commands.hasDomain(item.value, host)))));
+                    else if (!host)
+                        store.set(k, v.filter(function (item) !timespan.contains(item.timestamp)));
+                }
+            }
+        });
+        // Delete history-like items from the commandline and messages on history purge
+        sanitizer.addItem("history", {
+            action: function (timespan, host) {
+                commandline._store.set("command",
+                    commandline._store.get("command", []).filter(function (item)
+                        !(timespan.contains(item.timestamp) && (host ? commands.hasDomain(item.value, host)
+                                                                     : item.privateData))));
+
+                commandline._messageHistory.filter(function (item) !timespan.contains(item.timestamp * 1000) ||
+                    !item.domains && !item.privateData ||
+                    host && (!item.domains || !item.domains.some(function (d) util.isSubdomain(d, host))));
+            }
+        });
+        sanitizer.addItem("messages", {
+            description: "Saved :messages",
+            action: function (timespan, host) {
+                commandline._messageHistory.filter(function (item) !timespan.contains(item.timestamp * 1000) ||
+                    host && (!item.domains || !item.domains.some(function (d) util.isSubdomain(d, host))));
+            }
+        });
+    }
+});
+
+/**
+ * The list which is used for the completion box (and QuickFix window in
+ * future).
+ *
+ * @param {string} id The id of the iframe which will display the list. It
+ *     must be in its own container element, whose height it will update as
+ *     necessary.
+ */
+var ItemList = Class("ItemList", {
+    init: function init(id) {
+        this._completionElements = [];
+
+        var iframe = document.getElementById(id);
+
+        this._doc = iframe.contentDocument;
+        this._win = iframe.contentWindow;
+        this._container = iframe.parentNode;
+
+        this._doc.documentElement.id = id + "-top";
+        this._doc.body.id = id + "-content";
+        this._doc.body.className = iframe.className + "-content";
+        this._doc.body.appendChild(this._doc.createTextNode(""));
+        this._doc.body.style.borderTop = "1px solid black"; // FIXME: For cases where completions/MOW are shown at once, or ls=0. Should use :highlight.
+
+        this._items = null;
+        this._startIndex = -1; // The index of the first displayed item
+        this._endIndex = -1;   // The index one *after* the last displayed item
+        this._selIndex = -1;   // The index of the currently selected element
+        this._div = null;
+        this._divNodes = {};
+        this._minHeight = 0;
+    },
+
+    _dom: function _dom(xml, map) util.xmlToDom(xml instanceof XML ? xml : <>{xml}</>, this._doc, map),
+
+    _autoSize: function _autoSize() {
+        if (!this._div)
+            return;
+
+        if (this._container.collapsed)
+            this._div.style.minWidth = document.getElementById("dactyl-commandline").scrollWidth + "px";
+
+        this._minHeight = Math.max(this._minHeight,
+            this._win.scrollY + this._divNodes.completions.getBoundingClientRect().bottom);
+
+        if (this._container.collapsed)
+            this._div.style.minWidth = "";
+
+        // FIXME: Belongs elsewhere.
+        mow.resize(false, Math.max(0, this._minHeight - this._container.height));
+
+        this._container.height = this._minHeight;
+        this._container.height -= mow.spaceNeeded;
+        mow.resize(false);
+        this.timeout(function () {
+            this._container.height -= mow.spaceNeeded;
+        });
+    },
+
+    _getCompletion: function _getCompletion(index) this._completionElements.snapshotItem(index - this._startIndex),
+
+    _init: function _init() {
+        this._div = this._dom(
+            <div class="ex-command-output" highlight="Normal" style="white-space: nowrap">
+                <div highlight="Completions" key="noCompletions"><span highlight="Title">No Completions</span></div>
+                <div key="completions"/>
+                <div highlight="Completions">
+                {
+                    template.map(util.range(0, options["maxitems"] * 2), function (i)
+                    <div highlight="CompItem NonText">
+                        <li>~</li>
+                    </div>)
+                }
+                </div>
+            </div>, this._divNodes);
+        this._doc.body.replaceChild(this._div, this._doc.body.firstChild);
+        util.scrollIntoView(this._div, true);
+
+        this._items.contextList.forEach(function init_eachContext(context) {
+            delete context.cache.nodes;
+            if (!context.items.length && !context.message && !context.incomplete)
+                return;
+            context.cache.nodes = [];
+            this._dom(<div key="root" highlight="CompGroup">
+                    <div highlight="Completions">
+                        { context.createRow(context.title || [], "CompTitle") }
+                    </div>
+                    <div highlight="CompTitleSep"/>
+                    <div key="message" highlight="CompMsg"/>
+                    <div key="up" highlight="CompLess"/>
+                    <div key="items" highlight="Completions"/>
+                    <div key="waiting" highlight="CompMsg">{ItemList.WAITING_MESSAGE}</div>
+                    <div key="down" highlight="CompMore"/>
+                </div>, context.cache.nodes);
+            this._divNodes.completions.appendChild(context.cache.nodes.root);
+        }, this);
+
+        this.timeout(this._autoSize);
+    },
+
+    /**
+     * Uses the entries in "items" to fill the listbox and does incremental
+     * filling to speed up things.
+     *
+     * @param {number} offset Start at this index and show options["maxitems"].
+     */
+    _fill: function _fill(offset) {
+        XML.ignoreWhiteSpace = false;
+        let diff = offset - this._startIndex;
+        if (this._items == null || offset == null || diff == 0 || offset < 0)
+            return false;
+
+        this._startIndex = offset;
+        this._endIndex = Math.min(this._startIndex + options["maxitems"], this._items.allItems.items.length);
+
+        let haveCompletions = false;
+        let off = 0;
+        let end = this._startIndex + options["maxitems"];
+        function getRows(context) {
+            function fix(n) Math.constrain(n, 0, len);
+            let len = context.items.length;
+            let start = off;
+            end -= !!context.message + context.incomplete;
+            off += len;
+
+            let s = fix(offset - start), e = fix(end - start);
+            return [s, e, context.incomplete && e >= offset && off - 1 < end];
+        }
+
+        this._items.contextList.forEach(function fill_eachContext(context) {
+            let nodes = context.cache.nodes;
+            if (!nodes)
+                return;
+            haveCompletions = true;
+
+            let root = nodes.root;
+            let items = nodes.items;
+            let [start, end, waiting] = getRows(context);
+
+            if (context.message) {
+                nodes.message.textContent = "";
+                nodes.message.appendChild(this._dom(context.message));
+            }
+            nodes.message.style.display = context.message ? "block" : "none";
+            nodes.waiting.style.display = waiting ? "block" : "none";
+            nodes.up.style.opacity = "0";
+            nodes.down.style.display = "none";
+
+            for (let [i, row] in Iterator(context.getRows(start, end, this._doc)))
+                nodes[i] = row;
+            for (let [i, row] in array.iterItems(nodes)) {
+                if (!row)
+                    continue;
+                let display = (i >= start && i < end);
+                if (display && row.parentNode != items) {
+                    do {
+                        var next = nodes[++i];
+                        if (next && next.parentNode != items)
+                            next = null;
+                    }
+                    while (!next && i < end)
+                    items.insertBefore(row, next);
+                }
+                else if (!display && row.parentNode == items)
+                    items.removeChild(row);
+            }
+            if (context.items.length == 0)
+                return;
+            nodes.up.style.opacity = (start == 0) ? "0" : "1";
+            if (end != context.items.length)
+                nodes.down.style.display = "block";
+            else
+                nodes.up.style.display = "block";
+            if (start == end) {
+                nodes.up.style.display = "none";
+                nodes.down.style.display = "none";
+            }
+        }, this);
+
+        this._divNodes.noCompletions.style.display = haveCompletions ? "none" : "block";
+
+        this._completionElements = util.evaluateXPath("//xhtml:div[@dactyl:highlight='CompItem']", this._doc);
+
+        return true;
+    },
+
+    clear: function clear() { this.setItems(); this._doc.body.innerHTML = ""; },
+    get visible() !this._container.collapsed,
+    set visible(val) this._container.collapsed = !val,
+
+    reset: function reset(brief) {
+        this._startIndex = this._endIndex = this._selIndex = -1;
+        this._div = null;
+        if (!brief)
+            this.selectItem(-1);
+    },
+
+    // if @param selectedItem is given, show the list and select that item
+    setItems: function setItems(newItems, selectedItem) {
+        if (this._selItem > -1)
+            this._getCompletion(this._selItem).removeAttribute("selected");
+        if (this._container.collapsed) {
+            this._minHeight = 0;
+            this._container.height = 0;
+        }
+        this._startIndex = this._endIndex = this._selIndex = -1;
+        this._items = newItems;
+        this.reset(true);
+        if (typeof selectedItem == "number") {
+            this.selectItem(selectedItem);
+            this.visible = true;
+        }
+    },
+
+    // select index, refill list if necessary
+    selectItem: function selectItem(index) {
+        //let now = Date.now();
+
+        if (this._div == null)
+            this._init();
+
+        let sel = this._selIndex;
+        let len = this._items.allItems.items.length;
+        let newOffset = this._startIndex;
+        let maxItems = options["maxitems"];
+        let contextLines = Math.min(3, parseInt((maxItems - 1) / 2));
+
+        if (index == -1 || index == null || index == len) { // wrapped around
+            if (this._selIndex < 0)
+                newOffset = 0;
+            this._selIndex = -1;
+            index = -1;
+        }
+        else {
+            if (index <= this._startIndex + contextLines)
+                newOffset = index - contextLines;
+            if (index >= this._endIndex - contextLines)
+                newOffset = index + contextLines - maxItems + 1;
+
+            newOffset = Math.min(newOffset, len - maxItems);
+            newOffset = Math.max(newOffset, 0);
+
+            this._selIndex = index;
+        }
+
+        if (sel > -1)
+            this._getCompletion(sel).removeAttribute("selected");
+        this._fill(newOffset);
+        if (index >= 0) {
+            this._getCompletion(index).setAttribute("selected", "true");
+            if (this._container.height != 0)
+                util.scrollIntoView(this._getCompletion(index));
+        }
+
+        //if (index == 0)
+        //    this.start = now;
+        //if (index == Math.min(len - 1, 100))
+        //    util.dump({ time: Date.now() - this.start });
+    },
+
+    onKeyPress: function onKeyPress(event) false
+}, {
+    WAITING_MESSAGE: "Generating results..."
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/dactyl.js b/common/content/dactyl.js
new file mode 100644 (file)
index 0000000..6296b9a
--- /dev/null
@@ -0,0 +1,2185 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+default xml namespace = XHTML;
+XML.ignoreWhitespace = false;
+XML.prettyPrinting = false;
+
+var userContext = { __proto__: modules };
+var _userContext = newContext(userContext);
+
+var EVAL_ERROR = "__dactyl_eval_error";
+var EVAL_RESULT = "__dactyl_eval_result";
+var EVAL_STRING = "__dactyl_eval_string";
+
+var Dactyl = Module("dactyl", XPCOM(Ci.nsISupportsWeakReference, ModuleBase), {
+    init: function () {
+        window.dactyl = this;
+        // cheap attempt at compatibility
+        let prop = { get: deprecated("dactyl", function liberator() dactyl) };
+        Object.defineProperty(window, "liberator", prop);
+        Object.defineProperty(modules, "liberator", prop);
+        this.commands = {};
+        this.indices = {};
+        this.modules = modules;
+        this._observers = {};
+        util.addObserver(this);
+
+        this.commands["dactyl.help"] = function (event) {
+            let elem = event.originalTarget;
+            dactyl.help(elem.getAttribute("tag") || elem.textContent);
+        };
+        this.commands["dactyl.restart"] = function (event) {
+            dactyl.restart();
+        };
+
+        styles.registerSheet("resource://dactyl-skin/dactyl.css");
+    },
+
+    cleanup: function () {
+        delete window.dactyl;
+        delete window.liberator;
+
+        styles.unregisterSheet("resource://dactyl-skin/dactyl.css");
+    },
+
+    destroy: function () {
+        autocommands.trigger("LeavePre", {});
+        dactyl.triggerObserver("shutdown", null);
+        util.dump("All dactyl modules destroyed\n");
+        autocommands.trigger("Leave", {});
+    },
+
+    observers: {
+        "dactyl-cleanup": function dactyl_cleanup() {
+            let modules = dactyl.modules;
+
+            for (let mod in values(modules.moduleList.reverse())) {
+                mod.stale = true;
+                if ("cleanup" in mod)
+                    this.trapErrors("cleanup", mod);
+                if ("destroy" in mod)
+                    this.trapErrors("destroy", mod);
+            }
+
+            for (let mod in values(modules.ownPropertyValues.reverse()))
+                if (mod instanceof Class && "INIT" in mod && "cleanup" in mod.INIT)
+                    this.trapErrors(mod.cleanup, mod, dactyl, modules, window);
+
+            for (let name in values(Object.getOwnPropertyNames(modules).reverse()))
+                try {
+                    delete modules[name];
+                }
+                catch (e) {}
+            modules.__proto__ = {};
+        }
+    },
+
+    /** @property {string} The name of the current user profile. */
+    profileName: Class.memoize(function () {
+        // NOTE: services.profile.selectedProfile.name doesn't return
+        // what you might expect. It returns the last _actively_ selected
+        // profile (i.e. via the Profile Manager or -P option) rather than the
+        // current profile. These will differ if the current process was run
+        // without explicitly selecting a profile.
+
+        let dir = services.directory.get("ProfD", Ci.nsIFile);
+        for (let prof in iter(services.profile.profiles))
+            if (prof.QueryInterface(Ci.nsIToolkitProfile).rootDir.path === dir.path)
+                return prof.name;
+        return "unknown";
+    }),
+
+    /**
+     * @property {number} The current main mode.
+     * @see modes#mainModes
+     */
+    mode: deprecated("modes.main", {
+        get: function mode() modes.main,
+        set: function mode(val) modes.main = val
+    }),
+
+    get menuItems() Dactyl.getMenuItems(),
+
+    // Global constants
+    CURRENT_TAB: "here",
+    NEW_TAB: "tab",
+    NEW_BACKGROUND_TAB: "background-tab",
+    NEW_WINDOW: "window",
+
+    forceNewTab: false,
+    forceNewWindow: false,
+
+    version: deprecated("config.version", { get: function version() config.version }),
+
+    /**
+     * @property {Object} The map of command-line options. These are
+     *     specified in the argument to the host application's -{config.name}
+     *     option. E.g. $ firefox -pentadactyl '+u=/tmp/rcfile ++noplugin'
+     *     Supported options:
+     *         +u RCFILE   Use RCFILE instead of .pentadactylrc.
+     *         ++noplugin  Don't load plugins.
+     *     These two can be specified multiple times:
+     *         ++cmd CMD   Execute an Ex command before initialization.
+     *         +c CMD      Execute an Ex command after initialization.
+     */
+    commandLineOptions: {
+        /** @property Whether plugin loading should be prevented. */
+        noPlugins: false,
+        /** @property An RC file to use rather than the default. */
+        rcFile: null,
+        /** @property An Ex command to run before any initialization is performed. */
+        preCommands: null,
+        /** @property An Ex command to run after all initialization has been performed. */
+        postCommands: null
+    },
+
+    registerObserver: function registerObserver(type, callback, weak) {
+        if (!(type in this._observers))
+            this._observers[type] = [];
+        this._observers[type].push(weak ? Cu.getWeakReference(callback) : { get: function () callback });
+    },
+
+    registerObservers: function registerObservers(obj, prop) {
+        for (let [signal, func] in Iterator(obj[prop || "signals"]))
+            this.registerObserver(signal, obj.closure(func), false);
+    },
+
+    unregisterObserver: function unregisterObserver(type, callback) {
+        if (type in this._observers)
+            this._observers[type] = this._observers[type].filter(function (c) c.get() != callback);
+    },
+
+    // TODO: "zoom": if the zoom value of the current buffer changed
+    applyTriggerObserver: function triggerObserver(type, args) {
+        if (type in this._observers)
+            this._observers[type] = this._observers[type].filter(function (callback) {
+                if (callback.get()) {
+                    try {
+                        try {
+                            callback.get().apply(null, args);
+                        }
+                        catch (e if e.message == "can't wrap XML objects") {
+                            // Horrible kludge.
+                            callback.get().apply(null, [String(args[0])].concat(args.slice(1)));
+                        }
+                    }
+                    catch (e) {
+                        dactyl.reportError(e);
+                    }
+                    return true;
+                }
+            });
+    },
+
+    triggerObserver: function triggerObserver(type) {
+        return this.applyTriggerObserver(type, Array.slice(arguments, 1));
+    },
+
+    addUsageCommand: function (params) {
+        let name = commands.add(params.name, params.description,
+            function (args) {
+                let results = array(params.iterate(args))
+                    .sort(function (a, b) String.localeCompare(a.name, b.name));
+
+                let filters = args.map(function (arg) RegExp("\\b" + util.regexp.escape(arg) + "\\b", "i"));
+                if (filters.length)
+                    results = results.filter(function (item) filters.every(function (re) re.test(item.name + " " + item.description)));
+
+                commandline.commandOutput(
+                    template.usage(results, params.format));
+            },
+            {
+                argCount: "*",
+                completer: function (context, args) {
+                    context.keys.text = util.identity;
+                    context.keys.description = function () seen[this.text] + " matching items";
+                    let seen = {};
+                    context.completions = array(item.description.toLowerCase().split(/[()\s]+/)
+                                                for (item in params.iterate(args)))
+                        .flatten().filter(function (w) /^\w[\w-_']+$/.test(w))
+                        .map(function (k) {
+                            seen[k] = (seen[k] || 0) + 1;
+                            return k;
+                        }).uniq();
+                },
+                options: params.options || []
+            });
+
+        if (params.index)
+            this.indices[params.index] = function () {
+                let results = array((params.iterateIndex || params.iterate).call(params, commands.get(name).newArgs()))
+                        .array.sort(function (a, b) String.localeCompare(a.name, b.name));
+
+                let tags = services["dactyl:"].HELP_TAGS;
+                for (let obj in values(results)) {
+                    let res = dactyl.generateHelp(obj, null, null, true);
+                    if (!set.has(tags, obj.helpTag))
+                        res[1].@tag = obj.helpTag;
+
+                    yield res;
+                }
+            };
+    },
+
+    /**
+     * Triggers the application bell to notify the user of an error. The
+     * bell may be either audible or visual depending on the value of the
+     * 'visualbell' option.
+     */
+    beep: function () {
+        this.triggerObserver("beep");
+        if (options["visualbell"]) {
+            let elems = {
+                bell: document.getElementById("dactyl-bell"),
+                strut: document.getElementById("dactyl-bell-strut")
+            };
+            XML.ignoreWhitespace = true;
+            if (!elems.bell)
+                util.overlayWindow(window, {
+                    objects: elems,
+                    prepend: <>
+                        <window id={document.documentElement.id} xmlns={XUL}>
+                            <hbox style="display: none" highlight="Bell" id="dactyl-bell" key="bell"/>
+                        </window>
+                    </>,
+                    append: <>
+                        <window id={document.documentElement.id} xmlns={XUL}>
+                            <hbox style="display: none" highlight="Bell" id="dactyl-bell-strut" key="strut"/>
+                        </window>
+                    </>
+                }, elems);
+
+            elems.bell.style.height = window.innerHeight + "px";
+            elems.strut.style.marginBottom = -window.innerHeight + "px";
+            elems.strut.style.display = elems.bell.style.display = "";
+
+            util.timeout(function () { elems.strut.style.display = elems.bell.style.display = "none"; }, 20);
+        }
+        else {
+            let soundService = Cc["@mozilla.org/sound;1"].getService(Ci.nsISound);
+            soundService.beep();
+        }
+    },
+
+    /**
+     * Reads a string from the system clipboard.
+     *
+     * This is same as Firefox's readFromClipboard function, but is needed for
+     * apps like Thunderbird which do not provide it.
+     *
+     * @returns {string}
+     */
+    clipboardRead: function clipboardRead(getClipboard) {
+        try {
+            const clipboard = Cc["@mozilla.org/widget/clipboard;1"].getService(Ci.nsIClipboard);
+            const transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable);
+
+            transferable.addDataFlavor("text/unicode");
+
+            let source = clipboard[getClipboard || !clipboard.supportsSelectionClipboard() ?
+                                   "kGlobalClipboard" : "kSelectionClipboard"];
+            clipboard.getData(transferable, source);
+
+            let str = {}, len = {};
+            transferable.getTransferData("text/unicode", str, len);
+
+            if (str)
+                return str.value.QueryInterface(Ci.nsISupportsString)
+                          .data.substr(0, len.value / 2);
+        }
+        catch (e) {}
+        return null;
+    },
+
+    /**
+     * Copies a string to the system clipboard. If *verbose* is specified the
+     * copied string is also echoed to the command line.
+     *
+     * @param {string} str
+     * @param {boolean} verbose
+     */
+    clipboardWrite: function clipboardWrite(str, verbose) {
+        const clipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
+        clipboardHelper.copyString(str);
+
+        if (verbose) {
+            let message = { message: "Yanked " + str };
+            try {
+                message.domains = [util.newURI(str).host];
+            }
+            catch (e) {};
+            dactyl.echomsg(message);
+        }
+    },
+
+    dump: deprecated("util.dump",
+                     { get: function dump() util.closure.dump }),
+    dumpStack: deprecated("util.dumpStack",
+                          { get: function dumpStack() util.closure.dumpStack }),
+
+    /**
+     * Outputs a plain message to the command line.
+     *
+     * @param {string} str The message to output.
+     * @param {number} flags These control the multi-line message behavior.
+     *     See {@link CommandLine#echo}.
+     */
+    echo: function echo(str, flags) {
+        commandline.echo(str, commandline.HL_NORMAL, flags);
+    },
+
+    /**
+     * Outputs an error message to the command line.
+     *
+     * @param {string} str The message to output.
+     * @param {number} flags These control the multi-line message behavior.
+     *     See {@link CommandLine#echo}.
+     */
+    echoerr: function echoerr(str, flags) {
+        flags |= commandline.APPEND_TO_MESSAGES;
+
+        if (isinstance(str, ["Error", "Exception"]))
+            dactyl.reportError(str);
+        if (isObject(str) && "echoerr" in str)
+            str = str.echoerr;
+        else if (isinstance(str, ["Error", FailedAssertion]) && str.fileName)
+            str = <>{str.fileName.replace(/^.* -> /, "")}: {str.lineNumber}: {str}</>;
+
+        if (options["errorbells"])
+            dactyl.beep();
+
+        commandline.echo(str, commandline.HL_ERRORMSG, flags);
+    },
+
+    /**
+     * Outputs a warning message to the command line.
+     *
+     * @param {string} str The message to output.
+     * @param {number} flags These control the multi-line message behavior.
+     *     See {@link CommandLine#echo}.
+     */
+    warn: function warn(str, flags) {
+        commandline.echo(str, "WarningMsg", flags | commandline.APPEND_TO_MESSAGES);
+    },
+
+    // TODO: add proper level constants
+    /**
+     * Outputs an information message to the command line.
+     *
+     * @param {string} str The message to output.
+     * @param {number} verbosity The messages log level (0 - 15). Only
+     *     messages with verbosity less than or equal to the value of the
+     *     *verbosity* option will be output.
+     * @param {number} flags These control the multi-line message behavior.
+     *     See {@link CommandLine#echo}.
+     */
+    echomsg: function echomsg(str, verbosity, flags) {
+        if (verbosity == null)
+            verbosity = 0; // verbosity level is exclusionary
+
+        if (options["verbose"] >= verbosity)
+            commandline.echo(str, commandline.HL_INFOMSG,
+                             flags | commandline.APPEND_TO_MESSAGES);
+    },
+
+    /**
+     * Loads and executes the script referenced by *uri* in the scope of the
+     * *context* object.
+     *
+     * @param {string} uri The URI of the script to load. Should be a local
+     *     chrome:, file:, or resource: URL.
+     * @param {Object} context The context object into which the script
+     *     should be loaded.
+     */
+    loadScript: function (uri, context) {
+        JSMLoader.loadSubScript(uri, context, File.defaultEncoding);
+    },
+
+    userEval: function (str, context, fileName, lineNumber) {
+        let ctxt;
+        if (jsmodules.__proto__ != window)
+            str = "with (window) { with (modules) { (this.eval || eval)(" + str.quote() + ") } }";
+
+        let info = contexts.context;
+        if (fileName == null)
+            if (info && info.file[0] !== "[")
+                ({ file: fileName, line: lineNumber, context: ctxt }) = info;
+
+        if (!context && fileName && fileName[0] !== "[")
+            context = _userContext || ctxt;
+
+        if (isinstance(context, ["Sandbox"]))
+            return Cu.evalInSandbox(str, context, "1.8", fileName, lineNumber);
+        else
+            try {
+                if (!context)
+                    context = userContext || ctxt;
+
+                context[EVAL_ERROR] = null;
+                context[EVAL_STRING] = str;
+                context[EVAL_RESULT] = null;
+                this.loadScript("resource://dactyl-content/eval.js", context);
+                if (context[EVAL_ERROR]) {
+                    try {
+                        context[EVAL_ERROR].fileName = info.file;
+                        context[EVAL_ERROR].lineNumber += info.line;
+                    }
+                    catch (e) {}
+                    throw context[EVAL_ERROR];
+                }
+                return context[EVAL_RESULT];
+            }
+            finally {
+                delete context[EVAL_ERROR];
+                delete context[EVAL_RESULT];
+                delete context[EVAL_STRING];
+            }
+    },
+
+    /**
+     * Acts like the Function builtin, but the code executes in the
+     * userContext global.
+     */
+    userFunc: function () {
+        return this.userEval(
+            "(function userFunction(" + Array.slice(arguments, 0, -1).join(", ") + ")" +
+            " { " + arguments[arguments.length - 1] + " })");
+    },
+
+    /**
+     * Execute an Ex command string. E.g. ":zoom 300".
+     *
+     * @param {string} str The command to execute.
+     * @param {Object} modifiers Any modifiers to be passed to
+     *     {@link Command#action}.
+     * @param {boolean} silent Whether the command should be echoed on the
+     *     command line.
+     */
+    execute: function (str, modifiers, silent) {
+        // skip comments and blank lines
+        if (/^\s*("|$)/.test(str))
+            return;
+
+        modifiers = modifiers || {};
+
+        if (!silent)
+            commands.lastCommand = str.replace(/^\s*:\s*/, "");
+        let res = true;
+        for (let [command, args] in commands.parseCommands(str.replace(/^'(.*)'$/, "$1"))) {
+            if (command === null)
+                throw FailedAssertion(_("dactyl.notCommand", config.appName, args.commandString));
+
+            res = res && command.execute(args, modifiers);
+        }
+        return res;
+    },
+
+    focus: function focus(elem, flags) {
+        flags = flags || services.focus.FLAG_BYMOUSE;
+        try {
+            if (elem instanceof Document)
+                elem = elem.defaultView;
+            if (elem instanceof Element)
+                services.focus.setFocus(elem, flags);
+            else if (elem instanceof Window)
+                services.focus.focusedWindow = elem;
+        }
+        catch (e) {
+            util.dump(elem);
+            util.reportError(e);
+        }
+    },
+
+    /**
+     * Focuses the content window.
+     *
+     * @param {boolean} clearFocusedElement Remove focus from any focused
+     *     element.
+     */
+    focusContent: function focusContent(clearFocusedElement) {
+        if (window != services.focus.activeWindow)
+            return;
+
+        let win = document.commandDispatcher.focusedWindow;
+        let elem = config.mainWidget || content;
+
+        // TODO: make more generic
+        try {
+            if (this.has("mail") && !config.isComposeWindow) {
+                let i = gDBView.selection.currentIndex;
+                if (i == -1 && gDBView.rowCount >= 0)
+                    i = 0;
+                gDBView.selection.select(i);
+            }
+            else {
+                let frame = buffer.focusedFrame;
+                if (frame && frame.top == content && !Editor.getEditor(frame))
+                    elem = frame;
+            }
+        }
+        catch (e) {}
+
+        if (clearFocusedElement) {
+            if (dactyl.focusedElement)
+                dactyl.focusedElement.blur();
+            if (win && Editor.getEditor(win)) {
+                this.withSavedValues(["ignoreFocus"], function _focusContent() {
+                    this.ignoreFocus = true;
+                    if (win.frameElement)
+                        win.frameElement.blur();
+                    // Grr.
+                    if (content.document.activeElement instanceof HTMLIFrameElement)
+                        content.document.activeElement.blur();
+                });
+            }
+        }
+
+        if (elem instanceof Window && Editor.getEditor(elem))
+            elem = window;
+
+        if (elem && elem != dactyl.focusedElement)
+            dactyl.focus(elem);
+     },
+
+    /** @property {Element} The currently focused element. */
+    get focusedElement() services.focus.getFocusedElementForWindow(window, true, {}),
+    set focusedElement(elem) dactyl.focus(elem),
+
+    /**
+     * Returns whether this Dactyl extension supports *feature*.
+     *
+     * @param {string} feature The feature name.
+     * @returns {boolean}
+     */
+    has: function (feature) set.has(config.features, feature),
+
+    /**
+     * Returns the URL of the specified help *topic* if it exists.
+     *
+     * @param {string} topic The help topic to look up.
+     * @param {boolean} consolidated Whether to search the consolidated help page.
+     * @returns {string}
+     */
+    findHelp: function (topic, consolidated) {
+        if (!consolidated && topic in services["dactyl:"].FILE_MAP)
+            return topic;
+        let items = completion._runCompleter("help", topic, null, !!consolidated).items;
+        let partialMatch = null;
+
+        function format(item) item.description + "#" + encodeURIComponent(item.text);
+
+        for (let [i, item] in Iterator(items)) {
+            if (item.text == topic)
+                return format(item);
+            else if (!partialMatch && topic)
+                partialMatch = item;
+        }
+
+        if (partialMatch)
+            return format(partialMatch);
+        return null;
+    },
+
+    /**
+     * @private
+     */
+    initDocument: function initDocument(doc) {
+        try {
+            if (doc.location.protocol === "dactyl:") {
+                dactyl.initHelp();
+                config.styleHelp();
+            }
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+    },
+
+    /**
+     * @private
+     * Initialize the help system.
+     */
+    initHelp: function (force) {
+        if (force || !this.helpInitialized) {
+            if ("noscriptOverlay" in window) {
+                noscriptOverlay.safeAllow("chrome-data:", true, false);
+                noscriptOverlay.safeAllow("dactyl:", true, false);
+            }
+
+            // Find help and overlay files with the given name.
+            let findHelpFile = function findHelpFile(file) {
+                let result = [];
+                for (let [, namespace] in Iterator(namespaces)) {
+                    let url = ["dactyl://", namespace, "/", file, ".xml"].join("");
+                    let res = util.httpGet(url);
+                    if (res) {
+                        if (res.responseXML.documentElement.localName == "document")
+                            fileMap[file] = url;
+                        if (res.responseXML.documentElement.localName == "overlay")
+                            overlayMap[file] = url;
+                        result.push(res.responseXML);
+                    }
+                }
+                return result;
+            };
+            // Find the tags in the document.
+            let addTags = function addTags(file, doc) {
+                for (let elem in util.evaluateXPath("//@tag|//dactyl:tags/text()|//dactyl:tag/text()", doc))
+                    for (let tag in values((elem.value || elem.textContent).split(/\s+/)))
+                        tagMap[tag] = file;
+            };
+
+            let namespaces = ["locale-local", "locale"];
+            services["dactyl:"].init({});
+
+            let tagMap = services["dactyl:"].HELP_TAGS;
+            let fileMap = services["dactyl:"].FILE_MAP;
+            let overlayMap = services["dactyl:"].OVERLAY_MAP;
+
+            // Scrape the list of help files from all.xml
+            // Manually process main and overlay files, since XSLTProcessor and
+            // XMLHttpRequest don't allow access to chrome documents.
+            tagMap["all"] = tagMap["all.xml"] = "all";
+            tagMap["versions"] = tagMap["versions.xml"] = "versions";
+            let files = findHelpFile("all").map(function (doc)
+                    [f.value for (f in util.evaluateXPath("//dactyl:include/@href", doc))]);
+
+            // Scrape the tags from the rest of the help files.
+            array.flatten(files).forEach(function (file) {
+                tagMap[file + ".xml"] = file;
+                findHelpFile(file).forEach(function (doc) {
+                    addTags(file, doc);
+                });
+            });
+
+            // Process plugin help entries.
+            XML.ignoreWhiteSpace = XML.prettyPrinting = false;
+
+            let body = XML();
+            for (let [, context] in Iterator(plugins.contexts))
+                if (context && context.INFO instanceof XML) {
+                    let info = context.INFO;
+                    if (info.*.@lang.length()) {
+                        let lang = config.bestLocale(String(a) for each (a in info.*.@lang));
+
+                        info.* = info.*.(function::attribute("lang").length() == 0 || @lang == lang);
+
+                        for each (let elem in info.NS::info)
+                            for each (let attr in ["@name", "@summary", "@href"])
+                                if (elem[attr].length())
+                                    info[attr] = elem[attr];
+                    }
+                    body += <h2 xmlns={NS.uri} tag={context.INFO.@name + '-plugin'}>{context.INFO.@summary}</h2> +
+                        context.INFO;
+                }
+
+            let help =
+                '<?xml version="1.0"?>\n' +
+                '<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>\n' +
+                '<!DOCTYPE document SYSTEM "resource://dactyl-content/dactyl.dtd">\n' +
+                unescape(encodeURI( // UTF-8 handling hack.
+                <document xmlns={NS}
+                    name="plugins" title={config.appName + " Plugins"}>
+                    <h1 tag="using-plugins">Using Plugins</h1>
+                    <toc start="2"/>
+
+                    {body}
+                </document>.toXMLString()));
+            fileMap["plugins"] = function () ['text/xml;charset=UTF-8', help];
+
+            fileMap["versions"] = function () {
+                let NEWS = util.httpGet(config.addon.getResourceURI("NEWS").spec,
+                                        { mimeType: "text/plain;charset=UTF-8" })
+                               .responseText;
+
+                let re = util.regexp(<![CDATA[
+                      ^ (?P<comment> \s* # .*\n)
+
+                    | ^ (?P<space> \s*)
+                        (?P<char>  [-•*+]) \ //
+                      (?P<content> .*\n
+                         (?: \2\ \ .*\n | \s*\n)* )
+
+                    | (?P<par>
+                          (?: ^ [^\S\n]*
+                              (?:[^-•*+\s] | [-•*+]\S)
+                              .*\n
+                          )+
+                      )
+
+                    | (?: ^ [^\S\n]* \n) +
+                ]]>, "gmxy");
+
+                let betas = util.regexp(/\[(b\d)\]/, "gx");
+
+                let beta = array(betas.iterate(NEWS))
+                            .map(function (m) m[1]).uniq().slice(-1)[0];
+
+                default xml namespace = NS;
+                function rec(text, level, li) {
+                    let res = <></>;
+                    let list, space, i = 0;
+
+                    for (let match in re.iterate(text)) {
+                        if (match.comment)
+                            continue;
+                        else if (match.char) {
+                            if (!list)
+                                res += list = <ul/>;
+                            let li = <li/>;
+                            li.* += rec(match.content.replace(RegExp("^" + match.space, "gm"), ""), level + 1, li)
+                            list.* += li;
+                        }
+                        else if (match.par) {
+                            let [, par, tags] = /([^]*?)\s*((?:\[[^\]]+\])*)\n*$/.exec(match.par);
+                            let t = tags;
+                            tags = array(betas.iterate(tags)).map(function (m) m[1]);
+
+                            let group = !tags.length                       ? "" :
+                                        !tags.some(function (t) t == beta) ? "HelpNewsOld" : "HelpNewsNew";
+                            if (i === 0 && li) {
+                                li.@highlight = group;
+                                group = "";
+                            }
+
+                            list = null;
+                            if (level == 0 && /^.*:\n$/.test(match.par))
+                                res += <h2>{template.linkifyHelp(par.slice(0, -1), true)}</h2>;
+                            else {
+                                let [, a, b] = /^(IMPORTANT:?)?([^]*)/.exec(par);
+                                res += <p highlight={group + " HelpNews"}>{
+                                    !tags.length ? "" :
+                                    <hl key="HelpNewsTag">{tags.join(" ")}</hl>
+                                }{
+                                    a ? <hl key="HelpWarning">{a}</hl> : ""
+                                }{
+                                    template.linkifyHelp(b, true)
+                                }</p>;
+                            }
+                        }
+                        i++;
+                    }
+                    for each (let attr in res..@highlight) {
+                        attr.parent().@NS::highlight = attr;
+                        delete attr.parent().@highlight;
+                    }
+                    return res;
+                }
+
+                let body = rec(NEWS, 0);
+                for each (let li in body..li) {
+                    let list = li..li.(@NS::highlight == "HelpNewsOld");
+                    if (list.length() && list.length() == li..li.(@NS::highlight != "").length()) {
+                        for each (let li in list)
+                            li.@NS::highlight = "";
+                        li.@NS::highlight = "HelpNewsOld";
+                    }
+                }
+
+                XML.prettyPrinting = XML.ignoreWhitespace = false;
+                return ["application/xml",
+                    '<?xml version="1.0"?>\n' +
+                    '<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>\n' +
+                    '<!DOCTYPE document SYSTEM "resource://dactyl-content/dactyl.dtd">\n' +
+                    unescape(encodeURI( // UTF-8 handling hack.
+                    <document xmlns={NS} xmlns:dactyl={NS}
+                        name="versions" title={config.appName + " Versions"}>
+                        <h1 tag="versions news NEWS">{config.appName} Versions</h1>
+                        <toc start="2"/>
+
+                        {body}
+                    </document>.toXMLString()))
+                ];
+            }
+            addTags("versions", util.httpGet("dactyl://help/versions").responseXML);
+            addTags("plugins", util.httpGet("dactyl://help/plugins").responseXML);
+
+            default xml namespace = NS;
+
+            overlayMap["index"] = ['text/xml;charset=UTF-8',
+                '<?xml version="1.0"?>\n' +
+                '<overlay xmlns="' + NS + '">\n' +
+                unescape(encodeURI( // UTF-8 handling hack.
+                template.map(dactyl.indices, function ([name, iter])
+                    <dl insertafter={name + "-index"}>{
+                        template.map(iter(), util.identity)
+                    }</dl>, <>{"\n\n"}</>))) +
+                '\n</overlay>'];
+
+            addTags("index", util.httpGet("dactyl://help-overlay/index").responseXML);
+
+            this.helpInitialized = true;
+        }
+    },
+
+    stringifyXML: function (xml) {
+        XML.prettyPrinting = false;
+        XML.ignoreWhitespace = false;
+        return UTF8(xml.toXMLString());
+    },
+
+    exportHelp: JavaScript.setCompleter(function (path) {
+        const FILE = io.File(path);
+        const PATH = FILE.leafName.replace(/\..*/, "") + "/";
+        const TIME = Date.now();
+
+        if (!FILE.exists() && (/\/$/.test(path) && !/\./.test(FILE.leafName)))
+            FILE.create(FILE.DIRECTORY_TYPE, octal(755));
+
+        dactyl.initHelp();
+        if (FILE.isDirectory()) {
+            var addDataEntry = function addDataEntry(file, data) FILE.child(file).write(data);
+            var addURIEntry  = function addURIEntry(file, uri) addDataEntry(file, util.httpGet(uri).responseText);
+        }
+        else {
+            var zip = services.ZipWriter();
+            zip.open(FILE, File.MODE_CREATE | File.MODE_WRONLY | File.MODE_TRUNCATE);
+
+            addURIEntry = function addURIEntry(file, uri)
+                zip.addEntryChannel(PATH + file, TIME, 9,
+                    services.io.newChannel(uri, null, null), false);
+            addDataEntry = function addDataEntry(file, data) // Unideal to an extreme.
+                addURIEntry(file, "data:text/plain;charset=UTF-8," + encodeURI(data));
+        }
+
+        let empty = set("area base basefont br col frame hr img input isindex link meta param"
+                            .split(" "));
+        function fix(node) {
+            switch(node.nodeType) {
+                case Node.ELEMENT_NODE:
+                    if (isinstance(node, [HTMLBaseElement]))
+                        return;
+
+                    data.push("<"); data.push(node.localName);
+                    if (node instanceof HTMLHtmlElement)
+                        data.push(" xmlns=" + XHTML.uri.quote());
+
+                    for (let { name, value } in array.iterValues(node.attributes)) {
+                        if (name == "dactyl:highlight") {
+                            set.add(styles, value);
+                            name = "class";
+                            value = "hl-" + value;
+                        }
+                        if (name == "href") {
+                            value = node.href;
+                            if (value.indexOf("dactyl://help-tag/") == 0) {
+                                let uri = services.io.newChannel(value, null, null).originalURI;
+                                value = uri.spec == value ? "javascript:;" : uri.path.substr(1);
+                            }
+                            if (!/^#|[\/](#|$)|^[a-z]+:/.test(value))
+                                value = value.replace(/(#|$)/, ".xhtml$1");
+                        }
+                        if (name == "src" && value.indexOf(":") > 0) {
+                            chromeFiles[value] = value.replace(/.*\//, "");
+                            value = value.replace(/.*\//, "");
+                        }
+                        data.push(" ");
+                        data.push(name);
+                        data.push('="');
+                        data.push(<>{value}</>.toXMLString());
+                        data.push('"');
+                    }
+                    if (node.localName in empty)
+                        data.push(" />");
+                    else {
+                        data.push(">");
+                        if (node instanceof HTMLHeadElement)
+                            data.push(<link rel="stylesheet" type="text/css" href="help.css"/>.toXMLString());
+                        Array.map(node.childNodes, fix);
+                        data.push("</"); data.push(node.localName); data.push(">");
+                    }
+                    break;
+                case Node.TEXT_NODE:
+                    data.push(<>{node.textContent}</>.toXMLString());
+            }
+        }
+
+        let chromeFiles = {};
+        let styles = {};
+        for (let [file, ] in Iterator(services["dactyl:"].FILE_MAP)) {
+            dactyl.open("dactyl://help/" + file);
+            dactyl.modules.events.waitForPageLoad();
+            let data = [
+                '<?xml version="1.0" encoding="UTF-8"?>\n',
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"\n',
+                '          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n'
+            ];
+            fix(content.document.documentElement);
+            addDataEntry(file + ".xhtml", data.join(""));
+        }
+
+        let data = [h for (h in highlight) if (set.has(styles, h.class) || /^Help/.test(h.class))]
+            .map(function (h) h.selector
+                               .replace(/^\[.*?=(.*?)\]/, ".hl-$1")
+                               .replace(/html\|/g, "") + "\t" + "{" + h.cssText + "}")
+            .join("\n");
+        addDataEntry("help.css", data.replace(/chrome:[^ ")]+\//g, ""));
+
+        addDataEntry("tag-map.json", JSON.stringify(services["dactyl:"].HELP_TAGS));
+
+        let m, re = /(chrome:[^ ");]+\/)([^ ");]+)/g;
+        while ((m = re.exec(data)))
+            chromeFiles[m[0]] = m[2];
+
+        for (let [uri, leaf] in Iterator(chromeFiles))
+            addURIEntry(leaf, uri);
+
+        if (zip)
+            zip.close();
+    }, [function (context, args) completion.file(context)]),
+
+    /**
+     * Generates a help entry and returns it as a string.
+     *
+     * @param {Command|Map|Option} obj A dactyl *Command*, *Map* or *Option*
+     *     object
+     * @param {XMLList} extraHelp Extra help text beyond the description.
+     * @returns {string}
+     */
+    generateHelp: function generateHelp(obj, extraHelp, str, specOnly) {
+        default xml namespace = "";
+
+        let link, tag, spec;
+        link = tag = spec = util.identity;
+        let args = null;
+
+        if (obj instanceof Command) {
+            link = function (cmd) <ex>{cmd}</ex>;
+            args = obj.parseArgs("", CompletionContext(str || ""));
+            spec = function (cmd) cmd + (obj.bang ? <oa>!</oa> : <></>);
+        }
+        else if (obj instanceof Map) {
+            spec = function (map) obj.count ? <><oa>count</oa>{map}</> : <>{map}</>;
+            link = function (map) {
+                let [, mode, name, extra] = /^(?:(.)_)?(?:<([^>]+)>)?(.*)$/.exec(map);
+                let k = <k>{extra}</k>;
+                if (name)
+                    k.@name = name;
+                if (mode)
+                    k.@mode = mode;
+                return k;
+            };
+        }
+        else if (obj instanceof Option) {
+            link = function (opt, name) <o>{name}</o>;
+        }
+
+        XML.prettyPrinting = false;
+        XML.ignoreWhitespace = false;
+        default xml namespace = NS;
+
+        // E4X has its warts.
+        let br = <>
+                    </>;
+
+        let res = <res>
+                <dt>{link(obj.helpTag || obj.name, obj.name)}</dt> <dd>{
+                    template.linkifyHelp(obj.description ? obj.description.replace(/\.$/, "") : "", true)
+                }</dd></res>;
+        if (specOnly)
+            return res.elements();
+
+        res.* += <>
+            <item>
+                <tags>{template.map(obj.names.slice().reverse(), tag, " ")}</tags>
+                <spec>{
+                    spec(template.highlightRegexp((obj.specs || obj.names)[0],
+                                                  /\[(.*?)\]/g,
+                                                  function (m, n0) <oa>{n0}</oa>))
+                }</spec>{
+                !obj.type ? "" : <>
+                <type>{obj.type}</type>
+                <default>{obj.stringDefaultValue}</default></>}
+                <description>{
+                    obj.description ? br + <p>{template.linkifyHelp(obj.description.replace(/\.?$/, "."), true)}</p> : "" }{
+                        extraHelp ? br + extraHelp : "" }{
+                        !(extraHelp || obj.description) ? br + <p>Sorry, no help available.</p> : "" }
+                </description>
+            </item></>;
+
+        function add(ary) {
+            res.item.description.* += br +
+                let (br = br + <>    </>)
+                    <><dl>{ br + template.map(ary, function ([a, b]) <><dt>{a}</dt> <dd>{b}</dd></>, br) }
+                    </dl>
+                </>;
+        }
+
+        if (obj.completer)
+            add(completion._runCompleter(obj.completer, "", null, args).items
+                          .map(function (i) [i.text, i.description]));
+
+        if (obj.options && obj.options.some(function (o) o.description))
+            add(obj.options.filter(function (o) o.description)
+                   .map(function (o) [
+                        o.names[0],
+                        <>{o.description}{
+                            o.names.length == 1 ? "" :
+                                <> (short name: {
+                                    template.map(o.names.slice(1), function (n) <em>{n}</em>, <>, </>)
+                                })</>
+                        }</>
+                    ]));
+        return res.*.toXMLString()
+                  .replace(' xmlns="' + NS + '"', "", "g")
+                  .replace(/^ {12}|[ \t]+$/gm, "")
+                  .replace(/^\s*\n|\n\s*$/g, "") + "\n";
+    },
+
+    /**
+     * Opens the help page containing the specified *topic* if it exists.
+     *
+     * @param {string} topic The help topic to open.
+     * @param {boolean} consolidated Whether to use the consolidated help page.
+     */
+    help: function (topic, consolidated) {
+        dactyl.initHelp();
+        if (!topic) {
+            let helpFile = consolidated ? "all" : options["helpfile"];
+
+            if (helpFile in services["dactyl:"].FILE_MAP)
+                dactyl.open("dactyl://help/" + helpFile, { from: "help" });
+            else
+                dactyl.echomsg(_("help.noFile", helpFile.quote()));
+            return;
+        }
+
+        let page = this.findHelp(topic, consolidated);
+        dactyl.assert(page != null, _("help.noTopic", topic));
+
+        dactyl.open("dactyl://help/" + page, { from: "help" });
+    },
+
+    /**
+     * The map of global variables.
+     *
+     * These are set and accessed with the "g:" prefix.
+     */
+    _globalVariables: {},
+    globalVariables: deprecated("the options system", {
+        get: function globalVariables() this._globalVariables
+    }),
+
+    loadPlugins: function (args, force) {
+        function sourceDirectory(dir) {
+            dactyl.assert(dir.isReadable(), _("io.notReadable", dir.path));
+
+            dactyl.log(_("dactyl.sourcingPlugins", dir.path), 3);
+
+            let loadplugins = options.get("loadplugins");
+            if (args)
+                loadplugins = { __proto__: loadplugins, value: args.map(Option.parseRegexp) }
+
+            dir.readDirectory(true).forEach(function (file) {
+                if (file.isFile() && loadplugins.getKey(file.path) && !(!force && file.path in dactyl.pluginFiles)) {
+                    try {
+                        io.source(file.path);
+                        dactyl.pluginFiles[file.path] = true;
+                    }
+                    catch (e) {
+                        dactyl.reportError(e);
+                    }
+                }
+                else if (file.isDirectory())
+                    sourceDirectory(file);
+            });
+        }
+
+        let dirs = io.getRuntimeDirectories("plugins");
+
+        if (dirs.length == 0) {
+            dactyl.log(_("dactyl.noPluginDir"), 3);
+            return;
+        }
+
+        dactyl.echomsg(
+            _("plugin.searchingForIn",
+                ("plugins/**/*.{js," + config.fileExtension + "}").quote(),
+                [dir.path.replace(/.plugins$/, "") for ([, dir] in Iterator(dirs))]
+                    .join(",").quote()),
+            2);
+
+        dirs.forEach(function (dir) {
+            dactyl.echomsg(_("plugin.searchingFor", (dir.path + "/**/*.{js," + config.fileExtension + "}").quote()), 3);
+            sourceDirectory(dir);
+        });
+    },
+
+    // TODO: add proper level constants
+    /**
+     * Logs a message to the JavaScript error console. Each message has an
+     * associated log level. Only messages with a log level less than or equal
+     * to *level* will be printed. If *msg* is an object, it is pretty printed.
+     *
+     * @param {string|Object} msg The message to print.
+     * @param {number} level The logging level 0 - 15.
+     */
+    log: function (msg, level) {
+        let verbose = localPrefs.get("loglevel", 0);
+
+        if (!level || level <= verbose) {
+            if (isObject(msg) && !isinstance(msg, _))
+                msg = util.objectToString(msg, false);
+
+            services.console.logStringMessage(config.name + ": " + msg);
+        }
+    },
+
+    onClick: function onClick(event) {
+        if (event.originalTarget instanceof Element) {
+            let command = event.originalTarget.getAttributeNS(NS, "command");
+            if (command && event.button == 0) {
+                event.preventDefault();
+
+                if (dactyl.commands[command])
+                    dactyl.withSavedValues(["forceNewTab"], function () {
+                        dactyl.forceNewTab = event.ctrlKey || event.shiftKey || event.button == 1;
+                        dactyl.commands[command](event);
+                    });
+            }
+        }
+    },
+
+    onExecute: function onExecute(event) {
+        let cmd = event.originalTarget.getAttribute("dactyl-execute");
+        commands.execute(cmd, null, false, null,
+                         { file: "[Command Line]", line: 1 });
+    },
+
+    /**
+     * Opens one or more URLs. Returns true when load was initiated, or
+     * false on error.
+     *
+     * @param {string|Array} urls A representation of the URLs to open. May be
+     *     either a string, which will be passed to
+     *     {@see Dactyl#parseURLs}, or an array in the same format as
+     *     would be returned by the same.
+     * @param {object} params A set of parameters specifying how to open the
+     *     URLs. The following properties are recognized:
+     *
+     *      • background   If true, new tabs are opened in the background.
+     *
+     *      • from         The designation of the opener, as appears in
+     *                     'activate' and 'newtab' options. If present,
+     *                     the newtab option provides the default 'where'
+     *                     parameter, and the value of the 'activate'
+     *                     parameter is inverted if 'background' is true.
+     *
+     *      • where        One of CURRENT_TAB, NEW_TAB, or NEW_WINDOW
+     *
+     *      As a deprecated special case, the where parameter may be provided
+     *      by itself, in which case it is transformed into { where: params }.
+     *
+     * @param {boolean} force Don't prompt whether to open more than 20
+     *     tabs.
+     * @returns {boolean}
+     */
+    open: function (urls, params, force) {
+        if (typeof urls == "string")
+            urls = dactyl.parseURLs(urls);
+
+        if (urls.length > prefs.get("browser.tabs.maxOpenBeforeWarn", 20) && !force)
+            return commandline.input("This will open " + urls.length + " new tabs. Would you like to continue? (yes/[no]) ",
+                function (resp) {
+                    if (resp && resp.match(/^y(es)?$/i))
+                        dactyl.open(urls, params, true);
+                });
+
+        params = params || {};
+        if (isString(params))
+            params = { where: params };
+
+        let flags = 0;
+        for (let [opt, flag] in Iterator({ replace: "REPLACE_HISTORY", hide: "BYPASS_HISTORY" }))
+            flags |= params[opt] && Ci.nsIWebNavigation["LOAD_FLAGS_" + flag];
+
+        let where = params.where || dactyl.CURRENT_TAB;
+        let background = ("background" in params) ? params.background
+                                                  : params.where == dactyl.NEW_BACKGROUND_TAB;
+
+        if (params.from && dactyl.has("tabs")) {
+            if (!params.where && options.get("newtab").has(params.from))
+                where = dactyl.NEW_TAB;
+            background ^= !options.get("activate").has(params.from);
+        }
+
+        if (urls.length == 0)
+            return;
+
+        let browser = config.tabbrowser;
+        function open(urls, where) {
+            try {
+                let url = Array.concat(urls)[0];
+                let postdata = Array.concat(urls)[1];
+
+                // decide where to load the first url
+                switch (where) {
+
+                case dactyl.NEW_TAB:
+                    if (!dactyl.has("tabs"))
+                        return open(urls, dactyl.NEW_WINDOW);
+
+                    return prefs.withContext(function () {
+                        prefs.set("browser.tabs.loadInBackground", true);
+                        return browser.loadOneTab(url, null, null, postdata, background).linkedBrowser.contentDocument;
+                    });
+
+                case dactyl.NEW_WINDOW:
+                    let win = window.openDialog(document.documentURI, "_blank", "chrome,all,dialog=no");
+                    util.waitFor(function () win.document.readyState === "complete");
+                    browser = win.dactyl && win.dactyl.modules.config.tabbrowser || win.getBrowser();
+                    // FALLTHROUGH
+                case dactyl.CURRENT_TAB:
+                    browser.loadURIWithFlags(url, flags, null, null, postdata);
+                    return browser.contentWindow;
+                }
+            }
+            catch (e) {}
+            // Unfortunately, failed page loads throw exceptions and
+            // cause a lot of unwanted noise. This solution means that
+            // any genuine errors go unreported.
+        }
+
+        if (dactyl.forceNewTab)
+            where = dactyl.NEW_TAB;
+        else if (dactyl.forceNewWindow)
+            where = dactyl.NEW_WINDOW;
+        else if (!where)
+            where = dactyl.CURRENT_TAB;
+
+        return urls.map(function (url) {
+            let res = open(url, where);
+            where = dactyl.NEW_TAB;
+            background = true;
+            return res;
+        });
+    },
+
+    /**
+     * Returns an array of URLs parsed from *str*.
+     *
+     * Given a string like 'google bla, www.osnews.com' return an array
+     * ['www.google.com/search?q=bla', 'www.osnews.com']
+     *
+     * @param {string} str
+     * @returns {string[]}
+     */
+    parseURLs: function parseURLs(str) {
+        let urls;
+
+        if (options["urlseparator"])
+            urls = util.splitLiteral(str, util.regexp("\\s*" + options["urlseparator"] + "\\s*"));
+        else
+            urls = [str];
+
+        return urls.map(function (url) {
+            url = url.trim();
+
+            if (/^(\.{0,2}|~)(\/|$)/.test(url)) {
+                try {
+                    // Try to find a matching file.
+                    let file = io.File(url);
+                    if (file.exists() && file.isReadable())
+                        return services.io.newFileURI(file).spec;
+                }
+                catch (e) {}
+            }
+
+            // If it starts with a valid protocol, pass it through.
+            let proto = /^([-\w]+):/.exec(url);
+            if (proto && "@mozilla.org/network/protocol;1?name=" + proto[1] in Cc)
+                return url.replace(/\s+/g, "");
+
+            // Check for a matching search keyword.
+            let searchURL = this.has("bookmarks") && bookmarks.getSearchURL(url, false);
+            if (searchURL)
+                return searchURL;
+
+            // If it looks like URL-ish (foo.com/bar), let Gecko figure it out.
+            if (this.urlish.test(url) || !this.has("bookmarks"))
+                return util.createURI(url).spec;
+
+            // Pass it off to the default search engine or, failing
+            // that, let Gecko deal with it as is.
+            return bookmarks.getSearchURL(url, true) || util.createURI(url).spec;
+        }, this);
+    },
+    stringToURLArray: deprecated("dactyl.parseURLs", "parseURLs"),
+    urlish: Class.memoize(function () util.regexp(<![CDATA[
+            ^ (
+                <domain>+ (:\d+)? (/ .*) |
+                <domain>+ (:\d+) |
+                <domain>+ \. [a-z0-9]+ |
+                localhost
+            ) $
+        ]]>, "ix", {
+        domain: util.regexp(String.replace(<![CDATA[
+            [^
+                U0000-U002c // U002d-U002e --.
+                U002f       // /
+                            // U0030-U0039 0-9
+                U003a-U0040 // U0041-U005a a-z
+                U005b-U0060 // U0061-U007a A-Z
+                U007b-U007f
+            ]
+        ]]>, /U/g, "\\u"), "x")
+    })),
+
+    pluginFiles: {},
+
+    get plugins() plugins,
+
+    setNodeVisible: function setNodeVisible(node, visible) {
+        if (window.setToolbarVisibility && node.localName == "toolbar")
+            window.setToolbarVisibility(node, visible);
+        else
+            node.collapsed = !visible;
+    },
+
+    confirmQuit: function confirmQuit()
+        prefs.withContext(function () {
+            prefs.set("browser.warnOnQuit", false);
+            return window.canQuitApplication();
+        }),
+
+    /**
+     * Quit the host application, no matter how many tabs/windows are open.
+     *
+     * @param {boolean} saveSession If true the current session will be
+     *     saved and restored when the host application is restarted.
+     * @param {boolean} force Forcibly quit irrespective of whether all
+     *    windows could be closed individually.
+     */
+    quit: function (saveSession, force) {
+        if (!force && !this.confirmQuit())
+            return;
+
+        let pref = "browser.startup.page";
+        prefs.save(pref);
+        if (saveSession)
+            prefs.safeSet(pref, 3);
+        if (!saveSession && prefs.get(pref) >= 2)
+            prefs.safeSet(pref, 1);
+
+        services.appStartup.quit(Ci.nsIAppStartup[force ? "eForceQuit" : "eAttemptQuit"]);
+    },
+
+    /**
+     * Restart the host application.
+     */
+    restart: function () {
+        if (!this.confirmQuit())
+            return;
+
+        services.appStartup.quit(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart);
+    },
+
+    get assert() util.assert,
+
+    /**
+     * Traps errors in the called function, possibly reporting them.
+     *
+     * @param {function} func The function to call
+     * @param {object} self The 'this' object for the function.
+     */
+    trapErrors: function trapErrors(func, self) {
+        try {
+            if (isString(func))
+                func = self[func];
+            return func.apply(self || this, Array.slice(arguments, 2));
+        }
+        catch (e) {
+            dactyl.reportError(e, true);
+            return e;
+        }
+    },
+
+    /**
+     * Reports an error to both the console and the host application's
+     * Error Console.
+     *
+     * @param {Object} error The error object.
+     */
+    reportError: function reportError(error, echo) {
+        if (error instanceof FailedAssertion && error.noTrace || error.message === "Interrupted") {
+            let context = contexts.context;
+            let prefix = context ? context.file + ":" + context.line + ": " : "";
+            if (error.message && error.message.indexOf(prefix) !== 0)
+                error.message = prefix + error.message;
+
+            if (error.message)
+                dactyl.echoerr(template.linkifyHelp(error.message));
+            else
+                dactyl.beep();
+
+            if (!error.noTrace)
+                util.reportError(error);
+            return;
+        }
+        if (error.result == Cr.NS_BINDING_ABORTED)
+            return;
+        if (echo)
+            dactyl.echoerr(error, commandline.FORCE_SINGLELINE);
+        else
+            util.reportError(error);
+    },
+
+    /**
+     * Parses a Dactyl command-line string i.e. the value of the
+     * -dactyl command-line option.
+     *
+     * @param {string} cmdline The string to parse for command-line
+     *     options.
+     * @returns {Object}
+     * @see Commands#parseArgs
+     */
+    parseCommandLine: function (cmdline) {
+        try {
+            return commands.get("rehash").parseArgs(cmdline);
+        }
+        catch (e) {
+            dactyl.reportError(e, true);
+            return [];
+        }
+    },
+
+    wrapCallback: function (callback, self) {
+        self = self || this;
+        let save = ["forceNewTab", "forceNewWindow"];
+        let saved = save.map(function (p) dactyl[p]);
+        return function wrappedCallback() {
+            let args = arguments;
+            return dactyl.withSavedValues(save, function () {
+                saved.forEach(function (p, i) dactyl[save[i]] = p);
+                try {
+                    return callback.apply(self, args);
+                }
+                catch (e) {
+                    dactyl.reportError(e, true);
+                }
+            });
+        }
+    },
+
+    /**
+     * @property {Window[]} Returns an array of all the host application's
+     *     open windows.
+     */
+    get windows() [win for (win in iter(services.windowMediator.getEnumerator("navigator:browser")))],
+
+}, {
+    // initially hide all GUI elements, they are later restored unless the user
+    // has :set go= or something similar in his config
+    hideGUI: function () {
+        let guioptions = config.guioptions;
+        for (let option in guioptions) {
+            guioptions[option].forEach(function (elem) {
+                try {
+                    document.getElementById(elem).collapsed = true;
+                }
+                catch (e) {}
+            });
+        }
+    },
+
+    // TODO: move this
+    getMenuItems: function () {
+        function addChildren(node, parent) {
+            for (let [, item] in Iterator(node.childNodes)) {
+                if (item.childNodes.length == 0 && item.localName == "menuitem"
+                    && !/rdf:http:/.test(item.getAttribute("label"))) { // FIXME
+                    item.fullMenuPath = parent + item.getAttribute("label");
+                    items.push(item);
+                }
+                else {
+                    let path = parent;
+                    if (item.localName == "menu")
+                        path += item.getAttribute("label") + ".";
+                    addChildren(item, path);
+                }
+            }
+        }
+
+        let items = [];
+        addChildren(document.getElementById(config.guioptions["m"][1]), "");
+        return items;
+    }
+}, {
+    events: function () {
+        events.listen(window, "click", dactyl.closure.onClick, true);
+        events.listen(window, "dactyl.execute", dactyl.closure.onExecute, true);
+    },
+    // Only general options are added here, which are valid for all Dactyl extensions
+    options: function () {
+        options.add(["errorbells", "eb"],
+            "Ring the bell when an error message is displayed",
+            "boolean", false);
+
+        options.add(["exrc", "ex"],
+            "Enable automatic sourcing of an RC file in the current directory at startup",
+            "boolean", false);
+
+        options.add(["fullscreen", "fs"],
+            "Show the current window fullscreen",
+            "boolean", false, {
+                setter: function (value) window.fullScreen = value,
+                getter: function () window.fullScreen
+            });
+
+        const groups = [
+            {
+                opts: {
+                    c: ["Always show the command line, even when empty"],
+                    C: ["Always show the command line outside of the status line"],
+                    M: ["Always show messages outside of the status line"]
+                },
+                setter: function (opts) {
+                    if (loaded.commandline)
+                        commandline.widgets.updateVisibility();
+                }
+            },
+            {
+                opts: update({
+                    s: ["Status bar", [statusline.statusBar.id]]
+                }, config.guioptions),
+                setter: function (opts) {
+                    for (let [opt, [, ids]] in Iterator(this.opts)) {
+                        ids.map(function (id) document.getElementById(id))
+                           .forEach(function (elem) {
+                            if (elem)
+                                dactyl.setNodeVisible(elem, opts.indexOf(opt) >= 0);
+                        });
+                    }
+                }
+            },
+            {
+                opts: {
+                    r: ["Right Scrollbar", "vertical"],
+                    l: ["Left Scrollbar", "vertical"],
+                    b: ["Bottom Scrollbar", "horizontal"]
+                },
+                setter: function (opts) {
+                    let dir = ["horizontal", "vertical"].filter(
+                        function (dir) !Array.some(opts,
+                            function (o) this.opts[o] && this.opts[o][1] == dir, this),
+                        this);
+                    let class_ = dir.map(function (dir) "html|html > xul|scrollbar[orient=" + dir + "]");
+
+                    styles.system.add("scrollbar", "*",
+                                      class_.length ? class_.join(", ") + " { visibility: collapse !important; }" : "",
+                                      true);
+
+                    prefs.safeSet("layout.scrollbar.side", opts.indexOf("l") >= 0 ? 3 : 2,
+                                  "See 'guioptions' scrollbar flags.");
+                },
+                validator: function (opts) Option.validIf(!(opts.indexOf("l") >= 0 && opts.indexOf("r") >= 0),
+                                                          UTF8("Only one of ‘l’ or ‘r’ allowed"))
+            },
+            {
+                feature: "tabs",
+                opts: {
+                    n: ["Tab number", highlight.selector("TabNumber")],
+                    N: ["Tab number over icon", highlight.selector("TabIconNumber")]
+                },
+                setter: function (opts) {
+                    let classes = [v[1] for ([k, v] in Iterator(this.opts)) if (opts.indexOf(k) < 0)];
+
+                    styles.system.add("taboptions", "chrome://*",
+                                      classes.length ? classes.join(",") + "{ display: none; }" : "");
+
+                    if (!dactyl.has("Gecko2")) {
+                        tabs.tabBinding.enabled = Array.some(opts, function (k) k in this.opts, this);
+                        tabs.updateTabCount();
+                    }
+                    if (config.tabbrowser.tabContainer._positionPinnedTabs)
+                        config.tabbrowser.tabContainer._positionPinnedTabs();
+                },
+                /*
+                validator: function (opts) dactyl.has("Gecko2") ||
+                    Option.validIf(!/[nN]/.test(opts), "Tab numbering not available in this " + config.host + " version")
+                 */
+            }
+        ].filter(function (group) !group.feature || dactyl.has(group.feature));
+
+        options.add(["guioptions", "go"],
+            "Show or hide certain GUI elements like the menu or toolbar",
+            "charlist", config.defaults.guioptions || "", {
+
+                // FIXME: cleanup
+                cleanupValue: config.cleanups.guioptions ||
+                    "r" + [k for ([k, v] in iter(groups[1].opts))
+                           if (!document.getElementById(v[1][0]).collapsed)].join(""),
+
+                values: array(groups).map(function (g) [[k, v[0]] for ([k, v] in Iterator(g.opts))]).flatten(),
+
+                setter: function (value) {
+                    for (let group in values(groups))
+                        group.setter(value);
+                    events.checkFocus();
+                    return value;
+                },
+                validator: function (val) Option.validateCompleter.call(this, val) &&
+                        groups.every(function (g) !g.validator || g.validator(val))
+            });
+
+        options.add(["helpfile", "hf"],
+            "Name of the main help file",
+            "string", "intro");
+
+        options.add(["loadplugins", "lpl"],
+            "A regexp list that defines which plugins are loaded at startup and via :loadplugins",
+            "regexplist", "'\\.(js|" + config.fileExtension + ")$'");
+
+        options.add(["titlestring"],
+            "The string shown at the end of the window title",
+            "string", config.defaults.titlestring || config.host,
+            {
+                setter: function (value) {
+                    let win = document.documentElement;
+                    function updateTitle(old, current) {
+                        document.title = document.title.replace(RegExp("(.*)" + util.regexp.escape(old)), "$1" + current);
+                    }
+
+                    if (services.has("privateBrowsing")) {
+                        let oldValue = win.getAttribute("titlemodifier_normal");
+                        let suffix = win.getAttribute("titlemodifier_privatebrowsing").substr(oldValue.length);
+
+                        win.setAttribute("titlemodifier_normal", value);
+                        win.setAttribute("titlemodifier_privatebrowsing", value + suffix);
+
+                        if (services.privateBrowsing.privateBrowsingEnabled) {
+                            updateTitle(oldValue + suffix, value + suffix);
+                            return value;
+                        }
+                    }
+
+                    updateTitle(win.getAttribute("titlemodifier"), value);
+                    win.setAttribute("titlemodifier", value);
+
+                    return value;
+                }
+            });
+
+        options.add(["urlseparator", "urlsep", "us"],
+            "The regular expression used to separate multiple URLs in :open and friends",
+            "string", "\\|",
+            { validator: function (value) RegExp(value) });
+
+        options.add(["verbose", "vbs"],
+            "Define which info messages are displayed",
+            "number", 1,
+            { validator: function (value) Option.validIf(value >= 0 && value <= 15, "Value must be between 0 and 15") });
+
+        options.add(["visualbell", "vb"],
+            "Use visual bell instead of beeping on errors",
+            "boolean", false,
+            {
+                setter: function (value) {
+                    prefs.safeSet("accessibility.typeaheadfind.enablesound", !value,
+                                  "See 'visualbell' option");
+                    return value;
+                }
+            });
+    },
+
+    mappings: function () {
+        mappings.add([modes.MAIN], ["<F1>"],
+            "Open the introductory help page",
+            function () { dactyl.help(); });
+
+        mappings.add([modes.MAIN], ["<A-F1>"],
+            "Open the single, consolidated help page",
+            function () { ex.helpall(); });
+
+        if (dactyl.has("session"))
+            mappings.add([modes.NORMAL], ["ZQ"],
+                "Quit and don't save the session",
+                function () { dactyl.quit(false); });
+
+        mappings.add([modes.NORMAL], ["ZZ"],
+            "Quit and save the session",
+            function () { dactyl.quit(true); });
+    },
+
+    commands: function () {
+        commands.add(["dia[log]"],
+            "Open a " + config.appName + " dialog",
+            function (args) {
+                let dialog = args[0];
+
+                dactyl.assert(dialog in config.dialogs,
+                              _("error.invalidArgument", dialog));
+                dactyl.assert(!config.dialogs[dialog][2] || config.dialogs[dialog][2](),
+                              _("dialog.notAvailable", dialog));
+                try {
+                    config.dialogs[dialog][1]();
+                }
+                catch (e) {
+                    dactyl.echoerr(_("error.cantOpen", dialog.quote(), e.message || e));
+                }
+            }, {
+                argCount: "1",
+                bang: true,
+                completer: function (context) {
+                    context.ignoreCase = true;
+                    completion.dialog(context);
+                }
+            });
+
+        commands.add(["em[enu]"],
+            "Execute the specified menu item from the command line",
+            function (args) {
+                let arg = args[0] || "";
+                let items = Dactyl.getMenuItems();
+
+                dactyl.assert(items.some(function (i) i.fullMenuPath == arg),
+                              _("emenu.notFound", arg));
+
+                for (let [, item] in Iterator(items)) {
+                    if (item.fullMenuPath == arg)
+                        item.doCommand();
+                }
+            }, {
+                argCount: "1",
+                completer: function (context) completion.menuItem(context),
+                literal: 0
+            });
+
+        commands.add(["exe[cute]"],
+            "Execute the argument as an Ex command",
+            function (args) {
+                try {
+                    let cmd = dactyl.userEval(args[0] || "");
+                    dactyl.execute(cmd || "", null, true);
+                }
+                catch (e) {
+                    dactyl.echoerr(e);
+                }
+            }, {
+                completer: function (context) completion.javascript(context),
+                literal: 0
+            });
+
+        [
+            {
+                name: "h[elp]",
+                description: "Open the introductory help page"
+            }, {
+                name: "helpa[ll]",
+                description: "Open the single consolidated help page"
+            }
+        ].forEach(function (command) {
+            let consolidated = command.name == "helpa[ll]";
+
+            commands.add([command.name],
+                command.description,
+                function (args) {
+                    dactyl.assert(!args.bang, _("help.dontPanic"));
+                    dactyl.help(args.literalArg, consolidated);
+                }, {
+                    argCount: "?",
+                    bang: true,
+                    completer: function (context) completion.help(context, consolidated),
+                    literal: 0
+                });
+        });
+
+        commands.add(["loadplugins", "lpl"],
+            "Load all plugins immediately",
+            function (args) {
+                dactyl.loadPlugins(args.length ? args : null, args.bang);
+            },
+            {
+                argCount: "*",
+                bang: true,
+                keepQuotes: true,
+                serialGroup: 10,
+                serialize: function ()  [
+                    {
+                        command: this.name,
+                        literalArg: options["loadplugins"].join(" ")
+                    }
+                ]
+            });
+
+        commands.add(["norm[al]"],
+            "Execute Normal mode commands",
+            function (args) { events.feedkeys(args[0], args.bang, false, modes.NORMAL); },
+            {
+                argCount: "1",
+                bang: true,
+                literal: 0
+            });
+
+        commands.add(["q[uit]"],
+            dactyl.has("tabs") ? "Quit current tab" : "Quit application",
+            function (args) {
+                if (dactyl.has("tabs") && tabs.remove(tabs.getTab(), 1, false))
+                    return;
+                else if (dactyl.windows.length > 1)
+                    window.close();
+                else
+                    dactyl.quit(false, args.bang);
+            }, {
+                argCount: "0",
+                bang: true
+            });
+
+        commands.add(["reh[ash]"],
+            "Reload the " + config.appName + " add-on",
+            function (args) {
+                if (args.trailing)
+                    JSMLoader.rehashCmd = args.trailing; // Hack.
+                args.break = true;
+                util.rehash(args);
+            },
+            {
+                argCount: "0",
+                options: [
+                    {
+                        names: ["+u"],
+                        description: "The initialization file to execute at startup",
+                        type: CommandOption.STRING
+                    },
+                    {
+                        names: ["++noplugin"],
+                        description: "Do not automatically load plugins"
+                    },
+                    {
+                        names: ["++cmd"],
+                        description: "Ex commands to execute prior to initialization",
+                        type: CommandOption.STRING,
+                        multiple: true
+                    },
+                    {
+                        names: ["+c"],
+                        description: "Ex commands to execute after initialization",
+                        type: CommandOption.STRING,
+                        multiple: true
+                    }
+                ]
+            });
+
+        commands.add(["res[tart]"],
+            "Force " + config.appName + " to restart",
+            function () { dactyl.restart(); });
+
+        function findToolbar(name) util.evaluateXPath(
+            "//*[@toolbarname=" + util.escapeString(name, "'") + "]",
+            document).snapshotItem(0);
+
+        var toolbox = document.getElementById("navigator-toolbox");
+        if (toolbox) {
+            let hidden = function hidden(elem) (elem.getAttribute("autohide") || elem.getAttribute("collapsed")) == "true";
+
+            let toolbarCommand = function (names, desc, action, filter) {
+                commands.add(names, desc,
+                    function (args) {
+                        let toolbar = findToolbar(args[0] || "");
+                        dactyl.assert(toolbar, _("error.invalidArgument"));
+                        action(toolbar);
+                        events.checkFocus();
+                    }, {
+                        argcount: "1",
+                        completer: function (context) {
+                            completion.toolbar(context);
+                            if (filter)
+                                context.filters.push(filter);
+                        },
+                        literal: 0
+                    });
+            };
+
+            toolbarCommand(["toolbars[how]", "tbs[how]"], "Show the named toolbar",
+                function (toolbar) dactyl.setNodeVisible(toolbar, true),
+                function ({ item }) hidden(item));
+            toolbarCommand(["toolbarh[ide]", "tbh[ide]"], "Hide the named toolbar",
+                function (toolbar) dactyl.setNodeVisible(toolbar, false),
+                function ({ item }) !hidden(item));
+            toolbarCommand(["toolbart[oggle]", "tbt[oggle]"], "Toggle the named toolbar",
+                function (toolbar) dactyl.setNodeVisible(toolbar, hidden(toolbar)));
+        }
+
+        commands.add(["time"],
+            "Profile a piece of code or run a command multiple times",
+            function (args) {
+                let count = args.count;
+                let special = args.bang;
+                args = args[0] || "";
+
+                if (args[0] == ":")
+                    var method = function () commands.execute(args, null, true);
+                else
+                    method = dactyl.userFunc(args);
+
+                try {
+                    if (count > 1) {
+                        let each, eachUnits, totalUnits;
+                        let total = 0;
+
+                        for (let i in util.interruptibleRange(0, count, 500)) {
+                            let now = Date.now();
+                            method();
+                            total += Date.now() - now;
+                        }
+
+                        if (special)
+                            return;
+
+                        if (total / count >= 100) {
+                            each = total / 1000.0 / count;
+                            eachUnits = "sec";
+                        }
+                        else {
+                            each = total / count;
+                            eachUnits = "msec";
+                        }
+
+                        if (total >= 100) {
+                            total = total / 1000.0;
+                            totalUnits = "sec";
+                        }
+                        else
+                            totalUnits = "msec";
+
+                        commandline.commandOutput(
+                                <table>
+                                    <tr highlight="Title" align="left">
+                                        <th colspan="3">Code execution summary</th>
+                                    </tr>
+                                    <tr><td>&#xa0;&#xa0;Executed:</td><td align="right"><span class="times-executed">{count}</span></td><td>times</td></tr>
+                                    <tr><td>&#xa0;&#xa0;Average time:</td><td align="right"><span class="time-average">{each.toFixed(2)}</span></td><td>{eachUnits}</td></tr>
+                                    <tr><td>&#xa0;&#xa0;Total time:</td><td align="right"><span class="time-total">{total.toFixed(2)}</span></td><td>{totalUnits}</td></tr>
+                                </table>);
+                    }
+                    else {
+                        let beforeTime = Date.now();
+                        method();
+
+                        if (special)
+                            return;
+
+                        let afterTime = Date.now();
+
+                        if (afterTime - beforeTime >= 100)
+                            dactyl.echo(_("time.total", ((afterTime - beforeTime) / 1000.0).toFixed(2) + " sec"));
+                        else
+                            dactyl.echo(_("time.total", (afterTime - beforeTime) + " msec"));
+                    }
+                }
+                catch (e) {
+                    dactyl.echoerr(e);
+                }
+            }, {
+                argCount: "+",
+                bang: true,
+                completer: function (context) {
+                    if (/^:/.test(context.filter))
+                        return completion.ex(context);
+                    else
+                        return completion.javascript(context);
+                },
+                count: true,
+                hereDoc: true,
+                literal: 0,
+                subCommand: 0
+            });
+
+        commands.add(["verb[ose]"],
+            "Execute a command with 'verbose' set",
+            function (args) {
+                let vbs = options.get("verbose");
+                let value = vbs.value;
+                let setFrom = vbs.setFrom;
+
+                try {
+                    vbs.set(args.count || 1);
+                    vbs.setFrom = null;
+                    dactyl.execute(args[0] || "", null, true);
+                }
+                finally {
+                    vbs.set(value);
+                    vbs.setFrom = setFrom;
+                }
+            }, {
+                argCount: "+",
+                completer: function (context) completion.ex(context),
+                count: true,
+                literal: 0,
+                subCommand: 0
+            });
+
+        commands.add(["ve[rsion]"],
+            "Show version information",
+            function (args) {
+                if (args.bang)
+                    dactyl.open("about:");
+                else
+                    commandline.commandOutput(<>
+                        {config.appName} {config.version} running on:<br/>{navigator.userAgent}
+                    </>);
+            }, {
+                argCount: "0",
+                bang: true
+            });
+
+    },
+
+    completion: function () {
+        completion.dialog = function dialog(context) {
+            context.title = ["Dialog"];
+            context.filters.push(function ({ item }) !item[2] || item[2]());
+            context.completions = [[k, v[0], v[2]] for ([k, v] in Iterator(config.dialogs))];
+        };
+
+        completion.help = function help(context, consolidated) {
+            dactyl.initHelp();
+            context.title = ["Help"];
+            context.anchored = false;
+            context.completions = services["dactyl:"].HELP_TAGS;
+            if (consolidated)
+                context.keys = { text: 0, description: function () "all" };
+        };
+
+        completion.menuItem = function menuItem(context) {
+            context.title = ["Menu Path", "Label"];
+            context.anchored = false;
+            context.keys = { text: "fullMenuPath", description: function (item) item.getAttribute("label") };
+            context.completions = dactyl.menuItems;
+        };
+
+        var toolbox = document.getElementById("navigator-toolbox");
+        completion.toolbar = function toolbar(context) {
+            context.title = ["Toolbar"];
+            context.keys = { text: function (item) item.getAttribute("toolbarname"), description: function () "" };
+            context.completions = util.evaluateXPath("//*[@toolbarname]", document);
+        };
+
+        completion.window = function window(context) {
+            context.title = ["Window", "Title"];
+            context.keys = { text: function (win) dactyl.windows.indexOf(win) + 1, description: function (win) win.document.title };
+            context.completions = dactyl.windows;
+        };
+    },
+    load: function () {
+        dactyl.triggerObserver("load");
+
+        dactyl.log(_("dactyl.modulesLoaded"), 3);
+
+        dactyl.timeout(function () {
+            try {
+                var args = JSMLoader.commandlineArgs || services.commandLineHandler.optionValue;
+                if (isString(args))
+                    args = dactyl.parseCommandLine(args);
+
+                if (args) {
+                    dactyl.commandLineOptions.rcFile = args["+u"];
+                    dactyl.commandLineOptions.noPlugins = "++noplugin" in args;
+                    dactyl.commandLineOptions.postCommands = args["+c"];
+                    dactyl.commandLineOptions.preCommands = args["++cmd"];
+                    util.dump("Processing command-line option: " + args.string);
+                }
+            }
+            catch (e) {
+                dactyl.echoerr(_("dactyl.parsingCommandLine", e));
+            }
+
+            dactyl.log(_("dactyl.commandlineOpts", util.objectToString(dactyl.commandLineOptions)), 3);
+
+            // first time intro message
+            const firstTime = "extensions." + config.name + ".firsttime";
+            if (prefs.get(firstTime, true)) {
+                dactyl.timeout(function () {
+                    this.withSavedValues(["forceNewTab"], function () {
+                        this.forceNewTab = true;
+                        this.help();
+                        prefs.set(firstTime, false);
+                    });
+                }, 1000);
+            }
+
+            // TODO: we should have some class where all this guioptions stuff fits well
+            // Dactyl.hideGUI();
+
+            if (dactyl.userEval("typeof document", null, "test.js") === "undefined")
+                jsmodules.__proto__ = XPCSafeJSObjectWrapper(window);
+
+            if (dactyl.commandLineOptions.preCommands)
+                dactyl.commandLineOptions.preCommands.forEach(function (cmd) {
+                    dactyl.execute(cmd);
+                });
+
+            // finally, read the RC file and source plugins
+            let init = services.environment.get(config.idName + "_INIT");
+            let rcFile = io.getRCFile("~");
+
+            try {
+                if (dactyl.commandLineOptions.rcFile) {
+                    let filename = dactyl.commandLineOptions.rcFile;
+                    if (!/^(NONE|NORC)$/.test(filename))
+                        io.source(io.File(filename).path, { group: contexts.user });
+                }
+                else {
+                    if (init)
+                        dactyl.execute(init);
+                    else {
+                        if (rcFile) {
+                            io.source(rcFile.path, { group: contexts.user });
+                            services.environment.set("MY_" + config.idName + "RC", rcFile.path);
+                        }
+                        else
+                            dactyl.log(_("dactyl.noRCFile"), 3);
+                    }
+
+                    if (options["exrc"] && !dactyl.commandLineOptions.rcFile) {
+                        let localRCFile = io.getRCFile(io.cwd);
+                        if (localRCFile && !localRCFile.equals(rcFile))
+                            io.source(localRCFile.path, { group: contexts.user });
+                    }
+                }
+
+                if (dactyl.commandLineOptions.rcFile == "NONE" || dactyl.commandLineOptions.noPlugins)
+                    options["loadplugins"] = [];
+
+                if (options["loadplugins"])
+                    dactyl.loadPlugins();
+            }
+            catch (e) {
+                dactyl.reportError(e, true);
+            }
+
+            // after sourcing the initialization files, this function will set
+            // all gui options to their default values, if they have not been
+            // set before by any RC file
+            for (let option in values(options.needInit))
+                option.initValue();
+
+            if (dactyl.commandLineOptions.postCommands)
+                dactyl.commandLineOptions.postCommands.forEach(function (cmd) {
+                    dactyl.execute(cmd);
+                });
+
+            if (JSMLoader.rehashCmd)
+                dactyl.execute(JSMLoader.rehashCmd);
+            JSMLoader.rehashCmd = null;
+
+            dactyl.fullyInitialized = true;
+            dactyl.triggerObserver("enter", null);
+            autocommands.trigger("Enter", {});
+        }, 100);
+
+        statusline.update();
+        dactyl.log(_("dactyl.initialized", config.appName), 0);
+        dactyl.initialized = true;
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/disable-acr.jsm b/common/content/disable-acr.jsm
new file mode 100644 (file)
index 0000000..80b9032
--- /dev/null
@@ -0,0 +1,72 @@
+// By Kris Maglione. Public Domain.
+// Please feel free to copy and use at will.
+
+var ADDON_ID;
+
+const OVERLAY_URLS = [
+    "about:addons",
+    "chrome://mozapps/content/extensions/extensions.xul"
+];
+
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+function observe(window, topic, url) {
+    if (topic === "chrome-document-global-created")
+        checkDocument(window.document);
+}
+function init(id) {
+    if (id)
+        ADDON_ID = id;
+
+    Services.obs[id ? "addObserver" : "removeObserver"](observe, "chrome-document-global-created", false);
+    for (let doc in chromeDocuments)
+        checkDocument(doc, !id);
+}
+function cleanup() { init(null); }
+
+function checkPopup(event) {
+    let doc = event.originalTarget.ownerDocument;
+    let binding = doc.getBindingParent(event.originalTarget);
+    if (binding && binding.addon && binding.addon.guid == ADDON_ID && !binding.addon.compatible) {
+        let elem = doc.getAnonymousElementByAttribute(binding, "anonid", "stillworks");
+        if (elem && elem.nextSibling) {
+            elem.nextSibling.disabled = true;
+            elem.nextSibling.setAttribute("tooltiptext", "Developer has opted out of incompatibility reports\n"+
+                                                         "Development versions are available with updated support");
+        }
+    }
+}
+
+function checkDocument(doc, disable, force) {
+    if (["interactive", "complete"].indexOf(doc.readyState) >= 0 || force && doc.readyState === "uninitialized") {
+        if (OVERLAY_URLS.indexOf(doc.documentURI) >= 0)
+            doc[disable ? "removeEventListener" : "addEventListener"]("popupshowing", checkPopup, false);
+    }
+    else {
+        doc.addEventListener("DOMContentLoaded", function listener() {
+            doc.removeEventListener("DOMContentLoaded", listener, false);
+            checkDocument(doc, disable, true);
+        }, false);
+    }
+}
+
+function chromeDocuments() {
+    let windows = services.windowMediator.getXULWindowEnumerator(null);
+    while (windows.hasMoreElements()) {
+        let window = windows.getNext().QueryInterface(Ci.nsIXULWindow);
+        for each (let type in ["typeChrome", "typeContent"]) {
+            let docShells = window.docShell.getDocShellEnumerator(Ci.nsIDocShellTreeItem[type],
+                                                                  Ci.nsIDocShell.ENUMERATE_FORWARDS);
+            while (docShells.hasMoreElements())
+                yield docShells.getNext().QueryInterface(Ci.nsIDocShell).contentViewer.DOMDocument;
+        }
+    }
+}
+
+var EXPORTED_SYMBOLS = ["cleanup", "init"];
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/content/editor.js b/common/content/editor.js
new file mode 100644 (file)
index 0000000..e1df653
--- /dev/null
@@ -0,0 +1,884 @@
+// Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+// command names taken from:
+// http://developer.mozilla.org/en/docs/Editor_Embedding_Guide
+
+/** @instance editor */
+var Editor = Module("editor", {
+    get isCaret() modes.getStack(1).main == modes.CARET,
+    get isTextEdit() modes.getStack(1).main == modes.TEXT_EDIT,
+
+    unselectText: function (toEnd) {
+        try {
+            Editor.getEditor(null).selection[toEnd ? "collapseToEnd" : "collapseToStart"]();
+        }
+        catch (e) {}
+    },
+
+    selectedText: function () String(Editor.getEditor(null).selection),
+
+    pasteClipboard: function (clipboard, toStart) {
+        // TODO: I don't think this is needed anymore? --djk
+        if (util.OS.isWindows) {
+            this.executeCommand("cmd_paste");
+            return;
+        }
+
+        let elem = dactyl.focusedElement;
+        if (elem.inputField)
+            elem = elem.inputField;
+
+        if (elem.setSelectionRange) {
+            let text = dactyl.clipboardRead(clipboard);
+            if (!text)
+                return;
+            if (isinstance(elem, [HTMLInputElement, XULTextBoxElement]))
+                text = text.replace(/\n+/g, "");
+
+            // This is a hacky fix - but it works.
+            // <s-insert> in the bottom of a long textarea bounces up
+            let top = elem.scrollTop;
+            let left = elem.scrollLeft;
+
+            let start = elem.selectionStart; // caret position
+            let end = elem.selectionEnd;
+            let value = elem.value.substring(0, start) + text + elem.value.substring(end);
+            elem.value = value;
+
+            if (/^(search|text)$/.test(elem.type))
+                Editor.getEditor(elem).rootElement.firstChild.textContent = value;
+
+            elem.selectionStart = Math.min(start + (toStart ? 0 : text.length), elem.value.length);
+            elem.selectionEnd = elem.selectionStart;
+
+            elem.scrollTop = top;
+            elem.scrollLeft = left;
+
+            events.dispatch(elem, events.create(elem.ownerDocument, "input"));
+        }
+    },
+
+    // count is optional, defaults to 1
+    executeCommand: function (cmd, count) {
+        let editor = Editor.getEditor(null);
+        let controller = Editor.getController();
+        dactyl.assert(callable(cmd) ||
+                          controller &&
+                          controller.supportsCommand(cmd) &&
+                          controller.isCommandEnabled(cmd));
+
+        // XXX: better as a precondition
+        if (count == null)
+          count = 1;
+
+        let didCommand = false;
+        while (count--) {
+            // some commands need this try/catch workaround, because a cmd_charPrevious triggered
+            // at the beginning of the textarea, would hang the doCommand()
+            // good thing is, we need this code anyway for proper beeping
+            try {
+                if (callable(cmd))
+                    cmd(editor, controller);
+                else
+                    controller.doCommand(cmd);
+                didCommand = true;
+            }
+            catch (e) {
+                util.reportError(e);
+                dactyl.assert(didCommand);
+                break;
+            }
+        }
+    },
+
+    // cmd = y, d, c
+    // motion = b, 0, gg, G, etc.
+    selectMotion: function selectMotion(cmd, motion, count) {
+        // XXX: better as a precondition
+        if (count == null)
+            count = 1;
+
+        if (cmd == motion) {
+            motion = "j";
+            count--;
+        }
+
+        if (modes.main != modes.VISUAL)
+            modes.push(modes.VISUAL);
+
+        switch (motion) {
+        case "j":
+            this.executeCommand("cmd_beginLine", 1);
+            this.executeCommand("cmd_selectLineNext", count + 1);
+            break;
+        case "k":
+            this.executeCommand("cmd_beginLine", 1);
+            this.executeCommand("cmd_lineNext", 1);
+            this.executeCommand("cmd_selectLinePrevious", count + 1);
+            break;
+        case "h":
+            this.executeCommand("cmd_selectCharPrevious", count);
+            break;
+        case "l":
+            this.executeCommand("cmd_selectCharNext", count);
+            break;
+        case "e":
+        case "w":
+            this.executeCommand("cmd_selectWordNext", count);
+            break;
+        case "b":
+            this.executeCommand("cmd_selectWordPrevious", count);
+            break;
+        case "0":
+        case "^":
+            this.executeCommand("cmd_selectBeginLine", 1);
+            break;
+        case "$":
+            this.executeCommand("cmd_selectEndLine", 1);
+            break;
+        case "gg":
+            this.executeCommand("cmd_endLine", 1);
+            this.executeCommand("cmd_selectTop", 1);
+            this.executeCommand("cmd_selectBeginLine", 1);
+            break;
+        case "G":
+            this.executeCommand("cmd_beginLine", 1);
+            this.executeCommand("cmd_selectBottom", 1);
+            this.executeCommand("cmd_selectEndLine", 1);
+            break;
+
+        default:
+            dactyl.beep();
+            return;
+        }
+    },
+
+    // This function will move/select up to given "pos"
+    // Simple setSelectionRange() would be better, but we want to maintain the correct
+    // order of selectionStart/End (a Gecko bug always makes selectionStart <= selectionEnd)
+    // Use only for small movements!
+    moveToPosition: function (pos, forward, select) {
+        if (!select) {
+            Editor.getEditor().setSelectionRange(pos, pos);
+            return;
+        }
+
+        if (forward) {
+            if (pos <= Editor.getEditor().selectionEnd || pos > Editor.getEditor().value.length)
+                return;
+
+            do { // TODO: test code for endless loops
+                this.executeCommand("cmd_selectCharNext", 1);
+            }
+            while (Editor.getEditor().selectionEnd != pos);
+        }
+        else {
+            if (pos >= Editor.getEditor().selectionStart || pos < 0)
+                return;
+
+            do { // TODO: test code for endless loops
+                this.executeCommand("cmd_selectCharPrevious", 1);
+            }
+            while (Editor.getEditor().selectionStart != pos);
+        }
+    },
+
+    // returns the position of char
+    findCharForward: function (ch, count) {
+        if (!Editor.getEditor())
+            return -1;
+
+        let text = Editor.getEditor().value;
+        // XXX
+        if (count == null)
+            count = 1;
+
+        for (let i = Editor.getEditor().selectionEnd + 1; i < text.length; i++) {
+            if (text[i] == "\n")
+                break;
+            if (text[i] == ch)
+                count--;
+            if (count == 0)
+                return i + 1; // always position the cursor after the char
+        }
+
+        dactyl.beep();
+        return -1;
+    },
+
+    // returns the position of char
+    findCharBackward: function (ch, count) {
+        if (!Editor.getEditor())
+            return -1;
+
+        let text = Editor.getEditor().value;
+        // XXX
+        if (count == null)
+            count = 1;
+
+        for (let i = Editor.getEditor().selectionStart - 1; i >= 0; i--) {
+            if (text[i] == "\n")
+                break;
+            if (text[i] == ch)
+                count--;
+            if (count == 0)
+                return i;
+        }
+
+        dactyl.beep();
+        return -1;
+    },
+
+    /**
+     * Edits the given file in the external editor as specified by the
+     * 'editor' option.
+     *
+     * @param {object|File|string} args An object specifying the file,
+     *  line, and column to edit. If a non-object is specified, it is
+     *  treated as the file parameter of the object.
+     * @param {boolean} blocking If true, this function does not return
+     *  until the editor exits.
+     */
+    editFileExternally: function (args, blocking) {
+        if (!isObject(args) || args instanceof File)
+            args = { file: args };
+        args.file = args.file.path || args.file;
+
+        let args = options.get("editor").format(args);
+
+        dactyl.assert(args.length >= 1, _("editor.noEditor"));
+
+        io.run(args.shift(), args, blocking);
+    },
+
+    // TODO: clean up with 2 functions for textboxes and currentEditor?
+    editFieldExternally: function editFieldExternally(forceEditing) {
+        if (!options["editor"])
+            return;
+
+        let textBox = config.isComposeWindow ? null : dactyl.focusedElement;
+        let line, column;
+
+        if (!forceEditing && textBox && textBox.type == "password") {
+            commandline.input("Editing a password field externally will reveal the password. Would you like to continue? (yes/[no]): ",
+                function (resp) {
+                    if (resp && resp.match(/^y(es)?$/i))
+                        editor.editFieldExternally(true);
+                });
+                return;
+        }
+
+        if (textBox) {
+            var text = textBox.value;
+            let pre = text.substr(0, textBox.selectionStart);
+            line = 1 + pre.replace(/[^\n]/g, "").length;
+            column = 1 + pre.replace(/[^]*\n/, "").length;
+        }
+        else {
+            var editor = window.GetCurrentEditor ? GetCurrentEditor()
+                                                 : Editor.getEditor(document.commandDispatcher.focusedWindow);
+            dactyl.assert(editor);
+            text = Array.map(editor.rootElement.childNodes, function (e) util.domToString(e, true)).join("");
+        }
+
+        let origGroup = textBox && textBox.getAttributeNS(NS, "highlight") || "";
+        let cleanup = util.yieldable(function cleanup(error) {
+            if (timer)
+                timer.cancel();
+
+            let blink = ["EditorBlink1", "EditorBlink2"];
+            if (error) {
+                dactyl.reportError(error, true);
+                blink[1] = "EditorError";
+            }
+            else
+                dactyl.trapErrors(update, null, true);
+
+            if (tmpfile && tmpfile.exists())
+                tmpfile.remove(false);
+
+            if (textBox) {
+                dactyl.focus(textBox);
+                for (let group in values(blink.concat(blink, ""))) {
+                    highlight.highlightNode(textBox, origGroup + " " + group);
+                    yield 100;
+                }
+            }
+        });
+
+        function update(force) {
+            if (force !== true && tmpfile.lastModifiedTime <= lastUpdate)
+                return;
+            lastUpdate = Date.now();
+
+            let val = tmpfile.read();
+            if (textBox)
+                textBox.value = val;
+            else {
+                while (editor.rootElement.firstChild)
+                    editor.rootElement.removeChild(editor.rootElement.firstChild);
+                editor.rootElement.innerHTML = val;
+            }
+        }
+
+        try {
+            var tmpfile = io.createTempFile();
+            if (!tmpfile)
+                throw Error("Couldn't create temporary file");
+
+            if (textBox) {
+                highlight.highlightNode(textBox, origGroup + " EditorEditing");
+                textBox.blur();
+            }
+
+            if (!tmpfile.write(text))
+                throw Error("Input contains characters not valid in the current " +
+                            "file encoding");
+
+            var lastUpdate = Date.now();
+
+            var timer = services.Timer(update, 100, services.Timer.TYPE_REPEATING_SLACK);
+            this.editFileExternally({ file: tmpfile.path, line: line, column: column }, cleanup);
+        }
+        catch (e) {
+            cleanup(e);
+        }
+    },
+
+    /**
+     * Expands an abbreviation in the currently active textbox.
+     *
+     * @param {string} mode The mode filter.
+     * @see Abbreviation#expand
+     */
+    expandAbbreviation: function (mode) {
+        let elem = dactyl.focusedElement;
+        if (!(elem && elem.value))
+            return;
+
+        let text   = elem.value;
+        let start  = elem.selectionStart;
+        let end    = elem.selectionEnd;
+        let abbrev = abbreviations.match(mode, text.substring(0, start).replace(/.*\s/g, ""));
+        if (abbrev) {
+            let len = abbrev.lhs.length;
+            let rhs = abbrev.expand(elem);
+            elem.value = text.substring(0, start - len) + rhs + text.substring(start);
+            elem.selectionStart = start - len + rhs.length;
+            elem.selectionEnd   = end   - len + rhs.length;
+        }
+    },
+}, {
+    extendRange: function extendRange(range, forward, re, sameWord) {
+        function advance(positive) {
+            let idx = range.endOffset;
+            while (idx < text.length && re.test(text[idx++]) == positive)
+                range.setEnd(range.endContainer, idx);
+        }
+        function retreat(positive) {
+            let idx = range.startOffset;
+            while (idx > 0 && re.test(text[--idx]) == positive)
+                range.setStart(range.startContainer, idx);
+        }
+
+        let nodeRange = range.cloneRange();
+        nodeRange.selectNodeContents(range.startContainer);
+        let text = String(nodeRange);
+
+        if (forward) {
+            advance(true);
+            if (!sameWord)
+                advance(false);
+        }
+        else {
+            if (!sameWord)
+                retreat(false);
+            retreat(true);
+        }
+        return range;
+    },
+
+    getEditor: function (elem) {
+        if (arguments.length === 0) {
+            dactyl.assert(dactyl.focusedElement);
+            return dactyl.focusedElement;
+        }
+
+        if (!elem)
+            elem = dactyl.focusedElement || document.commandDispatcher.focusedWindow;
+        dactyl.assert(elem);
+
+        if (elem instanceof Element)
+            return elem.QueryInterface(Ci.nsIDOMNSEditableElement).editor;
+        try {
+            return elem.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
+                       .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIEditingSession)
+                       .getEditorForWindow(elem);
+        }
+        catch (e) {
+            return null;
+        }
+    },
+
+    getController: function () {
+        let ed = dactyl.focusedElement;
+        if (!ed || !ed.controllers)
+            return null;
+
+        return ed.controllers.getControllerForCommand("cmd_beginLine");
+    }
+}, {
+    mappings: function () {
+
+        // add mappings for commands like h,j,k,l,etc. in CARET, VISUAL and TEXT_EDIT mode
+        function addMovementMap(keys, description, hasCount, caretModeMethod, caretModeArg, textEditCommand, visualTextEditCommand) {
+            let extraInfo = {};
+            if (hasCount)
+                extraInfo.count = true;
+
+            function caretExecute(arg, again) {
+                function fixSelection() {
+                    sel.removeAllRanges();
+                    sel.addRange(RangeFind.endpoint(
+                        RangeFind.nodeRange(buffer.focusedFrame.document.documentElement),
+                        true));
+                }
+
+                let controller = buffer.selectionController;
+                let sel = controller.getSelection(controller.SELECTION_NORMAL);
+                if (!sel.rangeCount) // Hack.
+                    fixSelection();
+
+                try {
+                    controller[caretModeMethod](caretModeArg, arg);
+                }
+                catch (e) {
+                    dactyl.assert(again && e.result === Cr.NS_ERROR_FAILURE);
+                    fixSelection();
+                    caretExecute(arg, false);
+                }
+            }
+
+            mappings.add([modes.CARET], keys, description,
+                function ({ count }) {
+                    if (!count)
+                       count = 1;
+
+                    while (count--)
+                        caretExecute(false, true);
+                },
+                extraInfo);
+
+            mappings.add([modes.VISUAL], keys, description,
+                function ({ count }) {
+                    if (!count)
+                        count = 1;
+
+                    let editor_ = Editor.getEditor(null);
+                    let controller = buffer.selectionController;
+                    while (count-- && modes.main == modes.VISUAL) {
+                        if (editor.isTextEdit) {
+                            if (callable(visualTextEditCommand))
+                                visualTextEditCommand(editor_);
+                            else
+                                editor.executeCommand(visualTextEditCommand);
+                        }
+                        else
+                            caretExecute(true, true);
+                    }
+                },
+                extraInfo);
+
+            mappings.add([modes.TEXT_EDIT], keys, description,
+                function ({ count }) {
+                    if (!count)
+                        count = 1;
+
+                    editor.executeCommand(textEditCommand, count);
+                },
+                extraInfo);
+        }
+
+        // add mappings for commands like i,a,s,c,etc. in TEXT_EDIT mode
+        function addBeginInsertModeMap(keys, commands, description) {
+            mappings.add([modes.TEXT_EDIT], keys, description || "",
+                function () {
+                    commands.forEach(function (cmd)
+                        editor.executeCommand(cmd, 1));
+                    modes.push(modes.INSERT);
+                });
+        }
+
+        function selectPreviousLine() {
+            editor.executeCommand("cmd_selectLinePrevious");
+            if ((modes.extended & modes.LINE) && !editor.selectedText())
+                editor.executeCommand("cmd_selectLinePrevious");
+        }
+
+        function selectNextLine() {
+            editor.executeCommand("cmd_selectLineNext");
+            if ((modes.extended & modes.LINE) && !editor.selectedText())
+                editor.executeCommand("cmd_selectLineNext");
+        }
+
+        function updateRange(editor, forward, re, modify) {
+            let range = Editor.extendRange(editor.selection.getRangeAt(0),
+                                           forward, re, false);
+            modify(range);
+            editor.selection.removeAllRanges();
+            editor.selection.addRange(range);
+        }
+        function move(forward, re)
+            function _move(editor) {
+                updateRange(editor, forward, re, function (range) { range.collapse(!forward); });
+            }
+        function select(forward, re)
+            function _select(editor) {
+                updateRange(editor, forward, re, function (range) {});
+            }
+        function beginLine(editor_) {
+            editor.executeCommand("cmd_beginLine");
+            move(true, /\S/)(editor_);
+        }
+
+        //             COUNT  CARET                   TEXT_EDIT            VISUAL_TEXT_EDIT
+        addMovementMap(["k", "<Up>"],                 "Move up one line",
+                       true,  "lineMove", false,      "cmd_linePrevious", selectPreviousLine);
+        addMovementMap(["j", "<Down>", "<Return>"],   "Move down one line",
+                       true,  "lineMove", true,       "cmd_lineNext",     selectNextLine);
+        addMovementMap(["h", "<Left>", "<BS>"],       "Move left one character",
+                       true,  "characterMove", false, "cmd_charPrevious", "cmd_selectCharPrevious");
+        addMovementMap(["l", "<Right>", "<Space>"],   "Move right one character",
+                       true,  "characterMove", true,  "cmd_charNext",     "cmd_selectCharNext");
+        addMovementMap(["b", "<C-Left>"],             "Move left one word",
+                       true,  "wordMove", false,      "cmd_wordPrevious", "cmd_selectWordPrevious");
+        addMovementMap(["w", "<C-Right>"],            "Move right one word",
+                       true,  "wordMove", true,       "cmd_wordNext",     "cmd_selectWordNext");
+        addMovementMap(["B"],                         "Move left to the previous white space",
+                       true,  "wordMove", false,      move(false, /\S/),  select(false, /\S/));
+        addMovementMap(["W"],                         "Move right to just beyond the next white space",
+                       true,  "wordMove", true,       move(true,  /\S/),  select(true,  /\S/));
+        addMovementMap(["e"],                         "Move to the end of the current word",
+                       true,  "wordMove", true,       move(true,  /\W/),  select(true,  /\W/));
+        addMovementMap(["E"],                         "Move right to the next white space",
+                       true,  "wordMove", true,       move(true,  /\s/),  select(true,  /\s/));
+        addMovementMap(["<C-f>", "<PageDown>"],       "Move down one page",
+                       true,  "pageMove", true,       "cmd_movePageDown", "cmd_selectNextPage");
+        addMovementMap(["<C-b>", "<PageUp>"],         "Move up one page",
+                       true,  "pageMove", false,      "cmd_movePageUp",   "cmd_selectPreviousPage");
+        addMovementMap(["gg", "<C-Home>"],            "Move to the start of text",
+                       false, "completeMove", false,  "cmd_moveTop",      "cmd_selectTop");
+        addMovementMap(["G", "<C-End>"],              "Move to the end of text",
+                       false, "completeMove", true,   "cmd_moveBottom",   "cmd_selectBottom");
+        addMovementMap(["0", "<Home>"],               "Move to the beginning of the line",
+                       false, "intraLineMove", false, "cmd_beginLine",    "cmd_selectBeginLine");
+        addMovementMap(["^"],                         "Move to the first non-whitespace character of the line",
+                       false, "intraLineMove", false, beginLine,          "cmd_selectBeginLine");
+        addMovementMap(["$", "<End>"],                "Move to the end of the current line",
+                       false, "intraLineMove", true,  "cmd_endLine" ,     "cmd_selectEndLine");
+
+        addBeginInsertModeMap(["i", "<Insert>"], [], "Insert text before the cursor");
+        addBeginInsertModeMap(["a"],             ["cmd_charNext"], "Append text after the cursor");
+        addBeginInsertModeMap(["I"],             ["cmd_beginLine"], "Insert text at the beginning of the line");
+        addBeginInsertModeMap(["A"],             ["cmd_endLine"], "Append text at the end of the line");
+        addBeginInsertModeMap(["s"],             ["cmd_deleteCharForward"], "Delete the character in front of the cursor and start insert");
+        addBeginInsertModeMap(["S"],             ["cmd_deleteToEndOfLine", "cmd_deleteToBeginningOfLine"], "Delete the current line and start insert");
+        addBeginInsertModeMap(["C"],             ["cmd_deleteToEndOfLine"], "Delete from the cursor to the end of the line and start insert");
+
+        function addMotionMap(key, desc, cmd, mode) {
+            mappings.add([modes.TEXT_EDIT], [key],
+                desc,
+                function ({ count,  motion }) {
+                    editor.selectMotion(key, motion, Math.max(count, 1));
+                    if (callable(cmd))
+                        cmd.call(events, Editor.getEditor(null));
+                    else {
+                        editor.executeCommand(cmd, 1);
+                        modes.pop(modes.TEXT_EDIT);
+                    }
+                    if (mode)
+                        modes.push(mode);
+                },
+                { count: true, motion: true });
+        }
+
+        addMotionMap("d", "Delete motion", "cmd_delete");
+        addMotionMap("c", "Change motion", "cmd_delete", modes.INSERT);
+        addMotionMap("y", "Yank motion",   "cmd_copy");
+
+        mappings.add([modes.INPUT],
+            ["<C-w>"], "Delete previous word",
+            function () { editor.executeCommand("cmd_deleteWordBackward", 1); });
+
+        mappings.add([modes.INPUT],
+            ["<C-u>"], "Delete until beginning of current line",
+            function () {
+                // Deletes the whole line. What the hell.
+                // editor.executeCommand("cmd_deleteToBeginningOfLine", 1);
+
+                editor.executeCommand("cmd_selectBeginLine", 1);
+                if (Editor.getController().isCommandEnabled("cmd_delete"))
+                    editor.executeCommand("cmd_delete", 1);
+            });
+
+        mappings.add([modes.INPUT],
+            ["<C-k>"], "Delete until end of current line",
+            function () { editor.executeCommand("cmd_deleteToEndOfLine", 1); });
+
+        mappings.add([modes.INPUT],
+            ["<C-a>"], "Move cursor to beginning of current line",
+            function () { editor.executeCommand("cmd_beginLine", 1); });
+
+        mappings.add([modes.INPUT],
+            ["<C-e>"], "Move cursor to end of current line",
+            function () { editor.executeCommand("cmd_endLine", 1); });
+
+        mappings.add([modes.INPUT],
+            ["<C-h>"], "Delete character to the left",
+            function () { events.feedkeys("<BS>", true); });
+
+        mappings.add([modes.INPUT],
+            ["<C-d>"], "Delete character to the right",
+            function () { editor.executeCommand("cmd_deleteCharForward", 1); });
+
+        mappings.add([modes.INPUT],
+            ["<S-Insert>"], "Insert clipboard/selection",
+            function () { editor.pasteClipboard(); });
+
+        mappings.add([modes.INPUT, modes.TEXT_EDIT],
+            ["<C-i>"], "Edit text field with an external editor",
+            function () { editor.editFieldExternally(); });
+
+        mappings.add([modes.INPUT],
+            ["<C-t>"], "Edit text field in Vi mode",
+            function () {
+                dactyl.assert(!editor.isTextEdit);
+                modes.push(modes.TEXT_EDIT);
+            });
+
+        mappings.add([modes.INSERT],
+            ["<Space>", "<Return>"], "Expand insert mode abbreviation",
+            function () {
+                editor.expandAbbreviation(modes.INSERT);
+                return Events.PASS;
+            });
+
+        mappings.add([modes.INSERT],
+            ["<C-]>", "<C-5>"], "Expand insert mode abbreviation",
+            function () { editor.expandAbbreviation(modes.INSERT); });
+
+        // text edit mode
+        mappings.add([modes.TEXT_EDIT],
+            ["u"], "Undo changes",
+            function (args) {
+                editor.executeCommand("cmd_undo", Math.max(args.count, 1));
+                editor.unselectText();
+            },
+            { count: true });
+
+        mappings.add([modes.TEXT_EDIT],
+            ["<C-r>"], "Redo undone changes",
+            function (args) {
+                editor.executeCommand("cmd_redo", Math.max(args.count, 1));
+                editor.unselectText();
+            },
+            { count: true });
+
+        mappings.add([modes.TEXT_EDIT],
+            ["D"], "Delete the characters under the cursor until the end of the line",
+            function () { editor.executeCommand("cmd_deleteToEndOfLine"); });
+
+        mappings.add([modes.TEXT_EDIT],
+            ["o"], "Open line below current",
+            function () {
+                editor.executeCommand("cmd_endLine", 1);
+                modes.push(modes.INSERT);
+                events.feedkeys("<Return>");
+            });
+
+        mappings.add([modes.TEXT_EDIT],
+            ["O"], "Open line above current",
+            function () {
+                editor.executeCommand("cmd_beginLine", 1);
+                modes.push(modes.INSERT);
+                events.feedkeys("<Return>");
+                editor.executeCommand("cmd_linePrevious", 1);
+            });
+
+        mappings.add([modes.TEXT_EDIT],
+            ["X"], "Delete character to the left",
+            function (args) { editor.executeCommand("cmd_deleteCharBackward", Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.TEXT_EDIT],
+            ["x"], "Delete character to the right",
+            function (args) { editor.executeCommand("cmd_deleteCharForward", Math.max(args.count, 1)); },
+            { count: true });
+
+        // visual mode
+        mappings.add([modes.CARET, modes.TEXT_EDIT],
+            ["v"], "Start visual mode",
+            function () { modes.push(modes.VISUAL); });
+
+        mappings.add([modes.VISUAL],
+            ["v", "V"], "End visual mode",
+            function () { modes.pop(); });
+
+        mappings.add([modes.TEXT_EDIT],
+            ["V"], "Start visual line mode",
+            function () {
+                modes.push(modes.VISUAL, modes.LINE);
+                editor.executeCommand("cmd_beginLine", 1);
+                editor.executeCommand("cmd_selectLineNext", 1);
+            });
+
+        mappings.add([modes.VISUAL],
+            ["c", "s"], "Change selected text",
+            function () {
+                dactyl.assert(editor.isTextEdit);
+                editor.executeCommand("cmd_cut");
+                modes.push(modes.INSERT);
+            });
+
+        mappings.add([modes.VISUAL],
+            ["d", "x"], "Delete selected text",
+            function () {
+                dactyl.assert(editor.isTextEdit);
+                editor.executeCommand("cmd_cut");
+            });
+
+        mappings.add([modes.VISUAL],
+            ["y"], "Yank selected text",
+            function () {
+                if (editor.isTextEdit) {
+                    editor.executeCommand("cmd_copy");
+                    modes.pop();
+                }
+                else
+                    dactyl.clipboardWrite(buffer.currentWord, true);
+            });
+
+        mappings.add([modes.VISUAL, modes.TEXT_EDIT],
+            ["p"], "Paste clipboard contents",
+            function ({ count }) {
+                dactyl.assert(!editor.isCaret);
+                editor.executeCommand("cmd_paste", count || 1);
+                modes.pop(modes.TEXT_EDIT);
+            },
+            { count: true });
+
+        // finding characters
+        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
+            ["f"], "Move to a character on the current line after the cursor",
+            function ({ arg, count }) {
+                let pos = editor.findCharForward(arg, Math.max(count, 1));
+                if (pos >= 0)
+                    editor.moveToPosition(pos, true, modes.main == modes.VISUAL);
+            },
+            { arg: true, count: true });
+
+        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
+            ["F"], "Move to a character on the current line before the cursor",
+            function ({ arg, count }) {
+                let pos = editor.findCharBackward(arg, Math.max(count, 1));
+                if (pos >= 0)
+                    editor.moveToPosition(pos, false, modes.main == modes.VISUAL);
+            },
+            { arg: true, count: true });
+
+        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
+            ["t"], "Move before a character on the current line",
+            function ({ arg, count }) {
+                let pos = editor.findCharForward(arg, Math.max(count, 1));
+                if (pos >= 0)
+                    editor.moveToPosition(pos - 1, true, modes.main == modes.VISUAL);
+            },
+            { arg: true, count: true });
+
+        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
+            ["T"], "Move before a character on the current line, backwards",
+            function ({ arg, count }) {
+                let pos = editor.findCharBackward(arg, Math.max(count, 1));
+                if (pos >= 0)
+                    editor.moveToPosition(pos + 1, false, modes.main == modes.VISUAL);
+            },
+            { arg: true, count: true });
+
+        // text edit and visual mode
+        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
+            ["~"], "Switch case of the character under the cursor and move the cursor to the right",
+            function ({ count }) {
+                if (modes.main == modes.VISUAL)
+                    count = Editor.getEditor().selectionEnd - Editor.getEditor().selectionStart;
+                count = Math.max(count, 1);
+
+                // FIXME: do this in one pass?
+                while (count-- > 0) {
+                    let text = Editor.getEditor().value;
+                    let pos = Editor.getEditor().selectionStart;
+                    dactyl.assert(pos < text.length);
+
+                    let chr = text[pos];
+                    Editor.getEditor().value = text.substring(0, pos) +
+                        (chr == chr.toLocaleLowerCase() ? chr.toLocaleUpperCase() : chr.toLocaleLowerCase()) +
+                        text.substring(pos + 1);
+                    editor.moveToPosition(pos + 1, true, false);
+                }
+                modes.pop(modes.TEXT_EDIT);
+            },
+            { count: true });
+
+        function bind() mappings.add.apply(mappings,
+                                           [[modes.AUTOCOMPLETE]].concat(Array.slice(arguments)))
+
+        bind(["<Esc>"], "Return to INSERT mode",
+             function () Events.PASS_THROUGH);
+
+        bind(["<C-[>"], "Return to INSERT mode",
+             function () { events.feedkeys("<Esc>", { skipmap: true }); });
+
+        bind(["<Up>"], "Select the previous autocomplete result",
+             function () Events.PASS_THROUGH);
+
+        bind(["<C-p>"], "Select the previous autocomplete result",
+             function () { events.feedkeys("<Up>", { skipmap: true }); });
+
+        bind(["<Down>"], "Select the next autocomplete result",
+             function () Events.PASS_THROUGH);
+
+        bind(["<C-n>"], "Select the next autocomplete result",
+             function () { events.feedkeys("<Down>", { skipmap: true }); });
+    },
+
+    options: function () {
+        options.add(["editor"],
+            "The external text editor",
+            "string", "gvim -f +<line> <file>", {
+                format: function (obj, value) {
+                    let args = commands.parseArgs(value || this.value, { argCount: "*", allowUnknownOptions: true })
+                                       .map(util.compileMacro).filter(function (fmt) fmt.valid(obj))
+                                       .map(function (fmt) fmt(obj));
+                    if (obj["file"] && !this.has("file"))
+                        args.push(obj["file"]);
+                    return args;
+                },
+                has: function (key) set.has(util.compileMacro(this.value).seen, key),
+                validator: function (value) {
+                    this.format({}, value);
+                    return Object.keys(util.compileMacro(value).seen).every(function (k) ["column", "file", "line"].indexOf(k) >= 0);
+                }
+            });
+
+        options.add(["insertmode", "im"],
+            "Enter Insert mode rather than Text Edit mode when focusing text areas",
+            "boolean", true);
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/eval.js b/common/content/eval.js
new file mode 100644 (file)
index 0000000..49298d9
--- /dev/null
@@ -0,0 +1,12 @@
+try { __dactyl_eval_result = eval(__dactyl_eval_string); }
+catch (e) { __dactyl_eval_error = e; }
+
+// IMPORTANT: The eval statement *must* remain on the first line
+// in order for line numbering in any errors to remain correct.
+
+// Copyright (c) 2008-2010 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/events.js b/common/content/events.js
new file mode 100644 (file)
index 0000000..0b279fd
--- /dev/null
@@ -0,0 +1,1647 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+var ProcessorStack = Class("ProcessorStack", {
+    init: function (mode, hives, builtin) {
+        this.main = mode.main;
+        this._actions = [];
+        this.actions = [];
+        this.buffer = "";
+        this.events = [];
+
+        let main = { __proto__: mode.main, params: mode.params };
+        let keyModes = array([mode.params.keyModes, main, mode.main.allBases]).flatten().compact();
+
+        if (builtin)
+            hives = hives.filter(function (h) h.name === "builtin");
+
+        this.processors = keyModes.map(function (m) hives.map(function (h) KeyProcessor(m, h)))
+                                  .flatten().array;
+        this.ownsBuffer = !this.processors.some(function (p) p.main.ownsBuffer);
+
+        for (let [i, input] in Iterator(this.processors)) {
+            let params = input.main.params;
+            if (params.preExecute)
+                input.preExecute = params.preExecute;
+            if (params.postExecute)
+                input.postExecute = params.postExecute;
+            if (params.onKeyPress && input.hive === mappings.builtin)
+                input.fallthrough = function fallthrough(events) {
+                    return params.onKeyPress(events) === false ? Events.KILL : Events.PASS;
+                };
+            }
+
+        let hive = options.get("passkeys")[this.main.input ? "inputHive" : "commandHive"];
+        if (!builtin && hive.active
+                && (!dactyl.focusedElement || events.isContentNode(dactyl.focusedElement)))
+            this.processors.unshift(KeyProcessor(modes.BASE, hive));
+    },
+
+    notify: function () {
+        events.keyEvents = [];
+        events.processor = null;
+        if (!this.execute(Events.KILL, true)) {
+            events.processor = this;
+            events.keyEvents = this.keyEvents;
+        }
+    },
+
+    _result: function (result) (result === Events.KILL         ? "KILL"  :
+                                result === Events.PASS         ? "PASS"  :
+                                result === Events.PASS_THROUGH ? "PASS_THROUGH"  :
+                                result === Events.ABORT        ? "ABORT" :
+                                callable(result) ? result.toSource().substr(0, 50) : result),
+
+    execute: function execute(result, force) {
+
+        if (force && this.actions.length)
+            this.processors.length = 0;
+
+        if (this.ownsBuffer)
+            statusline.inputBuffer = this.processors.length ? this.buffer : "";
+
+        if (!this.processors.some(function (p) !p.extended) && this.actions.length) {
+            if (this._actions.length == 0) {
+                dactyl.beep();
+                events.feedingKeys = false;
+            }
+
+            for (var action in values(this.actions)) {
+                while (callable(action)) {
+                    action = dactyl.trapErrors(action);
+                    events.dbg("ACTION RES: " + this._result(action));
+                }
+                if (action !== Events.PASS)
+                    break;
+            }
+
+            result = action !== undefined ? action : Events.KILL;
+            if (action !== Events.PASS)
+                this.processors.length = 0;
+        }
+        else if (this.processors.length) {
+            result = Events.KILL;
+            if (this.actions.length && options["timeout"])
+                this.timer = services.Timer(this, options["timeoutlen"], services.Timer.TYPE_ONE_SHOT);
+        }
+        else if (result !== Events.KILL && !this.actions.length &&
+                 (this.events.length > 1 ||
+                     this.processors.some(function (p) !p.main.passUnknown))) {
+            result = Events.KILL;
+            if (!Events.isEscape(this.events.slice(-1)[0]))
+                dactyl.beep();
+            events.feedingKeys = false;
+        }
+        else if (result === undefined)
+            result = Events.PASS;
+
+        events.dbg("RESULT: " + this._result(result));
+
+        if (result === Events.PASS || result === Events.PASS_THROUGH)
+            if (this.events[0].originalTarget)
+                this.events[0].originalTarget.dactylKeyPress = undefined;
+
+        if (result !== Events.PASS || this.events.length > 1)
+            Events.kill(this.events[this.events.length - 1]);
+
+        if (result === Events.PASS_THROUGH)
+            events.feedevents(null, this.keyEvents, { skipmap: true, isMacro: true, isReplay: true });
+        else if (result === Events.PASS || result === Events.ABORT) {
+            let list = this.events.filter(function (e) e.getPreventDefault() && !e.dactylDefaultPrevented);
+            if (list.length)
+                events.dbg("REFEED: " + list.map(events.closure.toString).join(""));
+            events.feedevents(null, list, { skipmap: true, isMacro: true, isReplay: true });
+        }
+
+        return this.processors.length === 0;
+    },
+
+    process: function process(event) {
+        if (this.timer)
+            this.timer.cancel();
+
+        let key = events.toString(event);
+        this.events.push(event);
+        if (this.keyEvents)
+            this.keyEvents.push(event);
+
+        this.buffer += key;
+
+        let actions = [];
+        let processors = [];
+
+        events.dbg("KEY: " + key + " skipmap: " + event.skipmap + " macro: " + event.isMacro + " replay: " + event.isReplay);
+
+        for (let [i, input] in Iterator(this.processors)) {
+            let res = input.process(event);
+            if (res !== Events.ABORT)
+                var result = res;
+
+            events.dbg("RES: " + input + " " + this._result(res));
+
+            if (res === Events.KILL)
+                break;
+
+            buffer = buffer || input.inputBuffer;
+
+            if (callable(res))
+                actions.push(res);
+
+            if (res === Events.WAIT || input.waiting)
+                processors.push(input);
+            if (isinstance(res, KeyProcessor))
+                processors.push(res);
+        }
+
+        events.dbg("RESULT: " + event.getPreventDefault() + " " + this._result(result));
+        events.dbg("ACTIONS: " + actions.length + " " + this.actions.length);
+        events.dbg("PROCESSORS:", processors);
+
+        this._actions = actions;
+        this.actions = actions.concat(this.actions);
+
+        if (result === Events.KILL)
+            this.actions = [];
+        else if (!this.actions.length && !processors.length)
+            for (let input in values(this.processors))
+                if (input.fallthrough) {
+                    if (result === Events.KILL)
+                        break;
+                    result = dactyl.trapErrors(input.fallthrough, input, this.events);
+                }
+
+        this.processors = processors;
+
+        return this.execute(result, options["timeout"] && options["timeoutlen"] === 0);
+    }
+});
+
+var KeyProcessor = Class("KeyProcessor", {
+    init: function init(main, hive) {
+        this.main = main;
+        this.events = [];
+        this.hive = hive;
+        this.wantCount = this.main.count;
+    },
+
+    get toStringParams() [this.main.name, this.hive.name],
+
+    countStr: "",
+    command: "",
+    get count() this.countStr ? Number(this.countStr) : null,
+
+    append: function append(event) {
+        this.events.push(event);
+        let key = events.toString(event);
+
+        if (this.wantCount && !this.command &&
+                (this.countStr ? /^[0-9]$/ : /^[1-9]$/).test(key))
+            this.countStr += key;
+        else
+            this.command += key;
+        return this.events;
+    },
+
+    process: function process(event) {
+        this.append(event);
+        this.waiting = false;
+        return this.onKeyPress(event);
+    },
+
+    execute: function execute(map, args)
+        let (self = this)
+            function execute() {
+                if (self.preExecute)
+                    self.preExecute.apply(self, args);
+                let res = map.execute.call(map, update({ self: self.main.params.mappingSelf || self.main.mappingSelf || map },
+                                                       args));
+                if (self.postExecute)
+                    self.postExecute.apply(self, args);
+                return res;
+            },
+
+    onKeyPress: function onKeyPress(event) {
+        if (event.skipmap)
+            return Events.ABORT;
+
+        if (!this.command)
+            return Events.WAIT;
+
+        var map = this.hive.get(this.main, this.command);
+        this.waiting = this.hive.getCandidates(this.main, this.command);
+        if (map) {
+            if (map.arg)
+                return KeyArgProcessor(this, map, false, "arg");
+            else if (map.motion)
+                return KeyArgProcessor(this, map, true, "motion");
+
+            return this.execute(map, {
+                keyEvents: this.keyEvents,
+                command: this.command,
+                count: this.count,
+                keypressEvents: this.events
+            });
+        }
+
+        if (!this.waiting)
+            return this.main.insert ? Events.PASS : Events.ABORT;
+
+        return Events.WAIT;
+    }
+});
+
+var KeyArgProcessor = Class("KeyArgProcessor", KeyProcessor, {
+    init: function init(input, map, wantCount, argName) {
+        init.supercall(this, input.main, input.hive);
+        this.map = map;
+        this.parent = input;
+        this.argName = argName;
+        this.wantCount = wantCount;
+    },
+
+    extended: true,
+
+    onKeyPress: function onKeyPress(event) {
+        if (Events.isEscape(event))
+            return Events.KILL;
+        if (!this.command)
+            return Events.WAIT;
+
+        let args = {
+            command: this.parent.command,
+            count:   this.count || this.parent.count,
+            events:  this.parent.events.concat(this.events)
+        };
+        args[this.argName] = this.command;
+
+        return this.execute(this.map, args);
+    }
+});
+
+var EventHive = Class("EventHive", Contexts.Hive, {
+    init: function init(group) {
+        init.supercall(this, group);
+        this.sessionListeners = [];
+    },
+
+    cleanup: function cleanup() {
+        this.unlisten(null);
+    },
+
+    /**
+     * Adds an event listener for this session and removes it on
+     * dactyl shutdown.
+     *
+     * @param {Element} target The element on which to listen.
+     * @param {string} event The event to listen for.
+     * @param {function} callback The function to call when the event is received.
+     * @param {boolean} capture When true, listen during the capture
+     *      phase, otherwise during the bubbling phase.
+     * @param {boolean} allowUntrusted When true, allow capturing of
+     *      untrusted events.
+     */
+    listen: function (target, event, callback, capture, allowUntrusted) {
+        if (!isObject(event))
+            var [self, events] = [null, array.toObject([[event, callback]])];
+        else {
+            [self, events] = [event, event[callback || "events"]];
+            [,, capture, allowUntrusted] = arguments;
+        }
+
+        for (let [event, callback] in Iterator(events)) {
+            let args = [Cu.getWeakReference(target),
+                        event,
+                        this.wrapListener(callback, self),
+                        capture,
+                        allowUntrusted];
+
+            target.addEventListener.apply(target, args.slice(1));
+            this.sessionListeners.push(args);
+        }
+    },
+
+    /**
+     * Remove an event listener.
+     *
+     * @param {Element} target The element on which to listen.
+     * @param {string} event The event to listen for.
+     * @param {function} callback The function to call when the event is received.
+     * @param {boolean} capture When true, listen during the capture
+     *      phase, otherwise during the bubbling phase.
+     */
+    unlisten: function (target, event, callback, capture) {
+        this.sessionListeners = this.sessionListeners.filter(function (args) {
+            if (target == null || args[0].get() == target && args[1] == event && args[2] == callback && args[3] == capture) {
+                args[0].get().removeEventListener.apply(args[0].get(), args.slice(1));
+                return false;
+            }
+            return !args[0].get();
+        });
+    }
+});
+
+/**
+ * @instance events
+ */
+var Events = Module("events", {
+    dbg: function () {},
+
+    init: function () {
+        const self = this;
+        this.keyEvents = [];
+
+        update(this, {
+            hives: contexts.Hives("events", EventHive),
+            user: contexts.hives.events.user,
+            builtin: contexts.hives.events.builtin
+        });
+
+        EventHive.prototype.wrapListener = this.closure.wrapListener;
+
+        XML.ignoreWhitespace = true;
+        util.overlayWindow(window, {
+            append: <e4x xmlns={XUL}>
+                <window id={document.documentElement.id}>
+                    <!--this notifies us also of focus events in the XUL
+                        from: http://developer.mozilla.org/en/docs/XUL_Tutorial:Updating_Commands !-->
+                    <!-- I don't think we really need this. ––Kris -->
+                    <commandset id="dactyl-onfocus" commandupdater="true" events="focus"
+                                oncommandupdate="dactyl.modules.events.onFocusChange(event);"/>
+                    <commandset id="dactyl-onselect" commandupdater="true" events="select"
+                                oncommandupdate="dactyl.modules.events.onSelectionChange(event);"/>
+                </window>
+            </e4x>.elements()
+        });
+
+        this._fullscreen = window.fullScreen;
+        this._lastFocus = null;
+        this._macroKeys = [];
+        this._lastMacro = "";
+
+        this._macros = storage.newMap("macros", { privateData: true, store: true });
+        for (let [k, m] in this._macros)
+            if (isString(m))
+                m = { keys: m, timeRecorded: Date.now() };
+
+        // NOTE: the order of ["Esc", "Escape"] or ["Escape", "Esc"]
+        //       matters, so use that string as the first item, that you
+        //       want to refer to within dactyl's source code for
+        //       comparisons like if (key == "<Esc>") { ... }
+        this._keyTable = {
+            add: ["Plus", "Add"],
+            back_space: ["BS"],
+            count: ["count"],
+            delete: ["Del"],
+            escape: ["Esc", "Escape"],
+            insert: ["Insert", "Ins"],
+            leader: ["Leader"],
+            left_shift: ["LT", "<"],
+            nop: ["Nop"],
+            pass: ["Pass"],
+            return: ["Return", "CR", "Enter"],
+            right_shift: [">"],
+            space: ["Space", " "],
+            subtract: ["Minus", "Subtract"]
+        };
+
+        this._pseudoKeys = set(["count", "leader", "nop", "pass"]);
+
+        this._key_key = {};
+        this._code_key = {};
+        this._key_code = {};
+
+        for (let list in values(this._keyTable))
+            for (let v in values(list)) {
+                if (v.length == 1)
+                    v = v.toLowerCase();
+                this._key_key[v.toLowerCase()] = v;
+            }
+
+        for (let [k, v] in Iterator(KeyEvent)) {
+            k = k.substr(7).toLowerCase();
+            let names = [k.replace(/(^|_)(.)/g, function (m, n1, n2) n2.toUpperCase())
+                          .replace(/^NUMPAD/, "k")];
+
+            if (names[0].length == 1)
+                names[0] = names[0].toLowerCase();
+
+            if (k in this._keyTable)
+                names = this._keyTable[k];
+            this._code_key[v] = names[0];
+            for (let [, name] in Iterator(names)) {
+                this._key_key[name.toLowerCase()] = name;
+                this._key_code[name.toLowerCase()] = v;
+            }
+        }
+
+        // HACK: as Gecko does not include an event for <, we must add this in manually.
+        if (!("<" in this._key_code)) {
+            this._key_code["<"] = 60;
+            this._key_code["lt"] = 60;
+            this._code_key[60] = "lt";
+        }
+
+        this._activeMenubar = false;
+        this.listen(window, this, "events", true);
+
+        dactyl.registerObserver("modeChange", function () {
+            delete self.processor;
+        });
+    },
+
+    signals: {
+        "browser.locationChange": function (webProgress, request, uri) {
+            options.get("passkeys").flush();
+        }
+    },
+
+    /**
+     * Adds an event listener for this session and removes it on
+     * dactyl shutdown.
+     *
+     * @param {Element} target The element on which to listen.
+     * @param {string} event The event to listen for.
+     * @param {function} callback The function to call when the event is received.
+     * @param {boolean} capture When true, listen during the capture
+     *      phase, otherwise during the bubbling phase.
+     */
+    get listen() this.builtin.closure.listen,
+    addSessionListener: deprecated("events.listen", { get: function addSessionListener() this.listen }),
+
+    /**
+     * Wraps an event listener to ensure that errors are reported.
+     */
+    wrapListener: function wrapListener(method, self) {
+        self = self || this;
+        method.wrapped = wrappedListener;
+        function wrappedListener(event) {
+            try {
+                method.apply(self, arguments);
+            }
+            catch (e) {
+                dactyl.reportError(e);
+                if (e.message == "Interrupted")
+                    dactyl.echoerr(_("error.interrupted"), commandline.FORCE_SINGLELINE);
+                else
+                    dactyl.echoerr(_("event.error", event.type, e.echoerr || e),
+                                   commandline.FORCE_SINGLELINE);
+            }
+        };
+        return wrappedListener;
+    },
+
+    /**
+     * @property {boolean} Whether synthetic key events are currently being
+     *     processed.
+     */
+    feedingKeys: false,
+
+    /**
+     * Initiates the recording of a key event macro.
+     *
+     * @param {string} macro The name for the macro.
+     */
+    _recording: null,
+    get recording() this._recording,
+
+    set recording(macro) {
+        dactyl.assert(macro == null || /[a-zA-Z0-9]/.test(macro),
+                      _("macro.invalid", macro));
+
+        modes.recording = !!macro;
+
+        if (/[A-Z]/.test(macro)) { // uppercase (append)
+            macro = macro.toLowerCase();
+            this._macroKeys = events.fromString((this._macros.get(macro) || { keys: "" }).keys, true)
+                                    .map(events.closure.toString);
+        }
+        else if (macro) {
+            this._macroKeys = [];
+        }
+        else {
+            this._macros.set(this.recording, {
+                keys: this._macroKeys.join(""),
+                timeRecorded: Date.now()
+            });
+
+            dactyl.log("Recorded " + this.recording + ": " + this._macroKeys.join(""), 9);
+            dactyl.echomsg(_("macro.recorded", this.recording));
+        }
+        this._recording = macro || null;
+    },
+
+    /**
+     * Replays a macro.
+     *
+     * @param {string} The name of the macro to replay.
+     * @returns {boolean}
+     */
+    playMacro: function (macro) {
+        let res = false;
+        dactyl.assert(/^[a-zA-Z0-9@]$/.test(macro), _("macro.invalid", macro));
+
+        if (macro == "@")
+            dactyl.assert(this._lastMacro, _("macro.noPrevious"));
+        else
+            this._lastMacro = macro.toLowerCase(); // XXX: sets last played macro, even if it does not yet exist
+
+        if (this._macros.get(this._lastMacro)) {
+            try {
+                modes.replaying = true;
+                res = events.feedkeys(this._macros.get(this._lastMacro).keys, { noremap: true });
+            }
+            finally {
+                modes.replaying = false;
+            }
+        }
+        else
+            // TODO: ignore this like Vim?
+            dactyl.echoerr(_("macro.noSuch", this._lastMacro));
+        return res;
+    },
+
+    /**
+     * Returns all macros matching *filter*.
+     *
+     * @param {string} filter A regular expression filter string. A null
+     *     filter selects all macros.
+     */
+    getMacros: function (filter) {
+        let re = RegExp(filter || "");
+        return ([k, m.keys] for ([k, m] in events._macros) if (re.test(k)));
+    },
+
+    /**
+     * Deletes all macros matching *filter*.
+     *
+     * @param {string} filter A regular expression filter string. A null
+     *     filter deletes all macros.
+     */
+    deleteMacros: function (filter) {
+        let re = RegExp(filter || "");
+        for (let [item, ] in this._macros) {
+            if (!filter || re.test(item))
+                this._macros.remove(item);
+        }
+    },
+
+    /**
+     * Feeds a list of events to *target* or the originalTarget member
+     * of each event if *target* is null.
+     *
+     * @param {EventTarget} target The destination node for the events.
+     *      @optional
+     * @param {[Event]} list The events to dispatch.
+     * @param {object} extra Extra properties for processing by dactyl.
+     *      @optional
+     */
+    feedevents: function feedevents(target, list, extra) {
+        list.forEach(function _feedevent(event, i) {
+            let elem = target || event.originalTarget;
+            if (elem) {
+                let doc = elem.ownerDocument || elem.document || elem;
+                let evt = events.create(doc, event.type, event);
+                events.dispatch(elem, evt, extra);
+            }
+            else if (i > 0 && event.type === "keypress")
+                events.events.keypress.call(events, event);
+        });
+    },
+
+    /**
+     * Pushes keys onto the event queue from dactyl. It is similar to
+     * Vim's feedkeys() method, but cannot cope with 2 partially-fed
+     * strings, you have to feed one parseable string.
+     *
+     * @param {string} keys A string like "2<C-f>" to push onto the event
+     *     queue. If you want "<" to be taken literally, prepend it with a
+     *     "\\".
+     * @param {boolean} noremap Whether recursive mappings should be
+     *     disallowed.
+     * @param {boolean} silent Whether the command should be echoed to the
+     *     command line.
+     * @returns {boolean}
+     */
+    feedkeys: function (keys, noremap, quiet, mode) {
+        try {
+            var savedEvents = this._processor && this._processor.keyEvents;
+
+            var wasFeeding = this.feedingKeys;
+            this.feedingKeys = true;
+
+            var wasQuiet = commandline.quiet;
+            if (quiet)
+                commandline.quiet = quiet;
+
+            for (let [, evt_obj] in Iterator(events.fromString(keys))) {
+                let now = Date.now();
+                for (let type in values(["keydown", "keyup", "keypress"])) {
+                    let evt = update({}, evt_obj, { type: type });
+
+                    if (isObject(noremap))
+                        update(evt, noremap);
+                    else
+                        evt.noremap = !!noremap;
+                    evt.isMacro = true;
+                    evt.dactylMode = mode;
+                    evt.dactylSavedEvents = savedEvents;
+                    this.feedingEvent = evt;
+
+                    let event = events.create(document.commandDispatcher.focusedWindow.document, type, evt);
+                    if (!evt_obj.dactylString && !evt_obj.dactylShift && !mode)
+                        events.dispatch(dactyl.focusedElement || buffer.focusedFrame, event, evt);
+                    else if (type === "keypress")
+                        events.events.keypress.call(events, event);
+                }
+
+                if (!this.feedingKeys)
+                    return false;
+            }
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+        finally {
+            this.feedingEvent = null;
+            this.feedingKeys = wasFeeding;
+            if (quiet)
+                commandline.quiet = wasQuiet;
+            dactyl.triggerObserver("events.doneFeeding");
+        }
+        return true;
+    },
+
+    /**
+     * Creates an actual event from a pseudo-event object.
+     *
+     * The pseudo-event object (such as may be retrieved from events.fromString)
+     * should have any properties you want the event to have.
+     *
+     * @param {Document} doc The DOM document to associate this event with
+     * @param {Type} type The type of event (keypress, click, etc.)
+     * @param {Object} opts The pseudo-event. @optional
+     */
+    create: function (doc, type, opts) {
+        opts = opts || {};
+        var DEFAULTS = {
+            HTML: {
+                type: type, bubbles: true, cancelable: false
+            },
+            Key: {
+                type: type,
+                bubbles: true, cancelable: true,
+                view: doc.defaultView,
+                ctrlKey: false, altKey: false, shiftKey: false, metaKey: false,
+                keyCode: 0, charCode: 0
+            },
+            Mouse: {
+                type: type,
+                bubbles: true, cancelable: true,
+                view: doc.defaultView,
+                detail: 1,
+                screenX: 0, screenY: 0,
+                clientX: 0, clientY: 0,
+                ctrlKey: false, altKey: false, shiftKey: false, metaKey: false,
+                button: 0,
+                relatedTarget: null
+            }
+        };
+        const TYPES = {
+            change: "", input: "", submit: "",
+            click: "Mouse", mousedown: "Mouse", mouseup: "Mouse",
+            mouseover: "Mouse", mouseout: "Mouse",
+            keypress: "Key", keyup: "Key", keydown: "Key"
+        };
+        var t = TYPES[type];
+        var evt = doc.createEvent((t || "HTML") + "Events");
+
+        let defaults = DEFAULTS[t || "HTML"];
+        evt["init" + t + "Event"].apply(evt, Object.keys(defaults)
+                                                   .map(function (k) k in opts ? opts[k]
+                                                                               : defaults[k]));
+        return evt;
+    },
+
+    /**
+     * Converts a user-input string of keys into a canonical
+     * representation.
+     *
+     * <C-A> maps to <C-a>, <C-S-a> maps to <C-S-A>
+     * <C- > maps to <C-Space>, <S-a> maps to A
+     * << maps to <lt><lt>
+     *
+     * <S-@> is preserved, as in Vim, to allow untypeable key-combinations
+     * in macros.
+     *
+     * canonicalKeys(canonicalKeys(x)) == canonicalKeys(x) for all values
+     * of x.
+     *
+     * @param {string} keys Messy form.
+     * @param {boolean} unknownOk Whether unknown keys are passed
+     *     through rather than being converted to <lt>keyname>.
+     *     @default false
+     * @returns {string} Canonical form.
+     */
+    canonicalKeys: function (keys, unknownOk) {
+        if (arguments.length === 1)
+            unknownOk = true;
+        return events.fromString(keys, unknownOk).map(events.closure.toString).join("");
+    },
+
+    iterKeys: function (keys) {
+        let match, re = /<.*?>?>|[^<]/g;
+        while (match = re.exec(keys))
+            yield match[0];
+    },
+
+    /**
+     * Dispatches an event to an element as if it were a native event.
+     *
+     * @param {Node} target The DOM node to which to dispatch the event.
+     * @param {Event} event The event to dispatch.
+     */
+    dispatch: Class.memoize(function ()
+        util.haveGecko("2b")
+            ? function dispatch(target, event, extra) {
+                try {
+                    this.feedingEvent = extra;
+                    if (target instanceof Element)
+                        // This causes a crash on Gecko<2.0, it seems.
+                        return (target.ownerDocument || target.document || target).defaultView
+                               .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils)
+                               .dispatchDOMEventViaPresShell(target, event, true);
+                    else {
+                        target.dispatchEvent(event);
+                        return !event.getPreventDefault();
+                    }
+                }
+                catch (e) {
+                    util.reportError(e);
+                }
+                finally {
+                    this.feedingEvent = null;
+                }
+            }
+            : function dispatch(target, event, extra) {
+                try {
+                    this.feedingEvent = extra;
+                    target.dispatchEvent(update(event, extra));
+                }
+                finally {
+                    this.feedingEvent = null;
+                }
+            }),
+
+    get defaultTarget() dactyl.focusedElement || content.document.body || document.documentElement,
+
+    /**
+     * Converts an event string into an array of pseudo-event objects.
+     *
+     * These objects can be used as arguments to events.toString or
+     * events.create, though they are unlikely to be much use for other
+     * purposes. They have many of the properties you'd expect to find on a
+     * real event, but none of the methods.
+     *
+     * Also may contain two "special" parameters, .dactylString and
+     * .dactylShift these are set for characters that can never by
+     * typed, but may appear in mappings, for example <Nop> is passed as
+     * dactylString, and dactylShift is set when a user specifies
+     * <S-@> where @ is a non-case-changeable, non-space character.
+     *
+     * @param {string} keys The string to parse.
+     * @param {boolean} unknownOk Whether unknown keys are passed
+     *     through rather than being converted to <lt>keyname>.
+     *     @default false
+     * @returns {Array[Object]}
+     */
+    fromString: function (input, unknownOk) {
+
+        if (arguments.length === 1)
+            unknownOk = true;
+
+        let out = [];
+        for (let match in util.regexp.iterate(/<.*?>?>|[^<]|<(?!.*>)/g, input)) {
+            let evt_str = match[0];
+            let evt_obj = { ctrlKey: false, shiftKey: false, altKey: false, metaKey: false,
+                            keyCode: 0, charCode: 0, type: "keypress" };
+
+            if (evt_str.length > 1) { // <.*?>
+                let [match, modifier, keyname] = evt_str.match(/^<((?:[CSMA]-)*)(.+?)>$/i) || [false, '', ''];
+                modifier = modifier.toUpperCase();
+                keyname = keyname.toLowerCase();
+                evt_obj.dactylKeyname = keyname;
+                if (/^u[0-9a-f]+$/.test(keyname))
+                    keyname = String.fromCharCode(parseInt(keyname.substr(1), 16));
+
+                if (keyname && (unknownOk || keyname.length == 1 || /mouse$/.test(keyname) ||
+                                this._key_code[keyname] || set.has(this._pseudoKeys, keyname))) {
+                    evt_obj.ctrlKey  = /C-/.test(modifier);
+                    evt_obj.altKey   = /A-/.test(modifier);
+                    evt_obj.shiftKey = /S-/.test(modifier);
+                    evt_obj.metaKey  = /M-/.test(modifier);
+
+                    if (keyname.length == 1) { // normal characters
+                        if (evt_obj.shiftKey) {
+                            keyname = keyname.toUpperCase();
+                            if (keyname == keyname.toLowerCase())
+                                evt_obj.dactylShift = true;
+                        }
+
+                        evt_obj.charCode = keyname.charCodeAt(0);
+                    }
+                    else if (set.has(this._pseudoKeys, keyname)) {
+                        evt_obj.dactylString = "<" + this._key_key[keyname] + ">";
+                    }
+                    else if (/mouse$/.test(keyname)) { // mouse events
+                        evt_obj.type = (/2-/.test(modifier) ? "dblclick" : "click");
+                        evt_obj.button = ["leftmouse", "middlemouse", "rightmouse"].indexOf(keyname);
+                        delete evt_obj.keyCode;
+                        delete evt_obj.charCode;
+                    }
+                    else { // spaces, control characters, and <
+                        evt_obj.keyCode = this._key_code[keyname];
+                        evt_obj.charCode = 0;
+                    }
+                }
+                else { // an invalid sequence starting with <, treat as a literal
+                    out = out.concat(events.fromString("<lt>" + evt_str.substr(1)));
+                    continue;
+                }
+            }
+            else // a simple key (no <...>)
+                evt_obj.charCode = evt_str.charCodeAt(0);
+
+            // TODO: make a list of characters that need keyCode and charCode somewhere
+            if (evt_obj.keyCode == 32 || evt_obj.charCode == 32)
+                evt_obj.charCode = evt_obj.keyCode = 32; // <Space>
+            if (evt_obj.keyCode == 60 || evt_obj.charCode == 60)
+                evt_obj.charCode = evt_obj.keyCode = 60; // <lt>
+
+            evt_obj.modifiers = (evt_obj.ctrlKey && Ci.nsIDOMNSEvent.CONTROL_MASK)
+                              | (evt_obj.altKey && Ci.nsIDOMNSEvent.ALT_MASK)
+                              | (evt_obj.shiftKey && Ci.nsIDOMNSEvent.SHIFT_MASK)
+                              | (evt_obj.metaKey && Ci.nsIDOMNSEvent.META_MASK);
+
+            out.push(evt_obj);
+        }
+        return out;
+    },
+
+    /**
+     * Converts the specified event to a string in dactyl key-code
+     * notation. Returns null for an unknown event.
+     *
+     * @param {Event} event
+     * @returns {string}
+     */
+    toString: function toString(event) {
+        if (!event)
+            return toString.supercall(this);
+
+        if (event.dactylString)
+            return event.dactylString;
+
+        let key = null;
+        let modifier = "";
+
+        if (event.ctrlKey)
+            modifier += "C-";
+        if (event.altKey)
+            modifier += "A-";
+        if (event.metaKey)
+            modifier += "M-";
+
+        if (/^key/.test(event.type)) {
+            let charCode = event.type == "keyup" ? 0 : event.charCode; // Why? --Kris
+            if (charCode == 0) {
+                if (event.keyCode in this._code_key) {
+                    key = this._code_key[event.keyCode];
+
+                    if (event.shiftKey && (key.length > 1 || event.ctrlKey || event.altKey || event.metaKey) || event.dactylShift)
+                        modifier += "S-";
+                    else if (!modifier && key.length === 1)
+                        if (event.shiftKey)
+                            key = key.toUpperCase();
+                        else
+                            key = key.toLowerCase();
+                    if (!modifier && /^[a-z0-9]$/i.test(key))
+                        return key;
+                }
+            }
+            // [Ctrl-Bug] special handling of mysterious <C-[>, <C-\\>, <C-]>, <C-^>, <C-_> bugs (OS/X)
+            //            (i.e., cntrl codes 27--31)
+            // ---
+            // For more information, see:
+            //     [*] Referenced mailing list msg: http://www.mozdev.org/pipermail/pentadactyl/2008-May/001548.html
+            //     [*] Mozilla bug 416227: event.charCode in keypress handler has unexpected values on Mac for Ctrl with chars in "[ ] _ \"
+            //         https://bugzilla.mozilla.org/show_bug.cgi?id=416227
+            //     [*] Mozilla bug 432951: Ctrl+'foo' doesn't seem same charCode as Meta+'foo' on Cocoa
+            //         https://bugzilla.mozilla.org/show_bug.cgi?id=432951
+            // ---
+            //
+            // The following fixes are only activated if util.OS.isMacOSX.
+            // Technically, they prevent mappings from <C-Esc> (and
+            // <C-C-]> if your fancy keyboard permits such things<?>), but
+            // these <C-control> mappings are probably pathological (<C-Esc>
+            // certainly is on Windows), and so it is probably
+            // harmless to remove the util.OS.isMacOSX if desired.
+            //
+            else if (util.OS.isMacOSX && event.ctrlKey && charCode >= 27 && charCode <= 31) {
+                if (charCode == 27) { // [Ctrl-Bug 1/5] the <C-[> bug
+                    key = "Esc";
+                    modifier = modifier.replace("C-", "");
+                }
+                else // [Ctrl-Bug 2,3,4,5/5] the <C-\\>, <C-]>, <C-^>, <C-_> bugs
+                    key = String.fromCharCode(charCode + 64);
+            }
+            // a normal key like a, b, c, 0, etc.
+            else if (charCode > 0) {
+                key = String.fromCharCode(charCode);
+
+                if (!/^[a-z0-9]$/i.test(key) && key in this._key_code) {
+                    // a named charCode key (<Space> and <lt>) space can be shifted, <lt> must be forced
+                    if ((key.match(/^\s$/) && event.shiftKey) || event.dactylShift)
+                        modifier += "S-";
+
+                    key = this._code_key[this._key_code[key]];
+                }
+                else {
+                    // a shift modifier is only allowed if the key is alphabetical and used in a C-A-M- mapping in the uppercase,
+                    // or if the shift has been forced for a non-alphabetical character by the user while :map-ping
+                    if (key != key.toLowerCase() && (event.ctrlKey || event.altKey || event.metaKey) || event.dactylShift)
+                        modifier += "S-";
+                    if (/^\s$/.test(key))
+                        key = let (s = charCode.toString(16)) "U" + "0000".substr(4 - s.length) + s;
+                    else if (modifier.length == 0)
+                        return key;
+                }
+            }
+            if (key == null)
+                key = this._key_key[event.dactylKeyname] || event.dactylKeyname;
+            if (key == null)
+                return null;
+        }
+        else if (event.type == "click" || event.type == "dblclick") {
+            if (event.shiftKey)
+                modifier += "S-";
+            if (event.type == "dblclick")
+                modifier += "2-";
+            // TODO: triple and quadruple click
+
+            switch (event.button) {
+            case 0:
+                key = "LeftMouse";
+                break;
+            case 1:
+                key = "MiddleMouse";
+                break;
+            case 2:
+                key = "RightMouse";
+                break;
+            }
+        }
+
+        if (key == null)
+            return null;
+
+        return "<" + modifier + key + ">";
+    },
+
+    /**
+     * Whether *key* is a key code defined to accept/execute input on the
+     * command line.
+     *
+     * @param {string} key The key code to test.
+     * @returns {boolean}
+     */
+    isAcceptKey: function (key) key == "<Return>" || key == "<C-j>" || key == "<C-m>",
+
+    /**
+     * Whether *key* is a key code defined to reject/cancel input on the
+     * command line.
+     *
+     * @param {string} key The key code to test.
+     * @returns {boolean}
+     */
+    isCancelKey: function (key) key == "<Esc>" || key == "<C-[>" || key == "<C-c>",
+
+    isContentNode: function isContentNode(node) {
+        let win = (node.ownerDocument || node).defaultView || node;
+        return XPCNativeWrapper(win).top == content;
+    },
+
+    /**
+     * Waits for the current buffer to successfully finish loading. Returns
+     * true for a successful page load otherwise false.
+     *
+     * @returns {boolean}
+     */
+    waitForPageLoad: function (time) {
+        if (buffer.loaded)
+            return true;
+
+        dactyl.echo(_("macro.loadWaiting"), commandline.DISALLOW_MULTILINE);
+
+        const maxWaitTime = (time || 25);
+        util.waitFor(function () !events.feedingKeys || buffer.loaded, this, maxWaitTime * 1000, true);
+
+        if (!buffer.loaded)
+            dactyl.echoerr(_("macro.loadFailed", maxWaitTime));
+
+        return buffer.loaded;
+    },
+
+    /**
+     * Ensures that the currently focused element is visible and blurs
+     * it if it's not.
+     */
+    checkFocus: function () {
+        if (dactyl.focusedElement) {
+            let rect = dactyl.focusedElement.getBoundingClientRect();
+            if (!rect.width || !rect.height) {
+                services.focus.clearFocus(window);
+                document.commandDispatcher.focusedWindow = content;
+                // onFocusChange needs to die.
+                this.onFocusChange();
+            }
+        }
+    },
+
+    events: {
+        DOMMenuBarActive: function () {
+            this._activeMenubar = true;
+            if (modes.main != modes.MENU)
+                modes.push(modes.MENU);
+        },
+
+        DOMMenuBarInactive: function () {
+            this._activeMenubar = false;
+            modes.remove(modes.MENU, true);
+        },
+
+        blur: function onBlur(event) {
+            let elem = event.originalTarget;
+            if (elem instanceof Window && services.focus.activeWindow == null
+                && document.commandDispatcher.focusedWindow !== window) {
+                // Deals with circumstances where, after the main window
+                // blurs while a collapsed frame has focus, re-activating
+                // the main window does not restore focus and we lose key
+                // input.
+                services.focus.clearFocus(window);
+                document.commandDispatcher.focusedWindow = Editor.getEditor(content) ? window : content;
+            }
+
+            let hold = modes.topOfStack.params.holdFocus;
+            if (elem == hold) {
+                dactyl.focus(hold);
+                this.timeout(function () { dactyl.focus(hold); });
+            }
+        },
+
+        // TODO: Merge with onFocusChange
+        focus: function onFocus(event) {
+            let elem = event.originalTarget;
+
+            if (event.target instanceof Ci.nsIDOMXULTextBoxElement)
+                if (Events.isHidden(elem, true))
+                    elem.blur();
+
+            let win = (elem.ownerDocument || elem).defaultView || elem;
+
+            if (events.isContentNode(elem) && !buffer.focusAllowed(elem)
+                && !(services.focus.getLastFocusMethod(win) & 0x7000)
+                && isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, Window])) {
+                if (elem.frameElement)
+                    dactyl.focusContent(true);
+                else if (!(elem instanceof Window) || Editor.getEditor(elem))
+                    dactyl.focus(window);
+            }
+        },
+
+        /*
+        onFocus: function onFocus(event) {
+            let elem = event.originalTarget;
+            if (!(elem instanceof Element))
+                return;
+            let win = elem.ownerDocument.defaultView;
+
+            try {
+                util.dump(elem, services.focus.getLastFocusMethod(win) & (0x7000));
+                if (buffer.focusAllowed(win))
+                    win.dactylLastFocus = elem;
+                else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement])) {
+                    if (win.dactylLastFocus)
+                        dactyl.focus(win.dactylLastFocus);
+                    else
+                        elem.blur();
+                }
+            }
+            catch (e) {
+                util.dump(win, String(elem.ownerDocument), String(elem.ownerDocument && elem.ownerDocument.defaultView));
+                util.reportError(e);
+            }
+        },
+        */
+
+        input: function onInput(event) {
+            event.originalTarget.dactylKeyPress = undefined;
+        },
+
+        // this keypress handler gets always called first, even if e.g.
+        // the command-line has focus
+        // TODO: ...help me...please...
+        keypress: function onKeyPress(event) {
+            event.dactylDefaultPrevented = event.getPreventDefault();
+
+            let duringFeed = this.duringFeed || [];
+            this.duringFeed = [];
+            try {
+                if (this.feedingEvent)
+                    for (let [k, v] in Iterator(this.feedingEvent))
+                        if (!(k in event))
+                            event[k] = v;
+                this.feedingEvent = null;
+
+                let key = events.toString(event);
+
+                // Hack to deal with <BS> and so forth not dispatching input
+                // events
+                if (key && event.originalTarget instanceof HTMLInputElement && !modes.main.passthrough) {
+                    let elem = event.originalTarget;
+                    elem.dactylKeyPress = elem.value;
+                    util.timeout(function () {
+                        if (elem.dactylKeyPress !== undefined && elem.value !== elem.dactylKeyPress)
+                            events.dispatch(elem, events.create(elem.ownerDocument, "input"));
+                        elem.dactylKeyPress = undefined;
+                    });
+                }
+
+                if (!key)
+                     return null;
+
+                if (modes.recording && !event.isReplay)
+                    events._macroKeys.push(key);
+
+                // feedingKeys needs to be separate from interrupted so
+                // we can differentiate between a recorded <C-c>
+                // interrupting whatever it's started and a real <C-c>
+                // interrupting our playback.
+                if (events.feedingKeys && !event.isMacro) {
+                    if (key == "<C-c>") {
+                        events.feedingKeys = false;
+                        if (modes.replaying) {
+                            modes.replaying = false;
+                            this.timeout(function () { dactyl.echomsg(_("macro.canceled", this._lastMacro)); }, 100);
+                        }
+                    }
+                    else
+                        duringFeed.push(event);
+
+                    return Events.kill(event);
+                }
+
+                if (!this.processor) {
+                    let mode = modes.getStack(0);
+                    if (event.dactylMode)
+                        mode = Modes.StackElement(event.dactylMode);
+
+                    let ignore = false;
+
+                    if (modes.main == modes.PASS_THROUGH)
+                        ignore = !Events.isEscape(key) && key != "<C-v>";
+                    else if (modes.main == modes.QUOTE) {
+                        if (modes.getStack(1).main == modes.PASS_THROUGH) {
+                            mode.params.mainMode = modes.getStack(2).main;
+                            ignore = Events.isEscape(key);
+                        }
+                        else if (events.shouldPass(event))
+                            mode.params.mainMode = modes.getStack(1).main;
+                        else
+                            ignore = true;
+
+                        if (ignore && !Events.isEscape(key))
+                            modes.pop();
+                    }
+                    else if (!event.isMacro && !event.noremap && events.shouldPass(event))
+                        ignore = true;
+
+                    events.dbg("\n\n");
+                    events.dbg("ON KEYPRESS " + key + " ignore: " + ignore,
+                               event.originalTarget instanceof Element ? event.originalTarget : String(event.originalTarget));
+
+                    if (ignore)
+                        return null;
+
+                    // FIXME: Why is this hard coded? --Kris
+                    if (key == "<C-c>")
+                        util.interrupted = true;
+
+                    this.processor = ProcessorStack(mode, mappings.hives.array, event.noremap);
+                    this.processor.keyEvents = this.keyEvents;
+                }
+
+                let { keyEvents, processor } = this;
+                this._processor = processor;
+                this.processor = null;
+                this.keyEvents = [];
+
+                if (!processor.process(event)) {
+                    this.keyEvents = keyEvents;
+                    this.processor = processor;
+                }
+
+            }
+            catch (e) {
+                dactyl.reportError(e);
+            }
+            finally {
+                [duringFeed, this.duringFeed] = [this.duringFeed, duringFeed];
+                if (this.feedingKeys)
+                    this.duringFeed = this.duringFeed.concat(duringFeed);
+                else
+                    for (let event in values(duringFeed))
+                        try {
+                            this.dispatch(event.originalTarget, event, event);
+                        }
+                        catch (e) {
+                            util.reportError(e);
+                        }
+            }
+        },
+
+        keyup: function onKeyUp(event) {
+            this.keyEvents.push(event);
+
+            let pass = this.feedingEvent && this.feedingEvent.isReplay ||
+                    event.isReplay ||
+                    modes.main == modes.PASS_THROUGH ||
+                    modes.main == modes.QUOTE
+                        && modes.getStack(1).main !== modes.PASS_THROUGH
+                        && !this.shouldPass(event) ||
+                    !modes.passThrough && this.shouldPass(event);
+
+            events.dbg("ON " + event.type.toUpperCase() + " " + this.toString(event) + " pass: " + pass);
+
+            // Prevents certain sites from transferring focus to an input box
+            // before we get a chance to process our key bindings on the
+            // "keypress" event.
+            if (!pass && !Events.isInputElement(dactyl.focusedElement))
+                event.stopPropagation();
+        },
+        keydown: function onKeyDown(event) {
+            this.events.keyup.call(this, event);
+        },
+
+        mousedown: function onMouseDown(event) {
+            let elem = event.target;
+            let win = elem.ownerDocument && elem.ownerDocument.defaultView || elem;
+
+            for (; win; win = win != win.parent && win.parent)
+                win.document.dactylFocusAllowed = true;
+        },
+
+        popupshown: function onPopupShown(event) {
+            let elem = event.originalTarget;
+            if (elem instanceof Ci.nsIAutoCompletePopup) {
+                if (modes.main != modes.AUTOCOMPLETE)
+                    modes.push(modes.AUTOCOMPLETE);
+            }
+            else if (elem.localName !== "tooltip")
+                if (Events.isHidden(elem)) {
+                    if (elem.hidePopup && Events.isHidden(elem.parentNode))
+                        elem.hidePopup();
+                }
+                else if (modes.main != modes.MENU)
+                    modes.push(modes.MENU);
+        },
+
+        popuphidden: function onPopupHidden() {
+            // gContextMenu is set to NULL, when a context menu is closed
+            if (window.gContextMenu == null && !this._activeMenubar)
+                modes.remove(modes.MENU, true);
+            modes.remove(modes.AUTOCOMPLETE);
+        },
+
+        resize: function onResize(event) {
+            if (window.fullScreen != this._fullscreen) {
+                statusline.statusBar.removeAttribute("moz-collapsed");
+                this._fullscreen = window.fullScreen;
+                dactyl.triggerObserver("fullscreen", this._fullscreen);
+                autocommands.trigger("Fullscreen", { url: this._fullscreen ? "on" : "off", state: this._fullscreen });
+            }
+        }
+    },
+
+    // argument "event" is deliberately not used, as i don't seem to have
+    // access to the real focus target
+    // Huh? --djk
+    onFocusChange: function onFocusChange(event) {
+        function hasHTMLDocument(win) win && win.document && win.document instanceof HTMLDocument
+        if (dactyl.ignoreFocus)
+            return;
+
+        let win  = window.document.commandDispatcher.focusedWindow;
+        let elem = window.document.commandDispatcher.focusedElement;
+
+        if (elem == null && Editor.getEditor(win))
+            elem = win;
+
+        if (win && win.top == content && dactyl.has("tabs"))
+            buffer.focusedFrame = win;
+
+        try {
+            if (elem && elem.readOnly)
+                return;
+
+            if (isinstance(elem, [HTMLEmbedElement, HTMLEmbedElement])) {
+                modes.push(modes.EMBED);
+                return;
+            }
+
+            let haveInput = modes.stack.some(function (m) m.main.input);
+
+            if (elem instanceof HTMLTextAreaElement
+               || elem instanceof Element && util.computedStyle(elem).MozUserModify === "read-write"
+               || elem == null && win && Editor.getEditor(win)) {
+
+                if (modes.main == modes.VISUAL && elem.selectionEnd == elem.selectionStart)
+                    modes.pop();
+
+                if (!haveInput)
+                    if (options["insertmode"])
+                        modes.push(modes.INSERT);
+                    else {
+                        modes.push(modes.TEXT_EDIT);
+                        if (elem.selectionEnd - elem.selectionStart > 0)
+                            modes.push(modes.VISUAL);
+                    }
+
+                if (hasHTMLDocument(win))
+                    buffer.lastInputField = elem;
+                return;
+            }
+
+            if (Events.isInputElement(elem)) {
+                if (!haveInput)
+                    modes.push(modes.INSERT);
+
+                if (hasHTMLDocument(win))
+                    buffer.lastInputField = elem;
+                return;
+            }
+
+            if (config.focusChange) {
+                config.focusChange(win);
+                return;
+            }
+
+            let urlbar = document.getElementById("urlbar");
+            if (elem == null && urlbar && urlbar.inputField == this._lastFocus)
+                util.threadYield(true); // Why? --Kris
+
+            while (modes.main.ownsFocus && !modes.topOfStack.params.holdFocus)
+                 modes.pop(null, { fromFocus: true });
+        }
+        finally {
+            this._lastFocus = elem;
+        }
+    },
+
+    onSelectionChange: function onSelectionChange(event) {
+        let controller = document.commandDispatcher.getControllerForCommand("cmd_copy");
+        let couldCopy = controller && controller.isCommandEnabled("cmd_copy");
+
+        if (modes.main == modes.VISUAL) {
+            if (!couldCopy)
+                modes.pop(); // Really not ideal.
+        }
+        else if (couldCopy) {
+            if (modes.main == modes.TEXT_EDIT && !options["insertmode"])
+                modes.push(modes.VISUAL);
+            else if (modes.main == modes.CARET)
+                modes.push(modes.VISUAL);
+        }
+    },
+
+    shouldPass: function shouldPass(event)
+        !event.noremap && (!dactyl.focusedElement || events.isContentNode(dactyl.focusedElement)) &&
+        options.get("passkeys").has(events.toString(event))
+}, {
+    ABORT: {},
+    KILL: true,
+    PASS: false,
+    PASS_THROUGH: {},
+    WAIT: null,
+
+    isEscape: function isEscape(event)
+        let (key = isString(event) ? event : events.toString(event))
+            key === "<Esc>" || key === "<C-[>",
+
+    isHidden: function isHidden(elem, aggressive) {
+        for (let e = elem; e instanceof Element; e = e.parentNode) {
+            if (util.computedStyle(e).visibility !== "visible" ||
+                    aggressive && e.boxObject && e.boxObject.height === 0)
+                return true;
+        }
+        return false;
+    },
+
+    isInputElement: function isInputElement(elem) {
+        return elem instanceof HTMLInputElement && set.has(util.editableInputs, elem.type) ||
+               isinstance(elem, [HTMLIsIndexElement, HTMLEmbedElement,
+                                 HTMLObjectElement, HTMLSelectElement,
+                                 HTMLTextAreaElement,
+                                 Ci.nsIDOMXULTreeElement, Ci.nsIDOMXULTextBoxElement]) ||
+               elem instanceof Window && Editor.getEditor(elem);
+    },
+
+    kill: function kill(event) {
+        event.stopPropagation();
+        event.preventDefault();
+    }
+}, {
+    commands: function () {
+        commands.add(["delmac[ros]"],
+            "Delete macros",
+            function (args) {
+                dactyl.assert(!args.bang || !args[0], _("error.invalidArgument"));
+
+                if (args.bang)
+                    events.deleteMacros();
+                else if (args[0])
+                    events.deleteMacros(args[0]);
+                else
+                    dactyl.echoerr(_("error.argumentRequired"));
+            }, {
+                bang: true,
+                completer: function (context) completion.macro(context),
+                literal: 0
+            });
+
+        commands.add(["macros"],
+            "List all macros",
+            function (args) { completion.listCompleter("macro", args[0]); }, {
+                argCount: "?",
+                completer: function (context) completion.macro(context)
+            });
+    },
+    completion: function () {
+        completion.macro = function macro(context) {
+            context.title = ["Macro", "Keys"];
+            context.completions = [item for (item in events.getMacros())];
+        };
+    },
+    mappings: function () {
+
+        mappings.add([modes.MAIN],
+            ["<A-b>"], "Process the next key as a builtin mapping",
+            function () {
+                events.processor = ProcessorStack(modes.getStack(0), mappings.hives.array, true);
+                events.processor.keyEvents = events.keyEvents;
+            });
+
+        mappings.add([modes.MAIN],
+            ["<C-z>", "<pass-all-keys>"], "Temporarily ignore all " + config.appName + " key bindings",
+            function () { modes.push(modes.PASS_THROUGH); });
+
+        mappings.add([modes.MAIN],
+            ["<C-v>", "<pass-next-key>"], "Pass through next key",
+            function () {
+                if (modes.main == modes.QUOTE)
+                    return Events.PASS;
+                modes.push(modes.QUOTE);
+            });
+
+        mappings.add([modes.BASE],
+            ["<Nop>"], "Do nothing",
+            function () {});
+
+        mappings.add([modes.BASE],
+            ["<Pass>"], "Pass the events consumed by the last executed mapping",
+            function ({ keypressEvents: [event] }) {
+                dactyl.assert(event.dactylSavedEvents,
+                              _("event.nothingToPass"));
+                return function () {
+                    events.feedevents(null, event.dactylSavedEvents,
+                                      { skipmap: true, isMacro: true, isReplay: true });
+                };
+            });
+
+        // macros
+        mappings.add([modes.COMMAND],
+            ["q", "<record-macro>"], "Record a key sequence into a macro",
+            function ({ arg }) {
+                events._macroKeys.pop();
+                events.recording = arg;
+            },
+            { get arg() !modes.recording });
+
+        mappings.add([modes.COMMAND],
+            ["@", "<play-macro>"], "Play a macro",
+            function ({ arg, count }) {
+                count = Math.max(count, 1);
+                while (count--)
+                    events.playMacro(arg);
+            },
+            { arg: true, count: true });
+
+        mappings.add([modes.COMMAND],
+            ["<A-m>s", "<sleep>"], "Sleep for {count} milliseconds before continuing macro playback",
+            function ({ command, count }) {
+                let now = Date.now();
+                dactyl.assert(count, _("error.countRequired", command));
+                if (events.feedingKeys)
+                    util.sleep(count);
+            },
+            { count: true });
+
+        mappings.add([modes.COMMAND],
+            ["<A-m>l", "<wait-for-page-load>"], "Wait for the current page to finish loading before continuing macro playback",
+            function ({ count }) {
+                if (events.feedingKeys && !events.waitForPageLoad(count)) {
+                    util.interrupted = true;
+                    throw Error("Interrupted");
+                }
+            },
+            { count: true });
+    },
+    options: function () {
+        const Hive = Class("Hive", {
+            init: function init(values, map) {
+                this.name = "passkeys:" + map;
+                this.stack = MapHive.Stack(values.map(function (v) Map(v[map + "Keys"])));
+                function Map(keys) ({
+                    execute: function () Events.PASS_THROUGH,
+                    keys: keys
+                });
+            },
+
+            get active() this.stack.length,
+
+            get: function get(mode, key) this.stack.mappings[key],
+
+            getCandidates: function getCandidates(mode, key) this.stack.candidates[key]
+        });
+        options.add(["passkeys", "pk"],
+            "Pass certain keys through directly for the given URLs",
+            "sitemap", "", {
+                flush: function flush() {
+                    memoize(this, "filters", function () this.value.filter(function (f) f(buffer.documentURI)));
+                    memoize(this, "pass", function () set(array.flatten(this.filters.map(function (f) f.keys))));
+                    memoize(this, "commandHive", function hive() Hive(this.filters, "command"));
+                    memoize(this, "inputHive", function hive() Hive(this.filters, "input"));
+                },
+
+                has: function (key) set.has(this.pass, key) || set.has(this.commandHive.stack.mappings, key),
+
+                get pass() (this.flush(), this.pass),
+
+                keepQuotes: true,
+
+                setter: function (values) {
+                    values.forEach(function (filter) {
+                        let vals = Option.splitList(filter.result);
+                        filter.keys = events.fromString(vals[0]).map(events.closure.toString);
+
+                        filter.commandKeys = vals.slice(1).map(events.closure.canonicalKeys);
+                        filter.inputKeys = filter.commandKeys.filter(/^<[ACM]-/);
+                    });
+                    this.flush();
+                    return values;
+                }
+            });
+
+        options.add(["strictfocus", "sf"],
+            "Prevent scripts from focusing input elements without user intervention",
+            "boolean", true);
+
+        options.add(["timeout", "tmo"],
+            "Whether to execute a shorter key command after a timeout when a longer command exists",
+            "boolean", true);
+
+        options.add(["timeoutlen", "tmol"],
+            "Maximum time (milliseconds) to wait for a longer key command when a shorter one exists",
+            "number", 1000);
+    },
+    sanitizer: function () {
+        sanitizer.addItem("macros", {
+            description: "Saved macros",
+            persistent: true,
+            action: function (timespan, host) {
+                if (!host)
+                    for (let [k, m] in events._macros)
+                        if (timespan.contains(m.timeRecorded * 1000))
+                            events._macros.remove(k);
+            }
+        });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/help.css b/common/content/help.css
new file mode 100644 (file)
index 0000000..cdcabaa
--- /dev/null
@@ -0,0 +1,120 @@
+div.main {
+    font-family: -moz-fixed;
+    white-space: -moz-pre-wrap;
+    width: 800px;
+    margin-left: auto;
+    margin-right: auto;
+}
+
+h1 {
+    text-align: center;
+}
+
+p.tagline {
+    text-align: center;
+    font-weight: bold;
+}
+
+table.pentadactyl {
+    border-width: 1px;
+    border-style: dotted;
+    border-color: gray;
+}
+table.pentadactyl td {
+    border: none;
+    padding: 3px;
+}
+tr.separator {
+    height: 10px;
+}
+hr {
+    height: 1px;
+    background-color: white;
+    border-style: none;
+    margin-top: 0;
+    margin-bottom: 0;
+}
+td.taglist {
+    text-align: right;
+    vertical-align: top;
+    border-spacing: 13px 10px;
+}
+td.taglist td {
+    width: 100px;
+    padding: 3px 0px;
+}
+tr.taglist code, td.usage code {
+    margin: 0px 2px;
+}
+td.usage code {
+    white-space: nowrap;
+}
+td.taglist code {
+    margin-left: 2em;
+}
+code.tag {
+    font-weight: bold;
+    color: rgb(255, 0, 255); /* magenta */
+    padding-left: 5px;
+}
+tr.description {
+    margin-bottom: 4px;
+}
+table.commands {
+    background-color: rgb(250, 240, 230);
+    color: black;
+}
+table.mappings {
+    background-color: rgb(230, 240, 250);
+    color: black;
+}
+table.options {
+    background-color: rgb(240, 250, 230);
+    color: black;
+}
+
+fieldset.paypal {
+    border: none;
+}
+
+.argument {
+    color: #6A97D4;
+}
+
+.command {
+    font-weight: bold;
+    color: #632610;
+}
+
+.mapping {
+    font-weight: bold;
+    color: #102663;
+}
+
+.option {
+    font-weight: bold;
+    color: #106326;
+}
+
+.code {
+    color: #108826;
+}
+
+.shorthelp {
+    font-weight: bold;
+}
+
+.version {
+    position: absolute;
+    top: 10px;
+    right: 2%;
+    color: #C0C0C0;
+    text-align: right;
+}
+
+.warning {
+    font-weight: bold;
+    color: red;
+}
+
+/* vim: set fdm=marker sw=4 ts=4 et: */
diff --git a/common/content/help.js b/common/content/help.js
new file mode 100644 (file)
index 0000000..9faf7a2
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (c) 2009-2011 by Kris Maglione <kris@vimperator.org>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+function checkFragment() {
+    document.title = document.getElementsByTagNameNS("http://www.w3.org/1999/xhtml", "title")[0].textContent;
+
+    function action() {
+        content.scrollTo(0, content.scrollY + elem.getBoundingClientRect().top - 10); // 10px context
+    }
+
+    var frag = document.location.hash.substr(1);
+    if (frag) {
+        var elem = document.getElementById(frag);
+        if (elem) {
+            action();
+            setTimeout(action, 10);
+        }
+    }
+}
+
+document.addEventListener("load", checkFragment, true);
+document.addEventListener("hashChange", checkFragment, true);
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/help.xsl b/common/content/help.xsl
new file mode 100644 (file)
index 0000000..e70875b
--- /dev/null
@@ -0,0 +1,682 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE stylesheet SYSTEM "dactyl://content/dtd">
+
+<!-- Header {{{1 -->
+<xsl:stylesheet version="1.0"
+    xmlns="http://www.w3.org/1999/xhtml"
+    xmlns:html="http://www.w3.org/1999/xhtml"
+    xmlns:dactyl="http://vimperator.org/namespaces/liberator"
+    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+    xmlns:exsl="http://exslt.org/common"
+    xmlns:regexp="http://exslt.org/regular-expressions"
+    xmlns:str="http://exslt.org/strings"
+    extension-element-prefixes="exsl regexp str">
+
+    <xsl:output method="xml" indent="no"/>
+
+    <!-- Variable Definitions {{{1 -->
+
+
+    <!-- Process Overlays {{{1 -->
+
+    <xsl:template name="splice-overlays">
+        <xsl:param name="elem"/>
+        <xsl:param name="tag"/>
+        <xsl:for-each select="ancestor::*/dactyl:overlay/*[@insertbefore=$tag]">
+            <xsl:apply-templates select="." mode="overlay"/>
+        </xsl:for-each>
+        <xsl:choose>
+            <xsl:when test="ancestor::*/dactyl:overlay/*[@replace=$tag] and not($elem[@replace])">
+                <xsl:for-each select="ancestor::*/dactyl:overlay/*[@replace=$tag]">
+                    <xsl:apply-templates select="." mode="overlay-2"/>
+                </xsl:for-each>
+            </xsl:when>
+            <xsl:otherwise>
+                <xsl:for-each select="$elem">
+                    <xsl:apply-templates select="." mode="overlay-2"/>
+                </xsl:for-each>
+            </xsl:otherwise>
+        </xsl:choose>
+        <xsl:for-each select="ancestor::*/dactyl:overlay/*[@insertafter=$tag]">
+            <xsl:apply-templates select="." mode="overlay"/>
+        </xsl:for-each>
+    </xsl:template>
+
+    <xsl:template match="dactyl:tags[parent::dactyl:document]|dactyl:tag" mode="overlay">
+        <xsl:call-template name="splice-overlays">
+            <xsl:with-param name="tag" select="substring-before(concat(., ' '), ' ')"/>
+            <xsl:with-param name="elem" select="self::node()"/>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template match="*[dactyl:tags]" mode="overlay">
+        <xsl:call-template name="splice-overlays">
+            <xsl:with-param name="tag" select="substring-before(concat(dactyl:tags, ' '), ' ')"/>
+            <xsl:with-param name="elem" select="self::node()"/>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template match="dactyl:*[@tag and not(@replace)]" mode="overlay">
+        <xsl:call-template name="splice-overlays">
+            <xsl:with-param name="tag" select="substring-before(concat(@tag, ' '), ' ')"/>
+            <xsl:with-param name="elem" select="self::node()"/>
+        </xsl:call-template>
+    </xsl:template>
+
+    <!-- Process Inclusions {{{1 -->
+
+    <xsl:template name="include">
+        <xsl:param name="root-node" select="."/>
+        <xsl:param name="overlay" select="concat('dactyl://help-overlay/', $root-node/@name)"/>
+
+        <!-- Ridiculous three-pass processing is needed to deal with
+           - lack of dynamic variable scope in XSL 1.0.  -->
+
+        <!-- Store a copy of the overlay for the current document. -->
+        <xsl:variable name="doc">
+            <dactyl:document>
+                <xsl:copy-of select="document($overlay)/dactyl:overlay"/>
+                <xsl:copy-of select="$root-node/node()"/>
+            </dactyl:document>
+        </xsl:variable>
+
+        <xsl:call-template name="parse-tags">
+            <xsl:with-param name="text" select="concat($root-node/@name, '.xml')"/>
+        </xsl:call-template>
+        <xsl:apply-templates select="exsl:node-set($doc)/dactyl:document/node()[position() != 1]" mode="overlay"/>
+    </xsl:template>
+
+    <xsl:template match="dactyl:include" mode="overlay-2">
+        <div dactyl:highlight="HelpInclude">
+            <xsl:call-template name="include">
+                <xsl:with-param name="root-node" select="document(@href)/dactyl:document"/>
+            </xsl:call-template>
+        </div>
+    </xsl:template>
+
+    <xsl:template match="@*|node()" mode="overlay">
+        <xsl:apply-templates select="." mode="overlay-2"/>
+    </xsl:template>
+    <xsl:template match="@*|node()" mode="overlay-2">
+        <xsl:copy>
+            <xsl:apply-templates select="@*|node()" mode="overlay"/>
+        </xsl:copy>
+    </xsl:template>
+
+    <!-- Root {{{1 -->
+
+    <xsl:template match="/">
+
+        <!-- Ridiculous three-pass processing is needed to deal with
+           - lack of dynamic variable scope in XSL 1.0.  -->
+
+        <xsl:variable name="doc1">
+            <xsl:call-template name="include">
+                <xsl:with-param name="root-node" select="dactyl:document"/>
+            </xsl:call-template>
+        </xsl:variable>
+        <xsl:variable name="root" select="exsl:node-set($doc1)"/>
+
+        <!-- Store a cache of all tags defined -->
+        <xsl:variable name="doc2">
+            <dactyl:document>
+                <xsl:attribute name="document-tags">
+                    <xsl:text> </xsl:text>
+                    <xsl:for-each select="$root//@tag|$root//dactyl:tags/text()|$root//dactyl:tag/text()">
+                        <xsl:value-of select="concat(., ' ')"/>
+                    </xsl:for-each>
+                </xsl:attribute>
+                <xsl:copy-of select="$root/node()"/>
+            </dactyl:document>
+        </xsl:variable>
+        <xsl:variable name="root2" select="exsl:node-set($doc2)/dactyl:document"/>
+
+        <html dactyl:highlight="Help">
+            <head>
+                <title><xsl:value-of select="/dactyl:document/@title"/></title>
+                <script type="text/javascript" src="resource://dactyl-content/help.js"/>
+            </head>
+            <body dactyl:highlight="HelpBody">
+                <xsl:apply-templates select="$root2/node()|$root2/@*" mode="help-1"/>
+            </body>
+        </html>
+    </xsl:template>
+
+    <!-- Table of Contents {{{1 -->
+
+    <xsl:template name="toc">
+        <xsl:param name="level" select="1"/>
+        <xsl:param name="context"/>
+        <xsl:param name="toc"/>
+
+        <xsl:variable name="tag" select="concat('h', $level)"/>
+        <xsl:variable name="lasttag" select="concat('h', $level - 1)"/>
+
+        <xsl:variable name="nodes" select="$toc/*[
+            local-name() = $tag and not(preceding::*[local-name() = $lasttag][position() = 1 and not(.=$context)])]"/>
+
+        <xsl:if test="$nodes">
+            <ol level="{$level}" dactyl:highlight="HelpOrderedList">
+                <xsl:for-each select="$nodes">
+                    <li>
+                        <a>
+                            <xsl:if test="@tag">
+                                <xsl:attribute name="href"><xsl:value-of select="concat('#', substring-before(concat(@tag, ' '), ' '))"/></xsl:attribute>
+                            </xsl:if>
+                            <xsl:apply-templates select="node()[not(self::dactyl:strut)]" mode="help-1"/>
+                        </a>
+                        <xsl:call-template name="toc">
+                            <xsl:with-param name="level" select="$level + 1"/>
+                            <xsl:with-param name="context" select="."/>
+                            <xsl:with-param name="toc" select="$toc"/>
+                        </xsl:call-template>
+                    </li>
+                </xsl:for-each>
+            </ol>
+        </xsl:if>
+    </xsl:template>
+    <xsl:template match="dactyl:toc" mode="help-2">
+        <xsl:variable name="TOC">
+            <context/>
+            <xsl:for-each
+                select="following::dactyl:h1|following::dactyl:h2|following::dactyl:h3|following::dactyl:h4|following::dactyl:h5">
+                <xsl:copy-of select="."/>
+            </xsl:for-each>
+        </xsl:variable>
+        <xsl:variable name="toc" select="exsl:node-set($TOC)"/>
+
+        <xsl:if test="//dactyl:toc[1 and self::*]">
+            <div dactyl:highlight="HelpTOC">
+                <h2>Contents</h2>
+                <xsl:if test="@start">
+                    <xsl:call-template name="toc">
+                        <xsl:with-param name="level" select="number(@start)"/>
+                        <xsl:with-param name="toc" select="$toc"/>
+                    </xsl:call-template>
+                </xsl:if>
+                <xsl:if test="not(@start)">
+                    <xsl:call-template name="toc">
+                        <xsl:with-param name="toc" select="$toc"/>
+                    </xsl:call-template>
+                </xsl:if>
+            </div>
+        </xsl:if>
+    </xsl:template>
+
+    <!-- Items {{{1 -->
+
+    <xsl:template match="dactyl:strut" mode="help-2">
+        <div style="clear: both"/>
+    </xsl:template>
+    <xsl:template match="dactyl:item" mode="help-2">
+        <div dactyl:highlight="HelpItem">
+            <xsl:apply-templates select="dactyl:tags|dactyl:spec|dactyl:strut" mode="help-1"/>
+            <xsl:if test="not(dactyl:description/@short)">
+                <hr style="border: 0; height: 0; margin: 0; width: 100%; float: right;"/>
+                <xsl:if test="dactyl:type|dactyl:default">
+                    <div dactyl:highlight="HelpOptInfo">
+                        <xsl:apply-templates select="dactyl:type|dactyl:default" mode="help-1"/>
+                        <div style="clear: both;"/>
+                    </div>
+                </xsl:if>
+            </xsl:if>
+            <xsl:apply-templates select="dactyl:description" mode="help-1"/>
+            <div style="clear: both;"/>
+        </div>
+    </xsl:template>
+    <!--
+    <xsl:template match="dactyl:item/dactyl:spec[position() = last()]" mode="help-2">
+        <div style="clear: both;"/>
+        <div dactyl:highlight="HelpSpec"><xsl:apply-templates mode="help-1"/></div>
+    </xsl:template>
+    -->
+
+    <xsl:template match="dactyl:default[not(@type='plain')]" mode="help-2">
+        <xsl:variable name="type" select="preceding-sibling::dactyl:type[1] | following-sibling::dactyl:type[1]"/>
+        <span dactyl:highlight="HelpDefault">
+            <xsl:copy-of select="@*"/>
+            <xsl:text>(default: </xsl:text>
+            <xsl:choose>
+                <xsl:when test="$type = 'string'">
+                    <span dactyl:highlight="HelpString" delim="'"><xsl:apply-templates mode="help-1"/></span>
+                </xsl:when>
+                <xsl:when test="contains($type, 'list') or contains($type, 'map')">
+                    <span dactyl:highlight="HelpString" delim=""><xsl:apply-templates mode="help-1"/></span>
+                    <xsl:if test=". = ''">(empty)</xsl:if>
+                </xsl:when>
+                <xsl:otherwise>
+                    <span>
+                        <xsl:attribute name="dactyl:highlight">
+                            <xsl:choose>
+                                <xsl:when test="$type = 'boolean'">Boolean</xsl:when>
+                                <xsl:when test="$type = 'number'">Number</xsl:when>
+                                <xsl:when test="$type = 'charlist'">String</xsl:when>
+                            </xsl:choose>
+                        </xsl:attribute>
+                        <xsl:apply-templates select="node()" mode="help-1"/>
+                    </span>
+                </xsl:otherwise>
+            </xsl:choose>)</span>
+    </xsl:template>
+
+    <!-- Tag Definitions {{{1 -->
+
+    <xsl:template match="dactyl:item/dactyl:tags[position() = last()]" mode="help-2">
+        <div style="clear: right"/>
+        <xsl:call-template name="parse-tags">
+            <xsl:with-param name="text" select="."/>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template match="dactyl:tags" mode="help-2">
+        <xsl:call-template name="parse-tags">
+            <xsl:with-param name="text" select="."/>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template match="@tag[parent::dactyl:p]" mode="help-2">
+        <xsl:call-template name="parse-tags">
+            <xsl:with-param name="text" select="."/>
+        </xsl:call-template>
+        <div style="clear: right"/>
+    </xsl:template>
+    <xsl:template match="dactyl:tag|@tag" mode="help-2">
+        <xsl:call-template name="parse-tags">
+            <xsl:with-param name="text" select="."/>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template name="parse-tags">
+        <xsl:param name="text"/>
+        <div dactyl:highlight="HelpTags">
+        <xsl:for-each select="str:tokenize($text)">
+            <a id="{.}" dactyl:highlight="HelpTag"><xsl:value-of select="."/></a>
+        </xsl:for-each>
+        </div>
+    </xsl:template>
+
+    <!-- Tag Links {{{1 -->
+
+    <xsl:template name="linkify-tag">
+        <xsl:param name="contents" select="text()"/>
+        <xsl:variable name="tag" select="$contents"/>
+        <a style="color: inherit;">
+            <xsl:if test="not(@link) or @link != 'false'">
+                <xsl:choose>
+                    <xsl:when test="contains(ancestor::*/@document-tags, concat(' ', $tag, ' '))">
+                        <xsl:attribute name="href">#<xsl:value-of select="$tag"/></xsl:attribute>
+                    </xsl:when>
+                    <xsl:otherwise>
+                        <xsl:attribute name="href">dactyl://help-tag/<xsl:value-of select="$tag"/></xsl:attribute>
+                    </xsl:otherwise>
+                </xsl:choose>
+            </xsl:if>
+            <xsl:value-of select="$contents"/>
+        </a>
+    </xsl:template>
+
+    <xsl:template match="dactyl:o" mode="help-2">
+        <span dactyl:highlight="HelpOpt">
+            <xsl:call-template name="linkify-tag">
+                <xsl:with-param name="contents" select='concat("&#39;", text(), "&#39;")'/>
+            </xsl:call-template>
+        </span>
+    </xsl:template>
+    <xsl:template match="dactyl:pref" mode="help-2">
+        <a href="http://kb.mozillazine.org/{text()}" dactyl:highlight="HelpOpt"
+            >'<xsl:apply-templates select="@*|node()" mode="help-1"/>'</a>
+    </xsl:template>
+    <xsl:template match="dactyl:t" mode="help-2">
+        <span dactyl:highlight="HelpTopic">
+            <xsl:call-template name="linkify-tag"/>
+        </span>
+    </xsl:template>
+    <xsl:template match="dactyl:k" mode="help-2">
+        <span dactyl:highlight="HelpKey">
+            <xsl:call-template name="linkify-tag"/>
+        </span>
+    </xsl:template>
+    <xsl:template match="dactyl:kwd" mode="help-2">
+        <span dactyl:highlight="HelpKeyword">
+            <xsl:apply-templates select="@*|node()" mode="help-1"/>
+        </span>
+    </xsl:template>
+    <xsl:template match="dactyl:k[@name]" mode="help-2">
+        <span dactyl:highlight="HelpKey">
+            <xsl:call-template name="linkify-tag">
+                <xsl:with-param name="contents" select="concat('&lt;', @name, '>', .)"/>
+            </xsl:call-template>
+        </span>
+    </xsl:template>
+    <xsl:template match="dactyl:k[@mode]" mode="help-2">
+        <span dactyl:highlight="HelpKey">
+            <xsl:call-template name="linkify-tag">
+                <xsl:with-param name="contents" select="concat(@mode, '_', text())"/>
+            </xsl:call-template>
+        </span>
+    </xsl:template>
+    <xsl:template match="dactyl:k[@mode and @name]" mode="help-2">
+        <span dactyl:highlight="HelpKey">
+            <xsl:call-template name="linkify-tag">
+                <xsl:with-param name="contents" select="concat(@mode, '_', '&lt;', @name, '>', .)"/>
+            </xsl:call-template>
+        </span>
+    </xsl:template>
+
+    <!-- HTML-ish elements {{{1 -->
+
+    <xsl:template match="dactyl:dl" mode="help-2">
+        <xsl:apply-templates select="@tag" mode="help-1"/>
+        <dl>
+            <column style="{@dt}"/>
+            <column style="{@dd}"/>
+            <xsl:for-each select="dactyl:dt">
+                <tr>
+                    <xsl:apply-templates select="." mode="help-1"/>
+                    <xsl:apply-templates select="following-sibling::dactyl:dd[1]" mode="help-1"/>
+                </tr>
+            </xsl:for-each>
+        </dl>
+    </xsl:template>
+
+    <xsl:template match="dactyl:link" mode="help-2">
+        <a>
+            <xsl:choose>
+                <xsl:when test="not(@topic)"/>
+                <xsl:when test="regexp:match(@topic, '^([a-zA-Z]*:|[^/]*#|/)', '')">
+                    <xsl:attribute name="href"><xsl:value-of select="@topic"/></xsl:attribute>
+                </xsl:when>
+                <xsl:otherwise>
+                    <xsl:attribute name="href"><xsl:value-of select="concat('dactyl://help-tag/', @topic)"/></xsl:attribute>
+                </xsl:otherwise>
+            </xsl:choose>
+            <xsl:if test="regexp:match(@topic, '^[a-zA-Z]*:', '')
+                    and not(starts-with(@topic, 'mailto:') or
+                            starts-with(@topic, 'chrome:') or
+                            starts-with(@topic, 'resource:') or
+                            starts-with(@topic, 'dactyl:'))">
+                    <!-- A regexp does not work for the second test here ↑. :( -->
+                <xsl:attribute name="rel">external</xsl:attribute>
+            </xsl:if>
+            <xsl:apply-templates select="@*|node()" mode="help-1"/>
+        </a>
+    </xsl:template>
+
+    <xsl:template match="dactyl:hl" mode="help-2">
+        <span dactyl:highlight="{@key}"><xsl:apply-templates select="@*|node()" mode="help-1"/></span>
+    </xsl:template>
+    <xsl:template match="dactyl:h" mode="help-2">
+        <em><xsl:apply-templates select="@*|node()" mode="help-1"/></em>
+    </xsl:template>
+    <xsl:template match="dactyl:em | dactyl:tt | dactyl:p  |
+                         dactyl:dt | dactyl:dd |
+                         dactyl:ol | dactyl:ul | dactyl:li |
+                         dactyl:h1 | dactyl:h2 | dactyl:h3 |
+                         dactyl:h4"
+                  mode="help-2">
+        <xsl:element name="{local-name()}">
+            <xsl:apply-templates select="@*|node()" mode="help-1"/>
+        </xsl:element>
+    </xsl:template>
+
+    <xsl:template match="dactyl:code" mode="help-2">
+        <pre dactyl:highlight="HelpCode"><xsl:apply-templates select="@*|node()" mode="help-1"/></pre>
+    </xsl:template>
+
+    <!-- Help elements {{{1 -->
+
+    <xsl:template match="dactyl:a" mode="help-2">
+        <span dactyl:highlight="HelpArg">{<xsl:apply-templates select="@*|node()" mode="help-1"/>}</span>
+    </xsl:template>
+    <xsl:template match="dactyl:oa" mode="help-2">
+        <span dactyl:highlight="HelpOptionalArg">[<xsl:apply-templates select="@*|node()" mode="help-1"/>]</span>
+    </xsl:template>
+
+    <xsl:template match="dactyl:deprecated" mode="help-2">
+        <p style="clear: both;">
+            <xsl:apply-templates select="@*" mode="help-1"/>
+            <span dactyl:highlight="HelpWarning">Deprecated:</span>
+            <xsl:text> </xsl:text>
+            <xsl:apply-templates select="node()" mode="help-1"/>
+        </p>
+    </xsl:template>
+    <xsl:template match="dactyl:note" mode="help-2">
+        <p style="clear: both;">
+            <xsl:apply-templates select="@*" mode="help-1"/>
+            <div style="clear: both;"/>
+            <span dactyl:highlight="HelpNote">Note:</span>
+            <xsl:text> </xsl:text>
+            <xsl:apply-templates select="node()" mode="help-1"/>
+        </p>
+    </xsl:template>
+    <xsl:template match="dactyl:warning" mode="help-2">
+        <p style="clear: both;">
+            <xsl:apply-templates select="@*" mode="help-1"/>
+            <div style="clear: both;"/>
+            <span dactyl:highlight="HelpWarning">Warning:</span>
+            <xsl:text> </xsl:text>
+            <xsl:apply-templates select="node()" mode="help-1"/>
+        </p>
+    </xsl:template>
+    <xsl:template match="dactyl:default" mode="help-2">
+        <span dactyl:highlight="HelpDefault">(default:<xsl:text> </xsl:text><xsl:apply-templates select="@*|node()" mode="help-1"/>)</span>
+    </xsl:template>
+
+    <!-- HTML-ify other elements {{{1 -->
+
+    <xsl:template match="dactyl:ex" mode="help-2">
+        <span dactyl:highlight="HelpEx">
+            <xsl:variable name="tag" select="str:tokenize(text(), ' [!')[1]"/>
+            <a href="dactyl://help-tag/{$tag}" style="color: inherit;">
+                <xsl:if test="contains(ancestor::*/@document-tags, concat(' ', $tag, ' '))">
+                    <xsl:attribute name="href">#<xsl:value-of select="$tag"/></xsl:attribute>
+                </xsl:if>
+                <xsl:apply-templates mode="help-1"/>
+            </a>
+        </span>
+    </xsl:template>
+
+    <xsl:template match="dactyl:se" mode="help-2">
+        <xsl:variable name="nodes" xmlns="&xmlns.dactyl;">
+            <html:span style="display: inline-block;">
+                <ex>:set</ex>
+                <xsl:text> </xsl:text>
+                <link>
+                    <xsl:if test="@link != 'false'">
+                        <xsl:attribute name="topic">'<xsl:value-of select="@opt"/>'</xsl:attribute>
+                    </xsl:if>
+                    <hl key="HelpOpt"><xsl:value-of select="@opt"/></hl>
+                </link>
+                <xsl:choose>
+                    <xsl:when test="@op and @op != ''"><xsl:value-of select="@op"/></xsl:when>
+                    <xsl:otherwise>=</xsl:otherwise>
+                </xsl:choose>
+                <xsl:copy-of select="@*|node()"/>
+            </html:span>
+        </xsl:variable>
+        <xsl:apply-templates select="exsl:node-set($nodes)" mode="help-1"/>
+    </xsl:template>
+
+    <xsl:template match="dactyl:set" mode="help-2">
+        <xsl:variable name="nodes">
+            <code xmlns="&xmlns.dactyl;">
+                <se opt="{@opt}" op="{@op}" link="{@link}">
+                    <xsl:copy-of select="@*|node()"/>
+                </se>
+            </code>
+        </xsl:variable>
+        <xsl:apply-templates select="exsl:node-set($nodes)" mode="help-1"/>
+    </xsl:template>
+
+    <xsl:template match="dactyl:description | dactyl:example | dactyl:spec" mode="help-2">
+        <div>
+            <xsl:if test="self::dactyl:description"><xsl:attribute name="dactyl:highlight">HelpDescription</xsl:attribute></xsl:if>
+            <xsl:if test="self::dactyl:example"><xsl:attribute name="dactyl:highlight">HelpExample</xsl:attribute></xsl:if>
+            <xsl:if test="self::dactyl:spec"><xsl:attribute name="dactyl:highlight">HelpSpec</xsl:attribute></xsl:if>
+            <xsl:apply-templates select="@*|node()" mode="help-1"/>
+        </div>
+    </xsl:template>
+    <xsl:template match="dactyl:str | dactyl:type" mode="help-2">
+        <span>
+            <xsl:if test="self::dactyl:str"><xsl:attribute name="dactyl:highlight">HelpString</xsl:attribute></xsl:if>
+            <xsl:if test="self::dactyl:type"><xsl:attribute name="dactyl:highlight">HelpType</xsl:attribute></xsl:if>
+            <xsl:apply-templates select="@*|node()" mode="help-1"/>
+        </span>
+    </xsl:template>
+    <xsl:template match="dactyl:xml-block" mode="help-2">
+        <div dactyl:highlight="HelpXMLBlock">
+            <xsl:call-template name="xml-highlight"/>
+        </div>
+    </xsl:template>
+    <xsl:template match="dactyl:xml-highlight" mode="help-2">
+        <xsl:call-template name="xml-highlight"/>
+    </xsl:template>
+
+    <!-- Plugins {{{1 -->
+
+    <xsl:template name="info">
+        <xsl:param name="label"/>
+        <xsl:param name="link" select="@href"/>
+        <xsl:param name="nodes" select="node()"/>
+        <xsl:param name="extra"/>
+        <div dactyl:highlight="HelpInfo">
+            <div dactyl:highlight="HelpInfoLabel">
+                <xsl:value-of select="$label"/>:
+            </div>
+            <span dactyl:highlight="HelpInfoValue">
+                <a>
+                    <xsl:if test="$link">
+                        <xsl:attribute name="href"><xsl:value-of select="$link"/></xsl:attribute>
+                    </xsl:if>
+                    <xsl:copy-of select="exsl:node-set($nodes)"/>
+                </a>
+                <xsl:copy-of select="exsl:node-set($extra)"/>
+            </span>
+        </div>
+    </xsl:template>
+    <xsl:template match="dactyl:author[@email]" mode="help-2">
+        <xsl:call-template name="info">
+            <xsl:with-param name="label" select="'Author'"/>
+            <xsl:with-param name="extra">
+                <xsl:text> </xsl:text><a href="mailto:{@email}"></a>
+            </xsl:with-param>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template match="dactyl:author" mode="help-2">
+        <xsl:call-template name="info">
+            <xsl:with-param name="label" select="'Author'"/>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template match="dactyl:license" mode="help-2">
+        <xsl:call-template name="info">
+            <xsl:with-param name="label" select="'License'"/>
+        </xsl:call-template>
+    </xsl:template>
+    <xsl:template match="dactyl:plugin" mode="help-2">
+        <xsl:call-template name="info">
+            <xsl:with-param name="label" select="'Plugin'"/>
+            <xsl:with-param name="nodes">
+                <span><xsl:value-of select="@name"/></span>
+            </xsl:with-param>
+        </xsl:call-template>
+        <xsl:if test="@version">
+            <xsl:call-template name="info">
+                <xsl:with-param name="label" select="'Version'"/>
+                <xsl:with-param name="link" select="''"/>
+                <xsl:with-param name="nodes">
+                    <span><xsl:value-of select="@version"/></span>
+                </xsl:with-param>
+            </xsl:call-template>
+        </xsl:if>
+        <xsl:apply-templates mode="help-1"/>
+    </xsl:template>
+
+    <!-- Special Element Templates {{{1 -->
+
+    <xsl:template match="dactyl:logo" mode="help-1">
+        <span dactyl:highlight="Logo"/>
+    </xsl:template>
+
+    <!-- Process Tree {{{1 -->
+
+    <xsl:template match="@*|node()" mode="help-2">
+        <xsl:copy>
+            <xsl:apply-templates select="@*|node()" mode="help-1"/>
+        </xsl:copy>
+    </xsl:template>
+    <xsl:template match="@*|node()" mode="help-1">
+        <xsl:apply-templates select="." mode="help-2"/>
+    </xsl:template>
+
+    <!-- XML Highlighting (xsl:import doesn't work in Firefox 3.x) {{{1 -->
+    <xsl:template name="xml-highlight">
+        <div dactyl:highlight="HelpXML">
+            <xsl:apply-templates mode="xml-highlight"/>
+        </div>
+    </xsl:template>
+
+    <xsl:template name="xml-namespace">
+        <xsl:param name="node" select="."/>
+        <xsl:if test="name($node) != local-name($node)">
+            <span dactyl:highlight="HelpXMLNamespace">
+                <xsl:value-of select="substring-before(name($node), ':')"/>
+            </span>
+        </xsl:if>
+        <xsl:value-of select="local-name($node)"/>
+    </xsl:template>
+
+    <xsl:template match="*" mode="xml-highlight">
+        <span dactyl:highlight="HelpXMLTagStart">
+            <xsl:text>&lt;</xsl:text>
+            <xsl:call-template name="xml-namespace"/>
+            <xsl:apply-templates select="@*" mode="xml-highlight"/>
+            <xsl:text>/></xsl:text>
+        </span>
+    </xsl:template>
+
+    <xsl:template match="*[node()]" mode="xml-highlight">
+        <span dactyl:highlight="HelpXMLTagStart">
+            <xsl:text>&lt;</xsl:text>
+            <xsl:call-template name="xml-namespace"/>
+            <xsl:apply-templates select="@*" mode="xml-highlight"/>
+            <xsl:text>></xsl:text>
+        </span>
+        <xsl:apply-templates select="node()" mode="xml-highlight"/>
+        <span dactyl:highlight="HelpXMLTagEnd">
+            <xsl:text>&lt;/</xsl:text>
+            <xsl:call-template name="xml-namespace"/>
+            <xsl:text>></xsl:text>
+        </span>
+    </xsl:template>
+
+    <xsl:template match="dactyl:escape | dactyl:escape[node()]" mode="xml-highlight">
+        <span dactyl:highlight="HelpXMLText">
+            <xsl:apply-templates mode="help-1"/>
+        </span>
+    </xsl:template>
+
+    <xsl:template match="@*" mode="xml-highlight">
+        <xsl:text> </xsl:text>
+        <span dactyl:highlight="HelpXMLAttribute">
+            <xsl:call-template name="xml-namespace"/>
+        </span>
+        <span dactyl:highlight="HelpXMLString">
+            <xsl:value-of select="regexp:replace(., '&quot;', 'g', '&amp;quot;')"/>
+        </span>
+    </xsl:template>
+
+    <xsl:template match="comment()" mode="xml-highlight">
+        <span dactyl:highlight="HelpXMLComment">
+            <xsl:value-of select="."/>
+        </span>
+    </xsl:template>
+
+    <xsl:template match="processing-instruction()" mode="xml-highlight">
+        <span dactyl:highlight="HelpXMLProcessing">
+            <xsl:value-of select="."/>
+        </span>
+    </xsl:template>
+
+    <xsl:template match="text()" mode="xml-highlight">
+        <span dactyl:highlight="HelpXMLText">
+            <xsl:value-of select="regexp:replace(., '&lt;', 'g', '&amp;lt;')"/>
+        </span>
+    </xsl:template>
+</xsl:stylesheet>
+
+<!-- vim:se ft=xslt sts=4 sw=4 et fdm=marker: -->
diff --git a/common/content/hints.js b/common/content/hints.js
new file mode 100644 (file)
index 0000000..bcf431c
--- /dev/null
@@ -0,0 +1,1272 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+/** @instance hints */
+
+var HintSession = Class("HintSession", CommandMode, {
+    get extendedMode() modes.HINTS,
+
+    init: function init(mode, opts) {
+        init.supercall(this);
+
+        opts = opts || {};
+
+        // Hack.
+        if (!opts.window && modes.main == modes.OUTPUT_MULTILINE)
+            opts.window = commandline.widgets.multilineOutput.contentWindow;
+
+        this.hintMode = hints.modes[mode];
+        dactyl.assert(this.hintMode);
+
+        this.activeTimeout = null; // needed for hinttimeout > 0
+        this.continue = Boolean(opts.continue);
+        this.docs = [];
+        this.hintKeys = events.fromString(options["hintkeys"]).map(events.closure.toString);
+        this.hintNumber = 0;
+        this.hintString = opts.filter || "";
+        this.pageHints = [];
+        this.prevInput = "";
+        this.usedTabKey = false;
+        this.validHints = []; // store the indices of the "hints" array with valid elements
+
+        this.open();
+
+        this.top = opts.window || content;
+        this.top.addEventListener("resize", hints.resizeTimer.closure.tell, true);
+        this.top.addEventListener("dactyl-commandupdate", hints.resizeTimer.closure.tell, false, true);
+
+        this.generate();
+
+        this.show();
+
+        if (this.validHints.length == 0) {
+            dactyl.beep();
+            modes.pop();
+        }
+        else if (this.validHints.length == 1 && !this.continue)
+            this.process(false);
+        else // Ticket #185
+            this.checkUnique();
+    },
+
+    Hint: {
+        get active() this._active,
+        set active(val) {
+            this._active = val;
+            if (val)
+                this.span.setAttribute("active", true);
+            else
+                this.span.removeAttribute("active");
+
+            hints.setClass(this.elem, this.valid ? val : null);
+            if (this.imgSpan)
+                hints.setClass(this.imgSpan, this.valid ? val : null);
+        },
+
+        get valid() this._valid,
+        set valid(val) {
+            this._valid = val,
+
+            this.span.style.display = (val ? "" : "none");
+            if (this.imgSpan)
+                this.imgSpan.style.display = (val ? "" : "none");
+
+            this.active = this.active;
+        }
+    },
+
+    get mode() modes.HINTS,
+
+    get prompt() ["Question", UTF8(this.hintMode.prompt) + ": "],
+
+    leave: function leave(stack) {
+        leave.superapply(this, arguments);
+
+        if (!stack.push) {
+            if (hints.hintSession == this)
+                hints.hintSession = null;
+            if (this.top) {
+                this.top.removeEventListener("resize", hints.resizeTimer.closure.tell, true);
+                this.top.removeEventListener("dactyl-commandupdate", hints.resizeTimer.closure.tell, true);
+            }
+
+            this.removeHints(0);
+        }
+    },
+
+    checkUnique: function _checkUnique() {
+        if (this.hintNumber == 0)
+            return;
+        dactyl.assert(this.hintNumber <= this.validHints.length);
+
+        // if we write a numeric part like 3, but we have 45 hints, only follow
+        // the hint after a timeout, as the user might have wanted to follow link 34
+        if (this.hintNumber > 0 && this.hintNumber * this.hintKeys.length <= this.validHints.length) {
+            let timeout = options["hinttimeout"];
+            if (timeout > 0)
+                this.activeTimeout = this.timeout(function () {
+                    this.process(true);
+                }, timeout);
+        }
+        else // we have a unique hint
+            this.process(true);
+    },
+
+    /**
+     * Clear any timeout which might be active after pressing a number
+     */
+    clearTimeout: function () {
+        if (this.activeTimeout)
+            this.activeTimeout.cancel();
+        this.activeTimeout = null;
+    },
+
+    _escapeNumbers: false,
+    get escapeNumbers() this._escapeNumbers,
+    set escapeNumbers(val) {
+        this.clearTimeout();
+        this._escapeNumbers = !!val;
+        if (val && this.usedTabKey)
+            this.hintNumber = 0;
+
+        this.updateStatusline();
+    },
+
+    /**
+     * Returns the hint string for a given number based on the values of
+     * the 'hintkeys' option.
+     *
+     * @param {number} n The number to transform.
+     * @returns {string}
+     */
+    getHintString: function getHintString(n) {
+        let res = [], len = this.hintKeys.length;
+        do {
+            res.push(this.hintKeys[n % len]);
+            n = Math.floor(n / len);
+        }
+        while (n > 0);
+        return res.reverse().join("");
+    },
+
+    /**
+     * Returns true if the given key string represents a
+     * pseudo-hint-number.
+     *
+     * @param {string} key The key to test.
+     * @returns {boolean} Whether the key represents a hint number.
+     */
+    isHintKey: function isHintKey(key) this.hintKeys.indexOf(key) >= 0,
+
+    /**
+     * Gets the actual offset of an imagemap area.
+     *
+     * Only called by {@link #_generate}.
+     *
+     * @param {Object} elem The <area> element.
+     * @param {number} leftPos The left offset of the image.
+     * @param {number} topPos The top offset of the image.
+     * @returns [leftPos, topPos] The updated offsets.
+     */
+    getAreaOffset: function _getAreaOffset(elem, leftPos, topPos) {
+        try {
+            // Need to add the offset to the area element.
+            // Always try to find the top-left point, as per dactyl default.
+            let shape = elem.getAttribute("shape").toLowerCase();
+            let coordStr = elem.getAttribute("coords");
+            // Technically it should be only commas, but hey
+            coordStr = coordStr.replace(/\s+[;,]\s+/g, ",").replace(/\s+/g, ",");
+            let coords = coordStr.split(",").map(Number);
+
+            if ((shape == "rect" || shape == "rectangle") && coords.length == 4) {
+                leftPos += coords[0];
+                topPos += coords[1];
+            }
+            else if (shape == "circle" && coords.length == 3) {
+                leftPos += coords[0] - coords[2] / Math.sqrt(2);
+                topPos += coords[1] - coords[2] / Math.sqrt(2);
+            }
+            else if ((shape == "poly" || shape == "polygon") && coords.length % 2 == 0) {
+                let leftBound = Infinity;
+                let topBound = Infinity;
+
+                // First find the top-left corner of the bounding rectangle (offset from image topleft can be noticeably suboptimal)
+                for (let i = 0; i < coords.length; i += 2) {
+                    leftBound = Math.min(coords[i], leftBound);
+                    topBound = Math.min(coords[i + 1], topBound);
+                }
+
+                let curTop = null;
+                let curLeft = null;
+                let curDist = Infinity;
+
+                // Then find the closest vertex. (we could generalize to nearest point on an edge, but I doubt there is a need)
+                for (let i = 0; i < coords.length; i += 2) {
+                    let leftOffset = coords[i] - leftBound;
+                    let topOffset = coords[i + 1] - topBound;
+                    let dist = Math.sqrt(leftOffset * leftOffset + topOffset * topOffset);
+                    if (dist < curDist) {
+                        curDist = dist;
+                        curLeft = coords[i];
+                        curTop = coords[i + 1];
+                    }
+                }
+
+                // If we found a satisfactory offset, let's use it.
+                if (curDist < Infinity)
+                    return [leftPos + curLeft, topPos + curTop];
+            }
+        }
+        catch (e) {} // badly formed document, or shape == "default" in which case we don't move the hint
+        return [leftPos, topPos];
+    },
+
+    // the containing block offsets with respect to the viewport
+    getContainerOffsets: function _getContainerOffsets(doc) {
+        let body = doc.body || doc.documentElement;
+        // TODO: getComputedStyle returns null for Facebook channel_iframe doc - probable Gecko bug.
+        let style = util.computedStyle(body);
+
+        if (style && /^(absolute|fixed|relative)$/.test(style.position)) {
+            let rect = body.getClientRects()[0];
+            return [-rect.left, -rect.top];
+        }
+        else
+            return [doc.defaultView.scrollX, doc.defaultView.scrollY];
+    },
+
+    /**
+     * Generate the hints in a window.
+     *
+     * Pushes the hints into the pageHints object, but does not display them.
+     *
+     * @param {Window} win The window for which to generate hints.
+     * @default content
+     */
+    generate: function _generate(win, offsets) {
+        if (!win)
+            win = this.top;
+
+        let doc = win.document;
+
+        let [offsetX, offsetY] = this.getContainerOffsets(doc);
+
+        offsets = offsets || { left: 0, right: 0, top: 0, bottom: 0 };
+        offsets.right  = win.innerWidth  - offsets.right;
+        offsets.bottom = win.innerHeight - offsets.bottom;
+
+        function isVisible(elem) {
+            let rect = elem.getBoundingClientRect();
+            if (!rect || !rect.width || !rect.height ||
+                rect.top > offsets.bottom || rect.bottom < offsets.top ||
+                rect.left > offsets.right || rect.right < offsets.left)
+                return false;
+
+            let computedStyle = doc.defaultView.getComputedStyle(elem, null);
+            if (computedStyle.visibility != "visible" || computedStyle.display == "none")
+                return false;
+            return true;
+        }
+
+        let body = doc.body || doc.querySelector("body");
+        if (body) {
+            let fragment = util.xmlToDom(<div highlight="hints"/>, doc);
+            body.appendChild(fragment);
+            util.computedStyle(fragment).height; // Force application of binding.
+            let container = doc.getAnonymousElementByAttribute(fragment, "anonid", "hints") || fragment;
+
+            let baseNodeAbsolute = util.xmlToDom(<span highlight="Hint" style="display: none"/>, doc);
+
+            let mode = this.hintMode;
+            let res = mode.matcher(doc);
+
+            let start = this.pageHints.length;
+            for (let elem in res) {
+                let hint = { elem: elem, showText: false, __proto__: this.Hint };
+
+                if (!isVisible(elem) || mode.filter && !mode.filter(elem))
+                    continue;
+
+                if (elem.hasAttributeNS(NS, "hint"))
+                    [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
+                else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]))
+                    [hint.text, hint.showText] = hints.getInputHint(elem, doc);
+                else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent))
+                    [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
+                else
+                    hint.text = elem.textContent.toLowerCase();
+
+                hint.span = baseNodeAbsolute.cloneNode(true);
+
+                let rect = elem.getClientRects()[0] || elem.getBoundingClientRect();
+                let leftPos = Math.max((rect.left + offsetX), offsetX);
+                let topPos  = Math.max((rect.top + offsetY), offsetY);
+
+                if (elem instanceof HTMLAreaElement)
+                    [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
+
+                hint.span.style.left = leftPos + "px";
+                hint.span.style.top =  topPos + "px";
+                container.appendChild(hint.span);
+
+                this.pageHints.push(hint);
+            }
+
+            this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
+        }
+
+        Array.forEach(win.frames, function (f) {
+            if (isVisible(f.frameElement)) {
+                let rect = f.frameElement.getBoundingClientRect();
+                this.generate(f, {
+                    left: Math.max(offsets.left - rect.left, 0),
+                    right: Math.max(rect.right - offsets.right, 0),
+                    top: Math.max(offsets.top - rect.top, 0),
+                    bottom: Math.max(rect.bottom - offsets.bottom, 0)
+                });
+            }
+        }, this);
+
+        return true;
+    },
+
+    /**
+     * Handle user input.
+     *
+     * Will update the filter on displayed hints and follow the final hint if
+     * necessary.
+     *
+     * @param {Event} event The keypress event.
+     */
+    onChange: function onChange(event) {
+        this.prevInput = "text";
+
+        this.clearTimeout();
+
+        this.hintNumber = 0;
+        this.hintString = commandline.command;
+        this.updateStatusline();
+        this.show();
+        if (this.validHints.length == 1)
+            this.process(false);
+    },
+
+    /**
+     * Handle a hint mode event.
+     *
+     * @param {Event} event The event to handle.
+     */
+    onKeyPress: function onKeyPress(eventList) {
+        const KILL = false, PASS = true;
+        let key = events.toString(eventList[0]);
+
+        this.clearTimeout();
+
+        if (!this.escapeNumbers && this.isHintKey(key)) {
+            this.prevInput = "number";
+
+            let oldHintNumber = this.hintNumber;
+            if (this.usedTabKey) {
+                this.hintNumber = 0;
+                this.usedTabKey = false;
+            }
+            this.hintNumber = this.hintNumber * this.hintKeys.length +
+                this.hintKeys.indexOf(key);
+
+            this.updateStatusline();
+
+            if (this.docs.length)
+                this.updateValidNumbers();
+            else {
+                this.generate();
+                this.show();
+            }
+
+            this.showActiveHint(this.hintNumber, oldHintNumber || 1);
+
+            dactyl.assert(this.hintNumber != 0);
+
+            this.checkUnique();
+            return KILL;
+        }
+
+        return PASS;
+    },
+
+    onResize: function () {
+        this.removeHints(0);
+        this.generate(this.top);
+        this.show();
+    },
+
+    /**
+     * Finish hinting.
+     *
+     * Called when there are one or zero hints in order to possibly activate it
+     * and, if activated, to clean up the rest of the hinting system.
+     *
+     * @param {boolean} followFirst Whether to force the following of the first
+     *     link (when 'followhints' is 1 or 2)
+     *
+     */
+    process: function _processHints(followFirst) {
+        dactyl.assert(this.validHints.length > 0);
+
+        // This "followhints" option is *too* confusing. For me, and
+        // presumably for users, too. --Kris
+        if (options["followhints"] > 0) {
+            if (!followFirst)
+                return; // no return hit; don't examine uniqueness
+
+            // OK. return hit. But there's more than one hint, and
+            // there's no tab-selected current link. Do not follow in mode 2
+            dactyl.assert(options["followhints"] != 2 || this.validHints.length == 1 || this.hintNumber);
+        }
+
+        if (!followFirst) {
+            let firstHref = this.validHints[0].elem.getAttribute("href") || null;
+            if (firstHref) {
+                if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref))
+                    return;
+            }
+            else if (this.validHints.length > 1)
+                return;
+        }
+
+        let timeout = followFirst || events.feedingKeys ? 0 : 500;
+        let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
+        let elem = this.validHints[activeIndex].elem;
+        let top = this.top;
+
+        if (this.continue)
+            this._reset();
+        else
+            this.removeHints(timeout);
+
+        let n = 5;
+        (function next() {
+            let hinted = n || this.validHints.some(function (h) h.elem === elem);
+            if (!hinted)
+                hints.setClass(elem, null);
+            else if (n)
+                hints.setClass(elem, n % 2);
+            else
+                hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber-1)].elem === elem);
+
+            if (n--)
+                this.timeout(next, 50);
+        }).call(this);
+
+        if (!this.continue) {
+            modes.pop();
+            if (timeout)
+                modes.push(modes.IGNORE, modes.HINTS);
+        }
+
+        this.timeout(function () {
+            if ((modes.extended & modes.HINTS) && !this.continue)
+                modes.pop();
+            commandline.lastEcho = null; // Hack.
+            dactyl.trapErrors("action", this.hintMode,
+                              elem, elem.href || elem.src || "",
+                              this.extendedhintCount, top);
+            if (this.continue && this.top)
+                this.show();
+        }, timeout);
+    },
+
+    /**
+     * Remove all hints from the document, and reset the completions.
+     *
+     * Lingers on the active hint briefly to confirm the selection to the user.
+     *
+     * @param {number} timeout The number of milliseconds before the active
+     *     hint disappears.
+     */
+    removeHints: function _removeHints(timeout) {
+        for (let { doc, start, end } in values(this.docs)) {
+            for (let elem in util.evaluateXPath("//*[@dactyl:highlight='hints']", doc))
+                elem.parentNode.removeChild(elem);
+            for (let i in util.range(start, end + 1))
+                this.pageHints[i].valid = false;
+        }
+        styles.system.remove("hint-positions");
+
+        this.reset();
+    },
+
+    reset: function reset() {
+        this.pageHints = [];
+        this.validHints = [];
+        this.docs = [];
+        this.clearTimeout();
+    },
+    _reset: function _reset() {
+        if (!this.usedTabKey)
+            this.hintNumber = 0;
+        if (this.continue && this.validHints.length <= 1) {
+            this.hintString = "";
+            commandline.widgets.command = this.hintString;
+            this.show();
+        }
+        this.updateStatusline();
+    },
+
+    /**
+     * Display the hints in pageHints that are still valid.
+     */
+    show: function _show() {
+        let hintnum = 1;
+        let validHint = hints.hintMatcher(this.hintString.toLowerCase());
+        let activeHint = this.hintNumber || 1;
+        this.validHints = [];
+
+        for (let { doc, start, end } in values(this.docs)) {
+            let [offsetX, offsetY] = this.getContainerOffsets(doc);
+
+        inner:
+            for (let i in (util.interruptibleRange(start, end + 1, 500))) {
+                let hint = this.pageHints[i];
+
+                hint.valid = validHint(hint.text);
+                if (!hint.valid)
+                    continue inner;
+
+                if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
+                    if (!hint.imgSpan) {
+                        let rect = hint.elem.firstChild.getBoundingClientRect();
+                        if (!rect)
+                            continue;
+
+                        hint.imgSpan = util.xmlToDom(<span highlight="Hint" dactyl:hl="HintImage" xmlns:dactyl={NS}/>, doc);
+                        hint.imgSpan.style.display = "none";
+                        hint.imgSpan.style.left = (rect.left + offsetX) + "px";
+                        hint.imgSpan.style.top = (rect.top + offsetY) + "px";
+                        hint.imgSpan.style.width = (rect.right - rect.left) + "px";
+                        hint.imgSpan.style.height = (rect.bottom - rect.top) + "px";
+                        hint.span.parentNode.appendChild(hint.imgSpan);
+                    }
+                }
+
+                let str = this.getHintString(hintnum);
+                let text = [];
+                if (hint.elem instanceof HTMLInputElement)
+                    if (hint.elem.type === "radio")
+                        text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
+                    else if (hint.elem.type === "checkbox")
+                        text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
+                if (hint.showText)
+                    text.push(hint.text.substr(0, 50));
+
+                hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
+                hint.span.setAttribute("number", str);
+                if (hint.imgSpan)
+                    hint.imgSpan.setAttribute("number", str);
+                hint.active = activeHint == hintnum;
+                this.validHints.push(hint);
+                hintnum++;
+            }
+        }
+
+        if (options["usermode"]) {
+            let css = [];
+            for (let hint in values(this.pageHints)) {
+                let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
+                let imgSpan = "[dactyl|hl=HintImage]";
+                css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
+                if (hint.imgSpan)
+                    css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
+            }
+            styles.system.add("hint-positions", "*", css.join("\n"));
+        }
+
+        return true;
+    },
+
+    /**
+     * Update the activeHint.
+     *
+     * By default highlights it green instead of yellow.
+     *
+     * @param {number} newId The hint to make active.
+     * @param {number} oldId The currently active hint.
+     */
+    showActiveHint: function _showActiveHint(newId, oldId) {
+        let oldHint = this.validHints[oldId - 1];
+        if (oldHint)
+            oldHint.active = false;
+
+        let newHint = this.validHints[newId - 1];
+        if (newHint)
+            newHint.active = true;
+    },
+
+    backspace: function () {
+        this.clearTimeout();
+        if (this.prevInput !== "number")
+            return Events.PASS;
+
+        if (this.hintNumber > 0 && !this.usedTabKey) {
+            this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
+            if (this.hintNumber == 0)
+                this.prevInput = "text";
+            this.update(false);
+        }
+        else {
+            this.usedTabKey = false;
+            this.hintNumber = 0;
+            dactyl.beep();
+        }
+        return Events.KILL;
+    },
+
+    updateValidNumbers: function updateValidNumbers(always) {
+        let string = this.getHintString(this.hintNumber);
+        for (let hint in values(this.validHints))
+            hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
+    },
+
+    tab: function tab(previous) {
+        this.clearTimeout();
+        this.usedTabKey = true;
+        if (this.hintNumber == 0)
+            this.hintNumber = 1;
+
+        let oldId = this.hintNumber;
+        if (!previous) {
+            if (++this.hintNumber > this.validHints.length)
+                this.hintNumber = 1;
+        }
+        else {
+            if (--this.hintNumber < 1)
+                this.hintNumber = this.validHints.length;
+        }
+
+        this.updateValidNumbers(true);
+        this.showActiveHint(this.hintNumber, oldId);
+        this.updateStatusline();
+    },
+
+    update: function update(followFirst) {
+        this.clearTimeout();
+        this.updateStatusline();
+
+        if (this.docs.length == 0 && this.hintString.length > 0)
+            this.generate();
+
+        this.show();
+        this.process(followFirst);
+    },
+
+    /**
+     * Display the current status to the user.
+     */
+    updateStatusline: function _updateStatusline() {
+        statusline.inputBuffer = (this.escapeNumbers ? options["mapleader"] : "") +
+                                 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
+    },
+});
+
+var Hints = Module("hints", {
+    init: function init() {
+        this.resizeTimer = Timer(100, 500, function () {
+            if (isinstance(modes.main, modes.HINTS))
+                modes.getStack(0).params.onResize();
+        });
+
+        let appContent = document.getElementById("appcontent");
+        if (appContent)
+            events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
+
+        const Mode = Hints.Mode;
+        Mode.defaultValue("tags", function () function () options.get("hinttags").matcher);
+        Mode.prototype.__defineGetter__("matcher", function ()
+            options.get("extendedhinttags").getKey(this.name, this.tags()));
+
+        this.modes = {};
+        this.addMode(";", "Focus hint",                           buffer.closure.focusElement);
+        this.addMode("?", "Show information for hint",            function (elem) buffer.showElementInfo(elem));
+        this.addMode("s", "Save hint",                            function (elem) buffer.saveLink(elem, false));
+        this.addMode("f", "Focus frame",                          function (elem) dactyl.focus(elem.ownerDocument.defaultView));
+        this.addMode("F", "Focus frame or pseudo-frame",          buffer.closure.focusElement, null, isScrollable);
+        this.addMode("o", "Follow hint",                          function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
+        this.addMode("t", "Follow hint in a new tab",             function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
+        this.addMode("b", "Follow hint in a background tab",      function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
+        this.addMode("w", "Follow hint in a new window",          function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
+        this.addMode("O", "Generate an ‘:open URL’ prompt",       function (elem, loc) CommandExMode().open("open " + loc));
+        this.addMode("T", "Generate a ‘:tabopen URL’ prompt",     function (elem, loc) CommandExMode().open("tabopen " + loc));
+        this.addMode("W", "Generate a ‘:winopen URL’ prompt",     function (elem, loc) CommandExMode().open("winopen " + loc));
+        this.addMode("a", "Add a bookmark",                       function (elem) bookmarks.addSearchKeyword(elem));
+        this.addMode("S", "Add a search keyword",                 function (elem) bookmarks.addSearchKeyword(elem));
+        this.addMode("v", "View hint source",                     function (elem, loc) buffer.viewSource(loc, false));
+        this.addMode("V", "View hint source in external editor",  function (elem, loc) buffer.viewSource(loc, true));
+        this.addMode("y", "Yank hint location",                   function (elem, loc) dactyl.clipboardWrite(loc, true));
+        this.addMode("Y", "Yank hint description",                function (elem) dactyl.clipboardWrite(elem.textContent || "", true));
+        this.addMode("c", "Open context menu",                    function (elem) buffer.openContextMenu(elem));
+        this.addMode("i", "Show image",                           function (elem) dactyl.open(elem.src));
+        this.addMode("I", "Show image in a new tab",              function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
+
+        function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
+            Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
+    },
+
+    hintSession: Modes.boundProperty(),
+
+    /**
+     * Creates a new hint mode.
+     *
+     * @param {string} mode The letter that identifies this mode.
+     * @param {string} prompt The description to display to the user
+     *     about this mode.
+     * @param {function(Node)} action The function to be called with the
+     *     element that matches.
+     * @param {function():string} tags The function that returns an
+     *     XPath expression to decide which elements can be hinted (the
+     *     default returns options["hinttags"]).
+     * @optional
+     */
+    addMode: function (mode, prompt, action, tags) {
+        arguments[1] = UTF8(prompt);
+        this.modes[mode] = Hints.Mode.apply(Hints.Mode, arguments);
+    },
+
+    /**
+     * Get a hint for "input", "textarea" and "select".
+     *
+     * Tries to use <label>s if possible but does not try to guess that a
+     * neighboring element might look like a label. Only called by
+     * {@link #_generate}.
+     *
+     * If it finds a hint it returns it, if the hint is not the caption of the
+     * element it will return showText=true.
+     *
+     * @param {Object} elem The element used to generate hint text.
+     * @param {Document} doc The containing document.
+     *
+     * @returns [text, showText]
+     */
+    getInputHint: function _getInputHint(elem, doc) {
+        // <input type="submit|button|reset"/>   Always use the value
+        // <input type="radio|checkbox"/>        Use the value if it is not numeric or label or name
+        // <input type="password"/>              Never use the value, use label or name
+        // <input type="text|file"/> <textarea/> Use value if set or label or name
+        // <input type="image"/>                 Use the alt text if present (showText) or label or name
+        // <input type="hidden"/>                Never gets here
+        // <select/>                             Use the text of the selected item or label or name
+
+        let type = elem.type;
+
+        if (elem instanceof HTMLInputElement && set.has(util.editableInputs, elem.type))
+            return [elem.value, false];
+        else {
+            for (let [, option] in Iterator(options["hintinputs"])) {
+                if (option == "value") {
+                    if (elem instanceof HTMLSelectElement) {
+                        if (elem.selectedIndex >= 0)
+                            return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
+                    }
+                    else if (type == "image") {
+                        if (elem.alt)
+                            return [elem.alt.toLowerCase(), true];
+                    }
+                    else if (elem.value && type != "password") {
+                        // radio's and checkboxes often use internal ids as values - maybe make this an option too...
+                        if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
+                            return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
+                    }
+                }
+                else if (option == "label") {
+                    if (elem.id) {
+                        // TODO: (possibly) do some guess work for label-like objects
+                        let label = util.evaluateXPath(["label[@for=" + elem.id.quote() + "]"], doc).snapshotItem(0);
+                        if (label)
+                            return [label.textContent.toLowerCase(), true];
+                    }
+                }
+                else if (option == "name")
+                    return [elem.name.toLowerCase(), true];
+            }
+        }
+
+        return ["", false];
+    },
+
+    /**
+     * Get the hintMatcher according to user preference.
+     *
+     * @param {string} hintString The currently typed hint.
+     * @returns {hintMatcher}
+     */
+    hintMatcher: function _hintMatcher(hintString) { //{{{
+        /**
+         * Divide a string by a regular expression.
+         *
+         * @param {RegExp|string} pat The pattern to split on.
+         * @param {string} str The string to split.
+         * @returns {Array(string)} The lowercased splits of the splitting.
+         */
+        function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
+
+        /**
+         * Get a hint matcher for hintmatching=contains
+         *
+         * The hintMatcher expects the user input to be space delimited and it
+         * returns true if each set of characters typed can be found, in any
+         * order, in the link.
+         *
+         * @param {string} hintString  The string typed by the user.
+         * @returns {function(String):boolean} A function that takes the text
+         *     of a hint and returns true if all the (space-delimited) sets of
+         *     characters typed by the user can be found in it.
+         */
+        function containsMatcher(hintString) { //{{{
+            let tokens = tokenize(/\s+/, hintString);
+            return function (linkText) {
+                linkText = linkText.toLowerCase();
+                return tokens.every(function (token) indexOf(linkText, token) >= 0);
+            };
+        } //}}}
+
+        /**
+         * Get a hintMatcher for hintmatching=firstletters|wordstartswith
+         *
+         * The hintMatcher will look for any division of the user input that
+         * would match the first letters of words. It will always only match
+         * words in order.
+         *
+         * @param {string} hintString The string typed by the user.
+         * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
+         *     words to match.
+         * @returns {function(String):boolean} A function that will filter only
+         *     hints that match as above.
+         */
+        function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
+            let hintStrings     = tokenize(/\s+/, hintString);
+            let wordSplitRegexp = util.regexp(options["wordseparators"]);
+
+            /**
+             * Match a set of characters to the start of words.
+             *
+             * What the **** does this do? --Kris
+             * This function matches hintStrings like 'hekho' to links
+             * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
+             * --Daniel
+             *
+             * @param {string} chars The characters to match.
+             * @param {Array(string)} words The words to match them against.
+             * @param {boolean} allowWordOverleaping Whether words may be
+             *     skipped during matching.
+             * @returns {boolean} Whether a match can be found.
+             */
+            function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
+                function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
+                    let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
+                    if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
+                        let nextWordIdx = wordIdx + 1;
+                        if (nextWordIdx == words.length)
+                            return false;
+
+                        return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
+                    }
+
+                    if (matches) {
+                        let nextCharIdx = charIdx + 1;
+                        if (nextCharIdx == chars.length)
+                            return true;
+
+                        let nextWordIdx = wordIdx + 1;
+                        let beyondLastWord = (nextWordIdx == words.length);
+                        let charMatched = false;
+                        if (beyondLastWord == false)
+                            charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
+
+                        if (charMatched)
+                            return true;
+
+                        if (charMatched == false || beyondLastWord == true) {
+                            let nextInWordIdx = inWordIdx + 1;
+                            if (nextInWordIdx == words[wordIdx].length)
+                                return false;
+
+                            return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
+                        }
+                    }
+
+                    return false;
+                }
+
+                return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
+            }
+
+            /**
+             * Check whether the array of strings all exist at the start of the
+             * words.
+             *
+             * i.e. ['ro', 'e'] would match ['rollover', 'effect']
+             *
+             * The matches must be in order, and, if allowWordOverleaping is
+             * false, contiguous.
+             *
+             * @param {Array(string)} strings The strings to search for.
+             * @param {Array(string)} words The words to search in.
+             * @param {boolean} allowWordOverleaping Whether matches may be
+             *     non-contiguous.
+             * @returns {boolean} Whether all the strings matched.
+             */
+            function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
+                let strIdx = 0;
+                for (let [, word] in Iterator(words)) {
+                    if (word.length == 0)
+                        continue;
+
+                    let str = strings[strIdx];
+                    if (str.length == 0 || indexOf(word, str) == 0)
+                        strIdx++;
+                    else if (!allowWordOverleaping)
+                        return false;
+
+                    if (strIdx == strings.length)
+                        return true;
+                }
+
+                for (; strIdx < strings.length; strIdx++) {
+                    if (strings[strIdx].length != 0)
+                        return false;
+                }
+                return true;
+            }
+
+            return function (linkText) {
+                if (hintStrings.length == 1 && hintStrings[0].length == 0)
+                    return true;
+
+                let words = tokenize(wordSplitRegexp, linkText);
+                if (hintStrings.length == 1)
+                    return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
+                else
+                    return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
+            };
+        } //}}}
+
+        let indexOf = String.indexOf;
+        if (options.get("hintmatching").has("transliterated"))
+            indexOf = Hints.indexOf;
+
+        switch (options["hintmatching"][0]) {
+        case "contains"      : return containsMatcher(hintString);
+        case "wordstartswith": return wordStartsWithMatcher(hintString, true);
+        case "firstletters"  : return wordStartsWithMatcher(hintString, false);
+        case "custom"        : return dactyl.plugins.customHintMatcher(hintString);
+        default              : dactyl.echoerr(_("hints.noMatcher", hintMatching));
+        }
+        return null;
+    }, //}}}
+
+    open: function open(mode, opts) {
+        this._extendedhintCount = opts.count;
+        commandline.input(["Normal", mode], "", {
+            completer: function (context) {
+                context.compare = function () 0;
+                context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
+            },
+            onSubmit: function (arg) {
+                if (arg)
+                    hints.show(arg, opts);
+            },
+            onChange: function () {
+                this.accepted = true;
+                modes.pop();
+            },
+        });
+    },
+
+    /**
+     * Toggle the highlight of a hint.
+     *
+     * @param {Object} elem The element to toggle.
+     * @param {boolean} active Whether it is the currently active hint or not.
+     */
+    setClass: function _setClass(elem, active) {
+        if (elem.dactylHighlight == null)
+            elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
+
+        let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
+        if (active)
+            highlight.highlightNode(elem, prefix + "HintActive");
+        else if (active != null)
+            highlight.highlightNode(elem, prefix + "HintElem");
+        else {
+            highlight.highlightNode(elem, elem.dactylHighlight);
+            // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
+            elem.dactylHighlight = null;
+        }
+    },
+
+    show: function show(mode, opts) {
+        this.hintSession = HintSession(mode, opts);
+    }
+}, {
+    translitTable: Class.memoize(function () {
+        const table = {};
+        [
+            [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
+            [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
+            [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
+            [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
+            [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
+            [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
+            [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
+            [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
+            [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
+            [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
+            [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
+            [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
+            [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
+            [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
+            [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
+            [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
+            [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
+            [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
+            [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
+            [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
+            [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
+            [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
+            [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
+            [0x0193, 0x0194, ["G"]],
+            [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
+            [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
+            [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
+            [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
+            [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
+               "I", "i", "O", "o"]],
+            [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
+            [0x01e2, 0x01e3, ["AE", "ae"]],
+            [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
+            [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
+            [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
+            [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
+               "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
+               "u", "U", "u"]],
+            [0x0253, 0x0257, ["b", "c", "d", "d"]],
+            [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
+            [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
+            [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
+            [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
+            [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
+            [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
+            [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
+            [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
+               "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
+               "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
+               "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
+               "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
+               "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
+               "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
+               "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
+            [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
+            [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
+            [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
+            [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
+            [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
+            [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
+            [0x24d0, 0x24e9, "a"],
+            [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
+            [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
+        ].forEach(function (start, stop, val) {
+            if (typeof val != "string")
+                for (let i = start; i <= stop; i++)
+                    table[String.fromCharCode(i)] = val[(i - start) % val.length];
+            else {
+                let n = val.charCodeAt(0);
+                for (let i = start; i <= stop; i++)
+                    table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
+            }
+        });
+        return table;
+    }),
+    indexOf: function indexOf(dest, src) {
+        let table = this.translitTable;
+        var end = dest.length - src.length;
+        if (src.length == 0)
+            return 0;
+    outer:
+        for (var i = 0; i < end; i++) {
+                var j = i;
+                for (var k = 0; k < src.length;) {
+                    var s = dest[j++];
+                    s = table[s] || s;
+                    for (var l = 0; l < s.length; l++, k++) {
+                        if (s[l] != src[k])
+                            continue outer;
+                        if (k == src.length - 1)
+                            return i;
+                    }
+                }
+            }
+        return -1;
+    },
+
+    Mode: Struct("HintMode", "name", "prompt", "action", "tags", "filter")
+            .localize("prompt")
+}, {
+    modes: function initModes() {
+        initModes.require("commandline");
+        modes.addMode("HINTS", {
+            extended: true,
+            description: "Active when selecting elements in QuickHint or ExtendedHint mode",
+            bases: [modes.COMMAND_LINE],
+            input: true,
+            ownsBuffer: true
+        });
+    },
+    mappings: function () {
+        var myModes = config.browserModes.concat(modes.OUTPUT_MULTILINE);
+        mappings.add(myModes, ["f"],
+            "Start QuickHint mode",
+            function () { hints.show("o"); });
+
+        mappings.add(myModes, ["F"],
+            "Start QuickHint mode, but open link in a new tab",
+            function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
+
+        mappings.add(myModes, [";"],
+            "Start an extended hint mode",
+            function ({ count }) { hints.open(";", { count: count }); },
+            { count: true });
+
+        mappings.add(myModes, ["g;"],
+            "Start an extended hint mode and stay there until <Esc> is pressed",
+            function ({ count }) { hints.open("g;", { continue: true, count: count }); },
+            { count: true });
+
+        mappings.add(modes.HINTS, ["<Return>"],
+            "Follow the selected hint",
+            function ({ self }) { self.update(true); });
+
+        mappings.add(modes.HINTS, ["<Tab>"],
+            "Focus the next matching hint",
+            function ({ self }) { self.tab(false); });
+
+        mappings.add(modes.HINTS, ["<S-Tab>"],
+            "Focus the previous matching hint",
+            function ({ self }) { self.tab(true); });
+
+        mappings.add(modes.HINTS, ["<BS>", "<C-h>"],
+            "Delete the previous character",
+            function ({ self }) self.backspace());
+
+        mappings.add(modes.HINTS, ["<Leader>"],
+            "Toggle hint filtering",
+            function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
+    },
+    options: function () {
+        function xpath(arg) util.makeXPath(arg);
+
+        options.add(["extendedhinttags", "eht"],
+            "XPath or CSS selector strings of hintable elements for extended hint modes",
+            "regexpmap", {
+                "[iI]": "img",
+                "[asOTivVWy]": ["a[href]", "area[href]", "img[src]", "iframe[src]"],
+                "[f]": "body",
+                "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
+                "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
+            },
+            {
+                keepQuotes: true,
+                getKey: function (val, default_)
+                    let (res = array.nth(this.value, function (re) re.test(val), 0))
+                        res ? res.matcher : default_,
+                setter: function (vals) {
+                    for (let value in values(vals))
+                        value.matcher = util.compileMatcher(Option.splitList(value.result));
+                    return vals;
+                },
+                validator: util.validateMatcher
+            });
+
+        options.add(["hinttags", "ht"],
+            "XPath string of hintable elements activated by 'f' and 'F'",
+            "stringlist", "input:not([type=hidden]),a,area,iframe,textarea,button,select," +
+                          "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
+                          "[tabindex],[role=link],[role=button]",
+            {
+                setter: function (values) {
+                    this.matcher = util.compileMatcher(values);
+                    return values;
+                },
+                validator: util.validateMatcher
+            });
+
+        options.add(["hintkeys", "hk"],
+            "The keys used to label and select hints",
+            "string", "0123456789",
+            {
+                values: {
+                    "0123456789": "Numbers",
+                    "asdfg;lkjh": "Home Row"
+                },
+                validator: function (value) {
+                    let values = events.fromString(value).map(events.closure.toString);
+                    return Option.validIf(array.uniq(values).length === values.length,
+                                            "Duplicate keys not allowed");
+                }
+            });
+
+        options.add(["hinttimeout", "hto"],
+            "Timeout before automatically following a non-unique numerical hint",
+            "number", 0,
+            { validator: function (value) value >= 0 });
+
+        options.add(["followhints", "fh"],
+            // FIXME: this description isn't very clear but I can't think of a
+            // better one right now.
+            "Change the behavior of <Return> in hint mode",
+            "number", 0,
+            {
+                values: {
+                    "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
+                    "1": "Follow the selected hint on <Return>.",
+                    "2": "Follow the selected hint on <Return> only it's been <Tab>-selected."
+                }
+            });
+
+        options.add(["hintmatching", "hm"],
+            "How hints are filtered",
+            "stringlist", "contains",
+            {
+                values: {
+                    "contains":       "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
+                    "custom":         "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
+                    "firstletters":   "Behaves like wordstartswith, but all groups must match a sequence of words.",
+                    "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
+                    "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
+                },
+                validator: function (values) Option.validateCompleter.call(this, values) &&
+                    1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
+            });
+
+        options.add(["wordseparators", "wsp"],
+            "Regular expression defining which characters separate words when matching hints",
+            "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
+            { validator: function (value) RegExp(value) });
+
+        options.add(["hintinputs", "hin"],
+            "Which text is used to filter hints for input elements",
+            "stringlist", "label,value",
+            {
+                values: {
+                    "value": "Match against the value of the input field",
+                    "label": "Match against the text of a label for the input field, if one can be found",
+                    "name":  "Match against the name of the input field"
+                }
+            });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/history.js b/common/content/history.js
new file mode 100644 (file)
index 0000000..cf985d9
--- /dev/null
@@ -0,0 +1,297 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+var History = Module("history", {
+    get format() bookmarks.format,
+
+    get service() services.history,
+
+    get: function get(filter, maxItems, order) {
+        // no query parameters will get all history
+        let query = services.history.getNewQuery();
+        let options = services.history.getNewQueryOptions();
+
+        if (typeof filter == "string")
+            filter = { searchTerms: filter };
+        for (let [k, v] in Iterator(filter))
+            query[k] = v;
+
+        order = order || "+date";
+        dactyl.assert((order = /^([+-])(.+)/.exec(order)) &&
+                      (order = "SORT_BY_" + order[2].toUpperCase() + "_" +
+                        (order[1] == "+" ? "ASCENDING" : "DESCENDING")) &&
+                      order in options,
+                     _("error.invalidSort", order));
+
+        options.sortingMode = options[order];
+        options.resultType = options.RESULTS_AS_URI;
+        if (maxItems > 0)
+            options.maxResults = maxItems;
+
+        // execute the query
+        let root = services.history.executeQuery(query, options).root;
+        root.containerOpen = true;
+        let items = iter(util.range(0, root.childCount)).map(function (i) {
+            let node = root.getChild(i);
+            return {
+                url: node.uri,
+                title: node.title,
+                icon: node.icon ? node.icon.spec : DEFAULT_FAVICON
+            };
+        }).toArray();
+        root.containerOpen = false; // close a container after using it!
+
+        return items;
+    },
+
+    get session() {
+        let sh = window.getWebNavigation().sessionHistory;
+        let obj = [];
+        obj.index = sh.index;
+        obj.__iterator__ = function () array.iterItems(this);
+        for (let i in util.range(0, sh.count)) {
+            obj[i] = update(Object.create(sh.getEntryAtIndex(i, false)),
+                            { index: i });
+            memoize(obj[i], "icon",
+                function () services.favicon.getFaviconImageForPage(this.URI).spec);
+        }
+        return obj;
+    },
+
+    stepTo: function stepTo(steps) {
+        let start = 0;
+        let end = window.getWebNavigation().sessionHistory.count - 1;
+        let current = window.getWebNavigation().sessionHistory.index;
+
+        if (current == start && steps < 0 || current == end && steps > 0)
+            dactyl.beep();
+        else {
+            let index = Math.constrain(current + steps, start, end);
+            try {
+                window.getWebNavigation().gotoIndex(index);
+            }
+            catch (e) {} // We get NS_ERROR_FILE_NOT_FOUND if files in history don't exist
+        }
+    },
+
+    goToStart: function goToStart() {
+        let index = window.getWebNavigation().sessionHistory.index;
+
+        if (index > 0)
+            window.getWebNavigation().gotoIndex(0);
+        else
+            dactyl.beep();
+
+    },
+
+    goToEnd: function goToEnd() {
+        let sh = window.getWebNavigation().sessionHistory;
+        let max = sh.count - 1;
+
+        if (sh.index < max)
+            window.getWebNavigation().gotoIndex(max);
+        else
+            dactyl.beep();
+
+    },
+
+    // if openItems is true, open the matching history items in tabs rather than display
+    list: function list(filter, openItems, maxItems, sort) {
+        // FIXME: returning here doesn't make sense
+        //   Why the hell doesn't it make sense? --Kris
+        // See comment at bookmarks.list --djk
+        if (!openItems)
+            return completion.listCompleter("history", filter, maxItems, maxItems, sort);
+        let items = completion.runCompleter("history", filter, maxItems, maxItems, sort);
+
+        if (items.length)
+            return dactyl.open(items.map(function (i) i.url), dactyl.NEW_TAB);
+
+        if (filter.length > 0)
+            dactyl.echoerr(_("history.noMatching", filter.quote()));
+        else
+            dactyl.echoerr(_("history.none"));
+        return null;
+    }
+}, {
+}, {
+    commands: function () {
+        commands.add(["ba[ck]"],
+            "Go back in the browser history",
+            function (args) {
+                let url = args[0];
+
+                if (args.bang)
+                    history.goToStart();
+                else {
+                    if (url) {
+                        let sh = history.session;
+                        if (/^\d+(:|$)/.test(url) && sh.index - parseInt(url) in sh)
+                            return void window.getWebNavigation().gotoIndex(sh.index - parseInt(url));
+
+                        for (let [i, ent] in Iterator(sh.slice(0, sh.index).reverse()))
+                            if (ent.URI.spec == url)
+                                return void window.getWebNavigation().gotoIndex(i);
+                        dactyl.echoerr(_("history.noURL"));
+                    }
+                    else
+                        history.stepTo(-Math.max(args.count, 1));
+                }
+                return null;
+            },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function completer(context) {
+                    let sh = history.session;
+
+                    context.anchored = false;
+                    context.compare = CompletionContext.Sort.unsorted;
+                    context.filters = [CompletionContext.Filter.textDescription];
+                    context.completions = sh.slice(0, sh.index).reverse();
+                    context.keys = { text: function (item) (sh.index - item.index) + ": " + item.URI.spec, description: "title", icon: "icon" };
+                },
+                count: true,
+                literal: 0,
+                privateData: true
+            });
+
+        commands.add(["fo[rward]", "fw"],
+            "Go forward in the browser history",
+            function (args) {
+                let url = args.literalArg;
+
+                if (args.bang)
+                    history.goToEnd();
+                else {
+                    if (url) {
+                        let sh = history.session;
+                        if (/^\d+(:|$)/.test(url) && sh.index + parseInt(url) in sh)
+                            return void window.getWebNavigation().gotoIndex(sh.index + parseInt(url));
+
+                        for (let [i, ent] in Iterator(sh.slice(sh.index + 1)))
+                            if (ent.URI.spec == url)
+                                return void window.getWebNavigation().gotoIndex(i);
+                        dactyl.echoerr(_("history.noURL"));
+                    }
+                    else
+                        history.stepTo(Math.max(args.count, 1));
+                }
+                return null;
+            },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function completer(context) {
+                    let sh = history.session;
+
+                    context.anchored = false;
+                    context.compare = CompletionContext.Sort.unsorted;
+                    context.filters = [CompletionContext.Filter.textDescription];
+                    context.completions = sh.slice(sh.index + 1);
+                    context.keys = { text: function (item) (item.index - sh.index) + ": " + item.URI.spec, description: "title", icon: "icon" };
+                },
+                count: true,
+                literal: 0,
+                privateData: true
+            });
+
+        commands.add(["hist[ory]", "hs"],
+            "Show recently visited URLs",
+            function (args) { history.list(args.join(" "), args.bang, args["-max"], args["-sort"]); }, {
+                bang: true,
+                completer: function (context, args) completion.history(context, args["-max"], args["-sort"]),
+                options: [
+                    {
+                        names: ["-max", "-m"],
+                        description: "The maximum number of items to list",
+                        default: 1000,
+                        type: CommandOption.INT
+                    },
+                    {
+                        names: ["-sort", "-s"],
+                        type: CommandOption.STRING,
+                        description: "The sort order of the results",
+                        completer: function (context, args) {
+                            context.compare = CompletionContext.Sort.unsorted;
+                            return array.flatten([
+                                "annotation",
+                                "date",
+                                "date added",
+                                "keyword",
+                                "last modified",
+                                "tags",
+                                "title",
+                                "uri",
+                                "visitcount"
+                            ].map(function (order) [
+                                  ["+" + order.replace(" ", ""), "Sort by " + order + " ascending"],
+                                  ["-" + order.replace(" ", ""), "Sort by " + order + " descending"]
+                            ]));
+                        }
+                    }
+                ],
+                privateData: true
+            });
+    },
+    completion: function () {
+        completion.domain = function (context) {
+            context.anchored = false;
+            context.compare = function (a, b) String.localeCompare(a.key, b.key);
+            context.keys = { text: util.identity, description: util.identity,
+                key: function (host) host.split(".").reverse().join(".") };
+
+            // FIXME: Schema-specific
+            context.generate = function () [
+                Array.slice(row.rev_host).reverse().join("").slice(1)
+                for (row in iter(services.history.DBConnection
+                                         .createStatement("SELECT DISTINCT rev_host FROM moz_places WHERE rev_host IS NOT NULL;")))
+            ].slice(2);
+        };
+
+        completion.history = function _history(context, maxItems, sort) {
+            context.format = history.format;
+            context.title = ["History"];
+            context.compare = CompletionContext.Sort.unsorted;
+            //context.background = true;
+            if (maxItems == null)
+                context.maxItems = maxItems;
+            if (maxItems && context.maxItems == null)
+                context.maxItems = 100;
+            context.regenerate = true;
+            context.generate = function () history.get(context.filter, this.maxItems, sort);
+        };
+
+        completion.addUrlCompleter("h", "History", completion.history);
+    },
+    mappings: function () {
+        var myModes = config.browserModes;
+
+        mappings.add(myModes,
+            ["<C-o>"], "Go to an older position in the jump list",
+            function (args) { history.stepTo(-Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add(myModes,
+            ["<C-i>"], "Go to a newer position in the jump list",
+            function (args) { history.stepTo(Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add(myModes,
+            ["H", "<A-Left>", "<M-Left>"], "Go back in the browser history",
+            function (args) { history.stepTo(-Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add(myModes,
+            ["L", "<A-Right>", "<M-Right>"], "Go forward in the browser history",
+            function (args) { history.stepTo(Math.max(args.count, 1)); },
+            { count: true });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/mappings.js b/common/content/mappings.js
new file mode 100644 (file)
index 0000000..668d8ce
--- /dev/null
@@ -0,0 +1,766 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+/**
+ * A class representing key mappings. Instances are created by the
+ * {@link Mappings} class.
+ *
+ * @param {number[]} modes The modes in which this mapping is active.
+ * @param {string[]} keys The key sequences which are bound to
+ *     *action*.
+ * @param {string} description A short one line description of the key mapping.
+ * @param {function} action The action invoked by each key sequence.
+ * @param {Object} extraInfo An optional extra configuration hash. The
+ *     following properties are supported.
+ *         arg     - see {@link Map#arg}
+ *         count   - see {@link Map#count}
+ *         motion  - see {@link Map#motion}
+ *         noremap - see {@link Map#noremap}
+ *         rhs     - see {@link Map#rhs}
+ *         silent  - see {@link Map#silent}
+ * @optional
+ * @private
+ */
+var Map = Class("Map", {
+    init: function (modes, keys, description, action, extraInfo) {
+        modes = Array.concat(modes);
+        if (!modes.every(util.identity))
+            throw TypeError("Invalid modes: " + modes);
+
+        this.id = ++Map.id;
+        this.modes = modes;
+        this._keys = keys;
+        this.action = action;
+        this.description = description;
+
+        if (Object.freeze)
+            Object.freeze(this.modes);
+
+        if (extraInfo)
+            update(this, extraInfo);
+    },
+
+    name: Class.memoize(function () this.names[0]),
+
+    /** @property {string[]} All of this mapping's names (key sequences). */
+    names: Class.memoize(function () this._keys.map(function (k) events.canonicalKeys(k))),
+
+    get toStringParams() [this.modes.map(function (m) m.name), this.names.map(String.quote)],
+
+    get identifier() [this.modes[0].name, this.hive.prefix + this.names[0]].join("."),
+
+    /** @property {number} A unique ID for this mapping. */
+    id: null,
+    /** @property {number[]} All of the modes for which this mapping applies. */
+    modes: null,
+    /** @property {function (number)} The function called to execute this mapping. */
+    action: null,
+    /** @property {string} This mapping's description, as shown in :listkeys. */
+    description: Messages.Localized(""),
+
+    /** @property {boolean} Whether this mapping accepts an argument. */
+    arg: false,
+    /** @property {boolean} Whether this mapping accepts a count. */
+    count: false,
+    /**
+     * @property {boolean} Whether the mapping accepts a motion mapping
+     *     as an argument.
+     */
+    motion: false,
+    /** @property {boolean} Whether the RHS of the mapping should expand mappings recursively. */
+    noremap: false,
+    /** @property {boolean} Whether any output from the mapping should be echoed on the command line. */
+    silent: false,
+    /** @property {string} The literal RHS expansion of this mapping. */
+    rhs: null,
+    /**
+     * @property {boolean} Specifies whether this is a user mapping. User
+     *     mappings may be created by plugins, or directly by users. Users and
+     *     plugin authors should create only user mappings.
+     */
+    user: false,
+
+    /**
+     * Returns whether this mapping can be invoked by a key sequence matching
+     * *name*.
+     *
+     * @param {string} name The name to query.
+     * @returns {boolean}
+     */
+    hasName: function (name) this.keys.indexOf(name) >= 0,
+
+    get keys() this.names.map(mappings.expandLeader),
+
+    /**
+     * Execute the action for this mapping.
+     *
+     * @param {object} args The arguments object for the given mapping.
+     */
+    execute: function (args) {
+        if (!isObject(args)) // Backwards compatibility :(
+            args = iter(["motion", "count", "arg", "command"])
+                .map(function ([i, prop]) [prop, this[i]], arguments)
+                .toObject();
+
+        args = update({ context: contexts.context },
+                      this.hive.argsExtra(args),
+                      args);
+
+        let self = this;
+        function repeat() self.action(args)
+        if (this.names[0] != ".") // FIXME: Kludge.
+            mappings.repeat = repeat;
+
+        if (this.executing)
+            util.dumpStack("Attempt to execute mapping recursively: " + args.command);
+        dactyl.assert(!this.executing, _("map.recursive", args.command));
+
+        try {
+            this.executing = true;
+            var res = repeat();
+        }
+        catch (e) {
+            events.feedingKeys = false;
+            dactyl.reportError(e, true);
+        }
+        finally {
+            this.executing = false;
+        }
+        return res;
+    }
+
+}, {
+    id: 0
+});
+
+var MapHive = Class("MapHive", Contexts.Hive, {
+    init: function init(group) {
+        init.supercall(this, group);
+        this.stacks = {};
+    },
+
+    /**
+     * Iterates over all mappings present in all of the given *modes*.
+     *
+     * @param {[Modes.Mode]} modes The modes for which to return mappings.
+     */
+    iterate: function (modes) {
+        let stacks = Array.concat(modes).map(this.closure.getStack);
+        return values(stacks.shift().sort(function (m1, m2) String.localeCompare(m1.name, m2.name))
+            .filter(function (map) map.rhs &&
+                stacks.every(function (stack) stack.some(function (m) m.rhs && m.rhs === map.rhs && m.name === map.name))));
+    },
+
+    /**
+     * Adds a new default key mapping.
+     *
+     * @param {number[]} modes The modes that this mapping applies to.
+     * @param {string[]} keys The key sequences which are bound to *action*.
+     * @param {string} description A description of the key mapping.
+     * @param {function} action The action invoked by each key sequence.
+     * @param {Object} extra An optional extra configuration hash.
+     * @optional
+     */
+    add: function (modes, keys, description, action, extra) {
+        extra = extra || {};
+
+        let map = Map(modes, keys, description, action, extra);
+        map.definedAt = contexts.getCaller(Components.stack.caller);
+        map.hive = this;
+
+        if (this.name !== "builtin")
+            for (let [, name] in Iterator(map.names))
+                for (let [, mode] in Iterator(map.modes))
+                    this.remove(mode, name);
+
+        for (let mode in values(map.modes))
+            this.getStack(mode).add(map);
+        return map;
+    },
+
+    /**
+     * Returns the mapping stack for the given mode.
+     *
+     * @param {Modes.Mode} mode The mode to search.
+     * @returns {[Map]}
+     */
+    getStack: function getStack(mode) {
+        if (!(mode in this.stacks))
+            return this.stacks[mode] = MapHive.Stack();
+        return this.stacks[mode];
+    },
+
+    /**
+     * Returns the map from *mode* named *cmd*.
+     *
+     * @param {Modes.Mode} mode The mode to search.
+     * @param {string} cmd The map name to match.
+     * @returns {Map|null}
+     */
+    get: function (mode, cmd) this.getStack(mode).mappings[cmd],
+
+    /**
+     * Returns a count of maps with names starting with but not equal to
+     * *prefix*.
+     *
+     * @param {Modes.Mode} mode The mode to search.
+     * @param {string} prefix The map prefix string to match.
+     * @returns {number)
+     */
+    getCandidates: function (mode, prefix) this.getStack(mode).candidates[prefix] || 0,
+
+    /**
+     * Returns whether there is a user-defined mapping *cmd* for the specified
+     * *mode*.
+     *
+     * @param {Modes.Mode} mode The mode to search.
+     * @param {string} cmd The candidate key mapping.
+     * @returns {boolean}
+     */
+    has: function (mode, cmd) this.getStack(mode).mappings[cmd] != null,
+
+    /**
+     * Remove the mapping named *cmd* for *mode*.
+     *
+     * @param {Modes.Mode} mode The mode to search.
+     * @param {string} cmd The map name to match.
+     */
+    remove: function (mode, cmd) {
+        let stack = this.getStack(mode);
+        for (let [i, map] in array.iterItems(stack)) {
+            let j = map.names.indexOf(cmd);
+            if (j >= 0) {
+                delete stack.states;
+                map.names.splice(j, 1);
+                if (map.names.length == 0) // FIX ME.
+                    for (let [mode, stack] in Iterator(this.stacks))
+                        this.stacks[mode] = MapHive.Stack(stack.filter(function (m) m != map));
+                return;
+            }
+        }
+    },
+
+    /**
+     * Remove all user-defined mappings for *mode*.
+     *
+     * @param {Modes.Mode} mode The mode to remove all mappings from.
+     */
+    clear: function (mode) {
+        this.stacks[mode] = MapHive.Stack([]);
+    }
+}, {
+    Stack: Class("Stack", Array, {
+        init: function (ary) {
+            let self = ary || [];
+            self.__proto__ = this.__proto__;
+            return self;
+        },
+
+        __iterator__: function () array.iterValues(this),
+
+        get candidates() this.states.candidates,
+        get mappings() this.states.mappings,
+
+        add: function (map) {
+            this.push(map);
+            delete this.states;
+        },
+
+        states: Class.memoize(function () {
+            var states = {
+                candidates: {},
+                mappings: {}
+            };
+
+            for (let map in this)
+                for (let name in values(map.keys)) {
+                    states.mappings[name] = map;
+                    let state = "";
+                    for (let key in events.iterKeys(name)) {
+                        state += key;
+                        if (state !== name)
+                            states.candidates[state] = (states.candidates[state] || 0) + 1;
+                    }
+                }
+            return states;
+        })
+    })
+});
+
+/**
+ * @instance mappings
+ */
+var Mappings = Module("mappings", {
+    init: function () {
+    },
+
+    repeat: Modes.boundProperty(),
+
+    get allHives() contexts.allGroups.mappings,
+
+    get userHives() this.allHives.filter(function (h) h !== this.builtin, this),
+
+    expandLeader: function (keyString) keyString.replace(/<Leader>/i, options["mapleader"]),
+
+    iterate: function (mode) {
+        let seen = {};
+        for (let hive in this.hives.iterValues())
+            for (let map in array(hive.getStack(mode)).iterValues())
+                if (!set.add(seen, map.name))
+                    yield map;
+    },
+
+    // NOTE: just normal mode for now
+    /** @property {Iterator(Map)} */
+    __iterator__: function () this.iterate(modes.NORMAL),
+
+    getDefault: deprecated("mappings.builtin.get", function getDefault(mode, cmd) this.builtin.get(mode, cmd)),
+    getUserIterator: deprecated("mappings.user.iterator", function getUserIterator(modes) this.user.iterator(modes)),
+    hasMap: deprecated("group.mappings.has", function hasMap(mode, cmd) this.user.has(mode, cmd)),
+    remove: deprecated("group.mappings.remove", function remove(mode, cmd) this.user.remove(mode, cmd)),
+    removeAll: deprecated("group.mappings.clear", function removeAll(mode) this.user.clear(mode)),
+
+    /**
+     * Adds a new default key mapping.
+     *
+     * @param {number[]} modes The modes that this mapping applies to.
+     * @param {string[]} keys The key sequences which are bound to *action*.
+     * @param {string} description A description of the key mapping.
+     * @param {function} action The action invoked by each key sequence.
+     * @param {Object} extra An optional extra configuration hash.
+     * @optional
+     */
+    add: function add() {
+        let group = this.builtin;
+        if (!util.isDactyl(Components.stack.caller)) {
+            deprecated.warn(add, "mappings.add", "group.mappings.add");
+            group = this.user;
+        }
+
+        let map = group.add.apply(group, arguments);
+        map.definedAt = contexts.getCaller(Components.stack.caller);
+        return map;
+    },
+
+    /**
+     * Adds a new user-defined key mapping.
+     *
+     * @param {number[]} modes The modes that this mapping applies to.
+     * @param {string[]} keys The key sequences which are bound to *action*.
+     * @param {string} description A description of the key mapping.
+     * @param {function} action The action invoked by each key sequence.
+     * @param {Object} extra An optional extra configuration hash (see
+     *     {@link Map#extraInfo}).
+     * @optional
+     */
+    addUserMap: deprecated("group.mappings.add", function addUserMap() {
+        let map = this.user.add.apply(this.user, arguments);
+        map.definedAt = contexts.getCaller(Components.stack.caller);
+        return map;
+    }),
+
+    /**
+     * Returns the map from *mode* named *cmd*.
+     *
+     * @param {number} mode The mode to search.
+     * @param {string} cmd The map name to match.
+     * @returns {Map}
+     */
+    get: function get(mode, cmd) this.hives.map(function (h) h.get(mode, cmd)).compact()[0] || null,
+
+    /**
+     * Returns an array of maps with names starting with but not equal to
+     * *prefix*.
+     *
+     * @param {number} mode The mode to search.
+     * @param {string} prefix The map prefix string to match.
+     * @returns {Map[]}
+     */
+    getCandidates: function (mode, prefix)
+        this.hives.map(function (h) h.getCandidates(mode, prefix))
+                  .flatten(),
+
+    /**
+     * Lists all user-defined mappings matching *filter* for the specified
+     * *modes*.
+     *
+     * @param {number[]} modes An array of modes to search.
+     * @param {string} filter The filter string to match.
+     */
+    list: function (modes, filter, hives) {
+        let modeSign = "";
+        modes.filter(function (m)  m.char).forEach(function (m) { modeSign += m.char; });
+        modes.filter(function (m) !m.char).forEach(function (m) { modeSign += " " + m.name; });
+        modeSign = modeSign.replace(/^ /, "");
+
+        hives = (hives || mappings.userHives).map(function (h) [h, maps(h)])
+                                             .filter(function ([h, m]) m.length);
+
+        function maps(hive) {
+            let maps = iter.toArray(hive.iterate(modes));
+            if (filter)
+                maps = maps.filter(function (m) m.names[0] === filter);
+            return maps;
+        }
+
+        let list = <table>
+                <tr highlight="Title">
+                    <td/>
+                    <td style="padding-right: 1em;">Mode</td>
+                    <td style="padding-right: 1em;">Command</td>
+                    <td style="padding-right: 1em;">Action</td>
+                </tr>
+                <col style="min-width: 6em; padding-right: 1em;"/>
+                {
+                    template.map(hives, function ([hive, maps]) let (i = 0)
+                        <tr style="height: .5ex;"/> +
+                        template.map(maps, function (map)
+                            template.map(map.names, function (name)
+                            <tr>
+                                <td highlight="Title">{!i++ ? hive.name : ""}</td>
+                                <td>{modeSign}</td>
+                                <td>{name}</td>
+                                <td>{map.rhs || map.action.toSource()}</td>
+                            </tr>)) +
+                        <tr style="height: .5ex;"/>)
+                }
+                </table>;
+
+        // TODO: Move this to an ItemList to show this automatically
+        if (list.*.length() === list.text().length() + 2)
+            dactyl.echomsg(_("map.none"));
+        else
+            commandline.commandOutput(list);
+    }
+}, {
+}, {
+    contexts: function initContexts(dactyl, modules, window) {
+        update(Mappings.prototype, {
+            hives: contexts.Hives("mappings", MapHive),
+            user: contexts.hives.mappings.user,
+            builtin: contexts.hives.mappings.builtin
+        });
+    },
+    commands: function initCommands(dactyl, modules, window) {
+        function addMapCommands(ch, mapmodes, modeDescription) {
+            function map(args, noremap) {
+                let mapmodes = array.uniq(args["-modes"].map(findMode));
+                let hives = args.explicitOpts["-group"] ? [args["-group"]] : null;
+
+                if (!args.length) {
+                    mappings.list(mapmodes, null, hives);
+                    return;
+                }
+
+                let [lhs, rhs] = args;
+                if (noremap)
+                    args["-builtin"] = true;
+
+                if (!rhs) // list the mapping
+                    mappings.list(mapmodes, mappings.expandLeader(lhs), hives);
+                else {
+                    util.assert(args["-group"].modifiable,
+                                _("map.builtinImmutable"));
+
+                    args["-group"].add(mapmodes, [lhs],
+                        args["-description"],
+                        contexts.bindMacro(args, "-keys", function (params) params),
+                        {
+                            arg: args["-arg"],
+                            count: args["-count"],
+                            noremap: args["-builtin"],
+                            persist: !args["-nopersist"],
+                            get rhs() String(this.action),
+                            silent: args["-silent"]
+                        });
+                }
+            }
+
+            const opts = {
+                    completer: function (context, args) {
+                        let mapmodes = array.uniq(args["-modes"].map(findMode));
+                        if (args.length == 1)
+                            return completion.userMapping(context, mapmodes, args["-group"]);
+                        if (args.length == 2) {
+                            if (args["-javascript"])
+                                return completion.javascript(context);
+                            if (args["-ex"])
+                                return completion.ex(context);
+                        }
+                    },
+                    hereDoc: true,
+                    literal: 1,
+                    options: [
+                        {
+                            names: ["-arg", "-a"],
+                            description: "Accept an argument after the requisite key press",
+                        },
+                        {
+                            names: ["-builtin", "-b"],
+                            description: "Execute this mapping as if there were no user-defined mappings"
+                        },
+                        {
+                            names: ["-count", "-c"],
+                            description: "Accept a count before the requisite key press"
+                        },
+                        {
+                            names: ["-description", "-desc", "-d"],
+                            description: "A description of this mapping",
+                            default: "User-defined mapping",
+                            type: CommandOption.STRING
+                        },
+                        {
+                            names: ["-ex", "-e"],
+                            description: "Execute this mapping as an Ex command rather than keys"
+                        },
+                        contexts.GroupFlag("mappings"),
+                        {
+                            names: ["-javascript", "-js", "-j"],
+                            description: "Execute this mapping as JavaScript rather than keys"
+                        },
+                        update({}, modeFlag, {
+                            names: ["-modes", "-mode", "-m"],
+                            type: CommandOption.LIST,
+                            description: "Create this mapping in the given modes",
+                            default: mapmodes || ["n", "v"]
+                        }),
+                        {
+                            names: ["-nopersist", "-n"],
+                            description: "Do not save this mapping to an auto-generated RC file"
+                        },
+                        {
+                            names: ["-silent", "-s", "<silent>", "<Silent>"],
+                            description: "Do not echo any generated keys to the command line"
+                        }
+                    ],
+                    serialize: function () {
+                        return this.name != "map" ? [] :
+                            array(mappings.userHives)
+                                .filter(function (h) h.persist)
+                                .map(function (hive) [
+                                    {
+                                        command: "map",
+                                        options: array([
+                                            hive.name !== "user" && ["-group", hive.name],
+                                            ["-modes", uniqueModes(map.modes)],
+                                            ["-description", map.description],
+                                            map.silent && ["-silent"]])
+
+                                            .filter(util.identity)
+                                            .toObject(),
+                                        arguments: [map.names[0]],
+                                        literalArg: map.rhs,
+                                        ignoreDefaults: true
+                                    }
+                                    for (map in userMappings(hive))
+                                    if (map.persist)
+                                ])
+                                .flatten().array;
+                    }
+            };
+            function userMappings(hive) {
+                let seen = {};
+                for (let stack in values(hive.stacks))
+                    for (let map in array.iterValues(stack))
+                        if (!set.add(seen, map.id))
+                            yield map;
+            }
+
+            modeDescription = modeDescription ? " in " + modeDescription + " mode" : "";
+            commands.add([ch ? ch + "m[ap]" : "map"],
+                "Map a key sequence" + modeDescription,
+                function (args) { map(args, false); },
+                update({}, opts));
+
+            commands.add([ch + "no[remap]"],
+                "Map a key sequence without remapping keys" + modeDescription,
+                function (args) { map(args, true); },
+                update({}, opts));
+
+            commands.add([ch + "unm[ap]"],
+                "Remove a mapping" + modeDescription,
+                function (args) {
+                    util.assert(args["-group"].modifiable, _("map.builtinImmutable"));
+
+                    util.assert(args.bang ^ !!args[0], _("error.argumentOrBang"));
+
+                    let mapmodes = array.uniq(args["-modes"].map(findMode));
+
+                    let found = 0;
+                    for (let mode in values(mapmodes))
+                        if (args.bang)
+                            args["-group"].clear(mode);
+                        else if (args["-group"].has(mode, args[0])) {
+                            args["-group"].remove(mode, args[0]);
+                            found++;
+                        }
+
+                    if (!found && !args.bang)
+                        dactyl.echoerr(_("map.noSuch", args[0]));
+                },
+                {
+                    argCount: "?",
+                    bang: true,
+                    completer: opts.completer,
+                    options: [
+                        contexts.GroupFlag("mappings"),
+                        update({}, modeFlag, {
+                            names: ["-modes", "-mode", "-m"],
+                            type: CommandOption.LIST,
+                            description: "Remove mapping from the given modes",
+                            default: mapmodes || ["n", "v"]
+                        })
+                    ]
+                });
+        }
+
+        let modeFlag = {
+            names: ["-mode", "-m"],
+            type: CommandOption.STRING,
+            validator: function (value) Array.concat(value).every(findMode),
+            completer: function () [[array.compact([mode.name.toLowerCase().replace(/_/g, "-"), mode.char]), mode.description]
+                                    for (mode in values(modes.all))
+                                    if (!mode.hidden)],
+        };
+
+        function findMode(name) {
+            if (name)
+                for (let mode in values(modes.all))
+                    if (name == mode || name == mode.char
+                        || String.toLowerCase(name).replace(/-/g, "_") == mode.name.toLowerCase())
+                        return mode;
+            return null;
+        }
+        function uniqueModes(modes) {
+            let chars = [k for ([k, v] in Iterator(modules.modes.modeChars))
+                         if (v.every(function (mode) modes.indexOf(mode) >= 0))];
+            return array.uniq(modes.filter(function (m) chars.indexOf(m.char) < 0).concat(chars));
+        }
+
+        commands.add(["feedkeys", "fk"],
+            "Fake key events",
+            function (args) { events.feedkeys(args[0] || "", args.bang, false, findMode(args["-mode"])); },
+            {
+                argCount: "1",
+                bang: true,
+                literal: 0,
+                options: [
+                    update({}, modeFlag, {
+                        description: "The mode in which to feed the keys"
+                    })
+                ]
+            });
+
+        addMapCommands("", [modes.NORMAL, modes.VISUAL], "");
+
+        for (let mode in modes.mainModes)
+            if (mode.char && !commands.get(mode.char + "map", true))
+                addMapCommands(mode.char,
+                               [m.mask for (m in modes.mainModes) if (m.char == mode.char)],
+                               [mode.name.toLowerCase()]);
+
+        let args = {
+            getMode: function (args) findMode(args["-mode"]),
+            iterate: function (args, mainOnly) {
+                let modes = [this.getMode(args)];
+                if (!mainOnly)
+                    modes = modes.concat(modes[0].bases);
+
+                let seen = {};
+                // Bloody hell. --Kris
+                for (let [i, mode] in Iterator(modes))
+                    for (let hive in mappings.hives.iterValues())
+                        for (let map in array.iterValues(hive.getStack(mode)))
+                            for (let name in values(map.names))
+                                if (!set.add(seen, name)) {
+                                    yield {
+                                        name: name,
+                                        columns: [
+                                            i === 0 ? "" : <span highlight="Object" style="padding-right: 1em;">{mode.name}</span>,
+                                            hive == mappings.builtin ? "" : <span highlight="Object" style="padding-right: 1em;">{hive.name}</span>
+                                        ],
+                                        __proto__: map
+                                    };
+                                }
+            },
+            format: {
+                description: function (map) (XML.ignoreWhitespace = false, XML.prettyPrinting = false, <>
+                        {options.get("passkeys").has(map.name)
+                            ? <span highlight="URLExtra">(passed by {template.helpLink("'passkeys'")})</span>
+                            : <></>}
+                        {template.linkifyHelp(map.description + (map.rhs ? ": " + map.rhs : ""))}
+                </>),
+                help: function (map) let (char = array.compact(map.modes.map(function (m) m.char))[0])
+                    char === "n" ? map.name : char ? char + "_" + map.name : "",
+                headings: ["Command", "Mode", "Group", "Description"]
+            }
+        }
+
+        dactyl.addUsageCommand({
+            __proto__: args,
+            name: ["listk[eys]", "lk"],
+            description: "List all mappings along with their short descriptions",
+            options: [
+                update({}, modeFlag, {
+                    default: "n",
+                    description: "The mode for which to list mappings"
+                })
+            ]
+        });
+
+        iter.forEach(modes.mainModes, function (mode) {
+            if (mode.char && !commands.get(mode.char + "listkeys", true))
+                dactyl.addUsageCommand({
+                    __proto__: args,
+                    name: [mode.char + "listk[eys]", mode.char + "lk"],
+                    iterateIndex: function (args)
+                            let (self = this, prefix = /^[bCmn]$/.test(mode.char) ? "" : mode.char + "_",
+                                 tags = services["dactyl:"].HELP_TAGS)
+                                    ({ helpTag: prefix + map.name, __proto__: map }
+                                     for (map in self.iterate(args, true))
+                                     if (map.hive === mappings.builtin || set.has(tags, prefix + map.name))),
+                    description: "List all " + mode.name + " mode mappings along with their short descriptions",
+                    index: mode.char + "-map",
+                    getMode: function (args) mode,
+                    options: []
+                });
+        });
+    },
+    completion: function initCompletion(dactyl, modules, window) {
+        completion.userMapping = function userMapping(context, modes_, hive) {
+            hive = hive || mappings.user;
+            modes_ = modes_ || [modes.NORMAL];
+            context.keys = { text: function (m) m.names[0], description: function (m) m.description + ": " + m.action };
+            context.completions = hive.iterate(modes_);
+        };
+    },
+    javascript: function initJavascript(dactyl, modules, window) {
+        JavaScript.setCompleter([mappings.get, mappings.builtin.get],
+            [
+                null,
+                function (context, obj, args) [[m.names, m.description] for (m in this.iterate(args[0]))]
+            ]);
+    },
+    options: function initOptions(dactyl, modules, window) {
+        options.add(["mapleader", "ml"],
+            "Define the replacement keys for the <Leader> pseudo-key",
+            "string", "\\", {
+                setter: function (value) {
+                    if (this.hasChanged)
+                        for (let hive in values(mappings.allHives))
+                            for (let stack in values(hive.stacks))
+                                delete stack.states;
+                    return value;
+                }
+            });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/marks.js b/common/content/marks.js
new file mode 100644 (file)
index 0000000..aedbfc0
--- /dev/null
@@ -0,0 +1,304 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/**
+ * @scope modules
+ * @instance marks
+ */
+var Marks = Module("marks", {
+    init: function init() {
+        this._localMarks = storage.newMap("local-marks", { privateData: true, replacer: Storage.Replacer.skipXpcom, store: true });
+        this._urlMarks = storage.newMap("url-marks", { privateData: true, replacer: Storage.Replacer.skipXpcom, store: true });
+
+        try {
+            if (isArray(Iterator(this._localMarks).next()[1]))
+                this._localMarks.clear();
+        }
+        catch (e) {}
+
+        this._pendingJumps = [];
+    },
+
+    /**
+     * @property {Array} Returns all marks, both local and URL, in a sorted
+     *     array.
+     */
+    get all() iter(this._localMarks.get(this.localURI) || {},
+                   this._urlMarks
+                  ).sort(function (a, b) String.localeCompare(a[0], b[0])),
+
+    get localURI() buffer.focusedFrame.document.documentURI,
+
+    /**
+     * Add a named mark for the current buffer, at its current position.
+     * If mark matches [A-Z], it's considered a URL mark, and will jump to
+     * the same position at the same URL no matter what buffer it's
+     * selected from. If it matches [a-z], it's a local mark, and can
+     * only be recalled from a buffer with a matching URL.
+     *
+     * @param {string} mark The mark name.
+     * @param {boolean} silent Whether to output error messages.
+     */
+    add: function (mark, silent) {
+        let win = buffer.focusedFrame;
+        let doc = win.document;
+
+        let position = { x: buffer.scrollXPercent / 100, y: buffer.scrollYPercent / 100 };
+
+        if (Marks.isURLMark(mark)) {
+            let res = this._urlMarks.set(mark, { location: doc.documentURI, position: position, tab: Cu.getWeakReference(tabs.getTab()), timestamp: Date.now()*1000 });
+            if (!silent)
+                dactyl.log("Adding URL mark: " + Marks.markToString(mark, res), 5);
+        }
+        else if (Marks.isLocalMark(mark)) {
+            let marks = this._localMarks.get(doc.documentURI, {});
+            marks[mark] = { location: doc.documentURI, position: position, timestamp: Date.now()*1000 };
+            this._localMarks.changed();
+            if (!silent)
+                dactyl.log("Adding local mark: " + Marks.markToString(mark, marks[mark]), 5);
+        }
+    },
+
+    /**
+     * Remove all marks matching *filter*. If *special* is given, removes all
+     * local marks.
+     *
+     * @param {string} filter The list of marks to delete, e.g. "aA b C-I"
+     * @param {boolean} special Whether to delete all local marks.
+     */
+    remove: function (filter, special) {
+        if (special)
+            this._localMarks.remove(this.localURI);
+        else {
+            let pattern = util.charListToRegexp(filter, "a-zA-Z");
+            let local = this._localMarks.get(this.localURI);
+            this.all.forEach(function ([k, ]) {
+                if (pattern.test(k)) {
+                    local && delete local[k];
+                    marks._urlMarks.remove(k);
+                }
+            });
+            try {
+                Iterator(local).next();
+                this._localMarks.changed();
+            }
+            catch (e) {
+                this._localMarks.remove(this.localURI);
+            }
+        }
+    },
+
+    /**
+     * Jumps to the named mark. See {@link #add}
+     *
+     * @param {string} char The mark to jump to.
+     */
+    jumpTo: function (char) {
+        if (Marks.isURLMark(char)) {
+            let mark = this._urlMarks.get(char);
+            dactyl.assert(mark, _("mark.unset", char));
+
+            let tab = mark.tab && mark.tab.get();
+            if (!tab || !tab.linkedBrowser || tabs.allTabs.indexOf(tab) == -1)
+                for ([, tab] in iter(tabs.visibleTabs, tabs.allTabs)) {
+                    if (tab.linkedBrowser.contentDocument.documentURI === mark.location)
+                        break;
+                    tab = null;
+                }
+
+            if (tab) {
+                tabs.select(tab);
+                let doc = tab.linkedBrowser.contentDocument;
+                if (doc.documentURI == mark.location) {
+                    dactyl.log("Jumping to URL mark: " + Marks.markToString(char, mark), 5);
+                    buffer.scrollToPercent(mark.position.x * 100, mark.position.y * 100);
+                }
+                else {
+                    this._pendingJumps.push(mark);
+
+                    let sh = tab.linkedBrowser.sessionHistory;
+                    let items = array(util.range(0, sh.count));
+
+                    let a = items.slice(0, sh.index).reverse();
+                    let b = items.slice(sh.index);
+                    a.length = b.length = Math.max(a.length, b.length);
+                    items = array(a).zip(b).flatten().compact();
+
+                    for (let i in items.iterValues()) {
+                        let entry = sh.getEntryAtIndex(i, false);
+                        if (entry.URI.spec.replace(/#.*/, "") == mark.location)
+                            return void tab.linkedBrowser.webNavigation.gotoIndex(i);
+                    }
+                    dactyl.open(mark.location);
+                }
+            }
+            else {
+                this._pendingJumps.push(mark);
+                dactyl.open(mark.location, dactyl.NEW_TAB);
+            }
+        }
+        else if (Marks.isLocalMark(char)) {
+            let mark = (this._localMarks.get(this.localURI) || {})[char];
+            dactyl.assert(mark, _("mark.unset", char));
+
+            dactyl.log("Jumping to local mark: " + Marks.markToString(char, mark), 5);
+            buffer.scrollToPercent(mark.position.x * 100, mark.position.y * 100);
+        }
+        else
+            dactyl.echoerr(_("mark.invalid"));
+
+    },
+
+    /**
+     * List all marks matching *filter*.
+     *
+     * @param {string} filter List of marks to show, e.g. "ab A-I".
+     */
+    list: function (filter) {
+        let marks = this.all;
+
+        dactyl.assert(marks.length > 0, _("mark.none"));
+
+        if (filter.length > 0) {
+            let pattern = util.charListToRegexp(filter, "a-zA-Z");
+            marks = marks.filter(function ([k, ]) pattern.test(k));
+            dactyl.assert(marks.length > 0, _("mark.noMatching", filter.quote()));
+        }
+
+        commandline.commandOutput(
+            template.tabular(
+                ["Mark",   "HPos",              "VPos",              "File"],
+                ["",       "text-align: right", "text-align: right", "color: green"],
+                ([mark[0],
+                  Math.round(mark[1].position.x * 100) + "%",
+                  Math.round(mark[1].position.y * 100) + "%",
+                  mark[1].location]
+                  for ([, mark] in Iterator(marks)))));
+    },
+
+    _onPageLoad: function _onPageLoad(event) {
+        let win = event.originalTarget.defaultView;
+        for (let [i, mark] in Iterator(this._pendingJumps)) {
+            if (win && win.location.href == mark.location) {
+                buffer.scrollToPercent(mark.position.x * 100, mark.position.y * 100);
+                this._pendingJumps.splice(i, 1);
+                return;
+            }
+        }
+    },
+}, {
+    markToString: function markToString(name, mark) {
+        let tab = mark.tab && mark.tab.get();
+        return name + ", " + mark.location +
+                ", (" + Math.round(mark.position.x * 100) +
+                "%, " + Math.round(mark.position.y * 100) + "%)" +
+                (tab ? ", tab: " + tabs.index(tab) : "");
+    },
+
+    isLocalMark: function isLocalMark(mark) /^[a-z`']$/.test(mark),
+
+    isURLMark: function isURLMark(mark) /^[A-Z]$/.test(mark)
+}, {
+    events: function () {
+        let appContent = document.getElementById("appcontent");
+        if (appContent)
+            events.listen(appContent, "load", marks.closure._onPageLoad, true);
+    },
+    mappings: function () {
+        var myModes = config.browserModes;
+
+        mappings.add(myModes,
+            ["m"], "Set mark at the cursor position",
+            function ({ arg }) {
+                dactyl.assert(/^[a-zA-Z]$/.test(arg), _("mark.invalid"));
+                marks.add(arg);
+            },
+            { arg: true });
+
+        mappings.add(myModes,
+            ["'", "`"], "Jump to the mark in the current buffer",
+            function ({ arg }) { marks.jumpTo(arg); },
+            { arg: true });
+    },
+
+    commands: function () {
+        commands.add(["delm[arks]"],
+            "Delete the specified marks",
+            function (args) {
+                let special = args.bang;
+                let arg = args[0] || "";
+
+                // assert(special ^ args)
+                dactyl.assert( special ||  arg, _("error.argumentRequired"));
+                dactyl.assert(!special || !arg, _("error.invalidArgument"));
+
+                marks.remove(arg, special);
+            },
+            {
+                bang: true,
+                completer: function (context) completion.mark(context),
+                literal: 0
+            });
+
+        commands.add(["ma[rk]"],
+            "Mark current location within the web page",
+            function (args) {
+                let mark = args[0] || "";
+                dactyl.assert(mark.length <= 1, _("error.trailing"));
+                dactyl.assert(/[a-zA-Z]/.test(mark), _("mark.invalid"));
+
+                marks.add(mark);
+            },
+            { argCount: "1" });
+
+        commands.add(["marks"],
+            "Show the specified marks",
+            function (args) {
+                marks.list(args[0] || "");
+            }, {
+                completer: function (context) completion.mark(context),
+                literal: 0
+            });
+    },
+
+    completion: function () {
+        completion.mark = function mark(context) {
+            function percent(i) Math.round(i * 100);
+
+            // FIXME: Line/Column doesn't make sense with %
+            context.title = ["Mark", "HPos VPos File"];
+            context.keys.description = function ([, m]) percent(m.position.x) + "% " + percent(m.position.y) + "% " + m.location;
+            context.completions = marks.all;
+        };
+    },
+    sanitizer: function () {
+        sanitizer.addItem("marks", {
+            description: "Local and URL marks",
+            persistent: true,
+            contains: ["history"],
+            action: function (timespan, host) {
+                function matchhost(url) !host || util.isDomainURL(url, host);
+                function match(marks) (k for ([k, v] in Iterator(marks)) if (timespan.contains(v.timestamp) && matchhost(v.location)));
+
+                for (let [url, local] in marks._localMarks)
+                    if (matchhost(url)) {
+                        for (let key in match(local))
+                            delete local[key];
+                        if (!Object.keys(local).length)
+                            marks._localMarks.remove(url);
+                    }
+                marks._localMarks.changed();
+
+                for (let key in match(marks._urlMarks))
+                    marks._urlMarks.remove(key);
+            }
+        });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/modes.js b/common/content/modes.js
new file mode 100644 (file)
index 0000000..55fa19f
--- /dev/null
@@ -0,0 +1,554 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+var Modes = Module("modes", {
+    init: function init() {
+        this.modeChars = {};
+        this._main = 1;     // NORMAL
+        this._extended = 0; // NONE
+
+        this._lastShown = null;
+
+        this._passNextKey = false;
+        this._passAllKeys = false;
+        this._recording = false;
+        this._replaying = false; // playing a macro
+
+        this._modeStack = update([], {
+            pop: function pop() {
+                if (this.length <= 1)
+                    throw Error("Trying to pop last element in mode stack");
+                return pop.superapply(this, arguments);
+            }
+        });
+
+        this._modes = [];
+        this._mainModes = [];
+        this._modeMap = {};
+
+        this.boundProperties = {};
+
+        this.addMode("BASE", {
+            char: "b",
+            description: "The base mode for all other modes",
+            bases: [],
+            count: false
+        });
+        this.addMode("MAIN", {
+            char: "m",
+            description: "The base mode for most other modes",
+            bases: [this.BASE],
+            count: false
+        });
+        this.addMode("COMMAND", {
+            char: "C",
+            description: "The base mode for most modes which accept commands rather than input"
+        });
+
+        this.addMode("NORMAL", {
+            char: "n",
+            description: "Active when nothing is focused",
+            bases: [this.COMMAND]
+        });
+        this.addMode("VISUAL", {
+            char: "v",
+            description: "Active when text is selected",
+            display: function () "VISUAL" + (this._extended & modes.LINE ? " LINE" : ""),
+            bases: [this.COMMAND],
+            ownsFocus: true,
+            passUnknown: false
+        }, {
+            leave: function (stack, newMode) {
+                if (newMode.main == modes.CARET) {
+                    let selection = content.getSelection();
+                    if (selection && !selection.isCollapsed)
+                        selection.collapseToStart();
+                }
+                else if (stack.pop)
+                    editor.unselectText();
+            }
+        });
+        this.addMode("CARET", {
+            description: "Active when the caret is visible in the web content",
+            bases: [this.COMMAND]
+        }, {
+
+            get pref()    prefs.get("accessibility.browsewithcaret"),
+            set pref(val) prefs.set("accessibility.browsewithcaret", val),
+
+            enter: function (stack) {
+                if (stack.pop && !this.pref)
+                    modes.pop();
+                else if (!stack.pop && !this.pref)
+                    this.pref = true;
+            },
+
+            leave: function (stack) {
+                if (!stack.push && this.pref)
+                    this.pref = false;
+            }
+        });
+        this.addMode("TEXT_EDIT", {
+            char: "t",
+            description: "Vim-like editing of input elements",
+            bases: [this.COMMAND],
+            input: true,
+            ownsFocus: true,
+            passUnknown: false
+        });
+        this.addMode("OUTPUT_MULTILINE", {
+            description: "Active when the multi-line output buffer is open",
+            bases: [this.COMMAND],
+        });
+
+        this.addMode("INPUT", {
+            char: "I",
+            description: "The base mode for input modes, including Insert and Command Line",
+            bases: [this.MAIN],
+            insert: true
+        });
+        this.addMode("INSERT", {
+            char: "i",
+            description: "Active when an input element is focused",
+            insert: true,
+            ownsFocus: true
+        });
+        this.addMode("AUTOCOMPLETE", {
+            description: "Active when an input autocomplete pop-up is active",
+            display: function () "AUTOCOMPLETE (insert)",
+            bases: [this.INSERT]
+        });
+
+        this.addMode("EMBED", {
+            description: "Active when an <embed> or <object> element is focused",
+            insert: true,
+            ownsFocus: true,
+            passthrough: true
+        });
+
+        this.addMode("PASS_THROUGH", {
+            description: "All keys but <C-v> are ignored by " + config.appName,
+            bases: [this.BASE],
+            hidden: true,
+            insert: true,
+            passthrough: true
+        });
+        this.addMode("QUOTE", {
+            description: "The next key sequence is ignored by " + config.appName + ", unless in Pass Through mode",
+            bases: [this.BASE],
+            hidden: true,
+            passthrough: true,
+            display: function ()
+                (modes.getStack(1).main == modes.PASS_THROUGH
+                    ? (modes.getStack(2).main.display() || modes.getStack(2).main.name)
+                    : "PASS THROUGH") + " (next)"
+        }, {
+            // Fix me.
+            preExecute: function (map) { if (modes.main == modes.QUOTE && map.name !== "<C-v>") modes.pop(); },
+            postExecute: function (map) { if (modes.main == modes.QUOTE && map.name === "<C-v>") modes.pop(); },
+            onKeyPress: function (events) { if (modes.main == modes.QUOTE) modes.pop(); }
+        });
+        this.addMode("IGNORE", { hidden: true }, {
+            onKeyPress: function (events) Events.KILL,
+            bases: [],
+            passthrough: true
+        });
+
+        this.addMode("MENU", {
+            description: "Active when a menu or other pop-up is open",
+            input: true,
+            passthrough: true,
+            ownsInput: false
+        }, {
+            leave: function leave(stack) {
+                util.timeout(function () {
+                    if (stack.pop && !modes.main.input && Events.isInputElement(dactyl.focusedElement))
+                        modes.push(modes.INSERT);
+                });
+            }
+        });
+
+        this.addMode("LINE", {
+            extended: true, hidden: true
+        });
+
+        this.push(this.NORMAL, 0, {
+            enter: function (stack, prev) {
+                if (prefs.get("accessibility.browsewithcaret"))
+                    prefs.set("accessibility.browsewithcaret", false);
+
+                statusline.updateStatus();
+                if (!stack.fromFocus && prev.main.ownsFocus)
+                    dactyl.focusContent(true);
+                if (prev.main == modes.NORMAL) {
+                    dactyl.focusContent(true);
+                    for (let frame in values(buffer.allFrames())) {
+                        // clear any selection made
+                        let selection = frame.getSelection();
+                        if (selection && !selection.isCollapsed)
+                            selection.collapseToStart();
+                    }
+                }
+
+            }
+        });
+    },
+    cleanup: function cleanup() {
+        modes.reset();
+    },
+
+    _getModeMessage: function _getModeMessage() {
+        // when recording a macro
+        let macromode = "";
+        if (this.recording)
+            macromode = "recording";
+        else if (this.replaying)
+            macromode = "replaying";
+
+        let val = this._modeMap[this._main].display();
+        if (val)
+            return "-- " + val + " --" + macromode;;
+        return macromode;
+    },
+
+    NONE: 0,
+
+    __iterator__: function __iterator__() array.iterValues(this.all),
+
+    get all() this._modes.slice(),
+
+    get mainModes() (mode for ([k, mode] in Iterator(modes._modeMap)) if (!mode.extended && mode.name == k)),
+
+    get mainMode() this._modeMap[this._main],
+
+    get passThrough() !!(this.main & (this.PASS_THROUGH|this.QUOTE)) ^ (this.getStack(1).main === this.PASS_THROUGH),
+
+    get topOfStack() this._modeStack[this._modeStack.length - 1],
+
+    addMode: function addMode(name, options, params) {
+        let mode = Modes.Mode(name, options, params);
+
+        this[name] = mode;
+        if (mode.char)
+            this.modeChars[mode.char] = (this.modeChars[mode.char] || []).concat(mode);
+        this._modeMap[name] = mode;
+        this._modeMap[mode] = mode;
+
+        this._modes.push(mode);
+        if (!mode.extended)
+            this._mainModes.push(mode);
+
+        dactyl.triggerObserver("mode-add", mode);
+    },
+
+    dumpStack: function dumpStack() {
+        util.dump("Mode stack:");
+        for (let [i, mode] in array.iterItems(this._modeStack))
+            util.dump("    " + i + ": " + mode);
+    },
+
+    getMode: function getMode(name) this._modeMap[name],
+
+    getStack: function getStack(idx) this._modeStack[this._modeStack.length - idx - 1] || this._modeStack[0],
+
+    get stack() this._modeStack.slice(),
+
+    getCharModes: function getCharModes(chr) (this.modeChars[chr] || []).slice(),
+
+    have: function have(mode) this._modeStack.some(function (m) isinstance(m.main, mode)),
+
+    matchModes: function matchModes(obj)
+        this._modes.filter(function (mode) Object.keys(obj)
+                                                 .every(function (k) obj[k] == (mode[k] || false))),
+
+    // show the current mode string in the command line
+    show: function show() {
+        let msg = null;
+        if (options.get("showmode").getKey(this.main.name, true))
+            msg = this._getModeMessage();
+        if (msg || loaded.commandline)
+            commandline.widgets.mode = msg || null;
+    },
+
+    remove: function remove(mode, covert) {
+        if (covert && this.topOfStack.main != mode) {
+            util.assert(mode != this.NORMAL);
+            for (let m; m = array.nth(this.modeStack, function (m) m.main == mode, 0);)
+                this._modeStack.splice(this._modeStack.indexOf(m));
+        }
+        else if (this.stack.some(function (m) m.main == mode)) {
+            this.pop(mode);
+            this.pop();
+        }
+    },
+
+    delayed: [],
+    delay: function delay(callback, self) { this.delayed.push([callback, self]); },
+
+    save: function save(id, obj, prop, test) {
+        if (!(id in this.boundProperties))
+            for (let elem in array.iterValues(this._modeStack))
+                elem.saved[id] = { obj: obj, prop: prop, value: obj[prop], test: test };
+        this.boundProperties[id] = { obj: Cu.getWeakReference(obj), prop: prop, test: test };
+    },
+
+    inSet: false,
+
+    // helper function to set both modes in one go
+    set: function set(mainMode, extendedMode, params, stack) {
+        var delayed, oldExtended, oldMain, prev, push;
+
+        if (this.inSet) {
+            dactyl.reportError(Error(_("mode.recursiveSet")), true);
+            return;
+        }
+
+        params = params || this.getMode(mainMode || this.main).params;
+
+        if (!stack && mainMode != null && this._modeStack.length > 1)
+            this.reset();
+
+        this.withSavedValues(["inSet"], function set() {
+            this.inSet = true;
+
+            oldMain = this._main, oldExtended = this._extended;
+
+            if (extendedMode != null)
+                this._extended = extendedMode;
+            if (mainMode != null) {
+                this._main = mainMode;
+                if (!extendedMode)
+                    this._extended = this.NONE;
+            }
+
+            if (stack && stack.pop && stack.pop.params.leave)
+                dactyl.trapErrors("leave", stack.pop.params,
+                                  stack, this.topOfStack);
+
+            push = mainMode != null && !(stack && stack.pop) &&
+                Modes.StackElement(this._main, this._extended, params, {});
+
+            if (push && this.topOfStack) {
+                if (this.topOfStack.params.leave)
+                    dactyl.trapErrors("leave", this.topOfStack.params,
+                                      { push: push }, push);
+
+                for (let [id, { obj, prop, test }] in Iterator(this.boundProperties)) {
+                    if (!obj.get())
+                        delete this.boundProperties[id];
+                    else
+                        this.topOfStack.saved[id] = { obj: obj.get(), prop: prop, value: obj.get()[prop], test: test };
+                }
+            }
+
+            delayed = this.delayed;
+            this.delayed = [];
+
+            prev = stack && stack.pop || this.topOfStack;
+            if (push)
+                this._modeStack.push(push);
+
+            if (stack && stack.pop)
+                for (let { obj, prop, value, test } in values(this.topOfStack.saved))
+                    if (!test || !test(stack, prev))
+                        dactyl.trapErrors(function () { obj[prop] = value });
+
+            this.show();
+        });
+
+        delayed.forEach(function ([fn, self]) dactyl.trapErrors(fn, self));
+
+        if (this.topOfStack.params.enter && prev)
+            dactyl.trapErrors("enter", this.topOfStack.params,
+                              push ? { push: push } : stack || {},
+                              prev);
+
+        dactyl.triggerObserver("modeChange", [oldMain, oldExtended], [this._main, this._extended], stack);
+        this.show();
+    },
+
+    onCaretChange: function onPrefChange(value) {
+        if (!value && modes.main == modes.CARET)
+            modes.pop();
+        if (value && modes.main == modes.NORMAL)
+            modes.push(modes.CARET);
+    },
+
+    push: function push(mainMode, extendedMode, params) {
+        this.set(mainMode, extendedMode, params, { push: this.topOfStack });
+    },
+
+    pop: function pop(mode, args) {
+        while (this._modeStack.length > 1 && this.main != mode) {
+            let a = this._modeStack.pop();
+            this.set(this.topOfStack.main, this.topOfStack.extended, this.topOfStack.params,
+                     update({ pop: a }, args || {}));
+
+            if (mode == null)
+                return;
+        }
+    },
+
+    replace: function replace(mode, oldMode) {
+        while (oldMode && this._modeStack.length > 1 && this.main != oldMode)
+            this.pop();
+
+        if (this._modeStack.length > 1)
+            this.set(mode, null, null, { push: this.topOfStack, pop: this._modeStack.pop() });
+        this.push(mode);
+    },
+
+    reset: function reset() {
+        if (this._modeStack.length == 1 && this.topOfStack.params.enter)
+            this.topOfStack.params.enter({}, this.topOfStack);
+        while (this._modeStack.length > 1)
+            this.pop();
+    },
+
+    get recording() this._recording,
+    set recording(value) { this._recording = value; this.show(); },
+
+    get replaying() this._replaying,
+    set replaying(value) { this._replaying = value; this.show(); },
+
+    get main() this._main,
+    set main(value) { this.set(value); },
+
+    get extended() this._extended,
+    set extended(value) { this.set(null, value); }
+}, {
+    Mode: Class("Mode", {
+        init: function init(name, options, params) {
+            if (options.bases)
+                util.assert(options.bases.every(function (m) m instanceof this, this.constructor),
+                            "Invalid bases", true);
+
+            update(this, {
+                id: 1 << Modes.Mode._id++,
+                name: name,
+                params: params || {}
+            }, options);
+        },
+
+        isinstance: function isinstance(obj)
+            this === obj || this.allBases.indexOf(obj) >= 0 || callable(obj) && this instanceof obj,
+
+        allBases: Class.memoize(function () {
+            let seen = {}, res = [], queue = this.bases;
+            for (let mode in array.iterValues(queue))
+                if (!set.add(seen, mode)) {
+                    res.push(mode);
+                    queue.push.apply(queue, mode.bases);
+                }
+            return res;
+        }),
+
+        get bases() this.input ? [modes.INPUT] : [modes.MAIN],
+
+        get count() !this.insert,
+
+        get description() this._display,
+
+        _display: Class.memoize(function _display() this.name.replace("_", " ", "g")),
+
+        display: function display() this._display,
+
+        extended: false,
+
+        hidden: false,
+
+        input: Class.memoize(function input() this.insert || this.bases.length && this.bases.some(function (b) b.input)),
+
+        insert: Class.memoize(function insert() this.bases.length && this.bases.some(function (b) b.insert)),
+
+        ownsFocus: Class.memoize(function ownsFocus() this.bases.length && this.bases.some(function (b) b.ownsFocus)),
+
+        get passUnknown() this.input,
+
+        get mask() this,
+
+        get toStringParams() [this.name],
+
+        valueOf: function valueOf() this.id
+    }, {
+        _id: 0
+    }),
+    StackElement: (function () {
+        const StackElement = Struct("main", "extended", "params", "saved");
+        StackElement.className = "Modes.StackElement";
+        StackElement.defaultValue("params", function () this.main.params);
+
+        update(StackElement.prototype, {
+            get toStringParams() !loaded.modes ? this.main.name : [
+                this.main.name,
+                <>({ modes.all.filter(function (m) this.extended & m, this).map(function (m) m.name).join("|") })</>
+            ]
+        });
+        return StackElement;
+    })(),
+    cacheId: 0,
+    boundProperty: function BoundProperty(desc) {
+        let id = this.cacheId++;
+        let value;
+
+        desc = desc || {};
+        return Class.Property(update({
+            configurable: true,
+            enumerable: true,
+            init: function bound_init(prop) update(this, {
+                get: function bound_get() {
+                    if (desc.get)
+                        var val = desc.get.call(this, value);
+                    return val === undefined ? value : val;
+                },
+                set: function bound_set(val) {
+                    modes.save(id, this, prop, desc.test);
+                    if (desc.set)
+                        value = desc.set.call(this, val);
+                    value = !desc.set || value === undefined ? val : value;
+                }
+            })
+        }, desc));
+    }
+}, {
+    mappings: function initMappings() {
+        mappings.add([modes.BASE, modes.NORMAL],
+            ["<Esc>", "<C-[>"],
+            "Return to NORMAL mode",
+            function () { modes.reset(); });
+
+        mappings.add([modes.INPUT, modes.COMMAND, modes.PASS_THROUGH, modes.QUOTE],
+            ["<Esc>", "<C-[>"],
+            "Return to the previous mode",
+            function () { modes.pop(); });
+
+        mappings.add([modes.MENU], ["<Esc>"],
+            "Close the current popup",
+            function () {
+                modes.pop();
+                return Events.PASS_THROUGH;
+            });
+
+        mappings.add([modes.MENU], ["<C-[>"],
+            "Close the current popup",
+            function () { events.feedkeys("<Esc>"); });
+    },
+    options: function initOptions() {
+        options.add(["showmode", "smd"],
+            "Show the current mode in the command line when it matches this expression",
+            "regexplist", "!^normal$",
+            { regexpFlags: "i" });
+    },
+    prefs: function initPrefs() {
+        prefs.watch("accessibility.browsewithcaret", function () modes.onCaretChange.apply(modes, arguments));
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/mow.js b/common/content/mow.js
new file mode 100644 (file)
index 0000000..9ace65a
--- /dev/null
@@ -0,0 +1,395 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+var MOW = Module("mow", {
+    init: function init() {
+
+        this._resize = Timer(20, 400, function _resize() {
+            if (this.visible)
+                this.resize(false);
+
+            if (this.visible && isinstance(modes.main, modes.OUTPUT_MULTILINE))
+                this.updateMorePrompt();
+        }, this);
+
+        this._timer = Timer(20, 400, function _timer() {
+            if (modes.have(modes.OUTPUT_MULTILINE)) {
+                this.resize(true);
+
+                if (options["more"] && this.isScrollable(1)) {
+                    // start the last executed command's output at the top of the screen
+                    let elements = this.document.getElementsByClassName("ex-command-output");
+                    elements[elements.length - 1].scrollIntoView(true);
+                }
+                else
+                    this.body.scrollTop = this.body.scrollHeight;
+
+                dactyl.focus(this.window);
+                this.updateMorePrompt();
+            }
+        }, this);
+
+        events.listen(window, this, "windowEvents");
+
+        modules.mow = this;
+        let fontSize = util.computedStyle(document.documentElement).fontSize;
+        styles.system.add("font-size", "dactyl://content/buffer.xhtml",
+                          "body { font-size: " + fontSize + "; } \
+                           html|html > xul|scrollbar { visibility: collapse !important; }",
+                          true);
+
+        XML.ignoreWhitespace = true;
+        util.overlayWindow(window, {
+            objects: {
+                eventTarget: this
+            },
+            append: <e4x xmlns={XUL} xmlns:dactyl={NS}>
+                <window id={document.documentElement.id}>
+                    <popupset>
+                        <menupopup id="dactyl-contextmenu" highlight="Events" events="contextEvents">
+                            <menuitem id="dactyl-context-copylink"
+                                      label="Copy Link Location" dactyl:group="link"
+                                      oncommand="goDoCommand('cmd_copyLink');"/>
+                            <menuitem id="dactyl-context-copypath"
+                                      label="Copy File Path" dactyl:group="link path"
+                                      oncommand="dactyl.clipboardWrite(document.popupNode.getAttribute('path'));"/>
+                            <menuitem id="dactyl-context-copy"
+                                      label="Copy" dactyl:group="selection"
+                                      command="cmd_copy"/>
+                            <menuitem id="dactyl-context-selectall"
+                                      label="Select All"
+                                      command="cmd_selectAll"/>
+                        </menupopup>
+                    </popupset>
+                </window>
+                <vbox id={config.commandContainer}>
+                    <vbox class="dactyl-container" id="dactyl-multiline-output-container" hidden="false" collapsed="true">
+                        <iframe id="dactyl-multiline-output" src="dactyl://content/buffer.xhtml"
+                                flex="1" hidden="false" collapsed="false" contextmenu="dactyl-contextmenu"
+                                highlight="Events" />
+                    </vbox>
+                </vbox>
+            </e4x>
+        });
+    },
+
+    __noSuchMethod__: function (meth, args) Buffer[meth].apply(Buffer, [this.body].concat(args)),
+
+    get widget() this.widgets.multilineOutput,
+    widgets: Class.memoize(function widgets() commandline.widgets),
+
+    body: Class.memoize(function body() this.widget.contentDocument.documentElement),
+    get document() this.widget.contentDocument,
+    get window() this.widget.contentWindow,
+
+    /**
+     * Display a multi-line message.
+     *
+     * @param {string} data
+     * @param {string} highlightGroup
+     */
+    echo: function echo(data, highlightGroup, silent) {
+        let body = this.document.body;
+
+        this.widgets.message = null;
+        if (!commandline.commandVisible)
+            commandline.hide();
+
+        if (modes.main != modes.OUTPUT_MULTILINE) {
+            modes.push(modes.OUTPUT_MULTILINE, null, {
+                onKeyPress: this.closure.onKeyPress,
+                leave: this.closure(function leave(stack) {
+                    if (stack.pop)
+                        for (let message in values(this.messages))
+                            if (message.leave)
+                                message.leave(stack);
+                })
+            });
+            this.messages = [];
+        }
+
+        // If it's already XML, assume it knows what it's doing.
+        // Otherwise, white space is significant.
+        // The problem elsewhere is that E4X tends to insert new lines
+        // after interpolated data.
+        XML.ignoreWhitespace = XML.prettyPrinting = false;
+
+        if (isObject(data) && !isinstance(data, _)) {
+            this.lastOutput = null;
+
+            var output = util.xmlToDom(<div class="ex-command-output" style="white-space: nowrap" highlight={highlightGroup}/>,
+                                       this.document);
+            data.document = this.document;
+            try {
+                output.appendChild(data.message);
+            }
+            catch (e) {
+                util.reportError(e);
+                util.dump(data);
+                this.messages.push(data);
+            }
+        }
+        else {
+            let style = isString(data) ? "pre" : "nowrap";
+            this.lastOutput = <div class="ex-command-output" style={"white-space: " + style} highlight={highlightGroup}>{data}</div>;
+
+            var output = util.xmlToDom(this.lastOutput, this.document);
+        }
+
+        // FIXME: need to make sure an open MOW is closed when commands
+        //        that don't generate output are executed
+        if (!this.visible) {
+            this.body.scrollTop = 0;
+            body.textContent = "";
+        }
+
+        body.appendChild(output);
+
+        let str = typeof data !== "xml" && data.message || data;
+        if (!silent)
+            dactyl.triggerObserver("echoMultiline", data, highlightGroup, output);
+
+        this._timer.tell();
+        if (!this.visible)
+            this._timer.flush();
+    },
+
+    events: {
+        click: function onClick(event) {
+            if (event.getPreventDefault())
+                return;
+
+            const openLink = function openLink(where) {
+                event.preventDefault();
+                dactyl.open(event.target.href, where);
+            };
+
+            if (event.target instanceof HTMLAnchorElement)
+                switch (events.toString(event)) {
+                case "<LeftMouse>":
+                    openLink(dactyl.CURRENT_TAB);
+                    break;
+                case "<MiddleMouse>":
+                case "<C-LeftMouse>":
+                case "<C-M-LeftMouse>":
+                    openLink({ where: dactyl.NEW_TAB, background: true });
+                    break;
+                case "<S-MiddleMouse>":
+                case "<C-S-LeftMouse>":
+                case "<C-M-S-LeftMouse>":
+                    openLink({ where: dactyl.NEW_TAB, background: false });
+                    break;
+                case "<S-LeftMouse>":
+                    openLink(dactyl.NEW_WINDOW);
+                    break;
+                }
+        },
+        unload: function onUnload(event) {
+            event.preventDefault();
+        }
+    },
+
+    windowEvents: {
+        resize: function onResize(event) {
+            this._resize.tell();
+        }
+    },
+
+    contextEvents: {
+        popupshowing: function onPopupShowing(event) {
+            let menu = commandline.widgets.contextMenu;
+            let enabled = {
+                link: window.document.popupNode instanceof HTMLAnchorElement,
+                path: window.document.popupNode.hasAttribute("path"),
+                selection: !window.document.commandDispatcher.focusedWindow.getSelection().isCollapsed
+            };
+
+            for (let node in array.iterValues(menu.children)) {
+                let group = node.getAttributeNS(NS, "group");
+                node.hidden = group && !group.split(/\s+/).every(function (g) enabled[g]);
+            }
+        }
+    },
+
+    onKeyPress: function onKeyPress(eventList) {
+        const KILL = false, PASS = true;
+
+        if (options["more"] && mow.isScrollable(1))
+            this.updateMorePrompt(false, true);
+        else {
+            modes.pop();
+            events.feedevents(null, eventList);
+            return KILL;
+        }
+        return PASS;
+    },
+
+    /**
+     * Changes the height of the message window to fit in the available space.
+     *
+     * @param {boolean} open If true, the widget will be opened if it's not
+     *     already so.
+     */
+    resize: function resize(open, extra) {
+        if (!(open || this.visible))
+            return;
+
+        let doc = this.widget.contentDocument;
+
+        let availableHeight = config.outputHeight;
+        if (this.visible)
+            availableHeight += parseFloat(this.widgets.mowContainer.height || 0);
+        availableHeight -= extra || 0;
+
+        doc.body.style.minWidth = this.widgets.commandbar.commandline.scrollWidth + "px";
+        this.widgets.mowContainer.height = Math.min(doc.body.clientHeight, availableHeight) + "px";
+        this.timeout(function ()
+            this.widgets.mowContainer.height = Math.min(doc.body.clientHeight, availableHeight) + "px",
+            0);
+
+        doc.body.style.minWidth = "";
+
+        this.visible = true;
+    },
+
+    get spaceNeeded() {
+        let rect = this.widgets.commandbar.commandline.getBoundingClientRect();
+        let offset = rect.bottom - window.innerHeight;
+        return Math.max(0, offset);
+    },
+
+    /**
+     * Update or remove the multi-line output widget's "MORE" prompt.
+     *
+     * @param {boolean} force If true, "-- More --" is shown even if we're
+     *     at the end of the output.
+     * @param {boolean} showHelp When true, show the valid key sequences
+     *     and what they do.
+     */
+    updateMorePrompt: function updateMorePrompt(force, showHelp) {
+        if (!this.visible || !isinstance(modes.main, modes.OUTPUT_MULTILINE))
+            return this.widgets.message = null;
+
+        let elem = this.widget.contentDocument.documentElement;
+
+        if (showHelp)
+            this.widgets.message = ["MoreMsg", "-- More -- SPACE/<C-f>/j: screen/page/line down, <C-b>/<C-u>/k: up, q: quit"];
+        else if (force || (options["more"] && Buffer.isScrollable(elem, 1)))
+            this.widgets.message = ["MoreMsg", "-- More --"];
+        else
+            this.widgets.message = ["Question", "Press ENTER or type command to continue"];
+    },
+
+    visible: Modes.boundProperty({
+        get: function get_mowVisible() !this.widgets.mowContainer.collapsed,
+        set: function set_mowVisible(value) {
+            this.widgets.mowContainer.collapsed = !value;
+
+            let elem = this.widget;
+            if (!value && elem && elem.contentWindow == document.commandDispatcher.focusedWindow) {
+
+                let focused = content.document.activeElement;
+                if (Events.isInputElement(focused))
+                    focused.blur();
+
+                document.commandDispatcher.focusedWindow = content;
+            }
+        }
+    })
+}, {
+}, {
+    mappings: function initMappings() {
+        const PASS = true;
+        const DROP = false;
+        const BEEP = {};
+
+        mappings.add([modes.COMMAND],
+            ["g<lt>"], "Redisplay the last command output",
+            function () {
+                dactyl.assert(commandline.lastOutput, _("mow.noPreviousOutput"));
+                mow.echo(mow.lastOutput, "Normal");
+            });
+
+        let bind = function bind(keys, description, action, test, default_) {
+            mappings.add([modes.OUTPUT_MULTILINE],
+                keys, description,
+                function (args) {
+                    if (!options["more"])
+                        var res = PASS;
+                    else if (test && !test(args))
+                        res = default_;
+                    else
+                        res = action.call(this, args);
+
+                    if (res === PASS || res === DROP)
+                        modes.pop();
+                    else
+                        mow.updateMorePrompt();
+                    if (res === BEEP)
+                        dactyl.beep();
+                    else if (res === PASS)
+                        events.feedkeys(args.command);
+                }, {
+                    count: action.length > 0
+                });
+        };
+
+        bind(["j", "<C-e>", "<Down>"], "Scroll down one line",
+             function ({ count }) { mow.scrollVertical("lines", 1 * (count || 1)); },
+             function () mow.isScrollable(1), BEEP);
+
+        bind(["k", "<C-y>", "<Up>"], "Scroll up one line",
+             function ({ count }) { mow.scrollVertical("lines", -1 * (count || 1)); },
+             function () mow.isScrollable(-1), BEEP);
+
+        bind(["<C-j>", "<C-m>", "<Return>"], "Scroll down one line, exit on last line",
+             function ({ count }) { mow.scrollVertical("lines", 1 * (count || 1)); },
+             function () mow.isScrollable(1), DROP);
+
+        // half page down
+        bind(["<C-d>"], "Scroll down half a page",
+             function ({ count }) { mow.scrollVertical("pages", .5 * (count || 1)); },
+             function () mow.isScrollable(1), BEEP);
+
+        bind(["<C-f>", "<PageDown>"], "Scroll down one page",
+             function ({ count }) { mow.scrollVertical("pages", 1 * (count || 1)); },
+             function () mow.isScrollable(1), BEEP);
+
+        bind(["<Space>"], "Scroll down one page",
+             function ({ count }) { mow.scrollVertical("pages", 1 * (count || 1)); },
+             function () mow.isScrollable(1), DROP);
+
+        bind(["<C-u>"], "Scroll up half a page",
+             function ({ count }) { mow.scrollVertical("pages", -.5 * (count || 1)); },
+             function () mow.isScrollable(-1), BEEP);
+
+        bind(["<C-b>", "<PageUp>"], "Scroll up half a page",
+             function ({ count }) { mow.scrollVertical("pages", -1 * (count || 1)); },
+             function () mow.isScrollable(-1), BEEP);
+
+        bind(["gg"], "Scroll to the beginning of output",
+             function () { mow.scrollToPercent(null, 0); });
+
+        bind(["G"], "Scroll to the end of output",
+             function ({ count }) { mow.scrollToPercent(null, count || 100); });
+
+        // copy text to clipboard
+        bind(["<C-y>"], "Yank selection to clipboard",
+             function () { dactyl.clipboardWrite(buffer.getCurrentWord(mow.window)); });
+
+        // close the window
+        bind(["q"], "Close the output window",
+             function () {},
+             function () false, DROP);
+    },
+    options: function initOptions() {
+        options.add(["more"],
+            "Pause the message list window when the full output will not fit on one page",
+            "boolean", true);
+    }
+});
diff --git a/common/content/preferences.xul b/common/content/preferences.xul
new file mode 100644 (file)
index 0000000..4fd422b
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+    <script type="application/javascript;version=1.8">
+        let uri = Components.classes["@mozilla.org/network/io-service;1"]
+                            .getService(Components.interfaces.nsIIOService)
+                            .newURI("dactyl://help/options", null, null);
+        Application.activeWindow.open(uri).focus(); // TODO: generalise for Teledactyl et al.
+        window.close();
+    </script>
+</window>
+
+<!-- vim: set fdm=marker sw=4 ts=4 et: -->
diff --git a/common/content/quickmarks.js b/common/content/quickmarks.js
new file mode 100644 (file)
index 0000000..c12e768
--- /dev/null
@@ -0,0 +1,207 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+/**
+* @instance quickmarks
+*/
+var QuickMarks = Module("quickmarks", {
+    init: function () {
+        this._qmarks = storage.newMap("quickmarks", { store: true });
+        storage.addObserver("quickmarks", function () {
+            statusline.updateStatus();
+        }, window);
+    },
+
+    /**
+     * Adds a new quickmark with name *qmark* referencing the URL *location*.
+     * Any existing quickmark with the same name will be replaced.
+     *
+     * @param {string} qmark The name of the quickmark {A-Z}.
+     * @param {string} location The URL accessed by this quickmark.
+     */
+    add: function add(qmark, location) {
+        this._qmarks.set(qmark, location);
+        dactyl.echomsg({ domains: [util.getHost(location)], message: _("quickmark.added", qmark, location) }, 1);
+    },
+
+    /**
+     * Returns a list of QuickMarks associates with the given URL.
+     *
+     * @param {string} url The url to find QuickMarks for.
+     * @return {[string]}
+     */
+    find: function find(url) {
+        let res = [];
+        for (let [k, v] in this._qmarks)
+            if (dactyl.parseURLs(v).some(function (u) String.replace(u, /#.*/, "") == url))
+                res.push(k);
+        return res;
+    },
+
+    /**
+     * Returns the URL of the given QuickMark, or null if none exists.
+     *
+     * @param {string} mark The mark to find.
+     * @returns {string} The mark's URL.
+     */
+    get: function (mark) this._qmarks.get(mark) || null,
+
+    /**
+     * Deletes the specified quickmarks. The *filter* is a list of quickmarks
+     * and ranges are supported. Eg. "ab c d e-k".
+     *
+     * @param {string} filter The list of quickmarks to delete.
+     *
+     */
+    remove: function remove(filter) {
+        let pattern = util.charListToRegexp(filter, "a-zA-Z0-9");
+
+        for (let [qmark, ] in this._qmarks) {
+            if (pattern.test(qmark))
+                this._qmarks.remove(qmark);
+        }
+    },
+
+    /**
+     * Removes all quickmarks.
+     */
+    removeAll: function removeAll() {
+        this._qmarks.clear();
+    },
+
+    /**
+     * Opens the URL referenced by the specified *qmark*.
+     *
+     * @param {string} qmark The quickmark to open.
+     * @param {object} where A set of parameters specifying how to open the
+     *     URL. See {@link Dactyl#open}.
+     */
+    jumpTo: function jumpTo(qmark, where) {
+        let url = this.get(qmark);
+
+        if (url)
+            dactyl.open(url, where);
+        else
+            dactyl.echoerr(_("quickmark.notSet"));
+    },
+
+    /**
+     * Lists all quickmarks matching *filter* in the message window.
+     *
+     * @param {string} filter The list of quickmarks to display, e.g. "a-c i O-X".
+     */
+    list: function list(filter) {
+        let marks = [k for ([k, v] in this._qmarks)];
+        let lowercaseMarks = marks.filter(function (x) /[a-z]/.test(x)).sort();
+        let uppercaseMarks = marks.filter(function (x) /[A-Z]/.test(x)).sort();
+        let numberMarks    = marks.filter(function (x) /[0-9]/.test(x)).sort();
+
+        marks = Array.concat(lowercaseMarks, uppercaseMarks, numberMarks);
+
+        dactyl.assert(marks.length > 0, _("quickmark.none"));
+
+        if (filter.length > 0) {
+            let pattern = util.charListToRegexp(filter, "a-zA-Z0-9");
+            marks = marks.filter(function (qmark) pattern.test(qmark));
+            dactyl.assert(marks.length >= 0, _("quickmark.noMatching", filter.quote()));
+        }
+
+        commandline.commandOutput(template.tabular(["QuickMark", "URL"], [],
+            ([mark, quickmarks._qmarks.get(mark)] for ([k, mark] in Iterator(marks)))));
+    }
+}, {
+}, {
+    commands: function () {
+        commands.add(["delqm[arks]"],
+            "Delete the specified QuickMarks",
+            function (args) {
+                // TODO: finish arg parsing - we really need a proper way to do this. :)
+                // assert(args.bang ^ args[0])
+                dactyl.assert( args.bang ||  args[0], _("error.argumentRequired"));
+                dactyl.assert(!args.bang || !args[0], _("error.invalidArgument"));
+
+                if (args.bang)
+                    quickmarks.removeAll();
+                else
+                    quickmarks.remove(args[0]);
+            },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function (context) completion.quickmark(context)
+            });
+
+        commands.add(["qma[rk]"],
+            "Mark a URL with a letter for quick access",
+            function (args) {
+                dactyl.assert(/^[a-zA-Z0-9]$/.test(args[0]),
+                              _("quickmark.invalid"));
+                if (!args[1])
+                    quickmarks.add(args[0], buffer.uri.spec);
+                else
+                    quickmarks.add(args[0], args[1]);
+            },
+            {
+                argCount: "+",
+                completer: function (context, args) {
+                    if (args.length == 1)
+                        return completion.quickmark(context);
+                    if (args.length == 2) {
+                        context.fork("current", 0, this, function (context) {
+                            context.title = ["Extra Completions"];
+                            context.completions = [
+                                [quickmarks.get(args[0]), "Current Value"]
+                            ].filter(function ([k, v]) k);
+                        });
+                        context.fork("url", 0, completion, "url");
+                    }
+                },
+                literal: 1
+            });
+
+        commands.add(["qmarks"],
+            "Show the specified QuickMarks",
+            function (args) {
+                quickmarks.list(args[0] || "");
+            }, {
+                argCount: "?",
+                completer: function (context) completion.quickmark(context),
+            });
+    },
+    completion: function () {
+        completion.quickmark = function (context) {
+            context.title = ["QuickMark", "URL"];
+            context.generate = function () Iterator(quickmarks._qmarks);
+        };
+    },
+    mappings: function () {
+        var myModes = config.browserModes;
+
+        mappings.add(myModes,
+            ["go"], "Jump to a QuickMark",
+            function ({ arg }) { quickmarks.jumpTo(arg, dactyl.CURRENT_TAB); },
+            { arg: true });
+
+        mappings.add(myModes,
+            ["gn"], "Jump to a QuickMark in a new tab",
+            function ({ arg }) { quickmarks.jumpTo(arg, { from: "quickmark", where: dactyl.NEW_TAB }); },
+            { arg: true });
+
+        mappings.add(myModes,
+            ["M"], "Add new QuickMark for current URL",
+            function ({ arg }) {
+                dactyl.assert(/^[a-zA-Z0-9]$/.test(arg), _("quickmark.invalid"));
+                quickmarks.add(arg, buffer.uri.spec);
+            },
+            { arg: true });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/statusline.js b/common/content/statusline.js
new file mode 100644 (file)
index 0000000..5494342
--- /dev/null
@@ -0,0 +1,392 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+var StatusLine = Module("statusline", {
+    init: function init() {
+        this._statusLine = document.getElementById("status-bar");
+        this.statusBar = document.getElementById("addon-bar") || this._statusLine;
+        this.statusBar.collapsed = true;
+        this.baseGroup = this.statusBar == this._statusLine ? "StatusLine " : "";
+
+        if (this.statusBar.localName == "toolbar") {
+            styles.system.add("addon-bar", config.styleableChrome, <css><![CDATA[
+                #status-bar { margin-top: 0 !important; }
+                #addon-bar > statusbar { -moz-box-flex: 1 }
+                #addon-bar > #addonbar-closebutton { visibility: collapse; }
+                #addon-bar > xul|toolbarspring { visibility: collapse; }
+            ]]></css>);
+
+            util.overlayWindow(window, { append: <><statusbar id="status-bar" ordinal="0"/></> });
+
+            highlight.loadCSS(util.compileMacro(<![CDATA[
+                !AddonBar;#addon-bar  {
+                    padding-left: 0 !important;
+                    min-height: 18px !important;
+                    -moz-appearance: none !important;
+                    <padding>
+                }
+                !AddonButton;#addon-bar xul|toolbarbutton  {
+                    -moz-appearance: none !important;
+                    padding: 0 !important;
+                    border-width: 0px !important;
+                    min-width: 0 !important;
+                    color: inherit !important;
+                }
+                AddonButton:not(:hover)  background: transparent !important;
+            ]]>)({ padding: util.OS.isMacOSX ? "padding-right: 10px !important;" : "" }));
+
+            if (document.getElementById("appmenu-button"))
+                highlight.loadCSS(<![CDATA[
+                    AppmenuButton       min-width: 0 !important; padding: 0 .5em !important;
+                ]]>);
+        }
+
+        XML.ignoreWhitespace = true;
+        let _commandline = "if (window.dactyl) return dactyl.modules.commandline";
+        let prepend = <e4x xmlns={XUL} xmlns:dactyl={NS}>
+            <button id="appmenu-button" label="" image="chrome://branding/content/icon16.png" highlight="AppmenuButton" />
+            <toolbarbutton id="appmenu-toolbar-button" label="" image="chrome://branding/content/icon16.png" />
+            <statusbar id="status-bar" highlight="StatusLine">
+                <!-- insertbefore="dactyl.statusBefore;" insertafter="dactyl.statusAfter;" -->
+                <hbox key="container" hidden="false" align="center"  flex="1">
+                    <stack orient="horizontal"       align="stretch" flex="1" highlight="CmdLine StatusCmdLine" class="dactyl-container">
+                        <hbox                                                 highlight="CmdLine StatusCmdLine" class="dactyl-container">
+                            <label key="mode"          crop="end"                                               class="plain" collapsed="true"/>
+                            <stack  id="dactyl-statusline-stack"     flex="1" highlight="CmdLine StatusCmdLine" class="dactyl-container">
+                                <textbox key="url"     crop="end"    flex="1"                                   class="plain dactyl-status-field-url" readonly="true"/>
+                                <textbox key="message" crop="end"    flex="1" highlight="Normal StatusNormal"   class="plain"                         readonly="true"/>
+                            </stack>
+                        </hbox>
+                    </stack>
+                    <label class="plain" key="inputbuffer"    flex="0"/>
+                    <label class="plain" key="progress"       flex="0"/>
+                    <label class="plain" key="tabcount"       flex="0"/>
+                    <label class="plain" key="bufferposition" flex="0"/>
+                    <label class="plain" key="zoomlevel"      flex="0"/>
+                </hbox>
+                <!-- just hide them since other elements expect them -->
+                <statusbarpanel id="statusbar-display"       hidden="true"/>
+                <statusbarpanel id="statusbar-progresspanel" hidden="true"/>
+            </statusbar>
+        </e4x>;
+
+        for each (let attr in prepend..@key)
+            attr.parent().@id = "dactyl-statusline-field-" + attr;
+
+        util.overlayWindow(window, {
+            objects: this.widgets = { get status() this.container },
+            prepend: prepend.elements()
+        });
+
+        try {
+            this.security = content.document.dactylSecurity || "insecure";
+        }
+        catch (e) {}
+    },
+
+    get visible() !this.statusBar.collapsed && !this.statusBar.hidden,
+
+    signals: {
+        "browser.locationChange": function (webProgress, request, uri) {
+            let win = webProgress.DOMWindow;
+            this.status = buffer.uri;
+            this.progress = uri && win && win.dactylProgress || "";
+
+            // if this is not delayed we get the position of the old buffer
+            this.timeout(function () {
+                this.updateBufferPosition();
+                this.updateZoomLevel();
+            }, 500);
+        },
+        "browser.overLink": function (link) {
+            switch (options["showstatuslinks"]) {
+            case "status":
+                this.status = link ? _("status.link", link) : buffer.uri;
+                break;
+            case "command":
+                if (link)
+                    dactyl.echo(_("status.link", link), commandline.FORCE_SINGLELINE);
+                else
+                    commandline.clear();
+                break;
+            }
+        },
+        "browser.progressChange": function onProgressChange(webProgress, request, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) {
+            if (webProgress && webProgress.DOMWindow)
+                webProgress.DOMWindow.dactylProgress = curTotalProgress / maxTotalProgress;
+            this.progress = curTotalProgress / maxTotalProgress;
+        },
+        "browser.securityChange": function onSecurityChange(webProgress, request, state) {
+
+            if (state & Ci.nsIWebProgressListener.STATE_IS_BROKEN)
+                this.security = "broken";
+            else if (state & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL)
+                this.security = "extended";
+            else if (state & Ci.nsIWebProgressListener.STATE_SECURE_HIGH)
+                this.security = "secure";
+            else // if (state & Ci.nsIWebProgressListener.STATE_IS_INSECURE)
+                this.security = "insecure";
+
+            if (webProgress && webProgress.DOMWindow)
+                webProgress.DOMWindow.document.dactylSecurity = this.security;
+        },
+        "browser.stateChange": function onStateChange(webProgress, request, flags, status) {
+            if (flags & Ci.nsIWebProgressListener.STATE_START)
+                this.progress = 0;
+            if (flags & Ci.nsIWebProgressListener.STATE_STOP) {
+                this.progress = "";
+                this.status = buffer.uri;
+            }
+        },
+        "browser.statusChange": function onStatusChange(webProgress, request, status, message) {
+            this.status = message || buffer.uri;
+        },
+    },
+
+    /**
+     * Update the status bar to indicate how secure the website is:
+     * extended - Secure connection with Extended Validation(EV) certificate.
+     * secure -   Secure connection with valid certificate.
+     * broken -   Secure connection with invalid certificate, or
+     *            mixed content.
+     * insecure - Insecure connection.
+     *
+     * @param {'extended'|'secure'|'broken'|'insecure'} type
+     */
+    set security(type) {
+        this._security = type;
+        const highlightGroup = {
+            extended: "StatusLineExtended",
+            secure:   "StatusLineSecure",
+            broken:   "StatusLineBroken",
+            insecure: "StatusLineNormal"
+        };
+
+        highlight.highlightNode(this.statusBar, this.baseGroup + highlightGroup[type]);
+    },
+    get security() this._security,
+
+    // update all fields of the statusline
+    update: function update() {
+        this.status = buffer.uri;
+        this.inputBuffer = "";
+        this.progress = "";
+        this.updateTabCount();
+        this.updateBufferPosition();
+        this.updateZoomLevel();
+    },
+
+    // ripped from Firefox; modified
+    unsafeURI: util.regexp(String.replace(<![CDATA[
+            [
+                \s
+                // Invisible characters (bug 452979)
+                U001C U001D U001E U001F // file/group/record/unit separator
+                U00AD // Soft hyphen
+                UFEFF // BOM
+                U2060 // Word joiner
+                U2062 U2063  // Invisible times/separator
+                U200B UFFFC // Zero-width space/no-break space
+
+                // Bidi formatting characters. (RFC 3987 sections 3.2 and 4.1 paragraph 6)
+                U200E U200F U202A U202B U202C U202D U202E
+            ]
+        ]]>, /U/g, "\\u"),
+        "gx"),
+    losslessDecodeURI: function losslessDecodeURI(url) {
+        return url.split("%25").map(function (url) {
+                // Non-UTF-8 compliant URLs cause "malformed URI sequence" errors.
+                try {
+                    return decodeURI(url).replace(this.unsafeURI, encodeURIComponent);
+                }
+                catch (e) {
+                    return url;
+                }
+            }, this).join("%25");
+    },
+
+    /**
+     * Update the URL displayed in the status line. Also displays status
+     * icons, [+-♥], when there are next and previous pages in the
+     * current tab's history, and when the current URL is bookmarked,
+     * respectively.
+     *
+     * @param {string} url The URL to display.
+     */
+    get status() this._uri,
+    set status(uri) {
+        let modified = "";
+        let url = uri;
+        if (isinstance(uri, Ci.nsIURI)) {
+            // when session information is available, add [+] when we can go
+            // backwards, [-] when we can go forwards
+            if (uri.equals(buffer.uri) && window.getWebNavigation) {
+                let sh = window.getWebNavigation().sessionHistory;
+                if (sh && sh.index > 0)
+                    modified += "+";
+                if (sh && sh.index < sh.count - 1)
+                    modified += "-";
+            }
+
+            if (modules.bookmarkcache) {
+                if (bookmarkcache.isBookmarked(uri))
+                    modified += UTF8("❤");
+            }
+
+            if (modules.quickmarks)
+                modified += quickmarks.find(uri.spec.replace(/#.*/, "")).join("");
+
+            url = this.losslessDecodeURI(uri.spec);
+        }
+
+        if (url == "about:blank") {
+            if (!buffer.title)
+                url = "[No Name]";
+        }
+        else {
+            url = url.replace(RegExp("^dactyl://help/(\\S+)#(.*)"), function (m, n1, n2) n1 + " " + decodeURIComponent(n2) + " [Help]")
+                     .replace(RegExp("^dactyl://help/(\\S+)"), "$1 [Help]");
+        }
+
+        if (modified)
+            url += " [" + modified + "]";
+
+        this.widgets.url.value = url;
+        this._status = uri;
+
+    },
+
+    updateStatus: function updateStatus() { this.status = buffer.uri; },
+
+    updateUrl: deprecated("statusline.status", function updateUrl(url) { this.status = url || buffer.uri }),
+
+    /**
+     * Set the contents of the status line's input buffer to the given
+     * string. Used primarily when a key press requires further input
+     * before being processed, including mapping counts and arguments,
+     * along with multi-key mappings.
+     *
+     * @param {string} buffer
+     * @optional
+     */
+    get inputBuffer() this.widgets.inputbuffer.value,
+    set inputBuffer(val) this.widgets.inputbuffer.value = val == null ? "" : val,
+    updateInputBuffer: deprecated("statusline.inputBuffer", function updateInputBuffer(val) { this.inputBuffer = val; }),
+
+    /**
+     * Update the page load progress bar.
+     *
+     * @param {string|number} progress The current progress, as follows:
+     *    A string          - Displayed literally.
+     *    A ratio 0 < n < 1 - Displayed as a progress bar.
+     *    A number n <= 0   - Displayed as a "Loading" message.
+     *    Any other number  - The progress is cleared.
+     */
+    progress: Modes.boundProperty({
+        get: function progress() this._progress,
+        set: function  progress(progress) {
+            this._progress = progress || "";
+
+            if (typeof progress == "string")
+                this.widgets.progress.value = this._progress;
+            else if (typeof progress == "number") {
+                let progressStr = "";
+                if (this._progress <= 0)
+                    progressStr = "[ Loading...         ]";
+                else if (this._progress < 1) {
+                    let progress = Math.round(this._progress * 20);
+                    progressStr = "["
+                        + "===================>                    "
+                            .substr(20 - progress, 20)
+                        + "]";
+                }
+                this.widgets.progress.value = progressStr;
+            }
+        }
+    }),
+    updateProgress: deprecated("statusline.progress", function updateProgress(progress) {
+        this.progress = progress;
+    }),
+
+    /**
+     * Display the correct tabcount (e.g., [1/5]) on the status bar.
+     *
+     * @param {boolean} delayed When true, update count after a brief timeout.
+     *     Useful in the many cases when an event that triggers an update is
+     *     broadcast before the tab state is fully updated.
+     * @optional
+     */
+    updateTabCount: function updateTabCount(delayed) {
+        if (dactyl.has("tabs")) {
+            if (delayed) {
+                this.timeout(function () this.updateTabCount(false), 0);
+                return;
+            }
+
+            this.widgets.tabcount.value = "[" + (tabs.index(null, true) + 1) + "/" + tabs.visibleTabs.length + "]";
+        }
+    },
+
+    /**
+     * Display the main content's vertical scroll position in the status
+     * bar.
+     *
+     * @param {number} percent The position, as a percentage.
+     * @optional
+     */
+    updateBufferPosition: function updateBufferPosition(percent) {
+        if (percent == null) {
+            let win = document.commandDispatcher.focusedWindow;
+            if (!win)
+                return;
+            win.scrollY; // intentional - see Kris
+            percent = win.scrollY    == 0 ?  0 : // This prevents a forced rendering
+                      win.scrollMaxY == 0 ? -1 : win.scrollY / win.scrollMaxY;
+        }
+
+        percent = Math.round(percent * 100);
+
+        if (percent < 0)
+            var position = "All";
+        else if (percent == 0)
+            position = "Top";
+        else if (percent >= 100)
+            position = "Bot";
+        else if (percent < 10)
+            position = " " + percent + "%";
+        else
+            position = percent + "%";
+
+        this.widgets.bufferposition.value = position;
+    },
+
+    /**
+     * Display the main content's zoom level.
+     *
+     * @param {number} percent The zoom level, as a percentage. @optional
+     * @param {boolean} full True if full zoom is in operation. @optional
+     */
+    updateZoomLevel: function updateZoomLevel(percent, full) {
+        if (arguments.length == 0)
+            [percent, full] = [buffer.zoomLevel, buffer.fullZoom];
+
+        if (percent == 100)
+            this.widgets.zoomlevel.value = "";
+        else {
+            percent = ("  " + Math.round(percent)).substr(-3);
+            if (full)
+                this.widgets.zoomlevel.value = " [" + percent + "%]";
+            else
+                this.widgets.zoomlevel.value = " (" + percent + "%)";
+        }
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/content/tabs.js b/common/content/tabs.js
new file mode 100644 (file)
index 0000000..f8584da
--- /dev/null
@@ -0,0 +1,1076 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+/** @scope modules */
+
+// TODO: many methods do not work with Thunderbird correctly yet
+
+/**
+ * @instance tabs
+ */
+var Tabs = Module("tabs", {
+    init: function () {
+        // used for the "gb" and "gB" mappings to remember the last :buffer[!] command
+        this._lastBufferSwitchArgs = "";
+        this._lastBufferSwitchSpecial = true;
+
+        // hide tabs initially to prevent flickering when 'stal' would hide them
+        // on startup
+        if (config.hasTabbrowser)
+            config.tabStrip.collapsed = true;
+
+        this.tabStyle = styles.system.add("tab-strip-hiding", config.styleableChrome,
+                                          (config.tabStrip.id ? "#" + config.tabStrip.id : ".tabbrowser-strip") +
+                                              "{ visibility: collapse; }",
+                                          false, true);
+
+        dactyl.commands["tabs.select"] = function (event) {
+            tabs.select(event.originalTarget.getAttribute("identifier"));
+        };
+
+        this.tabBinding = styles.system.add("tab-binding", "chrome://browser/content/browser.xul", String.replace(<><![CDATA[
+                xul|tab { -moz-binding: url(chrome://dactyl/content/bindings.xml#tab) !important; }
+            ]]></>, /tab-./g, function (m) util.OS.isMacOSX ? "tab-mac" : m),
+            false, true);
+
+        this.timeout(function () {
+            for (let { linkedBrowser: { contentDocument } } in values(this.allTabs))
+                if (contentDocument.readyState === "complete")
+                    dactyl.initDocument(contentDocument);
+        });
+    },
+
+    _alternates: Class.memoize(function () [config.tabbrowser.mCurrentTab, null]),
+
+    cleanup: function cleanup() {
+        for (let [i, tab] in Iterator(this.allTabs)) {
+            let node = function node(class_) document.getAnonymousElementByAttribute(tab, "class", class_);
+            for (let elem in values(["dactyl-tab-icon-number", "dactyl-tab-number"].map(node)))
+                if (elem)
+                    elem.parentNode.parentNode.removeChild(elem.parentNode);
+        }
+    },
+
+    updateTabCount: function () {
+        for (let [i, tab] in Iterator(this.visibleTabs)) {
+            if (dactyl.has("Gecko2")) {
+                let node = function node(class_) document.getAnonymousElementByAttribute(tab, "class", class_);
+                if (!node("dactyl-tab-number")) {
+                    let img = node("tab-icon-image");
+                    if (img) {
+                        let nodes = {};
+                        let dom = util.xmlToDom(<xul xmlns:xul={XUL} xmlns:html={XHTML}
+                            ><xul:hbox highlight="tab-number"><xul:label key="icon" align="center" highlight="TabIconNumber" class="dactyl-tab-icon-number"/></xul:hbox
+                            ><xul:hbox highlight="tab-number"><html:div key="label" highlight="TabNumber" class="dactyl-tab-number"/></xul:hbox
+                        ></xul>.*, document, nodes);
+                        img.parentNode.appendChild(dom);
+                        tab.__defineGetter__("dactylOrdinal", function () Number(nodes.icon.value));
+                        tab.__defineSetter__("dactylOrdinal", function (i) nodes.icon.value = nodes.label.textContent = i);
+                    }
+                }
+            }
+            tab.setAttribute("dactylOrdinal", i + 1);
+            tab.dactylOrdinal = i + 1;
+        }
+        statusline.updateTabCount(true);
+    },
+
+    _onTabSelect: function () {
+        // TODO: is all of that necessary?
+        //       I vote no. --Kris
+        modes.reset();
+        statusline.updateTabCount(true);
+        this.updateSelectionHistory();
+    },
+
+    get allTabs() Array.slice(config.tabbrowser.tabContainer.childNodes),
+
+    /**
+     * @property {Object} The previously accessed tab or null if no tab
+     *     other than the current one has been accessed.
+     */
+    get alternate() this.allTabs.indexOf(this._alternates[1]) > -1 ? this._alternates[1] : null,
+
+    /**
+     * @property {Iterator(Object)} A genenerator that returns all browsers
+     *     in the current window.
+     */
+    get browsers() {
+        let browsers = config.tabbrowser.browsers;
+        for (let i = 0; i < browsers.length; i++)
+            yield [i, browsers[i]];
+    },
+
+    /**
+     * @property {number} The number of tabs in the current window.
+     */
+    get count() config.tabbrowser.mTabs.length,
+
+    /**
+     * @property {Object} The local options store for the current tab.
+     */
+    get options() {
+        let store = this.localStore;
+        if (!("options" in store))
+            store.options = {};
+        return store.options;
+    },
+
+    get visibleTabs() config.tabbrowser.visibleTabs || this.allTabs.filter(function (tab) !tab.hidden),
+
+    /**
+     * Returns the local state store for the tab at the specified *tabIndex*.
+     * If *tabIndex* is not specified then the current tab is used.
+     *
+     * @param {number} tabIndex
+     * @returns {Object}
+     */
+    // FIXME: why not a tab arg? Why this and the property?
+    //      : To the latter question, because this works for any tab, the
+    //        property doesn't. And the property is so oft-used that it's
+    //        convenient. To the former question, because I think this is mainly
+    //        useful for autocommands, and they get index arguments. --Kris
+    getLocalStore: function (tabIndex) {
+        let tab = this.getTab(tabIndex);
+        if (!tab.dactylStore)
+            tab.dactylStore = {};
+        return tab.dactylStore;
+    },
+
+    /**
+     * @property {Object} The local state store for the currently selected
+     *     tab.
+     */
+    get localStore() this.getLocalStore(),
+
+    /**
+     * @property {Object[]} The array of closed tabs for the current
+     *     session.
+     */
+    get closedTabs() services.json.decode(services.sessionStore.getClosedTabData(window)),
+
+    /**
+     * Clones the specified *tab* and append it to the tab list.
+     *
+     * @param {Object} tab The tab to clone.
+     * @param {boolean} activate Whether to select the newly cloned tab.
+     */
+    cloneTab: function (tab, activate) {
+        let newTab = config.tabbrowser.addTab();
+        Tabs.copyTab(newTab, tab);
+
+        if (activate)
+            config.tabbrowser.mTabContainer.selectedItem = newTab;
+
+        return newTab;
+    },
+
+    /**
+     * Detaches the specified *tab* and open it in a new window. If no tab is
+     * specified the currently selected tab is detached.
+     *
+     * @param {Object} tab The tab to detach.
+     */
+    detachTab: function (tab) {
+        if (!tab)
+            tab = config.tabbrowser.mTabContainer.selectedItem;
+
+        services.windowWatcher
+                .openWindow(window, window.getBrowserURL(), null, "chrome,dialog=no,all", tab);
+    },
+
+    /**
+     * Returns the index of the tab containing *content*.
+     *
+     * @param {Object} content Either a content window or a content
+     *     document.
+     */
+    // FIXME: Only called once...necessary?
+    getContentIndex: function (content) {
+        for (let [i, browser] in this.browsers) {
+            if (browser.contentWindow == content || browser.contentDocument == content)
+                return i;
+        }
+        return -1;
+    },
+
+    /**
+     * If TabView exists, returns the Panorama window. If the Panorama
+     * is has not yet initialized, this function will not return until
+     * it has.
+     *
+     * @returns {Window}
+     */
+    getGroups: function () {
+        if ("_groups" in this)
+            return this._groups;
+
+        if (window.TabView && TabView._initFrame)
+            TabView._initFrame();
+
+        let iframe = document.getElementById("tab-view");
+        this._groups = iframe ? iframe.contentWindow : null;
+        if (this._groups)
+            util.waitFor(function () this._groups.TabItems, this);
+        return this._groups;
+    },
+
+    /**
+     * Returns the tab at the specified *index* or the currently selected tab
+     * if *index* is not specified. This is a 0-based index.
+     *
+     * @param {number|Node} index The index of the tab required or the tab itself
+     * @returns {Object}
+     */
+    getTab: function (index) {
+        if (index instanceof Node)
+            return index;
+        if (index != null)
+            return config.tabbrowser.mTabs[index];
+        return config.tabbrowser.mCurrentTab;
+    },
+
+    /**
+     * Returns the index of *tab* or the index of the currently selected tab if
+     * *tab* is not specified. This is a 0-based index.
+     *
+     * @param {<xul:tab/>} tab A tab from the current tab list.
+     * @param {boolean} visible Whether to consider only visible tabs.
+     * @returns {number}
+     */
+    index: function (tab, visible) {
+        let tabs = this[visible ? "visibleTabs" : "allTabs"];
+        return tabs.indexOf(tab || config.tabbrowser.mCurrentTab);
+    },
+
+    /**
+     * @param spec can either be:
+     * - an absolute integer
+     * - "" for the current tab
+     * - "+1" for the next tab
+     * - "-3" for the tab, which is 3 positions left of the current
+     * - "$" for the last tab
+     */
+    indexFromSpec: function (spec, wrap) {
+        if (spec instanceof Node)
+            return this.allTabs.indexOf(spec);
+
+        let tabs     = this.visibleTabs;
+        let position = this.index(null, true);
+
+        if (spec == null || spec === "")
+            return position;
+
+        if (typeof spec === "number")
+            position = spec;
+        else if (spec === "$")
+            position = tabs.length - 1;
+        else if (/^[+-]\d+$/.test(spec))
+            position += parseInt(spec, 10);
+        else if (/^\d+$/.test(spec))
+            position = parseInt(spec, 10);
+        else
+            return -1;
+
+        if (position >= tabs.length)
+            position = wrap ? position % tabs.length : tabs.length - 1;
+        else if (position < 0)
+            position = wrap ? (position % tabs.length) + tabs.length : 0;
+
+        return this.allTabs.indexOf(tabs[position]);
+    },
+
+    /**
+     * Removes all tabs from the tab list except the specified *tab*.
+     *
+     * @param {Object} tab The tab to keep.
+     */
+    keepOnly: function (tab) {
+        config.tabbrowser.removeAllTabsBut(tab);
+    },
+
+    /**
+     * Lists all tabs matching *filter*.
+     *
+     * @param {string} filter A filter matching a substring of the tab's
+     *     document title or URL.
+     */
+    list: function (filter) {
+        completion.listCompleter("buffer", filter);
+    },
+
+    /**
+     * Moves a tab to a new position in the tab list.
+     *
+     * @param {Object} tab The tab to move.
+     * @param {string} spec See {@link Tabs.indexFromSpec}.
+     * @param {boolean} wrap Whether an out of bounds *spec* causes the
+     *     destination position to wrap around the start/end of the tab list.
+     */
+    move: function (tab, spec, wrap) {
+        let index = tabs.indexFromSpec(spec, wrap);
+        config.tabbrowser.moveTabTo(tab, index);
+    },
+
+    /**
+     * Removes the specified *tab* from the tab list.
+     *
+     * @param {Object} tab The tab to remove.
+     * @param {number} count How many tabs to remove.
+     * @param {boolean} focusLeftTab Focus the tab to the left of the removed tab.
+     */
+    remove: function (tab, count, focusLeftTab) {
+        count = count || 1;
+        let res = this.count > count;
+
+        let tabs = this.visibleTabs;
+        if (tabs.indexOf(tab) < 0)
+            tabs = this.allTabs;
+        let index = tabs.indexOf(tab);
+
+        let next = index + (focusLeftTab ? -count : count);
+        if (!(next in tabs))
+            next = index + (focusLeftTab ? 1 : -1);
+        if (next in tabs) {
+            this._alternates[0] = tabs[next];
+            config.tabbrowser.mTabContainer.selectedItem = tabs[next];
+        }
+
+        if (focusLeftTab)
+            tabs.slice(Math.max(0, index + 1 - count), index + 1).forEach(config.closure.removeTab);
+        else
+            tabs.slice(index, index + count).forEach(config.closure.removeTab);
+        return res;
+    },
+
+    /**
+     * Reloads the specified tab.
+     *
+     * @param {Object} tab The tab to reload.
+     * @param {boolean} bypassCache Whether to bypass the cache when
+     *     reloading.
+     */
+    reload: function (tab, bypassCache) {
+        try {
+            if (bypassCache) {
+                const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY | Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+                config.tabbrowser.getBrowserForTab(tab).reloadWithFlags(flags);
+            }
+            else
+                config.tabbrowser.reloadTab(tab);
+        }
+        catch (e if !(e instanceof Error)) {}
+    },
+
+    /**
+     * Reloads all tabs.
+     *
+     * @param {boolean} bypassCache Whether to bypass the cache when
+     *     reloading.
+     */
+    reloadAll: function (bypassCache) {
+        if (bypassCache) {
+            for (let i = 0; i < config.tabbrowser.mTabs.length; i++) {
+                try {
+                    this.reload(config.tabbrowser.mTabs[i], bypassCache);
+                }
+                catch (e) {
+                    // FIXME: can we do anything useful here without stopping the
+                    //        other tabs from reloading?
+                }
+            }
+        }
+        else
+            config.tabbrowser.reloadAllTabs();
+    },
+
+    /**
+     * Selects the tab at the position specified by *spec*.
+     *
+     * @param {string} spec See {@link Tabs.indexFromSpec}
+     * @param {boolean} wrap Whether an out of bounds *spec* causes the
+     *     selection position to wrap around the start/end of the tab list.
+     */
+    select: function (spec, wrap) {
+        let index = tabs.indexFromSpec(spec, wrap);
+        if (index == -1)
+            dactyl.beep();
+        else
+            config.tabbrowser.mTabContainer.selectedIndex = index;
+    },
+
+    /**
+     * Selects the alternate tab.
+     */
+    selectAlternateTab: function () {
+        dactyl.assert(tabs.alternate != null && tabs.getTab() != tabs.alternate,
+                      _("buffer.noAlternate"));
+        tabs.select(tabs.alternate);
+    },
+
+    /**
+     * Stops loading the specified tab.
+     *
+     * @param {Object} tab The tab to stop loading.
+     */
+    stop: function (tab) {
+        if (config.stop)
+            config.stop(tab);
+        else
+            tab.linkedBrowser.stop();
+    },
+
+    /**
+     * Stops loading all tabs.
+     */
+    stopAll: function () {
+        for (let [, browser] in this.browsers)
+            browser.stop();
+    },
+
+    /**
+     * Selects the tab containing the specified *buffer*.
+     *
+     * @param {string} buffer A string which matches the URL or title of a
+     *     buffer, if it is null, the last used string is used again.
+     * @param {boolean} allowNonUnique Whether to select the first of
+     *     multiple matches.
+     * @param {number} count If there are multiple matches select the
+     *     *count*th match.
+     * @param {boolean} reverse Whether to search the buffer list in
+     *     reverse order.
+     *
+     */
+    // FIXME: help!
+    switchTo: function (buffer, allowNonUnique, count, reverse) {
+        if (buffer != null) {
+            // store this command, so it can be repeated with "B"
+            this._lastBufferSwitchArgs = buffer;
+            this._lastBufferSwitchSpecial = allowNonUnique;
+        }
+        else {
+            buffer = this._lastBufferSwitchArgs;
+            if (allowNonUnique == null) // XXX
+                allowNonUnique = this._lastBufferSwitchSpecial;
+        }
+
+        if (buffer == "#")
+            return tabs.selectAlternateTab();
+
+        reverse = Boolean(reverse);
+        count = Math.max(1, count || 1) * (1 + -2 * reverse);
+
+        let matches = buffer.match(/^(\d+):?/);
+        if (matches)
+            return tabs.select(this.allTabs[parseInt(matches[1], 10) - 1], false);
+
+        matches = array.nth(tabs.allTabs, function (t) (t.linkedBrowser.lastURI || {}).spec === buffer, 0);
+        if (matches)
+            return tabs.select(matches, false);
+
+        matches = completion.runCompleter("buffer", buffer).map(function (obj) obj.tab);
+
+        if (matches.length == 0)
+            dactyl.echoerr(_("buffer.noMatching", buffer));
+        else if (matches.length > 1 && !allowNonUnique)
+            dactyl.echoerr(_("buffer.multipleMatching", buffer));
+        else {
+            let start = matches.indexOf(tabs.getTab());
+            if (start == -1 && reverse)
+                start++;
+
+            let index = (start + count) % matches.length;
+            if (index < 0)
+                index = matches.length + index;
+            tabs.select(matches[index], false);
+        }
+    },
+
+    // NOTE: when restarting a session FF selects the first tab and then the
+    // tab that was selected when the session was created.  As a result the
+    // alternate after a restart is often incorrectly tab 1 when there
+    // shouldn't be one yet.
+    /**
+     * Sets the current and alternate tabs, updating the tab selection
+     * history.
+     *
+     * @param {Array(Object)} tabs The current and alternate tab.
+     * @see tabs#alternate
+     */
+    updateSelectionHistory: function (tabs) {
+        if (!tabs) {
+            if (this.getTab() == this._alternates[0]
+                || this.alternate && this.allTabs.indexOf(this._alternates[0]) == -1
+                || this.alternate && config.tabbrowser._removingTabs && config.tabbrowser._removingTabs.indexOf(this._alternates[0]) >= 0)
+                tabs = [this.getTab(), this.alternate];
+        }
+        this._alternates = tabs || [this.getTab(), this._alternates[0]];
+    }
+}, {
+    copyTab: function (to, from) {
+        if (!from)
+            from = config.tabbrowser.mTabContainer.selectedItem;
+
+        let tabState = services.sessionStore.getTabState(from);
+        services.sessionStore.setTabState(to, tabState);
+    }
+}, {
+    load: function () {
+        tabs.updateTabCount();
+    },
+    commands: function () {
+        commands.add(["bd[elete]", "bw[ipeout]", "bun[load]", "tabc[lose]"],
+            "Delete current buffer",
+            function (args) {
+                let special = args.bang;
+                let count   = args.count;
+                let arg     = args[0] || "";
+
+                if (arg) {
+                    let removed = 0;
+                    let matches = arg.match(/^(\d+):?/);
+
+                    if (matches) {
+                        config.removeTab(tabs.getTab(parseInt(matches[1], 10) - 1));
+                        removed = 1;
+                    }
+                    else {
+                        let str = arg.toLowerCase();
+                        let browsers = config.tabbrowser.browsers;
+
+                        for (let i = browsers.length - 1; i >= 0; i--) {
+                            let host, title, uri = browsers[i].currentURI.spec;
+                            if (browsers[i].currentURI.schemeIs("about")) {
+                                host = "";
+                                title = "(Untitled)";
+                            }
+                            else {
+                                host = browsers[i].currentURI.host;
+                                title = browsers[i].contentTitle;
+                            }
+
+                            [host, title, uri] = [host, title, uri].map(String.toLowerCase);
+
+                            if (host.indexOf(str) >= 0 || uri == str ||
+                                (special && (title.indexOf(str) >= 0 || uri.indexOf(str) >= 0))) {
+                                config.removeTab(tabs.getTab(i));
+                                removed++;
+                            }
+                        }
+                    }
+
+                    if (removed > 0)
+                        dactyl.echomsg(_("buffer.fewer", removed, removed == 1 ? "" : "s"), 9);
+                    else
+                        dactyl.echoerr(_("buffer.noMatching", arg));
+                }
+                else // just remove the current tab
+                    tabs.remove(tabs.getTab(), Math.max(count, 1), special);
+            }, {
+                argCount: "?",
+                bang: true,
+                count: true,
+                completer: function (context) completion.buffer(context),
+                literal: 0,
+                privateData: true
+            });
+
+        commands.add(["keepa[lt]"],
+            "Execute a command without changing the current alternate buffer",
+            function (args) {
+                let alternate = tabs.alternate;
+
+                try {
+                    commands.execute(args[0] || "", null, true);
+                }
+                finally {
+                    tabs.updateSelectionHistory([tabs.getTab(), alternate]);
+                }
+            }, {
+                argCount: "+",
+                completer: function (context) completion.ex(context),
+                literal: 0,
+                subCommand: 0
+            });
+
+        commands.add(["tab"],
+            "Execute a command and tell it to output in a new tab",
+            function (args) {
+                dactyl.withSavedValues(["forceNewTab"], function () {
+                    this.forceNewTab = true;
+                    commands.execute(args[0] || "", null, true);
+                });
+            }, {
+                argCount: "+",
+                completer: function (context) completion.ex(context),
+                literal: 0,
+                subCommand: 0
+            });
+
+        commands.add(["tabd[o]", "bufd[o]"],
+            "Execute a command in each tab",
+            function (args) {
+                for (let tab in values(tabs.visibleTabs)) {
+                    tabs.select(tab);
+                    dactyl.execute(args[0], null, true);
+                }
+            }, {
+                argCount: "1",
+                completer: function (context) completion.ex(context),
+                literal: 0,
+                subCommand: 0
+            });
+
+        commands.add(["tabl[ast]", "bl[ast]"],
+            "Switch to the last tab",
+            function () tabs.select("$", false),
+            { argCount: "0" });
+
+        // TODO: "Zero count" if 0 specified as arg
+        commands.add(["tabp[revious]", "tp[revious]", "tabN[ext]", "tN[ext]", "bp[revious]", "bN[ext]"],
+            "Switch to the previous tab or go [count] tabs back",
+            function (args) {
+                let count = args.count;
+                let arg   = args[0];
+
+                // count is ignored if an arg is specified, as per Vim
+                if (arg) {
+                    if (/^\d+$/.test(arg))
+                        tabs.select("-" + arg, true);
+                    else
+                        dactyl.echoerr(_("error.trailing"));
+                }
+                else if (count > 0)
+                    tabs.select("-" + count, true);
+                else
+                    tabs.select("-1", true);
+            }, {
+                argCount: "?",
+                count: true
+            });
+
+        // TODO: "Zero count" if 0 specified as arg
+        commands.add(["tabn[ext]", "tn[ext]", "bn[ext]"],
+            "Switch to the next or [count]th tab",
+            function (args) {
+                let count = args.count;
+                let arg   = args[0];
+
+                if (arg || count > 0) {
+                    let index;
+
+                    // count is ignored if an arg is specified, as per Vim
+                    if (arg) {
+                        dactyl.assert(/^\d+$/.test(arg), _("error.trailing"));
+                        index = arg - 1;
+                    }
+                    else
+                        index = count - 1;
+
+                    if (index < tabs.count)
+                        tabs.select(index, true);
+                    else
+                        dactyl.beep();
+                }
+                else
+                    tabs.select("+1", true);
+            }, {
+                argCount: "?",
+                count: true
+            });
+
+        commands.add(["tabr[ewind]", "tabfir[st]", "br[ewind]", "bf[irst]"],
+            "Switch to the first tab",
+            function () { tabs.select(0, false); },
+            { argCount: "0" });
+
+        if (config.hasTabbrowser) {
+            commands.add(["b[uffer]"],
+                "Switch to a buffer",
+                function (args) { tabs.switchTo(args[0], args.bang, args.count); }, {
+                    argCount: "?",
+                    bang: true,
+                    count: true,
+                    completer: function (context) completion.buffer(context),
+                    literal: 0,
+                    privateData: true
+                });
+
+            commands.add(["buffers", "files", "ls", "tabs"],
+                "Show a list of all buffers",
+                function (args) { tabs.list(args[0] || ""); }, {
+                    argCount: "?",
+                    literal: 0
+                });
+
+            commands.add(["quita[ll]", "qa[ll]"],
+                "Quit " + config.appName,
+                function (args) { dactyl.quit(false, args.bang); }, {
+                    argCount: "0",
+                    bang: true
+                });
+
+            commands.add(["reloada[ll]"],
+                "Reload all tab pages",
+                function (args) { tabs.reloadAll(args.bang); }, {
+                    argCount: "0",
+                    bang: true
+                });
+
+            commands.add(["stopa[ll]"],
+                "Stop loading all tab pages",
+                function () { tabs.stopAll(); },
+                { argCount: "0" });
+
+            // TODO: add count support
+            commands.add(["tabm[ove]"],
+                "Move the current tab after tab N",
+                function (args) {
+                    let arg = args[0];
+
+                    // FIXME: tabmove! N should probably produce an error
+                    dactyl.assert(!arg || /^([+-]?\d+)$/.test(arg),
+                                  _("error.trailing"));
+
+                    // if not specified, move to after the last tab
+                    tabs.move(config.tabbrowser.mCurrentTab, arg || "$", args.bang);
+                }, {
+                    argCount: "?",
+                    bang: true
+                });
+
+            commands.add(["tabo[nly]"],
+                "Close all other tabs",
+                function () { tabs.keepOnly(tabs.getTab()); },
+                { argCount: "0" });
+
+            commands.add(["tabopen", "t[open]", "tabnew"],
+                "Open one or more URLs in a new tab",
+                function (args) {
+                    dactyl.open(args[0] || "about:blank", { from: "tabopen", where: dactyl.NEW_TAB, background: args.bang });
+                }, {
+                    bang: true,
+                    completer: function (context) completion.url(context),
+                    domains: function (args) commands.get("open").domains(args),
+                    literal: 0,
+                    privateData: true
+                });
+
+            commands.add(["tabde[tach]"],
+                "Detach current tab to its own window",
+                function () { tabs.detachTab(null); },
+                { argCount: "0" });
+
+            commands.add(["tabdu[plicate]"],
+                "Duplicate current tab",
+                function (args) {
+                    let tab = tabs.getTab();
+
+                    let activate = args.bang ? true : false;
+                    if (options.get("activate").has("tabopen"))
+                        activate = !activate;
+
+                    for (let i in util.range(0, Math.max(1, args.count)))
+                        tabs.cloneTab(tab, activate);
+                }, {
+                    argCount: "0",
+                    bang: true,
+                    count: true
+                });
+
+            // TODO: match window by title too?
+            //     : accept the full :tabmove arg spec for the tab index arg?
+            //     : better name or merge with :tabmove?
+            commands.add(["taba[ttach]"],
+                "Attach the current tab to another window",
+                function (args) {
+                    dactyl.assert(args.length <= 2 && !args.some(function (i) !/^\d+$/.test(i)),
+                                  _("error.trailing"));
+
+                    let [winIndex, tabIndex] = args.map(parseInt);
+                    let win = dactyl.windows[winIndex - 1];
+
+                    dactyl.assert(win, _("window.noIndex", winIndex));
+                    dactyl.assert(win != window, _("window.cantAttachSame"));
+
+                    let browser = win.getBrowser();
+                    let dummy = browser.addTab("about:blank");
+                    browser.stop();
+                    // XXX: the implementation of DnD in tabbrowser.xml suggests
+                    // that we may not be guaranteed of having a docshell here
+                    // without this reference?
+                    browser.docShell;
+
+                    let last = browser.mTabs.length - 1;
+
+                    browser.moveTabTo(dummy, Math.constrain(tabIndex || last, 0, last));
+                    browser.selectedTab = dummy; // required
+                    browser.swapBrowsersAndCloseOther(dummy, config.tabbrowser.mCurrentTab);
+                }, {
+                    argCount: "+",
+                    completer: function (context, args) {
+                        if (args.completeArg == 0) {
+                            context.filters.push(function ({ item }) item != window);
+                            completion.window(context);
+                        }
+                    }
+                });
+        }
+
+        if (dactyl.has("tabs_undo")) {
+            commands.add(["u[ndo]"],
+                "Undo closing of a tab",
+                function (args) {
+                    if (args.length)
+                        args = args[0];
+                    else
+                        args = args.count || 0;
+
+                    let m = /^(\d+)(:|$)/.exec(args || '1');
+                    if (m)
+                        window.undoCloseTab(Number(m[1]) - 1);
+                    else if (args) {
+                        for (let [i, item] in Iterator(tabs.closedTabs))
+                            if (item.state.entries[item.state.index - 1].url == args) {
+                                window.undoCloseTab(i);
+                                return;
+                            }
+
+                        dactyl.echoerr(_("buffer.noClosed"));
+                    }
+                }, {
+                    argCount: "?",
+                    completer: function (context) {
+                        context.anchored = false;
+                        context.compare = CompletionContext.Sort.unsorted;
+                        context.filters = [CompletionContext.Filter.textDescription];
+                        context.keys = { text: function ([i, { state: s }]) (i + 1) + ": " + s.entries[s.index - 1].url, description: "[1].title", icon: "[1].image" };
+                        context.completions = Iterator(tabs.closedTabs);
+                    },
+                    count: true,
+                    literal: 0,
+                    privateData: true
+                });
+
+            commands.add(["undoa[ll]"],
+                "Undo closing of all closed tabs",
+                function (args) {
+                    for (let i in Iterator(tabs.closedTabs))
+                        window.undoCloseTab(0);
+
+                },
+                { argCount: "0" });
+
+        }
+
+        if (dactyl.has("session")) {
+            commands.add(["wqa[ll]", "wq", "xa[ll]"],
+                "Save the session and quit",
+                function () { dactyl.quit(true); },
+                { argCount: "0" });
+        }
+    },
+    events: function () {
+        let tabContainer = config.tabbrowser.mTabContainer;
+        function callback() {
+            tabs.timeout(function () { this.updateTabCount(); });
+        }
+        for (let event in values(["TabMove", "TabOpen", "TabClose"]))
+            events.listen(tabContainer, event, callback, false);
+        events.listen(tabContainer, "TabSelect", tabs.closure._onTabSelect, false);
+    },
+    mappings: function () {
+        mappings.add([modes.NORMAL], ["g0", "g^"],
+            "Go to the first tab",
+            function () { tabs.select(0); });
+
+        mappings.add([modes.NORMAL], ["g$"],
+            "Go to the last tab",
+            function () { tabs.select("$"); });
+
+        mappings.add([modes.NORMAL], ["gt"],
+            "Go to the next tab",
+            function ({ count }) {
+                if (count != null)
+                    tabs.select(count - 1, false);
+                else
+                    tabs.select("+1", true);
+            },
+            { count: true });
+
+        mappings.add([modes.NORMAL], ["<C-n>", "<C-Tab>", "<C-PageDown>"],
+            "Go to the next tab",
+            function ({ count }) { tabs.select("+" + (count || 1), true); },
+            { count: true });
+
+        mappings.add([modes.NORMAL], ["gT", "<C-p>", "<C-S-Tab>", "<C-PageUp>"],
+           "Go to previous tab",
+            function ({ count }) { tabs.select("-" + (count || 1), true); },
+            { count: true });
+
+        if (config.hasTabbrowser) {
+            mappings.add([modes.NORMAL], ["b"],
+                "Open a prompt to switch buffers",
+                function ({ count }) {
+                    if (count != null)
+                        tabs.switchTo(String(count));
+                    else
+                        CommandExMode().open("buffer! ");
+                },
+                { count: true });
+
+            mappings.add([modes.NORMAL], ["B"],
+                "Show buffer list",
+                function () { tabs.list(false); });
+
+            mappings.add([modes.NORMAL], ["d"],
+                "Delete current buffer",
+                function ({ count }) { tabs.remove(tabs.getTab(), count, false); },
+                { count: true });
+
+            mappings.add([modes.NORMAL], ["D"],
+                "Delete current buffer, focus tab to the left",
+                function ({ count }) { tabs.remove(tabs.getTab(), count, true); },
+                { count: true });
+
+            mappings.add([modes.NORMAL], ["gb"],
+                "Repeat last :buffer[!] command",
+                function ({ count }) { tabs.switchTo(null, null, count, false); },
+                { count: true });
+
+            mappings.add([modes.NORMAL], ["gB"],
+                "Repeat last :buffer[!] command in reverse direction",
+                function ({ count }) { tabs.switchTo(null, null, count, true); },
+                { count: true });
+
+            // TODO: feature dependencies - implies "session"?
+            if (dactyl.has("tabs_undo")) {
+                mappings.add([modes.NORMAL], ["u"],
+                    "Undo closing of a tab",
+                    function ({ count }) { ex.undo({ "#": count }); },
+                    { count: true });
+            }
+
+            mappings.add([modes.NORMAL], ["<C-^>", "<C-6>"],
+                "Select the alternate tab or the [count]th tab",
+                function ({ count }) {
+                    if (count != null)
+                        tabs.switchTo(String(count), false);
+                    else
+                        tabs.selectAlternateTab();
+                },
+                { count: true });
+        }
+    },
+    options: function () {
+        options.add(["showtabline", "stal"],
+            "Define when the tab bar is visible",
+            "string", config.defaults["showtabline"],
+            {
+                setter: function (value) {
+                    if (value === "never")
+                        tabs.tabStyle.enabled = true;
+                    else {
+                        prefs.safeSet("browser.tabs.autoHide", value === "multitab",
+                                      "See 'showtabline' option.");
+                        tabs.tabStyle.enabled = false;
+                    }
+                    if (value !== "multitab" || !dactyl.has("Gecko2"))
+                        config.tabStrip.collapsed = false;
+                    if (config.tabbrowser.tabContainer._positionPinnedTabs)
+                        config.tabbrowser.tabContainer._positionPinnedTabs();
+                    return value;
+                },
+                values: {
+                    "never":    "Never show the tab bar",
+                    "multitab": "Show the tab bar when there are multiple tabs",
+                    "always":   "Always show the tab bar"
+                }
+            });
+
+        if (config.hasTabbrowser) {
+            let activateGroups = [
+                ["all", "Activate everything"],
+                ["addons", ":addo[ns] command"],
+                ["bookmarks", "Tabs loaded from bookmarks", "loadBookmarksInBackground"],
+                ["diverted", "Links with targets set to new tabs", "loadDivertedInBackground"],
+                ["downloads", ":downl[oads] command"],
+                ["extoptions", ":exto[ptions] command"],
+                ["help", ":h[elp] command"],
+                ["homepage", "gH mapping"],
+                ["links", "Middle- or Control-clicked links", "loadInBackground"],
+                ["quickmark", "go and gn mappings"],
+                ["tabopen", ":tabopen[!] command"],
+                ["paste", "P and gP mappings"]
+            ];
+            options.add(["activate", "act"],
+                "Define when newly created tabs are automatically activated",
+                "stringlist", [g[0] for (g in values(activateGroups.slice(1))) if (!g[2] || !prefs.get("browser.tabs." + g[2]))].join(","),
+                {
+                    values: activateGroups,
+                    has: Option.has.toggleAll,
+                    setter: function (newValues) {
+                        let valueSet = set(newValues);
+                        for (let group in values(activateGroups))
+                            if (group[2])
+                                prefs.safeSet("browser.tabs." + group[2],
+                                              !(valueSet["all"] ^ valueSet[group[0]]),
+                                              "See the 'activate' option");
+                        return newValues;
+                    }
+                });
+
+            options.add(["newtab"],
+                "Define which commands should output in a new tab by default",
+                "stringlist", "",
+                {
+                    values: {
+                        "all": "All commands",
+                        "addons": ":addo[ns] command",
+                        "downloads": ":downl[oads] command",
+                        "extoptions": ":exto[ptions] command",
+                        "help": ":h[elp] command",
+                        "javascript": ":javascript! or :js! command",
+                        "prefs": ":pref[erences]! or :prefs! command"
+                    },
+                    has: Option.has.toggleAll
+                });
+
+            // TODO: Is this really applicable to Melodactyl?
+            options.add(["popups", "pps"],
+                "Where to show requested popup windows",
+                "stringlist", "tab",
+                {
+                    setter: function (values) {
+                        let open = 1, restriction = 0;
+                        for (let [, opt] in Iterator(values)) {
+                            if (opt == "tab")
+                                open = 3;
+                            else if (opt == "window")
+                                open = 2;
+                            else if (opt == "resized")
+                                restriction = 2;
+                        }
+
+                        prefs.safeSet("browser.link.open_newwindow", open,
+                                      "See 'popups' option.");
+                        prefs.safeSet("browser.link.open_newwindow.restriction", restriction,
+                                      "See 'popups' option.");
+                        return values;
+                    },
+                    values: {
+                        "tab":     "Open popups in a new tab",
+                        "window":  "Open popups in a new window",
+                        "resized": "Open resized popups in a new window"
+                    }
+                });
+        }
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/common/contrib/fix_symlinks.py b/common/contrib/fix_symlinks.py
new file mode 100644 (file)
index 0000000..2a04070
--- /dev/null
@@ -0,0 +1,20 @@
+from mercurial import util
+import os
+
+def fix_symlinks(ui, repo, hooktype, parent1, **kwargs):
+    revert = hooktype in ('precommit', 'preupdate')
+    ctxt = repo[parent1]
+    for filename in ctxt:
+        file = ctxt[filename]
+        if 'l' in file.flags():
+            path = repo.wjoin(file.path())
+            try:
+                os.unlink(path)
+            except Exception, e:
+                print repr(e)
+            if revert:
+                repo.wwrite(file.path(), file.data(), '')
+            else:
+                target = os.path.join(os.path.dirname(path), file.data())
+                util.copyfiles(target, path, True)
+
diff --git a/common/javascript.vim b/common/javascript.vim
new file mode 100644 (file)
index 0000000..c1bfe25
--- /dev/null
@@ -0,0 +1,343 @@
+" Vim syntax file
+" Language:     JavaScript
+" Maintainer:   Yi Zhao (ZHAOYI) <zzlinux AT hotmail DOT com>
+" Last Change:  May 17, 2007
+" Version:      0.7.5
+" Changes:      1, Get the vimdiff problem fixed finally.
+"                Matthew Gallant reported the problem and test the fix. ;)
+"               2, Follow the suggestioin from Ingo Karkat.
+"                The 'foldtext' and 'foldlevel' settings should only be
+"                changed if the file being edited is pure JavaScript,
+"                not if JavaScript syntax is embedded inside other syntaxes.
+"               3, Remove function FT_JavaScriptDoc().
+"                Since VIM do the better than me.
+"
+" TODO:
+"  - Add the HTML syntax inside the JSDoc
+
+if !exists("main_syntax")
+  if version < 600
+    syntax clear
+  elseif exists("b:current_syntax")
+    finish
+  endif
+  let main_syntax = 'javascript'
+endif
+
+"" Drop fold if it set but VIM doesn't support it.
+let b:javascript_fold='true'
+if version < 600    " Don't support the old version
+  unlet! b:javascript_fold
+endif
+
+syn include @xmlTop syntax/xml.vim
+unlet b:current_syntax
+
+syn include @cssTop syntax/css.vim
+unlet b:current_syntax
+
+"" dollar sigh is permittd anywhere in an identifier
+setlocal iskeyword+=$
+
+syntax sync fromstart
+syntax sync maxlines=200
+
+"" JavaScript comments
+syntax keyword javaScriptCommentTodo    TODO FIXME XXX TBD contained
+syntax region  javaScriptLineComment    start=+\/\/+ end="\v$|(\</?(css|e4x)\>)@=" keepend contains=javaScriptCommentTodo,@Spell
+syntax region  javaScriptLineComment    start=+^\s*\/\/+ skip=+\n\s*\/\/+ end="\v$|(\</?(css|e4x)\>)@=" keepend contains=javaScriptCommentTodo,@Spell fold
+syntax region  javaScriptCvsTag         start="\$\cid:" end="\$" oneline contained
+syntax region  javaScriptComment        start="/\*"  end="\v\*/|(\</?(css|e4x)\>)@=" contains=javaScriptCommentTodo,javaScriptCvsTag,@Spell fold
+
+"" JSDoc support start
+if !exists("javascript_ignore_javaScriptdoc")
+  syntax case ignore
+
+  "" syntax coloring for javadoc comments (HTML)
+  "syntax include @javaHtml <sfile>:p:h/html.vim
+  "unlet b:current_syntax
+
+  syntax region javaScriptDocComment    matchgroup=javaScriptComment start="/\*\*\s*$"  end="\*/" contains=javaScriptDocTags,javaScriptCommentTodo,javaScriptCvsTag,@javaScriptHtml,@Spell fold
+  syntax match  javaScriptDocTags       contained "@\(param\|argument\|requires\|exception\|throws\|type\|class\|extends\|see\|link\|member\|module\|method\|title\|namespace\|optional\|default\|base\|file\)\>" nextgroup=javaScriptDocParam,javaScriptDocSeeTag skipwhite
+  syntax match  javaScriptDocTags       contained "@\(beta\|deprecated\|description\|fileoverview\|author\|license\|version\|returns\=\|constructor\|private\|protected\|final\|ignore\|addon\|exec\)\>"
+  syntax match  javaScriptDocParam      contained "\%(#\|\w\|\.\|:\|\/\)\+"
+  syntax region javaScriptDocSeeTag     contained matchgroup=javaScriptDocSeeTag start="{" end="}" contains=javaScriptDocTags
+
+  syntax case match
+endif   "" JSDoc end
+
+syntax case match
+
+"" Syntax in the JavaScript code
+syntax match   javaScriptSpecial        "\\\d\d\d\|\\x\x\{2\}\|\\u\x\{4\}\|\\."
+syntax region  javaScriptStringD        start=+"+  skip=+\\\\\|\\$"+  end=+"+  contains=javaScriptSpecial,@htmlPreproc
+syntax region  javaScriptStringS        start=+'+  skip=+\\\\\|\\$'+  end=+'+  contains=javaScriptSpecial,@htmlPreproc
+syntax region  javaScriptRegexpString   start=+/\(\*\|/\)\@!+ skip=+\\\\\|\\/+ end=+/[gim]\{-,3}+ contains=javaScriptSpecial,@htmlPreproc oneline
+syntax match   javaScriptNumber         /\<-\=\d\+L\=\>\|\<0[xX]\x\+\>/
+syntax match   javaScriptFloat          /\<-\=\%(\d\+\.\d\+\|\d\+\.\|\.\d\+\)\%([eE][+-]\=\d\+\)\=\>/
+syntax match   javaScriptLabel          /\(?\s*\)\@<!\<\w\+\(\s*:\)\@=/
+
+"" JavaScript Prototype
+syntax keyword javaScriptPrototype      prototype
+
+"" Programm Keywords
+syntax keyword javaScriptSource         import export
+syntax keyword javaScriptType           const this var void yield arguments
+syntax keyword javaScriptOperator       delete new in instanceof let typeof
+syntax keyword javaScriptBoolean        true false
+syntax keyword javaScriptNull           null
+
+"" Statement Keywords
+syntax keyword javaScriptConditional    if else
+syntax keyword javaScriptRepeat         do while for
+syntax keyword javaScriptBranch         break continue switch case default return
+syntax keyword javaScriptStatement      try catch throw with finally
+
+syntax keyword javaScriptGlobalObjects  Array Boolean Date Function Infinity JavaArray JavaClass JavaObject JavaPackage Math Number NaN Object Packages RegExp String Undefined java netscape sun
+
+syntax keyword javaScriptExceptions     Error EvalError RangeError ReferenceError SyntaxError TypeError URIError
+
+syntax keyword javaScriptFutureKeys     abstract enum int short boolean export interface static byte extends long super char final native synchronized class float package throws goto private transient debugger implements protected volatile double import public
+
+"" DOM/HTML/CSS specified things
+
+  " DOM2 Objects
+  syntax keyword javaScriptGlobalObjects  DOMImplementation DocumentFragment Document Node NodeList NamedNodeMap CharacterData Attr Element Text Comment CDATASection DocumentType Notation Entity EntityReference ProcessingInstruction
+  syntax keyword javaScriptExceptions     DOMException
+
+  " DOM2 CONSTANT
+  syntax keyword javaScriptDomErrNo       INDEX_SIZE_ERR DOMSTRING_SIZE_ERR HIERARCHY_REQUEST_ERR WRONG_DOCUMENT_ERR INVALID_CHARACTER_ERR NO_DATA_ALLOWED_ERR NO_MODIFICATION_ALLOWED_ERR NOT_FOUND_ERR NOT_SUPPORTED_ERR INUSE_ATTRIBUTE_ERR INVALID_STATE_ERR SYNTAX_ERR INVALID_MODIFICATION_ERR NAMESPACE_ERR INVALID_ACCESS_ERR
+  syntax keyword javaScriptDomNodeConsts  ELEMENT_NODE ATTRIBUTE_NODE TEXT_NODE CDATA_SECTION_NODE ENTITY_REFERENCE_NODE ENTITY_NODE PROCESSING_INSTRUCTION_NODE COMMENT_NODE DOCUMENT_NODE DOCUMENT_TYPE_NODE DOCUMENT_FRAGMENT_NODE NOTATION_NODE
+
+  " HTML events and internal variables
+  syntax case ignore
+  syntax keyword javaScriptHtmlEvents     onblur onclick oncontextmenu ondblclick onfocus onkeydown onkeypress onkeyup onmousedown onmousemove onmouseout onmouseover onmouseup onresize
+  syntax case match
+
+" Follow stuff should be highligh within a special context
+" While it can't be handled with context depended with Regex based highlight
+" So, turn it off by default
+if exists("javascript_enable_domhtmlcss")
+
+    " DOM2 things
+    syntax match javaScriptDomElemAttrs     contained /\%(nodeName\|nodeValue\|nodeType\|parentNode\|childNodes\|firstChild\|lastChild\|previousSibling\|nextSibling\|attributes\|ownerDocument\|namespaceURI\|prefix\|localName\|tagName\)\>/
+    syntax match javaScriptDomElemFuncs     contained /\%(insertBefore\|replaceChild\|removeChild\|appendChild\|hasChildNodes\|cloneNode\|normalize\|isSupported\|hasAttributes\|getAttribute\|setAttribute\|removeAttribute\|getAttributeNode\|setAttributeNode\|removeAttributeNode\|getElementsByTagName\|getAttributeNS\|setAttributeNS\|removeAttributeNS\|getAttributeNodeNS\|setAttributeNodeNS\|getElementsByTagNameNS\|hasAttribute\|hasAttributeNS\)\>/ nextgroup=javaScriptParen skipwhite
+    " HTML things
+    syntax match javaScriptHtmlElemAttrs    contained /\%(className\|clientHeight\|clientLeft\|clientTop\|clientWidth\|dir\|id\|innerHTML\|lang\|length\|offsetHeight\|offsetLeft\|offsetParent\|offsetTop\|offsetWidth\|scrollHeight\|scrollLeft\|scrollTop\|scrollWidth\|style\|tabIndex\|title\)\>/
+    syntax match javaScriptHtmlElemFuncs    contained /\%(blur\|click\|focus\|scrollIntoView\|addEventListener\|dispatchEvent\|removeEventListener\|item\)\>/ nextgroup=javaScriptParen skipwhite
+
+    " CSS Styles in JavaScript
+    syntax keyword javaScriptCssStyles      contained color font fontFamily fontSize fontSizeAdjust fontStretch fontStyle fontVariant fontWeight letterSpacing lineBreak lineHeight quotes rubyAlign rubyOverhang rubyPosition
+    syntax keyword javaScriptCssStyles      contained textAlign textAlignLast textAutospace textDecoration textIndent textJustify textJustifyTrim textKashidaSpace textOverflowW6 textShadow textTransform textUnderlinePosition
+    syntax keyword javaScriptCssStyles      contained unicodeBidi whiteSpace wordBreak wordSpacing wordWrap writingMode
+    syntax keyword javaScriptCssStyles      contained bottom height left position right top width zIndex
+    syntax keyword javaScriptCssStyles      contained border borderBottom borderLeft borderRight borderTop borderBottomColor borderLeftColor borderTopColor borderBottomStyle borderLeftStyle borderRightStyle borderTopStyle borderBottomWidth borderLeftWidth borderRightWidth borderTopWidth borderColor borderStyle borderWidth borderCollapse borderSpacing captionSide emptyCells tableLayout
+    syntax keyword javaScriptCssStyles      contained margin marginBottom marginLeft marginRight marginTop outline outlineColor outlineStyle outlineWidth padding paddingBottom paddingLeft paddingRight paddingTop
+    syntax keyword javaScriptCssStyles      contained listStyle listStyleImage listStylePosition listStyleType
+    syntax keyword javaScriptCssStyles      contained background backgroundAttachment backgroundColor backgroundImage gackgroundPosition backgroundPositionX backgroundPositionY backgroundRepeat
+    syntax keyword javaScriptCssStyles      contained clear clip clipBottom clipLeft clipRight clipTop content counterIncrement counterReset cssFloat cursor direction display filter layoutGrid layoutGridChar layoutGridLine layoutGridMode layoutGridType
+    syntax keyword javaScriptCssStyles      contained marks maxHeight maxWidth minHeight minWidth opacity MozOpacity overflow overflowX overflowY verticalAlign visibility zoom cssText
+    syntax keyword javaScriptCssStyles      contained scrollbar3dLightColor scrollbarArrowColor scrollbarBaseColor scrollbarDarkShadowColor scrollbarFaceColor scrollbarHighlightColor scrollbarShadowColor scrollbarTrackColor
+
+    " Highlight ways
+    syntax match javaScriptDotNotation      "\." nextgroup=javaScriptPrototype,javaScriptDomElemAttrs,javaScriptDomElemFuncs,javaScriptHtmlElemAttrs,javaScriptHtmlElemFuncs
+    syntax match javaScriptDotNotation      "\.style\." nextgroup=javaScriptCssStyles
+
+endif "DOM/HTML/CSS
+
+"" end DOM/HTML/CSS specified things
+
+
+"" Code blocks
+syntax cluster javaScriptAll       contains=javaScriptComment,javaScriptLineComment,javaScriptDocComment,javaScriptStringD,javaScriptStringS,javaScriptRegexpString,javaScriptNumber,javaScriptFloat,javaScriptLabel,javaScriptSource,javaScriptType,javaScriptOperator,javaScriptBoolean,javaScriptNull,javaScriptFunction,javaScriptConditional,javaScriptRepeat,javaScriptBranch,javaScriptStatement,javaScriptGlobalObjects,javaScriptExceptions,javaScriptFutureKeys,javaScriptDomErrNo,javaScriptDomNodeConsts,javaScriptHtmlEvents,javaScriptDotNotation,javascriptE4X,javascriptCSS,javascriptCDATA
+syntax region  javaScriptBracket   matchgroup=javaScriptBracket transparent start="\[" end="\]" contains=@javaScriptAll,javaScriptParensErrB,javaScriptParensErrC,javaScriptBracket,javaScriptParen,javaScriptBlock,@htmlPreproc
+syntax region  javaScriptParen     matchgroup=javaScriptParen   transparent start="("  end=")"  contains=@javaScriptAll,javaScriptParensErrA,javaScriptParensErrC,javaScriptParen,javaScriptBracket,javaScriptBlock,@htmlPreproc
+syntax region  javaScriptBlock     matchgroup=javaScriptBlock   transparent start="{"  end="}"  contains=@javaScriptAll,javaScriptParensErrA,javaScriptParensErrB,javaScriptParen,javaScriptBracket,javaScriptBlock,@htmlPreproc
+
+syntax region  javascriptCDATA matchgroup=javascriptCDATA start="<\!\[CDATA\[" end="\]\]>" keepend contains=javascriptCSS
+syntax region  javascriptCSS   matchgroup=javascriptCSSDelimiter start="<css>" end="</css>" contains=@cssTop
+syntax region  javascriptE4X   matchgroup=javascriptE4XDelimiter start="<e4x>" end="</e4x>" contains=@xmlTop
+syntax region  javascriptE4X   matchgroup=javascriptE4XDelimiter start="<>" end="</>" contains=@xmlTop oneline
+
+"" catch errors caused by wrong parenthesis
+syntax match   javaScriptParensError    ")\|}\|\]"
+syntax match   javaScriptParensErrA     contained "\]"
+syntax match   javaScriptParensErrB     contained ")"
+syntax match   javaScriptParensErrC     contained "}"
+
+if main_syntax == "javascript"
+  syntax sync ccomment javaScriptComment
+endif
+
+"" Fold control
+if exists("b:javascript_fold")
+    syntax match   javaScriptFunction       /\<function\>/ nextgroup=javaScriptFuncName skipwhite
+    syntax match   javaScriptOpAssign       /=\@<!=/ nextgroup=javaScriptFuncBlock skipwhite skipempty
+    syntax region  javaScriptFuncName       contained matchgroup=javaScriptFuncName start=/\%(\$\|\w\)*\s*(/ end=/)/ contains=javaScriptLineComment,javaScriptComment nextgroup=javaScriptFuncBlock skipwhite skipempty
+    syntax region  javaScriptFuncBlock      contained matchgroup=javaScriptFuncBlock start="{" end="}" contains=@javaScriptAll,javaScriptParensErrA,javaScriptParensErrB,javaScriptParen,javaScriptBracket,javaScriptBlock fold
+
+    if &l:filetype=='javascript' && !&diff
+      " Fold setting
+      " Redefine the foldtext (to show a JS function outline) and foldlevel
+      " only if the entire buffer is JavaScript, but not if JavaScript syntax
+      " is embedded in another syntax (e.g. HTML).
+      setlocal foldmethod=syntax
+      setlocal foldlevel=4
+    endif
+else
+    syntax keyword javaScriptFunction       function
+    setlocal foldmethod<
+    setlocal foldlevel<
+endif
+
+" Define the default highlighting.
+" For version 5.7 and earlier: only when not done already
+" For version 5.8 and later: only when an item doesn't have highlighting yet
+if version >= 508 || !exists("did_javascript_syn_inits")
+  if version < 508
+    let did_javascript_syn_inits = 1
+    command -nargs=+ HiLink hi link <args>
+  else
+    command -nargs=+ HiLink hi def link <args>
+  endif
+  HiLink javascriptCDATA                String
+  HiLink javaScriptComment              Comment
+  HiLink javaScriptLineComment          Comment
+  HiLink javaScriptDocComment           Comment
+  HiLink javaScriptCommentTodo          Todo
+  HiLink javaScriptCvsTag               Function
+  HiLink javaScriptDocTags              Special
+  HiLink javaScriptDocSeeTag            Function
+  HiLink javaScriptDocParam             Function
+  HiLink javaScriptStringS              String
+  HiLink javaScriptStringD              String
+  HiLink javaScriptRegexpString         String
+  HiLink javaScriptCharacter            Character
+  HiLink javaScriptPrototype            Type
+  HiLink javaScriptConditional          Conditional
+  HiLink javaScriptBranch               Conditional
+  HiLink javaScriptRepeat               Repeat
+  HiLink javaScriptStatement            Statement
+  HiLink javaScriptFunction             Function
+  HiLink javaScriptError                Error
+  HiLink javaScriptParensError          Error
+  HiLink javaScriptParensErrA           Error
+  HiLink javaScriptParensErrB           Error
+  HiLink javaScriptParensErrC           Error
+  HiLink javaScriptOperator             Operator
+  HiLink javaScriptType                 Type
+  HiLink javaScriptNull                 Type
+  HiLink javaScriptNumber               Number
+  HiLink javaScriptFloat                Number
+  HiLink javaScriptBoolean              Boolean
+  HiLink javaScriptLabel                Label
+  HiLink javaScriptSpecial              Special
+  HiLink javaScriptSource               Special
+  HiLink javaScriptGlobalObjects        Special
+  HiLink javaScriptExceptions           Special
+
+  HiLink javaScriptDomErrNo             Constant
+  HiLink javaScriptDomNodeConsts        Constant
+  HiLink javaScriptDomElemAttrs         Label
+  HiLink javaScriptDomElemFuncs         PreProc
+
+  HiLink javaScriptHtmlEvents           Special
+  HiLink javaScriptHtmlElemAttrs        Label
+  HiLink javaScriptHtmlElemFuncs        PreProc
+
+  HiLink javaScriptCssStyles            Label
+
+  delcommand HiLink
+endif
+
+" Define the htmlJavaScript for HTML syntax html.vim
+"syntax clear htmlJavaScript
+"syntax clear javaScriptExpression
+syntax cluster  htmlJavaScript contains=@javaScriptAll,javaScriptBracket,javaScriptParen,javaScriptBlock,javaScriptParenError
+syntax cluster  javaScriptExpression contains=@javaScriptAll,javaScriptBracket,javaScriptParen,javaScriptBlock,javaScriptParenError,@htmlPreproc
+
+let b:current_syntax = "javascript"
+if main_syntax == 'javascript'
+  unlet main_syntax
+endif
+
+
+if exists('b:did_indent')
+  finish
+endif
+let b:did_indent = 1
+
+setlocal indentexpr=GetJsIndent()
+setlocal indentkeys=0{,0},0),:,!^F,O,e,=*/
+" Clean CR when the file is in Unix format
+if &fileformat == "unix"
+    silent! %s/\r$//g
+endif
+" Only define the functions once per Vim session.
+"if exists("*GetJsIndent")
+"    finish
+"endif
+"function! GetJsIndent()
+"    let pnum = prevnonblank(v:lnum - 1)
+"    if pnum == 0
+"       return 0
+"    endif
+"    let line = getline(v:lnum)
+"    let pline = getline(pnum)
+"    let ind = indent(pnum)
+"
+"    if pline =~ '{\s*$\|[\s*$\|(\s*$'
+"      let ind = ind + &sw
+"    endif
+"
+"    if pline =~ ';\s*$' && line =~ '^\s*}'
+"        let ind = ind - &sw
+"    endif
+"
+"    if pline =~ '\s*]\s*$' && line =~ '^\s*),\s*$'
+"      let ind = ind - &sw
+"    endif
+"
+"    if pline =~ '\s*]\s*$' && line =~ '^\s*}\s*$'
+"      let ind = ind - &sw
+"    endif
+"
+"    if line =~ '^\s*});\s*$\|^\s*);\s*$' && pline !~ ';\s*$'
+"      let ind = ind - &sw
+"    endif
+"
+"    if line =~ '^\s*})' && pline =~ '\s*,\s*$'
+"      let ind = ind - &sw
+"    endif
+"
+"    if line =~ '^\s*}();\s*$' && pline =~ '^\s*}\s*$'
+"      let ind = ind - &sw
+"    endif
+"
+"    if line =~ '^\s*}),\s*$'
+"      let ind = ind - &sw
+"    endif
+"
+"    if pline =~ '^\s*}\s*$' && line =~ '),\s*$'
+"       let ind = ind - &sw
+"    endif
+"
+"    if pline =~ '^\s*for\s*' && line =~ ')\s*$'
+"       let ind = ind + &sw
+"    endif
+"
+"    if line =~ '^\s*}\s*$\|^\s*]\s*$\|\s*},\|\s*]);\s*\|\s*}]\s*$\|\s*};\s*$\|\s*})$\|\s*}).el$' && pline !~ '\s*;\s*$\|\s*]\s*$' && line !~ '^\s*{' && line !~ '\s*{\s*}\s*'
+"          let ind = ind - &sw
+"    endif
+"
+"    if pline =~ '^\s*/\*'
+"      let ind = ind + 1
+"    endif
+"
+"    if pline =~ '\*/$'
+"      let ind = ind - 1
+"    endif
+"    return ind
+"endfunction
+
+" vim: ts=4
diff --git a/common/locale/en-US/all.xml b/common/locale/en-US/all.xml
new file mode 100644 (file)
index 0000000..59c1830
--- /dev/null
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="all"
+    title="&dactyl.appName; All"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+<tags>all</tags>
+
+<toc/>
+
+<include href="intro" tag="intro.xml"/>
+<include href="starting" tag="starting.xml"/>
+<include href="browsing" tag="browsing.xml"/>
+<include href="buffer" tag="buffer.xml"/>
+<include href="cmdline" tag="cmdline.xml"/>
+<include href="editing" tag="editing.xml"/>
+<include href="options" tag="options.xml"/>
+<include href="pattern" tag="pattern.xml"/>
+<include href="tabs" tag="tabs.xml"/>
+<include href="hints" tag="hints.xml"/>
+<include href="map" tag="map.xml"/>
+<include href="eval" tag="eval.xml"/>
+<include href="marks" tag="marks.xml"/>
+<include href="repeat" tag="repeat.xml"/>
+<include href="autocommands" tag="autocommands.xml"/>
+<include href="print" tag="print.xml"/>
+<include href="gui" tag="gui.xml"/>
+<include href="styling" tag="styling.xml"/>
+<include href="message" tag="message.xml"/>
+<include href="privacy" tag="privacy.xml"/>
+<include href="developer" tag="developer.xml"/>
+<include href="various" tag="various.xml"/>
+<include href="plugins" tag="plugins.xml"/>
+<include href="faq" tag="faq.xml"/>
+<include href="index" tag="index.xml"/>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/autocommands.xml b/common/locale/en-US/autocommands.xml
new file mode 100644 (file)
index 0000000..260eb07
--- /dev/null
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="autocommands"
+    title="&dactyl.appName; Autocommands"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="autocommands">Automatic commands</h1>
+<toc start="2"/>
+
+<p>
+    Autocommands are a way to automatically execute code when
+    certain events happen.
+</p>
+
+<item>
+    <tags>:au :autocmd</tags>
+    <spec>:au<oa>tocmd</oa><oa>!</oa> <oa>events</oa> <oa>filter</oa> <oa>cmd</oa></spec>
+    <description>
+        <p>Execute commands automatically on events.</p>
+
+        <p>
+            When <oa>cmd</oa> is not given, list all commands
+            defined for the given <oa>events</oa> and <oa>filter</oa>.
+            When <oa>!</oa> is given, delete the matching commands
+            rather than listing them.
+        </p>
+
+        <p>
+            When <oa>cmd</oa> is given, add it to the list of commands to be
+            executed when <oa>events</oa> occur for pages matching the
+            comma-separated list of <t>site-filters</t>, <oa>filter</oa>. If the
+            <em>-javascript</em> (short name <em>-js</em>) option is given,
+            <oa>cmd</oa> is interpreted as JavaScript code. Otherwise, it is
+            interpreted as an Ex command.
+        </p>
+
+        <p>
+            If the <em>-group</em>=<a>group</a> flag is given, add this autocmd
+            to the named <t>group</t>. Any filters for <a>group</a> apply in
+            addition to <oa>filter</oa>.
+        </p>
+
+        <p>Available <oa>events</oa>:</p>
+
+        <dl tag="autocommand-list"/>
+
+        <p>
+            For Ex <oa>cmd</oa>s, the following keywords are
+            replaced with the appropriate value before the commands
+            are executed. For JavaScript commands, they may be
+            accessed as ordinary variables, sans angle brackets.
+        </p>
+
+        <dl tag="autocommand-args"/>
+    </description>
+</item>
+
+<item>
+</item>
+
+<item>
+    <tags>:doautoa :doautoall</tags>
+    <spec>:doautoa<oa>ll</oa> <a>event</a> <oa>url</oa></spec>
+    <description>
+        <p>
+            Apply all <a>event</a> autocommands matching the
+            specified <oa>url</oa> to all buffers. If no
+            <oa>url</oa> is specified use the current URL.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:do :doautocmd</tags>
+    <spec>:do<oa>autocmd</oa> <a>event</a> <oa>url</oa></spec>
+    <description>
+        <p>
+            Apply all autocommands matching the specified
+            <oa>url</oa> to the current buffer. If no <oa>url</oa>
+            is specified use the current URL.
+        </p>
+    </description>
+</item>
+<h2 tag="autocmd-examples">Examples</h2>
+
+<p>Enable <em>passthrough</em> mode on all Google sites:</p>
+
+<code><ex>:autocmd LocationChange</ex> <str delim="">google.com</str> <ex>:normal!</ex> <k name="C-z"/></code>
+
+<p>Enable <em>passthrough</em> mode on <em>some</em> Google sites:</p>
+
+<code><ex>:autocmd LocationChange</ex> <str delim="'">^https?://(www|mail)\.google\.com/</str> <ex>:normal!</ex> <k name="C-z"/></code>
+
+<p>or</p>
+
+<code><ex>:autocmd LocationChange</ex> <str delim="">www.google.com</str>,<str delim="">mail.google.com</str> <ex>:normal!</ex> <k name="C-z"/></code>
+
+<p>Set the filetype to mail when editing email at Gmail:</p>
+
+<code><ex>:autocmd LocationChange</ex> !<str delim="">mail.google.com</str>,<str delim="">*</str> <se opt="editor" op="&amp;"/>
+<ex>:autocmd LocationChange</ex> <str delim="'">mail.google.com</str> <se opt="editor"><str>gvim -f -c 'set ft=mail' +&lt;line></str></se></code>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/browsing.xml b/common/locale/en-US/browsing.xml
new file mode 100644 (file)
index 0000000..cbb31f1
--- /dev/null
@@ -0,0 +1,572 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="browsing"
+    title="&dactyl.appName; Browsing"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="surfing browsing">Browsing</h1>
+<toc start="2"/>
+
+<h2 tag="bypassing-&dactyl.name;">Bypassing &dactyl.appName;</h2>
+
+&dactyl.appName; overrides nearly all &dactyl.host; keys in order to
+make browsing more pleasant for Vim users. On the occasions when you
+want to bypass &dactyl.appName;'s key handling and pass keys directly to
+&dactyl.host; or to a web page, you have two options:
+
+<item>
+    <tags><![CDATA[<A-b>]]></tags>
+    <spec><![CDATA[<A-b>]]></spec>
+    <description>
+        <p>
+            Process the next key as a builtin mapping, ignoring any user defined
+            mappings and <o>passkeys</o> settings.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[send-key <pass-next-key> <C-v> CTRL-V]]></tags>
+    <spec><![CDATA[<C-v>]]></spec>
+    <description>
+        <p>
+            Pass the next key press directly to &dactyl.host;.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[pass-through <pass-all-keys> <C-z> CTRL-Z]]></tags>
+    <spec><![CDATA[<C-z>]]></spec>
+    <description>
+        <p>
+            Pass all keys except for <k name="Esc"/> directly to
+            &dactyl.host;. When <k name="Esc"/> is pressed,
+            resume normal key handling. This is especially useful
+            for web sites which make heavy use of key bindings.
+        </p>
+    </description>
+</item>
+
+<h2 tag="opening">Opening web pages</h2>
+
+<item>
+    <tags>o :o :open</tags>
+    <spec>:o<oa>pen</oa> <oa>args</oa></spec>
+    <spec>o</spec>
+    <description>
+        <p>
+            Open a single URL in the current tab, or multiple URLs
+            in the current tab and background tabs. URLs may be
+            separated with <o>urlseparator</o>, in which case the
+            first URL is opened in the current tab and the rest are
+            opened in new background tabs.
+        </p>
+
+        <p>
+            Each URL may be one of the following:
+        </p>
+
+        <ol>
+            <li>
+                A local filename, if it begins with <em>/</em>,
+                <em>./</em>, or <em>~/</em> and the specified file
+                exists.
+            </li>
+            <li>
+                <p>
+                    A search or bookmark keyword, or a search engine
+                    name, followed by search arguments.
+                </p>
+                <example><ex>:open wikipedia Linus Torvalds</ex></example>
+                <p>
+                    Search engines can be edited via
+                    <ex>:dialog searchengines</ex> and search
+                    keywords may be added by right clicking any
+                    search box and selecting <str>Add a Keyword for
+                    this Search</str>.
+                </p>
+            </li>
+            <li>
+                <p>
+                    Any search string which does not look like a URL or
+                    hostname, which will be passed to the default
+                    search engine (see <o>defsearch</o>).
+                </p>
+                <example><ex>:open Linus Torvalds</ex></example>
+           </li>
+           <li>
+               Any other value is passed directly &dactyl.host; and
+               must be a valid URL or hostname.
+           </li>
+        </ol>
+
+        <p>
+            <ex>:open</ex> provides powerful URL completion from
+            several possible sources, which can be adjusted via the
+            <o>complete</o> option.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>t :t :tabopen :tabnew</tags>
+    <spec>:tabopen<oa>!</oa> <oa>args</oa></spec>
+    <spec>t</spec>
+    <description>
+        <p>
+            Like <ex>:open</ex>, but all arguments are opened in new
+            tabs. The first new tab is activated if <o>activate</o>
+            contains <str>tabopen</str> or <oa>!</oa> is provided.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>T</tags>
+    <spec>T</spec>
+    <description short="true">
+        <p>
+            Open a <ex>:tabopen</ex> prompt followed by the current URL.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:tabdu :tabduplicate</tags>
+    <spec>:<oa>count</oa>tabdu<oa>plicate</oa><oa>!</oa></spec>
+    <description>
+        <p>
+            Duplicates current tab <oa>count</oa> times. The first
+            new tab is activated if <o>activate</o> contains
+            <str>tabopen</str> or <oa>!</oa> is provided.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>O</tags>
+    <spec>O</spec>
+    <description short="true">
+        <p>
+            Open an <ex>:open</ex> prompt followed by the current URL.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>w :winopen :wopen</tags>
+    <spec>:wino<oa>pen</oa><oa>!</oa> <oa>args</oa></spec>
+    <spec>w</spec>
+    <description>
+        <p>
+            Like <ex>:tabopen</ex>, but all arguments are opened in
+            a single new window.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>W</tags>
+    <spec>W</spec>
+    <description short="true">
+        <p>
+            Open a <ex>:winopen</ex> prompt followed by the current URL.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<open-clipboard-url> <MiddleMouse> p]]></tags>
+    <strut/>
+    <spec>p</spec>
+    <description>
+        <p>
+            Open (put) a URL based on the current clipboard
+            contents, or, on X11 systems, the currently selected
+            text. All white space is stripped from the selection and
+            it is opened in the same manner as <ex>:open</ex>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;tab-open-clipboard-url> P</tags>
+    <strut/>
+    <spec>P</spec>
+    <description>
+        <p>
+            Open (put) a URL based on the current clipboard contents
+            in a new buffer. Works like <k>p</k> but opens a new
+            tab. The new tab is activated if <o>activate</o>
+            contains <str>paste</str>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gP</tags>
+    <strut/>
+    <spec>gP</spec>
+    <description>
+        <p>
+            Open (put) a URL based on the current clipboard contents
+            in a new buffer. The new tab is activated if <o>activate</o>
+            does <em>not</em> contain <str>paste</str>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<C-x>]]></tags>
+    <strut/>
+    <spec><oa>count</oa>&lt;C-x></spec>
+    <description>
+        <p>
+            Decrements the last number in URL by 1, or by
+            <oa>count</oa> if given. Negative numbers are not
+            supported as trailing numbers in URLs are often preceded
+            by hyphens.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<C-a>]]></tags>
+    <strut/>
+    <spec><oa>count</oa>&lt;C-a></spec>
+    <description>
+        <p>
+            Increments the last number in URL by 1, or by
+            <oa>count</oa> if given.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>~</tags>
+    <spec>~</spec>
+    <description short="true">
+        <p>Open home directory. Equivalent to <ex>:open ~/</ex></p>
+    </description>
+</item>
+
+<h2 tag="navigating">Navigating</h2>
+
+<item>
+    <tags><![CDATA[H <C-o> CTRL-O :ba :back]]></tags>
+    <spec>:<oa>count</oa>ba<oa>ck</oa> <oa>url</oa></spec>
+    <spec>:ba<oa>ck</oa>!</spec>
+    <spec><oa>count</oa>&lt;C-o></spec>
+    <description>
+        <p>
+            Go <oa>count</oa> pages back in the browser history. If
+            <oa>url</oa> is specified go back to the first matching
+            URL. The special version <ex>:back!</ex> goes to the
+            beginning of the browser history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[L <C-i> CTRL-I :fo :fw :forward]]></tags>
+    <spec>:<oa>count</oa>fo<oa>rward</oa> <oa>url</oa></spec>
+    <spec>:fo<oa>rward</oa>!</spec>
+    <spec><oa>count</oa>&lt;C-i></spec>
+    <description>
+        <p>
+            Go <oa>count</oa> pages forward in the browser history.
+            If <oa>url</oa> is specified go forward to the first
+            matching URL. The special version <ex>:forward!</ex>
+            goes to the end of the browser history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:ju :jumps</tags>
+    <spec>:ju<oa>mps</oa></spec>
+    <description>
+        <p>List all jumps, i.e., the current tab's session history.</p>
+
+        <p>
+            Current history position is marked with <em>></em>.
+            Jump numbers may be used as counts for with
+            <ex>:back</ex> or <ex>:forward</ex>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gh</tags>
+    <spec>gh</spec>
+    <description short="true">
+        <p>Go home. Opens the homepage in the current tab.</p>
+    </description>
+</item>
+
+<item>
+    <tags>gH</tags>
+    <strut/>
+    <spec>gH</spec>
+    <description>
+        <p>
+            Go home in a new tab. Opens the homepage in a new tab.
+            The new tab is activated if <o>activate</o> contains
+            <str>homepage</str>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gu</tags>
+    <spec><oa>count</oa>gu</spec>
+    <description short="true">
+        <p>Go to <oa>count</oa>th parent directory.</p>
+
+        <p>
+            For example, at the URL
+            <tt>http://www.example.com/dir1/dir2/file.htm</tt>,
+            2<k>gu</k> opens <tt>http://www.example.com/dir1/</tt>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gU</tags>
+    <spec>gU</spec>
+    <description short="true">
+        <p>Go to the root of the web site.</p>
+
+        <p>
+            For example, at the URL
+            <tt>http://www.example.com/dir1/dir2/file.htm</tt>,
+            <k>gU</k> opens <tt>http://www.example.com/</tt>.
+        </p>
+    </description>
+</item>
+
+<h2 tag="reloading">Reloading</h2>
+
+<item>
+    <tags>&lt;reload> r</tags>
+    <spec>r</spec>
+    <description short="true">
+        <p>Reload the current web page.</p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;full-reload> R</tags>
+    <spec>R</spec>
+    <description short="true">
+        <p>Reload the current web page without using the cache.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:reh :rehash</tags>
+    <spec>:reh<oa>ash</oa> <oa>arg</oa> …</spec>
+    <description>
+        <p>
+            Reload the &dactyl.appName; add-on, including all code, plugins,
+            and configuration. For users running directly from the development
+            repository, this is a good way to update to the latest version or
+            to test your changes.
+        </p>
+        <p>
+            Any arguments supplied are parsed as command-line arguments as
+            specified in <t>startup-options</t>.
+        </p>
+        <warning>
+            Not all plugins are designed to cleanly un-apply during a rehash.
+            While official plugins are safe, beware of possible instability
+            if you rehash while running third-party plugins.
+        </warning>
+    </description>
+</item>
+
+<item>
+    <tags>:re :reload</tags>
+    <spec>:re<oa>load</oa><oa>!</oa></spec>
+    <description>
+        <p>
+            Reload current web page. If <oa>!</oa> is given, reload
+            without using the cache.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:reloada :reloadall</tags>
+    <spec>:reloada<oa>ll</oa><oa>!</oa></spec>
+    <description>
+        <p>
+            Reload all tabs. If <oa>!</oa> is given, reload without
+            using the cache.
+        </p>
+    </description>
+</item>
+
+<h2 tag="stopping">Stopping</h2>
+
+<item>
+    <tags><![CDATA[<C-c> :st :stop]]></tags>
+    <spec>&lt;C-c></spec>
+    <strut/>
+    <spec>:st<oa>op</oa></spec>
+    <description short="true">
+        <p>Stop loading the current web page.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:stopa :stopall</tags>
+    <spec>:stopa<oa>ll</oa></spec>
+    <description short="true">
+        <p>Stop loading all web pages.</p>
+    </description>
+</item>
+
+<h2 tag="writing save-file">Writing</h2>
+
+<item>
+    <tags>:w :write :sav :saveas</tags>
+    <spec>:sav<oa>eas</oa><oa>!</oa> <oa>file</oa></spec>
+    <description>
+        <p>
+            Save current web page to disk. If <oa>file</oa> is omitted, save to
+            the page's default filename. If <oa>file</oa> is a directory or ends
+            with your platform's path separator, save to the page's default
+            filename in that directory. Existing documents will only be
+            overwritten if <oa>!</oa> is given.
+        </p>
+    </description>
+</item>
+
+<item>
+    <spec>:write >> <a>file</a></spec>
+    <description>
+        <p>
+            Appends the current web page to the file <a>file</a>. The given
+            file must already exist.
+        </p>
+    </description>
+</item>
+
+<item>
+    <spec>:write !<a>cmd</a></spec>
+    <description>
+        <p>
+            Writes the current web page to <a>cmd</a> and prints the command's
+            output.
+        </p>
+    </description>
+</item>
+
+<h2 tag="quitting save-session">Quitting</h2>
+
+<item>
+    <tags>:q :quit</tags>
+    <strut/>
+    <spec>:q<oa>uit</oa></spec>
+    <description>
+        <p>
+            Quit current tab. If this is the last tab in the window,
+            close the window.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:qa :qall :quita :quitall</tags>
+    <strut/>
+    <spec>:quita<oa>ll</oa></spec>
+    <description>
+        <p>
+            Quit &dactyl.appName;, no matter how many tabs/windows
+            are open. The session is not stored.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:wc :wclose :winc :winclose</tags>
+    <spec>:winc<oa>lose</oa></spec>
+    <description short="true">
+        <p>Close the current window.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:winon :winonly</tags>
+    <spec>:winon<oa>ly</oa></spec>
+    <description short="true">
+        <p>Close all windows but the current.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:xa :xall :wq :wqa :wqall</tags>
+    <spec>:wqa<oa>ll</oa></spec>
+    <strut/>
+    <spec>:xa<oa>ll</oa></spec>
+    <description short="true">
+        <p>Save the current session and quit.</p>
+        <note>
+            Unlike Vim, <ex>:wq</ex> closes the entire window rather
+            than just the current tab.
+        </note>
+    </description>
+</item>
+
+<item>
+    <tags>ZQ</tags>
+    <spec>ZQ</spec>
+    <description short="true">
+        <p>Quit and don't save the session. Works like <ex>:qall</ex>.</p>
+    </description>
+</item>
+
+<item>
+    <tags>ZZ</tags>
+    <spec>ZZ</spec>
+    <description short="true">
+        <p>
+            Quit &dactyl.appName; and save the session. Works like
+            <ex>:xall</ex>.
+        </p>
+    </description>
+</item>
+
+<h2 tag="current-directory">The current directory</h2>
+
+<item>
+    <tags>:chd :chdir :cd</tags>
+    <strut/>
+    <spec>:cd <oa>path</oa></spec>
+    <description>
+        <p>
+            Change the current directory. If <oa>path</oa> is
+            <em>-</em>, change to the previous directory. If it is
+            omitted, change to the home directory.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:pw :pwd</tags>
+    <spec>:pw<oa>d</oa></spec>
+    <description short="true">
+        <p>Print the current directory name.</p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/buffer.xml b/common/locale/en-US/buffer.xml
new file mode 100644 (file)
index 0000000..66d0d97
--- /dev/null
@@ -0,0 +1,529 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="buffer"
+    title="&dactyl.appName; Buffer"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="buffer document">Buffer</h1>
+<toc start="2"/>
+
+<p>
+    A buffer is a container that holds the given web page, including
+    all of its history and frames. Each tab contains exactly one
+    buffer, and for most purposes the two terms are interchangeable.
+    See <t>tabs</t> for more.
+</p>
+
+<h2 tag="buffer-information">Buffer information</h2>
+
+<item>
+    <tags><![CDATA[<page-info> <C-g>]]></tags>
+    <strut/>
+    <spec>&lt;C-g></spec>
+    <description>
+        <p>
+            Print the current file name along with basic page
+            information including last modification time, the number
+            of feeds present, and the page title.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<more-page-info> g<C-g>]]></tags>
+    <spec>g&lt;C-g></spec>
+    <description short="true">
+        <p>Print file information. Same as <ex>:pa<oa>geinfo</oa></ex>.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:pa :pageinfo</tags>
+    <spec>:pa<oa>geinfo</oa> <oa>items</oa></spec>
+    <description>
+        <p>
+            Show various page information. The information provided
+            is determined by the value of <o>pageinfo</o>, or
+            <oa>items</oa> if present.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gf</tags>
+    <strut/>
+    <spec>gf</spec>
+    <description>
+        <p>
+            View source. Toggles between the source and rendered
+            content of the page.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gF</tags>
+    <strut/>
+    <spec>gF</spec>
+    <description>
+        <p>
+            View source with an external editor. Opens the source
+            code of the current web site with the external editor
+            specified by the <o>editor</o> option.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:vie :viewsource</tags>
+    <spec>:vie<oa>wsource</oa><oa>!</oa> <oa>url</oa></spec>
+    <description>
+        <p>
+            View source code of current document. If <oa>url</oa> is
+            specified then view the source of that document. When
+            <oa>!</oa> is given, it is opened with the external
+            editor.
+        </p>
+    </description>
+</item>
+
+<h2 tag="motion scrolling">Motion commands</h2>
+
+<item>
+    <tags>&lt;scroll-begin> ^ 0</tags>
+    <strut/>
+    <spec>0</spec>
+    <description>
+        <p>
+            Scroll to the absolute left of the document. Unlike in
+            Vim, <k>0</k> and <k>^</k> work exactly the same way.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;scroll-end> $</tags>
+    <spec>$</spec>
+    <description short="true">
+        <p>Scroll to the absolute right of the document</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<Home> gg]]></tags>
+    <strut/>
+    <spec><oa>count</oa>gg</spec>
+    <description>
+        <p>
+            Go to the top of the document. With <oa>count</oa>,
+            scroll vertically to <oa>count</oa> percent of the
+            document.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<End> G]]></tags>
+    <strut/>
+    <spec><oa>count</oa>G</spec>
+    <description>
+        <p>
+            Go to the end of the document. With <oa>count</oa>,
+            behaves exactly the same as <oa>gg</oa>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;scroll-percent> N%</tags>
+    <spec><a>count</a>%</spec>
+    <description short="true">
+        <p>Scroll to <a>count</a> percent of the document.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-column-left> <Left> h]]></tags>
+    <strut/>
+    <spec><oa>count</oa>h</spec>
+    <description>
+        <p>
+            Scroll document to the left. If <oa>count</oa> is specified,
+            repeat <oa>count</oa> times.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-line-down> <C-e> <Down> j]]></tags>
+    <strut/>
+    <spec><oa>count</oa>j</spec>
+    <description>
+        <p>
+            Scroll document to the down. If <oa>count</oa> is specified,
+            repeat <oa>count</oa> times.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-line-up> <C-y> <Up> k]]></tags>
+    <strut/>
+    <spec><oa>count</oa>k</spec>
+    <description>
+        <p>
+            Scroll document to the up. If <oa>count</oa> is specified,
+            repeat <oa>count</oa> times.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-column-right> <Right> l]]></tags>
+    <strut/>
+    <spec><oa>count</oa>l</spec>
+    <description>
+        <p>
+            Scroll document to the right. If <oa>count</oa> is specified,
+            repeat <oa>count</oa> times.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-down> <C-d>]]></tags>
+    <strut/>
+    <spec><oa>count</oa>&lt;C-d></spec>
+    <description>
+        <p>
+            Scroll window downwards by the amount specified in the
+            <o>scroll</o> option. With <oa>count</oa>, scroll as if
+            <o>scroll</o> were set to <oa>count</oa>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-up> <C-u>]]></tags>
+    <strut/>
+    <spec><oa>count</oa>&lt;C-u></spec>
+    <description>
+        <p>
+            Scroll window upwards by the amount specified in the
+            <o>scroll</o> option. With <oa>count</oa>, scroll as if
+            <o>scroll</o> were set to <oa>count</oa>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-page-up> <S-Space> <PageUp> <C-b>]]></tags>
+    <strut/>
+    <spec><oa>count</oa>&lt;C-b></spec>
+    <description>
+        <p>
+            Scroll up a full page. With <oa>count</oa>, scroll up
+            <oa>count</oa> full pages.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<scroll-page-down> <Space> <PageDown> <C-f>]]></tags>
+    <strut/>
+    <spec><oa>count</oa>&lt;C-f></spec>
+    <description>
+        <p>
+            Scroll down a full page. With <oa>count</oa>, scroll
+            down <oa>count</oa> full pages.
+        </p>
+    </description>
+</item>
+
+<h2 tag="jumping">Jumping to elements</h2>
+
+<item>
+    <tags><![CDATA[<Tab>]]></tags>
+    <spec>&lt;Tab></spec>
+    <description short="true">
+        <p>Advance keyboard focus to the next element.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<S-Tab>]]></tags>
+    <spec>&lt;S-Tab></spec>
+    <description short="true">
+        <p>Rewind keyboard focus to the previous element.</p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;focus-input> gi</tags>
+    <strut/>
+    <spec><oa>count</oa>gi</spec>
+    <description>
+        <p>
+            Focus last used input field. If there is no last input
+            field, focus the first input field. With <oa>count</oa>,
+            focus the <oa>count</oa>th input field.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;next-frame> ]f</tags>
+    <strut/>
+    <spec><oa>count</oa>]f</spec>
+    <description>
+        <p>
+            Transfer keyboard focus to the <oa>count</oa>th next
+            frame. The newly focused frame is briefly highlighted
+            with <h>FrameIndicator</h>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;previous-frame> [f</tags>
+    <strut/>
+    <spec><oa>count</oa>[f</spec>
+    <description>
+        <p>
+            Transfer keyboard focus to the <oa>count</oa>th next
+            previous frame. The newly focused frame is briefly highlighted
+            with <h>FrameIndicator</h>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;next-page> ]]</tags>
+    <strut/>
+    <spec><oa>count</oa>]]</spec>
+    <description>
+        <p>
+            Follow the last link matching <o>nextpattern</o>. Used,
+            for instance, to move to the next page of search
+            results.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;previous-page> [[</tags>
+    <strut/>
+    <spec><oa>count</oa>[[</spec>
+    <description>
+        <p>
+            Follow the last link matching <o>previouspattern</o>. Used,
+            for instance, to move to the previous page of search
+            results.
+        </p>
+    </description>
+</item>
+
+<h2 tag="zooming zoom">Zooming</h2>
+
+<p>
+    The zooming commands are dependent on two properties—a zoom
+    range and a series of levels within that range.
+</p>
+
+<p>
+    The absolute value of the page zoom is limited to a value within
+    the configured zoom range (default: 30%–300%). By default,
+    commands which zoom in or out select between the zoom levels,
+    30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%,
+    200%, 240%, 300%.
+</p>
+
+<p>
+    The available zoom range can be changed by setting the
+    <pref>zoom.minPercent</pref>
+    and
+    <pref>zoom.maxPercent</pref>
+    &dactyl.host; preferences. The zoom levels can be changed using the
+    <pref>toolkit.zoomManager.zoomValues</pref>
+    preference.
+</p>
+
+<note>
+    <pref>toolkit.zoomManager.zoomValues</pref> is specified as a
+    list of values between <em>0</em> and <em>1</em> rather than
+    percentages. For instance, <em>0.5</em> is equivalent to
+    <em>50%</em>.
+</note>
+
+<item>
+    <tags><![CDATA[<text-zoom-in> + zi]]></tags>
+    <spec><oa>count</oa>zi</spec>
+    <description short="true">
+        <p>Enlarge text zoom of current web page. Mnemonic: zoom in.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<text-zoom-more> zm]]></tags>
+    <strut/>
+    <spec><oa>count</oa>zm</spec>
+    <description>
+        <p>Enlarge text zoom of current web page by a larger amount. Mnemonic: zoom more.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<text-zoom-out> - zo]]></tags>
+    <spec><oa>count</oa>zo</spec>
+    <description short="true">
+        <p>Reduce text zoom of current web page. Mnemonic: zoom out.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<text-zoom-reduce> zr]]></tags>
+    <spec><oa>count</oa>zr</spec>
+    <description short="true">
+        <p>Reduce text zoom of current web page by a larger amount. Mnemonic: zoom reduce.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<text-zoom> zz]]></tags>
+    <strut/>
+    <spec><oa>count</oa>zz</spec>
+    <description>
+        <p>
+            Set text zoom value of current web page. Zoom value can
+            be between 30% and 300%.  If it is omitted, text zoom is
+            reset to 100%.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<full-zoom-in> ZI zI]]></tags>
+    <spec><oa>count</oa>ZI</spec>
+    <description short="true">
+        <p>Enlarge full zoom of current web page. Mnemonic: zoom in.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<full-zoom-more> ZM zM]]></tags>
+    <strut/>
+    <spec><oa>count</oa>ZM</spec>
+    <description>
+        <p>Enlarge full zoom of current web page by a larger amount. Mnemonic: zoom more.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<full-zoom-out> ZO zO]]></tags>
+    <spec><oa>count</oa>ZO</spec>
+    <description short="true">
+        <p>Reduce full zoom of current web page. Mnemonic: zoom out.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<full-zoom-reduce> ZR zR]]></tags>
+    <spec><oa>count</oa>ZR</spec>
+    <description short="true">
+        <p>Reduce full zoom of current web page by a larger amount. Mnemonic: zoom reduce.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<full-zoom> zZ]]></tags>
+    <strut/>
+    <spec><oa>count</oa>zZ</spec>
+    <description>
+        <p>
+            Set full zoom value of current web page. Zoom value can be between 30 and
+            300%. If it is omitted, full zoom is reset to 100%.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:zo :zoom</tags>
+    <spec>:zo<oa>om</oa><oa>!</oa> <oa>value</oa></spec>
+    <spec>:zo<oa>om</oa><oa>!</oa> +<a>value</a></spec>
+    <spec>:zo<oa>om</oa><oa>!</oa> -<a>value</a></spec>
+    <description>
+        <p>
+            Set zoom value of current web page. <oa>value</oa> can be an absolute value
+            between 30% and 300% or a relative value if prefixed with "-" or "+". If
+            <oa>value</oa> is omitted, zoom is reset to 100%.
+        </p>
+
+        <p>
+            Normally this command operates on the text zoom; if used with <oa>!</oa>, it
+            operates on full zoom.
+        </p>
+    </description>
+</item>
+
+<h2 tag="frames">Working with frames</h2>
+
+<item>
+    <tags>:frameo :frameonly</tags>
+    <spec>:frameo<oa>nly</oa></spec>
+    <description short="true">
+        <p>Show only the current frame's page.</p>
+    </description>
+</item>
+
+<h2 tag="copying yanking">Copying text</h2>
+
+<p>
+    When running in X11, the text of the following commands is not only
+    copied to the clipboard but is also put into the X11 selection, which
+    can be pasted with the middle mouse button:
+</p>
+
+<item>
+    <tags>&lt;yank-location> y</tags>
+    <spec>y</spec>
+    <description short="true">
+        <p>Yank current location to the clipboard.</p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;yank-word> Y</tags>
+    <spec>Y</spec>
+    <description short="true">
+        <p>Copy currently selected text to the system clipboard.</p>
+    </description>
+</item>
+
+<h2 tag="alternate-stylesheet">Alternate style sheets</h2>
+
+Page authors may specify alternate style sheets for an HTML
+document. Users can then switch between these various style sheets,
+selecting their favorite.
+
+<item>
+    <tags>:pagest :pagestyle</tags>
+    <spec>:pagest<oa>yle</oa> <oa>stylesheet</oa></spec>
+    <description>
+        <p>
+            Select the author style sheet to apply. If
+            <oa>stylesheet</oa> is not specified the page's default
+            style sheet is used.
+        </p>
+
+        <p>All author styling can be removed by setting the <o>usermode</o> option.</p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/cmdline.xml b/common/locale/en-US/cmdline.xml
new file mode 100644 (file)
index 0000000..0ad3332
--- /dev/null
@@ -0,0 +1,295 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="cmdline"
+    title="&dactyl.appName; Command-line"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="command-line-mode command-line mode-cmdline">Command-line mode</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName;'s command-line mode is perhaps its most
+    powerful interface. In this mode, the command input bar at the
+    bottom of the window is given the keyboard focus for any of a
+    variety of required inputs. In addition to access to almost
+    every aspect of &dactyl.appName; and &dactyl.host;, the command
+    line provides power and comprehensive completion for all of its
+    commands, along with concise descriptions for each command and
+    all of its arguments. Couple this with persistent, searchable
+    command history, and you have a very efficient interface for
+    easily performing simple and complex tasks.
+</p>
+
+<p>
+    Included among the several command-line modes are Ex command
+    mode (the standard mode for entering commands), find mode (for
+    searching the current page), prompt mode (for selecting files,
+    confirming actions), and hint mode (for selecting links and
+    other items on a page).
+</p>
+
+<item>
+    <tags>:</tags>
+    <strut/>
+    <spec>:</spec>
+    <description>
+        <p>
+            Opens the command line in Ex mode. This is the mode used
+            for entering the various commands listed in
+            <t>ex-cmd-index</t>.
+        </p>
+    </description>
+</item>
+
+<h2 tag="cmdline-editing">Command-line editing</h2>
+
+<item>
+    <tags><![CDATA[c_<C-i>]]></tags>
+    <spec>&lt;C-i></spec>
+    <description short="true">
+        <p>Launch the external editor. See the <o>editor</o> option.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<C-c>]]></tags>
+    <spec>&lt;C-c></spec>
+    <description short="true">
+        <p>Quit Command-line mode without executing.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<C-]>]]></tags>
+    <spec>&lt;C-]></spec>
+    <description short="true">
+        <p>Expand a command-line abbreviation.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<Up>]]></tags>
+    <strut/>
+    <spec>&lt;Up></spec>
+    <description>
+        <p>
+            Recall from command history the previous command line
+            which begins with the current input value.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<Down>]]></tags>
+    <strut/>
+    <spec>&lt;Down></spec>
+    <description>
+        <p>
+            Recall from command history the next command line
+            which begins with the current input value.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<C-p> c_<S-Up> c_<PageUp>]]></tags>
+    <spec>&lt;S-Up></spec>
+    <strut/>
+    <spec>&lt;PageUp></spec>
+    <description>
+        <p>Recall the previous command line from the history list.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<C-n> c_<S-Down> c_<PageDown>]]></tags>
+    <spec>&lt;S-Down></spec>
+    <spec>&lt;PageDown></spec>
+    <description>
+        <p>Recall the next command line from the history list.</p>
+    </description>
+</item>
+
+<h2 tag="cmdline-completion">Command-line completion</h2>
+
+<item>
+    <tags><![CDATA[c_<Tab>]]></tags>
+    <strut/>
+    <spec>&lt;Tab></spec>
+    <description>
+        <p>
+            Complete the word in front of the cursor according to the behavior
+            specified in <o>wildmode</o>. If <o>wildmode</o> contains
+            <str>list</str> and there are multiple matches then the completion
+            menu window is opened.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<S-Tab>]]></tags>
+    <strut/>
+    <spec>&lt;S-Tab></spec>
+    <description>
+        <p>Complete the previous full match when <o>wildmode</o> contains <str>full</str>.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<A-Tab>]]></tags>
+    <strut/>
+    <spec>&lt;A-Tab></spec>
+    <description>
+        <p>
+            Similar to <k name="Tab" mode="c"/>, but the completion behavior is
+            specified by the <o>altwildmode</o> option.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[c_<A-S-Tab>]]></tags>
+    <strut/>
+    <spec>&lt;A-S-Tab></spec>
+    <description>
+        <p>The <k name="S-Tab" mode="c"/> equivalent for <o>altwildmode</o>.</p>
+    </description>
+</item>
+
+<h2 tag="cmdline-lines">Ex command lines</h2>
+
+<item>
+    <tags>:bar</tags>
+    <strut/>
+    <description>
+        <p>
+            Multiple commands, separated by a <em>|</em> can be
+            given in a single command line and will be executed consecutively.
+            <em>|</em> can be included as an argument to a command by escaping
+            it with a backslash. E.g.
+            <code><ex>:map \|</ex> <ex>:echo <str>bar</str></ex><k name="CR"/></code>
+
+            Several commands process the entire command-line string literally.
+            These commands will include any <em>|</em> as part of their
+            argument string and so cannot be followed by another command. The
+            list of these commands is:
+            <ul>
+                <li><ex>:abbreviate</ex></li>
+                <li><ex>:autocmd</ex></li>
+                <li><ex>:cabbreviate</ex></li>
+                <li><ex>:cmap</ex></li>
+                <li><ex>:cnoremap</ex></li>
+                <li><ex>:command</ex></li>
+                <li><ex>:delmacros</ex></li>
+                <li><ex>:delmarks</ex></li>
+                <li><ex>:delqmarks</ex></li>
+                <li><ex>:delstyle</ex></li>
+                <li><ex>:echo</ex></li>
+                <li><ex>:echoerr</ex></li>
+                <li><ex>:echomsg</ex></li>
+                <li><ex>:elseif</ex></li>
+                <li><ex>:execute</ex></li>
+                <li><ex>:highlight</ex></li>
+                <li><ex>:iabbreviate</ex></li>
+                <li><ex>:if</ex></li>
+                <li><ex>:imap</ex></li>
+                <li><ex>:inoremap</ex></li>
+                <li><ex>:javascript</ex></li>
+                <li><ex>:let</ex></li>
+                <li><ex>:map</ex></li>
+                <li><ex>:marks</ex></li>
+                <li><ex>:nmap</ex></li>
+                <li><ex>:nnoremap</ex></li>
+                <li><ex>:noremap</ex></li>
+                <li><ex>:open</ex></li>
+                <li><ex>:qmarks</ex></li>
+                <li><ex>:silent</ex></li>
+                <li><ex>:style</ex></li>
+                <li><ex>:styledisable</ex></li>
+                <li><ex>:styleenable</ex></li>
+                <li><ex>:styletoggle</ex></li>
+                <li><ex>:tabopen</ex></li>
+                <li><ex>:toolbarhide</ex></li>
+                <li><ex>:toolbarshow</ex></li>
+                <li><ex>:toolbartoggle</ex></li>
+                <li><ex>:vmap</ex></li>
+                <li><ex>:vnoremap</ex></li>
+                <li><ex>:winopen</ex></li>
+                <li><ex>:yank</ex></li>
+            </ul>
+        </p>
+    </description>
+</item>
+
+<h3 tag="cmdline-arguments">Ex command-line arguments</h3>
+
+<p>
+    Most Ex commands accept a number of options and arguments. Arguments and
+    options are generally separated by spaces, and treat a number of
+    characters, including <em>\</em>, <em>'</em>, <em>"</em>, and <em>|</em>,
+    specially.  Moreover, certain arguments have their own special characters.
+    For instance, when using <ex>:set</ex> to change a <t>stringlist</t>
+    option, the comma character is used to separate elements of said list. Or
+    when calling <ex>:autocmd</ex>, the pattern given may be negated by
+    prefixing it with a <em>!</em>. In order to use these characters in
+    command arguments, stripped of their special meaning, they must be quoted.
+</p>
+
+<p tag="cmdline-quoting quoting">
+    &dactyl.appName; offers four distinct quoting styles, each with its own
+    distinct advantages and disadvantages. The first, and most basic, is the
+    automatic quoting applied to the commands listed in <ex>:bar</ex>. When
+    any of these commands is invoked, their final argument is always
+    interpreted literally. No characters have special meaning whatsoever, and
+    no care need be taken to quote anything. Additionally, the following three
+    optional quoting characters are available:
+</p>
+
+<dl dt="width: 8em;">
+    <dt>\</dt>
+    <dd>
+        This is the most basic quoting character. When it is encountered
+        outside of single or double quotes, it forces the next character to be
+        interpreted literally. So, for instance, <tt>\\</tt> ⇒ <tt>\</tt>,
+        <tt>\'</tt> ⇒ <tt>'</tt>, <tt>\a</tt> ⇒ <tt>a</tt>, and
+        <tt>\␣</tt> ⇒ <tt>␣</tt>.
+    </dd>
+    <dt>'</dt>
+    <dd>
+        Any character inside single quotes aside from the ' character itself
+        is interpreted literally. To include a literal single quote, it must
+        be doubled. So, <code>'foo\ ''bar\\ baz\' ⇒ foo\ 'bar\\ baz\</code>
+    </dd>
+    <dt>"</dt>
+    <dd>
+        Any character inside of double quotes except for <em>"</em> and
+        <em>\</em> is interpreted literally. A literal double quote may be
+        included by preceding it with a backslash. Any other occurrence of a
+        backslash starts an escape sequence as in JSON strings.
+        Among the available escape sequences are:
+        <dl dt="width: 8em;">
+            <dt>\n</dt> <dd>A newline character.</dd>
+            <dt>\t</dt> <dd>A tab character.</dd>
+            <dt>\uxxxx</dt> <dd>Where each <em>x</em> is a digit between 0 and F, a Unicode character at code-point <em>xxxx</em>.</dd>
+        </dl>
+    </dd>
+</dl>
+
+<p tag="cmdline-options">
+    Many Ex commands accept option arguments in addition to regular arguments.
+    Option arguments begin with a hyphen (<em>-</em>), and often have a short
+    form and a long form, such as <em>-name</em> and <em>-n</em>. Most options
+    accept arguments, which come after the option separated by either a space
+    or an equal sign. For instance, the following three forms,
+    <em>-name=<str>foo</str></em>, <em>-name <str>foo</str></em>, and
+    <em>-n <str>foo</str></em>, are all acceptable and entirely equivalent.
+</p>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/developer.xml b/common/locale/en-US/developer.xml
new file mode 100644 (file)
index 0000000..43fb99e
--- /dev/null
@@ -0,0 +1,246 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd" [
+    <!ENTITY tab "&#xa0;&#xa0;&#xa0;">
+]>
+
+<document
+    name="developer"
+    title="&dactyl.appName; Developer information"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="developer-information developer">Developer information</h1>
+<toc start="2"/>
+
+<h2 tag="writing-docs documentation">Writing documentation</h2>
+
+<p>
+    In order for any user-visible change to be accepted into the mainline, it
+    must be accompanied by accurate documentation. The docs are written in an
+    XML dialect similar to XHTML, with a few tags specific to our
+    documentation. For example:
+</p>
+
+<xml-block><item>
+    <tags>&lt;F1> :help :h help</tags>
+    <spec>:h<oa>elp</oa> <oa>subject</oa></spec>
+    <spec>&lt;F1></spec>
+    <description>
+        <p>
+            Open the help page. The default page, as specified by <o>helpfile</o> is shown
+            unless <oa>subject</oa> is specified. If you need help for a specific topic, try
+            <ex>:help overview</ex>.
+        </p>
+    </description>
+</item></xml-block>
+
+<p>
+    creates a new help section for the command <ex>:help</ex> and for
+    the related key binding, <k name="F1"/>. It also creates help tags
+    for the command, its shortcuts, the key binding, and the general
+    topic, ‘help’. These tags enable linking to this section from
+    other mentions of the topic and from the <ex>:help</ex> command.
+</p>
+
+<h3 tag="help-tags help-xml">Help tags</h3>
+
+<p>
+    The following is a list of the more common XML tags used in help pages,
+    along with their highlight groups.
+</p>
+
+<dl>
+    <dt>Layout</dt><dd/>
+    <dt>p</dt>         <dd>A paragraph (HelpParagraph)</dd>
+    <dt>h1</dt>        <dd>A first-level heading (HelpHead1)</dd>
+    <dt>h2</dt>        <dd>A second-level heading (HelpHead2)</dd>
+    <dt>h3</dt>        <dd>A third-level heading (HelpHead3)</dd>
+    <dt>h4</dt>        <dd>A fourth-level heading (HelpHead4)</dd>
+    <dt>code</dt>      <dd>A pre-formatted code block. (HelpCode)</dd>
+    <dt>note</dt>      <dd><note>A note paragraph. (HelpNote)</note></dd>
+    <dt>strut</dt>     <dd>A horizontal strut which prevents any previous floating elements from appearing below it.</dd>
+    <dt>warning</dt>   <dd><warning>A warning paragraph. (HelpWarning)</warning></dd>
+</dl>
+<dl>
+    <dt>Generic</dt><dd/>
+    <dt>link</dt>         <dd>A generic link. (HelpLink)</dd>
+    <dt>&tab;@topic</dt>  <dd>The topic of the link. Either a help topic or a fully-qualified URI.</dd>
+    <dt>em</dt>           <dd><em>Emphasized</em> text. (HelpEm)</dd>
+    <dt>str</dt>          <dd>A <str>string</str>, with its contents wrapped in quotes. (HelpString)</dd>
+    <dt>logo</dt>         <dd>&dactyl.appName;'s logo. (Logo)</dd>
+</dl>
+<dl>
+    <dt>Items</dt><dd/>
+    <dt>item</dt>       <dd>A help entry (HelpItem)</dd>
+    <dt>&tab;tags</dt>  <dd>See the 'Tagging' section (HelpTags)</dd>
+    <dt>&tab;spec</dt>  <dd>The specification for this item, such as an example command line. (HelpSpec)</dd>
+    <dt>&tab;strut</dt> <dd>A horizontal formatting strut which ensures that all previous &lt;tags> and &lt;spec> elements appear above the ones that follow.</dd>
+    <dt>&tab;type</dt>  <dd>For options, the type of the option.
+                            <em>number</em>, <em>boolean</em>, <em>string</em>, <em>stringlist</em>, or <em>charlist</em>.
+                            (HelpType)
+                        </dd>
+    <dt>&tab;default</dt>     <dd>For options, the default value. (HelpDefault)</dd>
+    <dt>&tab;description</dt> <dd>The description of this help item. (HelpDescription)</dd>
+    <dt>&tab;&tab;@short</dt> <dd>Indicates that this is a short description which should appear between the specs and tags.</dd>
+    <dt>a</dt>  <dd>Required <a>argument</a>. (HelpArg)</dd>
+    <dt>oa</dt> <dd>Optional <oa>argument</oa>. (HelpOptionalArg)</dd>
+</dl>
+<dl>
+    <dt>Tagging</dt><dd/>
+    <dt>tags</dt>   <dd>Space-separated list of strings to tag. Displayed right-aligned, and used for cross-linking. (HelpTags)</dd>
+    <dt>@tag</dt>   <dd>The tag attribute. Applied to any element, generates a &lt;tags> element for the given tags. (HelpTag)</dd>
+</dl>
+<dl>
+    <dt>Linking</dt><dd/>
+    <dt>o</dt>          <dd>Link to an option. (HelpOpt)</dd>
+    <dt>ex</dt>         <dd>Link to an Ex command. (HelpEx)</dd>
+    <dt>k</dt>          <dd>Link to a key. (HelpKey)</dd>
+    <dt>&tab;@name</dt> <dd>The name attribute to &lt;k>. When provided, &lt;<a>value</a>> is prepended to
+                            the element's contents, i.e., <em>&lt;k name="Tab"/></em> becomes <em><k name="Tab"/></em>.
+                        </dd>
+    <dt>&tab;@mode</dt> <dd>The mode attribute to &lt;k>. Some keys have different functions in different modes.
+                            You can use this attribute to specify which of the modes (other than Normal) a key pertains to.
+                            The &lt;<a>value</a>> is prepended to the element's contents, i.e., <em>&lt;k name="C-i" mode="I"/></em>
+                            becomes <em><k name="C-i" mode="I"/></em>, and <em>&lt;k mode="t">i&lt;/k></em> becomes <em><k mode="t">i</k></em>.
+                        </dd>
+    <dt>t</dt>          <dd>Links to an arbitrary help topic. (HelpTopic)</dd>
+</dl>
+<dl>
+    <dt>Plugins</dt><dd/>
+    <dt>@lang</dt>             <dd>When applied to any element under
+        &lt;plugin>, that element is only visible for a specific
+        locale.</dd>
+    <dt>plugin</dt>            <dd>The container tag used for describing a plugin.</dd>
+    <dt>&tab;@name</dt>        <dd>The name of the plugin. Used as the plugin's help tag.</dd>
+    <dt>&tab;@version</dt>     <dd>The plugin's version number.</dd>
+    <dt>&tab;@href</dt>        <dd>The plugin's home page.</dd>
+    <dt>&tab;@summary</dt>     <dd>A short description of the plugin, shown in its section head.</dd>
+
+    <dt>info</dt>              <dd>An element with the same
+        attributes as plugin, which may override the latter for
+        specific locales</dd>
+
+    <dt>project</dt>           <dd>The project for which this plugin was intended.</dd>
+    <dt>&tab;@name</dt>        <dd>The name of the project (i.e., <str>&dactyl.appName;</str>)</dd>
+    <dt>&tab;@min-version</dt>  <dd>The minimum version of the project for which this plugin is intended to work.</dd>
+    <dt>&tab;@max-version</dt>  <dd>The maximum version of the project for which this plugin is intended to work.</dd>
+
+    <dt>author</dt>            <dd>The plugin's author. May appear more than once.</dd>
+    <dt>&tab;@href</dt>        <dd>The author's home page.</dd>
+    <dt>&tab;@email</dt>       <dd>The author's email address.</dd>
+
+    <dt>license</dt>           <dd>The plugin's license. May appear more than once.</dd>
+    <dt>&tab;@href</dt>        <dd>The URI of a page which shows or explains the license.</dd>
+</dl>
+
+<h2 tag="generating-docs">Generating documentation</h2>
+
+<p>
+    You can also autogenerate most of the XML help after you have
+    written a new command, mapping or option.
+</p>
+
+<example><ex>:echo dactyl.generateHelp(commands.get(<str>addons</str>), <![CDATA[<p>Extra text</p>]]>)</ex></example>
+
+<h2 tag="writing-plugins">Writing plugins</h2>
+
+<p>
+    Writing &dactyl.appName; plugins is incredibly simple. Plugins are
+    simply JavaScript files which run with full chrome privileges and
+    have full access to the &dactyl.appName; and &dactyl.host; APIs.
+    Each plugin has its own global object, which means that the
+    variables and functions that you create won't pollute the global
+    <em>window</em> or private <em>dactyl</em> namespaces. This means
+    that there's no need to wrap your plugin in a closure, as is often
+    the practice in JavaScript development. Furthermore, any plugin
+    which is installed in your <o>runtimepath</o><em>/plugins</em>
+    directory will find its context stored in
+    <em>plugins.&lt;pluginName></em>, which is often invaluable during
+    development and testing.
+</p>
+
+<p>
+    Plugins are always initialized after the main window is loaded, so
+    there is no need to write <str>load</str> event handlers. Beyond
+    that, what you may do with your plugins is practically limitless.
+    Plugins have full access to all of the chrome resources that
+    ordinary &dactyl.host; does, along with the entire power of the
+    &dactyl.appName; API. If you need a starting point, have a look at some
+    <link topic="&dactyl.plugins;">existing plugins</link> or
+    <link topic="http://addon.mozilla.org/">extensions</link>,
+    especially the
+    <link topic="&dactyl.code;source/browse/">&dactyl.appName;</link>
+    source.
+</p>
+
+<h3 tag="plugin-documentation">Plugin documentation</h3>
+
+<p>
+    Plugins should provide inline documentation, which will appear on the
+    <ex>:help <str delim="">plugins</str></ex> page. The markup for help entries is the same
+    as the above, along with a few extra plugin-specific entries. Here is an
+    example from the popular <em>flashblock</em> extension:
+</p>
+
+<xml-block><escape><hl key="HelpXMLString">use strict</hl>;
+XML.ignoreWhitespace = <hl key="Boolean">false</hl>;
+XML.prettyPrinting   = <hl key="Boolean">false</hl>;
+<hl key="HelpXML">var</hl> INFO = <!-- Cursed manual XML highlighting! -->
+<hl key="HelpXMLTagStart">&lt;plugin
+    <hl key="HelpXMLAttribute">name</hl><hl key="HelpXMLString">flashblock</hl>
+    <hl key="HelpXMLAttribute">version</hl><hl key="HelpXMLString">1.0</hl>
+    <hl key="HelpXMLAttribute">href</hl><hl key="HelpXMLString">http://dactyl.sf.net/pentadactyl/plugins#flashblock-plugin</hl>
+    <hl key="HelpXMLAttribute">summary</hl><hl key="HelpXMLString">Flash Blocker</hl>
+    <hl key="HelpXMLAttribute">xmlns</hl>{NS}></hl></escape>
+    <author email="maglione.k@gmail.com">Kris Maglione</author>
+    <license href="http://opensource.org/licenses/mit-license.php">MIT</license>
+    <project name="Pentadactyl" min-version="1.0"/>
+    <p>
+        This plugin provides the same features as the ever popular FlashBlock
+        Firefox addon. Flash animations are substituted with place holders which
+        play the original animation when clicked. Additionally, this plugin provides
+        options to control which sites can play animations without restrictions, and
+        triggers to toggle the playing of animation on the current page.
+    </p>
+    <item>
+        <tags>'fb' 'flashblock'</tags>
+        <spec>'flashblock' 'fb'</spec>
+        <type>boolean</type>
+        <default>true</default>
+        <description>
+            <p>
+                Controls the blocking of flash animations. When true, place
+                holders are substituted for flash animations on untrusted sites.
+            </p>
+        </description>
+    </item>
+    <escape><oa>...</oa>
+<hl key="HelpXMLTagEnd">&lt;/plugin></hl></escape>;</xml-block>
+
+<p>
+    The inline XML is made possible by
+    <link topic="https://developer.mozilla.org/en/E4X">E4X</link>.
+    It is important that the documentation be assigned to the
+    <em>INFO</em> variable, or &dactyl.appName; will not be able
+    to find it. The XML property changes are not compulsory, but
+    they do prevent certain formatting problems that may occur
+    otherwise. Beginning your file with <str>use strict</str>, while
+    not required, helps to prevent a lot of common errors.
+</p>
+
+<p>
+    The documentation that you provide behaves exactly as other
+    &dactyl.appName; documentation, which means that the tags you
+    provide are available via <ex>:help</ex> with full tag
+    completion and cross-referencing support. Although documentation
+    is not required, we strongly recommend that all plugin authors
+    provide at least basic documentation of the functionality of
+    their plugins and of each of the options, commands, and
+    especially mappings that they provide.
+</p>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/editing.xml b/common/locale/en-US/editing.xml
new file mode 100644 (file)
index 0000000..1ad5b62
--- /dev/null
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="editing"
+    title="&dactyl.appName; Editing Text"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="text-area text-field editing">Editing Text Fields</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName; provides several ways to edit text areas or input fields.
+    After switching focus to a text field, Insert mode or Text Edit mode is
+    activated, depending on your <o>insertmode</o> settings. You can also
+    use an <link topic="'editor'">external editor</link>.
+</p>
+
+<h2 tag="insert-mode insert">Insert mode</h2>
+
+<p>
+    In Insert mode, all keys except for those described in the
+    <link topic="i-map-index">index</link> are passed directly to &dactyl.host;.
+</p>
+
+
+<h2 tag="text-edit-mode text-edit">Text Edit mode</h2>
+
+<p>
+    Text Edit mode provides basic Vim-like text editing. It can be entered
+    from Insert mode by pressing <k name="C-t" mode="I"/> or started directly
+    when a text area is focused if <o>insertmode</o> is unset. See the
+    <link topic="t-map-index">index</link> for a list of currently supported
+    mappings.
+</p>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/eval.xml b/common/locale/en-US/eval.xml
new file mode 100644 (file)
index 0000000..d10fd02
--- /dev/null
@@ -0,0 +1,251 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="eval"
+    title="&dactyl.appName; Expression Evaluation"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="expression expr eval javascript">Expression evaluation</h1>
+<toc start="2"/>
+
+<p>
+    Much of the power of &dactyl.appName; lies in its scriptable expression
+    evaluation. &dactyl.appName; understands two kinds of expressions: Ex
+    commands, and JavaScript. Ex commands are simple, easy to type, and
+    readily accessible from the &tag.command-line;.
+    They form a core part of the user interface. JavaScript, on the other hand,
+    is much less straightforward, but allows for any number of complex actions
+    to be executed, with full access to all of the internals of &dactyl.appName;
+    and &dactyl.host;. Both expression evaluation methods support sophisticated
+    expression completion, including option lists and descriptions thereof,
+    along with parentheses matching and syntax error highlighting.
+</p>
+
+<h2 tag="javascript-evaluation">JavaScript evaluation</h2>
+
+<item>
+    <tags>:ec :echo</tags>
+    <spec>:ec<oa>ho</oa> <a>expr</a></spec>
+    <description>
+        <p>
+            Echo a JavaScript expression. <a>expr</a> may be a simple quoted
+            string, in which case it is shown in the &tag.status-line;, or any
+            arbitrary JavaScript expression. If the expression results in
+            anything other than a string, it is pretty-printed in a multi-line
+            frame just above the command line. The output depends on the type
+            of object. Functions display their source, DOM nodes display the
+            pretty-printed XML of the top-level node, XML literals are
+            rendered as page content, and all other objects display their
+            string representation and all of their enumerable properties.
+        </p>
+
+        <p>See also <ex>:javascript</ex>.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:echoe :echoerr</tags>
+    <spec>:echoe<oa>rr</oa> <a>expr</a></spec>
+    <description>
+        <p>
+            Echo the expression as an error message. Just like <ex>:ec<oa>ho</oa></ex> but echoes
+            the result highlighted as with the ErrorMsg group and saves it to the message history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:echom :echomsg</tags>
+    <spec>:echom<oa>sg</oa> <a>expr</a></spec>
+    <description>
+        <p>
+            Echo the expression as an informational message. Just like <ex>:ec<oa>ho</oa></ex> but
+            also saves the message in the message history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:exe :execute</tags>
+    <spec>:exe<oa>cute</oa> <a>expr</a></spec>
+    <description>
+        <p>
+            Execute the Ex command string that results from the evaluation of
+            the JavaScript expression <a>expr</a>. For example,
+        </p>
+        <code><ex>:execute <str>open </str> + content.location.host</ex></code>
+        <p>
+            opens the homepage of the currently opened site.
+        </p>
+
+        <note>Unlike Vim this only supports a single argument.</note>
+    </description>
+</item>
+
+<item>
+    <tags>:js :javas :javascript</tags>
+    <spec>:javas<oa>cript</oa> <a>cmd</a></spec>
+    <spec style="white-space: pre; height: 1.6em; overflow: visible;">:javascript &lt;&lt;<a>endpattern</a>
+  <a>cmd</a>
+  ...
+<a>endpattern</a></spec>
+    <description>
+        <p>
+            Evaluates the given <a>cmd</a> as JavaScript. Behaves exactly as
+            <ex>:echo</ex>, except that the result is not printed. Any
+            exception raised by the evaluation will, however, be displayed as
+            an error message and appended to <ex>:messages</ex>.
+        </p>
+
+        <example>
+            <ex>:javascript alert(<str>Hello world</str>)</ex> will open a
+            dialog window with the message <str>Hello world</str>.
+        </example>
+
+        <p>
+            Moreover, multi-line scripts can be executed with shell-like here
+            document syntax. For example, the following,
+        </p>
+
+        <code><ex>:javascript</ex> &lt;&lt;<em>EOF</em>
+<kwd>for each</kwd> (<kwd>var</kwd> tab <kwd>in</kwd> tabs.visibleTabs)
+    tab.linkedBrowser.reload();
+<em>EOF</em></code>
+
+        <p>
+            will reload all visible tabs.
+        </p>
+
+        <p>
+            Moreover, sophisticated, context-sensitive <k name="Tab" mode="c"/>
+            completion is available for JavaScript code, which extends to
+            property names, object keys, and programmable completion for
+            string function arguments. The completion code is designed to be
+            both as safe and as powerful as possible. Expressions in a given
+            command-line session will only be evaluated once, and, with
+            auto-completion turned on, any completion which requires a function
+            to be executed requires an explicit <k name="Tab" mode="c"/> press
+            to commence.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>REPL</tags>
+    <spec>:js! <oa>context</oa></spec>
+    <tags>:js! :javas! :javascript!</tags>
+    <spec>:javas<oa>cript</oa>! <oa>context</oa></spec>
+    <description>
+        <p>
+            Starts the JavaScript Read Eval Print Loop, where JavaScript
+            statements are entered and evaluated, their results printed, and the
+            input modified and entered again. Within the REPL, the results of a
+            given evaluation are available as variables named for the given
+            prompt.
+        </p>
+
+        <p>
+            If <oa>context</oa> is given, then statements are executed in that
+            global context.
+        </p>
+
+        <example><code><hl key="REPL"
+><hl key="REPL-E"><hl key="REPL-R">js1></hl> ({ <hl key="Key">foo</hl>: <str>bar</str> })</hl
+><hl key="REPL-P" style="display: block;"><hl key="Title Object">[object Object]</hl>::
+<hl key="Key">foo</hl>: <hl key="String">"bar"</hl></hl
+><hl key="REPL-E"><hl key="REPL-R">js2></hl> js1.foo</hl
+><hl key="REPL-P" style="display: block;"><str>bar</str></hl></hl></code></example>
+    </description>
+</item>
+
+<h2 tag="global-variables">Global Variables</h2>
+
+<item>
+    <tags>:let</tags>
+    <spec>:let <a>var-name</a> [+-.]= <a>expr1</a></spec>
+    <spec>:let <a>var-name</a></spec>
+    <spec>:let</spec>
+    <description>
+        <deprecated>All scripts which make use of <ex>:let</ex> should be
+            updated to use the simpler and more powerful options system
+            instead.</deprecated>
+        <p>
+            Sets or lists a variable. Sets the variable <a>var-name</a> to the value of the
+            expression <a>expr1</a>. If no expression is given, the value of the variable is
+            displayed. Without arguments, displays a list of all variables.
+            This functionality has few useful applications and so is deprecated.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:unl :unlet</tags>
+    <spec>:unl<oa>et</oa><oa>!</oa> <a>name</a> …</spec>
+    <description>
+        <deprecated>All scripts which make use of <ex>:unlet</ex> should be
+            updated to use the simpler and more powerful options system
+            instead.</deprecated>
+        <p>
+            Deletes the named variables. When <oa>!</oa> is given, no error
+            message is output for non-existing variables.
+        </p>
+    </description>
+</item>
+
+<h2 tag="conditionals">Conditionals</h2>
+
+<item>
+    <tags>:if</tags>
+    <strut/>
+    <spec>:if <a>expr</a></spec>
+    <description>
+        <p>
+            Execute commands until the next <ex>:elseif</ex>, <ex>:else</ex>,
+            or <ex>:endif</ex> only if the JavaScript expression <a>expr</a>
+            evaluates to a true value.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:endif :en :fi</tags>
+    <spec>:en<oa>dif</oa></spec>
+    <description short="true">
+        <p>
+            Ends a string of <ex>:if</ex>/<ex>:elseif</ex>/<ex>:else</ex>
+            conditionals.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:elseif :elsei :elif</tags>
+    <spec>:elsei<oa>f</oa> <a>expr</a></spec>
+    <description>
+        <p>
+            Execute commands until the next <ex>:elseif</ex>, <ex>:else</ex>,
+            or <ex>:endif</ex> only if the JavaScript expression <a>expr</a>
+            evaluates to a true value.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:else :el</tags>
+    <strut/>
+    <spec>:el<oa>se</oa></spec>
+    <description>
+        <p>
+            Execute commands until the next <ex>:endif</ex> only if the
+            previous conditionals were not executed.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/faq.xml b/common/locale/en-US/faq.xml
new file mode 100644 (file)
index 0000000..ecc3bec
--- /dev/null
@@ -0,0 +1,178 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="faq"
+    title="&dactyl.appName; FAQ"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+            <h1 style="margin-top: 0;">Frequently Asked Questions</h1>
+            <p>
+                Below is a list of some of the commonest questions that come to
+                our attention, along with their hopefully satisfactory answers.
+                Please take a minute to search for your answers here before
+                asking the <link topic="&dactyl.list.href;">mailing list</link> or
+                <link topic="&dactyl.irc;">irc channel</link>,
+                and don't forget to peruse the
+                <link topic="&dactyl.hg.latest;&dactyl.name;/NEWS">NEWS file</link> for recent
+                changes that might throw you off balance.
+            </p>
+
+            <toc start="2"/>
+
+            <h2 tag="faq-general">General</h2>
+            <h3 tag="faq-fork">Why did Pentadactyl split from Vimperator?</h3>
+            <p>
+                The reasons for the fork were mostly political, but mostly
+                boil down to the fact that the current maintainer, while
+                making no substantial contributions to the project for several
+                years, continues to exercise full editorial control while
+                actively soliciting donations with no transparency whatever.
+                We considered the latter especially a slight on both our
+                developers and our users, and after a considerable escalation
+                of the degree of offense felt compelled to leave the project.
+            </p>
+            <p>
+                However, though we could no longer justify supporting the
+                Vimperator project, we've invested considerable time and
+                energy into the code over these past several years and still
+                use and care about it. For that reason, we've decided to
+                publicly release our personal changes and continue to develop
+                the extension under a different name.
+            </p>
+
+            <h3 tag="faq-differences">What differentiates Pentadactyl from Vimperator?</h3>
+            <p>
+                The main difference is that Vimperator's most active
+                developers have moved on to Pentadactyl. More qualitative
+                changes may be found in the
+                <link topic="&dactyl.hg.latest;&dactyl.name;/NEWS">change log</link>,
+                but essentially add up to what we consider more active and
+                thoughtful development. Among the most visible differences, as
+                of Pentadactyl 1.0, are more extensive Firefox 4 support,
+                significantly better startup time and completion performance,
+                considerably better :sanitize and private mode support, a
+                greatly improved incremental find implementation, major
+                improvements in Ex command parsing (including the ability to
+                separate commands with | and split long commands across lines),
+                conditionals (<ex>:if</ex>/<ex>:else</ex>) in configuration
+                files, greatly updated documentation, and a number of bug fixes.
+            </p>
+
+            <h3 tag="faq-statussymbols">What do the "[-+♥]" symbols in the status bar mean?</h3>
+            <p>
+                These indicate that you can move backward through history,
+                that you can move forward through history, and that the page
+                is bookmarked, respectively. See also <ex>:help</ex>
+                <t>status-line</t>.
+            </p>
+
+            <h3 tag="faq-lasttab">How can I prevent <k>d</k> on the last tab from closing the window?</h3>
+            <p><ex>:set!</ex> <hl key="HelpOpt">browser.tabs.closeWindowWithLastTab</hl>=<hl key="Boolean">false</hl></p>
+
+            <h3 tag="faq-regexpsearch">How can I use regular expressions in the page search?</h3>
+            <p>
+                Regular expression search is possible with the <tt>/Find Bar/</tt>
+                extension installed, in which case it can be toggled with the
+                <em>\r</em> and <em>\R</em> search flags. See also
+                <ex>:help</ex> <t>pattern</t>.
+            </p>
+
+            <h3 tag="faq-autocomplete">How can I prevent the command line completion list showing until I press <k name="Tab"/>?</h3>
+            <p>
+                You can disable it entirely with <ex>:set autocomplete=</ex>
+                or for specific types of command completion by
+                choosing more restrictive values. See <ex>:help</ex>
+                <o>autocomplete</o> and <o>wildmode</o>.
+            </p>
+
+            <h3 tag="faq-editor-fork">Why doesn't external input field editing work with my <o>editor</o> setting?</h3>
+            <p>
+                Unfortunately, external editors which return immediately,
+                before editing is complete, are not supported. This means that
+                gvim, for instance, must be run with the <em>-f</em> flag, and
+                editors run from a terminal must not connect to a remote
+                process. In the case of Rxvt-unicode, this means that the
+                urxvtc program is not an option, and Gnome Terminal is very
+                likely not useable under any circumstances.
+            </p>
+            <note>
+                If you are using a version of Firefox newer than 4.0
+                beta 7 and a version of Pentadactyl less than 1.0
+                βeta 4, you'll need to upgrade the latter.
+            </note>
+
+            <h3 tag="faq-symlinks">Why can't I build/install from the Mercurial repository on Windows®?</h3>
+            <p>
+                We use symbolic links in our repository to deal with certain
+                files which are common across projects. Mercurial for
+                Windows®, unfortunately, doesn't deal with these very well.
+                However, adding the following lines to the <tt>.hg\hgrc</tt>
+                file in your repository should make things work:
+            </p>
+            <code><hl key="Key">[hooks]</hl>
+update = <str delim="">python:common/contrib/fix_symlinks.py:fix_symlinks</str>
+preupdate = <str delim="">python:common/contrib/fix_symlinks.py:fix_symlinks</str>
+commit = <str delim="">python:common/contrib/fix_symlinks.py:fix_symlinks</str>
+precommit = <str delim="">python:common/contrib/fix_symlinks.py:fix_symlinks</str></code>
+
+            <h2 tag="faq-open"><ex>:open</ex> behavior</h2>
+            <h3 tag="faq-urlsep">Why can't I separate URLs in <ex>:open</ex> with a comma anymore?</h3>
+            <p>See <ex>:help <o>urlseparator</o></ex></p>
+
+            <h3 tag="faq-hidden-engines">
+                <strut/>
+                <ex>:open <a>search-string</a></ex> or <ex>:open <str delim="">google</str> <a>search-string</a></ex>
+                results in <str>The url is not valid and cannot be loaded</str>
+            </h3>
+            <p>
+                You need a valid search engine name in the <o>defsearch</o>
+                option. If it's stopped working suddenly, there's a good chance
+                that you've either deleted a search engine or changed its alias.
+                You can check by invoking
+
+                <code><ex>:dialog <str delim="">searchengines</str></ex></code>
+
+                There also appears to be a Firefox bug whereby the default
+                engines are hidden after an update. This can be remedied by
+                invoking
+
+                <code style="position: relative"><ex>:js</ex> services.get(<str>browserSearch</str>).getEngines().forEach(<em>function</em> (e) e.hidden = <hl key="Boolean">false</hl>)</code>
+            </p>
+
+            <h2 tag="faq-keys">Key bindings</h2>
+            <h3 tag="faq-website-keys">How can I use the native key bindings of sites like Gmail?</h3>
+            <p>
+                See the <o>passkeys</o> option to automatically pass specific
+                keys on sites of your choosing, or <t>autocmd-examples</t> to
+                automatically enter <em>PASS THROUGH</em> mode for certain websites.
+            </p>
+
+            <h3 tag="faq-passkeys-autocmd">Why doesn't my modes.passAllKeys autocmd work anymore?</h3>
+            <p>See <t>faq-website-keys</t> above.</p>
+
+            <h2 tag="faq-hints">Hints</h2>
+            <h3 tag="faq-hintkeys">How can I use keys other than numbers for hinting?</h3>
+            <p>Use the <o>hintkeys</o> option.</p>
+
+            <h3 tag="faq-hintkeys-uppercase">How can I display my hints in upper case but type them in lower case?</h3>
+            <p>
+                If you use alphabetic characters for your <o>hintkeys</o> and
+                would like to be able to type them in lower case but still have
+                the hints displayed in upper case, use:
+            </p>
+            <code><ex>:highlight</ex> <em>-a</em> Hint <hl key="Key">text-transform</hl>: <str delim="">uppercase</str>;</code>
+
+            <h3 tag="faq-hint-hidetext">How can I hide the hint text for input and image hints?</h3>
+            <p>
+                If you'd only like to show the numbered portion of hints, you
+                can do so with:
+            </p>
+            <code><ex>:highlight</ex> Hint<str delim="">::after</str> <hl key="Key">content</hl>: attr(<str delim="">number</str>) <hl key="Key">!important</hl>;</code>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/gui.xml b/common/locale/en-US/gui.xml
new file mode 100644 (file)
index 0000000..9bb1151
--- /dev/null
@@ -0,0 +1,298 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="gui"
+    title="&dactyl.appName; GUI"
+    xmlns="&xmlns.dactyl;"
+    xmlns:dactyl="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="gui">&dactyl.host;'s GUI</h1>
+<toc start="2"/>
+
+<p>
+    Although &dactyl.appName; offers access to the most frequently used
+    &dactyl.host; functionality via Ex and Normal mode commands, there may be
+    times when direct access to the &dactyl.host; GUI is required. For such
+    eventualities, there are commands to access menu items and to launch
+    standard &dactyl.host; dialogs.
+</p>
+
+<h2 tag="menu">Menus</h2>
+
+<item>
+    <tags>:emenu</tags>
+    <strut/>
+    <spec>:emenu <a>menu</a></spec>
+    <description>
+        <p>
+            Execute <a>menu</a> from the command line. This command provides command-line access
+            to all menu items available from the main &dactyl.host; menubar. <a>menu</a> is a
+            hierarchical path to the menu item with each submenu separated by a period.
+            E.g. <ex>:emenu File.Open File…</ex> launches the standard
+            &dactyl.host; ‘Open File’ dialog.
+        </p>
+    </description>
+</item>
+
+<h2 tag="dialogs">Dialogs</h2>
+
+<item>
+    <tags>:ao :addo :addons</tags>
+    <strut/>
+    <spec>:addo<oa>ns</oa></spec>
+    <description>
+        <p>Opens the add-on list.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:dia :dialog</tags>
+    <spec>:dia<oa>log</oa> <oa>&dactyl.host;-dialog</oa></spec>
+    <description>
+        <p>Open a &dactyl.host; dialog. Available dialogs include:</p>
+
+        <dl tag="dialog-list"/>
+    </description>
+</item>
+
+<item>
+    <tags>:dl :downl :downloads</tags>
+    <strut/>
+    <spec>:downl<oa>oads</oa></spec>
+    <description>
+        <p>
+            Show progress of current downloads. Here, downloads can
+            be paused, resumed, and canceled.
+        </p>
+    </description>
+</item>
+
+<h2 tag="extensions add-ons">Add-ons</h2>
+
+<item>
+    <tags>:exta :extadd</tags>
+    <spec>:exta<oa>dd</oa> <a>file|url</a></spec>
+    <strut/>
+    <description>
+        <p>
+            Install an extension. <a>file|uri</a> must be the local file
+            path or URL of an XPInstall (.xpi) file.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:extde :extdelete</tags>
+    <spec>:extde<oa>lete</oa> <a>extension</a></spec>
+    <spec>:extde<oa>lete</oa>!</spec>
+    <strut/>
+    <description>
+        <p>
+            Uninstall an extension. <a>extension</a> is the extension's name. When <oa>!</oa> is given
+            all extensions are uninstalled.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:extd :extdisable</tags>
+    <spec>:extd<oa>isable</oa> <a>extension</a></spec>
+    <spec>:extd<oa>isable</oa>!</spec>
+    <strut/>
+    <description>
+        <p>
+            Disable an extension. <a>extension</a> is the extension's name. When <oa>!</oa> is given
+            all extensions are disabled.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:exte :extenable</tags>
+    <spec>:exte<oa>nable</oa> <a>extension</a></spec>
+    <spec>:exte<oa>nable</oa>!</spec>
+    <strut/>
+    <description>
+        <p>
+            Enable an extension. <a>extension</a> is the extension's name. When <oa>!</oa> is given all
+            extensions are enabled.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:exto :extoptions</tags>
+    <spec>:exto<oa>ptions</oa><oa>!</oa> <a>extension</a></spec>
+    <tags>:extp :extpreferences</tags>
+    <spec>:extp<oa>references</oa><oa>!</oa> <a>extension</a></spec>
+    <description>
+        <p>
+            Open the preferences dialog for an extension. If <oa>!</oa> is given, open a dialog,
+            otherwise open a buffer. See also <o>newtab</o>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:extr :extrehash</tags>
+    <spec>:extr<oa>ehash</oa> <a>extension</a></spec>
+    <spec>:extr<oa>ehash</oa></spec>
+    <description>
+        <p>
+            Toggle an extension's enabled status twice. This is useful for rebooting
+            a restartless extension.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:extt :exttoggle</tags>
+    <spec>:extt<oa>oggle</oa> <a>extension</a></spec>
+    <spec>:extt<oa>oggle</oa></spec>
+    <description>
+        <p>
+            Toggle an extension's enabled status.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:extu :extupdate</tags>
+    <spec>:extu<oa>pdate</oa> <a>extension</a></spec>
+    <spec>:extu<oa>pdate</oa><oa>!</oa></spec>
+    <description>
+        <p>
+            Update an extension. When <oa>!</oa> is given, update all
+            extensions.
+        </p>
+    </description>
+</item>
+
+<h2 tag="sidebar">Sidebar</h2>
+
+<item>
+    <tags>:sbcl :sbclose</tags>
+    <spec>:sbcl<oa>ose</oa></spec>
+    <description short="true">
+        <p>Close the sidebar window.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:sbope :sbopen :sb :sbar :sideb :sidebar</tags>
+    <spec>:sidebar <a>name</a></spec>
+    <description>
+        <p>
+            Open the sidebar window. <a>name</a> is any of the menu items listed under the
+            standard &dactyl.host; View->Sidebar menu. Add-ons, Preferences and Downloads are
+            also available in the sidebar.
+        </p>
+    </description>
+</item>
+<item>
+    <spec>:sidebar! <oa>name</oa></spec>
+    <description>
+        <p>
+            Toggle the sidebar window. When <oa>name</oa> is provided, the
+            semantics are as follows: If the named sidebar is currently open,
+            it is closed. Otherwise the named sidebar is opened. When
+            <oa>name</oa> is not provided, the semantics are as follows: If the
+            sidebar is currently open, it is closed. Otherwise the previously
+            open sidebar panel is re-opened.
+        </p>
+    </description>
+</item>
+
+
+<h2 tag="status-line status-bar">Status line</h2>
+
+<p>
+    The status line appears at the bottom of each window. You can use
+    <o>guioptions</o> to specify if and when the status line appears, as well
+    as its relation to the command line and messages.
+</p>
+
+<p>
+    The status line contains several fields that provide information about the
+    state of the current buffer. These are, in order:
+</p>
+
+<ul>
+    <li>
+        <em>URL</em>: The URL of the currently loaded page. While the page is loading,
+        progress messages are also output to this field.
+    </li>
+    <li>
+        <em>History and bookmark status</em> (<tt>[+-❤⋯]</tt>): The position
+        of the current page in the tab's session history; <tt>+</tt> and
+        <tt>-</tt> indicate that it is possible to move backwards and forwards
+        through the history respectively. ❤ indicates that the current page is
+        bookmarked. Any other character indicates a QuickMark matching the
+        current page.
+    </li>
+    <li>
+        <em>Tab index</em> (<tt>[N/M]</tt>): <tt>N</tt> is the index of the
+        currently selected tab and <tt>M</tt> is the total number of tabs in
+        the current window.
+    </li>
+    <li>
+        <em>Vertical scroll</em>: The vertical scroll percentage of the current buffer,
+        or Top or Bot for the top and bottom of the buffer respectively.
+    </li>
+    <li>
+        <em>Security</em>: The security information button is displayed when appropriate
+        as per &dactyl.host;. The color of the status bar also changes to reflect the
+        current security status of the loaded page.
+        <dl>
+            <dt style="border:1px solid white" dactyl:highlight="StatusLineNormal">black</dt>
+            <dd>The site's identity is unverified and the connection is unencrypted</dd>
+
+            <dt style="border:1px solid white" dactyl:highlight="StatusLineBroken">red</dt>
+            <dd>The connection is encrypted, but the site's identity has not been verified or it contains unencrypted content</dd>
+
+            <dt style="border:1px solid white" dactyl:highlight="StatusLineSecure">blue</dt>
+            <dd>The site's domain has been verified and the connection is encrypted</dd>
+
+            <dt style="border:1px solid white" dactyl:highlight="StatusLineExtended">green</dt>
+            <dd>The site's domain and owner have been fully verified via an Extended Validation certificate and the connection is encrypted</dd>
+        </dl>
+    </li>
+    <li>
+        <em>Extensions</em>: Any extension buttons that would normally be installed to the
+        &dactyl.host; status bar are appended to the end of the status line.
+    </li>
+</ul>
+
+<h2 tag="toolbar">Toolbars</h2>
+
+<item>
+    <tags>:tbs :tbshow :toolbars :toolbarshow</tags>
+    <spec>:toolbarshow <a>name</a></spec>
+    <description>
+        <p>Shows the named toolbar.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:tbh :tbhide :toolbarh  :toolbarhide</tags>
+    <spec>:toolbarhide <a>name</a></spec>
+    <description>
+        <p>Hides the named toolbar.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:tbt :tbtoggle :toolbart :toolbartoggle</tags>
+    <spec>:toolbartoggle <a>name</a></spec>
+    <description>
+        <p>Toggles the named toolbar.</p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/hints.xml b/common/locale/en-US/hints.xml
new file mode 100644 (file)
index 0000000..68ce2f3
--- /dev/null
@@ -0,0 +1,143 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="hints"
+    title="&dactyl.appName; Hints"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="quick-hints hints">Hints</h1>
+<toc start="2"/>
+
+<p>
+    Hints are an easy way to interact with web pages without using
+    your mouse. In hint mode, &dactyl.appName; highlights and
+    numbers all clickable elements. The elements can be selected
+    either by typing their numbers, or typing parts of their text to
+    narrow them down. While the default action is to click the
+    selected link, other actions are available, including saving the
+    resulting link, copying its URL, or saving an image. For each
+    of these actions, only the set of applicable elements is
+    highlighted.
+</p>
+
+<item>
+    <tags>quick-hints</tags>
+    <tags>f QuickHint</tags>
+    <strut/>
+    <spec>f<a>hint</a></spec>
+    <description>
+        <p>
+            Start <t>QuickHint</t> mode. In this mode, every clickable
+            element (as defined by the <o>hinttags</o> option) is
+            highlighted and numbered.  Elements can be selected
+            either by typing their number, or by typing part of
+            their text to narrow down the result. When an element
+            has been selected, it is automatically clicked and hint
+            mode ends. Additionally, the following keys have
+            special meanings in QuickHint mode:
+        </p>
+        <dl>
+            <dt><k name="CR"/></dt>
+            <dd>Selects the first highlighted element, or that
+                focused by <k name="Tab"/>.</dd>
+
+            <dt><k name="Tab"/></dt>
+            <dd>Moves the focus to the next hintable element</dd>
+
+            <dt><k name="Leader"/></dt>
+            <dd>Temporarily treats all numbers (or other keys, depending on the
+                value of <o>hintkeys</o>) as ordinary text</dd>
+
+            <dt><k name="Esc"/></dt>
+            <dd>Exits hint mode without selecting an element</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>F</tags>
+    <strut/>
+    <spec>F<a>hint</a></spec>
+    <description>
+        <p>
+            Start <t>QuickHint</t> mode, but the selected elements
+            are clicked with the <k name="Shift" link="false"/> key pressed,
+            which has the normal effect of opening it in a new tab
+            (depending on the value of the
+            <pref>browser.tabs.loadInBackground</pref> preference).
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>extended-hints</tags>
+    <tags>; ExtendedHint</tags>
+    <strut/>
+    <spec>;<a>mode</a><a>hint</a></spec>
+    <description>
+        <p>
+            Start an extended hint mode. <t>ExtendedHint</t> mode is exactly
+            like <t>QuickHint</t> mode, except that each sub-mode highlights a
+            more specialized set of elements, and performs a unique action on
+            the selected link. Because of the panoply of extended hint modes
+            available, after pressing <k>;</k>, pressing <k name="Tab"/> brings
+            up the completion list with each hint mode and its description.
+        </p>
+
+        <p><a>mode</a> may be one of:</p>
+
+        <ul>
+            <li tag=";;"><em>;</em> to focus a link</li>
+            <li tag=";?"><em>?</em> to show information about the element (incomplete)</li>
+            <li tag=";s"><em>s</em> to save its destination</li>
+            <li tag=";f"><em>f</em> to focus a frame</li>
+            <li tag=";F"><em>F</em> to focus a frame or pseudo-frame</li>
+            <li tag=";o"><em>o</em> to open its location in the current tab</li>
+            <li tag=";t"><em>t</em> to open its location in a new tab</li>
+            <li tag=";b"><em>b</em> to open its location in a new background tab</li>
+            <li tag=";w"><em>w</em> to open its destination in a new window</li>
+            <li tag=";O"><em>O</em> to generate an <ex>:open</ex> prompt with hint’s URL</li>
+            <li tag=";T"><em>T</em> to generate a <ex>:tabopen</ex> prompt with hint’s URL (like <k>;O</k>)</li>
+            <li tag=";W"><em>W</em> to generate a <ex>:winopen</ex> prompt with hint’s URL (like <k>;T</k>)</li>
+            <li tag=";a"><em>a</em> to add a bookmark</li>
+            <li tag=";S"><em>S</em> to add a search keyword for the hint’s form</li>
+            <li tag=";v"><em>v</em> to view its destination source</li>
+            <li tag=";V"><em>V</em> to view its destination source in the external editor</li>
+            <li tag=";y"><em>y</em> to yank its destination location</li>
+            <li tag=";Y"><em>Y</em> to yank its text description</li>
+            <li tag=";c"><em>c</em> to open its context menu</li>
+            <li tag=";i"><em>i</em> to open an image</li>
+            <li tag=";I"><em>I</em> to open an image in a new tab.</li>
+        </ul>
+
+        <p>
+            Of the previous modes, the value of the <o>hinttags</o>
+            option is used to choose the highlighted elements,
+            unless an override can be found in
+            <o>extendedhinttags</o>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>g;</tags>
+    <strut/>
+    <spec>g;<a>mode</a><a>hint</a></spec>
+    <description>
+        <p>
+            Start an extended hint mode and stay there until
+            <k name="Esc"/> is pressed. Like <k>;</k>, except that
+            after a hint is selected, hints remain visible so that
+            another one can be selected with the same action as the
+            first.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/index.xml b/common/locale/en-US/index.xml
new file mode 100644 (file)
index 0000000..99b3c79
--- /dev/null
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="index"
+    title="&dactyl.appName; Index"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="index">Index</h1>
+<toc start="2"/>
+
+This file contains a list of all available commands, mappings and options.
+
+<h2 tag="b-map-index">Base mode</h2>
+
+<h2 tag="m-map-index">Main mode</h2>
+
+<h2 tag="I-map-index">Input mode</h2>
+
+<h2 tag="i-map-index">Insert mode</h2>
+
+<h2 tag="C-map-index">Command mode</h2>
+
+<h2 tag="n-map-index">Normal mode</h2>
+
+<h2 tag="v-map-index">Visual mode</h2>
+
+<h2 tag="c-map-index">Command-line editing</h2>
+
+<h2 tag="t-map-index">Text editing</h2>
+
+<h2 tag="ex-cmd-index :index">Ex commands</h2>
+
+<h2 tag="option-index">Options</h2>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/intro.xml b/common/locale/en-US/intro.xml
new file mode 100644 (file)
index 0000000..3c1e290
--- /dev/null
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="intro"
+    title="&dactyl.appName; Introduction"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<html:div style="text-align: center; clear: right; margin-bottom: -2.2em; padding-right: 4em;"><logo/></html:div>
+
+<h1 tag="intro">Introduction</h1>
+
+<p tag="intro-text"></p>
+
+<p tag="first-run">
+    If this is your first time running &dactyl.appName;, you may need some
+    time to adjust to the standard interface, which hides the menu,
+    navigation, and tool bars by default. If you find it uncomfortable to work
+    without them, you can re-enable them by typing
+</p>
+<set opt="guioptions" op="+="><str delim="">mT</str><k name="CR"/></set>
+<p>
+    If you have trouble remembering commands or keyboard shortcuts, you can
+    bring up this help page at any time by typing <ex>:help</ex> or
+    <k name="F1"/>. If you find that you don't like &dactyl.appName; at all,
+    you can disable it by typing <ex>:extdisable <str delim="">&dactyl.appName;</str></ex> or
+    delete it entirely by typing <ex>:extdelete <str delim="">&dactyl.appName;</str></ex>
+</p>
+
+<h2 tag="overview">Help topics</h2>
+
+<ol tag="topics-list"></ol>
+
+<p>
+    You can also jump directly to the help of a specific command,
+    key mapping, or topic via the <ex>:help</ex> command.
+</p>
+
+<example><ex>:help :help</ex></example>
+
+<h2 tag="features">Features</h2>
+
+<ul tag="features-list"></ul>
+
+<h2 tag="official-plugins">Official Plugins</h2>
+
+<p>
+    In case you're not sufficiently overwhelmed by &dactyl.appName;'s default
+    feature set, don't despair. You can find more entertainment in the
+    officially supported (read: you can flame us when they stop working)
+    <link topic="&dactyl.plugins;">plugins</link>.
+</p>
+
+<h2 tag="contact bugs">Contact</h2>
+
+<p>
+    Please send comments, questions, or patches to the
+    <link topic="&dactyl.list.href;">mailing list</link>,
+    where we will do our best to answer any inquiries. You can also
+    find more information on the <link topic="&dactyl.home;">homepage</link>.
+    Issue reports can be entered in the
+    <link topic="&dactyl.issues;">issue tracker</link>.
+</p>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/map.xml b/common/locale/en-US/map.xml
new file mode 100644 (file)
index 0000000..640b321
--- /dev/null
@@ -0,0 +1,714 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="map"
+    title="&dactyl.appName; Key Mappings"
+    xmlns="&xmlns.dactyl;"
+    xmlns:dactyl="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="keyboard-shortcuts">Keyboard shortcuts and commands</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName; provides a number of commands to change the
+    behavior of key presses. This can mean anything from
+    automatically substituting one key for another or automatically
+    replacing one typed word for another, to launching a dialog or
+    running a command.
+</p>
+
+<h2 tag="key-mapping mapping map macro">Key mapping</h2>
+
+<p>
+    Key mappings are the most basic means &dactyl.appName; provides
+    for altering the actions of key presses. Each key mapping is
+    associated with a mode, such as <link topic="insert-mode">insert</link>,
+    <link>normal</link>, or
+    <link topic="command-line-mode">command-line</link>, and only
+    has effect when that mode is active. Although each mode has a
+    full suite of internal mappings, they may be easily augmented,
+    altered, or removed with the <ex>:map</ex> command and its
+    variants. These commands, in essence, allow the user to quickly
+    substitute one sequence of key presses for another.
+    For instance,
+</p>
+
+<code><ex>:map <k name="F2" link="false"/></ex> <ex>:echo Date()<k name="CR"/></ex></code>
+
+<p>
+    causes “<ex>:echo Date()<k name="CR"/></ex>” to be typed out
+    whenever <k name="F2" link="false"/> is pressed, thus echoing the full date
+    to the command line.
+</p>
+
+<p tag=":map-modes">
+    Standard key mapping commands are provided for the five most
+    common modes,
+</p>
+
+<dl dt="width: 8em;">
+    <dt>n</dt> <dd>Normal mode: When browsing normally</dd>
+    <dt>v</dt> <dd>Visual mode: When selecting text with the cursor keys</dd>
+    <dt>i</dt> <dd>Insert mode: When interacting with text fields on a website</dd>
+    <dt>t</dt> <dd>TextEdit mode: When editing text fields in Vim-like NORMAL mode</dd>
+    <dt>c</dt> <dd>Command-line mode: When typing into the &dactyl.appName; command line</dd>
+</dl>
+
+<p>
+    The ordinary <ex>:map</ex> and <ex>:noremap</ex> commands
+    add mappings for normal and visual mode. In order to map key
+    bindings in a different mode, any of the mapping commands may be
+    prefixed with one of the above letters. For instance,
+    <ex>:imap</ex> creates a new key mapping in insert mode, while
+    <ex>:cunmap</ex> removes a key mapping from command-line mode.
+    Other modes can be specified using the -modes option described below.
+</p>
+
+<warning>
+    It is important to note that mappings are <em>not</em>
+    automatically saved between sessions. In order to preserve them,
+    they must either be added to your <tt><t>&dactyl.name;rc</t></tt> or
+    saved via the <ex>:mk&dactyl.name;rc</ex> command.
+</warning>
+
+<h3 tag=":map-commands">Map commands</h3>
+
+<item>
+    <tags>:map</tags>
+    <spec>:map <a>lhs</a> <a>rhs</a></spec>
+    <tags>:nm :nmap</tags>
+    <spec>:nm<oa>ap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:vm :vmap</tags>
+    <spec>:vm<oa>ap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:im :imap</tags>
+    <spec>:im<oa>ap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:tm :tmap</tags>
+    <spec>:tm<oa>ap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:cm :cmap</tags>
+    <spec>:cm<oa>ap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <description>
+        <p>
+            Map the <t>key-sequence</t> <a>lhs</a> to <a>rhs</a> for
+            the applicable mode(s). The keys of <a>rhs</a> respect
+            user-defined mappings, so the following will result in
+            an infinite loop,
+        </p>
+        <code><ex>:map a b</ex>
+<ex>:map b a</ex></code>
+        <p>
+            In order to avoid this shortcoming, the <ex>:noremap</ex> command
+            or the <em>-builtin</em> option may be used.
+        </p>
+    </description>
+</item>
+
+<h3 tag=":map-options">Map options</h3>
+<p>
+    Any of the map commands may be given the following options:
+</p>
+
+<dl dt="width: 12em;">
+    <dt></dt> <dd></dd>
+
+    <dt>-arg</dt> <dd>Accept an argument after the requisite key press. Sets the <tt>arg</tt> parameter to the result. (short name <em>-a</em>)</dd>
+    <dt>-builtin</dt> <dd>Execute this mapping as if there were no user-defined mappings (short name <em>-b</em>)</dd>
+    <dt>-count</dt> <dd>Accept a count before the requisite key press. Sets the <tt>count</tt> parameter to the result. (short name <em>-c</em>)</dd>
+    <dt>-description</dt> <dd>A description of this mapping (short name <em>-d</em>)</dd>
+    <dt>-ex</dt> <dd>Execute <a>rhs</a> as an Ex command rather than keys (short name <em>-e</em>)</dd>
+    <dt>-group=<a>group</a></dt> <dd>Add this command to the given <t>group</t> (short name <em>-g</em>)</dd>
+    <dt>-javascript</dt> <dd>Execute <a>rhs</a> as JavaScript rather than keys (short names <em>-js</em>, <em>-j</em>)</dd>
+    <dt>-literal=<a>n</a></dt> <dd>Parse the <a>n</a>th argument without specially processing any quote or meta characters. (short name <em>-l</em>)</dd>
+    <dt>-modes</dt> <dd>Create this mapping in the given modes (short names <em>-mode</em>, <em>-m</em>)</dd>
+    <dt>-nopersist</dt> <dd>Do not save this mapping to an auto-generated rc file (short name <em>-n</em>)</dd>
+    <dt>-silent</dt> <dd>Do not echo any generated keys to the command line (short name <em>-s</em>, also <em>&lt;silent></em> for Vim compatibility)</dd>
+</dl>
+
+<item>
+    <tags>:no :noremap</tags>
+    <spec>:no<oa>remap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:nn :nnoremap</tags>
+    <spec>:nn<oa>oremap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:vn :vnoremap</tags>
+    <spec>:vn<oa>oremap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:ino :inoremap</tags>
+    <spec>:ino<oa>remap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:tno :tnoremap</tags>
+    <spec>:tno<oa>remap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <tags>:cno :cnoremap</tags>
+    <spec>:cno<oa>remap</oa> <a>lhs</a> <a>rhs</a></spec>
+    <description>
+        <p>
+            Map the <t>key-sequence</t> <a>lhs</a> to <a>rhs</a> for
+            the applicable mode(s). The keys in <a>rhs</a> do not
+            respect user-defined key mappings, so the following
+            effectively reverses the default meanings of the keys
+            <k>d</k> and <k>D</k>
+        </p>
+        <code><ex>:noremap d D</ex>
+<ex>:noremap D d</ex></code>
+    </description>
+</item>
+
+<item>
+    <spec>:unmap <a>lhs</a></spec>
+    <tags>:unm :unmap</tags>
+    <spec>:unmap!</spec>
+    <spec>:nun<oa>map</oa> <a>lhs</a></spec>
+    <tags>:nun :nunmap</tags>
+    <spec>:nun<oa>map</oa>!</spec>
+    <spec>:vun<oa>map</oa> <a>lhs</a></spec>
+    <tags>:vun :vunmap</tags>
+    <spec>:vun<oa>map</oa>!</spec>
+    <spec>:iu<oa>nmap</oa> <a>lhs</a></spec>
+    <tags>:iu :iunmap</tags>
+    <spec>:iu<oa>nmap</oa>!</spec>
+    <spec>:tu<oa>nmap</oa> <a>lhs</a></spec>
+    <tags>:tu :tunmap</tags>
+    <spec>:tu<oa>nmap</oa>!</spec>
+    <spec>:cu<oa>nmap</oa> <a>lhs</a></spec>
+    <tags>:cu :cunmap</tags>
+    <spec>:cu<oa>nmap</oa>!</spec>
+    <description>
+        <p>Remove the mapping of <a>lhs</a> (or all mappings if <oa>!</oa> is
+            given) for the applicable mode(s).</p>
+    </description>
+</item>
+
+<item>
+    <spec>:map</spec>
+    <spec>:nm<oa>ap</oa></spec>
+    <spec>:vm<oa>ap</oa></spec>
+    <spec>:im<oa>ap</oa></spec>
+    <spec>:tm<oa>ap</oa></spec>
+    <spec>:cm<oa>ap</oa></spec>
+    <description>
+        <p>
+            List all mappings for the applicable mode(s). Mappings are
+            partitioned into <t>groups</t>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:map_l</tags>
+    <spec>:map <a>lhs</a></spec>
+    <tags>:nmap_l</tags>
+    <spec>:nm<oa>ap</oa> <a>lhs</a></spec>
+    <tags>:vmap_l</tags>
+    <spec>:vm<oa>ap</oa> <a>lhs</a></spec>
+    <tags>:imap_l</tags>
+    <spec>:im<oa>ap</oa> <a>lhs</a></spec>
+    <tags>:tmap_l</tags>
+    <spec>:tm<oa>ap</oa> <a>lhs</a></spec>
+    <tags>:cmap_l</tags>
+    <spec>:cm<oa>ap</oa> <a>lhs</a></spec>
+    <description>
+        <p>List all mappings starting with <a>lhs</a> for the applicable mode(s).</p>
+    </description>
+</item>
+
+<h3 tag=":map-timeout map-timeout">Mapping timeout</h3>
+<p>
+    When &dactyl.appName; receives a key event that has a separate binding and
+    at the same time is part of a key chain, values of the <o>timeout</o> and
+    <o>timeoutlen</o> options are used to decide what to do. See the
+    documentation of those options for more information.
+</p>
+
+<h3 tag=":map-arguments">Special arguments</h3>
+
+<p>
+    Below is an overview of which modes each map command applies to:
+</p>
+
+<!-- TODO: table format -->
+
+<code>
+:map   :noremap   :unmap   :mapclear  – both Normal and Visual modes
+:nmap  :nnoremap  :nunmap  :nmapclear – Normal mode
+:vmap  :vnoremap  :vunmap  :vmapclear – Visual mode
+:imap  :inoremap  :iunmap  :imapclear – Insert mode
+:tmap  :tnoremap  :tunmap  :tmapclear – Text Edit mode
+:cmap  :cnoremap  :cunmap  :cmapclear – Command-line mode
+</code>
+
+<h3 tag="key-notation key-sequence">Key sequences</h3>
+
+<p>
+    Most keys in key sequences are represented simply by the
+    character that you see on the screen when you type them.
+    However, as a number of these characters have special meanings,
+    and a number of keys have no visual representation, a special
+    notation is required.
+</p>
+
+<ul>
+    <li>
+        The first argument to the <ex>:map</ex> commands must be
+        <link topic="quoting">quoted</link> if it contains spaces,
+        quotation marks or back-slashes. A space may additionally be
+        typed as <k name="Space"/>.
+    </li>
+    <li key="&lt;lt>">
+        As special key names start with the <em>&lt;</em> character,
+        a literal &lt; must be typed as <k name="lt" link="false"/>.
+    </li>
+    <li>
+        <k name="Left"/>, <k name="Right"/>, <k name="Up"/>,
+        and <k name="Down"/> represent the standard arrow keys.
+    </li>
+    <li>
+        <k name="CapsLock" link="false"/>, <k name="NumLock" link="false"/>, <k name="Insert"/>
+        <k name="Del" link="false"/>, <k name="Tab"/>, <k name="PageUp"/>,
+        <k name="PageDown"/>, and <k name="Esc"/> work as
+        expected.
+   </li>
+    <li>
+        <k name="Return" link="false"/> or <k name="CR"/> represent the carriage
+        return key.
+    </li>
+    <li><k name="BS" link="false"/> represents the backspace key.</li>
+    <li><k name="F1"/> through <k name="F12" link="false"/> work as expected.</li>
+    <li>
+        <k name="K0" link="false"/> through <k name="K9" link="false"/> represent keys on the
+        numeric keypad.
+    </li>
+    <li>
+        <k name="Uxxxx" link="false"/>, where <em>xxxx</em> is any 4 hexadecimal
+        digits, represents the character at that Unicode codepoint.
+        For instance, <k name="U263a" link="false"/> represents ☺.
+    </li>
+</ul>
+
+<p>
+    In order to represent key presses using the Control, Alt, Meta,
+    or Shift keys, the following prefixes may be used,
+</p>
+
+<ol>
+    <li><k name="C-␣" link="false"/>: The control or ctrl key.</li>
+    <li><k name="A-␣" link="false"/>: The alt key.</li>
+    <li><k name="M-␣" link="false"/>: The meta key, windows key, or command key.</li>
+    <li><k name="S-␣" link="false"/>: The shift key.</li>
+</ol>
+
+<p>
+    These prefixes can be combined however you see fit.
+</p>
+
+<note>
+    Within angle brackets all alphabetic characters are read as lowercase.
+    Uppercase characters can only be specified with the <em>S-</em> modifier.
+</note>
+
+<p>
+    The following key sequences are interpreted as described:
+</p>
+
+<dl dt="width: 10em;">
+    <dt><k link="false">xc</k></dt>
+    <dd>Type the ‘X’ key followed by the ‘C’ key.</dd>
+
+    <dt><k name="C-x" link="false">c</k></dt>
+    <dd>
+        Type the ‘X’ key while holding the ‘Control’ key, followed
+        by the ‘C’ key.
+    </dd>
+
+    <dt><k name="C-2" link="false"/></dt>
+    <dd>Type the ‘2’ while holding the ‘Control’ key.</dd>
+
+    <dt><k name="C-@" link="false"/></dt>
+    <dd>Type the ‘@’ key while holding the ‘Control’ key.</dd>
+
+    <dt><k name="S-Space" link="false"/></dt>
+    <dd>Press the space bar while holding the ‘Shift’ key.</dd>
+
+    <dt><k name="C-A-j" link="false"/></dt>
+    <dd>Type the ‘J’ key while while holding both the ‘Control’ and ‘Alt’ keys.</dd>
+
+    <dt><k name="C-A-J" link="false"/></dt>
+    <dd>Exactly the same as above.</dd>
+
+    <dt><k name="C-A-S-j" link="false"/></dt>
+    <dd>Type the ‘J’ key while while holding both the ‘Control’, ‘Alt’, and ‘Shift’ keys.</dd>
+</dl>
+
+<h3 tag=":map-special-chars">Special characters</h3>
+
+<item>
+    <tags><![CDATA[<Nop>]]></tags>
+    <strut/>
+    <spec><![CDATA[<Nop>]]></spec>
+    <description>
+        <p>
+            Do nothing. This pseudo-key is useful for disabling a
+            specific builtin mapping. For example,
+            <ex>:map <k name="C-n"/> <k name="Nop"/></ex> will prevent <k name="C-n"/>
+            from doing anything.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<Pass>]]></tags>
+    <spec><![CDATA[<Pass>]]></spec>
+    <description short="true">
+        <p>
+            Pass the events consumed by the last executed mapping through to &dactyl.host;.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<CR> map_return]]></tags>
+    <strut/>
+    <spec><![CDATA[<CR>]]></spec>
+    <description>
+        <p>
+            Expand to a line terminator in a key mapping. An Ex command in the <a>rhs</a> of a
+            mapping requires a line terminator after it so that it is executed when the
+            mapping is expanded. <k name="CR"/> should be used for this purpose.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<Leader> \]]></tags>
+    <strut/>
+    <spec><![CDATA[<Leader>]]></spec>
+    <description>
+        <p>
+            A pseudo-key which expands to the value of the <o>mapleader</o>
+            option. For example, by default,
+        </p>
+        <code><ex>:map <k name="Leader"/>h</ex> <ex>:echo <str>Hello</str><k name="CR"/></ex></code>
+        <p>works like</p>
+        <code><ex>:map \h</ex> <ex>:echo <str>Hello</str><k name="CR"/></ex></code>
+        <p>but after</p>
+        <set opt="mapleader"><str>,</str></set>
+        <p>it works like</p>
+        <code><ex>:map ,h</ex> <ex>:echo <str>Hello</str><k name="CR"/></ex></code>
+    </description>
+</item>
+
+<h2 tag="abbreviations">Abbreviations</h2>
+
+<p>
+    In addition to basic mappings, &dactyl.appName; can also
+    automatically replace whole words after they've been typed.
+    These shortcuts are known as abbreviations, and are most often
+    useful for correcting spelling of commonly mistyped words, as
+    well as shortening the typing of oft-typed but long words or
+    phrases. There are three basic types of abbreviations, defined
+    by the types of characters they contain,
+</p>
+
+<ul>
+    <li>‘full-id’ abbreviations consist entirely of keyword characters (e.g., ‘teh’, ‘msoft’).</li>
+    <li>‘end-id’ abbreviations end in keyword character but otherwise contains all non-keyword characters (e.g., ‘'i’).</li>
+    <li>‘non-id’ abbreviations end in a non-keyword character but otherwise contains any non-whitespace character (e.g., ‘def'’).</li>
+    <li>Strings which fit none of the above patterns can not be defined as abbreviations (e.g., ‘a'b’ and ‘a b’).</li>
+</ul>
+
+<p>
+    For the purposes of abbreviations, keyword characters include
+    all non-whitespace characters except for single or double
+    quotation marks. Abbreviations are expanded as soon as any
+    non-keyword character, or the key <k name="C-]" mode="c"/>, is typed.
+</p>
+
+<item>
+    <tags>:ab :abbreviate</tags>
+    <spec>:ab<oa>breviate</oa> <oa>-group=<a>group</a></oa> <a>lhs</a> <a>rhs</a></spec>
+    <spec>:ab<oa>breviate</oa> <oa>-group=<a>group</a></oa> <a>lhs</a></spec>
+    <spec>:ab<oa>breviate</oa> <oa>-group=<a>group</a></oa></spec>
+    <description>
+        <p>
+            Abbreviate <a>lhs</a> to <a>rhs</a>. If only <a>lhs</a>
+            is given, list all abbreviations that start with
+            <a>lhs</a>. If no arguments are given, list all
+            abbreviations.
+        </p>
+
+        <p>
+            If the <em>-javascript</em> (short names <em>-js</em>,
+            <em>-j</em>) option is given, <a>lhs</a> is expanded to
+            the value <em>return</em>ed by the JavaScript code
+            <a>rhs</a>. The code is evaluated with the variable
+            <em>editor</em> set to the editable element that the
+            abbreviation is currently being expanded in. The code
+            should <em>not</em> make any changes to the contents of
+            the editor.
+        </p>
+
+        <p>
+            If <a>group</a> is specified then abbreviations are created or
+            listed for the given <t>group</t>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:ca :cabbreviate</tags>
+    <spec>:ca<oa>bbreviate</oa> <a>lhs</a> <a>rhs</a></spec>
+    <spec>:ca<oa>bbreviate</oa> <a>lhs</a></spec>
+    <spec>:ca<oa>bbreviate</oa></spec>
+    <description>
+        <p>
+            Abbreviate a key sequence for Command-line mode. Same as
+            <ex>:ab<oa>breviate</oa></ex>, but for
+            <t>command-line</t> mode only.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:ia :iabbreviate</tags>
+    <spec>:ia<oa>bbreviate</oa> <a>lhs</a> <a>rhs</a></spec>
+    <spec>:ia<oa>bbreviate</oa> <a>lhs</a></spec>
+    <spec>:ia<oa>bbreviate</oa></spec>
+    <description>
+        <p>
+            Abbreviate a key sequence for Insert mode. Same as
+            <ex>:ab<oa>breviate</oa></ex>, but for insert mode only.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:una :unabbreviate</tags>
+    <spec>:una<oa>bbreviate</oa> <a>lhs</a></spec>
+    <spec>:una<oa>bbreviate</oa>!</spec>
+    <description>
+        <p>Remove an abbreviation. With <oa>!</oa>, remove all abbreviations.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:cuna :cunabbreviate</tags>
+    <spec>:cuna<oa>bbreviate</oa> <a>lhs</a></spec>
+    <spec>:cuna<oa>bbreviate</oa>!</spec>
+    <description>
+        <p>
+            Remove abbreviation(s) for Command-line mode. Same as
+            <ex>:una<oa>bbreviate</oa></ex>, but for
+            <t>command-line</t> mode only.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:iuna :iunabbreviate</tags>
+    <spec>:iuna<oa>bbreviate</oa> <a>lhs</a></spec>
+    <spec>:iuna<oa>bbreviate</oa>!</spec>
+    <description>
+        <p>
+            Remove abbreviation(s) for Insert mode. Same as
+            <ex>:una<oa>bbreviate</oa></ex> but for Insert mode
+            only.
+        </p>
+    </description>
+</item>
+
+<h2 tag="user-commands">User-defined commands</h2>
+
+<p>
+    Defining new commands is perhaps the most straightforward way of
+    repeating commonly used actions. User-defined commands may be
+    entered from the command line or scripts exactly like standard
+    commands, and may similarly accept arguments, options, counts,
+    and <oa>!</oa>s, as well as provide command-line completion.
+    These commands may be defined as either ordinary,
+    macro-interpolated Ex commands, or otherwise as plain
+    JavaScript statements.
+</p>
+
+<item>
+    <tags>:com :command</tags>
+    <spec>:com<oa>mand</oa></spec>
+    <description short="true">
+        <p>List all user-defined commands.</p>
+    </description>
+</item>
+
+<item>
+    <spec>:com<oa>mand</oa> <oa>cmd</oa></spec>
+    <description>
+        <p>
+            List all user-defined commands that start with <oa>cmd</oa>. Commands
+            are partitioned into <t>groups</t>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <spec>:com<oa>mand</oa><oa>!</oa> <oa><a>options</a>…</oa> <a>cmd</a> <a>rep</a></spec>
+    <description>
+        <p>
+            Define a new user command. The name of the command is
+            <a>cmd</a> and its replacement text is <a>rep</a>. If a
+            command with this name already exists, an error is
+            reported unless <oa>!</oa> is specified, in which case
+            the command is redefined. Unlike Vim, the command may
+            start with a lowercase letter. <a>cmd</a> may also be multiple
+            alternative command names separated by commas.
+        </p>
+
+        <p>
+            The new command is usually defined by a string to be
+            executed as an Ex command. In this case, before
+            execution, strings of the form
+            <hl key="HelpKey">&lt;<a>var</a>></hl> are interpolated
+            as described below, in order to insert arguments,
+            options, and the like. If the <em>-javascript</em> (short
+            name <em>-js</em>) flag is present, the command is
+            executed as JavaScript, and the arguments are present as
+            variables in its scope instead, and no interpolation is
+            performed.
+        </p>
+
+        <p>
+            The command's behavior can be altered by providing
+            options when the command is defined.
+        </p>
+
+        <h3 tag=":command-group">Grouping</h3>
+
+        <p>
+            The <em>-group</em> flag (short name: <em>-g</em>) can be used to
+            assign this command to a specific <t>group</t>.
+        </p>
+
+        <h3 tag="E175 E176 :command-nargs">Argument handling</h3>
+
+        <p>
+            By default, user commands accept no arguments. This can be changed by specifying
+            the <tt>-nargs</tt> option.
+        </p>
+
+        <p>The valid values are:</p>
+
+        <dl>
+            <dt>-nargs=0</dt><dd>No arguments are allowed (default)</dd>
+            <dt>-nargs=1</dt><dd>One argument is allowed</dd>
+            <dt>-nargs=*</dt><dd>Zero or more arguments are allowed</dd>
+            <dt>-nargs=?</dt><dd>Zero or one argument is allowed</dd>
+            <dt>-nargs=+</dt><dd>One or more arguments are allowed</dd>
+        </dl>
+
+        <h3 tag="E180 E181 :command-complete">Argument completion</h3>
+
+        <p>
+            Completion for arguments to user-defined commands is not available by default.
+            Completion can be enabled by specifying one of the following arguments to the
+            -complete option when defining the command.
+        </p>
+
+        <dl tag=":command-complete-arg-list"/>
+
+        <h3 tag="E467 E468 :command-completion-custom">Custom completion</h3>
+
+        <p>
+            Custom completion can be provided by specifying the
+            <str>custom,<a>thing</a></str> argument to <tt>-complete</tt>. If
+            <a>thing</a> evaluates to a function (i.e., it is a variable holding
+            a function value, or a string containing the definition itself), it
+            is called with two arguments: a completion context, and an object
+            describing the command's arguments. It should set the context's
+            <tt>completions</tt> property to the list of completion results.
+            Other influential properties include <tt>title</tt>, <tt>sort</tt>,
+            <tt>anchored</tt>, and <tt>filters</tt>, which are documented in the
+            <link topic="resource://dactyl/completion.jsm"
+                line="17" dactyl:command="buffer.viewSource">source code</link>.
+        </p>
+
+        <p>
+            <em>completions</em> is a two-dimensional array of the form:
+            <tt>[[arg1, description1], [arg2, description2], …]</tt>
+        </p>
+
+        <p>
+            Otherwise <a>thing</a> should evaluate to an array of the same form
+            as the <tt>completions</tt> property of the context object.
+        </p>
+
+        <p>
+            Example:
+            <code><ex>:command foo -nargs=? -complete custom,<str delim="'">
+    \ function (context) context.completions = [["arg1", "description1"], ["arg2", "description2"]]</str>
+    \ <ex>:echo</ex> <str>Useless </str> + <em>&lt;q-args></em></ex>
+
+<ex>:command foo -nargs=?
+    \ -complete custom,<str delim="'">[["arg1", "description1"], ["arg2, "description2"]]</str>
+    \ <ex>:echo</ex> <str>Same as above but simpler </str> + <em>&lt;q-args></em></ex></code>
+        </p>
+
+        <h3 tag="E177 E178 :command-count">Count handling</h3>
+
+        <p>
+            By default, user commands do not accept a count. Use the -count option if
+            you'd like to have a count passed to your user command. This will then be
+            available for expansion as &lt;count> in the replacement.
+        </p>
+
+        <h3 tag=":command-bang">Special cases</h3>
+
+        <p>
+            By default, a user command does not have a special version, i.e. a version
+            executed with the ! modifier. Providing the -bang option will enable this
+            and &lt;bang> will be available in the replacement.
+        </p>
+
+        <h3 tag=":command-description">Command description</h3>
+
+        <p>
+            The command's description text can be set with -description. Otherwise it will
+            default to "User-defined command".
+        </p>
+
+        <h3 tag=":command-replacement-text">Replacement text</h3>
+
+        <p>
+            The replacement text <a>rep</a> is scanned for <t>macro-string</t>s and these are
+            replaced with values from the user-entered command line. The resulting string
+            is then executed as an Ex command.
+        </p>
+
+        <p>
+            In addition to the standard parameters listed in
+            <t>macro-string</t>, the following parameters are available:
+        </p>
+
+        <dl>
+            <dt>&lt;args></dt> <dd>The command arguments exactly as supplied</dd>
+            <dt>&lt;count></dt><dd>Any supplied count, e.g. 5</dd>
+            <dt>&lt;bang></dt> <dd>! if the command was executed with the ! modifier</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>:delc :delcommand</tags>
+    <spec>:delc<oa>ommand</oa> <a>cmd</a></spec>
+    <spec>:delc<oa>ommand</oa>!</spec>
+    <description>
+        <p>Delete the user-defined command <a>cmd</a>. With <oa>!</oa>, delete
+            all user commands.</p>
+    </description>
+</item>
+
+<h2 tag="command-examples">Examples</h2>
+
+<p>Add a :Google command to search via google:</p>
+<code><ex>:command -nargs=* Google open google &lt;args></ex></code>
+
+<!-- TODO: add decent examples -->
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/marks.xml b/common/locale/en-US/marks.xml
new file mode 100644 (file)
index 0000000..b85e936
--- /dev/null
@@ -0,0 +1,472 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="marks"
+    title="&dactyl.appName; Marks"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="marks">Marks</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName; supports a number of different methods of
+    marking your place, in order to easily return later,
+</p>
+
+<ul>
+    <li><em>Bookmarks</em> are the standard marks of &dactyl.host;, and are fully supported.</li>
+    <li><em>QuickMarks</em> allow you to quickly save and return to as many as 62 (a-zA-Z0-9) different web sites with a quick keyboard shortcut.</li>
+    <li><em>Local marks</em> allow you to store and return to a position within the current web page.</li>
+    <li><em>URL marks</em> allow you to store and return to the position and URL of the current web page.</li>
+    <li><em>History</em> marks every opened page with data on when and how often it has been visited.</li>
+</ul>
+
+<h2 tag="bookmarks">Bookmarks</h2>
+
+<p>
+    Bookmarks are the most traditional kind of marks supported by
+    &dactyl.appName;. They are accessible through &dactyl.host;'s
+    bookmark menu, sidebar, and toolbar, in addition to its location
+    bar completion system. &dactyl.appName; makes them accessible
+    not only via several commands and its completion system (see the
+    <o>complete</o> option), but also displays a ❤ in the status bar
+    when a bookmarked page is displayed.
+</p>
+
+<item>
+    <tags>a :bma :bmark</tags>
+    <spec>:bma<oa>rk</oa><oa>!</oa> <oa>options</oa> <oa>url</oa></spec>
+    <spec>a</spec>
+    <description>
+        <p>Add a bookmark.</p>
+
+        <p>The following options are available:</p>
+
+        <dl dt="width: 8em;">
+            <dt>-charset</dt>
+            <dd>
+                Character encoding to use for the bookmark. Useful e.g. for
+                sites that require query parameters in encodings other than
+                UTF-8 (short name <em>-c</em>).
+            </dd>
+            <dt>-keyword</dt>
+            <dd>
+                A keyword which may be used to open the bookmark via
+                the URL bar or <ex>:open</ex> prompt. If the
+                <oa>url</oa> contains the string <em>%s</em> it is
+                replaced by any text following the keyword when it
+                is opened. See also <k>;S</k>.
+                (short name <em>-k</em>)
+            </dd>
+            <dt>-post</dt>
+            <dd>
+                Data to be POSTed to the server when the bookmark is
+                opened.
+            </dd>
+            <dt>-tags</dt>
+            <dd>
+                Comma-separated list of tags for grouping and later
+                access (short name <em>-T</em>).
+            </dd>
+            <dt>-title</dt>
+            <dd>
+                The title of the bookmark.
+                Defaults to the page title, if available, or
+                <oa>url</oa> otherwise.
+                (short name <em>-t</em>)
+            </dd>
+        </dl>
+
+        <p>
+            If <oa>url</oa> is omitted, the URL of the currently loaded web
+            page is used.
+        </p>
+
+        <p>
+            If <oa>!</oa> is present, a new bookmark is always
+            added. Otherwise, the first bookmark matching
+            <oa>url</oa> is updated.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>A</tags>
+    <strut/>
+    <spec>A</spec>
+    <description>
+        <p>
+            Toggle bookmarked state of current URL. Add/remove a
+            bookmark for the current location, depending on whether
+            it is already bookmarked. New bookmarks are placed in
+            the <em>Unfiled Bookmarks</em> folder, and don't appear
+            in the bookmarks menu or toolbar, but do appear in
+            location bar and <em>:open</em> completions, as well as
+            the <em>:bmarks</em> list.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:bmarks</tags>
+    <spec>:bmarks<oa>!</oa> <oa>filter</oa></spec>
+    <description>
+        <p>
+            List or open multiple bookmarks. Opens the message window
+            at the bottom of the screen with all bookmarks with
+            titles or URLs matching <oa>filter</oa>. The resulting
+            URLs can be clicked, or accessed via extended hint modes
+            such as <k>;o</k>.
+        </p>
+
+        <p>
+            The special version <ex>:bmarks!</ex> works the same as
+            <ex>:bmarks</ex> except that it opens all matching
+            bookmarks in new tabs rather than listing them.
+        </p>
+
+        <p>The bookmarks may also be filtered via the following options:</p>
+
+        <dl dt="width: 8em;">
+            <dt>-keyword</dt>
+            <dd>
+                The bookmark's keyword (short name <em>-k</em>).
+            </dd>
+            <dt>-tags</dt>
+            <dd>
+                A comma-separated list of tags, all of which must be
+                present for a match (short name <em>-T</em>).
+            </dd>
+            <dt>-title</dt>
+            <dd>
+                The title of the bookmark (short name <em>-t</em>).
+            </dd>
+            <dt>-max</dt>
+            <dd>
+                The maximum number of items to list or open
+                (short name <em>-m</em>).
+            </dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>:delbm :delbmarks</tags>
+    <spec>:delbm[arks] <oa>url</oa></spec>
+    <spec>:delbm[arks]!</spec>
+    <description>
+        <p>
+            Deletes <em>all</em> bookmarks which match <oa>url</oa>.
+            If omitted, <oa>url</oa> defaults to the URL of the
+            current buffer.
+        </p>
+
+        <p>Accepts the same options as :bmarks.</p>
+
+        <p>If <oa>!</oa> is specified then all bookmarks will be deleted.</p>
+    </description>
+</item>
+
+<h2 tag="history">History</h2>
+
+<p>
+    Though not traditionally considered a mark, history behaves very
+    similarly to bookmarks both in &dactyl.host; and
+    &dactyl.appName;. Every visited page is marked and weighted by
+    when and how often it is visited, and can be retrieved both in
+    history list and location completions. Indeed, the ‘frecency’
+    algorithm used to determine the results of location completions
+    (see the <o>complete</o> option) means that history is often a
+    more effective type of mark than bookmarks themselves.
+</p>
+
+<item>
+    <tags><![CDATA[<C-o>]]></tags>
+    <strut/>
+    <spec><![CDATA[[count]<C-o>]]></spec>
+    <description>
+        <p>
+            Go to an older position in the jump list. This currently
+            entails moving backward in page history, but in the
+            future will take into account page positions as well.
+            If <oa>count</oa> is specified go back <oa>count</oa> pages.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<C-i>]]></tags>
+    <strut/>
+    <spec><![CDATA[[count]<C-i>]]></spec>
+    <description>
+        <p>
+            Go to an newer position in the jump list. This currently
+            entails moving forward in page history, but in the
+            future will take into account page positions as well.
+            If <oa>count</oa> is specified go forward <oa>count</oa> pages.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<M-Left> <A-Left> H]]></tags>
+    <strut/>
+    <spec>[count]H</spec>
+    <description>
+        <p>Go back in the browser history. If <oa>count</oa> is specified go back <oa>count</oa> pages.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<M-Right> <A-Right> L]]></tags>
+    <strut/>
+    <spec><oa>count</oa>L</spec>
+    <description>
+        <p>
+            Go forward in the browser history. If <oa>count</oa> is specified go forward <oa>count</oa>
+            pages.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:ba :back</tags>
+    <spec>:<oa>count</oa>ba<oa>ck</oa> <oa>url</oa></spec>
+    <spec>:<oa>count</oa>ba<oa>ck</oa>!</spec>
+    <description>
+        <p>
+            Go back in the browser history. If <oa>count</oa> is specified go back <oa>count</oa> pages.
+        </p>
+        <p>
+            The special version <ex>:back!</ex> goes to the beginning of the browser history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:fw :fo :forward</tags>
+    <spec>:<oa>count</oa>fo<oa>rward</oa> <oa>url</oa></spec>
+    <spec>:<oa>count</oa>fo<oa>rward</oa>!</spec>
+    <description>
+        <p>
+            Go forward in the browser history. If <oa>count</oa> is specified go forward <oa>count</oa>
+            pages.
+        </p>
+        <p>
+            The special version <ex>:forward!</ex> goes to the end of the browser history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:hs :hist :history</tags>
+    <spec>:hist<oa>ory</oa><oa>!</oa> <oa>filter</oa></spec>
+    <description>
+        <p>
+            Show recently visited URLs. Opens the message window at the bottom of the screen
+            with all history items whose page titles or URLs match
+            <oa>filter</oa>.
+        </p>
+
+        <p>
+            The special version <ex>:history!</ex> works the same as
+            <ex>:history</ex> except that it opens all matching
+            pages in new tabs rather than listing them.
+        </p>
+
+        <p>The pages may also be filtered via the following options,</p>
+
+        <dl dt="width: 8em;">
+            <dt>-max</dt>
+            <dd>
+                The maximum number of items to list or open
+                (short name <em>-m</em>).
+            </dd>
+            <dt>-sort</dt>
+            <dd>
+                The sort order of the results
+                (short name <em>-s</em>).
+            </dd>
+        </dl>
+    </description>
+</item>
+
+<h2 tag="quickmarks">QuickMarks</h2>
+
+<p>
+    QuickMarks are bookmarks stripped to the bone for quickly getting to the
+    pages that you visit most. A QuickMark is simply a URL assigned to a letter
+    or number. They can therefore be saved or opened with only three key
+    presses each. QuickMarks are persistent across browser sessions.
+</p>
+
+<item>
+    <tags>M</tags>
+    <strut/>
+    <spec>M<a>a-zA-Z0-9</a></spec>
+    <description>
+        <p>
+            Add new QuickMark for current URL. You can later jump to
+            the mark with <k>go</k><a>a-zA-Z0-9</a>
+            <k>gn</k><a>a-zA-Z0-9</a>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>go</tags>
+    <strut/>
+    <spec>go<a>a-zA-Z0-9</a></spec>
+    <description>
+        <p>
+            Jump to a QuickMark in the current tab. See also
+            <k>M</k> and <ex>:qmark</ex>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gn</tags>
+    <strut/>
+    <spec>gn<a>a-zA-Z0-9</a></spec>
+    <description>
+        <p>
+            Jump to a QuickMark in a new tab. The new tab is focused
+            only if <o>activate</o> contains <em>quickmark</em> or
+            <em>all</em>. See also <k>M</k> and <ex>:qmark</ex>.
+        </p>
+        <p>Mnemonic: Go in a new tab.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:delqm :delqmarks</tags>
+    <spec>:delqm<oa>arks</oa> <a>arg</a></spec>
+    <spec>:delqm<oa>arks</oa>!</spec>
+    <description>
+        <p>Delete the specified QuickMarks.</p>
+
+        <p>Examples:</p>
+
+        <ul>
+            <li><ex>:delqmarks Aa b p</ex> deletes QuickMarks A, a, b and p</li>
+            <li><ex>:delqmarks b-p</ex> deletes all QuickMarks in the range b to p</li>
+            <li><ex>:delqmarks!</ex> deletes all QuickMarks</li>
+        </ul>
+    </description>
+</item>
+
+<item>
+    <tags>:qma :qmark</tags>
+    <spec>:qmark <a>a-zA-Z0-9</a> <a>url</a></spec>
+    <description>
+        <p>
+            Mark <a>url</a> with a letter for quick access. See also
+            <k>go</k>, <k>gn</k>, and <k>M</k>.
+        </p>
+
+        <p>
+            In addition to simple URLs, <a>url</a> may be any string
+            that can be passed to <ex>:open</ex>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:qmarks</tags>
+    <strut/>
+    <spec>:qmarks <oa>arg</oa></spec>
+    <description>
+        <p>
+            List QuickMarks. If <oa>arg</oa> is given then limit the
+            list to those QuickMarks mentioned, otherwise list them
+            all.
+        </p>
+    </description>
+</item>
+
+<h2 tag="urlmarks localmarks">Local marks and URL marks</h2>
+
+<p>
+    Local and URL marks allow you to mark your position on the current page to
+    quickly return later. Each mark is assigned to a letter. Lowercase letters
+    behave as local marks, while uppercase letters act as URL marks. The
+    difference between the two is that local marks apply uniquely to each page,
+    while URL marks mark a specific position on a specific page. So, while the
+    mark <em>m</em> may take you to the top of the page on Site 1, it may take
+    you to the middle on Site 2. The mark <em>M</em>, on the other hand, will
+    always return you to Site 1, possibly switching buffers or creating a new
+    one. All marks are persistent across browser sessions.
+</p>
+
+<item>
+    <tags>m</tags>
+    <strut/>
+    <spec>m<a>a-zA-Z</a></spec>
+    <description>
+        <p>
+            Set mark at the cursor position. Marks a-z are local to the buffer, whereas
+            A-Z are valid between buffers.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>` '</tags>
+    <strut/>
+    <spec>'<a>a-zA-Z'</a></spec>
+    <spec>`<a>a-zA-Z'</a></spec>
+    <description>
+        <p>
+            Jump to the mark. Marks a-z are local to the buffer, whereas A-Z
+            are valid between buffers. The special mark ' holds the buffer
+            position before the last scrolling action.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:delm :delmarks</tags>
+    <spec>:delm<oa>arks</oa> <a>arg</a></spec>
+    <spec>:delm<oa>arks</oa>!</spec>
+    <description>
+        <p>Delete the specified marks.</p>
+
+        <p>Examples:</p>
+
+        <ul>
+            <li><ex>:delmarks Aa b p</ex> deletes marks A, a, b, and p</li>
+            <li><ex>:delmarks b-p</ex> deletes all marks in the range b to p</li>
+            <li><ex>:delmarks!</ex> deletes all marks for the current buffer</li>
+        </ul>
+    </description>
+</item>
+
+<item>
+    <tags>:ma :mark</tags>
+    <spec>:mark <a>a-zA-Z</a></spec>
+    <description short="true">
+        <p>Mark current location within the web page.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:marks</tags>
+    <strut/>
+    <spec>:marks <oa>arg</oa></spec>
+    <description>
+        <p>
+            Show all local marks for the current web page and all
+            URL marks. If <oa>arg</oa> is specified then limit the
+            list to those marks mentioned.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/message.xml b/common/locale/en-US/message.xml
new file mode 100644 (file)
index 0000000..a51edd4
--- /dev/null
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="message"
+    title="&dactyl.appName; Messages"
+    xmlns="&xmlns.dactyl;"
+    xmlns:dactyl="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="messages">Error and informational messages</h1>
+
+<tags>message-history</tags>
+
+<p>
+    &dactyl.appName; stores all info and error messages in a message
+    history. The type of info messages output can be controlled by
+    the <o>verbose</o> option. The maximum number of stored messages
+    can be limited with the <o>messages</o> option.
+</p>
+
+<item>
+    <tags>:mes :messages</tags>
+    <spec>:mes<oa>sages</oa></spec>
+    <description short="true">
+        <p>Display previously shown messages.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:messc :messclear</tags>
+    <spec>:messc<oa>lear</oa></spec>
+    <description short="true">
+        <p>Clear the message history.</p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[g<]]></tags>
+    <strut/>
+    <spec>g&lt;</spec>
+    <description>
+        <p>
+            Redisplay the last command output. Only the most recent command's output is
+            available.
+        </p>
+    </description>
+</item>
+
+<!-- FIXME: Everything the follows is horrendous. -->
+
+<tags>pager more-prompt</tags>
+
+<code dactyl:highlight="MoreMsg">
+ -- More --
+ -- More -- SPACE/d/j: screen/page/line down, b/u/k: up, q: quit
+</code>
+
+<p>
+    This message is displayed when the message window is filled with
+    messages and the <o>more</o> option is set. It is styled with
+    the <em>MoreMsg</em> <ex>:highlight</ex> group. When the more
+    message is shown, the following key mappings are available:
+</p>
+
+<dl>
+    <dt><k name="CR" link="false"/> or j or <k name="Down" link="false"/></dt>
+    <dd>one more line</dd>
+
+    <dt>d</dt>
+    <dd>down a page (half a screen)</dd>
+
+    <dt><k name="Space" link="false"/> or <k name="PageDown" link="false"/></dt>
+    <dd>down a screen</dd>
+
+    <dt>G</dt>
+    <dd>down all the way, until the hit-enter prompt</dd>
+
+    <dt/><dd/>
+    <dt><k name="BS" link="false"/> or k or <k name="Up" link="false"/></dt>
+    <dd>one line back</dd>
+
+    <dt>u</dt>
+    <dd>up a page (half a screen)</dd>
+
+    <dt>b or <k name="PageUp" link="false"/></dt>
+    <dd>back a screen</dd>
+
+    <dt>g</dt>
+    <dd>back to the start</dd>
+
+    <dt/><dd/>
+    <dt>q, <k name="Esc" link="false"/> or <k name="C-c"></k></dt>
+    <dd>stop the listing</dd>
+
+    <dt>:</dt>
+    <dd>stop the listing and enter a command line</dd>
+
+    <dt>;</dt>
+    <dd>start an <t>extended-hints</t> command</dd>
+
+    <dt><k name="C-y" link="false"/></dt>
+    <dd>yank (copy) a modeless selection to the clipboard</dd>
+</dl>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/messages.properties b/common/locale/en-US/messages.properties
new file mode 100644 (file)
index 0000000..662286b
--- /dev/null
@@ -0,0 +1,198 @@
+# TODO: normalize this debacle of Vim legacy messages
+#     : are we losing the error code prefixes? --djk
+
+abbrev.noSuch = No such abbreviation
+abbrev.none = No abbreviations found
+
+addon.check-1 = Checking updates for addons: %S
+addon.cantInstallDir-1 = Cannot install a directory: %S
+addon.unavailable = Don't have add-on yet
+
+autocmd.executing-2 = Executing %S Auto commands for %S
+autocmd.autocommand-1 = autocommand %S
+autocmd.noMatching = No matching autocommands
+autocmd.noGroup-1 = No such group or event: %S
+autocmd.cantExecuteAll = Can't execute autocommands for ALL events
+
+bookmark.noMatching-2 = No bookmarks matching tags %S and string %S
+bookmark.noMatchingTags-1 = No bookmarks matching tags %S
+bookmark.noMatchingString-1 = No bookmarks matching string %S
+bookmark.none = No bookmarks set
+bookmark.cantAdd-1 = Could not add bookmark %S
+bookmark.allGone = All bookmarks deleted
+bookmark.removed-1 = Removed bookmark: %S
+bookmark.added-1 = Added bookmark: %S
+bookmark.deleted-1 = %S bookmark(s) deleted
+
+buffer.fewer-2 = %S fewer tab%S
+buffer.cantDetatchLast = Can't detach the last tab
+buffer.noMatching-1 = No matching buffer for %S
+buffer.multipleMatching-1 = More than one match for %S
+buffer.noClosed = No matching closed tab
+buffer.noAlternate = No alternate page
+buffer.backgroundLoaded = Background tab loaded: %S
+
+command.commands = commands
+
+command.cantDelete = Cannot delete non-user commands
+command.cantReplace-1 = E182: Can't replace non-user command: %S
+command.wontReplace-1 = Not replacing command: %S
+command.eof-1 = Unexpected end of file waiting for %S
+command.noSuch = No such command
+command.noSuch-1 = No such command: %S
+command.noSuchUser-1 = No such user-defined command: %S
+command.notUseful-1 = This command is not useful in this version of %S
+command.invalidName-1 = Invalid command name: %S
+command.invalidOpt-1 = Invalid option: %S
+command.invalidOptArg-2 = Invalid argument for option %S: %S
+command.invalidOptTypeArg-3 = Invalid argument for %S option %S: %S
+command.parsing-1 = Error parsing arguments: %S
+command.none = No user-defined commands found
+command.unknownCompleter-1 = E117: Unknown function: %S
+command.exists = E174: Command already exists: add ! to replace it
+command.noPrevious = E30: No previous command line
+command.noRange = E481: No range allowed
+command.noBang = E477: No ! allowed
+
+command.colorscheme.notFound-1 = E185: Cannot find color scheme %S
+command.conditional.illegal = Invalid use of conditional
+command.finish.illegal = E168: :finish used outside of a sourced file
+command.let.noSuch-1 = E108: No such variable: %S
+command.let.unexpectedChar = E18: Unexpected characters in :let
+command.let.illegalVar-1 = E461: Illegal variable name: %S
+command.let.undefinedVar-1 = E121: Undefined variable: %S
+command.let.invalidExpression-1 = E15: Invalid expression: %S
+
+dactyl.parsingCommandLine-1 = Parsing command line options: %S
+dactyl.notCommand-2 = E492: Not a %S command: %S
+dactyl.sourcingPlugins-1 = Sourcing plugin directory: %S...
+dactyl.noPluginDir = No user plugin directory found
+dactyl.modulesLoaded = All modules loaded
+dactyl.commandlineOpts-1 = Command-line options: %S
+dactyl.noRCFile = No user RC file found
+dactyl.initialized-1 = %S fully initialized
+
+dialog.notAvailable-1 = Dialog %S not available
+
+group.cantChangeBuiltin-1 = Cannot change %S in the builtin group
+group.cantModifyBuiltin = Cannot modify builtin group
+group.cantRemoveBuiltin = Cannot remove builtin group
+group.noSuch-1 = No such group: %S
+group.invalidName-1 = Invalid group name: %S
+group.noCurrent = No current group
+
+editor.noEditor = No editor specified
+
+emenu.notFound-1 = Menu not found: %S
+
+event.error-2 = Processing %S event: %S
+event.nothingToPass = No events to pass
+
+finder.notFound-1 = E486: Pattern not found: %S
+finder.atTop = find hit TOP, continuing at BOTTOM
+finder.atBottom = find hit BOTTOM, continuing at TOP
+
+help.dontPanic = E478: Don't panic!
+help.noFile-1 = Sorry, help file %S not found
+help.noTopic-1 = Sorry, no help for %S
+
+hints.noMatcher-1 = Invalid hintmatching type: %S
+
+history.noMatching-1 = No history matching %S
+history.none = No history set
+history.noURL = URL not found in history
+
+io.noSuchDir-1 = E344: Can't find directory %S
+io.noPrevDir = E186: No previous directory
+io.notReadable-1 = Can't open file %S
+io.notWriteable-1 = Can't open file %S for writing
+io.exists = File exists (add ! to override)
+io.exists-1 = File %S exists (add ! to override)
+io.noCommand-1 = Command not found: %S
+io.commandFailed = E472: Command failed
+io.oneFileAllowed = E172: Only one file name allowed
+io.callingShell-1 = Calling shell to execute: %S
+io.sourcing-1 = sourcing %S
+io.sourcingEnd-1 = finished sourcing %S
+io.notInRTP-1 = not found in 'runtimepath': %S
+io.searchingFor-1 = Searching for %S
+io.searchingFor-2 = Searching for %S in %S
+io.downloadFinished-2 = Download of %S to %S finished
+
+macro.canceled-1 = Canceled playback of macro '%S'
+macro.recorded-1 = Recorded macro '%S'
+macro.loadFailed-1 = Page did not load completely in %S seconds
+macro.loadWaiting = Waiting for page to load...
+macro.noSuch-1 = Macro '%S' not set
+macro.noPrevious = No previous macro
+macro.invalid-1 = Invalid macro name: '%S'
+
+map.builtinImmutable = Cannot change mappings in the builtin group
+map.none = No mapping found
+map.noSuch-1 = No such mapping: %S
+map.recursive-1 = Attempt to execute mapping recursively: %S
+
+mark.none = No marks set
+mark.invalid = Invalid mark
+mark.unset-1 = Mark not set: %S
+mark.noMatching-1 = E283: No marks matching %S
+
+mode.recursiveSet = Not executing modes.set recursively
+
+mow.noPreviousOutput = No previous command output
+
+option.noSuch = No such option
+option.noSuch-1 = No such option: %S
+option.replaceExisting-1 = Warning: %S already exists: replacing existing option
+
+plugin.searchingFor-1 = Searching for %S
+plugin.searchingForIn-2 = Searching for %S in %S
+plugin.notReplacingContext-1 = Not replacing plugin context for %S
+
+print.toFile-1 = Printing to file: %S
+print.sending = Sending to printer...
+print.sent = Print job sent
+print.printed-1 = Printed: %S
+
+quickmark.none = No QuickMarks set
+quickmark.noMatching-1 = No QuickMarks matching %S
+quickmark.notSet = QuickMark not set
+quickmark.invalid = Argument must be an ASCII letter or digit
+quickmark.added-2 = Added Quick Mark '%S': %S
+
+save.invalidDestination-1 = Invalid destination: %S
+
+status.link-1 = Link: %S
+
+style.none = No style found
+
+time.total-1 = Total time: %S
+
+variable.none = No variables found
+
+window.cantAttachSame = Can't reattach to the same window
+window.noIndex-1 = Window %S does not exist
+
+zoom.outOfRange-2 = Zoom value out of range (%S - %S%%)
+zoom.illegal = Illegal zoom value
+
+error.clipboardEmpty = No clipboard data
+error.countRequired-1 = Count required for %S
+error.cantOpen-2 = Error opening %S: %S
+error.interrupted = Interrupted
+error.invalidSort-1 = Invalid sort order: %S
+error.trailing = Trailing characters
+error.trailing-1 = Trailing characters: %S
+error.invalid-1 = Invalid %S
+error.invalidArgument = Invalid argument
+error.invalidArgument-1 = Invalid argument: %S
+error.unavailable-2 = Not available on %S %S
+error.argumentRequired = Argument required
+error.argumentOrBang = Argument or ! required
+error.invalidOperation = Invalid operation
+error.monkeyPatchOverlay-1 = Not replacing property with eval-generated overlay by %S
+error.nullComputedStyle-1 = Computed style is null: %S
+
+warn.notDefaultBranch-2 = You are running %S from a testing branch: %S. Please do not report errors which do not also occur in the default branch.
+
+# vim:se ft=jproperties tw=0:
diff --git a/common/locale/en-US/options.xml b/common/locale/en-US/options.xml
new file mode 100644 (file)
index 0000000..9f911ab
--- /dev/null
@@ -0,0 +1,1637 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="options"
+    title="&dactyl.appName; Options"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="options">Options</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName; has a number of internal variables and switches which can be set to
+    achieve special effects. These options come in 8 forms:
+</p>
+
+<dl dt="width: 10em;">
+    <dt>boolean</dt>       <dd>Can only be <hl key="Boolean">on</hl> or <hl key="Boolean">off</hl></dd>
+    <dt>number</dt>        <dd>A numeric value</dd>
+    <dt>string</dt>        <dd>A string value</dd>
+
+    <dt/><dd tag="charlist"/>
+    <dt>charlist</dt>
+    <dd>A string containing a discrete set of distinct characters</dd>
+
+    <dt/><dd tag="stringlist"/>
+    <dt>stringlist</dt>
+    <dd>
+        A comma-separated list of strings. Any comma appearing within single
+        or double quotes, or prefixed with a <tt>\</tt>, will not be treated
+        as an item separator.
+    </dd>
+
+    <dt/><dd tag="stringmap"/>
+    <dt>stringmap</dt>
+    <dd>A comma-separated list of key-value pairs, e.g., <str delim="">key:val,foo:bar</str></dd>
+
+    <dt/><dd tag="regexplist"/>
+    <dt>regexplist</dt>
+    <dd>
+        A comma-separated list of regular expressions. Expressions may be
+        prefixed with a <tt>!</tt>, in which case the match will be negated. A
+        literal <tt>!</tt> at the beginning of the expression may be matched
+        with <tt>[!]</tt> or by placing the regular expression in quotes.
+        Generally, the first matching regular expression is used. Any comma
+        appearing within single or double quotes, or prefixed with a
+        <tt>\</tt>, will not be treated as an item separator.
+    </dd>
+
+    <dt/><dd tag="regexpmap"/>
+    <dt>regexpmap</dt>
+    <dd>
+        A combination of a <em>stringmap</em> and a <em>regexplist</em>. Each key
+        in the <a>key</a>:<a>value</a> pair is a regexp. If the regexp begins with a
+        <tt>!</tt>, the sense of the match is negated, such that a non-matching
+        expression will be considered a match and <html:i>vice versa</html:i>.
+        The first <a>key</a> to match yields value.
+    </dd>
+
+    <dt>sitelist</dt> <dd tag="sitelist sitemap"/>
+    <dt>sitemap</dt>
+    <dd>
+        Like <t>regexplist</t> and <t>regexpmap</t>, but the keys are
+        <t>site-filters</t> rather than regular expressions.
+    </dd>
+</dl>
+
+<p tag="macro-string">
+    Some options may be given format strings containing macro replacements in
+    the form of <tt>&lt;<a>name</a>></tt>. These tokens are replaced by
+    the parameter <a>name</a> as specified in the relevant documentation.
+    If the token is in the form <tt>&lt;q-<a>name</a>></tt>, the value of the
+    parameter is automatically <link topic="quoting">quoted</link>. If it is in
+    the form of <tt>&lt;e-<a>name</a>></tt>, its value is never shown but may be
+    used to test whether the given parameter is empty.
+</p>
+<p>
+    Any substring enclosed by <em><tt>&lt;{</tt></em> and <em><tt>}></tt></em>
+    is automatically elided if any of the contained macros aren't currently
+    valid. A literal <em><tt>&lt;</tt></em> or <em><tt>></tt></em> character may
+    be included with the special escape sequences <tt>&lt;lt></tt> or
+    <tt>&lt;gt></tt> respectively.
+</p>
+
+<p style="text-align: left">
+    For example, given the format string
+    <str>&lt;{(cmd: &lt;column>) }>&lt;{line: &lt;line> }>&lt;file></str>,
+    where <em>line</em>=<hl key="Number">32</hl> and
+    <em>file</em>=<str delim="'">Lieder eines fahrenden Gesellen.txt</str>,
+    the result is formatted as
+    <str>line: 32 'Lieder eines fahrenden Gesellen.txt'</str>
+</p>
+
+<h2 tag="set-option E764">Setting options</h2>
+
+<item>
+    <tags>:set :se</tags>
+    <spec>:se<oa>t</oa></spec>
+    <description short="true">
+        <p>Show all options which differ from their default values.</p>
+    </description>
+</item>
+
+<item>
+    <spec>:se<oa>t</oa> all</spec>
+    <description short="true">
+        <p>Show all options.</p>
+    </description>
+</item>
+
+<item>
+    <tags>E518 E519</tags>
+    <spec>:se<oa>t</oa> <a>option</a>?</spec>
+    <description>
+        <p>Show value of <a>option</a>.</p>
+    </description>
+</item>
+
+<item>
+    <spec>:se<oa>t</oa> <a>option</a> <oa>...</oa></spec>
+    <description>
+        <p>
+            For boolean options, turn them on. For all other types,
+            show their values.
+        </p>
+    </description>
+</item>
+
+<item>
+    <spec>:se<oa>t</oa> no<a>option</a> <oa>...</oa></spec>
+    <description>
+        <p>
+            For boolean options, turn them off. For all other types,
+            display an error.
+        </p>
+    </description>
+</item>
+
+<item>
+    <spec>:se<oa>t</oa> <a>option</a>! <oa>...</oa></spec>
+    <spec>:se<oa>t</oa> inv<a>option</a> <oa>...</oa></spec>
+    <description>
+        <p>
+            For boolean options, invert their value. For all other types,
+            display an error.
+        </p>
+    </description>
+</item>
+
+<item>
+    <spec>:se<oa>t</oa> inv<a>option</a>=<a>value</a> <oa>...</oa></spec>
+    <spec>:se<oa>t</oa> <a>option</a>!=<a>value</a> <oa>...</oa></spec>
+    <description>
+        <p>For list options, toggle the specified values.</p>
+
+        <p style="text-align: left;">
+            If the option is a list, the given values are toggled. Given,
+            <se opt="opt" link="false"><str delim="">foo</str>,<str delim="">bar</str></se>
+            then,
+            <se opt="opt" op="!=" link="false"><str delim="">foo</str>,<str delim="">baz</str></se>
+            has the same result as
+            <se opt="opt" link="false"><str delim="">bar</str>,<str delim="">baz</str></se>
+        </p>
+
+        <p>
+            This extends to string options in a natural way, e.g.,
+            <se opt="stal" op="!="><str delim="">always</str>,<str delim="">never</str></se>
+            toggles between the two values.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:set-default</tags>
+    <spec>:se<oa>t</oa> <a>option</a>&amp; <oa>...</oa></spec>
+    <description>
+        <p>Reset option to its default value.</p>
+    </description>
+</item>
+
+<item>
+    <spec>:se<oa>t</oa> all&amp;</spec>
+    <description>
+        <p>Set all options to their default value.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:set-args E487 E521</tags>
+    <spec>:se<oa>t</oa> <a>option</a>=<a>value</a> <oa>...</oa></spec>
+    <description>
+        <p>
+            Set string or number option to <a>value</a>.
+            For numeric options the value must be given in decimal.
+            The old value can be inserted by typing <k name="Tab" mode="c"/>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:set+=</tags>
+    <spec>:se<oa>t</oa> <a>option</a>+=<a>value</a> <oa>...</oa></spec>
+    <description>
+        <p>
+            Add the <a>value</a> to a number option, or append the <a>value</a>
+            to a string option.  When the option is a comma separated list, a
+            comma is added, unless the value was empty. If the option is a list
+            of flags, superfluous flags are removed.  When adding a flag that
+            was already present the option value doesn't change.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:set^=</tags>
+    <spec>:se<oa>t</oa> <a>option</a>^=<a>value</a> <oa>...</oa></spec>
+    <description>
+        <p>
+            Multiply the <a>value</a> to a number option, or prepend the
+            <a>value</a> to a string option. When the option is a comma
+            separated list, a comma is added, unless the value was empty.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:set-=</tags>
+    <spec>:se<oa>t</oa> <a>option</a>-=<a>value</a> <oa>...</oa></spec>
+    <description>
+        <p>
+            Subtract the <a>value</a> from a number option, or remove the
+            <a>value</a> from a string option if it is there. If the
+            <a>value</a> is not found in a string option, there is no error or
+            warning. When the option is a comma separated list, a comma is
+            deleted, unless the option becomes empty. When the option is a list
+            of flags, <a>value</a> must be exactly as they appear in the option.
+            Remove flags one by one to avoid problems.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:setlocal :setl</tags>
+    <spec>:setl<oa>ocal</oa> …</spec>
+    <description>
+        <p>
+            The same as <ex>:set</ex> command, but operates on current tab options
+            only. See <ex>:set</ex> for details.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:setglobal :setg</tags>
+    <spec>:setg<oa>lobal</oa> …</spec>
+    <description>
+        <p>
+            The same as <ex>:set</ex> command, but operates on global options only.
+            See <ex>:set</ex> for details.
+        </p>
+    </description>
+</item>
+
+<p tag="expand-environment-var expand-env :set_env">
+    Environment variables are expanded for path options like <o>cdpath</o> and
+    <o>runtimepath</o>. The variable notation is <em>$VAR</em> (terminated by a non-word
+    character) or <em>${VAR}</em>. <em>%VAR%</em> is also supported on Windows.
+</p>
+
+<h2 tag="&dactyl.host;-options preferences">Setting &dactyl.host; options</h2>
+
+<p>&dactyl.host; options can be viewed and set with the following commands:</p>
+
+<item>
+    <tags>:pref :prefs :preferences</tags>
+    <spec>:pref<oa>erences</oa></spec>
+    <description>
+        <p>
+            Show the &dactyl.host; preferences dialog. You can change the browser
+            preferences from this dialog. Be aware that not all &dactyl.host;
+            preferences work, because &dactyl.appName; overrides some key bindings and
+            changes &dactyl.host;'s GUI.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:pref! :prefs! :preferences!</tags>
+    <spec>:pref<oa>erences</oa>!</spec>
+    <description>
+        <p>
+            Opens about:config in the current tab, where you can change advanced &dactyl.host;
+            preferences.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:set! :set-!</tags>
+    <spec>:se<oa>t</oa>! <a>preference</a>=<a>value</a></spec>
+    <spec>:se<oa>t</oa>! <a>preference</a>&amp;</spec>
+    <description>
+        <p>
+            Change any &dactyl.host; <a>preference</a> (those on the about:config
+            page). You can also reset/delete these preferences with
+            <ex>:set! <a>preference</a>&amp;</ex>.
+        </p>
+    </description>
+</item>
+
+<p tag="overridden-preferences">
+    &dactyl.appName; needs to set several &dactyl.host; preferences at
+    startup in order to function properly. If this is unacceptable,
+    they can be changed in your RC file with the <ex>:set!</ex>
+    command, but beware of unexpected behavior. The affected
+    preferences are:
+</p>
+
+<ul>
+    <li><pref>accessibility.typeaheadfind</pref></li>
+</ul>
+
+<h2 tag="list-options">List of options</h2>
+
+<item>
+    <tags>'act' 'activate'</tags>
+    <strut/>
+    <spec>'activate' 'act'</spec>
+    <type>stringlist</type>
+    <default>addons,bookmarks,diverted,downloads,extoptions,
+          help,homepage,quickmark,tabopen,paste</default>
+    <description>
+        <p>
+            A list of items which, when opened in a new tab, are
+            automatically focused. Available items:
+        </p>
+
+        <dl>
+            <dt>all</dt>           <dd>Activate all items.</dd>
+            <dt>addons</dt>        <dd><ex>:addo<oa>ns</oa></ex> command</dd>
+            <dt>bookmarks</dt>     <dd>Tabs loaded from bookmarks</dd>
+            <dt>diverted</dt>      <dd>Links with targets set to new tabs</dd>
+            <dt>downloads</dt>     <dd><ex>:downl<oa>oads</oa></ex> command</dd>
+            <dt>extoptions</dt>    <dd><ex>:exto<oa>ptions</oa></ex> command</dd>
+            <dt>help</dt>          <dd><ex>:h<oa>elp</oa></ex> command</dd>
+            <dt>homepage</dt>      <dd><k>gH</k> mapping</dd>
+            <dt>links</dt>         <dd>Middle- or Control-clicked links</dd>
+            <dt>quickmark</dt>     <dd><k>go</k> and <k>gn</k> mappings</dd>
+            <dt>tabopen</dt>       <dd><ex>:tabopen<oa>!</oa></ex> command</dd>
+            <dt>paste</dt>         <dd><k>P</k> and <k>gP</k> mappings</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'awim' 'altwildmode'</tags>
+    <spec>'altwildmode' 'awim'</spec>
+    <type>stringlist</type>
+    <default>list:full</default>
+    <description>
+        <p>
+            Like <o>wildmode</o>, but when the <k name="A-Tab" mode="c"/> key
+            is pressed.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'au' 'autocomplete'</tags>
+    <spec>'autocomplete' 'au'</spec>
+    <type>regexplist</type>
+    <default>.*</default>
+    <description>
+        <p>
+            Enables automatic completion for completion contexts (see
+            <ex>:contexts</ex>) matching the given regular expressions. When
+            automatic completion is enabled, the completion list is
+            automatically opened when the &tag.command-line; is focused.
+            Thereafter, any key press triggers a completion update for the
+            matching contexts. Non-matching contexts will only be updated when
+            the <k name="Tab" mode="c"/> key is pressed. This option is useful
+            for disabling auto-completion for computationally intensive
+            contexts that don't perform well when your system is under load.
+        </p>
+
+        <note>
+            Completion contexts have names very much like Unix path names.
+            These denote the tree in which they're called. A completer will
+            never be called unless every completer preceding it in the tree
+            was also called. For example, if your completer excludes
+            <str>/ex/</str>, it will also exclude <str>/ex/bmarks</str>, and
+            so on.
+        </note>
+
+        <example>
+            To enable auto-completion for everything but <ex>:history</ex> or
+            <ex>:bmarks</ex>, you would choose a value such as
+            <str delim="">!/ex/(bmarks|history),.?</str>
+        </example>
+
+        <p>
+            To go in the other direction, i.e. <em>only</em> enable
+            auto-completion for those commands, you have to jump through
+            some hoops, due to the way contexts work (see the note above):
+            <str delim="">/ex/(bmarks|history),^(/|/ex/?)$</str>
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'bh' 'banghist'</tags>
+    <spec>'banghist' 'bh'</spec>
+    <type>boolean</type>
+    <default>on</default>
+    <description>
+        <p>
+            Replace occurrences of ! with the previous command when
+            executing external commands.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>$CDPATH</tags>
+    <tags>'cd' 'cdpath'</tags>
+    <spec>'cdpath' 'cd'</spec>
+    <type>stringlist</type>
+    <default type="plain">equivalent to <str>.</str> or <str>.,$CDPATH</str></default>
+    <description>
+        <p>
+            List of directories searched when executing the <ex>:cd</ex>
+            command. This is only used for relative paths; if an absolute path is
+            specified, this option is ignored.
+        </p>
+        <p>
+            If the <em>CDPATH</em> environment variable is set this path list
+            is appended to the default value of <str>.</str>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'ca' 'cookieaccept'</tags>
+    <spec>'cookieaccept' 'ca'</spec>
+    <type>string</type>
+    <default>all</default>
+    <description>
+        <p>When to accept cookies.</p>
+
+        <dl>
+            <dt>all</dt>      <dd>Accept all cookies</dd>
+            <dt>none</dt>     <dd>Accept no cookies</dd>
+            <dt>samesite</dt> <dd>Accept all non-third-party cookies</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'cl' 'cookielifetime'</tags>
+    <spec>'cookielifetime'</spec>
+    <type>string</type>
+    <default>default</default>
+    <description>
+        <p>
+            The lifetime for which to accept cookies. The available
+            options are:
+        </p>
+        <dl>
+            <dt>default</dt>     <dd>The lifetime requested by the setter</dd>
+            <dt>prompt</dt>      <dd>Always prompt for a lifetime</dd>
+            <dt>session</dt>     <dd>The current session</dd>
+            <dt><a>days</a></dt> <dd>When a number is given, it is
+                interpreted as the number of days for which to keep
+                cookies</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'ck' 'cookies'</tags>
+    <spec>'cookies' 'ck'</spec>
+    <type>stringlist</type> <default>session</default>
+    <description>
+        <p>The default action for the <ex>:cookies</ex> command.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'cpt' 'complete'</tags>
+    <spec>'complete' 'cpt'</spec>
+    <type>charlist</type>
+    <default>slf</default>
+    <description>
+        <p>Items which are completed at the <ex>:open</ex> prompts. Available items:</p>
+
+        <dl dt="width: 6em;">
+            <dt>s</dt> <dd>Search engines and keyword URLs</dd>
+            <dt>f</dt> <dd>Local files</dd>
+            <dt>l</dt> <dd>&dactyl.host; location bar entries (bookmarks and history sorted in an intelligent way)</dd>
+            <dt>b</dt> <dd>Bookmarks</dd>
+            <dt>h</dt> <dd>History</dd>
+            <dt>S</dt> <dd>Search engine suggestions</dd>
+        </dl>
+
+        <p>
+            The order is important, such that <se opt="complete"><str delim="">bsf</str></se> will
+            list bookmarks followed by matching quick searches and then
+            matching files.
+        </p>
+
+        <warning>
+            Using <em>b</em> and <em>h</em> can make completion very slow if
+            there are many items.
+        </warning>
+    </description>
+</item>
+
+<item>
+    <tags>'ds' 'defsearch'</tags>
+    <spec>'defsearch' 'ds'</spec>
+    <type>string</type>
+    <default>google</default>
+    <description>
+        <p>
+            Sets the default search engine. The default search engine is
+            used by <ex>:open</ex> and related commands for arguments which
+            include no search or bookmark keywords and can't otherwise be
+            converted into URLs or existing file names.
+        </p>
+
+        <p>
+            This means that with <o>defsearch</o> set to <str>youtube</str>,
+            <ex>:open Tim Minchin</ex> behaves exactly as
+            <ex>:open youtube Tim Minchin</ex>, so long as you don't have a
+            search or bookmark keyword called ‘Tim’.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'editor'</tags>
+    <spec>'editor'</spec>
+    <type>string</type>
+    <default><![CDATA[gvim -f +<line> <file>]]></default>
+    <description>
+        <p>
+            Set the external text editor.
+            This is the editor used by <k name="C-i" mode="I"/>, <k>gF</k>, and
+            other commands which launch an external text editor.
+        </p>
+
+        <p>
+            Accepts a <t>macro-string</t> with the following escapes available.
+            Arguments containing escapes which are not relevant to a given call
+            are automatically elided. All field splitting is done before format
+            characters are processed.
+        </p>
+
+        <dl>
+            <dt>&lt;file></dt>   <dd>The file to edit. Appended as the final argument if missing.</dd>
+            <dt>&lt;line></dt>   <dd>The line number at which to position the cursor.</dd>
+            <dt>&lt;column></dt> <dd>The column at which to position the cursor.</dd>
+        </dl>
+
+        <warning>
+            &dactyl.appName; will not behave correctly if the editor forks its
+            own process rather than blocking until editing is complete. Gvim
+            invoked without the <em>-f</em> option is one such example.
+        </warning>
+    </description>
+</item>
+
+<item>
+    <tags>'enc' 'encoding'</tags>
+    <spec>'encoding' 'enc'</spec>
+    <type>string</type>
+    <default>UTF-8</default>
+    <description>
+        <p>
+            Changes the character encoding of the current buffer. Valid only
+            until a new page is loaded.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'noeb' 'noerrorbells'</tags>
+    <tags>'eb' 'errorbells'</tags>
+    <spec>'errorbells' 'eb'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>
+            Ring the bell when an error message is displayed. See also
+            <o>visualbell</o>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'ei' 'eventignore'</tags>
+    <spec>'eventignore' 'ei'</spec>
+    <type>stringlist</type>
+    <default></default>
+    <description>
+        <p>
+            A list of autocommand event names which should be ignored. If the
+            list contains the value <str>all</str> then all events are
+            ignored.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'noex' 'noexrc'</tags>
+    <tags>'ex' 'exrc'</tags>
+    <spec>'exrc' 'ex'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>
+            Allow reading of an RC file in the current directory. This file is
+            sourced after the default <tt><t>&dactyl.name;rc</t></tt> file in your
+            home directory.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'eht' 'extendedhinttags'</tags>
+    <spec>'extendedhinttags' 'eht'</spec>
+    <strut/>
+    <type>regexpmap</type>
+    <default>[asOTivVWy]:a[href],area[href],img[src],iframe[src],
+          [f]:body,
+          [F]:body,code,div,html,p,pre,span,
+          [iI]:img,
+          [S]:button,'input:not([type=hidden])',select,textarea</default>
+    <description>
+        <p>
+            Defines specialized CSS selectors or XPath expressions for arbitrary
+            <t>extended-hints</t> modes. The syntax is the same as for
+            <o>hinttags</o>. If no matches are found, the value of
+            <o>hinttags</o> is used.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'fenc' 'fileencoding'</tags>
+    <spec>'fileencoding' 'fenc'</spec>
+    <type>string</type>
+    <default>UTF-8</default>
+    <description>
+        <p>
+            Changes the character encoding that &dactyl.appName; uses to read
+            and write files.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'fc' 'findcase'</tags>
+    <spec>'findcase' 'fc'</spec>
+    <type>string</type>
+    <default>smart</default>
+    <description>
+        <p>Find case matching mode.</p>
+
+        <dl>
+            <dt>ignore</dt> <dd>Case is never significant</dd>
+            <dt>match</dt> <dd>Case is always significant</dd>
+            <dt>smart</dt> <dd>Case is significant when capital letters are typed</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'fh' 'followhints'</tags>
+    <spec>'followhints' 'fh'</spec>
+    <type>number</type>
+    <default>0</default>
+    <description>
+        <p>Changes how soon matching hints are followed in Hints mode.</p>
+
+        <p>Possible values:</p>
+
+        <dl dt="width: 6em;">
+            <dt>0</dt>      <dd>Follow the first hint as soon as typed text uniquely identifies it.</dd>
+            <dt>1</dt>      <dd>Follow the selected hint on <k name="CR"/>.</dd>
+            <dt>2</dt>      <dd>Follow the selected hint on <k name="CR"/> only if it's been <k name="Tab" mode="c"/>-selected.</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'nofs' 'nofullscreen'</tags>
+    <tags>'fs' 'fullscreen'</tags>
+    <spec>'fullscreen' 'fs'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>
+            Show the current window full-screen. Also hide certain GUI elements, such as
+            <t>status-line</t> and tab bar.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'go' 'guioptions'</tags>
+    <spec>'guioptions' 'go'</spec>
+    <type>charlist</type>
+    <default>bCrs</default> <!-- TODO: make this config specific -->
+    <description>
+        <p>Show or hide certain GUI elements.</p>
+
+        <p>Supported characters:</p>
+
+        <dl dt="width: 6em;">
+            <dt>B</dt>      <dd>Bookmark bar</dd>
+            <dt>C</dt>      <dd>Always show the command line outside of the status line</dd>
+            <dt>M</dt>      <dd>Always show messages outside of the status line</dd>
+            <dt>N</dt>      <dd>Tab number over image</dd>
+            <dt>T</dt>      <dd>Toolbar</dd>
+            <dt>b</dt>      <dd>Bottom scrollbar</dd>
+            <dt>c</dt>      <dd>Always show the command line, even when empty</dd>
+            <dt>l</dt>      <dd>Left scrollbar (<em>l</em> and <em>r</em> are mutually exclusive)</dd>
+            <dt>m</dt>      <dd>Menu bar</dd>
+            <dt>n</dt>      <dd>Tab number</dd>
+            <dt>r</dt>      <dd>Right scrollbar</dd>
+            <dt>s</dt>      <dd>Status bar</dd>
+        </dl>
+
+        <p>See also <o>showtabline</o>.</p>
+
+        <note>Scrollbar changes require a page reload to take effect.</note>
+        <note>Only one of <em>l</em> or <em>r</em> may be included.</note>
+    </description>
+</item>
+
+<item>
+    <tags>'hf' 'helpfile'</tags>
+    <spec>'helpfile' 'hf'</spec>
+    <type>string</type>
+    <default>intro</default>
+    <description>
+        <p>
+            Name of the main help file. This is that page shown if the
+            <ex>:help</ex> command is called without any arguments.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'hin' 'hintinputs'</tags>
+    <spec>'hintinputs' 'hin'</spec>
+    <type>stringlist</type>
+    <default>label,value</default>
+    <description>
+        <p>
+            When generating hints for input elements that do not have an
+            explicit caption, this specifies the methods used to generate a
+            textual hint. The options are attempted in the order they are
+            given, and the first successful value is used.
+        </p>
+
+        <dl dt="width: 8em;">
+            <dt>value</dt>      <dd>The hint is the value displayed in a text input, or the selected option for a drop-down.</dd>
+            <dt>label</dt>      <dd>The value of an explicit label for the input; this will not match most manually added labels that are found on sites.</dd>
+            <dt>name </dt>      <dd>The name of the input will be used; although the name is not designed for user consumption, it is frequently very similar to the label.</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'hk' 'hintkeys'</tags>
+    <spec>'hintkeys' 'hk'</spec>
+    <type>string</type>
+    <default>0123456789</default>
+    <description>
+        <p>
+            The keys used to label and select hints. With its default value,
+            each hint has a unique number which can be typed to select it,
+            while all other characters are used to filter hints based on their
+            text. With a value such as <str>asdfg;lkjh</str>, each hint is
+            ‘numbered’ based on the characters of the home row.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'hm' 'hintmatching'</tags>
+    <spec>'hintmatching' 'hm'</spec>
+    <type>stringlist</type>
+    <default>contains</default>
+    <description>
+        <p>Change the hint matching algorithm used in Hints mode.</p>
+
+        <p>Possible values:</p>
+
+        <dl>
+            <dt>contains</dt>
+            <dd>
+                The typed characters are split on whitespace, and
+                these character groups have to match anywhere inside
+                the text of the link.
+            </dd>
+            <dt>wordstartswith</dt>
+            <dd>
+                The typed characters are matched with the beginning
+                of the first word (see <o>wordseparators</o>) in the
+                link as long as possible. If no matches occur in the
+                current word, then the matching is continued at the
+                beginning of the next word. The words are worked
+                through in the order they appear in the link. If the
+                typed characters contain spaces, then the characters
+                are split on whitespace. These character groups are
+                then matched with the beginning of the words,
+                beginning at the first one and continuing with the
+                following words in the order they appear in the
+                link.
+            </dd>
+            <dt>firstletters</dt>
+            <dd>
+                Behaves like wordstartswith, but non-matching words
+                aren't skipped.
+            </dd>
+            <dt>custom</dt>
+            <dd>
+                Delegate to the function
+                <tt>dactyl.plugins.customHintMatcher</tt>.
+            </dd>
+            <dt>transliterated</dt>
+            <dd>
+                Certain alphanumeric characters are transliterated into their
+                unaccented equivalents, such that ‘euro’ will match 'æuró',
+                and ‘Ångström’ will match ‘angstrom’.
+            </dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'ht' 'hinttags'</tags>
+    <strut/>
+    <spec>'hinttags' 'ht'</spec>
+    <type>stringlist</type>
+    <default>a,area,button,iframe,input:not([type=hidden]),select,textarea,
+          [onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand],
+          [tabindex],[role=link],[role=button]</default>
+    <description>
+        <p>
+            A list of CSS selectors or XPath expressions used to select elements
+            for <link topic="hints">hinting</link>. Values beginning with the
+            string <str>xpath:</str> are treated as XPath expressions, while any
+            other values are treated as CSS selectors. Can be overridden for
+            individual <t>extended-hints</t> modes with the
+            <o>extendedhinttags</o> option.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'hto' 'hinttimeout'</tags>
+    <spec>'hinttimeout' 'hto'</spec>
+    <type>number</type>
+    <default>0</default>
+    <description>
+        <p>
+            Timeout in milliseconds before automatically following a non-unique
+            hint. The timeout is measured since the last time a key listed in
+            <o>hintkeys</o> was pressed. It has no effect when narrowing hints
+            by typing part of their text. Set to 0 (the default) to only follow
+            hints after pressing <k name="CR"/> or when the hint is unique.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'hi' 'history'</tags>
+    <spec>'history' 'hi'</spec>
+    <type>number</type>
+    <default>500</default>
+    <description>
+        <p>
+            Maximum number of Ex commands and find patterns to store in the
+            <t>command-line</t> history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'nohlf' 'nohlfind'</tags>
+    <tags>'hlf' 'hlfind'</tags>
+    <spec>'hlfind' 'hlf'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>Highlight previous find pattern matches.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'noif' 'noincfind'</tags>
+    <tags>'if' 'incfind'</tags>
+    <spec>'incfind' 'if'</spec>
+    <type>boolean</type>
+    <default>on</default>
+    <description>
+        <p>Show the first match for a find pattern as it is typed.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'noim' 'noinsertmode'</tags>
+    <tags>'im' 'insertmode'</tags>
+    <spec>'insertmode' 'im'</spec>
+    <type>boolean</type>
+    <default>on</default>
+    <description>
+        <p>
+            Use Insert mode as the default for text areas. This is useful if you
+            want to use the known &dactyl.host; interface for editing text areas.
+            Input fields default to this behavior irrespective of this option's
+            setting.
+        </p>
+
+        <p>
+            TextEdit mode can be entered with <k name="C-t" mode="I"/> from Insert mode.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'nojsd' 'nojsdebugger'</tags>
+    <tags>'jsd' 'jsdebugger'</tags>
+    <spec>'jsdebugger' 'jsd'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>
+            Use the JavaScript debugger service for JavaScript completion.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'nolpl' 'noloadplugins'</tags>
+    <tags>'lpl' 'loadplugins'</tags>
+    <spec>'loadplugins' 'lpl'</spec>
+    <type>regexplist</type>
+    <default>'\.(js|&dactyl.fileExt;)$'</default>
+    <description>
+        <p>
+            A regular expression list that defines which plugins are loaded at
+            startup or via <ex>:loadplugins</ex>. The first item to match is
+            the one that takes effect. If no items match, the file is not
+            loaded. Setting this to a blank value effectively disables plugin
+            loading.
+        </p>
+        <p>
+            For example, to prepend to the default value of this option to load
+            all plugins except for <em>foobar-plugin</em>, you could use:
+        </p>
+        <set opt="loadplugins" op="^=">!<str delim="'">foobar-plugin</str></set>
+        <p>
+            Alternatively, you can specify which plugins to load and which to
+            omit in your <tt><t>&dactyl.name;rc</t></tt> using something like
+            the following:
+        </p>
+        <set opt="loadplugins">!<str delim="'">foo|bar</str>,<str delim="'">\.(js|&dactyl.fileExt;)$</str></set>
+        <p>
+            That will load all plugins but <em>foo</em> and <em>bar</em>.
+        </p>
+        <p>
+            Note that in the first expression of the latter example you don't
+            need parentheses, as the <em>!</em> negates the whole of the
+            following expression (cf. <t>regexplist</t>).
+        </p>
+        <p>
+            See also <ex>:runtime</ex>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'ml' 'mapleader'</tags>
+    <spec>'mapleader' 'ml'</spec>
+    <type>string</type>
+    <default>\</default>
+    <description>
+        <p>Defines the replacement keys for the <k name="Leader"/> pseudo-key.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'maxitems'</tags>
+    <spec>'maxitems'</spec>
+    <type>number</type>
+    <default>20</default>
+    <description>
+        <p>Maximum number of items to display at once in a listing.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'msgs' 'messages'</tags>
+    <spec>'messages' 'msgs'</spec>
+    <type>number</type>
+    <default>100</default>
+    <description>
+        <p>Maximum number of messages to store in the message history.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'nomore' 'more'</tags>
+    <spec>'more'</spec>
+    <type>boolean</type>
+    <default>on</default>
+    <description>
+        <p>
+            Pause the message list window when more than one screen of
+            listings is displayed.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'newtab'</tags>
+    <spec>'newtab'</spec>
+    <type>stringlist</type>
+    <default></default>
+    <description>
+        <p>
+            Defines which Ex commands open pages in new tabs rather than the
+            current tab by default. This may be overridden with the
+            <ex>:tab</ex> command, and is usually inverted by affixing a
+            <em>!</em> to the command in question.
+        </p>
+
+        <p>Possible values:</p>
+
+        <dl>
+            <dt>all</dt>            <dd>All commands</dd>
+            <dt>addons</dt>         <dd><ex>:addo<oa>ns</oa></ex> command</dd>
+            <dt>downloads</dt>      <dd><ex>:downl<oa>oads</oa></ex> command</dd>
+            <dt>extoptions</dt>     <dd><ex>:exto<oa>ptions</oa></ex> command</dd>
+            <dt>help</dt>           <dd><ex>:h<oa>elp</oa></ex> command</dd>
+            <dt>javascript</dt>     <dd><ex>:javascript!</ex> or <ex>:js!</ex> command</dd>
+            <dt>prefs</dt>          <dd><ex>:pref<oa>erences</oa>!</ex> or <ex>:prefs!</ex> command</dd>
+        </dl>
+
+    </description>
+</item>
+
+<item>
+    <tags>'nextpattern'</tags>
+    <strut/>
+    <spec>'nextpattern'</spec>
+    <type>stringlist</type>
+    <default>'\bnext',^>$,'^(>>|»)$','^(>|»)','(>|»)$','\bmore\b'</default>
+    <description>
+        <p>
+            Patterns to use when guessing the next page in a document
+            sequence after pressing the <k>]]</k> key. Each pattern is
+            successively tested against each link in the page (as defined by
+            <o>hinttags</o>, starting with the last), and the first link to
+            match is followed.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'noonline' 'online'</tags>
+    <spec>'online'</spec>
+    <type>boolean</type>
+    <default>on</default>
+    <description>
+        <p>
+            Enables or disables ‘offline’ mode, where network access is
+            disabled and all web pages are loaded entirely from cache.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'pa' 'pageinfo'</tags>
+    <spec>'pageinfo' 'pa'</spec>
+    <type>charlist</type>
+    <default>gfm</default>
+    <description>
+        <p>Info shown in the <ex>:pageinfo</ex> output.</p>
+
+        <p>Items available by default:</p>
+
+        <dl dt="width: 6em;">
+            <dt>g</dt>      <dd>General info</dd>
+            <dt>f</dt>      <dd>Feeds</dd>
+            <dt>m</dt>      <dd>Meta tags</dd>
+        </dl>
+
+        <p>
+            The order of the options defines the order in which they appear in
+            the result.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'pk' 'passkeys'</tags>
+    <spec>'passkeys' 'pk'</spec>
+    <type>sitemap</type>
+    <default/>
+    <description>
+        <p>
+            Pass certain keys through directly for the given URLs.
+            For any page with a URL matching a given regexp, all key
+            events for keys listed in that regexp's value are passed
+            through directly to &dactyl.host;, and are not processed
+            by &dactyl.appName; in any way. Key names are separated
+            by commas, where the first key name is treated as a list
+            of individual keys and each subsequent key is treated as
+            a key chain. Individual key entries always apply to all
+            modes. Key chains apply only to non-input modes unless
+            they begin with a key requiring a modifier other than
+            shift.
+        </p>
+
+        <example><set opt="passkeys" op="+="><str delim="">mail.google.com</str>:<str delim="">jk&lt;CR></str>,<str delim="">gi</str></set></example>
+
+        <p>
+            More subtle and complex pass through can be achieved
+            using <t>groups</t> and mode-specific mappings utilizing
+            the <k name="Pass"/> pseudo-key.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'pps' 'popups'</tags>
+    <spec>'popups' 'pps'</spec>
+    <type>stringlist</type>
+    <default>tab</default>
+    <description>
+        <p>
+            Defines where to show requested pop-up windows. Applies only to
+            links which request to open in a new window. The behavior of
+            middle-, shift-, or control- clicking a link is unaffected by this
+            option.
+        </p>
+
+        <p>Possible values are:</p>
+
+        <dl dt="width: 8em;">
+            <dt>tab</dt>            <dd>Open pop-ups in a new tab</dd>
+            <dt>window</dt>         <dd>Open pop-ups in a new window</dd>
+            <dt>resized</dt>        <dd>Open resized pop-ups in a new window</dd>
+        </dl>
+
+        <p>
+            If neither <em>tab</em> nor <em>window</em> is provided, all
+            pop-ups open in the current tab. <em>tab</em> and <em>window</em>
+            are mutually exclusive, and the last one listed is effective.
+        </p>
+
+        <note>
+            This option does not alter the &dactyl.host; pop-up blocker behavior
+            in any way.
+        </note>
+    </description>
+</item>
+
+<item>
+    <tags>'previouspattern'</tags>
+    <strut/>
+    <spec>'previouspattern'</spec>
+    <type>stringlist</type>
+    <default><![CDATA['\bprev|previous\b',^<$,'^(<<|«)$','^(<|«)','(<|«)$']]></default>
+    <description>
+        <p>
+            Patterns to use when guessing the previous page in a document
+            sequence after pressing the <k>[[</k> key. Each pattern is
+            successively tested against each link in the page (as defined by
+            <o>hinttags</o>, starting with the last), and the first link to
+            match is followed.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'noprivate' 'private'</tags>
+    <spec>'private'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>
+            Set the <str>private browsing</str> option. In private browsing mode
+            history, cache files, cookies, form data, passwords, download list
+            entries, local and URL <t>marks</t>, <t>command-line</t> history
+            and macros are available only for the duration of the private
+            browsing session and deleted when returning to normal browsing
+            mode. See also <t>privacy</t>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>$&dactyl.idName;_RUNTIME</tags>
+    <tags>'rtp' 'runtimepath'</tags>
+    <spec>'runtimepath' 'rtp'</spec>
+    <type>stringlist</type>
+    <default type="plain"><str>$&dactyl.idName;_RUNTIME</str> or
+       Unix, Mac: <str>~/.&dactyl.name;</str>
+         Windows: <str>~/&dactyl.name;</str></default>
+    <description>
+        <p>List of directories searched for runtime files:</p>
+
+        <ul>
+            <li>colors/</li>
+            <li>macros/</li>
+            <li>plugins/</li>
+        </ul>
+
+        <p>Example:</p>
+
+        <set opt="runtimepath"><str delim="">~/my&dactyl.name;</str>,<str delim="">~/.&dactyl.name;</str></set>
+
+        <p>
+            This will search for plugins in both
+            <str>~/my&dactyl.name;/plugins</str> and
+            <str>~/.&dactyl.name;/plugins</str>
+        </p>
+
+        <p>
+            On startup, if the environment variable <em>$&dactyl.idName;_RUNTIME</em> does not
+            exist, &dactyl.appName; will set it to match this value.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'si' 'sanitizeitems'</tags>
+    <spec>'sanitizeitems' 'si'</spec>
+    <strut/>
+    <type>stringlist</type>
+    <default>all</default>
+    <description>
+        <p>
+            The default list of private items to sanitize. See
+            <ex>:sanitize</ex> for a list and explanation of possible values.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'ss' 'sanitizeshutdown'</tags>
+    <spec>'sanitizeshutdown' 'ss'</spec>
+    <type>stringlist</type>
+    <default/>
+    <description>
+        <p>The items to sanitize automatically at shutdown.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'sts' 'sanitizetimespan'</tags>
+    <spec>'sanitizetimespan' 'sts'</spec>
+    <strut/>
+    <type>number</type>
+    <default>all</default>
+    <description>
+        <p>
+            The default sanitizer time span. Only items created within this timespan are
+            deleted. The value must be of the one of:
+        </p>
+
+        <dl dt="width: 8em;">
+            <dt>all</dt>       <dd>Everything</dd>
+            <dt>session</dt>   <dd>The current session</dd>
+            <dt><a>n</a>m</dt> <dd>Past <a>n</a> Minutes</dd>
+            <dt><a>n</a>h</dt> <dd>Past <a>n</a> Hours</dd>
+            <dt><a>n</a>d</dt> <dd>Past <a>n</a> Days</dd>
+            <dt><a>n</a>w</dt> <dd>Past <a>n</a> Weeks</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'scr' 'scroll'</tags>
+    <spec>'scroll' 'scr'</spec>
+    <type>number</type>
+    <default>0</default>
+    <description>
+        <p>
+            Number of lines to scroll with <k name="C-u"/> and <k name="C-d"/>
+            commands. The number of lines scrolled defaults to half the window
+            size. When a <oa>count</oa> is specified to the <k name="C-u"/> or
+            <k name="C-d"/> commands, that value is used instead. When the
+            value is <em>0</em>, it defaults to half the window height.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'sh' 'shell'</tags>
+    <spec>'shell' 'sh'</spec>
+    <type>string</type>
+    <default type="plain"><em>$SHELL</em> or <str>sh</str>, Windows: <str>cmd.exe</str></default>
+    <description>
+        <p>Shell to use for executing <ex>:!</ex> and <ex>:run</ex> commands.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'shcf' 'shellcmdflag'</tags>
+    <spec>'shellcmdflag' 'shcf'</spec>
+    <strut/>
+    <type>string</type>
+    <default type="plain"><str>-c</str>, Windows: <str>/c</str></default>
+    <description>
+        <p>Flag passed to shell when executing <ex>:!</ex> and <ex>:run</ex> commands.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'nosmd' 'noshowmode'</tags>
+    <tags>'smd' 'showmode'</tags>
+    <spec>'showmode' 'smd'</spec>
+    <type>regexplist</type>
+    <default>!^normal$</default>
+    <description>
+        <p>Show the current mode in the command line if it matches this expression.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'ssli' 'showstatuslinks'</tags>
+    <spec>'showstatuslinks' 'ssli'</spec>
+    <strut/>
+    <type>string</type>
+    <default>status</default>
+    <description>
+        <p>
+            When the mouse hovers over a link, or a link is otherwise focused,
+            show its destination in the status bar.
+        </p>
+
+        <p>Possible values are:</p>
+
+        <dl>
+            <dt></dt> <dd>Don't show link destination</dd>
+            <dt>status</dt> <dd>Show the link destination in the &tag.status-line;</dd>
+            <dt>command</dt> <dd>Show the link destination in the &tag.command-line;</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'stal' 'showtabline'</tags>
+    <spec>'showtabline' 'stal'</spec>
+    <type>string</type>
+    <default>always</default>
+    <description>
+        <p>Define when the tab bar is visible.</p>
+
+        <dl>
+            <dt>always</dt>   <dd>Always show the tab bar</dd>
+            <dt>multitab</dt> <dd>Show the tab bar when there are multiple tabs</dd>
+            <dt>never</dt>    <dd>Never show the tab bar</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'nosf' 'nostrictfocus'</tags>
+    <tags>'sf' 'strictfocus'</tags>
+    <spec>'strictfocus' 'sf'</spec>
+    <type>boolean</type>
+    <default>on</default>
+    <description>
+        <p>
+            Prevent scripts from focusing input elements without user intervention.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'suggestengines'</tags>
+    <spec>'suggestengines'</spec>
+    <type>stringlist</type>
+    <default>google</default>
+    <description>
+        <p>
+            Set the search engines which can be used for completion
+            suggestions when <o>complete</o> contains <em>S</em>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'notmo' 'notimeout'</tags>
+    <tags>'tmo' 'timeout'</tags>
+    <spec>'timeout' 'tmo'</spec>
+    <type>boolean</type>
+    <default>true</default>
+    <description>
+        <p>
+            When this option is set and a key sequence interpretable both as a
+            complete command and as a start of a longer command is typed,
+            execute the shorter command after <o>timeoutlen</o> milliseconds.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'tmol' 'timeoutlen'</tags>
+    <spec>'timeoutlen' 'tmol'</spec>
+    <type>number</type>
+    <default>1000</default>
+    <description>
+        <p>
+            Maximum number of milliseconds to wait for a longer key command
+            when a shorter one exists. Only effective when <o>timeout</o> is
+            set.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'titlestring'</tags>
+    <spec>'titlestring'</spec>
+    <type>string</type>
+    <default>&dactyl.appName;</default>
+    <description>
+        <p>
+            Set the application name shown after the current page title in
+            &dactyl.host;'s title bar.
+        </p>
+        <example><set opt="titlestring"><str>Mozilla &dactyl.host;</str></set></example>
+    </description>
+</item>
+
+<item>
+    <tags>'us' 'urlsep' 'urlseparator'</tags>
+    <spec>'urlseparator' 'urlsep' 'us'</spec>
+    <type>string</type>
+    <default>\|</default>
+    <description>
+        <p>
+            The regular expression used to split URL lists in commands
+            like <ex>:open</ex>. When set to the empty string, URL lists
+            are never split. With the default value, the following will open
+            three URLs in the current tab and two new background tabs,
+        </p>
+        <code><ex>:open <str delim="">google Linux</str> | <str delim="">wikipedia Arch Linux</str> | <str delim="">imdb Serenity</str></ex></code>
+    </description>
+</item>
+
+<item>
+    <tags>'noum' 'nousermode'</tags>
+    <tags>'um' 'usermode'</tags>
+    <spec>'usermode' 'um'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>Show current website with minimal styling.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'vbs' 'verbose'</tags>
+    <spec>'verbose' 'vbs'</spec>
+    <type>number</type>
+    <default>1</default>
+    <description>
+        <p>
+            Define which info messages are displayed. As the value increases,
+            &dactyl.appName; will show more messages about its progress.
+            These can be viewed at any time with the <ex>:messages</ex>
+            command. The highest useful value is 15, being the most verbose
+            mode.
+        </p>
+        <!-- TODO: list levels and associated messages -->
+    </description>
+</item>
+
+<item>
+    <tags>'novb' 'novisualbell'</tags>
+    <tags>'vb' 'visualbell'</tags>
+    <spec>'visualbell' 'vb'</spec>
+    <type>boolean</type>
+    <default>off</default>
+    <description>
+        <p>
+            Use visual bell instead of beeping on errors. The visual bell
+            style is controlled by <ex>:highlight Bell</ex>. See also
+            <o>errorbells</o>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'wia' 'wildanchor'</tags>
+    <strut/>
+    <spec>'wildanchor' 'wia'</spec>
+    <type>regexplist</type>
+    <default>!'/ex/(back|buffer|ext|forward|help|undo)'</default>
+    <description>
+        <p>
+            Regular expression list defining which completion groups show only
+            matches anchored to the beginning of the result. The first
+            matching expression is the one that applies. If the match is
+            negated, then the current filter may match anywhere in the result.
+            If it is not negated, then the match may only occur at the
+            beginning of the result. If no items match, then a
+            context-dependent default value is used.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>'wic' 'wildcase'</tags>
+    <spec>'wildcase' 'wic'</spec>
+    <type>regexpmap</type>
+    <default>.?:smart</default>
+    <description>
+        <p>
+            Defines how completions are matched with regard to character case.
+            Keys in the <t>regexpmap</t> refer to completion context names (see
+            <ex>:contexts</ex>) for which the value applies. Possible values
+            are:
+        </p>
+
+        <dl>
+            <dt><str>smart</str></dt>  <dd>Case is significant when capital letters are typed</dd>
+            <dt><str>match</str></dt>  <dd>Case is always significant</dd>
+            <dt><str>ignore</str></dt> <dd>Case is never significant</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>'wig' 'wildignore'</tags>
+    <spec>'wildignore' 'wig'</spec>
+    <type>regexplist</type>
+    <default></default>
+    <description>
+        <p>
+            List of file patterns to ignore when completing files. For example,
+            the following will ignore object files and Vim swap files:
+        </p>
+
+        <set opt="wildignore"><str delim="'">\.o$</str>,<str delim="'">^\..*\.s[a-z]{2}$</str></set>
+
+        <note>Unlike Vim, each pattern is a regular expression rather than a glob.</note>
+    </description>
+</item>
+
+<item>
+    <tags>'wim' 'wildmode'</tags>
+    <spec>'wildmode' 'wim'</spec>
+    <type>stringlist</type>
+    <default>list:full</default>
+    <description>
+        <p>
+            Defines how command-line completion works. It is a comma-separated
+            list of parts, where each part specifies what to do for each
+            consecutive press of the <k name="Tab" mode="c"/> key. The last
+            element in the list is used for each succeeding <k name="Tab" mode="c"/>
+            after it has been reached.
+        </p>
+
+        <p>These are the possible values for each part:</p>
+
+        <dl>
+            <dt><str></str></dt>             <dd>Complete only the first match.</dd>
+            <dt><str>full</str></dt>         <dd>Complete the next full match. After the last, the original string is used.</dd>
+            <dt><str>longest</str></dt>      <dd>Complete the longest common substring of all completions.</dd>
+            <dt><str>list</str></dt>         <dd>When more than one match, list all matches.</dd>
+            <dt><str>list:full</str></dt>    <dd>When more than one match, list all matches and complete the first match.</dd>
+            <dt><str>list:longest</str></dt>
+            <dd>
+                When more than one match, list all matches and
+                complete till the longest common string. When there
+                is only a single match, it is fully completed
+                regardless of the case.
+            </dd>
+        </dl>
+
+        <p>
+            See also <o>altwildmode</o>.
+        </p>
+
+    </description>
+</item>
+
+<item>
+    <tags>'wis' 'wildsort'</tags>
+    <spec>'wildsort' 'wis'</spec>
+    <type>regexplist</type>
+    <default>.*</default>
+    <description>
+        <p>
+            A list of regular expressions defining which completion contexts
+            should be sorted. The main purpose of this option is to prevent
+            sorting of certain completion lists that don't perform well under
+            load.
+        </p>
+
+        <p>See also <ex>:contexts</ex>.</p>
+    </description>
+</item>
+
+<item>
+    <tags>'wsp' 'wordseparators'</tags>
+    <spec>'wordseparators' 'wsp'</spec>
+    <strut/>
+    <type>string</type>
+    <default><![CDATA[[.,!?:;\\/"^$%&?()[\]{}<>#*+|=~ _-]]]></default>
+    <description>
+        <p>
+            A regular expression which defines how words are split for
+            the <o>hintmatching</o> types <str>wordstartswith</str> and
+            <str>firstletters</str>. Words are split on each occurrence of the
+            given pattern.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/pattern.xml b/common/locale/en-US/pattern.xml
new file mode 100644 (file)
index 0000000..e874701
--- /dev/null
@@ -0,0 +1,135 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="pattern"
+    title="&dactyl.appName; Patterns"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="text-find-commands">Text find commands</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName; provides a Vim-like incremental find interface to
+    replace &dactyl.host;'s crippled Typeahead Find. Among other improvements,
+    our find service:
+</p>
+<ul>
+    <li>
+        Starts at the cursor position in the currently selected frame, unlike
+        &dactyl.host;, which always starts at the beginning of the first frame
+        for documents with more than one frame.
+    </li>
+    <li>
+        Returns the cursor and viewport to their original position on cancel.
+    </li>
+    <li>
+        Backtracks to the first successful match after pressing backspace,
+        unlike &dactyl.host;, which will always continue from the last match.
+    </li>
+    <li>
+        Supports reverse incremental find.
+    </li>
+    <li>
+        Escape sequences to toggle link-only and case-sensitive find.
+    </li>
+</ul>
+
+<p>
+    Regular expression find, however, is not currently available unless the
+    /Find Bar/ service is installed, in which case it may be toggled on with
+    a find flag.
+</p>
+
+<item>
+    <tags>/</tags>
+    <spec>/<a>pattern</a><oa>/</oa><k name="CR"/></spec>
+    <description>
+        <p>Find <a>pattern</a> starting at the current caret position.</p>
+
+        <p>
+            The following escape sequences can be used to modify the
+            behavior of the find. When flags conflict, the last to
+            appear is the one that takes effect.
+        </p>
+
+        <dl dt="width: 6em;">
+            <dt>\c</dt> <dd>Perform case insensitive find (default if <o>findcase</o>=<str>ignore</str>).</dd>
+            <dt>\C</dt> <dd>Perform case sensitive find (default if <o>findcase</o>=<str>match</str>).</dd>
+            <dt>\l</dt> <dd>Search only in links, as defined by <o>hinttags</o>.</dd>
+            <dt>\L</dt> <dd>Search the entire page.</dd>
+        </dl>
+
+        <p>
+            Additionally, if the /Find Bar/ extension is installed, the
+            following flags may be used,
+        </p>
+        <dl dt="width: 6em;">
+            <dt>\r</dt> <dd>Process the entire pattern as a regular expression.</dd>
+            <dt>\R</dt> <dd>Process the entire pattern as an ordinary string.</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>?</tags>
+    <spec>?<a>pattern</a><oa>?</oa><k name="CR"/></spec>
+    <description>
+        <p>
+            Find a pattern backward of the current caret position in exactly the
+            same manner as <k>/</k>
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>n</tags>
+    <spec>n</spec>
+    <description short="true">
+        <p>Find next. Repeat the last find.</p>
+    </description>
+</item>
+
+<item>
+    <tags>N</tags>
+    <spec>N</spec>
+    <description short="true">
+        <p>Find previous. Repeat the last find in the opposite direction.</p>
+    </description>
+</item>
+
+<item>
+    <tags>*</tags>
+    <spec>*</spec>
+    <description short="true">
+        <p>Search forward for the next occurrence of the word under cursor.</p>
+    </description>
+</item>
+
+<item>
+    <tags>#</tags>
+    <spec>#</spec>
+    <description short="true">
+        <p>Search backward for the previous occurrence of the word under cursor.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:noh :nohlfind</tags>
+    <strut/>
+    <spec>:noh<oa>lfind</oa></spec>
+    <description>
+        <p>
+            Remove the find highlighting. The document is highlighted again
+            when another find command is used or the <o>hlfind</o> option
+            is set.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/print.xml b/common/locale/en-US/print.xml
new file mode 100644 (file)
index 0000000..bd2ec45
--- /dev/null
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="print"
+    title="&dactyl.appName; Printing"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="printing">Printing</h1>
+<toc start="2"/>
+
+<item>
+    <tags>:ha :hardcopy</tags>
+    <spec>:ha<oa>rdcopy</oa><oa>!</oa></spec>
+    <description>
+        <p>
+            Print current document. Open a GUI dialog where you can select the printer,
+            number of copies, orientation, etc. When used with <oa>!</oa>, the dialog is skipped
+            and the default printer used.
+        </p>
+    </description>
+</item>
+
+<item>
+    <spec>:ha<oa>rdcopy</oa><oa>!</oa> ><a>filename</a></spec>
+    <description>
+        <p>As above, but write the output to <a>filename</a>.</p>
+
+        <note>Not available on Windows.</note>
+    </description>
+</item>
+
+<h2 tag="&dactyl.host;-print-dialogs">&dactyl.host; printing dialogs</h2>
+
+<p>
+    The "Print Preview" and "Page Setup" dialogs can be opened via the <ex>:dialog</ex>
+    command
+</p>
+<code><ex>:dialog printpreview</ex></code>
+<p>
+    and
+</p>
+<code><ex>:dialog printsetup</ex></code>
+<p>
+    respectively.
+</p>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/privacy.xml b/common/locale/en-US/privacy.xml
new file mode 100644 (file)
index 0000000..84dafa9
--- /dev/null
@@ -0,0 +1,156 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="privacy"
+    title="&dactyl.appName; Privacy"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="privacy">Privacy and sensitive information</h1>
+<toc start="2"/>
+
+<p>
+    Part of &dactyl.appName;'s user efficiency comes at the cost of storing a
+    lot of potentially private data, including <t>command-line</t> history, page
+    marks, visited page history, and the like. Because we know that keeping a
+    detailed trail of all of your activities isn't always welcome,
+    &dactyl.appName; provides comprehensive facilities for erasing potentially
+    sensitive data.
+</p>
+
+<h2 tag="private-mode porn-mode">Private mode browsing</h2>
+
+<p>
+    &dactyl.appName; fully supports &dactyl.host;'s private browsing mode.
+    When in private browsing mode, no data other than Bookmarks and QuickMarks
+    are written to disk. Further, upon exiting private mode, all newly
+    accumulated data, including <t>command-line</t> history, local and URL
+    marks, and macros, are purged from memory. For more information, see
+    <o>private</o>.
+</p>
+
+<h2 tag="sanitizing clearing-data">Clearing private data</h2>
+
+<p>
+    In addition to private mode, &dactyl.appName; provides a comprehensive
+    facility for clearing any potentially sensitive data generated by either
+    &dactyl.appName; or &dactyl.host;. It directly integrates with
+    &dactyl.host;'s own sanitization facility, and so automatically clears any
+    domain data and session history when requested. Further, &dactyl.appName;
+    provides its own more granular sanitization facility, which allows, e.g.,
+    clearing only the command-line and macro history for the past ten minutes.
+</p>
+
+<item>
+    <tags>:sa :sanitize</tags>
+    <spec>:sa<oa>nitize</oa> <oa>-host=<a>host</a></oa> <oa>-older</oa> <oa>-timespan=<a>timespan</a></oa> <a>item</a> …</spec>
+    <spec>:sa<oa>nitize</oa>! <oa>-host=<a>host</a></oa> <oa>-older</oa> <oa>-timespan=<a>timespan</a></oa></spec>
+    <description>
+        <p>
+            Clear private data items for <a>timespan</a>, where <a>item</a> …
+            is a list of private items to delete. If <oa>!</oa> is specified,
+            then <o>sanitizeitems</o> is used for the list of items to delete.
+            Items may be any of:
+        </p>
+
+        <dl>
+            <dt>all         </dt>       <dd>All items</dd>
+            <dt>cache       </dt>       <dd>Cache</dd>
+            <dt>commandline </dt>       <dd>Command-line history</dd>
+            <dt>cookies     </dt>       <dd>Cookies</dd>
+            <dt>downloads   </dt>       <dd>Download history</dd>
+            <dt>formdata    </dt>       <dd>Saved form and search history</dd>
+            <dt>history     </dt>       <dd>Browsing history</dd>
+            <dt>host        </dt>       <dd>All data from the given host</dd>
+            <dt>marks       </dt>       <dd>Local and URL marks</dd>
+            <dt>macros      </dt>       <dd>Saved macros</dd>
+            <dt>messages    </dt>       <dd>Saved <ex>:messages</ex></dd>
+            <dt>offlineapps </dt>       <dd>Offline website data</dd>
+            <dt>options     </dt>       <dd>Options containing hostname data</dd>
+            <dt>passwords   </dt>       <dd>Saved passwords</dd>
+            <dt>sessions    </dt>       <dd>Authenticated sessions</dd>
+            <dt>sitesettings</dt>       <dd>Site preferences</dd>
+        </dl>
+
+        <p>
+            When <em>history</em> items are sanitized, all command-line
+            history items containing URLs or page titles (other than bookmark
+            commands) are additionally cleared. Invocations of the
+            <em>:sanitize</em> command are included in this set.
+        </p>
+
+        <p>
+            If <a>timespan</a> (short name <em>-t</em>) is specified, only
+            items within that timespan are deleted, otherwise the value of
+            <o>sanitizetimespan</o> is used.  If <oa>-older</oa> (short name
+            <em>-o</em>) is specified, then only items older than
+            <a>timespan</a> are deleted.
+        </p>
+
+        <note>
+            The following items are always cleared entirely, regardless of
+            <a>timeframe</a>: <em>cache</em>, <em>host</em>, <em>offlineapps</em>,
+            <em>passwords</em>, <em>sessions</em>, <em>sitesettings</em>.
+            Conversely, <em>host</em> and <em>options</em> are never cleared
+            unless a host is specified.
+        </note>
+
+        <p>
+            If <a>host</a> (short name <em>-h</em>) is specified, only items
+            containing a reference to that domain or a subdomain thereof are
+            cleared. Moreover, if either of <em>commandline</em> or
+            <em>history</em> is specified, the invocation of the
+            <em>:sanitize</em> command is naturally cleared as well.
+        </p>
+
+        <note>
+            This only applies to <em>commandline</em>, <em>cookies</em>,
+            <em>history</em>, <em>marks</em>, <em>messages</em>,
+            <em>options</em>, and <em>sitesettings</em>. All other
+            domain-specific data is cleared only along with <em>host</em>,
+            when a request is made to &dactyl.host; to purge all data for
+            <a>host</a>. Included in this purge are all matching history
+            entries, cookies, closed tabs, form data, and location bar
+            entries.
+        </note>
+    </description>
+</item>
+
+<h2 tag="cookie-settings">Cookie settings</h2>
+<item>
+    <tags>:cookies :ck</tags>
+    <spec>:cookies <a>host</a> <oa>action</oa> …</spec>
+    <description>
+        <p>
+            Manage cookies for <a>host</a>. Additionally, the completion
+            list will show you information about the cookies and
+            permissions for the current page.
+        </p>
+
+        <p>Available actions:</p>
+
+        <dl dt="width: 12em">
+            <dt>unset</dt>            <dd>Unset special permissions for <a>host</a></dd>
+            <dt>allow</dt>            <dd>Allow cookies from <a>host</a></dd>
+            <dt>deny</dt>             <dd>Deny cookies from <a>host</a></dd>
+            <dt>session</dt>          <dd>Allow cookies from <a>host</a> for the current session</dd>
+            <dt>list</dt>             <dd>List all cookies for <a>host</a></dd>
+            <dt>clear</dt>            <dd>Clear all cookies for <a>host</a></dd>
+            <dt>clear-persistent</dt> <dd>Clear all persistent cookies for <a>host</a></dd>
+            <dt>clear-session</dt>    <dd>Clear all session cookies for <a>host</a></dd>
+        </dl>
+
+        <p>
+            If no <oa>action</oa> is given, the value of <o>cookies</o> is used.
+        </p>
+
+        <example><ex>:map -b</ex> <k link="false">c</k> <ex>:cookies</ex> <k name="A-Tab" link="false"/></example>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/repeat.xml b/common/locale/en-US/repeat.xml
new file mode 100644 (file)
index 0000000..b14b8a8
--- /dev/null
@@ -0,0 +1,469 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="repeat"
+    title="&dactyl.appName; Repeating Commands"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="repeating">Repeating commands</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName; can repeat commands in a number of ways, from repeating
+    the last command, to recording and playing macros, to saving its state and
+    executing scripts.
+</p>
+
+<h2 tag="single-repeat">Single repeats</h2>
+
+<item>
+    <tags>&lt;repeat-key> .</tags>
+    <strut/>
+    <spec><oa>count</oa>.</spec>
+    <description>
+        <p>
+            Repeat the last keyboard mapping <oa>count</oa> times. Note that,
+            unlike in Vim, this does not apply solely to editing commands,
+            mainly because &dactyl.appName; doesn't have them.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>@:</tags>
+    <strut/>
+    <spec><oa>count</oa>@:</spec>
+    <description>
+        <p>Repeat the last Ex command <oa>count</oa> times.</p>
+    </description>
+</item>
+
+<h2 tag="macros complex-repeat">Macros</h2>
+
+<item>
+    <tags>&lt;record-macro> q</tags>
+    <strut/>
+    <spec>q<a>0-9a-zA-Z</a></spec>
+    <description>
+        <p>
+            Record a key sequence as a macro. Available macros are
+            <a>0-9a-zA-Z</a>. If the macro is an uppercase letter, the
+            recorded keys are appended to the lowercase macro of the same
+            name. Typing <k>q</k> again stops the recording.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:macros</tags>
+    <spec>:mac<oa>ros</oa> <oa>pat</oa></spec>
+    <description>
+        <p>
+            List recorded macros matching the optional regular expression
+            <oa>pat</oa>. If no regexp is given, list all macros.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:delmac :delmacros</tags>
+    <spec>:delmac<oa>ros</oa> <a>pat</a></spec>
+    <spec>:delmac<oa>ros</oa>!</spec>
+    <description>
+        <p>
+            Delete recorded macros matching the regular expression
+            <a>pat</a>. If <em>!</em> is given, all macros are deleted.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>&lt;play-macro> @</tags>
+    <spec><oa>count</oa>@<a>a-z0-9</a></spec>
+    <description>
+        <p>
+            Plays the contents of macro with name <a>a-z0-9</a> <oa>count</oa>
+            times.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>@@</tags>
+    <spec><oa>count</oa>@@</spec>
+    <description short="true">
+        <p>Replay the last executed macro <oa>count</oa> times.</p>
+    </description>
+</item>
+
+<h2 tag="macro-utilities">Macro utilities</h2>
+
+<p>
+    The following key bindings facilitate the recording of efficient
+    macros. They have no effect when typed normally, but are
+    recorded and take effect during macro playback.
+</p>
+
+<item>
+    <tags><![CDATA[<sleep> <A-m>s]]></tags>
+    <spec><a>count</a><![CDATA[<A-m>s]]></spec>
+    <description short="true">
+        <p>
+            Sleep for <a>count</a> milliseconds before resuming playback.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<wait-for-page-load> <A-m>l]]></tags>
+    <strut/>
+    <spec><oa>count</oa><![CDATA[<A-m>l]]></spec>
+    <description>
+        <p>
+            Wait for the current page to finish loading before resuming
+            playback. If <oa>count</oa> is given, wait no more than
+            <oa>count</oa> seconds. Otherwise wait no more than 25 seconds.
+        </p>
+    </description>
+</item>
+
+
+<h2 tag="group groups">Groups</h2>
+
+<p>
+    In order to facilitate script writing, especially scripts which only
+    apply to certain web sites, many types of commands and mappings can
+    be assigned to a named group. In addition to helping identify the
+    source of such mappings in listings, and aiding in the cleanup of
+    scripts, these groups can be configured to apply only to certain web
+    sites.
+</p>
+
+<item>
+    <tags>:gr :group</tags>
+    <spec>:gr<oa>oup</oa></spec>
+    <description>
+        <p>List all active <t>groups</t>.</p>
+    </description>
+</item>
+
+<item>
+    <spec>:gr<oa>oup</oa><oa>!</oa> <a>group</a> …</spec>
+    <description>
+        <p>
+            Select, create, or modify a <t>group</t>. After invocation,
+            <a>group</a> becomes the default group for all further commands
+            issued in the current script. If <oa>!</oa> is given the group is
+            cleared of all mappings, commands, and any other entries bound to
+            it before making the specified additions (if any).
+        </p>
+
+        <p>The following <a>group</a> names have special meanings:</p>
+
+        <dl>
+            <dt>builtin</dt> <dd>The default group for builtin items. Can not be modified in any way by scripts.</dd>
+            <dt>default</dt> <dd>The default group for this script.</dd>
+            <dt>user</dt> <dd>The default group for the command line and <t>&dactyl.name;rc</t>.</dd>
+        </dl>
+
+        <p>The following arguments are available:</p>
+
+        <dl>
+            <dt>-args=<a>javascript</a></dt> <dd>JavaScript Object which
+                augments the arguments passed to commands, mappings, and
+                autocommands, e.g., given <str delim="">{ foo: "bar" }</str>,
+                <tt>foo</tt> (<tt>&lt;foo></tt> if the Ex syntax is used) will
+                be replaced by <str delim="">bar</str> inside the definitions
+                (short name: <em>-a</em>)</dd>
+            <dt>-description</dt> <dd>A description of this group (short names: <em>-desc</em>, <em>-d</em>)</dd>
+            <dt>-locations=<a>filters</a></dt> <dd>The URLs for which this
+                group should be active. See <t>site-filters</t> (short names:
+                <em>-locs</em>, <em>-loc</em>, <em>-l</em>)</dd>
+            <dt>-nopersist</dt> <dd>Do not save this group to an auto-generated RC file (short name: <em>-n</em>)</dd>
+        </dl>
+    </description>
+</item>
+
+
+<h2 tag="site-filter site-filters">Site Filters</h2>
+
+<p>
+    Many &dactyl.appName; commands accept filters so that they may be applied
+    only to specific sites. Most of these commands accept filters in any of the
+    following formats:
+</p>
+
+<dl>
+    <dt>domain</dt>
+    <dd>
+        Any filter which is a valid domain name will match any site on that
+        domain or any sub-domain thereof. These filters may contain any letter
+        of the Roman alphabet, Arabic numerals, hyphens, and full stops.
+        Non-Latin domain names must be punycode encoded.
+    </dd>
+
+    <dt>URL prefix</dt>
+    <dd>
+        Any URL beginning with a valid protocol name and ending with a
+        <tt>*</tt> is treated as a URL prefix. It will match any URL which
+        begins with the given filter sans the trailing asterisk.
+    </dd>
+
+    <dt>Full URL</dt>
+    <dd>
+        Any URL beginning with a valid protocol name and not ending with an
+        asterisk is treated as a full URL match. It will match any page which
+        has a URL identical to the filter.
+    </dd>
+
+    <dt>Regular expression</dt>
+    <dd>
+        Any filter which does not fall into one of the above categories is
+        treated as a case-sensitive regular expression.
+    </dd>
+</dl>
+
+<p>
+    In most cases, any of the above may be prefixed with a <tt>!</tt> character
+    to exclude matching sites.
+</p>
+
+<h2 tag="using-scripts">Using scripts</h2>
+
+<item>
+    <tags>:so :source</tags>
+    <spec>:so<oa>urce</oa><oa>!</oa> <a>file</a></spec>
+    <description>
+        <p>
+            Read Ex commands, JavaScript, or CSS from <a>file</a>. Files are
+            interpreted based on their extensions. Files which end in
+            <em>.js</em> are executed as JavaScript, while those ending in
+            <em>.css</em> are loaded as Cascading Stylesheets, and anything
+            else is interpreted as Ex commands. In normal cases, any errors
+            generated by the execution or non-existence of <a>file</a> are
+            printed to the <t>command-line</t> area.  When <oa>!</oa> is
+            provided, these are suppressed.
+        </p>
+
+        <p>
+            Environment variables in <a>file</a> are expanded to their current
+            value, and the prefix <em>~</em> is replaced with the value of
+            <em>$HOME</em>. See <t>expand-env</t> and <t>initialization</t>
+            for more information.
+        </p>
+
+        <h3 tag=":source-contexts">Script Contexts</h3>
+
+        <p>
+            Each script executes in its own JavaScript context. This means that
+            any global variable or function, including those defined by
+            <ex>:javascript</ex> and the <tt>-javascript</tt> flag of
+            <ex>:map</ex>, <ex>:command</ex>, and <ex>:autocmd</ex>,
+            is directly available only within the current script. Outside of the
+            current script, they can only be accessed as properties of the
+            script's global object, which is stored in the <tt>plugins</tt>
+            global under the script's full path.
+        </p>
+
+        <h3 tag=":source-groups">Script Groups</h3>
+
+        <p>
+            In addition to its own JavaScript context, each script is executed
+            with its own default <link topic="groups">group</link> into which
+            its styles, mappings, commands, and autocommands are placed. This
+            means that commands such as <ex>:delcommand!</ex> can be issued
+            without fear of trampling other user-defined mappings. The command
+            <ex>:group! default</ex> can be issued to clear all such items at
+            once, and should be placed at the head of most scripts to prevent
+            the accumulation of stale items when the script is re-sourced.
+        </p>
+
+        <h3 tag=":source-css">Cascading Stylesheets</h3>
+
+        <p>
+            When a CSS file is sourced, its contents are applied to every web
+            page and every chrome document, including all browser windows and
+            dialogs. If the same file is sourced more than once, its previous
+            rules are cleared before it is applied again. Rules can be
+            restricted to specific documents by enclosing them in
+            <link topic="https://developer.mozilla.org/en/CSS/@-moz-document">@-moz-document</link>
+            blocks.
+        </p>
+
+        <h3 tag=":source-javascript">JavaScript</h3>
+
+        <p>
+            JavaScript files are executed with full chrome privileges in their
+            own global namespaces. These namespaces are stored as objects in
+            the <em>plugins</em> object, in the property named after the full
+            path of the sourced file. This means that any variables or
+            functions created by your script are stored as properties of that
+            object. Additionally, all properties of the global <em>window</em>
+            and <em>modules</em> objects are accessible to your script as
+            global variables.
+        </p>
+
+        <p>
+            Files in <em>~/.&dactyl.name;/plugins</em> may additionally be
+            accessed in <em>plugins.<a>filename</a></em> where <a>filename</a>
+            is the last component of the file's path stripped of any
+            extensions, with all hyphens stripped and any letter following a
+            hyphen capitalized.  So, the file
+            <em>~/.&dactyl.name;/plugins/foo-bar.js</em> may be accessed as
+            <em>plugins.fooBar</em>. See also <t>writing-plugins</t>.
+        </p>
+
+        <h3 tag=":source-ex">Ex commands</h3>
+
+        <p>
+            Ex command files are executed as if each line were entered into
+            the &tag.command-line; individually.
+            Additionally, certain commands support the same ‘here document’
+            syntax supported by most Unix shells and by the &tag.command-line;.
+            So, to execute a JavaScript statement which does not comfortably fit
+            on a single line, you can use:
+        </p>
+
+        <code><ex>:js</ex> &lt;&lt;<em>EOF</em>
+<kwd><hl key="Object">var</hl></kwd> hello = <kwd>function</kwd> () {
+    alert(<str>Hello world</str>);
+}
+<em>EOF</em></code>
+
+        <p>See also <t>ex-scripts</t> below.</p>
+    </description>
+</item>
+
+
+<item>
+    <tags>:lpl :loadplugins</tags>
+    <strut/>
+    <spec>:loadplugins <oa>pattern</oa> …</spec>
+    <description>
+        <p>
+            Immediately load all plugins which have yet to be loaded. Because
+            plugins are not automatically loaded until after <tt><t>&dactyl.name;rc</t></tt>
+            is sourced, this command must be placed early in the
+            <tt>&dactyl.name;rc</tt> file if <tt>&dactyl.name;rc</tt> uses commands or options
+            which are defined by plugins. Additionally, this command allows
+            newly installed plugins to be easily loaded without restarting
+            &dactyl.appName;. See also <o>loadplugins</o>.
+        </p>
+        <p>
+            If <oa>pattern</oa>s are provided, the given regular expressions are
+            used as filters rather than those in <o>loadplugins</o>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:ru :runtime</tags>
+    <spec>:runt<oa>ime</oa><oa>!</oa> <a>file</a> …</spec>
+    <description>
+        <p>
+            Source the specified file from the first directory in
+            <o>runtimepath</o> in which it exists. When <oa>!</oa> is given,
+            source the file from all directories in <o>runtimepath</o> in
+            which it exists.
+        </p>
+        <example><ex>:runtime plugins/foobar.js</ex></example>
+    </description>
+</item>
+
+<item>
+    <tags>:scrip :scriptnames</tags>
+    <spec>:scrip<oa>tnames</oa></spec>
+    <description>
+        <p>List all sourced script names, in the order they were first sourced.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:fini :finish</tags>
+    <strut/>
+    <spec>:fini<oa>sh</oa></spec>
+    <description>
+        <p>
+            Stop sourcing a script file. This can only be called from within a
+            &dactyl.appName; script file.
+        </p>
+    </description>
+</item>
+
+<h3 tag="ex-scripts">Ex Command Scripts</h3>
+
+<p>
+    Ex command scripts are similar to both entering commands on the
+    &tag.command-line; and to Vim
+    scripts, but with some notable differences.
+</p>
+
+<p tag="multiline-commands">
+    Commands in Ex command scripts can span multiple lines by
+    prefixing the second and further lines with a <em>\</em>
+    character. For instance, the following all define commands whose
+    definitions span multiple lines.
+</p>
+
+<code><ex>:command!</ex> <str delim="">foo</str>
+        \ <em>-description</em> <str>A command that frobs bars</str>
+        \ <ex>:javascript</ex> frob(content.bar)</code>
+
+<code><ex>:style</ex> <em>-name</em> <str delim="'">foo</str>
+     \ <str delim="'">foobar.com</str>
+     \ p<str delim="">:first-line</str> { <em>font-variant</em>: <str delim="">small-caps</str>; }
+     \ div<em>#side-bar</em> > <str delim="">:first-child</str> { <em>display</em>: <str delim="">none</str>; }</code>
+
+<code><ex>:command!</ex> <str delim="">do-some-stuff</str>
+        \ <em>-description</em> <str>A command which does some stuff in JavaScript</str>
+        \ <ex>:javascript</ex> &lt;&lt;<em>EOF</em>
+        \     window.do(<str>some</str>);
+        \     window.do(<str>stuff</str>);
+        \<em>EOF</em></code>
+
+<code><ex>:command!</ex> <str delim="">do-some-stuff</str>
+        \ <em>-description</em> <str>A command which does some stuff in JavaScript</str>
+        \ <ex>:javascript</ex>
+        \\    window.do(<str>some</str>);
+        \\    window.do(<str>stuff</str>);</code>
+
+<p tag="comments">
+    Lines may be commented out by prefixing them with a <em>"</em>
+    character.
+</p>
+
+<code>            <hl style="color: #444">" This is a comment</hl>
+    foo bar <hl style="color: #444">" This is a comment</hl>
+            <str> This is not a comment</str>
+    foo bar <str> This is not a comment</str>
+</code>
+
+<h2 tag="profile profiling">Profiling</h2>
+
+<item>
+    <tags>:time</tags>
+    <spec>:<oa>count</oa>time<oa>!</oa> <a>code|:command</a></spec>
+    <description>
+        <p>
+            Profile a piece of JavaScript code or an Ex command. Run
+            <a>code</a> <oa>count</oa> times and print the elapsed time.
+            If <a>code</a> begins with a <ex>:</ex>, it is executed as an Ex
+            command. Otherwise, it is executed as JavaScript, in which case it
+            is evaluated only once and stored as a function which is executed
+            <oa>count</oa> times.
+        </p>
+
+        <p>
+            When <oa>!</oa> is given, <a>code</a> is executed <oa>count</oa>
+            times, but no statistics are printed.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/starting.xml b/common/locale/en-US/starting.xml
new file mode 100644 (file)
index 0000000..2a6d33d
--- /dev/null
@@ -0,0 +1,180 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="starting"
+    title="&dactyl.appName; Starting"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="starting">Starting &dactyl.appName;</h1>
+<toc start="2"/>
+
+<h2 tag="startup-options">Command-line options</h2>
+
+<p>
+    Command-line options can be passed to &dactyl.appName; via the -&dactyl.name; &dactyl.host;
+    option. These are passed as single string argument.
+    E.g., <tt>&dactyl.hostbin; -&dactyl.name; <str><t>++cmd</t> 'set exrc' <t>+u</t> 'tempRcFile' <t>++noplugin</t></str></tt>
+</p>
+
+<item>
+    <tags>+c</tags>
+    <strut/>
+    <spec>+c <a>command</a></spec>
+    <description>
+        <p>
+            Execute a single Ex command after all initialization has been performed. See
+            <t>initialization</t>.
+        </p>
+
+        <p>This option can be specified multiple times.</p>
+    </description>
+</item>
+
+<item>
+    <tags>++cmd</tags>
+    <strut/>
+    <spec>++cmd <a>command</a></spec>
+    <description>
+        <p>
+            Execute a single Ex command before any initialization has been performed. See
+            <t>initialization</t>.
+        </p>
+
+        <p>This option can be specified multiple times.</p>
+    </description>
+</item>
+
+<item>
+    <tags>+u</tags>
+    <strut/>
+    <spec>+u <a>rcfile</a></spec>
+    <description>
+        <p>
+            The file <a>rcfile</a> is used for user initialization commands. If <a>rcfile</a> is
+            <str>NORC</str> then no startup initialization is performed except for the loading of
+            plugins, i.e., steps 1. and 2. in <t>initialization</t> are skipped. If <a>rcfile</a>
+            is <str>NONE</str> then plugin loading is also skipped.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>++noplugin</tags>
+    <strut/>
+    <spec>++noplugin</spec>
+    <description>
+        <p>Prevents plugin scripts from being loaded at startup. See <o>loadplugins</o>.</p>
+    </description>
+</item>
+
+<h2 tag="initialization startup">Initialization</h2>
+
+<p>At startup, &dactyl.appName; completes the following tasks in order. </p>
+
+<ol>
+    <li>
+        <p>
+            &dactyl.appName; first searches for user initialization commands in
+            the following locations. The first of these to be found is executed,
+            after which no further locations are searched.
+        </p>
+
+        <ol>
+            <li tag="$&dactyl.idName;_INIT">
+                <em>$&dactyl.idName;_INIT</em>
+                <strut/>
+                May contain a single Ex command (e.g., "<ex>:source <a>file</a></ex>").
+            </li>
+            <li tag="$MY_&dactyl.idName;RC">
+                <em>~/_&dactyl.name;rc</em>
+                <strut/>
+                Windows only. If this file exists, its contents
+                are executed and <tt>$MY_&dactyl.idName;RC</tt> set to its path.
+            </li>
+            <li tag="&dactyl.name;rc ">
+                <em>~/.&dactyl.name;rc</em>
+                <strut/>
+                If this file exists, its contents are executed.
+            </li>
+        </ol>
+    </li>
+    <li>
+        <p>
+            If <o>exrc</o> is set and the +u command-line option was not
+            specified, then any RC file in the current directory is also
+            sourced.
+        </p>
+    </li>
+    <li>
+        <p>
+            All directories in <o>runtimepath</o> are searched for a
+            ‘plugins’ subdirectory and all yet unloaded plugins are loaded.
+            For each plugins directory, all <tt>*.{js,&dactyl.fileExt;}</tt>
+            files (including those in further subdirectories) are sourced
+            alphabetically. No plugins will be sourced if,
+        </p>
+
+        <ul>
+            <li><o>noloadplugins</o> is set,</li>
+            <li>the <t>++noplugin</t> command-line option was specified, or</li>
+            <li>the <tt><t>+u</t>=NONE</tt> command-line option was specified.</li>
+        </ul>
+
+        <p>
+            Any plugin which was already loaded (e.g., by an earlier
+            invocation of the <ex>:loadplugins</ex> command) will be skipped.
+        </p>
+    </li>
+</ol>
+
+<p>
+    The user's home directory is determined as follows:
+</p>
+
+<ul>
+    <li>On Unix and Mac, the environment variable <tt>$HOME</tt> is used.</li>
+    <li>
+        On Windows, &dactyl.appName; checks for the existence of
+        <tt>%HOME%</tt>, then <tt>%USERPROFILE%</tt>, and then
+        <tt>%HOMEDRIVE%%HOMEPATH%</tt>. It uses the first one it
+        finds.
+    </li>
+</ul>
+
+<h2 tag="save-settings">Saving settings</h2>
+
+<item>
+    <tags>:mkp :mk&dactyl.name;rc</tags>
+    <spec>:mkp<oa>entadactylrc</oa><oa>!</oa> <oa>file</oa></spec>
+    <description>
+        <p>
+            Write current key mappings and changed options to <oa>file</oa>. If no
+            <oa>file</oa> is specified then <em>~/.&dactyl.name;rc</em> is written unless this file
+            already exists. The special version <ex>:mk&dactyl.name;rc!</ex> will overwrite
+            <oa>file</oa> if it exists.
+        </p>
+
+        <warning>
+            This behavior differs differs from that of Vim, which defaults to
+            writing the file in the current directory.
+        </warning>
+    </description>
+</item>
+
+<h2 tag="restarting">Restarting</h2>
+
+<item>
+    <tags>:res :restart</tags>
+    <spec>:res<oa>tart</oa></spec>
+    <description short="true">
+        <p>Force &dactyl.host; to restart. Useful when installing extensions.</p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/styling.xml b/common/locale/en-US/styling.xml
new file mode 100644 (file)
index 0000000..eda39bb
--- /dev/null
@@ -0,0 +1,242 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="styling"
+    title="&dactyl.appName; Styling"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="styling">Styling the GUI and web pages</h1>
+<toc start="2"/>
+
+<p>
+    &dactyl.appName; allows you to apply custom CSS styling to the web pages
+    you view as well as to the browser itself. Although it is possible to
+    style any user interface element via the <ex>:style</ex> command, most
+    &dactyl.appName; elements can be styled more easily by means of the
+    more specialized <ex>:highlight</ex> command.
+</p>
+
+<item>
+    <tags>E185 :colo :colorscheme</tags>
+    <spec>:colo<oa>rscheme</oa> <a>name</a></spec>
+    <description>
+        <p>
+            Load a color scheme. <a>name</a> is found by searching the <o>runtimepath</o> for the
+            first file matching <tt>colors/<a>name</a>.&dactyl.fileExt;</tt>.
+        </p>
+
+        <p>
+            The ColorScheme autocommand is triggered after the color scheme has been
+            sourced.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:hi :highlight</tags>
+    <spec>:hi<oa>ghlight</oa><oa>!</oa> <a>group</a><oa>selector</oa></spec>
+    <spec>:hi<oa>ghlight</oa><oa>!</oa> <oa>-append</oa> <oa>-link=<a>group</a>,…</oa> <a>group</a><oa>selector</oa> <oa>css</oa></spec>
+    <description>
+        <p>
+            Highlight <a>group</a> with <oa>css</oa>. <oa>css</oa> is one or more
+            semicolon-separated CSS declarations (E.g. <em>color: blue;
+            background-color: red</em>). <oa>selector</oa>, if provided, may
+            be any valid CSS selector (such as <em>:hover</em> or
+            <em>[href]</em>), and will restrict the highlighting to matching
+            elements.
+        </p>
+
+        <p>
+            If <em>-link</em> (short name <em>-l</em>) is supplied, the value of
+            each of the listed groups is prepended to the style of this group.
+        </p>
+
+        <p>Valid groups include:</p>
+
+        <dl>
+            <dt>Bell</dt>              <dd>&dactyl.appName;'s visual bell</dd>
+            <dt>Boolean</dt>           <dd>A JavaScript Boolean object</dd>
+            <dt>CmdLine</dt>           <dd>The command line</dd>
+            <dt>CmdOutput</dt>         <dd>The output of commands executed by <ex>:run</ex></dd>
+            <dt>CompDesc</dt>          <dd>The description column of the completion list</dd>
+            <dt>CompGroup</dt>         <dd>The top-level container for a group of completion results</dd>
+            <dt>CompIcon</dt>          <dd>The favicon of a completion row</dd>
+            <dt>CompItem</dt>          <dd>A row of completion list</dd>
+            <dt>CompItem[selected]</dt><dd>A selected row of completion list</dd>
+            <dt>CompLess::after</dt>   <dd>The character of indicator shown when completions may be scrolled up</dd>
+            <dt>CompLess</dt>          <dd>The indicator shown when completions may be scrolled up</dd>
+            <dt>CompMore::after</dt>   <dd>The character of indicator shown when completions may be scrolled down</dd>
+            <dt>CompMore</dt>          <dd>The indicator shown when completions may be scrolled down</dd>
+            <dt>CompMsg</dt>           <dd>The message which may appear at the top of a group of completion results</dd>
+            <dt>CompResult</dt>        <dd>The result column of the completion list</dd>
+            <dt>CompTitle</dt>         <dd>Completion row titles</dd>
+            <dt>CompTitleSep</dt>      <dd>The element which separates the completion title from its results</dd>
+            <dt>Disabled</dt>          <dd>Text indicating disabled status, such as of an extension or style group</dd>
+            <dt>Enabled</dt>           <dd>Text indicating enabled status, such as of an extension or style group</dd>
+            <dt>ErrorMsg</dt>          <dd>Error messages</dd>
+            <dt>Filter</dt>            <dd>The matching text in a completion list</dd>
+            <dt>FrameIndicator</dt>    <dd>The indicator shown when a new frame is selected</dd>
+            <dt>Function</dt>          <dd>A JavaScript Function object</dd>
+            <dt>Hint</dt>              <dd>A hint indicator. See <ex>:help hints</ex></dd>
+            <dt>HintActive</dt>        <dd>The hint element of link which will be followed by <k name="CR"/></dd>
+            <dt>HintElem</dt>          <dd>The hintable element</dd>
+            <dt>HintImage</dt>         <dd>The indicator which floats above hinted images</dd>
+            <dt>Indicator</dt>         <dd>The <em>#</em> and  <em>%</em> in the <ex>:buffers</ex> list</dd>
+            <dt>InfoMsg</dt>           <dd>Information messages</dd>
+            <dt>Key</dt>               <dd>Generally a keyword used in syntax highlighting.</dd>
+            <dt>Keyword</dt>           <dd>A bookmark keyword for a URL</dd>
+            <dt>LineNr</dt>            <dd>The line number of an error</dd>
+            <dt>Message</dt>           <dd>A message as displayed in <ex>:messages</ex></dd>
+            <dt>ModeMsg</dt>           <dd>The mode indicator in the command line</dd>
+            <dt>MoreMsg</dt>           <dd>The indicator that there is more text to view</dd>
+            <dt>NonText</dt>           <dd>The <em>~</em> indicators which mark blank lines in the completion list</dd>
+            <dt>Normal</dt>            <dd>Normal text in the command line</dd>
+            <dt>Null</dt>              <dd>A JavaScript Null object</dd>
+            <dt>Number</dt>            <dd>A JavaScript Number object</dd>
+            <dt>Object</dt>            <dd>A JavaScript Object</dd>
+            <dt>Preview</dt>           <dd>The completion preview displayed in the &tag.command-line;</dd>
+            <dt>Question</dt>          <dd>A prompt for a decision</dd>
+            <dt>StatusLine</dt>        <dd>The status bar</dd>
+            <dt>StatusLineNormal</dt>  <dd>The status bar for an ordinary web page</dd>
+            <dt>StatusLineBroken</dt>  <dd>The status bar for a broken web page</dd>
+            <dt>StatusLineExtended</dt><dd>The status bar for a secure web page with an Extended Validation (EV) certificate</dd>
+            <dt>StatusLineSecure</dt>  <dd>The status bar for a secure web page</dd>
+            <dt>String</dt>            <dd>A JavaScript String object</dd>
+            <dt>TabClose</dt>          <dd>The close button of a browser tab</dd>
+            <dt>TabIcon</dt>           <dd>The icon of a browser tab</dd>
+            <dt>TabIconNumber</dt>     <dd>The number of a browser tab, over its icon</dd>
+            <dt>TabNumber</dt>         <dd>The number of a browser tab, next to its icon</dd>
+            <dt>TabText</dt>           <dd>The text of a browser tab</dd>
+            <dt>Tag</dt>               <dd>A bookmark tag for a URL</dd>
+            <dt>Title</dt>             <dd>The title of a listing, including <ex>:pageinfo</ex>, <ex>:jumps</ex></dd>
+            <dt>URL</dt>               <dd>A URL</dd>
+            <dt>WarningMsg</dt>        <dd>A warning message</dd>
+        </dl>
+
+        <p>
+            The help system also has a comprehensive set of styling groups
+            which are not explained here, but many of which are described
+            along with <t>writing-docs</t>.
+        </p>
+
+        <p>
+            Every invocation completely replaces the styling of any previous
+            invocation, unless <em>-append</em> (short name <em>-a</em>) is
+            provided, in which case <a>css</a> is appended to its current
+            value. If <a>css</a> is not provided, any styles beginning with
+            <a>group</a> are listed.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:highlight-clear</tags>
+    <spec>:hi<oa>ghlight</oa> clear <oa>group</oa></spec>
+    <description>
+        <p>
+            Reset the highlighting for <oa>group</oa> to its default value. If
+            <oa>group</oa> is not given, reset all highlighting groups.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:sty :style</tags>
+    <spec>:sty<oa>le</oa> <oa>-name=<a>name</a></oa> <oa>-append</oa> <a>filter</a> <oa>css</oa></spec>
+    <description>
+        <p>
+            Add CSS styles to the browser or to web pages. <a>filter</a> is a
+            comma-separated list of <t>site-filters</t> for which the style will
+            apply. Regular expression filters may not be used and the <tt>!</tt>
+            character may not be used to invert the sense of the match.
+            <oa>css</oa> is a full CSS rule set (e.g., <tt>body { color: blue; }</tt>).
+        </p>
+
+        <p>The following options are available:</p>
+        <dl>
+            <dt>-append</dt>
+            <dd>If provided along with <em>-name</em>, <oa>css</oa> and
+                <a>filter</a> are appended to its current value. (short name <em>-a</em>)</dd>
+
+            <dt>-agent</dt>
+            <dd>If provided, the style is installed as an Agent sheet, which
+                applies to contents user interface widgets as well as normal
+                elements. (short name <em>-A</em>)</dd>
+
+            <dt>-group=<a>group</a></dt>
+            <dd>The <t>group</t> to which to add this style. Please note that
+                this grouping is for semantic and cleanup purposes only. No
+                additional site filtering is applied.</dd>
+
+            <dt>-name=<a>name</a></dt>
+            <dd>If provided, any existing style with the same name is
+                overridden, and the style may later be deleted using
+                <a>name</a>. (short name <em>-n</em>)</dd>
+
+        </dl>
+
+        <p>If <oa>css</oa> isn't provided, matching styles are listed.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:dels :delstyle</tags>
+    <strut/>
+    <spec>:dels<oa>tyle</oa><oa>!</oa> <oa>-name=<a>name</a></oa> <oa>-index=<a>index</a></oa> <oa>filter</oa> <oa>css</oa></spec>
+    <description>
+        <p>
+            Delete any matching styles. With <oa>!</oa>, delete all styles.
+            If <oa>filter</oa> is provided, only matching elements of the
+            filter are disabled. For instance, when run with a filter
+            <str delim="">mozilla.org</str>, an existing style with a filter
+            <str delim="">www.google.com,mozilla.org</str>, will result in a style for
+            <str delim="">www.google.com</str>.
+        </p>
+
+        <p>The available options are:</p>
+
+        <dl>
+            <dt>-name</dt>  <dd>The name provided to <ex>:style</ex> (short name <em>-n</em>)</dd>
+            <dt>-index</dt> <dd>For unnamed styles, the index listed by <ex>:style</ex>
+                (short name <em>-i</em>)</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>:styleenable :stylee</tags>
+    <tags>:styenable :stye</tags>
+    <strut/>
+    <spec>:styled<oa>isable</oa><oa>!</oa> <oa>-name=<a>name</a></oa> <oa>-index=<a>index</a></oa> <oa>filter</oa> <oa>css</oa></spec>
+    <description>
+        <p>Enable any matching styles. With <oa>!</oa>, enable all styles. Arguments are the same as for <ex>:delstyle</ex></p>
+    </description>
+</item>
+
+<item>
+    <tags>:styledisable :styled</tags>
+    <tags>:stydisable :styd</tags>
+    <strut/>
+    <spec>:stylee<oa>nable</oa><oa>!</oa> <oa>-name=<a>name</a></oa> <oa>-index=<a>index</a></oa> <oa>filter</oa> <oa>css</oa></spec>
+    <description>
+        <p>Disable any matching styles. With <oa>!</oa>, disable all styles. Arguments are the same as for <ex>:delstyle</ex></p>
+    </description>
+</item>
+
+<item>
+    <tags>:styletoggle :stylet</tags>
+    <tags>:stytoggle :styt</tags>
+    <strut/>
+    <spec>:stylet<oa>oggle</oa><oa>!</oa> <oa>-name=<a>name</a></oa> <oa>-index=<a>index</a></oa> <oa>filter</oa> <oa>css</oa></spec>
+    <description>
+        <p>Toggle any matching styles. With <oa>!</oa>, toggle all styles. Arguments are the same as for <ex>:delstyle</ex></p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/tabs.xml b/common/locale/en-US/tabs.xml
new file mode 100644 (file)
index 0000000..d894020
--- /dev/null
@@ -0,0 +1,386 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="tabs"
+    title="&dactyl.appName; Tabs"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="tabs buffers">Tabs and buffers</h1>
+<toc start="2"/>
+
+<p>
+    Tabs allow you to keep many web pages open at the same time and to quickly
+    switch between them. Each tab contains exactly one buffer. If your version
+    of &dactyl.host; supports tab groups, only buffers in the active group
+    will be attached to visible tabs. Although not all buffers are always
+    attached to visible tabs, they are all always accessible via the
+    buffer-centric commands and key bindings. Tab-centric commands and key
+    bindings, however, only operate on the set of visible tabs.
+</p>
+
+<p>
+
+</p>
+
+<h2 tag="listing-tabs">Listing tabs</h2>
+
+<item>
+    <tags>B :tabs :ls :files :buffers</tags>
+    <spec>:buffers <oa>filter</oa></spec>
+    <spec>B</spec>
+    <description>
+        <p>
+            Show a list of buffers matching <oa>filter</oa>. Buffers are
+            listed according to their tab groups, whether they are visible or
+            not.
+        </p>
+
+        <p>A buffer may be marked with one of the following indicators:</p>
+
+        <dl dt="width: 6em;">
+            <dt><hl key="Indicator">%</hl></dt><dd>The current buffer</dd>
+            <dt><hl key="Indicator">#</hl></dt><dd>The alternate buffer for <ex>:buffer #</ex> and <k name="C-^"/></dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>:keepa :keepalt</tags>
+    <spec>:keepa<oa>lt</oa> <a>cmd</a></spec>
+    <description>
+        <p>Execute a command without changing the current alternate buffer.</p>
+    </description>
+</item>
+
+<h2 tag="opening-tabs">Opening tabs</h2>
+
+<item>
+    <tags>:tab</tags>
+    <strut/>
+    <spec>:tab <a>cmd</a></spec>
+    <description>
+        <p>
+            Execute <a>cmd</a>, but open any new pages in a new tab rather
+            than the currently focused tab.
+        </p>
+    </description>
+</item>
+
+<item>
+    <!-- TODO: move this somewhere more appropriate -->
+    <tags>:window :wind</tags>
+    <spec>:wind<oa>ow</oa> <a>cmd</a></spec>
+    <description>
+        <p>
+            Execute <a>cmd</a>, but open any new pages in a new window rather
+            than the currently focused tab.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:tabdu :tabduplicate</tags>
+    <spec>:<oa>count</oa>tabdu<oa>plicate</oa></spec>
+    <description>
+        <p>
+            Duplicate the current tab and focus the duplicate. If
+            <oa>count</oa> is given, duplicate the tab <oa>count</oa> times.
+        </p>
+    </description>
+</item>
+
+<!-- TODO: should the tab commands be moved back here? -->
+<p>
+    See <t>opening</t> for other ways to open new tabs.
+</p>
+
+<h2 tag="changing-tabs">Changing tabs</h2>
+
+<item>
+    <tags>gb</tags>
+    <strut/>
+    <spec><oa>count</oa>gb</spec>
+    <description>
+        <p>
+            Repeat last <ex>:buffer<oa>!</oa></ex> command. This is useful to quickly jump between
+            buffers which have a similar URL or title.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gB</tags>
+    <strut/>
+    <spec><oa>count</oa>gB</spec>
+    <description>
+        <p>
+            Repeat last <ex>:buffer<oa>!</oa></ex> command in the reverse direction.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>gt</tags>
+    <strut/>
+    <spec><oa>count</oa>gt</spec>
+    <description>
+        <p>
+            Go to the next tab. Cycles to the first tab when the last one is selected.
+            If <oa>count</oa> is specified, go to the <oa>count</oa>th tab.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<C-PageDown> <C-Tab> <C-n>]]></tags>
+    <strut/>
+    <spec><oa>count</oa>&lt;C-n></spec>
+    <description>
+        <p>
+            Go to the next tab. Cycles to the first tab when the last one is selected.
+            If <oa>count</oa> is specified, go to the <oa>count</oa>th next tab.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<C-PageUp> <C-S-Tab> <C-p> gT]]></tags>
+    <strut/>
+    <spec><oa>count</oa>gT</spec>
+    <description>
+        <p>
+            Go to the previous tab. Cycles to the last tab when the first one is selected.
+            If <oa>count</oa> is specified, go to the <oa>count</oa>th previous tab.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:tabn :tabnext</tags>
+    <spec>:<oa>count</oa>tabn<oa>ext</oa> <oa>count</oa></spec>
+    <tags>:tn :tnext</tags>
+    <spec>:<oa>count</oa>tn<oa>ext</oa> <oa>count</oa></spec>
+    <tags>:bn :bnext</tags>
+    <spec>:<oa>count</oa>bn<oa>ext</oa> <oa>count</oa></spec>
+    <description>
+        <p>
+            Switch to the next or <oa>count</oa>th tab. Cycles to the first tab when
+            the last one is selected and <oa>count</oa> is not specified.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:bN :bNext :bp :bprevious</tags>
+    <spec>:<oa>count</oa>bp<oa>revious</oa> <oa>count</oa></spec>
+    <spec>:<oa>count</oa>bN<oa>ext</oa> <oa>count</oa></spec>
+    <tags>:tN :tNext :tabN :tabNext</tags>
+    <spec>:<oa>count</oa>tabN<oa>ext</oa> <oa>count</oa></spec>
+    <tags>:tp :tprevious :tabp :tabprevious</tags>
+    <spec>:<oa>count</oa>tabp<oa>revious</oa> <oa>count</oa></spec>
+    <spec>:<oa>count</oa>tp<oa>revious</oa> <oa>count</oa></spec>
+    <description>
+        <p>
+            Switch to the previous tab or go <oa>count</oa> tabs back. Wraps around from the
+            first tab to the last tab.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<C-6> <C-^>]]></tags>
+    <spec><oa>count</oa>&lt;C-^></spec>
+    <description>
+        <p>
+            Select the previously selected tab. This provides a quick method of toggling
+            between two tabs. If <oa>count</oa> is specified, go to the <oa>count</oa>th tab.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>b :b :buffer</tags>
+    <spec>:<oa>count</oa>b<oa>uffer</oa><oa>!</oa> <oa>url|index</oa></spec>
+    <spec><oa>count</oa>b</spec>
+    <description>
+        <p>
+            Go to the specified buffer from the buffer list. Argument can be
+            either the buffer index or the full URL opened in an existing
+            buffer. If <oa>count</oa> is given, go to the <oa>count</oa>th
+            buffer.
+        </p>
+
+        <p>
+            If argument is neither a full URL nor an index but uniquely identifies a
+            buffer, it is selected. With <oa>!</oa> the next buffer matching the argument is
+            selected, even if it cannot be identified uniquely. Use <k>b</k> as a
+            shortcut to open this prompt.
+        </p>
+
+        <p>If argument is <em>#</em>, the alternate buffer will be selected (see <k name="C-^"/>).</p>
+
+        <p>If no argument is given, the current buffer remains current.</p>
+    </description>
+</item>
+
+<item>
+    <tags>g^ g0</tags>
+    <spec>g0</spec>
+    <spec>g^</spec>
+
+    <tags>:bf :bfirst :br :brewind</tags>
+    <spec>:br<oa>ewind</oa></spec>
+    <spec>:bf<oa>irst</oa></spec>
+
+    <tags>:tabfir :tabfirst :tabr :tabrewind</tags>
+    <spec>:tabr<oa>ewind</oa></spec>
+    <spec>:tabfir<oa>st</oa></spec>
+    <description>
+        <p>Switch to the first tab.</p>
+    </description>
+</item>
+
+<item>
+    <tags>g$ :bl :blast :tabl :tablast</tags>
+    <spec>:tabl<oa>ast</oa></spec>
+    <spec>:bl<oa>ast</oa></spec>
+    <spec>g$</spec>
+    <description>
+        <p>Switch to the last tab.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:tabde :tabdetach</tags>
+    <strut/>
+    <spec>:tabde<oa>tach</oa></spec>
+    <description>
+        <p>
+            Detach the current tab, and open it in its own window. As each
+            window must contain at least one buffer, it is not possible to detach
+            the last buffer in a window.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:taba :tabattach</tags>
+    <spec>:taba<oa>ttach</oa> <a>window-index</a> <oa>buffer-index</oa></spec>
+    <description>
+        <p>
+            Attach the current tab to another window. <a>window-index</a> is
+            an index into the list of open windows and <oa>buffer-index</oa>
+            is the index at which to insert the tab in the other window's
+            buffer list. If this is the last buffer in a window, the window
+            will be closed.
+        </p>
+    </description>
+</item>
+
+<h2 tag="reordering-tabs">Reordering tabs</h2>
+
+<item>
+    <tags>:tabm :tabmove</tags>
+    <spec>:tabm<oa>ove</oa> <oa>N</oa></spec>
+    <spec>:tabm<oa>ove</oa><oa>!</oa> <oa>+N</oa> | <oa>-N</oa></spec>
+    <description>
+        <p>
+            Move the current tab to a position after tab <oa>N</oa>. When <oa>N</oa> is 0, the
+            current tab is made the first one. Without <oa>N</oa> the current tab is made the
+            last one. <oa>N</oa> can also be prefixed with ‘+’ or ‘-’ to indicate a relative
+            movement. If <oa>!</oa> is specified the movement wraps around the start or end of the
+            tab list.
+        </p>
+    </description>
+</item>
+
+<h2 tag="closing-tabs">Closing tabs</h2>
+
+<item>
+    <tags>d</tags>
+    <tags>:tabc :tabclose</tags>
+    <tags>:bun :bunload :bw :bwipeout :bd :bdelete</tags>
+    <spec>:<oa>count</oa>bd<oa>elete</oa><oa>!</oa> <oa>arg</oa></spec>
+    <spec>:<oa>count</oa>bun<oa>load</oa><oa>!</oa> <oa>arg</oa></spec>
+    <spec>:<oa>count</oa>bw<oa>ipeout</oa><oa>!</oa> <oa>arg</oa></spec>
+    <spec>:<oa>count</oa>tabc<oa>lose</oa><oa>!</oa> <oa>arg</oa></spec>
+    <spec><oa>count</oa>d</spec>
+    <description>
+        <p>
+            Delete current buffer. If <oa>count</oa> is specified then <oa>count</oa> tabs are
+            removed. Afterwards, the tab to the right of the deleted tabs is selected.
+        </p>
+
+        <p>
+            When used with <oa>arg</oa>, remove all tabs which contain <oa>arg</oa> in the
+            currently opened hostname. With <oa>!</oa>, remove all tabs for which
+            the currently opened page's URL or title contains <oa>arg</oa>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>D</tags>
+    <strut/>
+    <spec><oa>count</oa>D</spec>
+    <description>
+        <p>Like <k>d</k> but selects the tab to the left of the deleted tabs after deletion.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:tabo :tabonly</tags>
+    <strut/>
+    <spec>:tabo<oa>nly</oa></spec>
+    <description>
+        <p>Close all buffers other than the currently visible tab.</p>
+    </description>
+</item>
+
+<item>
+    <tags>u :u :undo</tags>
+    <spec>:<oa>count</oa>u<oa>ndo</oa> <oa>url</oa></spec>
+    <spec><oa>count</oa>u</spec>
+    <description>
+        <p>
+            Restore a closed tab. If a <oa>count</oa> is given, restore the
+            <oa>count</oa>th closed tab. With <oa>url</oa>, restores most
+            recently closed tab with a matching URL.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:undoa :undoall</tags>
+    <spec>:undoa<oa>ll</oa></spec>
+    <description short="true">
+        <p>Restore all closed tabs in the closed tabs list.</p>
+    </description>
+</item>
+
+<h2 tag="looping-over-tabs">Looping over tabs</h2>
+
+<item>
+    <tags>:tabd :tabdo :bufd :bufdo</tags>
+    <spec>:tabd<oa>o</oa> <a>cmd</a></spec>
+    <description>
+        <p>
+            Execute <a>cmd</a> once in each buffer. Each buffer is focused, in
+            turn, and <a>cmd</a> is executed therein. The last buffer remains
+            focused after execution.
+        </p>
+
+        <p>
+            <a>cmd</a> should not alter the tab list state by adding, removing or reordering
+            tabs.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/locale/en-US/various.xml b/common/locale/en-US/various.xml
new file mode 100644 (file)
index 0000000..78ade2e
--- /dev/null
@@ -0,0 +1,272 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="various"
+    title="&dactyl.appName; Other"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1>Other help</h1>
+<toc start="2"/>
+
+<h2 tag="various">Various commands</h2>
+
+<item>
+    <tags>:comp :completions</tags>
+    <spec>:comp<oa>letions</oa> <a>ex-command</a></spec>
+    <description>
+        <p>
+            List the completion results for a given command substring.
+        </p>
+        <example><ex>:completions :set! <str delim="">browser.tabs.</str></ex></example>
+    </description>
+</item>
+
+<item>
+    <tags>:contexts</tags>
+    <spec>:contexts <a>ex-command</a></spec>
+    <description>
+        <p>
+            Lists the completion contexts used during the completion of its
+            arguments. These context names may be used to tune the function of
+            the completion system via options like <o>autocomplete</o> and
+            <o>wildcase</o>. Note that completion must be triggered in order
+            for this command to be effective, so if auto-completion is not
+            active, you'll need to press the <k name="Tab" mode="c"/> key at
+            least once. You should also be aware that this command is only
+            useful from the command line.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:fk :feedkeys</tags>
+    <spec>feedkeys<oa>!</oa> <a>keys</a></spec>
+    <description>
+        <p>
+            Fake key events. If <oa>!</oa> is given, key remappings are
+            ignored, similarly to the <t>:map</t> command's <em>-builtin</em>
+            option.
+        </p>
+
+        <dl>
+            <dt>-mode</dt> <dd>The mode in which to feed the keys (short name <em>-m</em>)</dd>
+        </dl>
+    </description>
+</item>
+
+<item>
+    <tags>:norm :normal</tags>
+    <spec>:norm<oa>al</oa><oa>!</oa> <a>keys</a></spec>
+    <description>
+        <p>
+            Execute key mappings for <a>keys</a> as if they were typed in
+            Normal mode. If <oa>!</oa> is provided, only builtin key mappings
+            are executed. This makes it possible to fake Normal mode key
+            presses from scripts, key mappings, autocommands, and the command
+            line.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:mks :mksyntax</tags>
+    <spec>:mks<oa>yntax</oa><oa>!</oa> <oa>path</oa></spec>
+    <description>
+        <p>
+            Generate a Vim syntax file. If <oa>path</oa> is not given, the local
+            Vim runtime path is guessed. If <oa>path</oa> is a directory, the
+            file <str delim="">&dactyl.name;.vim</str> in that directory is
+            used. An existing file will never be overwritten unless
+            <oa>bang</oa> is given.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<C-l> CTRL-L :redr :redraw]]></tags>
+    <strut/>
+    <spec>:redr<oa>aw</oa></spec>
+    <description>
+        <p>Redraws the screen. Useful for updating the screen during the execution of a script or function.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:run :! :!cmd</tags>
+    <strut/>
+    <spec>:!<a>cmd</a></spec>
+    <description>
+        <p>
+            Run an external command. Runs <a>cmd</a> through system()
+            and displays its output. Any ‘!’ in <a>cmd</a> is replaced with
+            the previous external command, so long as it is not preceded by a
+            backslash and <o>banghist</o> is enabled.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:!!</tags>
+    <spec>:!!</spec>
+    <description short="true">
+        <p>Repeat last <ex>:!<a>cmd</a></ex>.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:sil :silent</tags>
+    <spec>:sil<oa>ent</oa> <a>command</a></spec>
+    <description>
+        <p>
+            Execute a command silently. Normal messages and error messages
+            generated by the command invocation will not be displayed and will
+            not be added to the message history.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:verb :verbose</tags>
+    <spec>:<oa>count</oa>verb<oa>ose</oa> <a>command</a></spec>
+    <description>
+        <p>
+            Execute a command with <o>verbose</o> set to <oa>count</oa>. If
+            <oa>count</oa> is not specified then 1 is used.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:ve :version</tags>
+    <strut/>
+    <spec>:ve<oa>rsion</oa><oa>!</oa></spec>
+    <description>
+        <p>
+            Print &dactyl.appName; and &dactyl.host; version information. When
+            <oa>!</oa> is provided, show the &dactyl.host; version page.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:yank :y</tags>
+    <spec>:y[ank] :<a>cmd</a></spec>
+    <spec>:y[ank] <a>js</a></spec>
+    <description>
+        <p>
+            Yanks the output of the given Ex command <a>cmd</a> or JavaScript <a>js</a> to the clipboard.
+        </p>
+    </description>
+</item>
+
+<h2 tag="online-help">Online help</h2>
+
+<item>
+    <tags><![CDATA[<F1> :help :h help]]></tags>
+    <spec>:h<oa>elp</oa> <oa>subject</oa></spec>
+    <spec>&lt;F1></spec>
+    <description>
+        <p>
+            Open a help page for <oa>subject</oa>. If <oa>subject</oa> is
+            omitted, open the default page as specified in <o>helpfile</o>.
+            If you're not sure of the exact topic you need help with, try
+            <k name="Tab" mode="c"/> completion or <ex>:help overview</ex>.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<A-F1> :helpall :helpa help-all]]></tags>
+    <spec>:helpa<oa>ll</oa> <oa>subject</oa></spec>
+    <spec>&lt;A-F1></spec>
+    <description>
+        <p>
+            Open all help pages consolidated into a single page and scroll to
+            <oa>subject</oa> if given.
+        </p>
+
+        <p>See <ex>:help</ex>.</p>
+    </description>
+</item>
+
+<item>
+    <tags>:lc :listcommands</tags>
+    <spec>:listc<oa>ommands</oa> <oa>filter</oa></spec>
+    <spec>:lc <oa>filter</oa></spec>
+    <description>
+        <p>
+            List all Ex commands matching <oa>filter</oa> along with their
+            short descriptions. The output contains links to the source code
+            definitions.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:lk :listkeys</tags>
+    <spec>:listk<oa>eys</oa> <oa>-mode <a>mode</a></oa> <oa>filter</oa></spec>
+    <spec>:lk <oa>-mode <a>mode</a></oa> <oa>filter</oa></spec>
+    <description>
+        <p>
+            List the key mappings defined for <a>mode</a> (Normal by default)
+            matching <oa>filter</oa> along with their short descriptions. The
+            output contains links to the source code definitions.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:lo :listoptions</tags>
+    <spec>:listo<oa>ptions</oa> <oa>filter</oa></spec>
+    <spec>:lo <oa>filter</oa></spec>
+    <description>
+        <p>
+            List all options matching <oa>filter</oa> along with their short
+            descriptions. The output contains links to the source code
+            definitions.
+        </p>
+    </description>
+</item>
+
+<tags>42</tags>
+
+<p>
+    What is the meaning of life, the universe and everything? Douglas Adams,
+    the only person who knew what this question really was about is now dead,
+    unfortunately. So now you might wonder what the meaning of death is...
+</p>
+
+<h2 tag="uncategorized">Uncategorized help</h2>
+
+<item>
+    <tags><![CDATA[<C-[> <Esc>]]></tags>
+    <strut/>
+    <spec>&lt;Esc></spec>
+    <description>
+        <p>
+            Exits Command-line, Insert, or Hints mode and returns to
+            Normal mode. Focuses the content web page.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[<Insert> i]]></tags>
+    <strut/>
+    <spec>i</spec>
+    <description>
+        <p>
+            Start Caret mode. This mode resembles the Vim's Normal mode where
+            the text cursor is visible on the web page. The <k link="false">v</k> key
+            enters visual mode, where text is selected as the cursor moves.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/common/make_jar.sh b/common/make_jar.sh
new file mode 100644 (file)
index 0000000..a84aad8
--- /dev/null
@@ -0,0 +1,99 @@
+#!/bin/sh
+set -e
+
+fromrepo=
+if [ "$1" = -r ]; then shift; fromrepo=1; fi
+
+top=$(pwd)
+jar=$1
+bases=$2
+dirs=$3
+text=$4
+bin=$5
+shift 5;
+files="$@"
+HG=${HG:-hg}
+
+stage="$top/${jar%.*}"
+mkdir -p "$stage"
+
+sed=$(which sed)
+if [ "xoo" = x$(echo foo | sed -E 's/f(o)/\1/' 2>/dev/null) ]
+then sed() { $sed -E "$@"; }
+else sed() { $sed -r "$@"; }
+fi
+
+if test -n "$fromrepo" && $HG root >/dev/null 2>&1
+then
+    root="$($HG root)"
+    which cygpath >/dev/null 2>&1 && root=$(cygpath $root)
+
+    mf="$($HG --config ui.debug=false --config ui.verbose=false manifest)"
+    find=$(which find)
+    find() {
+        echo "$mf" | sed -n "s!$(pwd | sed "s!$root/?!!")/?!!p" |
+            grep "^$1"
+        exit 1
+    }
+fi
+
+getfiles() {
+    filter="\.($(echo $1 | tr ' ' '|'))$"; shift
+    find "$@" -not -path '*\.hg*' 2>/dev/null | grep -E "$filter" || true
+}
+copytext() {
+    sed -e "s,@VERSION@,$VERSION,g" \
+        -e "s,@DATE@,$BUILD_DATE,g" \
+        <"$1" >"$2"
+    cmp -s -- "$1" "$2" ||
+    ( echo "modified: $1"; diff -u -- "$1" "$2" | grep '^[-+][^-+]' )
+}
+
+[ -f "$jar" ] && rm -f "$jar"
+case "$jar" in
+    /*) ;;
+    *)
+        [ -d "$jar" ] && rm -rf "$jar"
+        jar="$top/$jar";;
+esac
+
+for base in $bases
+do
+    (
+        set -e
+        cd $base
+        [ ${jar##*.} != xpi ] && stage="$stage/${base##*/}"
+        for dir in $dirs
+        do
+            for f in $(getfiles "$bin" "$dir")
+            do
+                mkdir -p "$stage/${f%/*}"
+                cp -- $f "$stage/$f"
+            done
+            for f in $(getfiles "$text" "$dir")
+            do
+                mkdir -p "$stage/${f%/*}"
+                copytext "$f" "$stage/$f"
+            done
+        done
+        for f in $files
+        do
+            [ -f "$f" ] && copytext "$f" "$stage/$f"
+        done
+       true
+    ) || exit 1
+done
+
+(
+    set -e;
+    cd "$stage";
+    case "$jar" in
+    (*/) if [ "$stage" != "$jar" ]; then mv -- * "$jar"; fi;;
+    (*)  zip -9r "$jar" -- *;;
+    esac
+) || exit 1
+
+[ "$stage" != "$jar" ] && rm -rf "$stage"
+true
+
+# vim:se ft=sh sts=4 sw=4 et:
diff --git a/common/modules/addons.jsm b/common/modules/addons.jsm
new file mode 100644 (file)
index 0000000..03c4e0f
--- /dev/null
@@ -0,0 +1,608 @@
+// Copyright (c) 2009-2011 by Kris Maglione <maglione.k@gmail.com>
+// Copyright (c) 2009-2010 by Doug Kearns <dougkearns@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("addons", {
+    exports: ["AddonManager", "Addons", "Addon", "addons"],
+    require: ["services"],
+    use: ["completion", "config", "io", "messages", "prefs", "template", "util"]
+}, this);
+
+var callResult = function callResult(method) {
+    let args = Array.slice(arguments, 1);
+    return function (result) { result[method].apply(result, args); };
+}
+
+var listener = function listener(action, event)
+    function addonListener(install) {
+        this.dactyl[install.error ? "echoerr" : "echomsg"](
+            "Add-on " + action + " " + event + ": " + (install.name || install.sourceURI.spec) +
+            (install.error ? ": " + addonErrors[install.error] : ""));
+    }
+
+var AddonListener = Class("AddonListener", {
+    init: function init(modules) {
+        this.dactyl = modules.dactyl;
+    },
+
+    onNewInstall:        function (install) {},
+    onExternalInstall:   function (addon, existingAddon, needsRestart) {},
+    onDownloadStarted:   listener("download", "started"),
+    onDownloadEnded:     listener("download", "complete"),
+    onDownloadCancelled: listener("download", "canceled"),
+    onDownloadFailed:    listener("download", "failed"),
+    onDownloadProgress:  function (install) {},
+    onInstallStarted:    function (install) {},
+    onInstallEnded:      listener("installation", "complete"),
+    onInstallCancelled:  listener("installation", "canceled"),
+    onInstallFailed:     listener("installation", "failed")
+});
+
+var updateAddons = Class("UpgradeListener", AddonListener, {
+    init: function init(addons, modules) {
+        init.supercall(this, modules);
+
+        util.assert(!addons.length || addons[0].findUpdates,
+                    _("error.unavailable", config.host, services.runtime.version));
+
+        this.remaining = addons;
+        this.upgrade = [];
+        this.dactyl.echomsg(_("addon.check", addons.map(function (a) a.name).join(", ")));
+        for (let addon in values(addons))
+            addon.findUpdates(this, AddonManager.UPDATE_WHEN_USER_REQUESTED, null, null);
+
+    },
+    onUpdateAvailable: function (addon, install) {
+        this.upgrade.push(addon);
+        install.addListener(this);
+        install.install();
+    },
+    onUpdateFinished: function (addon, error) {
+        this.remaining = this.remaining.filter(function (a) a != addon);
+        if (!this.remaining.length)
+            this.dactyl.echomsg(
+                this.upgrade.length
+                    ? "Installing updates for addons: " + this.upgrade.map(function (i) i.name).join(", ")
+                    : "No addon updates found");
+    }
+});
+
+var actions = {
+    delete: {
+        name: "extde[lete]",
+        description: "Uninstall an extension",
+        action: callResult("uninstall"),
+        perm: "uninstall"
+    },
+    enable: {
+        name: "exte[nable]",
+        description: "Enable an extension",
+        action: function (addon) { addon.userDisabled = false; },
+        filter: function ({ item }) item.userDisabled,
+        perm: "enable"
+    },
+    disable: {
+        name: "extd[isable]",
+        description: "Disable an extension",
+        action: function (addon) { addon.userDisabled = true; },
+        filter: function ({ item }) !item.userDisabled,
+        perm: "disable"
+    },
+    options: {
+        name: ["exto[ptions]", "extp[references]"],
+        description: "Open an extension's preference dialog",
+        bang: true,
+        action: function (addon, bang) {
+            if (bang)
+                this.window.openDialog(addon.optionsURL, "_blank", "chrome");
+            else
+                this.dactyl.open(addon.optionsURL, { from: "extoptions" });
+        },
+        filter: function ({ item }) item.isActive && item.optionsURL
+    },
+    rehash: {
+        name: "extr[ehash]",
+        description: "Reload an extension",
+        action: function (addon) {
+            util.assert(util.haveGecko("2b"), _("error.notUseful", config.host));
+            util.timeout(function () {
+                addon.userDisabled = true;
+                addon.userDisabled = false;
+            });
+        },
+        get filter() {
+            let ids = set(keys(JSON.parse(prefs.get("extensions.bootstrappedAddons", "{}"))));
+            return function ({ item }) !item.userDisabled && set.has(ids, item.id);
+        },
+        perm: "disable"
+    },
+    toggle: {
+        name: "extt[oggle]",
+        description: "Toggle an extension's enabled status",
+        action: function (addon) { addon.userDisabled = !addon.userDisabled; }
+    },
+    update: {
+        name: "extu[pdate]",
+        description: "Update an extension",
+        actions: updateAddons,
+        perm: "upgrade"
+    }
+};
+
+var Addon = Class("Addon", {
+    init: function init(addon, list) {
+        this.addon = addon;
+        this.instance = this;
+        this.list = list;
+
+        this.nodes = {
+            commandTarget: this
+        };
+        XML.ignoreWhitespace = true;
+        util.xmlToDom(
+            <tr highlight="Addon" key="row" xmlns:dactyl={NS} xmlns={XHTML}>
+                <td highlight="AddonName" key="name"/>
+                <td highlight="AddonVersion" key="version"/>
+                <td highlight="AddonStatus" key="status"/>
+                <td highlight="AddonButtons Buttons">
+                    <a highlight="Button" key="enable">On&#xa0;</a>
+                    <a highlight="Button" key="disable">Off</a>
+                    <a highlight="Button" key="delete">Del</a>
+                    <a highlight="Button" key="update">Upd</a>
+                    <a highlight="Button" key="options">Opt</a>
+                </td>
+                <td highlight="AddonDescription" key="description"/>
+            </tr>,
+            this.list.document, this.nodes);
+
+        this.update();
+    },
+
+    commandAllowed: function commandAllowed(cmd) {
+        util.assert(set.has(actions, cmd), "Unknown command");
+
+        let action = actions[cmd];
+        if ("perm" in action && !(this.permissions & AddonManager["PERM_CAN_" + action.perm.toUpperCase()]))
+            return false;
+        if ("filter" in action && !action.filter({ item: this }))
+            return false;
+        return true;
+    },
+
+    command: function command(cmd) {
+        util.assert(this.commandAllowed(cmd), "Command not allowed");
+
+        let action = actions[cmd];
+        if (action.action)
+            action.action.call(this.list.modules, this, true);
+        else
+            action.actions([this], this.list.modules);
+    },
+
+    compare: function compare(other) String.localeCompare(this.name, other.name),
+
+    get statusInfo() {
+        XML.ignoreWhitespace = XML.prettyPrinting = false;
+        default xml namespace = XHTML;
+
+        let info = this.isActive ? <span highlight="Enabled">enabled</span>
+                                 : <span highlight="Disabled">disabled</span>;
+
+        let pending;
+        if (this.pendingOperations & AddonManager.PENDING_UNINSTALL)
+            pending = ["Disabled", "uninstalled"];
+        else if (this.pendingOperations & AddonManager.PENDING_DISABLE)
+            pending = ["Disabled", "disabled"];
+        else if (this.pendingOperations & AddonManager.PENDING_INSTALL)
+            pending = ["Enabled", "installed"];
+        else if (this.pendingOperations & AddonManager.PENDING_ENABLE)
+            pending = ["Enabled", "enabled"];
+        else if (this.pendingOperations & AddonManager.PENDING_UPGRADE)
+            pending = ["Enabled", "upgraded"];
+        if (pending)
+            return <>{info}&#xa0;(<span highlight={pending[0]}>{pending[1]}</span>
+                                  &#xa0;on <a href="#" dactyl:command="dactyl.restart" xmlns:dactyl={NS}>restart</a>)</>;
+        return info;
+    },
+
+    update: function callee() {
+        let self = this;
+        function update(key, xml) {
+            let node = self.nodes[key];
+            while (node.firstChild)
+                node.removeChild(node.firstChild);
+            node.appendChild(util.xmlToDom(<>{xml}</>, self.list.document));
+        }
+
+        update("name", template.icon({ icon: this.iconURL }, this.name));
+        this.nodes.version.textContent = this.version;
+        update("status", this.statusInfo);
+        this.nodes.description.textContent = this.description;
+
+        for (let node in values(this.nodes))
+            if (node.update && node.update !== callee)
+                node.update();
+
+        let event = this.list.document.createEvent("Events");
+        event.initEvent("dactyl-commandupdate", true, false);
+        this.list.document.dispatchEvent(event);
+    }
+});
+
+["cancelUninstall", "findUpdates", "getResourceURI", "hasResource", "isCompatibleWith",
+ "uninstall"].forEach(function (prop) {
+     Addon.prototype[prop] = function proxy() this.addon[prop].apply(this.addon, arguments);
+});
+
+["aboutURL", "appDisabled", "applyBackgroundUpdates", "blocklistState", "contributors", "creator",
+ "description", "developers", "homepageURL", "iconURL", "id", "install", "installDate", "isActive",
+ "isCompatible", "isPlatformCompatible", "name", "operationsRequiringRestart", "optionsURL",
+ "pendingOperations", "pendingUpgrade", "permissions", "providesUpdatesSecurely", "releaseNotesURI",
+ "scope", "screenshots", "size", "sourceURI", "translators", "type", "updateDate", "userDisabled",
+ "version"].forEach(function (prop) {
+    Object.defineProperty(Addon.prototype, prop, {
+        get: function get_proxy() this.addon[prop],
+        set: function set_proxy(val) this.addon[prop] = val
+    });
+});
+
+var AddonList = Class("AddonList", {
+    init: function init(modules, types, filter) {
+        this.modules = modules;
+        this.filter = filter && filter.toLowerCase();
+        this.nodes = {};
+        this.addons = [];
+        this.ready = false;
+
+        AddonManager.getAddonsByTypes(types, this.closure(function (addons) {
+            this._addons = addons;
+            if (this.document)
+                this._init();
+        }));
+        AddonManager.addAddonListener(this);
+    },
+    cleanup: function cleanup() {
+        AddonManager.removeAddonListener(this);
+    },
+
+    _init: function _init() {
+        this._addons.forEach(this.closure.addAddon);
+        this.ready = true;
+        this.update();
+    },
+
+    message: Class.memoize(function () {
+
+        XML.ignoreWhitespace = true;
+        util.xmlToDom(<table highlight="Addons" key="list" xmlns={XHTML}>
+                        <tr highlight="AddonHead">
+                            <td>Name</td>
+                            <td>Version</td>
+                            <td>Status</td>
+                            <td/>
+                            <td>Description</td>
+                        </tr>
+                      </table>, this.document, this.nodes);
+
+        if (this._addons)
+            this._init();
+
+        return this.nodes.list;
+    }),
+
+    addAddon: function addAddon(addon) {
+        if (addon.id in this.addons)
+            this.update(addon);
+        else {
+            if (this.filter && addon.name.toLowerCase().indexOf(this.filter) === -1)
+                return;
+
+            addon = Addon(addon, this);
+            this.addons[addon.id] = addon;
+
+            let index = values(this.addons).sort(function (a, b) a.compare(b))
+                                           .indexOf(addon);
+
+            this.nodes.list.insertBefore(addon.nodes.row,
+                                         this.nodes.list.childNodes[index + 1]);
+            this.update();
+        }
+    },
+    removeAddon: function removeAddon(addon) {
+        if (addon.id in this.addons) {
+            this.nodes.list.removeChild(this.addons[addon.id].nodes.row);
+            delete this.addons[addon.id];
+            this.update();
+        }
+    },
+
+    leave: function leave(stack) {
+        if (stack.pop)
+            this.cleanup();
+    },
+
+    update: function update(addon) {
+        if (addon && addon.id in this.addons)
+            this.addons[addon.id].update();
+        if (this.ready)
+            this.modules.mow.resize(false);
+    },
+
+    onDisabled:           function (addon) { this.update(addon); },
+    onDisabling:          function (addon) { this.update(addon); },
+    onEnabled:            function (addon) { this.update(addon); },
+    onEnabling:           function (addon) { this.update(addon); },
+    onInstalled:          function (addon) { this.addAddon(addon); },
+    onInstalling:         function (addon) { this.update(addon); },
+    onUninstalled:        function (addon) { this.removeAddon(addon); },
+    onUninstalling:       function (addon) { this.update(addon); },
+    onOperationCancelled: function (addon) { this.update(addon); },
+    onPropertyChanged: function onPropertyChanged(addon, properties) {}
+});
+
+var Addons = Module("addons", {
+}, {
+}, {
+    commands: function (dactyl, modules, window) {
+        const { CommandOption, commands, completion } = modules;
+
+        commands.add(["addo[ns]", "ao"],
+            "List installed extensions",
+            function (args) {
+                let addons = AddonList(modules, args["-types"], args[0]);
+                modules.commandline.echo(addons);
+
+                if (modules.commandline.savingOutput)
+                    util.waitFor(function () addons.ready);
+            },
+            {
+                argCount: "?",
+                options: [
+                    {
+                        names: ["-types", "-type", "-t"],
+                        description: "The add-on types to list",
+                        default: ["extension"],
+                        completer: function (context, args) completion.addonType(context),
+                        type: CommandOption.LIST
+                    }
+                ]
+            });
+
+        let addonListener = AddonListener(modules);
+
+        commands.add(["exta[dd]"],
+            "Install an extension",
+            function (args) {
+                let url  = args[0];
+                let file = io.File(url);
+                function install(addonInstall) {
+                    addonInstall.addListener(addonListener);
+                    addonInstall.install();
+                }
+
+                if (!file.exists())
+                    AddonManager.getInstallForURL(url,   install, "application/x-xpinstall");
+                else if (file.isReadable() && file.isFile())
+                    AddonManager.getInstallForFile(file, install, "application/x-xpinstall");
+                else if (file.isDirectory())
+                    dactyl.echoerr(_("addon.cantInstallDir", file.path.quote()));
+                else
+                    dactyl.echoerr(_("io.notReadable-1", file.path));
+            }, {
+                argCount: "1",
+                completer: function (context) {
+                    context.filters.push(function ({ isdir, text }) isdir || /\.xpi$/.test(text));
+                    completion.file(context);
+                },
+                literal: 0
+            });
+
+        // TODO: handle extension dependencies
+        values(actions).forEach(function (command) {
+            let perm = command.perm && AddonManager["PERM_CAN_" + command.perm.toUpperCase()];
+            function ok(addon) !perm || addon.permissions & perm;
+
+            commands.add(Array.concat(command.name),
+                command.description,
+                function (args) {
+                    let name = args[0];
+                    if (args.bang && !command.bang)
+                        dactyl.assert(!name, _("error.trailing"));
+                    else
+                        dactyl.assert(name, _("error.argumentRequired"));
+
+                    AddonManager.getAddonsByTypes(["extension"], dactyl.wrapCallback(function (list) {
+                        if (!args.bang || command.bang) {
+                            list = list.filter(function (extension) extension.name == name);
+                            if (list.length == 0)
+                                return void dactyl.echoerr(_("error.invalidArgument", name));
+                            if (!list.every(ok))
+                                return void dactyl.echoerr(_("error.invalidOperation"));
+                        }
+                        if (command.actions)
+                            command.actions(list, this.modules);
+                        else
+                            list.forEach(function (addon) command.action.call(this.modules, addon, args.bang), this);
+                    }));
+                }, {
+                    argCount: "?", // FIXME: should be "1"
+                    bang: true,
+                    completer: function (context) {
+                        completion.extension(context);
+                        context.filters.push(function ({ item }) ok(item));
+                        if (command.filter)
+                            context.filters.push(command.filter);
+                    },
+                    literal: 0
+                });
+        });
+    },
+    completion: function (dactyl, modules, window) {
+        completion.addonType = function addonType(context) {
+            let base = ["extension", "theme"];
+            function update(types) {
+                context.completions = types.map(function (t) [t, util.capitalize(t)]);
+            }
+
+            context.generate = function generate() {
+                update(base);
+                if (AddonManager.getAllAddons) {
+                    context.incomplete = true;
+                    AddonManager.getAllAddons(function (addons) {
+                        context.incomplete = false;
+                        update(array.uniq(base.concat(addons.map(function (a) a.type)),
+                                          true));
+                    });
+                }
+            }
+        }
+
+        completion.extension = function extension(context, types) {
+            context.title = ["Extension"];
+            context.anchored = false;
+            context.keys = { text: "name", description: "description", icon: "iconURL" },
+            context.generate = function () {
+                context.incomplete = true;
+                AddonManager.getAddonsByTypes(types || ["extension"], function (addons) {
+                    context.incomplete = false;
+                    context.completions = addons;
+                });
+            };
+        };
+    }
+});
+
+if (!services.has("extensionManager"))
+    Components.utils.import("resource://gre/modules/AddonManager.jsm");
+else
+    var AddonManager = {
+        PERM_CAN_UNINSTALL: 1,
+        PERM_CAN_ENABLE: 2,
+        PERM_CAN_DISABLE: 4,
+        PERM_CAN_UPGRADE: 8,
+
+        getAddonByID: function (id, callback) {
+            callback = callback || util.identity;
+            addon = services.extensionManager.getItemForID(id);
+            if (addon)
+                addon = this.wrapAddon(addon);
+            return callback(addon);
+        },
+        wrapAddon: function wrapAddon(addon) {
+            addon = Object.create(addon.QueryInterface(Ci.nsIUpdateItem));
+
+            function getRdfProperty(item, property) {
+                let resource = services.rdf.GetResource("urn:mozilla:item:" + item.id);
+                let value = "";
+
+                if (resource) {
+                    let target = services.extensionManager.datasource.GetTarget(resource,
+                        services.rdf.GetResource("http://www.mozilla.org/2004/em-rdf#" + property), true);
+                    if (target && target instanceof Ci.nsIRDFLiteral)
+                        value = target.Value;
+                }
+
+                return value;
+            }
+
+            ["aboutURL", "creator", "description", "developers",
+             "homepageURL", "installDate", "optionsURL",
+             "releaseNotesURI", "updateDate"].forEach(function (item) {
+                memoize(addon, item, function (item) getRdfProperty(this, item));
+            });
+
+            update(addon, {
+
+                get permissions() 1 | (this.userDisabled ? 2 : 4),
+
+                appDisabled: false,
+
+                installLocation: Class.memoize(function () services.extensionManager.getInstallLocation(this.id)),
+                getResourceURI: function getResourceURI(path) {
+                    let file = this.installLocation.getItemFile(this.id, path);
+                    return services.io.newFileURI(file);
+                },
+
+                isActive: getRdfProperty(addon, "isDisabled") != "true",
+
+                uninstall: function uninstall() {
+                    services.extensionManager.uninstallItem(this.id);
+                },
+
+                get userDisabled() getRdfProperty(addon, "userDisabled") === "true",
+                set userDisabled(val) {
+                    services.extensionManager[val ? "disableItem" : "enableItem"](this.id);
+                }
+            });
+
+            return addon;
+        },
+        getAddonsByTypes: function (types, callback) {
+            let res = [];
+            for (let [, type] in Iterator(types))
+                for (let [, item] in Iterator(services.extensionManager
+                            .getItemList(Ci.nsIUpdateItem["TYPE_" + type.toUpperCase()], {})))
+                    res.push(this.wrapAddon(item));
+
+            if (callback)
+                util.timeout(function () { callback(res); });
+            return res;
+        },
+        getInstallForFile: function (file, callback, mimetype) {
+            callback({
+                addListener: function () {},
+                install: function () {
+                    services.extensionManager.installItemFromFile(file, "app-profile");
+                }
+            });
+        },
+        getInstallForURL: function (url, callback, mimetype) {
+            util.assert(false, _("error.unavailable", config.host, services.runtime.version));
+        },
+        observers: [],
+        addAddonListener: function (listener) {
+            observer.listener = listener;
+            function observer(subject, topic, data) {
+                if (subject instanceof Ci.nsIUpdateItem)
+                    subject = AddonManager.wrapAddon(subject);
+
+                if (data === "item-installed")
+                    listener.onInstalling(subject, true);
+                else if (data === "item-uninstalled")
+                    listener.onUnistalling(subject, true);
+                else if (data === "item-upgraded")
+                    listener.onInstalling(subject, true);
+                else if (data === "item-enabled")
+                    listener.onEnabling(subject, true);
+                else if (data === "item-disabled")
+                    listener.onDisabling(subject, true);
+            }
+            services.observer.addObserver(observer, "em-action-requested", false);
+            this.observers.push(observer);
+        },
+        removeAddonListener: function (listener) {
+            this.observers = this.observers.filter(function (observer) {
+                if (observer.listener !== listener)
+                    return true;
+                services.observer.removeObserver(observer, "em-action-requested");
+            });
+        }
+    };
+
+var addonErrors = array.toObject([
+    [AddonManager.ERROR_NETWORK_FAILURE, "A network error occurred"],
+    [AddonManager.ERROR_INCORRECT_HASH,  "The downloaded file did not match the expected hash"],
+    [AddonManager.ERROR_CORRUPT_FILE,    "The file appears to be corrupt"],
+    [AddonManager.ERROR_FILE_ACCESS,     "There was an error accessing the filesystem"]]);
+
+endModule();
+
+} catch(e){ if (isString(e)) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/base.jsm b/common/modules/base.jsm
new file mode 100644 (file)
index 0000000..13bd9a0
--- /dev/null
@@ -0,0 +1,1550 @@
+// Copyright (c) 2009-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+try {
+    var ctypes;
+    Components.utils.import("resource://gre/modules/ctypes.jsm");
+}
+catch (e) {}
+
+let objproto = Object.prototype;
+let { __lookupGetter__, __lookupSetter__, hasOwnProperty, propertyIsEnumerable } = objproto;
+
+if (typeof XPCSafeJSObjectWrapper === "undefined")
+    this.XPCSafeJSObjectWrapper = XPCNativeWrapper;
+
+if (!XPCNativeWrapper.unwrap)
+    XPCNativeWrapper.unwrap = function unwrap(obj) {
+        if (hasOwnProperty.call(obj, "wrappedJSObject"))
+            return obj.wrappedJSObject;
+        return obj;
+    };
+if (!Object.create)
+    Object.create = function create(proto, props) {
+        let obj = { __proto__: proto };
+        for (let k in properties(props || {}))
+            Object.defineProperty(obj, k, props[k]);
+        return obj;
+    };
+if (!Object.defineProperty)
+    Object.defineProperty = function defineProperty(obj, prop, desc) {
+        let value = desc.value;
+        if ("value" in desc)
+            if (desc.writable && !__lookupGetter__.call(obj, prop)
+                              && !__lookupSetter__.call(obj, prop))
+                try {
+                    obj[prop] = value;
+                }
+                catch (e if e instanceof TypeError) {}
+            else {
+                objproto.__defineGetter__.call(obj, prop, function () value);
+                if (desc.writable)
+                    objproto.__defineSetter__.call(obj, prop, function (val) { value = val; });
+            }
+
+        if ("get" in desc)
+            objproto.__defineGetter__.call(obj, prop, desc.get);
+        if ("set" in desc)
+            objproto.__defineSetter__.call(obj, prop, desc.set);
+    };
+if (!Object.defineProperties)
+    Object.defineProperties = function defineProperties(obj, props) {
+        for (let [k, v] in Iterator(props))
+            Object.defineProperty(obj, k, v);
+    }
+if (!Object.freeze)
+    Object.freeze = function freeze(obj) {};
+if (!Object.getOwnPropertyDescriptor)
+    Object.getOwnPropertyDescriptor = function getOwnPropertyDescriptor(obj, prop) {
+        if (!hasOwnProperty.call(obj, prop))
+            return undefined;
+        let desc = {
+            configurable: true,
+            enumerable: propertyIsEnumerable.call(obj, prop)
+        };
+        var get = __lookupGetter__.call(obj, prop),
+            set = __lookupSetter__.call(obj, prop);
+        if (!get && !set) {
+            desc.value = obj[prop];
+            desc.writable = true;
+        }
+        if (get)
+            desc.get = get;
+        if (set)
+            desc.set = set;
+        return desc;
+    };
+if (!Object.getOwnPropertyNames)
+    Object.getOwnPropertyNames = function getOwnPropertyNames(obj, _debugger) {
+        // This is an ugly and unfortunately necessary hack.
+        if (hasOwnProperty.call(obj, "__iterator__")) {
+            var oldIter = obj.__iterator__;
+            delete obj.__iterator__;
+        }
+        let res = [k for (k in obj) if (hasOwnProperty.call(obj, k))];
+        if (oldIter !== undefined) {
+            obj.__iterator__ = oldIter;
+            res.push("__iterator__");
+        }
+        return res;
+    };
+if (!Object.getPrototypeOf)
+    Object.getPrototypeOf = function getPrototypeOf(obj) obj.__proto__;
+if (!Object.keys)
+    Object.keys = function keys(obj)
+        Object.getOwnPropertyNames(obj).filter(function (k) propertyIsEnumerable.call(obj, k));
+
+let getGlobalForObject = Cu.getGlobalForObject || function (obj) obj.__parent__;
+
+let use = {};
+let loaded = {};
+let currentModule;
+let global = this;
+function defineModule(name, params, module) {
+    if (!module)
+        module = getGlobalForObject(params);
+
+    module.NAME = name;
+    module.EXPORTED_SYMBOLS = params.exports || [];
+    defineModule.loadLog.push("defineModule " + name);
+    for (let [, mod] in Iterator(params.require || []))
+        require(module, mod);
+
+    for (let [, mod] in Iterator(params.use || []))
+        if (loaded.hasOwnProperty(mod))
+            require(module, mod, "use");
+        else {
+            use[mod] = use[mod] || [];
+            use[mod].push(module);
+        }
+    currentModule = module;
+}
+
+defineModule.loadLog = [];
+Object.defineProperty(defineModule.loadLog, "push", {
+    value: function (val) {
+        if (false)
+            defineModule.dump(val + "\n");
+        this[this.length] = Date.now() + " " + val;
+    }
+});
+defineModule.dump = function dump_() {
+    let msg = Array.map(arguments, function (msg) {
+        if (loaded.util && typeof msg == "object")
+            msg = util.objectToString(msg);
+        return msg;
+    }).join(", ");
+    let name = loaded.config ? config.name : "dactyl";
+    dump(String.replace(msg, /\n?$/, "\n")
+               .replace(/^./gm, name + ": $&"));
+}
+defineModule.modules = [];
+defineModule.times = { all: 0 };
+defineModule.time = function time(major, minor, func, self) {
+    let time = Date.now();
+    if (typeof func !== "function")
+        func = self[func];
+
+    try {
+        var res = func.apply(self, Array.slice(arguments, 4));
+    }
+    catch (e) {
+        loaded.util && util.reportError(e);
+    }
+
+    let delta = Date.now() - time;
+    defineModule.times.all += delta;
+    defineModule.times[major] = (defineModule.times[major] || 0) + delta;
+    if (minor) {
+        defineModule.times[":" + minor] = (defineModule.times[":" + minor] || 0) + delta;
+        defineModule.times[major + ":" + minor] = (defineModule.times[major + ":" + minor] || 0) + delta;
+    }
+    return res;
+}
+
+function endModule() {
+    defineModule.loadLog.push("endModule " + currentModule.NAME);
+
+    for (let [, mod] in Iterator(use[currentModule.NAME] || []))
+        require(mod, currentModule.NAME, "use");
+
+    loaded[currentModule.NAME] = 1;
+}
+
+function require(obj, name, from) {
+    try {
+        if (arguments.length === 1)
+            [obj, name] = [{}, obj];
+
+        if (!loaded[name])
+            defineModule.loadLog.push((from || "require") + ": loading " + name + (obj.NAME ? " into " + obj.NAME : ""));
+
+        JSMLoader.load(name + ".jsm", obj);
+        return obj;
+    }
+    catch (e) {
+        defineModule.dump("loading " + String.quote(name + ".jsm") + "\n");
+        if (loaded.util)
+            util.reportError(e);
+        else
+            defineModule.dump("    " + (e.filename || e.fileName) + ":" + e.lineNumber + ": " + e +"\n");
+    }
+}
+
+defineModule("base", {
+    // sed -n 's/^(const|function) ([a-zA-Z0-9_]+).*/  "\2",/p' base.jsm | sort | fmt
+    exports: [
+        "ErrorBase", "Cc", "Ci", "Class", "Cr", "Cu", "Module", "JSMLoader", "Object", "Runnable",
+        "Struct", "StructBase", "Timer", "UTF8", "XPCOM", "XPCOMUtils", "XPCSafeJSObjectWrapper",
+        "array", "bind", "call", "callable", "ctypes", "curry", "debuggerProperties", "defineModule",
+        "deprecated", "endModule", "forEach", "isArray", "isGenerator", "isinstance", "isObject",
+        "isString", "isSubclass", "iter", "iterAll", "iterOwnProperties","keys", "memoize", "octal",
+        "properties", "require", "set", "update", "values", "withCallerGlobal"
+    ],
+    use: ["config", "services", "util"]
+}, this);
+
+function Runnable(self, func, args) {
+    return {
+        __proto__: Runnable.prototype,
+        run: function () { func.apply(self, args || []); }
+    };
+}
+Runnable.prototype.QueryInterface = XPCOMUtils.generateQI([Ci.nsIRunnable]);
+
+/**
+ * Returns a list of all of the top-level properties of an object, by
+ * way of the debugger.
+ *
+ * @param {object} obj
+ * @returns [jsdIProperty]
+ */
+function debuggerProperties(obj) {
+    if (loaded.services && services.debugger.isOn) {
+        let res = {};
+        services.debugger.wrapValue(obj).getProperties(res, {});
+        return res.value;
+    }
+}
+
+/**
+ * Iterates over the names of all of the top-level properties of an
+ * object or, if prototypes is given, all of the properties in the
+ * prototype chain below the top. Uses the debugger if possible.
+ *
+ * @param {object} obj The object to inspect.
+ * @param {boolean} properties Whether to inspect the prototype chain
+ * @default false
+ * @returns {Generator}
+ */
+function prototype(obj)
+    /* Temporary hack: */ typeof obj === "xml" || obj.__proto__ !== obj.__proto__ ? null :
+    obj.__proto__ || Object.getPrototypeOf(obj) ||
+    XPCNativeWrapper.unwrap(obj).__proto__ ||
+    Object.getPrototypeOf(XPCNativeWrapper.unwrap(obj));
+
+function properties(obj, prototypes, debugger_) {
+    let orig = obj;
+    let seen = { dactylPropertyNames: true };
+
+    try {
+        if ("dactylPropertyNames" in obj && !prototypes)
+            for (let key in values(obj.dactylPropertyNames))
+                if (key in obj && !set.add(seen, key))
+                    yield key;
+    }
+    catch (e) {}
+
+    for (; obj; obj = prototypes && prototype(obj)) {
+        try {
+            if (sandbox.Object.getOwnPropertyNames || !debugger_ || !services.debugger.isOn)
+                var iter = values(Object.getOwnPropertyNames(obj));
+        }
+        catch (e) {}
+        if (!iter)
+            iter = (prop.name.stringValue for (prop in values(debuggerProperties(obj))));
+
+        for (let key in iter)
+            if (!prototypes || !set.add(seen, key) && obj != orig)
+                yield key;
+    }
+}
+
+function iterOwnProperties(obj) {
+    for (let prop in properties(obj))
+        yield [prop, Object.getOwnPropertyDescriptor(obj, prop)];
+}
+
+function deprecated(alternative, fn) {
+    if (isObject(fn))
+        return Class.Property(iter(fn).map(function ([k, v]) [k, callable(v) ? deprecated(alternative, v) : v])
+                                      .toObject());
+
+    let name, func = callable(fn) ? fn : function () this[fn].apply(this, arguments);
+
+    function deprecatedMethod() {
+        let obj = this.className             ? this.className + "#" :
+                  this.constructor.className ? this.constructor.className + "#" :
+                      "";
+
+        deprecated.warn(func, obj + (fn.name || name), alternative);
+        return func.apply(this, arguments);
+    }
+
+    return callable(fn) ? deprecatedMethod : Class.Property({
+        get: function () deprecatedMethod,
+        init: function (prop) { name = prop; }
+    });
+}
+deprecated.warn = function warn(func, name, alternative, frame) {
+    if (!func.seenCaller)
+        func.seenCaller = set([
+            "resource://dactyl" + JSMLoader.suffix + "/javascript.jsm",
+            "resource://dactyl" + JSMLoader.suffix + "/util.jsm"
+        ]);
+
+    frame = frame || Components.stack.caller.caller;
+    let filename = util.fixURI(frame.filename || "unknown");
+    if (!set.add(func.seenCaller, filename))
+        util.dactyl(func).warn(
+            util.urlPath(filename) + ":" + frame.lineNumber + ": " +
+            name + " is deprecated: Please use " + alternative + " instead");
+}
+
+/**
+ * Iterates over all of the top-level, iterable property names of an
+ * object.
+ *
+ * @param {object} obj The object to inspect.
+ * @returns {Generator}
+ */
+function keys(obj) iter(function keys() {
+    for (var k in obj)
+        if (hasOwnProperty.call(obj, k))
+            yield k;
+}());
+/**
+ * Iterates over all of the top-level, iterable property values of an
+ * object.
+ *
+ * @param {object} obj The object to inspect.
+ * @returns {Generator}
+ */
+function values(obj) iter(function values() {
+    if (isinstance(obj, ["Generator", "Iterator"]))
+        for (let k in obj)
+            yield k;
+    else
+        for (var k in obj)
+            if (hasOwnProperty.call(obj, k))
+                yield obj[k];
+}());
+
+var forEach = deprecated("iter.forEach", function forEach() iter.forEach.apply(iter, arguments));
+var iterAll = deprecated("iter", function iterAll() iter.apply(null, arguments));
+
+/**
+ * Utility for managing sets of strings. Given an array, returns an
+ * object with one key for each value thereof.
+ *
+ * @param {[string]} ary @optional
+ * @returns {object}
+ */
+function set(ary) {
+    let obj = {};
+    if (ary)
+        for (let val in values(ary))
+            obj[val] = true;
+    return obj;
+}
+/**
+ * Adds an element to a set and returns true if the element was
+ * previously contained.
+ *
+ * @param {object} set The set.
+ * @param {string} key The key to add.
+ * @returns boolean
+ */
+set.add = curry(function set_add(set, key) {
+    let res = this.has(set, key);
+    set[key] = true;
+    return res;
+});
+/**
+ * Returns true if the given set contains the given key.
+ *
+ * @param {object} set The set.
+ * @param {string} key The key to check.
+ * @returns {boolean}
+ */
+set.has = curry(function set_has(set, key) hasOwnProperty.call(set, key) &&
+                                           propertyIsEnumerable.call(set, key));
+/**
+ * Returns a new set containing the members of the first argument which
+ * do not exist in any of the other given arguments.
+ *
+ * @param {object} set The set.
+ * @returns {object}
+ */
+set.subtract = function set_subtract(set) {
+    set = update({}, set);
+    for (let i = 1; i < arguments.length; i++)
+        for (let k in keys(arguments[i]))
+            delete set[k];
+    return set;
+};
+/**
+ * Removes an element from a set and returns true if the element was
+ * previously contained.
+ *
+ * @param {object} set The set.
+ * @param {string} key The key to remove.
+ * @returns boolean
+ */
+set.remove = curry(function set_remove(set, key) {
+    let res = set.has(set, key);
+    delete set[key];
+    return res;
+});
+
+/**
+ * Curries a function to the given number of arguments. Each
+ * call of the resulting function returns a new function. When
+ * a call does not contain enough arguments to satisfy the
+ * required number, the resulting function is another curried
+ * function with previous arguments accumulated.
+ *
+ *     function foo(a, b, c) [a, b, c].join(" ");
+ *     curry(foo)(1, 2, 3) -> "1 2 3";
+ *     curry(foo)(4)(5, 6) -> "4 5 6";
+ *     curry(foo)(7)(8)(9) -> "7 8 9";
+ *
+ * @param {function} fn The function to curry.
+ * @param {integer} length The number of arguments expected.
+ *     @default fn.length
+ *     @optional
+ * @param {object} self The 'this' value for the returned function. When
+ *     omitted, the value of 'this' from the first call to the function is
+ *     preserved.
+ *     @optional
+ */
+function curry(fn, length, self, acc) {
+    if (length == null)
+        length = fn.length;
+    if (length == 0)
+        return fn;
+
+    // Close over function with 'this'
+    function close(self, fn) function () fn.apply(self, Array.slice(arguments));
+
+    if (acc == null)
+        acc = [];
+
+    return function curried() {
+        let args = acc.concat(Array.slice(arguments));
+
+        // The curried result should preserve 'this'
+        if (arguments.length == 0)
+            return close(self || this, curried);
+
+        if (args.length >= length)
+            return fn.apply(self || this, args);
+
+        return curry(fn, length, self || this, args);
+    };
+}
+
+if (curry.bind)
+    var bind = function bind(func) func.bind.apply(func, Array.slice(arguments, bind.length));
+else
+    var bind = function bind(func, self) {
+        let args = Array.slice(arguments, bind.length);
+        return function bound() func.apply(self, args.concat(Array.slice(arguments)));
+    };
+
+/**
+ * Returns true if both arguments are functions and
+ * (targ() instanceof src) would also return true.
+ *
+ * @param {function} targ
+ * @param {function} src
+ * @returns {boolean}
+ */
+function isSubclass(targ, src) {
+    return src === targ ||
+        targ && typeof targ === "function" && targ.prototype instanceof src;
+}
+
+/**
+ * Returns true if *object* is an instance of *interfaces*. If *interfaces* is
+ * an array, returns true if *object* is an instance of any element of
+ * *interfaces*. If *interfaces* is the object form of a primitive type,
+ * returns true if *object* is a non-boxed version of the type, i.e., if
+ * (typeof object == "string"), isinstance(object, String) is true. Finally, if
+ * *interfaces* is a string, returns true if ({}.toString.call(object) ==
+ * "[object <interfaces>]").
+ *
+ * @param {object} object The object to check.
+ * @param {constructor|[constructor|string]} interfaces The types to check *object* against.
+ * @returns {boolean}
+ */
+var isinstance_types = {
+    boolean: Boolean,
+    string: String,
+    function: Function,
+    number: Number
+};
+function isinstance(object, interfaces) {
+    if (object == null)
+        return false;
+
+    return Array.concat(interfaces).some(function isinstance_some(iface) {
+        if (typeof iface === "string") {
+            if (objproto.toString.call(object) === "[object " + iface + "]")
+                return true;
+        }
+        else if (typeof object === "object" && "isinstance" in object && object.isinstance !== isinstance) {
+            if (object.isinstance(iface))
+                return true;
+        }
+        else {
+            if (object instanceof iface)
+                return true;
+            var type = isinstance_types[typeof object];
+            if (type && isSubclass(iface, type))
+                return true;
+        }
+        return false;
+    });
+}
+
+/**
+ * Returns true if obj is a non-null object.
+ */
+function isObject(obj) typeof obj === "object" && obj != null || obj instanceof Ci.nsISupports;
+
+/**
+ * Returns true if and only if its sole argument is an
+ * instance of the builtin Array type. The array may come from
+ * any window, frame, namespace, or execution context, which
+ * is not the case when using (obj instanceof Array).
+ */
+var isArray =
+    Array.isArray
+        // This is bloody stupid.
+        ? function isArray(val) Array.isArray(val) || val && val.constructor && val.constructor.name === "Array"
+        : function isArray(val) objproto.toString.call(val) == "[object Array]";
+
+/**
+ * Returns true if and only if its sole argument is an
+ * instance of the builtin Generator type. This includes
+ * functions containing the 'yield' statement and generator
+ * statements such as (x for (x in obj)).
+ */
+function isGenerator(val) objproto.toString.call(val) == "[object Generator]";
+
+/**
+ * Returns true if and only if its sole argument is a String,
+ * as defined by the builtin type. May be constructed via
+ * String(foo) or new String(foo) from any window, frame,
+ * namespace, or execution context, which is not the case when
+ * using (obj instanceof String) or (typeof obj == "string").
+ */
+function isString(val) objproto.toString.call(val) == "[object String]";
+
+/**
+ * Returns true if and only if its sole argument may be called
+ * as a function. This includes classes and function objects.
+ */
+function callable(val) typeof val === "function";
+
+function call(fn) {
+    fn.apply(arguments[1], Array.slice(arguments, 2));
+    return fn;
+}
+
+/**
+ * Memoizes an object property value.
+ *
+ * @param {object} obj The object to add the property to.
+ * @param {string} key The property name.
+ * @param {function} getter The function which will return the initial
+ * value of the property.
+ */
+function memoize(obj, key, getter) {
+    if (arguments.length == 1) {
+        obj = update({}, obj);
+        for (let prop in Object.getOwnPropertyNames(obj)) {
+            let get = __lookupGetter__.call(obj, prop);
+            if (get)
+                memoize(obj, prop, get);
+        }
+        return obj;
+    }
+
+    Object.defineProperty(obj, key, {
+        configurable: true,
+        enumerable: true,
+
+        get: function g_replaceProperty() (
+            Class.replaceProperty(this.instance || this, key, null),
+            Class.replaceProperty(this.instance || this, key, getter.call(this, key))),
+
+        set: function s_replaceProperty(val)
+            Class.replaceProperty(this.instance || this, key, val)
+    });
+}
+
+let sandbox = Cu.Sandbox(this);
+sandbox.__proto__ = this;
+/**
+ * Wraps a function so that when called, the global object of the caller
+ * is prepended to its arguments.
+ */
+// Hack to get around lack of access to caller in strict mode.
+var withCallerGlobal = Cu.evalInSandbox(<![CDATA[
+    (function withCallerGlobal(fn)
+        function withCallerGlobal_wrapped()
+            fn.apply(this,
+                     [Class.objectGlobal(withCallerGlobal_wrapped.caller)]
+                        .concat(Array.slice(arguments))))
+]]>, Cu.Sandbox(this), "1.8");
+
+/**
+ * Updates an object with the properties of another object. Getters
+ * and setters are copied as expected. Moreover, any function
+ * properties receive new 'supercall' and 'superapply' properties,
+ * which will call the identically named function in target's
+ * prototype.
+ *
+ *    let a = { foo: function (arg) "bar " + arg }
+ *    let b = { __proto__: a }
+ *    update(b, { foo: function foo() foo.supercall(this, "baz") });
+ *
+ *    a.foo("foo") -> "bar foo"
+ *    b.foo()      -> "bar baz"
+ *
+ * @param {Object} target The object to update.
+ * @param {Object} src The source object from which to update target.
+ *    May be provided multiple times.
+ * @returns {Object} Returns its updated first argument.
+ */
+function update(target) {
+    for (let i = 1; i < arguments.length; i++) {
+        let src = arguments[i];
+        Object.getOwnPropertyNames(src || {}).forEach(function (k) {
+            let desc = Object.getOwnPropertyDescriptor(src, k);
+            if (desc.value instanceof Class.Property)
+                desc = desc.value.init(k, target) || desc.value;
+
+            if (typeof desc.value === "function" && target.__proto__) {
+                let func = desc.value.wrapped || desc.value;
+                func.__defineGetter__("super", function () Object.getPrototypeOf(target)[k]);
+                func.superapply = function superapply(self, args)
+                    let (meth = Object.getPrototypeOf(target)[k])
+                        meth && meth.apply(self, args);
+                func.supercall = function supercall(self)
+                    func.superapply(self, Array.slice(arguments, 1));
+            }
+            try {
+                Object.defineProperty(target, k, desc);
+            }
+            catch (e) {}
+        });
+    }
+    return target;
+}
+
+/**
+ * @constructor Class
+ *
+ * Constructs a new Class. Arguments marked as optional must be
+ * either entirely elided, or they must have the exact type
+ * specified.
+ *
+ * @param {string} name The class's as it will appear when toString
+ *     is called, as well as in stack traces.
+ *     @optional
+ * @param {function} base The base class for this module. May be any
+ *     callable object.
+ *     @optional
+ *     @default Class
+ * @param {Object} prototype The prototype for instances of this
+ *     object. The object itself is copied and not used as a prototype
+ *     directly.
+ * @param {Object} classProperties The class properties for the new
+ *     module constructor. More than one may be provided.
+ *     @optional
+ *
+ * @returns {function} The constructor for the resulting class.
+ */
+function Class() {
+
+    var args = Array.slice(arguments);
+    if (isString(args[0]))
+        var name = args.shift();
+    var superclass = Class;
+    if (callable(args[0]))
+        superclass = args.shift();
+
+    var Constructor = eval(String.replace(<![CDATA[
+        (function constructor(PARAMS) {
+            var self = Object.create(Constructor.prototype, {
+                constructor: { value: Constructor },
+            });
+            self.instance = self;
+            var res = self.init.apply(self, arguments);
+            return res !== undefined ? res : self;
+        })]]>,
+        "constructor", (name || superclass.className).replace(/\W/g, "_"))
+            .replace("PARAMS", /^function .*?\((.*?)\)/.exec(args[0] && args[0].init || Class.prototype.init)[1]
+                                                       .replace(/\b(self|res|Constructor)\b/g, "$1_")));
+
+    Constructor.className = name || superclass.className || superclass.name;
+
+    if ("init" in superclass.prototype)
+        Constructor.__proto__ = superclass;
+    else {
+        let superc = superclass;
+        superclass = function Shim() {};
+        Class.extend(superclass, superc, {
+            init: superc
+        });
+        superclass.__proto__ = superc;
+    }
+
+    Class.extend(Constructor, superclass, args[0]);
+    update(Constructor, args[1]);
+    Constructor.__proto__ = superclass;
+    args = args.slice(2);
+    Array.forEach(args, function (obj) {
+        if (callable(obj))
+            obj = obj.prototype;
+        update(Constructor.prototype, obj);
+    });
+    return Constructor;
+}
+
+if (Cu.getGlobalForObject)
+    Class.objectGlobal = function (caller) {
+        try {
+            return Cu.getGlobalForObject(caller);
+        }
+        catch (e) {
+            return null;
+        }
+    };
+else
+    Class.objectGlobal = function (caller) {
+        while (caller.__parent__)
+            caller = caller.__parent__;
+        return caller;
+    };
+
+/**
+ * @class Class.Property
+ * A class which, when assigned to a property in a Class's prototype
+ * or class property object, defines that property's descriptor
+ * rather than its value. If the desc object has an init property, it
+ * will be called with the property's name before the descriptor is
+ * assigned.
+ *
+ * @param {Object} desc The property descriptor.
+ */
+Class.Property = function Property(desc) update(
+    Object.create(Property.prototype), desc || { configurable: true, writable: true });
+Class.Property.prototype.init = function () {};
+/**
+ * Extends a subclass with a superclass. The subclass's
+ * prototype is replaced with a new object, which inherits
+ * from the superclass's prototype, {@see update}d with the
+ * members of *overrides*.
+ *
+ * @param {function} subclass
+ * @param {function} superclass
+ * @param {Object} overrides @optional
+ */
+Class.extend = function extend(subclass, superclass, overrides) {
+    subclass.superclass = superclass;
+
+    subclass.prototype = Object.create(superclass.prototype);
+    update(subclass.prototype, overrides);
+    subclass.prototype.constructor = subclass;
+    subclass.prototype._class_ = subclass;
+
+    if (superclass.prototype.constructor === objproto.constructor)
+        superclass.prototype.constructor = superclass;
+}
+
+/**
+ * Memoizes the value of a class property to the value returned by
+ * the passed function the first time the property is accessed.
+ *
+ * @param {function(string)} getter The function which returns the
+ *      property's value.
+ * @return {Class.Property}
+ */
+Class.memoize = function memoize(getter, wait)
+    Class.Property({
+        configurable: true,
+        enumerable: true,
+        init: function (key) {
+            let done = false;
+
+            if (wait)
+                this.get = function replace() {
+                    let obj = this.instance || this;
+                    Object.defineProperty(obj, key,  {
+                        configurable: true, enumerable: false,
+                        get: function get() {
+                            util.waitFor(function () done);
+                            return this[key];
+                        }
+                    });
+
+                    util.yieldable(function () {
+                        let wait;
+                        for (var res in getter.call(obj)) {
+                            if (wait !== undefined)
+                                yield wait;
+                            wait = res;
+                        }
+                        Class.replaceProperty(obj, key, res);
+                        done = true;
+                    })();
+
+                    return this[key];
+                };
+            else
+                this.get = function replace() {
+                    let obj = this.instance || this;
+                    Class.replaceProperty(obj, key, null);
+                    return Class.replaceProperty(obj, key, getter.call(this, key));
+                };
+
+            this.set = function replace(val) Class.replaceProperty(this.instance || this, val);
+        }
+    });
+
+Class.replaceProperty = function replaceProperty(obj, prop, value) {
+    Object.defineProperty(obj, prop, { configurable: true, enumerable: true, value: value, writable: true });
+    return value;
+};
+Class.toString = function toString() "[class " + this.className + "]";
+Class.prototype = {
+    /**
+     * Initializes new instances of this class. Called automatically
+     * when new instances are created.
+     */
+    init: function c_init() {},
+
+    withSavedValues: function withSavedValues(names, callback, self) {
+        let vals = names.map(function (name) this[name], this);
+        try {
+            return callback.call(self || this);
+        }
+        finally {
+            names.forEach(function (name, i) this[name] = vals[i], this);
+        }
+    },
+
+    toString: function C_toString() {
+        if (this.toStringParams)
+            var params = "(" + this.toStringParams.map(function (m) isArray(m)  ? "[" + m + "]" :
+                                                                    isString(m) ? m.quote() : String(m))
+                                   .join(", ") + ")";
+        return "[instance " + this.constructor.className + (params || "") + "]";
+    },
+
+    /**
+     * Executes *callback* after *timeout* milliseconds. The value of
+     * 'this' is preserved in the invocation of *callback*.
+     *
+     * @param {function} callback The function to call after *timeout*
+     * @param {number} timeout The time, in milliseconds, to wait
+     *     before calling *callback*.
+     * @returns {nsITimer} The timer which backs this timeout.
+     */
+    timeout: function timeout(callback, timeout) {
+        const self = this;
+        function timeout_notify(timer) {
+            if (self.stale ||
+                    util.rehashing && !isinstance(Cu.getGlobalForObject(callback), ["BackstagePass"]))
+                return;
+            util.trapErrors(callback, self);
+        }
+        return services.Timer(timeout_notify, timeout || 0, services.Timer.TYPE_ONE_SHOT);
+    }
+};
+Class.makeClosure = function makeClosure() {
+    const self = this;
+    function closure(fn) {
+        function _closure() {
+            try {
+                return fn.apply(self, arguments);
+            }
+            catch (e if !(e instanceof FailedAssertion)) {
+                util.reportError(e);
+                throw e.stack ? e : Error(e);
+            }
+        }
+        _closure.wrapped = fn;
+        return _closure;
+    }
+
+    iter(properties(this), properties(this, true)).forEach(function (k) {
+        if (!__lookupGetter__.call(this, k) && callable(this[k]))
+            closure[k] = closure(this[k]);
+        else if (!(k in closure))
+            Object.defineProperty(closure, k, {
+                configurable: true,
+                enumerable: true,
+                get: function get_proxy() self[k],
+                set: function set_proxy(val) self[k] = val,
+            });
+    }, this);
+
+    return closure;
+};
+memoize(Class.prototype, "closure", Class.makeClosure);
+
+/**
+ * A base class generator for classes which implement XPCOM interfaces.
+ *
+ * @param {nsIIID|[nsIJSIID]} interfaces The interfaces which the class
+ *      implements.
+ * @param {Class} superClass A super class. @optional
+ * @returns {Class}
+ */
+function XPCOM(interfaces, superClass) {
+    interfaces = Array.concat(interfaces);
+
+    let shim = interfaces.reduce(function (shim, iface) shim.QueryInterface(iface),
+                                 Cc["@dactyl.googlecode.com/base/xpc-interface-shim"].createInstance());
+
+    let res = Class("XPCOM(" + interfaces + ")", superClass || Class, update(
+        iter.toObject([k, v === undefined || callable(v) ? function stub() null : v]
+                      for ([k, v] in Iterator(shim))),
+        { QueryInterface: XPCOMUtils.generateQI(interfaces) }));
+    shim = interfaces = null;
+    return res;
+}
+
+/**
+ * An abstract base class for classes that wish to inherit from Error.
+ */
+var ErrorBase = Class("ErrorBase", Error, {
+    level: 2,
+    init: function EB_init(message, level) {
+        level = level || 0;
+        update(this, Error(message))
+        this.message = message;
+
+        let frame = Components.stack;
+        for (let i = 0; i < this.level + level; i++) {
+            frame = frame.caller;
+            this.stack = this.stack.replace(/^.*\n/, "");
+        }
+        this.fileName = frame.filename;
+        this.lineNumber = frame.lineNumber;
+    }
+});
+
+/**
+ * Constructs a new Module class and instantiates an instance into the current
+ * module global object.
+ *
+ * @param {string} name The name of the instance.
+ * @param {Object} prototype The instance prototype.
+ * @param {Object} classProperties Properties to be applied to the class constructor.
+ * @returns {Class}
+ */
+function Module(name, prototype) {
+    let init = callable(prototype) ? 4 : 3;
+    const module = Class.apply(Class, Array.slice(arguments, 0, init));
+    let instance = module();
+    module.className = name.toLowerCase();
+
+    instance.INIT = update(Object.create(Module.INIT),
+                           arguments[init] || {});
+
+    currentModule[module.className] = instance;
+    defineModule.modules.push(instance);
+    return module;
+}
+Module.INIT = {
+    init: function Module_INIT_init(dactyl, modules, window) {
+        let args = arguments;
+
+        let locals = [];
+        for (let local = this.Local; local; local = local.super)
+            locals.push(local);
+
+        if (locals.length) {
+            let module = this, objs = {};
+            for (let i in locals) {
+                module = objs[i] = Object.create(module);
+                module.modules = modules;
+            }
+            module.isLocalModule = true;
+
+            modules.jsmodules[this.constructor.className] = module;
+            locals.reverse().forEach(function (fn, i) update(objs[i], fn.apply(module, args)))
+
+            memoize(module, "closure", Class.makeClosure);
+            module.instance = module;
+            module.init();
+
+            if (module.signals)
+                modules.dactyl.registerObservers(module);
+        }
+    }
+}
+
+/**
+ * @class Struct
+ *
+ * Creates a new Struct constructor, used for creating objects with
+ * a fixed set of named members. Each argument should be the name of
+ * a member in the resulting objects. These names will correspond to
+ * the arguments passed to the resultant constructor. Instances of
+ * the new struct may be treated very much like arrays, and provide
+ * many of the same methods.
+ *
+ *     const Point = Struct("x", "y", "z");
+ *     let p1 = Point(x, y, z);
+ *
+ * @returns {function} The constructor for the new Struct.
+ */
+function Struct() {
+    if (!/^[A-Z]/.test(arguments[0]))
+        var args = Array.slice(arguments, 0);
+    else {
+        var className = arguments[0];
+        args = Array.slice(arguments, 1);
+    }
+
+    const Struct = Class(className || "Struct", StructBase, {
+        length: args.length,
+        members: array.toObject(args.map(function (v, k) [v, k]))
+    });
+    args.forEach(function (name, i) {
+        Struct.prototype.__defineGetter__(name, function () this[i]);
+        Struct.prototype.__defineSetter__(name, function (val) { this[i] = val; });
+    });
+    return Struct;
+}
+let StructBase = Class("StructBase", Array, {
+    init: function struct_init() {
+        for (let i = 0; i < arguments.length; i++)
+            if (arguments[i] != undefined)
+                this[i] = arguments[i];
+    },
+
+    clone: function struct_clone() this.constructor.apply(null, this.slice()),
+
+    closure: Class.Property(Object.getOwnPropertyDescriptor(Class.prototype, "closure")),
+
+    get: function struct_get(key, val) this[this.members[key]],
+    set: function struct_set(key, val) this[this.members[key]] = val,
+
+    toString: function struct_toString() Class.prototype.toString.apply(this, arguments),
+
+    // Iterator over our named members
+    __iterator__: function struct__iterator__() {
+        let self = this;
+        return ([k, self[k]] for (k in keys(self.members)))
+    }
+}, {
+    fromArray: function fromArray(ary) {
+        if (!(ary instanceof this))
+            ary.__proto__ = this.prototype;
+        return ary;
+    },
+
+    /**
+     * Sets a lazily constructed default value for a member of
+     * the struct. The value is constructed once, the first time
+     * it is accessed and memoized thereafter.
+     *
+     * @param {string} key The name of the member for which to
+     *     provide the default value.
+     * @param {function} val The function which is to generate
+     *     the default value.
+     */
+    defaultValue: function defaultValue(key, val) {
+        let i = this.prototype.members[key];
+        this.prototype.__defineGetter__(i, function () (this[i] = val.call(this)));
+        this.prototype.__defineSetter__(i, function (value)
+            Class.replaceProperty(this, i, value));
+        return this;
+    },
+
+    localize: function localize(key, defaultValue) {
+        let i = this.prototype.members[key];
+        Object.defineProperty(this.prototype, i, require("messages").Messages.Localized(defaultValue).init(key, this.prototype));
+        return this;
+    }
+});
+
+var Timer = Class("Timer", {
+    init: function init(minInterval, maxInterval, callback, self) {
+        this._timer = services.Timer();
+        this.callback = callback;
+        this.self = self || this;
+        this.minInterval = minInterval;
+        this.maxInterval = maxInterval;
+        this.doneAt = 0;
+        this.latest = 0;
+    },
+
+    notify: function notify(timer, force) {
+        try {
+            if (!loaded || loaded.util && util.rehashing || typeof util === "undefined" || !force && this.doneAt == 0)
+                return;
+
+            this._timer.cancel();
+            this.latest = 0;
+            // minInterval is the time between the completion of the command and the next firing
+            this.doneAt = Date.now() + this.minInterval;
+
+            this.callback.call(this.self, this.arg);
+        }
+        catch (e) {
+            if (typeof util === "undefined")
+                dump("dactyl: " + e + "\n" + (e.stack || Error().stack));
+            else
+                util.reportError(e);
+        }
+        finally {
+            this.doneAt = Date.now() + this.minInterval;
+        }
+    },
+
+    tell: function tell(arg) {
+        if (arguments.length > 0)
+            this.arg = arg;
+
+        let now = Date.now();
+        if (this.doneAt == -1)
+            this._timer.cancel();
+
+        let timeout = this.minInterval;
+        if (now > this.doneAt && this.doneAt > -1)
+            timeout = 0;
+        else if (this.latest)
+            timeout = Math.min(timeout, this.latest - now);
+        else
+            this.latest = now + this.maxInterval;
+
+        this._timer.initWithCallback(this, Math.max(timeout, 0), this._timer.TYPE_ONE_SHOT);
+        this.doneAt = -1;
+    },
+
+    reset: function reset() {
+        this._timer.cancel();
+        this.doneAt = 0;
+    },
+
+    flush: function flush(force) {
+        if (this.doneAt == -1 || force)
+            this.notify(null, true);
+    }
+});
+
+/**
+ * Idempotent function which returns the UTF-8 encoded value of an
+ * improperly-decoded string.
+ *
+ * @param {string} str
+ * @returns {string}
+ */
+function UTF8(str) {
+    try {
+        return decodeURIComponent(escape(str));
+    }
+    catch (e) {
+        return str;
+    }
+}
+
+function octal(decimal) parseInt(decimal, 8);
+
+/**
+ * Iterates over an arbitrary object. The following iterator types are
+ * supported, and work as a user would expect:
+ *
+ *  • nsIDOMNodeIterator
+ *  • mozIStorageStatement
+ *
+ * Additionally, the following array-like objects yield a tuple of the
+ * form [index, element] for each contained element:
+ *
+ *  • nsIDOMHTMLCollection
+ *  • nsIDOMNodeList
+ *
+ * and the following likewise yield one element of the form
+ * [name, element] for each contained element:
+ *
+ *  • nsIDOMNamedNodeMap
+ *
+ * Duck typing is implemented for any other type. If the object
+ * contains the "enumerator" property, iter is called on that. If the
+ * property is a function, it is called first. If it contains the
+ * property "getNext" along with either "hasMoreItems" or "hasMore", it
+ * is iterated over appropriately.
+ *
+ * For all other cases, this function behaves exactly like the Iterator
+ * function.
+ *
+ * @param {object} obj
+ * @returns {Generator}
+ */
+function iter(obj) {
+    let args = arguments;
+    let res = Iterator(obj);
+
+    if (args.length > 1)
+        res = (function () {
+            for (let i = 0; i < args.length; i++)
+                for (let j in iter(args[i]))
+                    yield j;
+        })();
+    else if (isinstance(obj, ["Iterator", "Generator"]))
+        ;
+    else if (ctypes && ctypes.CData && obj instanceof ctypes.CData) {
+        while (obj.constructor instanceof ctypes.PointerType)
+            obj = obj.contents;
+        if (obj.constructor instanceof ctypes.ArrayType)
+            res = array.iterItems(obj);
+        else if (obj.constructor instanceof ctypes.StructType)
+            res = (function () {
+                for (let prop in values(obj.constructor.fields))
+                    yield let ([name, type] = Iterator(prop).next()) [name, obj[name]];
+            })();
+        else
+            return iter({});
+    }
+    else if (isinstance(obj, [Ci.nsIDOMHTMLCollection, Ci.nsIDOMNodeList]))
+        res = array.iterItems(obj);
+    else if (obj instanceof Ci.nsIDOMNamedNodeMap)
+        res = (function () {
+            for (let i = 0; i < obj.length; i++)
+                yield [obj.name, obj];
+        })();
+    else if (obj instanceof Ci.mozIStorageStatement)
+        res = (function (obj) {
+            while (obj.executeStep())
+                yield obj.row;
+            obj.reset();
+        })(obj);
+    else if ("getNext" in obj) {
+        if ("hasMoreElements" in obj)
+            res = (function () {
+                while (obj.hasMoreElements())
+                    yield obj.getNext();
+            })();
+        else if ("hasMore" in obj)
+            res = (function () {
+                while (obj.hasMore())
+                    yield obj.getNext();
+            })();
+    }
+    else if ("enumerator" in obj) {
+        if (callable(obj.enumerator))
+            return iter(obj.enumerator());
+        return iter(obj.enumerator);
+    }
+    res.__noSuchMethod__ = function __noSuchMethod__(meth, args) {
+        if (meth in iter)
+            var res = iter[meth].apply(iter, [this].concat(args));
+        else
+            res = let (ary = array(this))
+                ary[meth] ? ary[meth].apply(ary, args) : ary.__noSuchMethod__(meth, args);
+        if (isinstance(res, ["Iterator", "Generator"]))
+            return iter(res);
+        return res;
+    };
+    return res;
+}
+update(iter, {
+    toArray: function toArray(iter) array(iter).array,
+
+    // See array.prototype for API docs.
+    toObject: function toObject(iter) {
+        let obj = {};
+        for (let [k, v] in iter)
+            if (v instanceof Class.Property)
+                Object.defineProperty(obj, k, v.init(k, obj) || v);
+            else
+                obj[k] = v;
+        return obj;
+    },
+
+    compact: function compact(iter) (item for (item in iter) if (item != null)),
+
+    every: function every(iter, pred, self) {
+        pred = pred || util.identity;
+        for (let elem in iter)
+            if (!pred.call(self, elem))
+                return false;
+        return true;
+    },
+    some: function every(iter, pred, self) {
+        pred = pred || util.identity;
+        for (let elem in iter)
+            if (pred.call(self, elem))
+                return true;
+        return false;
+    },
+
+    filter: function filter(iter, pred, self) {
+        for (let elem in iter)
+            if (pred.call(self, elem))
+                yield elem;
+    },
+
+    /**
+     * Iterates over an iterable object and calls a callback for each
+     * element.
+     *
+     * @param {object} iter The iterator.
+     * @param {function} fn The callback.
+     * @param {object} self The this object for *fn*.
+     */
+    forEach: function forEach(iter, func, self) {
+        for (let val in iter)
+            func.call(self, val);
+    },
+
+    indexOf: function indexOf(iter, elem) {
+        let i = 0;
+        for (let item in iter) {
+            if (item == elem)
+                return i;
+            i++;
+        }
+    },
+
+    /**
+     * Returns the array that results from applying *func* to each property of
+     * *obj*.
+     *
+     * @param {Object} obj
+     * @param {function} func
+     * @returns {Array}
+     */
+    map: function map(iter, func, self) {
+        for (let i in iter)
+            yield func.call(self, i);
+    },
+
+    /**
+     * Returns the nth member of the given array that matches the
+     * given predicate.
+     */
+    nth: function nth(iter, pred, n, self) {
+        if (typeof pred === "number")
+            [pred, n] = [function () true, pred]; // Hack.
+
+        for (let elem in iter)
+            if (pred.call(self, elem) && n-- === 0)
+                return elem;
+        return undefined;
+    },
+
+    sort: function sort(iter, fn, self)
+        array(this.toArray(iter).sort(fn, self)),
+
+    uniq: function uniq(iter) {
+        let seen = {};
+        for (let item in iter)
+            if (!set.add(seen, item))
+                yield item;
+    },
+
+    /**
+     * Zips the contents of two arrays. The resulting array is the length of
+     * ary1, with any shortcomings of ary2 replaced with null strings.
+     *
+     * @param {Array} ary1
+     * @param {Array} ary2
+     * @returns {Array}
+     */
+    zip: function zip(iter1, iter2) {
+        try {
+            yield [iter1.next(), iter2.next()];
+        }
+        catch (e if e instanceof StopIteration) {}
+    }
+});
+
+/**
+ * Array utility methods.
+ */
+var array = Class("array", Array, {
+    init: function (ary) {
+        if (isinstance(ary, ["Iterator", "Generator"]) || "__iterator__" in ary)
+            ary = [k for (k in ary)];
+        else if (ary.length)
+            ary = Array.slice(ary);
+
+        return {
+            __proto__: ary,
+            __iterator__: function () this.iterItems(),
+            __noSuchMethod__: function (meth, args) {
+                var res = array[meth].apply(null, [this.array].concat(args));
+                if (isArray(res))
+                    return array(res);
+                if (isinstance(res, ["Iterator", "Generator"]))
+                    return iter(res);
+                return res;
+            },
+            array: ary,
+            toString: function () this.array.toString(),
+            concat: function () this.__noSuchMethod__("concat", Array.slice(arguments)),
+            filter: function () this.__noSuchMethod__("filter", Array.slice(arguments)),
+            map: function () this.__noSuchMethod__("map", Array.slice(arguments))
+        };
+    }
+}, {
+    /**
+     * Converts an array to an object. As in lisp, an assoc is an
+     * array of key-value pairs, which maps directly to an object,
+     * as such:
+     *    [["a", "b"], ["c", "d"]] -> { a: "b", c: "d" }
+     *
+     * @param {Array[]} assoc
+     * @... {string} 0 - Key
+     * @...          1 - Value
+     */
+    toObject: function toObject(assoc) {
+        let obj = {};
+        assoc.forEach(function ([k, v]) {
+            if (v instanceof Class.Property)
+                Object.defineProperty(obj, k, v.init(k, obj) || v);
+            else
+                obj[k] = v;
+        });
+        return obj;
+    },
+
+    /**
+     * Compacts an array, removing all elements that are null or undefined:
+     *    ["foo", null, "bar", undefined] -> ["foo", "bar"]
+     *
+     * @param {Array} ary
+     * @returns {Array}
+     */
+    compact: function compact(ary) ary.filter(function (item) item != null),
+
+    /**
+     * Returns true if each element of ary1 is equal to the
+     * corresponding element in ary2.
+     *
+     * @param {Array} ary1
+     * @param {Array} ary2
+     * @returns {boolean}
+     */
+    equals: function (ary1, ary2)
+        ary1.length === ary2.length && Array.every(ary1, function (e, i) e === ary2[i]),
+
+    /**
+     * Flattens an array, such that all elements of the array are
+     * joined into a single array:
+     *    [["foo", ["bar"]], ["baz"], "quux"] -> ["foo", ["bar"], "baz", "quux"]
+     *
+     * @param {Array} ary
+     * @returns {Array}
+     */
+    flatten: function flatten(ary) ary.length ? Array.prototype.concat.apply([], ary) : [],
+
+    /**
+     * Returns an Iterator for an array's values.
+     *
+     * @param {Array} ary
+     * @returns {Iterator(Object)}
+     */
+    iterValues: function iterValues(ary) {
+        for (let i = 0; i < ary.length; i++)
+            yield ary[i];
+    },
+
+    /**
+     * Returns an Iterator for an array's indices and values.
+     *
+     * @param {Array} ary
+     * @returns {Iterator([{number}, {Object}])}
+     */
+    iterItems: function iterItems(ary) {
+        let length = ary.length;
+        for (let i = 0; i < length; i++)
+            yield [i, ary[i]];
+    },
+
+    /**
+     * Returns the nth member of the given array that matches the
+     * given predicate.
+     */
+    nth: function nth(ary, pred, n, self) {
+        for (let elem in values(ary))
+            if (pred.call(self, elem) && n-- === 0)
+                return elem;
+        return undefined;
+    },
+
+    /**
+     * Filters out all duplicates from an array. If *unsorted* is false, the
+     * array is sorted before duplicates are removed.
+     *
+     * @param {Array} ary
+     * @param {boolean} unsorted
+     * @returns {Array}
+     */
+    uniq: function uniq(ary, unsorted) {
+        let res = [];
+        if (unsorted) {
+            for (let item in values(ary))
+                if (res.indexOf(item) == -1)
+                    res.push(item);
+        }
+        else {
+            for (let [, item] in Iterator(ary.sort())) {
+                if (item != last || !res.length)
+                    res.push(item);
+                var last = item;
+            }
+        }
+        return res;
+    },
+
+    /**
+     * Zips the contents of two arrays. The resulting array is the length of
+     * ary1, with any shortcomings of ary2 replaced with null strings.
+     *
+     * @param {Array} ary1
+     * @param {Array} ary2
+     * @returns {Array}
+     */
+    zip: function zip(ary1, ary2) {
+        let res = [];
+        for (let [i, item] in Iterator(ary1))
+            res.push([item, i in ary2 ? ary2[i] : ""]);
+        return res;
+    }
+});
+
+endModule();
+
+// catch(e){dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack);}
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/bookmarkcache.jsm b/common/modules/bookmarkcache.jsm
new file mode 100644 (file)
index 0000000..afaa56b
--- /dev/null
@@ -0,0 +1,235 @@
+// Copyright ©2008-2010 Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("bookmarkcache", {
+    exports: ["Bookmark", "BookmarkCache", "Keyword", "bookmarkcache"],
+    require: ["services", "storage", "util"]
+}, this);
+
+var Bookmark = Struct("url", "title", "icon", "post", "keyword", "tags", "charset", "id");
+var Keyword = Struct("keyword", "title", "icon", "url");
+Bookmark.defaultValue("icon", function () BookmarkCache.getFavicon(this.url));
+update(Bookmark.prototype, {
+    get extra() [
+        ["keyword", this.keyword,         "Keyword"],
+        ["tags",    this.tags.join(", "), "Tag"]
+    ].filter(function (item) item[1]),
+
+    get uri() util.newURI(this.url),
+
+    encodeURIComponent: function _encodeURIComponent(str) {
+        if (!this.charset || this.charset === "UTF-8")
+            return encodeURIComponent(str);
+        let conv = services.CharsetConv(this.charset);
+        return escape(conv.ConvertFromUnicode(str) + conv.Finish());
+    }
+})
+Bookmark.setter = function (key, func) this.prototype.__defineSetter__(key, func);
+Bookmark.setter("url", function (val) {
+    let tags = this.tags;
+    this.tags = null;
+    services.bookmarks.changeBookmarkURI(this.id, val);
+    this.tags = tags;
+});
+Bookmark.setter("title", function (val) { services.bookmarks.setItemTitle(this.id, val); });
+Bookmark.setter("post", function (val) { bookmarkcache.annotate(this.id, bookmarkcache.POST, val); });
+Bookmark.setter("charset", function (val) { bookmarkcache.annotate(this.id, bookmarkcache.CHARSET, val); });
+Bookmark.setter("keyword", function (val) { services.bookmarks.setKeywordForBookmark(this.id, val); });
+Bookmark.setter("tags", function (val) {
+    services.tagging.untagURI(this.uri, null);
+    if (val)
+        services.tagging.tagURI(this.uri, val);
+});
+
+var name = "bookmark-cache";
+
+var BookmarkCache = Module("BookmarkCache", XPCOM(Ci.nsINavBookmarkObserver), {
+    POST: "bookmarkProperties/POSTData",
+    CHARSET: "dactyl/charset",
+
+    init: function init() {
+        services.bookmarks.addObserver(this, false);
+    },
+
+    cleanup: function cleanup() {
+        services.bookmarks.removeObserver(this);
+    },
+
+    __iterator__: function () (val for ([, val] in Iterator(bookmarkcache.bookmarks))),
+
+    get bookmarks() Class.replaceProperty(this, "bookmarks", this.load()),
+
+    keywords: Class.memoize(function () array.toObject([[b.keyword, b] for (b in this) if (b.keyword)])),
+
+    rootFolders: ["toolbarFolder", "bookmarksMenuFolder", "unfiledBookmarksFolder"]
+        .map(function (s) services.bookmarks[s]),
+
+    _deleteBookmark: function deleteBookmark(id) {
+        let result = this.bookmarks[id] || null;
+        delete this.bookmarks[id];
+        return result;
+    },
+
+    _loadBookmark: function loadBookmark(node) {
+        if (node.uri == null) // How does this happen?
+            return false;
+        let uri = util.newURI(node.uri);
+        let keyword = services.bookmarks.getKeywordForBookmark(node.itemId);
+        let tags = services.tagging.getTagsForURI(uri, {}) || [];
+        let post = BookmarkCache.getAnnotation(node.itemId, this.POST);
+        let charset = BookmarkCache.getAnnotation(node.itemId, this.CHARSET);
+        return Bookmark(node.uri, node.title, node.icon && node.icon.spec, post, keyword, tags, charset, node.itemId);
+    },
+
+    annotate: function (id, key, val, timespan) {
+        if (val)
+            services.annotation.setItemAnnotation(id, key, val, 0,
+                                                  timespan || services.annotation.EXPIRE_NEVER);
+        else if (services.annotation.itemHasAnnotation(id, key))
+            services.annotation.removeItemAnnotation(id, key);
+    },
+
+    get: function (url) {
+        let ids = services.bookmarks.getBookmarkIdsForURI(util.newURI(url), {});
+        for (let id in values(ids))
+            if (id in this.bookmarks)
+                return this.bookmarks[id];
+        return null;
+    },
+
+    readBookmark: function readBookmark(id) ({
+        itemId: id,
+        uri:    services.bookmarks.getBookmarkURI(id).spec,
+        title:  services.bookmarks.getItemTitle(id)
+    }),
+
+    findRoot: function findRoot(id) {
+        do {
+            var root = id;
+            id = services.bookmarks.getFolderIdForItem(id);
+        } while (id != services.bookmarks.placesRoot && id != root);
+        return root;
+    },
+
+    isBookmark: function (id) this.rootFolders.indexOf(this.findRoot(id)) >= 0,
+
+    /**
+     * Returns true if the given URL is bookmarked and that bookmark is
+     * not a Live Bookmark.
+     *
+     * @param {nsIURI|string} url The URL of which to check the bookmarked
+     *     state.
+     * @returns {boolean}
+     */
+    isBookmarked: function isBookmarked(uri) {
+        if (isString(uri))
+            uri = util.newURI(uri);
+
+        try {
+            return services.bookmarks
+                           .getBookmarkIdsForURI(uri, {})
+                           .some(this.closure.isRegularBookmark);
+        }
+        catch (e) {
+            return false;
+        }
+    },
+
+    isRegularBookmark: function isRegularBookmark(id) {
+        do {
+            var root = id;
+            if (services.livemark && services.livemark.isLivemark(id))
+                return false;
+            id = services.bookmarks.getFolderIdForItem(id);
+        } while (id != services.bookmarks.placesRoot && id != root);
+        return this.rootFolders.indexOf(root) >= 0;
+    },
+
+    // Should be made thread safe.
+    load: function load() {
+        let bookmarks = {};
+
+        let folders = this.rootFolders.slice();
+        let query = services.history.getNewQuery();
+        let options = services.history.getNewQueryOptions();
+        while (folders.length > 0) {
+            query.setFolders(folders, 1);
+            folders.shift();
+            let result = services.history.executeQuery(query, options);
+            let folder = result.root;
+            folder.containerOpen = true;
+
+            // iterate over the immediate children of this folder
+            for (let i = 0; i < folder.childCount; i++) {
+                let node = folder.getChild(i);
+                if (node.type == node.RESULT_TYPE_FOLDER)   // folder
+                    folders.push(node.itemId);
+                else if (node.type == node.RESULT_TYPE_URI) // bookmark
+                    bookmarks[node.itemId] = this._loadBookmark(node);
+            }
+
+            // close a container after using it!
+            folder.containerOpen = false;
+        }
+
+        return bookmarks;
+    },
+
+    onItemAdded: function onItemAdded(itemId, folder, index) {
+        if (services.bookmarks.getItemType(itemId) == services.bookmarks.TYPE_BOOKMARK) {
+            if (this.isBookmark(itemId)) {
+                let bmark = this._loadBookmark(this.readBookmark(itemId));
+                this.bookmarks[bmark.id] = bmark;
+                storage.fireEvent(name, "add", bmark);
+                delete this.keywords;
+            }
+        }
+    },
+    onItemRemoved: function onItemRemoved(itemId, folder, index) {
+        let result = this._deleteBookmark(itemId);
+        delete this.keywords;
+        if (result)
+            storage.fireEvent(name, "remove", result);
+    },
+    onItemChanged: function onItemChanged(itemId, property, isAnnotation, value) {
+        if (isAnnotation)
+            if (property === this.POST)
+                [property, value] = ["post", BookmarkCache.getAnnotation(itemId, property)];
+            else if (property === this.CHARSET)
+                [property, value] = ["charset", BookmarkCache.getAnnotation(itemId, property)];
+            else
+                return;
+
+        let bookmark = this.bookmarks[itemId];
+        if (bookmark) {
+            if (property == "keyword")
+                delete this.keywords;
+            if (property == "tags")
+                value = services.tagging.getTagsForURI(bookmark.uri, {});
+            if (property in bookmark) {
+                bookmark[bookmark.members[property]] = value;
+                storage.fireEvent(name, "change", { __proto__: bookmark, changed: property });
+            }
+        }
+    }
+}, {
+    getAnnotation: function getAnnotation(item, anno)
+        services.annotation.itemHasAnnotation(item, anno) ?
+        services.annotation.getItemAnnotation(item, anno) : null,
+    getFavicon: function getFavicon(uri) {
+        try {
+            return services.favicon.getFaviconImageForPage(util.newURI(uri)).spec;
+        }
+        catch (e) {
+            return "";
+        }
+    }
+});
+
+endModule();
+
+// vim: set fdm=marker sw=4 sts=4 et ft=javascript:
diff --git a/common/modules/bootstrap.jsm b/common/modules/bootstrap.jsm
new file mode 100644 (file)
index 0000000..005ba16
--- /dev/null
@@ -0,0 +1,120 @@
+// Copyright (c) 2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+var EXPORTED_SYMBOLS = ["JSMLoader"];
+
+var BOOTSTRAP_CONTRACT = "@dactyl.googlecode.com/base/bootstrap";
+var JSMLoader = BOOTSTRAP_CONTRACT in Components.classes &&
+    Components.classes[BOOTSTRAP_CONTRACT].getService().wrappedJSObject.loader;
+
+if (!JSMLoader && "@mozilla.org/fuel/application;1" in Components.classes)
+    JSMLoader = Components.classes["@mozilla.org/fuel/application;1"]
+                          .getService(Components.interfaces.extIApplication)
+                          .storage.get("dactyl.JSMLoader", null);
+
+if (JSMLoader && JSMLoader.bump === 4)
+    JSMLoader.global = this;
+else
+    JSMLoader = {
+        bump: 4,
+        builtin: Components.utils.Sandbox(this),
+        canonical: {},
+        factories: [],
+        global: this,
+        globals: JSMLoader ? JSMLoader.globals : {},
+        io: Components.classes["@mozilla.org/network/io-service;1"].getService(Components.interfaces.nsIIOService),
+        loader: Components.classes["@mozilla.org/moz/jssubscript-loader;1"].getService(Components.interfaces.mozIJSSubScriptLoader),
+        manager: Components.manager.QueryInterface(Components.interfaces.nsIComponentRegistrar),
+        stale: JSMLoader ? JSMLoader.stale : {},
+        suffix: "",
+        init: function init(suffix) {
+            this.initialized = true;
+            this.suffix = suffix || "";
+
+            let base = this.load("base.jsm", this.global);
+            this.global.EXPORTED_SYMBOLS = base.EXPORTED_SYMBOLS;
+            this.global.JSMLoader = this;
+            base.JSMLoader = this;
+        },
+        getTarget: function getTarget(url) {
+            if (url.indexOf(":") === -1)
+                url = "resource://dactyl" + this.suffix + "/" + url;
+
+            let chan = this.io.newChannel(url, null, null);
+            chan.cancel(Components.results.NS_BINDING_ABORTED);
+            return chan.name;
+        },
+        load: function load(name, target) {
+            let url = name;
+            if (url.indexOf(":") === -1)
+                url = "resource://dactyl" + this.suffix + "/" + url;
+            let targetURL = this.getTarget(url);
+
+            let stale = this.stale[name] || this.stale[targetURL];
+            if (stale) {
+                delete this.stale[name];
+                delete this.stale[targetURL];
+
+                let loadURL = url.replace(RegExp("^(resource://dactyl)/"), "$1" + this.suffix + "/");
+
+                let global = this.globals[name];
+                if (stale === targetURL)
+                    this.loadSubScript(loadURL, global.global || global);
+            }
+
+            try {
+                let global = Components.utils.import(url, target);
+                return this.globals[name] = global;
+            }
+            catch (e) {
+                dump("Importing " + url + ": " + e + "\n" + (e.stack || Error().stack));
+                throw e;
+            }
+        },
+        loadSubScript: function loadSubScript() this.loader.loadSubScript.apply(this.loader, arguments),
+        cleanup: function unregister() {
+            for each (let factory in this.factories.splice(0))
+                this.manager.unregisterFactory(factory.classID, factory);
+        },
+        purge: function purge() {
+            dump("dactyl: JSMLoader: purge\n");
+
+            for (let [url, global] in Iterator(this.globals)) {
+                if (url === "bootstrap.jsm" || url === "resource://dactyl/bootstrap.jsm")
+                    continue;
+
+                let target = this.getTarget(url);
+                this.stale[url] = target;
+                this.stale[target] = target;
+
+                for each (let prop in Object.getOwnPropertyNames(global))
+                    try {
+                        if (!(prop in this.builtin) &&
+                            ["JSMLoader", "set", "EXPORTED_SYMBOLS"].indexOf(prop) < 0 &&
+                            !global.__lookupGetter__(prop))
+                            global[prop] = undefined;
+                    }
+                    catch (e) {
+                        dump("Deleting property " + prop + " on " + url + ":\n    " + e + "\n");
+                        Components.utils.reportError(e);
+                    }
+            }
+        },
+
+        registerFactory: function registerFactory(factory) {
+            this.manager.registerFactory(factory.classID,
+                                         String(factory.classID),
+                                         factory.contractID,
+                                         factory);
+            this.factories.push(factory);
+        }
+    };
+
+}catch(e){ dump(e + "\n" + (e.stack || Error().stack)); Components.utils.reportError(e) }
+
+// vim: set fdm=marker sw=4 sts=4 et ft=javascript:
diff --git a/common/modules/commands.jsm b/common/modules/commands.jsm
new file mode 100644 (file)
index 0000000..1036f20
--- /dev/null
@@ -0,0 +1,1649 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("commands", {
+    exports: ["ArgType", "Command", "Commands", "CommandOption", "Ex", "commands"],
+    require: ["contexts", "messages", "util"],
+    use: ["config", "options", "services", "template"]
+}, this);
+
+/**
+ * A structure representing the options available for a command.
+ *
+ * Do NOT create instances of this class yourself, use the helper method
+ * {@see Commands#add} instead
+ *
+ * @property {[string]} names An array of option names. The first name
+ *     is the canonical option name.
+ * @property {number} type The option's value type. This is one of:
+ *         (@link CommandOption.NOARG),
+ *         (@link CommandOption.STRING),
+ *         (@link CommandOption.BOOL),
+ *         (@link CommandOption.INT),
+ *         (@link CommandOption.FLOAT),
+ *         (@link CommandOption.LIST),
+ *         (@link CommandOption.ANY)
+ * @property {function} validator A validator function
+ * @property {function (CompletionContext, object)} completer A list of
+ *    completions, or a completion function which will be passed a
+ *    {@link CompletionContext} and an object like that returned by
+ *    {@link commands.parseArgs} with the following additional keys:
+ *      completeOpt - The name of the option currently being completed.
+ * @property {boolean} multiple Whether this option can be specified multiple times
+ * @property {string} description A description of the option
+ * @property {object} default The option's default value
+ */
+
+var CommandOption = Struct("names", "type", "validator", "completer", "multiple", "description", "default");
+CommandOption.defaultValue("description", function () "");
+CommandOption.defaultValue("type", function () CommandOption.NOARG);
+CommandOption.defaultValue("multiple", function () false);
+
+var ArgType = Struct("description", "parse");
+update(CommandOption, {
+    /**
+     * @property {object} The option argument is unspecified. Any argument
+     *     is accepted and caller is responsible for parsing the return
+     *     value.
+     * @final
+     */
+    ANY: 0,
+
+    /**
+     * @property {object} The option doesn't accept an argument.
+     * @final
+     */
+    NOARG: ArgType("no arg",  function (arg) !arg || null),
+    /**
+     * @property {object} The option accepts a boolean argument.
+     * @final
+     */
+    BOOL: ArgType("boolean", function parseBoolArg(val) Commands.parseBool(val)),
+    /**
+     * @property {object} The option accepts a string argument.
+     * @final
+     */
+    STRING: ArgType("string", function (val) val),
+    /**
+     * @property {object} The option accepts an integer argument.
+     * @final
+     */
+    INT: ArgType("int", function parseIntArg(val) parseInt(val)),
+    /**
+     * @property {object} The option accepts a float argument.
+     * @final
+     */
+    FLOAT: ArgType("float", function parseFloatArg(val) parseFloat(val)),
+    /**
+     * @property {object} The option accepts a string list argument.
+     *     E.g. "foo,bar"
+     * @final
+     */
+    LIST: ArgType("list", function parseListArg(arg, quoted) Option.splitList(quoted))
+});
+
+/**
+ * A class representing Ex commands. Instances are created by
+ * the {@link Commands} class.
+ *
+ * @param {string[]} specs The names by which this command can be invoked.
+ *     These are specified in the form "com[mand]" where "com" is a unique
+ *     command name prefix.
+ * @param {string} description A short one line description of the command.
+ * @param {function} action The action invoked by this command when executed.
+ * @param {Object} extraInfo An optional extra configuration hash. The
+ *     following properties are supported.
+ *         argCount    - see {@link Command#argCount}
+ *         bang        - see {@link Command#bang}
+ *         completer   - see {@link Command#completer}
+ *         count       - see {@link Command#count}
+ *         domains     - see {@link Command#domains}
+ *         heredoc     - see {@link Command#heredoc}
+ *         literal     - see {@link Command#literal}
+ *         options     - see {@link Command#options}
+ *         privateData - see {@link Command#privateData}
+ *         serialize   - see {@link Command#serialize}
+ *         subCommand  - see {@link Command#subCommand}
+ * @optional
+ * @private
+ */
+var Command = Class("Command", {
+    init: function init(specs, description, action, extraInfo) {
+        specs = Array.concat(specs); // XXX
+        let parsedSpecs = extraInfo.parsedSpecs || Command.parseSpecs(specs);
+
+        this.specs = specs;
+        this.shortNames = array.compact(parsedSpecs.map(function (n) n[1]));
+        this.longNames = parsedSpecs.map(function (n) n[0]);
+        this.name = this.longNames[0];
+        this.names = array.flatten(parsedSpecs);
+        this.description = description;
+        this.action = action;
+
+        if (extraInfo)
+            update(this, extraInfo);
+        if (this.options)
+            this.options = this.options.map(CommandOption.fromArray, CommandOption);
+        for each (let option in this.options)
+            option.localeName = ["command", this.name, option.names[0]];
+    },
+
+    get toStringParams() [this.name, this.hive.name],
+
+    get identifier() this.hive.prefix + this.name,
+
+    get helpTag() ":" + this.name,
+
+    get lastCommand() this._lastCommand || commandline.command,
+    set lastCommand(val) { this._lastCommand = val; },
+
+    /**
+     * Execute this command.
+     *
+     * @param {Args} args The Args object passed to {@link #action}.
+     * @param {Object} modifiers Any modifiers to be passed to {@link #action}.
+     */
+    execute: function execute(args, modifiers) {
+        const { dactyl } = this.modules;
+
+        let context = args.context;
+        if (this.deprecated && !set.add(this.complained, context ? context.file : "[Command Line]")) {
+            let loc = contexts.context ? context.file + ":" + context.line + ": " : "";
+            dactyl.echoerr(loc + ":" + this.name + " is deprecated" +
+                           (isString(this.deprecated) ? ": " + this.deprecated : ""));
+        }
+
+        modifiers = modifiers || {};
+
+        if (args.count != null && !this.count)
+            throw FailedAssertion(_("command.noRange"));
+        if (args.bang && !this.bang)
+            throw FailedAssertion(_("command.noBang"));
+
+        return !dactyl.trapErrors(function exec() {
+            let extra = this.hive.argsExtra(args);
+            for (let k in properties(extra))
+                if (!(k in args))
+                    Object.defineProperty(args, k, Object.getOwnPropertyDescriptor(extra, k));
+
+            if (this.always)
+                this.always(args, modifiers);
+
+            if (!context || !context.noExecute)
+                this.action(args, modifiers);
+        }, this);
+    },
+
+    /**
+     * Returns whether this command may be invoked via *name*.
+     *
+     * @param {string} name The candidate name.
+     * @returns {boolean}
+     */
+    hasName: function hasName(name) this.parsedSpecs.some(
+        function ([long, short]) name.indexOf(short) == 0 && long.indexOf(name) == 0),
+
+    /**
+     * A helper function to parse an argument string.
+     *
+     * @param {string} args The argument string to parse.
+     * @param {CompletionContext} complete A completion context.
+     *     Non-null when the arguments are being parsed for completion
+     *     purposes.
+     * @param {Object} extra Extra keys to be spliced into the
+     *     returned Args object.
+     * @returns {Args}
+     * @see Commands#parseArgs
+     */
+    parseArgs: function parseArgs(args, complete, extra) this.modules.commands.parseArgs(args, {
+        __proto__: this,
+        complete: complete,
+        extra: extra
+    }),
+
+    complained: Class.memoize(function () ({})),
+
+    /**
+     * @property {string[]} All of this command's name specs. e.g., "com[mand]"
+     */
+    specs: null,
+    /** @property {string[]} All of this command's short names, e.g., "com" */
+    shortNames: null,
+    /**
+     * @property {string[]} All of this command's long names, e.g., "command"
+     */
+    longNames: null,
+
+    /** @property {string} The command's canonical name. */
+    name: null,
+    /** @property {string[]} All of this command's long and short names. */
+    names: null,
+
+    /** @property {string} This command's description, as shown in :listcommands */
+    description: Messages.Localized(""),
+    /**
+     * @property {function (Args)} The function called to execute this command.
+     */
+    action: null,
+    /**
+     * @property {string} This command's argument count spec.
+     * @see Commands#parseArguments
+     */
+    argCount: 0,
+    /**
+     * @property {function (CompletionContext, Args)} This command's completer.
+     * @see CompletionContext
+     */
+    completer: null,
+    /** @property {boolean} Whether this command accepts a here document. */
+    hereDoc: false,
+    /**
+     * @property {boolean} Whether this command may be called with a bang,
+     *     e.g., :com!
+     */
+    bang: false,
+    /**
+     * @property {boolean} Whether this command may be called with a count,
+     *     e.g., :12bdel
+     */
+    count: false,
+    /**
+     * @property {function(args)} A function which should return a list
+     *     of domains referenced in the given args. Used in determining
+     *     whether to purge the command from history when clearing
+     *     private data.
+     */
+    domains: function (args) [],
+    /**
+     * @property {boolean} At what index this command's literal arguments
+     *     begin. For instance, with a value of 2, all arguments starting with
+     *     the third are parsed as a single string, with all quoting characters
+     *     passed literally. This is especially useful for commands which take
+     *     key mappings or Ex command lines as arguments.
+     */
+    literal: null,
+    /**
+     * @property {Array} The options this command takes.
+     * @see Commands@parseArguments
+     */
+    options: [],
+
+    optionMap: Class.memoize(function () array(this.options)
+                .map(function (opt) opt.names.map(function (name) [name, opt]))
+                .flatten().toObject()),
+
+    newArgs: function newArgs(base) {
+        let res = [];
+        update(res, base);
+        res.__proto__ = this.argsPrototype;
+        return res;
+    },
+
+    argsPrototype: Class.memoize(function argsPrototype() {
+        let res = update([], {
+                __iterator__: function AP__iterator__() array.iterItems(this),
+
+                command: this,
+
+                explicitOpts: Class.memoize(function () ({})),
+
+                has: function AP_has(opt) set.has(this.explicitOpts, opt) || typeof opt === "number" && set.has(this, opt),
+
+                get literalArg() this.command.literal != null && this[this.command.literal] || "",
+
+                // TODO: string: Class.memoize(function () { ... }),
+
+                verify: function verify() {
+                    if (this.command.argCount) {
+                        util.assert((this.length > 0 || !/^[1+]$/.test(this.command.argCount)) &&
+                                    (this.literal == null || !/[1+]/.test(this.command.argCount) || /\S/.test(this.literalArg || "")),
+                                     _("error.argumentRequired"));
+
+                        util.assert((this.length == 0 || this.command.argCount !== "0") &&
+                                    (this.length <= 1 || !/^[01?]$/.test(this.command.argCount)),
+                                    _("error.trailing"));
+                    }
+                }
+        });
+
+        this.options.forEach(function (opt) {
+            if (opt.default !== undefined)
+                Object.defineProperty(res, opt.names[0],
+                                      Object.getOwnPropertyDescriptor(opt, "default") ||
+                                          { configurable: true, enumerable: true, get: function () opt.default });
+        });
+
+        return res;
+    }),
+
+    /**
+     * @property {boolean|function(args)} When true, invocations of this
+     *     command may contain private data which should be purged from
+     *     saved histories when clearing private data. If a function, it
+     *     should return true if an invocation with the given args
+     *     contains private data
+     */
+    privateData: true,
+    /**
+     * @property {function} Should return an array of *Object*s suitable to be
+     *     passed to {@link Commands#commandToString}, one for each past
+     *     invocation which should be restored on subsequent @dactyl startups.
+     */
+    serialize: null,
+    serialGroup: 50,
+    /**
+     * @property {number} If this command takes another ex command as an
+     *     argument, the index of that argument. Used in determining whether to
+     *     purge the command from history when clearing private data.
+     */
+    subCommand: null,
+    /**
+     * @property {boolean} Specifies whether this is a user command. User
+     *     commands may be created by plugins, or directly by users, and,
+     *     unlike basic commands, may be overwritten. Users and plugin authors
+     *     should create only user commands.
+     */
+    user: false,
+    /**
+     * @property {string} For commands defined via :command, contains the Ex
+     *     command line to be executed upon invocation.
+     */
+    replacementText: null
+}, {
+    // TODO: do we really need more than longNames as a convenience anyway?
+    /**
+     *  Converts command name abbreviation specs of the form
+     *  'shortname[optional-tail]' to short and long versions:
+     *      ["abc[def]", "ghijkl"] ->  [["abcdef", "abc"], ["ghijlk"]]
+     *
+     *  @param {Array} specs An array of command name specs to parse.
+     *  @returns {Array}
+     */
+    parseSpecs: function parseSpecs(specs) {
+        return specs.map(function (spec) {
+            let [, head, tail] = /([^[]+)(?:\[(.*)])?/.exec(spec);
+            return tail ? [head + tail, head] : [head];
+        });
+    }
+});
+
+// Prototype.
+var Ex = Module("Ex", {
+    Local: function Local(dactyl, modules, window) ({
+        get commands() modules.commands,
+        get context() modules.contexts.context
+    }),
+
+    _args: function E_args(cmd, args) {
+        args = Array.slice(args);
+
+        let res = cmd.newArgs({ context: this.context });
+        if (isObject(args[0]))
+            for (let [k, v] in Iterator(args.shift()))
+                if (k == "!")
+                    res.bang = v;
+                else if (k == "#")
+                    res.count = v;
+                else {
+                    let opt = cmd.optionMap["-" + k];
+                    let val = opt.type && opt.type.parse(v);
+                    util.assert(val != null && (typeof val !== "number" || !isNaN(val)),
+                                _("option.noSuch", k));
+                    Class.replaceProperty(args, opt.names[0], val);
+                    args.explicitOpts[opt.names[0]] = val;
+                }
+        for (let [i, val] in array.iterItems(args))
+            res[i] = String(val);
+        return res;
+    },
+
+    _complete: function E_complete(cmd) let (self = this)
+        function _complete(context, func, obj, args) {
+            args = self._args(cmd, args);
+            args.completeArg = args.length - 1;
+            if (cmd.completer && args.length)
+                return cmd.completer(context, args);
+        },
+
+    _run: function E_run(name) {
+        const self = this;
+        let cmd = this.commands.get(name);
+        util.assert(cmd, _("command.noSuch"));
+
+        return update(function exCommand(options) {
+            let args = self._args(cmd, arguments);
+            args.verify();
+            return cmd.execute(args);
+        }, {
+            dactylCompleter: self._complete(cmd)
+        });
+    },
+
+    __noSuchMethod__: function __noSuchMethod__(meth, args) this._run(meth).apply(this, args)
+});
+
+var CommandHive = Class("CommandHive", Contexts.Hive, {
+    init: function init(group) {
+        init.supercall(this, group);
+        this._map = {};
+        this._list = [];
+    },
+
+    /** @property {Iterator(Command)} @private */
+    __iterator__: function __iterator__() array.iterValues(this._list.sort(function (a, b) a.name > b.name)),
+
+    /** @property {string} The last executed Ex command line. */
+    repeat: null,
+
+    /**
+     * Adds a new command to the builtin hive. Accessible only to core
+     * dactyl code. Plugins should use group.commands.add instead.
+     *
+     * @param {string[]} names The names by which this command can be
+     *     invoked. The first name specified is the command's canonical
+     *     name.
+     * @param {string} description A description of the command.
+     * @param {function} action The action invoked by this command.
+     * @param {Object} extra An optional extra configuration hash.
+     * @optional
+     */
+    add: function add(names, description, action, extra, replace) {
+        const { commands, contexts } = this.modules;
+
+        extra = extra || {};
+        if (!extra.definedAt)
+            extra.definedAt = contexts.getCaller(Components.stack.caller);
+
+        extra.hive = this;
+        extra.parsedSpecs = Command.parseSpecs(names);
+
+        let names = array.flatten(extra.parsedSpecs);
+        let name = names[0];
+
+        util.assert(!names.some(function (name) name in commands.builtin._map),
+                    _("command.cantReplace", name));
+
+        util.assert(replace || names.every(function (name) !(name in this._map), this),
+                    _("command.wontReplace", name));
+
+        for (let name in values(names)) {
+            ex.__defineGetter__(name, function () this._run(name));
+            if (name in this._map)
+                this.remove(name);
+        }
+
+        let self = this;
+        let closure = function () self._map[name];
+
+        memoize(this._map, name, function () commands.Command(names, description, action, extra));
+        memoize(this._list, this._list.length, closure);
+        for (let alias in values(names.slice(1)))
+            memoize(this._map, alias, closure);
+
+        return name;
+    },
+
+    _add: function _add(names, description, action, extra, replace) {
+        const { contexts } = this.modules;
+
+        extra = extra || {};
+        extra.definedAt = contexts.getCaller(Components.stack.caller.caller);
+        return this.add.apply(this, arguments);
+    },
+
+    /**
+     * Clear all commands.
+     * @returns {Command}
+     */
+    clear: function clear() {
+        util.assert(this.group.modifiable, _("command.cantDelete"));
+        this._map = {};
+        this._list = [];
+    },
+
+    /**
+     * Returns the command with matching *name*.
+     *
+     * @param {string} name The name of the command to return. This can be
+     *     any of the command's names.
+     * @param {boolean} full If true, only return a command if one of
+     *     its names matches *name* exactly.
+     * @returns {Command}
+     */
+    get: function get(name, full) this._map[name]
+            || !full && array.nth(this._list, function (cmd) cmd.hasName(name), 0)
+            || null,
+
+    /**
+     * Remove the user-defined command with matching *name*.
+     *
+     * @param {string} name The name of the command to remove. This can be
+     *     any of the command's names.
+     */
+    remove: function remove(name) {
+        util.assert(this.group.modifiable, _("command.cantDelete"));
+
+        let cmd = this.get(name);
+        this._list = this._list.filter(function (c) c !== cmd);
+        for (let name in values(cmd.names))
+            delete this._map[name];
+    }
+});
+
+/**
+ * @instance commands
+ */
+var Commands = Module("commands", {
+    lazyInit: true,
+
+    Local: function Local(dactyl, modules, window) let ({ Group, contexts } = modules) ({
+        init: function init() {
+            this.Command = Class("Command", Command, { modules: modules });
+            update(this, {
+                hives: contexts.Hives("commands", Class("CommandHive", CommandHive, { modules: modules })),
+                user: contexts.hives.commands.user,
+                builtin: contexts.hives.commands.builtin
+            });
+        },
+
+        get context() contexts.context,
+
+        get readHeredoc() modules.io.readHeredoc,
+
+        get allHives() contexts.allGroups.commands,
+
+        get userHives() this.allHives.filter(function (h) h !== this.builtin, this),
+
+        /**
+         * Executes an Ex command script.
+         *
+         * @param {string} string A string containing the commands to execute.
+         * @param {object} tokens An optional object containing tokens to be
+         *      interpolated into the command string.
+         * @param {object} args Optional arguments object to be passed to
+         *      command actions.
+         * @param {object} context An object containing information about
+         *      the file that is being or has been sourced to obtain the
+         *      command string.
+         */
+        execute: function execute(string, tokens, silent, args, context) {
+            contexts.withContext(context || this.context || { file: "[Command Line]", line: 1 },
+                                 function (context) {
+                modules.io.withSavedValues(["readHeredoc"], function () {
+                    this.readHeredoc = function readHeredoc(end) {
+                        let res = [];
+                        contexts.context.line++;
+                        while (++i < lines.length) {
+                            if (lines[i] === end)
+                                return res.join("\n");
+                            res.push(lines[i]);
+                        }
+                        util.assert(false, _("command.eof", end));
+                    };
+
+                    args = update({}, args || {});
+
+                    if (tokens && !callable(string))
+                        string = util.compileMacro(string, true);
+                    if (callable(string))
+                        string = string(tokens || {});
+
+                    let lines = string.split(/\r\n|[\r\n]/);
+                    let startLine = context.line;
+
+                    for (var i = 0; i < lines.length && !context.finished; i++) {
+                        // Deal with editors from Silly OSs.
+                        let line = lines[i].replace(/\r$/, "");
+
+                        context.line = startLine + i;
+
+                        // Process escaped new lines
+                        while (i < lines.length && /^\s*\\/.test(lines[i + 1]))
+                            line += "\n" + lines[++i].replace(/^\s*\\/, "");
+
+                        try {
+                            dactyl.execute(line, args);
+                        }
+                        catch (e) {
+                            if (!silent) {
+                                e.message = context.file + ":" + context.line + ": " + e.message;
+                                dactyl.reportError(e, true);
+                            }
+                        }
+                    }
+                });
+            });
+        },
+
+        /**
+         * Displays a list of user-defined commands.
+         */
+        list: function list() {
+            const { commandline, completion } = this.modules;
+            function completerToString(completer) {
+                if (completer)
+                    return [k for ([k, v] in Iterator(config.completers)) if (completer == completion.closure[v])][0] || "custom";
+                return "";
+            }
+
+            if (!this.userHives.some(function (h) h._list.length))
+                dactyl.echomsg(_("command.none"));
+            else
+                commandline.commandOutput(
+                    <table>
+                        <tr highlight="Title">
+                            <td/>
+                            <td style="padding-right: 1em;"></td>
+                            <td style="padding-right: 1ex;">Name</td>
+                            <td style="padding-right: 1ex;">Args</td>
+                            <td style="padding-right: 1ex;">Range</td>
+                            <td style="padding-right: 1ex;">Complete</td>
+                            <td style="padding-right: 1ex;">Definition</td>
+                        </tr>
+                        <col style="min-width: 6em; padding-right: 1em;"/>
+                        {
+                            template.map(this.userHives, function (hive) let (i = 0)
+                                <tr style="height: .5ex;"/> +
+                                template.map(hive, function (cmd)
+                                    template.map(cmd.names, function (name)
+                                    <tr>
+                                        <td highlight="Title">{!i++ ? hive.name : ""}</td>
+                                        <td>{cmd.bang ? "!" : " "}</td>
+                                        <td>{cmd.name}</td>
+                                        <td>{cmd.argCount}</td>
+                                        <td>{cmd.count ? "0c" : ""}</td>
+                                        <td>{completerToString(cmd.completer)}</td>
+                                        <td>{cmd.replacementText || "function () { ... }"}</td>
+                                    </tr>)) +
+                                <tr style="height: .5ex;"/>)
+                        }
+                    </table>);
+        }
+    }),
+
+    /**
+     * @property Indicates that no count was specified for this
+     *     command invocation.
+     * @final
+     */
+    COUNT_NONE: null,
+    /**
+     * @property {number} Indicates that the full buffer range (1,$) was
+     *     specified for this command invocation.
+     * @final
+     */
+    // FIXME: this isn't a count at all
+    COUNT_ALL: -2, // :%...
+
+    /** @property {Iterator(Command)} @private */
+    iterator: function iterator() iter.apply(null, this.hives.array)
+                              .sort(function (a, b) a.serialGroup - b.serialGroup || a.name > b.name)
+                              .iterValues(),
+
+    /** @property {string} The last executed Ex command line. */
+    repeat: null,
+
+    add: function add() {
+        let group = this.builtin;
+        if (!util.isDactyl(Components.stack.caller)) {
+            deprecated.warn(add, "commands.add", "group.commands.add");
+            group = this.user;
+        }
+
+        return group._add.apply(group, arguments);
+    },
+    addUserCommand: deprecated("group.commands.add", { get: function addUserCommand() this.user.closure._add }),
+    getUserCommands: deprecated("iter(group.commands)", function getUserCommands() iter(this.user).toArray()),
+    removeUserCommand: deprecated("group.commands.remove", { get: function removeUserCommand() this.user.closure.remove }),
+
+    /**
+     * Returns the specified command invocation object serialized to
+     * an executable Ex command string.
+     *
+     * @param {Object} args The command invocation object.
+     * @returns {string}
+     */
+    commandToString: function commandToString(args) {
+        let res = [args.command + (args.bang ? "!" : "")];
+
+        let defaults = {};
+        if (args.ignoreDefaults)
+            defaults = array(this.options).map(function (opt) [opt.names[0], opt.default])
+                                          .toObject();
+
+        for (let [opt, val] in Iterator(args.options || {})) {
+            if (val != null && defaults[opt] === val)
+                continue;
+            let chr = /^-.$/.test(opt) ? " " : "=";
+            if (isArray(val))
+                opt += chr + Option.stringify.stringlist(val);
+            else if (val != null)
+                opt += chr + Commands.quote(val);
+            res.push(opt);
+        }
+        for (let [, arg] in Iterator(args.arguments || []))
+            res.push(Commands.quote(arg));
+
+        let str = args.literalArg;
+        if (str)
+            res.push(!/\n/.test(str) ? str :
+                     this.hereDoc && false ? "<<EOF\n" + String.replace(str, /\n$/, "") + "\nEOF"
+                                           : String.replace(str, /\n/g, "\n" + res[0].replace(/./g, " ").replace(/.$/, "\\")));
+        return res.join(" ");
+    },
+
+    /**
+     * Returns the command with matching *name*.
+     *
+     * @param {string} name The name of the command to return. This can be
+     *     any of the command's names.
+     * @returns {Command}
+     */
+    get: function get(name, full) iter(this.hives).map(function ([i, hive]) hive.get(name, full))
+                                                  .nth(util.identity, 0),
+
+    /**
+     * Returns true if a command invocation contains a URL referring to the
+     * domain *host*.
+     *
+     * @param {string} command
+     * @param {string} host
+     * @returns {boolean}
+     */
+    hasDomain: function hasDomain(command, host) {
+        try {
+            for (let [cmd, args] in this.subCommands(command))
+                if (Array.concat(cmd.domains(args)).some(function (domain) util.isSubdomain(domain, host)))
+                    return true;
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+        return false;
+    },
+
+    /**
+     * Returns true if a command invocation contains private data which should
+     * be cleared when purging private data.
+     *
+     * @param {string} command
+     * @returns {boolean}
+     */
+    hasPrivateData: function hasPrivateData(command) {
+        for (let [cmd, args] in this.subCommands(command))
+            if (cmd.privateData)
+                return !callable(cmd.privateData) || cmd.privateData(args);
+        return false;
+    },
+
+    // TODO: should it handle comments?
+    //     : it might be nice to be able to specify that certain quoting
+    //     should be disabled E.g. backslash without having to resort to
+    //     using literal etc.
+    //     : error messages should be configurable or else we can ditch
+    //     Vim compatibility but it actually gives useful messages
+    //     sometimes rather than just "Invalid arg"
+    //     : I'm not sure documenting the returned object here, and
+    //     elsewhere, as type Args rather than simply Object makes sense,
+    //     especially since it is further augmented for use in
+    //     Command#action etc.
+    /**
+     * Parses *str* for options and plain arguments.
+     *
+     * The returned *Args* object is an augmented array of arguments.
+     * Any key/value pairs of *extra* will be available and the
+     * following additional properties:
+     *     -opt       - the value of the option -opt if specified
+     *     string     - the original argument string *str*
+     *     literalArg - any trailing literal argument
+     *
+     * Quoting rules:
+     *     '-quoted strings   - only ' and \ itself are escaped
+     *     "-quoted strings   - also ", \n and \t are translated
+     *     non-quoted strings - everything is taken literally apart from "\
+     *                          " and "\\"
+     *
+     * @param {string} str The Ex command-line string to parse. E.g.
+     *     "-x=foo -opt=bar arg1 arg2"
+     * @param {[CommandOption]} options The options accepted. These are specified
+     *      as an array of {@link CommandOption} structures.
+     * @param {string} argCount The number of arguments accepted.
+     *            "0": no arguments
+     *            "1": exactly one argument
+     *            "+": one or more arguments
+     *            "*": zero or more arguments (default if unspecified)
+     *            "?": zero or one arguments
+     * @param {boolean} allowUnknownOptions Whether unspecified options
+     *     should cause an error.
+     * @param {number} literal The index at which any literal arg begins.
+     *     See {@link Command#literal}.
+     * @param {CompletionContext} complete The relevant completion context
+     *     when the args are being parsed for completion.
+     * @param {Object} extra Extra keys to be spliced into the returned
+     *     Args object.
+     * @returns {Args}
+     */
+    parseArgs: function parseArgs(str, params) {
+        const self = this;
+
+        function getNextArg(str, _keepQuotes) {
+            if (arguments.length < 2)
+                _keepQuotes = keepQuotes;
+
+            if (str.substr(0, 2) === "<<" && hereDoc) {
+                let arg = /^<<(\S*)/.exec(str)[1];
+                let count = arg.length + 2;
+                if (complete)
+                    return [count, "", ""];
+                return [count, self.readHeredoc(arg), ""];
+            }
+
+            let [count, arg, quote] = Commands.parseArg(str, null, _keepQuotes);
+            if (quote == "\\" && !complete)
+                return [, , , "Trailing \\"];
+            if (quote && !complete)
+                return [, , , "E114: Missing quote: " + quote];
+            return [count, arg, quote];
+        }
+
+        try {
+
+            var { allowUnknownOptions, argCount, complete, extra, hereDoc, literal, options, keepQuotes } = params || {};
+
+            if (!options)
+                options = [];
+
+            if (!argCount)
+                argCount = "*";
+
+            var args = params.newArgs ? params.newArgs() : [];
+            args.string = str; // for access to the unparsed string
+
+            // FIXME!
+            for (let [k, v] in Iterator(extra || []))
+                args[k] = v;
+
+            // FIXME: best way to specify these requirements?
+            var onlyArgumentsRemaining = allowUnknownOptions || options.length == 0; // after a -- has been found
+            var arg = null;
+            var i = 0;
+            var completeOpts;
+
+            // XXX
+            let matchOpts = function matchOpts(arg) {
+                // Push possible option matches into completions
+                if (complete && !onlyArgumentsRemaining)
+                    completeOpts = options.filter(function (opt) opt.multiple || !set.has(args, opt.names[0]));
+            };
+            let resetCompletions = function resetCompletions() {
+                completeOpts = null;
+                args.completeArg = null;
+                args.completeOpt = null;
+                args.completeFilter = null;
+                args.completeStart = i;
+                args.quote = Commands.complQuote[""];
+            };
+            if (complete) {
+                resetCompletions();
+                matchOpts("");
+                args.completeArg = 0;
+            }
+
+            let fail = function fail(error) {
+                if (complete)
+                    complete.message = error;
+                else
+                    util.assert(false, error);
+            };
+
+            outer:
+            while (i < str.length || complete) {
+                var argStart = i;
+                let re = /\s*/gy;
+                re.lastIndex = i;
+                i += re.exec(str)[0].length;
+
+                if (str[i] == "|") {
+                    args.string = str.slice(0, i);
+                    args.trailing = str.slice(i + 1);
+                    break;
+                }
+                if (i == str.length && !complete)
+                    break;
+
+                if (complete)
+                    resetCompletions();
+
+                var sub = str.substr(i);
+                if ((!onlyArgumentsRemaining) && /^--(\s|$)/.test(sub)) {
+                    onlyArgumentsRemaining = true;
+                    i += 2;
+                    continue;
+                }
+
+                var optname = "";
+                if (!onlyArgumentsRemaining) {
+                    for (let [, opt] in Iterator(options)) {
+                        for (let [, optname] in Iterator(opt.names)) {
+                            if (sub.indexOf(optname) == 0) {
+                                let count = 0;
+                                let invalid = false;
+                                let arg, quote, quoted;
+
+                                let sep = sub[optname.length];
+                                let argString = sub.substr(optname.length + 1);
+                                if (sep == "=" || /\s/.test(sep) && opt.type != CommandOption.NOARG) {
+                                    [count, quoted, quote, error] = getNextArg(argString, true);
+                                    arg = Option.dequote(quoted);
+                                    util.assert(!error, error);
+
+                                    // if we add the argument to an option after a space, it MUST not be empty
+                                    if (sep != "=" && !quote && arg.length == 0)
+                                        arg = null;
+
+                                    count++; // to compensate the "=" character
+                                }
+                                else if (!/\s/.test(sep) && sep != undefined) // this isn't really an option as it has trailing characters, parse it as an argument
+                                    invalid = true;
+
+                                let context = null;
+                                if (!complete && quote)
+                                    fail(_("command.invalidOptArg", optname, argString));
+
+                                if (!invalid) {
+                                    if (complete && !/[\s=]/.test(sep))
+                                        matchOpts(sub);
+
+                                    if (complete && count > 0) {
+                                        args.completeStart += optname.length + 1;
+                                        args.completeOpt = opt;
+                                        args.completeFilter = arg;
+                                        args.quote = Commands.complQuote[quote] || Commands.complQuote[""];
+                                    }
+                                    if (!complete || arg != null) {
+                                        if (opt.type) {
+                                            let orig = arg;
+                                            arg = opt.type.parse(arg, quoted);
+
+                                            if (complete && isArray(arg)) {
+                                                args.completeFilter = arg[arg.length - 1] || "";
+                                                args.completeStart += orig.length - args.completeFilter.length;
+                                            }
+
+                                            if (arg == null || (typeof arg == "number" && isNaN(arg))) {
+                                                if (!complete || orig != "" || args.completeStart != str.length)
+                                                    fail(_("command.invalidOptTypeArg", opt.type.description, optname, argString));
+                                                if (complete)
+                                                    complete.highlight(args.completeStart, count - 1, "SPELLCHECK");
+                                            }
+                                        }
+
+                                        // we have a validator function
+                                        if (typeof opt.validator == "function") {
+                                            if (opt.validator(arg, quoted) == false && (arg || !complete)) {
+                                                fail(_("command.invalidOptArg", optname, argString));
+                                                if (complete) // Always true.
+                                                    complete.highlight(args.completeStart, count - 1, "SPELLCHECK");
+                                            }
+                                        }
+                                    }
+
+                                    if (arg != null || opt.type == CommandOption.NOARG) {
+                                        // option allowed multiple times
+                                        if (opt.multiple)
+                                            args[opt.names[0]] = (args[opt.names[0]] || []).concat(arg);
+                                        else
+                                            Class.replaceProperty(args, opt.names[0], opt.type == CommandOption.NOARG || arg);
+
+                                        args.explicitOpts[opt.names[0]] = args[opt.names[0]];
+                                    }
+
+                                    i += optname.length + count;
+                                    if (i == str.length)
+                                        break outer;
+                                    continue outer;
+                                }
+                                // if it is invalid, just fall through and try the next argument
+                            }
+                        }
+                    }
+                }
+
+                matchOpts(sub);
+
+                if (complete)
+                    if (argCount == "0" || args.length > 0 && (/[1?]/.test(argCount)))
+                        complete.highlight(i, sub.length, "SPELLCHECK");
+
+                if (args.length === literal) {
+                    if (complete)
+                        args.completeArg = args.length;
+
+                    let re = /(?:\s*(?=\n)|\s*)([^]*)/gy;
+                    re.lastIndex = argStart || 0;
+                    sub = re.exec(str)[1];
+
+                    // Hack.
+                    if (sub.substr(0, 2) === "<<" && hereDoc)
+                        let ([count, arg] = getNextArg(sub)) {
+                            sub = arg + sub.substr(count);
+                        }
+
+                    args.push(sub);
+                    args.quote = null;
+                    break;
+                }
+
+                // if not an option, treat this token as an argument
+                let [count, arg, quote, error] = getNextArg(sub);
+                util.assert(!error, error);
+
+                if (complete) {
+                    args.quote = Commands.complQuote[quote] || Commands.complQuote[""];
+                    args.completeFilter = arg || "";
+                }
+                else if (count == -1)
+                    fail(_("command.parsing", arg));
+                else if (!onlyArgumentsRemaining && sub[0] === "-")
+                    fail(_("command.invalidOpt", arg));
+
+                if (arg != null)
+                    args.push(arg);
+                if (complete)
+                    args.completeArg = args.length - 1;
+
+                i += count;
+                if (count <= 0 || i == str.length)
+                    break;
+            }
+
+            if (complete && args.trailing == null) {
+                if (args.completeOpt) {
+                    let opt = args.completeOpt;
+                    let context = complete.fork(opt.names[0], args.completeStart);
+                    let arg = args.explicitOpts[opt.names[0]];
+                    context.filter = args.completeFilter;
+
+                    if (isArray(arg))
+                        context.filters.push(function (item) arg.indexOf(item.text) === -1);
+
+                    if (typeof opt.completer == "function")
+                        var compl = opt.completer(context, args);
+                    else
+                        compl = opt.completer || [];
+
+                    context.title = [opt.names[0]];
+                    context.quote = args.quote;
+                    if (compl)
+                        context.completions = compl;
+                }
+                complete.advance(args.completeStart);
+                complete.keys = {
+                    text: "names",
+                    description: function (opt) messages.get(["command", params.name, "options", opt.names[0], "description"].join("."), opt.description)
+                };
+                complete.title = ["Options"];
+                if (completeOpts)
+                    complete.completions = completeOpts;
+            }
+
+            if (args.verify)
+                args.verify();
+
+            return args;
+        }
+        catch (e if complete && e instanceof FailedAssertion) {
+            complete.message = e;
+            return args;
+        }
+    },
+
+    nameRegexp: util.regexp(<![CDATA[
+            [^
+                0-9
+                <forbid>
+            ]
+            [^ <forbid> ]*
+        ]]>, "gx", {
+        forbid: util.regexp(String.replace(<![CDATA[
+            U0000-U002c // U002d -
+            U002e-U002f
+            U003a-U0040 // U0041-U005a a-z
+            U005b-U0060 // U0061-U007a A-Z
+            U007b-U00bf
+            U02b0-U02ff // Spacing Modifier Letters
+            U0300-U036f // Combining Diacritical Marks
+            U1dc0-U1dff // Combining Diacritical Marks Supplement
+            U2000-U206f // General Punctuation
+            U20a0-U20cf // Currency Symbols
+            U20d0-U20ff // Combining Diacritical Marks for Symbols
+            U2400-U243f // Control Pictures
+            U2440-U245f // Optical Character Recognition
+            U2500-U257f // Box Drawing
+            U2580-U259f // Block Elements
+            U2700-U27bf // Dingbats
+            Ufe20-Ufe2f // Combining Half Marks
+            Ufe30-Ufe4f // CJK Compatibility Forms
+            Ufe50-Ufe6f // Small Form Variants
+            Ufe70-Ufeff // Arabic Presentation Forms-B
+            Uff00-Uffef // Halfwidth and Fullwidth Forms
+            Ufff0-Uffff // Specials
+        ]]>, /U/g, "\\u"), "x")
+    }),
+
+    validName: Class.memoize(function validName() util.regexp("^" + this.nameRegexp.source + "$")),
+
+    commandRegexp: Class.memoize(function commandRegexp() util.regexp(<![CDATA[
+            ^
+            (?P<spec>
+                (?P<prespace> [:\s]*)
+                (?P<count>    (?:\d+ | %)? )
+                (?P<fullCmd>
+                    (?: (?P<group>   <name>) : )?
+                    (?P<cmd>      (?:<name> | !)? ))
+                (?P<bang>     !?)
+                (?P<space>    \s*)
+            )
+            (?P<args>
+                (?:. | \n)*?
+            )?
+            $
+        ]]>, "x", {
+            name: this.nameRegexp
+        })),
+
+    /**
+     * Parses a complete Ex command.
+     *
+     * The parsed string is returned as an Array like
+     * [count, command, bang, args]:
+     *     count   - any count specified
+     *     command - the Ex command name
+     *     bang    - whether the special "bang" version was called
+     *     args    - the commands full argument string
+     * E.g. ":2foo! bar" -> [2, "foo", true, "bar"]
+     *
+     * @param {string} str The Ex command line string.
+     * @returns {Array}
+     */
+    // FIXME: why does this return an Array rather than Object?
+    parseCommand: function parseCommand(str) {
+        // remove comments
+        str.replace(/\s*".*$/, "");
+
+        let matches = this.commandRegexp.exec(str);
+        if (!matches)
+            return [];
+
+        let { spec, count, group, cmd, bang, space, args } = matches;
+        if (!cmd && bang)
+            [cmd, bang] = [bang, cmd];
+
+        if (!cmd || args && args[0] != "|" && !(space || cmd == "!"))
+            return [];
+
+        // parse count
+        if (count)
+            count = count == "%" ? this.COUNT_ALL : parseInt(count, 10);
+        else
+            count = this.COUNT_NONE;
+
+        return [count, cmd, !!bang, args || "", spec.length, group];
+    },
+
+    parseCommands: function parseCommands(str, complete) {
+        const { contexts } = this.modules;
+        do {
+            let [count, cmd, bang, args, len, group] = commands.parseCommand(str);
+            if (!group)
+                var command = this.get(cmd || "");
+            else if (group = contexts.getGroup(group, "commands"))
+                command = group.get(cmd || "");
+
+            if (command == null) {
+                yield [null, { commandString: str }];
+                return;
+            }
+
+            if (complete) {
+                complete.fork(command.name);
+                var context = complete.fork("args", len);
+            }
+
+            if (!complete || /(\w|^)[!\s]/.test(str))
+                args = command.parseArgs(args, context, { count: count, bang: bang });
+            else
+                args = this.parseArgs(args, { extra: { count: count, bang: bang } });
+            args.context = this.context;
+            args.commandName = cmd;
+            args.commandString = str.substr(0, len) + args.string;
+            str = args.trailing;
+            yield [command, args];
+            if (args.break)
+                break;
+        }
+        while (str);
+    },
+
+    subCommands: function subCommands(command) {
+        let commands = [command];
+        while (command = commands.shift())
+            try {
+                for (let [command, args] in this.parseCommands(command)) {
+                    if (command) {
+                        yield [command, args];
+                        if (command.subCommand && args[command.subCommand])
+                            commands.push(args[command.subCommand]);
+                    }
+                }
+            }
+            catch (e) {}
+    },
+
+    /** @property */
+    get complQuote() Commands.complQuote,
+
+    /** @property */
+    get quoteArg() Commands.quoteArg // XXX: better somewhere else?
+
+}, {
+    // returns [count, parsed_argument]
+    parseArg: function parseArg(str, sep, keepQuotes) {
+        let arg = "";
+        let quote = null;
+        let len = str.length;
+
+        function fixEscapes(str) str.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}|(.))/g, function (m, n1) n1 || m);
+
+        // Fix me.
+        if (isString(sep))
+            sep = RegExp(sep);
+        sep = sep != null ? sep : /\s/;
+        let re1 = RegExp("^" + (sep.source === "" ? "(?!)" : sep.source));
+        let re2 = RegExp(/^()((?:[^\\S"']|\\.)+)((?:\\$)?)/.source.replace("S", sep.source));
+
+        while (str.length && !re1.test(str)) {
+            let res;
+            if ((res = re2.exec(str)))
+                arg += keepQuotes ? res[0] : res[2].replace(/\\(.)/g, "$1");
+            else if ((res = /^(")((?:[^\\"]|\\.)*)("?)/.exec(str)))
+                arg += keepQuotes ? res[0] : JSON.parse(fixEscapes(res[0]) + (res[3] ? "" : '"'));
+            else if ((res = /^(')((?:[^']|'')*)('?)/.exec(str)))
+                arg += keepQuotes ? res[0] : res[2].replace("''", "'", "g");
+            else
+                break;
+
+            if (!res[3])
+                quote = res[1];
+            if (!res[1])
+                quote = res[3];
+            str = str.substr(res[0].length);
+        }
+
+        return [len - str.length, arg, quote];
+    },
+
+    quote: function quote(str) Commands.quoteArg[
+        /[\b\f\n\r\t]/.test(str)   ? '"' :
+        /[\s"'\\]|^$|^-/.test(str) ? "'"
+                                   : ""](str)
+}, {
+    completion: function initCompletion(dactyl, modules, window) {
+        const { completion, contexts } = modules;
+
+        completion.command = function command(context, group) {
+            context.title = ["Command"];
+            context.keys = { text: "longNames", description: "description" };
+            if (group)
+                context.generate = function () group._list;
+            else
+                context.generate = function () modules.commands.hives.map(function (h) h._list).flatten();
+        };
+
+        // provides completions for ex commands, including their arguments
+        completion.ex = function ex(context) {
+            const { commands } = modules;
+
+            // if there is no space between the command name and the cursor
+            // then get completions of the command name
+            for (var [command, args] in commands.parseCommands(context.filter, context))
+                if (args.trailing)
+                    context.advance(args.commandString.length + 1);
+            if (!args)
+                args = { commandString: context.filter };
+
+            let match = commands.commandRegexp.exec(args.commandString);
+            if (!match)
+                return;
+
+            if (match.group)
+                context.advance(match.group.length + 1);
+
+            context.advance(match.prespace.length + match.count.length);
+            if (!(match.bang || match.space)) {
+                context.fork("", 0, this, "command", match.group && contexts.getGroup(match.group, "commands"));
+                return;
+            }
+
+            // dynamically get completions as specified with the command's completer function
+            context.highlight();
+            if (!command) {
+                context.message = "No such command: " + match.cmd;
+                context.highlight(0, match.cmd.length, "SPELLCHECK");
+                return;
+            }
+
+            let cmdContext = context.fork(command.name, match.fullCmd.length + match.bang.length + match.space.length);
+            try {
+                if (!cmdContext.waitingForTab) {
+                    if (!args.completeOpt && command.completer && args.completeStart != null) {
+                        cmdContext.advance(args.completeStart);
+                        cmdContext.quote = args.quote;
+                        cmdContext.filter = args.completeFilter;
+                        command.completer.call(command, cmdContext, args);
+                    }
+                }
+            }
+            catch (e) {
+                util.reportError(e);
+            }
+        };
+
+        completion.userCommand = function userCommand(context, group) {
+            context.title = ["User Command", "Definition"];
+            context.keys = { text: "name", description: "replacementText" };
+            context.completions = group || modules.commands.user;
+        };
+    },
+
+    commands: function initCommands(dactyl, modules, window) {
+        const { commands, contexts } = modules;
+
+        // TODO: Vim allows commands to be defined without {rep} if there are {attr}s
+        // specified - useful?
+        commands.add(["com[mand]"],
+            "List or define commands",
+            function (args) {
+                let cmd = args[0];
+
+                util.assert(!cmd || cmd.split(",").every(commands.validName.closure.test),
+                            _("command.invalidName", cmd));
+
+                if (!args.literalArg)
+                    commands.list();
+                else {
+                    util.assert(args["-group"].modifiable,
+                                _("group.cantChangeBuiltin", _("command.commands")));
+
+                    let completer  = args["-complete"];
+                    let completerFunc = null; // default to no completion for user commands
+
+                    if (completer) {
+                        if (/^custom,/.test(completer)) {
+                            completer = completer.substr(7);
+
+                            let context = update({}, contexts.context || {});
+                            completerFunc = function (context) {
+                                try {
+                                    var result = contextswithSavedValues(["context"], function () {
+                                        contexts.context = context;
+                                        return dactyl.userEval(completer);
+                                    });
+                                }
+                                catch (e) {
+                                    dactyl.echo(":" + this.name + " ...");
+                                    dactyl.echoerr(_("command.unknownCompleter", completer));
+                                    dactyl.log(e);
+                                    return undefined;
+                                }
+                                if (callable(result))
+                                    return result.apply(this, Array.slice(arguments));
+                                else
+                                    return context.completions = result;
+                            };
+                        }
+                        else
+                            completerFunc = function (context) modules.completion.closure[config.completers[completer]](context);
+                    }
+
+                    let added = args["-group"].add(cmd.split(","),
+                                    args["-description"],
+                                    contexts.bindMacro(args, "-ex",
+                                        function makeParams(args, modifiers) ({
+                                            args: {
+                                                __proto__: args,
+                                                toString: function () this.string,
+                                            },
+                                            bang:  this.bang && args.bang ? "!" : "",
+                                            count: this.count && args.count
+                                        })),
+                                    {
+                                        argCount: args["-nargs"],
+                                        bang: args["-bang"],
+                                        count: args["-count"],
+                                        completer: completerFunc,
+                                        literal: args["-literal"],
+                                        persist: !args["-nopersist"],
+                                        replacementText: args.literalArg,
+                                        context: contexts.context && update({}, contexts.context)
+                                    }, args.bang);
+
+                    if (!added)
+                        dactyl.echoerr(_("command.exists"));
+                }
+            }, {
+                bang: true,
+                completer: function (context, args) {
+                    const { completion } = modules;
+                    if (args.completeArg == 0)
+                        completion.userCommand(context, args["-group"]);
+                    else
+                        args["-javascript"] ? completion.javascript(context) : completion.ex(context);
+                },
+                hereDoc: true,
+                options: [
+                    { names: ["-bang", "-b"],  description: "Command may be followed by a !" },
+                    { names: ["-count", "-c"], description: "Command may be preceded by a count" },
+                    {
+                        // TODO: "E180: invalid complete value: " + arg
+                        names: ["-complete", "-C"],
+                        description: "The argument completion function",
+                        completer: function (context) [[k, ""] for ([k, v] in Iterator(config.completers))],
+                        type: CommandOption.STRING,
+                        validator: function (arg) arg in config.completers || /^custom,/.test(arg),
+                    },
+                    {
+                        names: ["-description", "-desc", "-d"],
+                        description: "A user-visible description of the command",
+                        default: "User-defined command",
+                        type: CommandOption.STRING
+                    },
+                    contexts.GroupFlag("commands"),
+                    {
+                        names: ["-javascript", "-js", "-j"],
+                        description: "Execute the definition as JavaScript rather than Ex commands"
+                    },
+                    {
+                        names: ["-literal", "-l"],
+                        description: "Process the nth ignoring any quoting or meta characters",
+                        type: CommandOption.INT
+                    },
+                    {
+                        names: ["-nargs", "-a"],
+                        description: "The allowed number of arguments",
+                        completer: [["0", "No arguments are allowed (default)"],
+                                    ["1", "One argument is allowed"],
+                                    ["*", "Zero or more arguments are allowed"],
+                                    ["?", "Zero or one argument is allowed"],
+                                    ["+", "One or more arguments are allowed"]],
+                        default: "0",
+                        type: CommandOption.STRING,
+                        validator: function (arg) /^[01*?+]$/.test(arg)
+                    },
+                    {
+                        names: ["-nopersist", "-n"],
+                        description: "Do not save this command to an auto-generated RC file"
+                    }
+                ],
+                literal: 1,
+
+                serialize: function () array(commands.userHives)
+                    .filter(function (h) h.persist)
+                    .map(function (hive) [
+                        {
+                            command: this.name,
+                            bang: true,
+                            options: iter([v, typeof cmd[k] == "boolean" ? null : cmd[k]]
+                                          // FIXME: this map is expressed multiple times
+                                          for ([k, v] in Iterator({
+                                              argCount: "-nargs",
+                                              bang: "-bang",
+                                              count: "-count",
+                                              description: "-description"
+                                          }))
+                                          if (cmd[k])).toObject(),
+                            arguments: [cmd.name],
+                            literalArg: cmd.action,
+                            ignoreDefaults: true
+                        }
+                        for (cmd in hive) if (cmd.persist)
+                    ], this)
+                    .flatten().array
+            });
+
+        commands.add(["delc[ommand]"],
+            "Delete the specified user-defined command",
+            function (args) {
+                util.assert(args.bang ^ !!args[0], _("error.argumentOrBang"));
+                let name = args[0];
+
+                if (args.bang)
+                    args["-group"].clear();
+                else if (args["-group"].get(name))
+                    args["-group"].remove(name);
+                else
+                    dactyl.echoerr(_("command.noSuchUser", name));
+            }, {
+                argCount: "?",
+                bang: true,
+                completer: function (context, args) modules.completion.userCommand(context, args["-group"]),
+                options: [contexts.GroupFlag("commands")]
+            });
+
+        commands.add(["comp[letions]"],
+            "List the completion results for a given command substring",
+            function (args) { modules.completion.listCompleter("ex", args[0]); },
+            {
+                argCount: "1",
+                completer: function (context, args) modules.completion.ex(context),
+                literal: 0
+            });
+
+        dactyl.addUsageCommand({
+            name: ["listc[ommands]", "lc"],
+            description: "List all Ex commands along with their short descriptions",
+            index: "ex-cmd",
+            iterate: function (args) commands.iterator().map(function (cmd) ({
+                __proto__: cmd,
+                columns: [
+                    cmd.hive == commands.builtin ? "" : <span highlight="Object" style="padding-right: 1em;">{cmd.hive.name}</span>
+                ]
+            })),
+            iterateIndex: function (args) let (tags = services["dactyl:"].HELP_TAGS)
+                this.iterate(args).filter(function (cmd) cmd.hive === commands.builtin || set.has(cmd.helpTag)),
+            format: {
+                headings: ["Command", "Group", "Description"],
+                description: function (cmd) template.linkifyHelp(cmd.description + (cmd.replacementText ? ": " + cmd.action : "")),
+                help: function (cmd) ":" + cmd.name
+            }
+        });
+
+        commands.add(["y[ank]"],
+            "Yank the output of the given command to the clipboard",
+            function (args) {
+                let cmd = /^:/.test(args[0]) ? args[0] : ":echo " + args[0];
+
+                let res = modules.commandline.withOutputToString(commands.execute, commands, cmd);
+
+                dactyl.clipboardWrite(res);
+
+                let lines = res.split("\n").length;
+                dactyl.echomsg("Yanked " + lines + " line" + (lines == 1 ? "" : "s"));
+            },
+            {
+                completer: function (context) modules.completion[/^:/.test(context.filter) ? "ex" : "javascript"](context),
+                literal: 0
+            });
+    },
+    javascript: function initJavascript(dactyl, modules, window) {
+        const { JavaScript, commands } = modules;
+
+        JavaScript.setCompleter([commands.user.get, commands.user.remove],
+                                [function () [[c.names, c.description] for (c in this)]]);
+        JavaScript.setCompleter([commands.get],
+                                [function () [[c.names, c.description] for (c in this.iterator())]]);
+    },
+    mappings: function initMappings(dactyl, modules, window) {
+        const { commands, mappings, modes } = modules;
+
+        mappings.add([modes.COMMAND],
+            ["@:"], "Repeat the last Ex command",
+            function (args) {
+                if (commands.repeat) {
+                    for (let i in util.interruptibleRange(0, Math.max(args.count, 1), 100))
+                        dactyl.execute(commands.repeat);
+                }
+                else
+                    dactyl.echoerr(_("command.noPrevious"));
+            },
+            { count: true });
+    }
+});
+
+(function () {
+
+    Commands.quoteMap = {
+        "\n": "\\n",
+        "\t": "\\t",
+    };
+    function quote(q, list, map) {
+        map = map || Commands.quoteMap;
+        let re = RegExp("[" + list + "]", "g");
+        function quote(str) q + String.replace(str, re, function ($0) $0 in map ? map[$0] : ("\\" + $0)) + q;
+        quote.list = list;
+        return quote;
+    };
+
+    Commands.quoteArg = {
+        '"': quote('"', '\n\t"\\\\'),
+        "'": quote("'", "'", { "'": "''" }),
+        "":  quote("",  "|\\\\\\s'\"")
+    };
+    Commands.complQuote = {
+        '"': ['"', quote("", Commands.quoteArg['"'].list), '"'],
+        "'": ["'", quote("", Commands.quoteArg["'"].list), "'"],
+        "":  ["", Commands.quoteArg[""], ""]
+    };
+
+    Commands.parseBool = function (arg) {
+        if (/^(true|1|on)$/i.test(arg))
+            return true;
+        if (/^(false|0|off)$/i.test(arg))
+            return false;
+        return NaN;
+    };
+})();
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/completion.jsm b/common/modules/completion.jsm
new file mode 100644 (file)
index 0000000..b7a34e5
--- /dev/null
@@ -0,0 +1,1083 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("completion", {
+    exports: ["CompletionContext", "Completion", "completion"],
+    use: ["config", "template", "util"]
+}, this);
+
+/**
+ * Creates a new completion context.
+ *
+ * @class A class to provide contexts for command completion.
+ * Manages the filtering and formatting of completions, and keeps
+ * track of the positions and quoting of replacement text. Allows for
+ * the creation of sub-contexts with different headers and quoting
+ * rules.
+ *
+ * @param {nsIEditor} editor The editor for which completion is
+ *     intended. May be a {CompletionContext} when forking a context,
+ *     or a {string} when creating a new one.
+ * @param {string} name The name of this context. Used when the
+ *     context is forked.
+ * @param {number} offset The offset from the parent context.
+ * @author Kris Maglione <maglione.k@gmail.com>
+ * @constructor
+ */
+var CompletionContext = Class("CompletionContext", {
+    init: function (editor, name, offset) {
+        if (!name)
+            name = "";
+
+        let self = this;
+        if (editor instanceof this.constructor) {
+            let parent = editor;
+            name = parent.name + "/" + name;
+
+            if (this.options) {
+                this.autoComplete = this.options.get("autocomplete").getKey(name);
+                this.sortResults  = this.options.get("wildsort").getKey(name);
+                this.wildcase     = this.options.get("wildcase").getKey(name);
+            }
+
+            this.contexts = parent.contexts;
+            if (name in this.contexts)
+                self = this.contexts[name];
+            else
+                self.contexts[name] = this;
+
+            /**
+             * @property {CompletionContext} This context's parent. {null} when
+             *     this is a top-level context.
+             */
+            self.parent = parent;
+
+            ["filters", "keys", "process", "title", "quote"].forEach(function (key)
+                self[key] = parent[key] && util.cloneObject(parent[key]));
+            ["anchored", "compare", "editor", "_filter", "filterFunc", "forceAnchored", "top"].forEach(function (key)
+                self[key] = parent[key]);
+
+            self.__defineGetter__("value", function () this.top.value);
+
+            self.offset = parent.offset;
+            self.advance(offset || 0);
+
+            /**
+             * @property {boolean} Specifies that this context is not finished
+             *     generating results.
+             * @default false
+             */
+            self.incomplete = false;
+            self.message = null;
+            /**
+             * @property {boolean} Specifies that this context is waiting for the
+             *     user to press <Tab>. Useful when fetching completions could be
+             *     dangerous or slow, and the user has enabled autocomplete.
+             */
+            self.waitingForTab = false;
+
+            self.hasItems = null;
+
+            delete self._generate;
+            delete self.ignoreCase;
+            if (self != this)
+                return self;
+            ["_caret", "contextList", "maxItems", "onUpdate", "selectionTypes", "tabPressed", "updateAsync", "value"].forEach(function (key) {
+                self.__defineGetter__(key, function () this.top[key]);
+                self.__defineSetter__(key, function (val) this.top[key] = val);
+            });
+        }
+        else {
+            if (typeof editor == "string")
+                this._value = editor;
+            else
+                this.editor = editor;
+            /**
+             * @property {boolean} Specifies whether this context results must
+             *     match the filter at the beginning of the string.
+             * @default true
+             */
+            this.anchored = true;
+            this.forceAnchored = null;
+
+            this.compare = function (a, b) String.localeCompare(a.text, b.text);
+            /**
+             * @property {function} This function is called when we close
+             *     a completion window with Esc or Ctrl-c. Usually this callback
+             *     is only needed for long, asynchronous completions
+             */
+            this.cancel = null;
+            /**
+             * @property {[CompletionContext]} A list of active
+             *     completion contexts, in the order in which they were
+             *     instantiated.
+             */
+            this.contextList = [];
+            /**
+             * @property {Object} A map of all contexts, keyed on their names.
+             *    Names are assigned when a context is forked, with its specified
+             *    name appended, after a '/', to its parent's name. May
+             *    contain inactive contexts. For active contexts, see
+             *    {@link #contextList}.
+             */
+            this.contexts = { "": this };
+            /**
+             * @property {function} The function used to filter the results.
+             * @default Selects all results which match every predicate in the
+             *     {@link #filters} array.
+             */
+            this.filterFunc = function (items) {
+                    let self = this;
+                    return this.filters.
+                        reduce(function (res, filter) res.filter(function (item) filter.call(self, item)),
+                                items);
+            };
+            /**
+             * @property {Array} An array of predicates on which to filter the
+             *     results.
+             */
+            this.filters = [CompletionContext.Filter.text];
+            /**
+             * @property {Object} A mapping of keys, for {@link #getKey}. Given
+             *      { key: value }, getKey(item, key) will return values as such:
+             *      if value is a string, it will return item.item[value]. If it's a
+             *      function, it will return value(item.item).
+             */
+            this.keys = { text: 0, description: 1, icon: "icon" };
+            /**
+             * @property {number} This context's offset from the beginning of
+             *     {@link #editor}'s value.
+             */
+            this.offset = offset || 0;
+            /**
+             * @property {function} A function which is called when any subcontext
+             *     changes its completion list. Only called when
+             *     {@link #updateAsync} is true.
+             */
+            this.onUpdate = function () true;
+
+            this.runCount = 0;
+
+            /**
+             * @property {CompletionContext} The top-level completion context.
+             */
+            this.top = this;
+            this.__defineGetter__("incomplete", function () this._incomplete || this.contextList.some(function (c) c.parent && c.incomplete));
+            this.__defineGetter__("waitingForTab", function () this._waitingForTab || this.contextList.some(function (c) c.parent && c.waitingForTab));
+            this.__defineSetter__("incomplete", function (val) { this._incomplete = val; });
+            this.__defineSetter__("waitingForTab", function (val) { this._waitingForTab = val; });
+            this.reset();
+        }
+        /**
+         * @property {Object} A general-purpose store for functions which need to
+         *     cache data between calls.
+         */
+        this.cache = {};
+        this._cache = {};
+        /**
+         * @private
+         * @property {Object} A cache for return values of {@link #generate}.
+         */
+        this.itemCache = {};
+        /**
+         * @property {string} A key detailing when the cached value of
+         *     {@link #generate} may be used. Every call to
+         *     {@link #generate} stores its result in {@link #itemCache}.
+         *     When itemCache[key] exists, its value is returned, and
+         *     {@link #generate} is not called again.
+         */
+        this.key = "";
+        /**
+         * @property {string} A message to be shown before any results.
+         */
+        this.message = null;
+        this.name = name || "";
+        /** @private */
+        this._completions = []; // FIXME
+        /**
+         * Returns a key, as detailed in {@link #keys}.
+         * @function
+         */
+        this.getKey = function (item, key) (typeof self.keys[key] == "function") ? self.keys[key].call(this, item.item) :
+                key in self.keys ? item.item[self.keys[key]]
+                                 : item.item[key];
+        return this;
+    },
+
+    // Temporary
+    /**
+     * @property {Object}
+     *
+     * An object describing the results from all sub-contexts. Results are
+     * adjusted so that all have the same starting offset.
+     *
+     * @deprecated
+     */
+    get allItems() {
+        try {
+            let self = this;
+            let allItems = this.contextList.map(function (context) context.hasItems && context.items);
+            if (this.cache.allItems && array.equals(this.cache.allItems, allItems))
+                return this.cache.allItemsResult;
+            this.cache.allItems = allItems;
+
+            let minStart = Math.min.apply(Math, [context.offset for ([k, context] in Iterator(this.contexts)) if (context.hasItems && context.items.length)]);
+            if (minStart == Infinity)
+                minStart = 0;
+            let items = this.contextList.map(function (context) {
+                if (!context.hasItems)
+                    return [];
+                let prefix = self.value.substring(minStart, context.offset);
+                return context.items.map(function (item) ({
+                    text: prefix + item.text,
+                    result: prefix + item.result,
+                    __proto__: item
+                }));
+            });
+            this.cache.allItemsResult = { start: minStart, items: array.flatten(items) };
+            memoize(this.cache.allItemsResult, "longestSubstring", function () self.longestAllSubstring);
+            return this.cache.allItemsResult;
+        }
+        catch (e) {
+            util.reportError(e);
+            return { start: 0, items: [], longestAllSubstring: "" };
+        }
+    },
+    // Temporary
+    get allSubstrings() {
+        let contexts = this.contextList.filter(function (c) c.hasItems && c.items.length);
+        let minStart = Math.min.apply(Math, contexts.map(function (c) c.offset));
+        let lists = contexts.map(function (context) {
+            let prefix = context.value.substring(minStart, context.offset);
+            return context.substrings.map(function (s) prefix + s);
+        });
+
+        /* TODO: Deal with sub-substrings for multiple contexts again.
+         * Possibly.
+         */
+        let substrings = lists.reduce(
+                function (res, list) res.filter(function (str) list.some(function (s) s.substr(0, str.length) == str)),
+                lists.pop());
+        if (!substrings) // FIXME: How is this undefined?
+            return [];
+        return array.uniq(Array.slice(substrings));
+    },
+    // Temporary
+    get longestAllSubstring() {
+        return this.allSubstrings.reduce(function (a, b) a.length > b.length ? a : b, "");
+    },
+
+    get caret() this._caret - this.offset,
+    set caret(val) this._caret = val + this.offset,
+
+    get compare() this._compare || function () 0,
+    set compare(val) this._compare = val,
+
+    get completions() this._completions || [],
+    set completions(items) {
+        if (items && isArray(items.array))
+            items = items.array;
+        // Accept a generator
+        if (!isArray(items))
+            items = [x for (x in Iterator(items || []))];
+        if (this._completions !== items) {
+            delete this.cache.filtered;
+            delete this.cache.filter;
+            this.cache.rows = [];
+            this._completions = items;
+            this.itemCache[this.key] = items;
+        }
+        if (this._completions)
+            this.hasItems = this._completions.length > 0;
+        if (this.updateAsync && !this.noUpdate)
+            this.onUpdate();
+    },
+
+    get createRow() this._createRow || template.completionRow, // XXX
+    set createRow(createRow) this._createRow = createRow,
+
+    get filterFunc() this._filterFunc || util.identity,
+    set filterFunc(val) this._filterFunc = val,
+
+    get filter() this._filter != null ? this._filter : this.value.substr(this.offset, this.caret),
+    set filter(val) {
+        delete this.ignoreCase;
+        return this._filter = val;
+    },
+
+    get format() ({
+        anchored: this.anchored,
+        title: this.title,
+        keys: this.keys,
+        process: this.process
+    }),
+    set format(format) {
+        this.anchored = format.anchored,
+        this.title = format.title || this.title;
+        this.keys = format.keys || this.keys;
+        this.process = format.process || this.process;
+    },
+
+    /**
+     * @property {string | xml | null}
+     * The message displayed at the head of the completions for the
+     * current context.
+     */
+    get message() this._message || (this.waitingForTab && this.hasItems !== false ? "Waiting for <Tab>" : null),
+    set message(val) this._message = val,
+
+    /**
+     * The prototype object for items returned by {@link items}.
+     */
+    get itemPrototype() {
+        let res = {};
+        function result(quote) {
+            yield ["result", quote ? function () quote[0] + quote[1](this.text) + quote[2]
+                                   : function () this.text];
+        };
+        for (let i in iter(this.keys, result(this.quote))) {
+            let [k, v] = i;
+            if (typeof v == "string" && /^[.[]/.test(v))
+                // This is only allowed to be a simple accessor, and shouldn't
+                // reference any variables. Don't bother with eval context.
+                v = Function("i", "return i" + v);
+            if (typeof v == "function")
+                res.__defineGetter__(k, function () Class.replaceProperty(this, k, v.call(this, this.item)));
+            else
+                res.__defineGetter__(k, function () Class.replaceProperty(this, k, this.item[v]));
+            res.__defineSetter__(k, function (val) Class.replaceProperty(this, k, val));
+        }
+        return res;
+    },
+
+    /**
+     * Returns true when the completions generated by {@link #generate}
+     * must be regenerated. May be set to true to invalidate the current
+     * completions.
+     */
+    get regenerate() this._generate && (!this.completions || !this.itemCache[this.key] || this._cache.offset != this.offset),
+    set regenerate(val) { if (val) delete this.itemCache[this.key]; },
+
+    /**
+     * A property which may be set to a function to generate the value
+     * of {@link completions} only when necessary. The generated
+     * completions are linked to the value in {@link #key} and may be
+     * invalidated by setting the {@link #regenerate} property.
+     */
+    get generate() this._generate || null,
+    set generate(arg) {
+        this.hasItems = true;
+        this._generate = arg;
+    },
+    /**
+     * Generates the item list in {@link #completions} via the
+     * {@link #generate} method if the previously generated value is no
+     * longer valid.
+     */
+    generateCompletions: function generateCompletions() {
+        if (this.offset != this._cache.offset || this.lastActivated != this.top.runCount) {
+            this.itemCache = {};
+            this._cache.offset = this.offset;
+            this.lastActivated = this.top.runCount;
+        }
+        if (!this.itemCache[this.key]) {
+            try {
+                let res = this._generate();
+                if (res != null)
+                    this.itemCache[this.key] = res;
+            }
+            catch (e) {
+                util.reportError(e);
+                this.message = "Error: " + e;
+            }
+        }
+        // XXX
+        this.noUpdate = true;
+        this.completions = this.itemCache[this.key];
+        this.noUpdate = false;
+    },
+
+    ignoreCase: Class.memoize(function () {
+        let mode = this.wildcase;
+        if (mode == "match")
+            return false;
+        else if (mode == "ignore")
+            return true;
+        else
+            return !/[A-Z]/.test(this.filter);
+    }),
+
+    /**
+     * Returns a list of all completion items which match the current
+     * filter. The items returned are objects containing one property
+     * for each corresponding property in {@link keys}. The returned
+     * list is generated on-demand from the item list in {@link completions}
+     * or generated by {@link generate}, and is cached as long as no
+     * properties which would invalidate the result are changed.
+     */
+    get items() {
+        // Don't return any items if completions or generator haven't
+        // been set during this completion cycle.
+        if (!this.hasItems)
+            return [];
+
+        // Regenerate completions if we must
+        if (this.generate)
+            this.generateCompletions();
+        let items = this.completions;
+
+        // Check for cache miss
+        if (this._cache.completions !== this.completions) {
+            this._cache.completions = this.completions;
+            this._cache.constructed = null;
+            this.cache.filtered = null;
+        }
+
+        if (this.cache.filtered && this.cache.filter == this.filter)
+            return this.cache.filtered;
+
+        this.cache.rows = [];
+        this.cache.filter = this.filter;
+        if (items == null)
+            return null;
+
+        let self = this;
+        delete this._substrings;
+
+        if (!this.forceAnchored && this.options)
+            this.anchored = this.options.get("wildanchor").getKey(this.name, this.anchored);
+
+        // Item matchers
+        if (this.ignoreCase)
+            this.matchString = this.anchored ?
+                function (filter, str) String.toLowerCase(str).indexOf(filter.toLowerCase()) == 0 :
+                function (filter, str) String.toLowerCase(str).indexOf(filter.toLowerCase()) >= 0;
+        else
+            this.matchString = this.anchored ?
+                function (filter, str) String.indexOf(str, filter) == 0 :
+                function (filter, str) String.indexOf(str, filter) >= 0;
+
+        // Item formatters
+        this.processor = Array.slice(this.process);
+        if (!this.anchored)
+            this.processor[0] = function (item, text) self.process[0].call(self, item,
+                    template.highlightFilter(item.text, self.filter));
+
+        try {
+            // Item prototypes
+            if (!this._cache.constructed) {
+                let proto = this.itemPrototype;
+                this._cache.constructed = items.map(function (item) ({ __proto__: proto, item: item }));
+            }
+
+            // Filters
+            let filtered = this.filterFunc(this._cache.constructed);
+            if (this.maxItems)
+                filtered = filtered.slice(0, this.maxItems);
+
+            // Sorting
+            if (this.sortResults && this.compare) {
+                filtered.sort(this.compare);
+                if (!this.anchored) {
+                    let filter = this.filter;
+                    filtered.sort(function (a, b) (b.text.indexOf(filter) == 0) - (a.text.indexOf(filter) == 0));
+                }
+            }
+
+            return this.cache.filtered = filtered;
+        }
+        catch (e) {
+            this.message = "Error: " + e;
+            util.reportError(e);
+            return [];
+        }
+    },
+
+    /**
+     * Returns a list of all substrings common to all items which
+     * include the current filter.
+     */
+    get substrings() {
+        let items = this.items;
+        if (items.length == 0 || !this.hasItems)
+            return [];
+        if (this._substrings)
+            return this._substrings;
+
+        let fixCase = this.ignoreCase ? String.toLowerCase : util.identity;
+        let text   = fixCase(items[0].text);
+        let filter = fixCase(this.filter);
+
+        // Exceedingly long substrings cause Gecko to go into convulsions
+        if (text.length > 100)
+            text = text.substr(0, 100);
+
+        if (this.anchored) {
+            var compare = function compare(text, s) text.substr(0, s.length) == s;
+            var substrings = [text];
+        }
+        else {
+            var compare = function compare(text, s) text.indexOf(s) >= 0;
+            var substrings = [];
+            let start = 0;
+            let idx;
+            let length = filter.length;
+            while ((idx = text.indexOf(filter, start)) > -1 && idx < text.length) {
+                substrings.push(text.substring(idx));
+                start = idx + 1;
+            }
+        }
+
+        substrings = items.reduce(function (res, item)
+            res.map(function (substring) {
+                // A simple binary search to find the longest substring
+                // of the given string which also matches the current
+                // item's text.
+                let len = substring.length;
+                let i = 0, n = len;
+                while (n) {
+                    let m = Math.floor(n / 2);
+                    let keep = compare(fixCase(item.text), substring.substring(0, i + m));
+                    if (!keep)
+                        len = i + m - 1;
+                    if (!keep || m == 0)
+                        n = m;
+                    else {
+                        i += m;
+                        n = n - m;
+                    }
+                }
+                return len == substring.length ? substring : substring.substr(0, Math.max(len, 0));
+            }),
+            substrings);
+
+        let quote = this.quote;
+        if (quote)
+            substrings = substrings.map(function (str) quote[0] + quote[1](str));
+        return this._substrings = substrings;
+    },
+
+    /**
+     * Advances the context *count* characters. {@link #filter} is advanced to
+     * match. If {@link #quote} is non-null, its prefix and suffix are set to
+     * the null-string.
+     *
+     * This function is still imperfect for quoted strings. When
+     * {@link #quote} is non-null, it adjusts the count based on the quoted
+     * size of the *count*-character substring of the filter, which is accurate
+     * so long as unquoting and quoting a string will always map to the
+     * original quoted string, which is often not the case.
+     *
+     * @param {number} count The number of characters to advance the context.
+     */
+    advance: function advance(count) {
+        delete this.ignoreCase;
+        let advance = count;
+        if (this.quote && count) {
+            advance = this.quote[1](this.filter.substr(0, count)).length;
+            count = this.quote[0].length + advance;
+            this.quote[0] = "";
+            this.quote[2] = "";
+        }
+        this.offset += count;
+        if (this._filter)
+            this._filter = this._filter.substr(advance);
+    },
+
+    /**
+     * Calls the {@link #cancel} method of all currently active
+     * sub-contexts.
+     */
+    cancelAll: function () {
+        for (let [, context] in Iterator(this.contextList)) {
+            if (context.cancel)
+                context.cancel();
+        }
+    },
+
+    /**
+     * Gets a key from {@link #cache}, setting it to *defVal* if it doesn't
+     * already exists.
+     *
+     * @param {string} key
+     * @param defVal
+     */
+    getCache: function (key, defVal) {
+        if (!(key in this.cache))
+            this.cache[key] = defVal();
+        return this.cache[key];
+    },
+
+    getItems: function getItems(start, end) {
+        let items = this.items;
+        let step = start > end ? -1 : 1;
+        start = Math.max(0, start || 0);
+        end = Math.min(items.length, end ? end : items.length);
+        return iter.map(util.range(start, end, step), function (i) items[i]);
+    },
+
+    getRows: function getRows(start, end, doc) {
+        let self = this;
+        let items = this.items;
+        let cache = this.cache.rows;
+        let step = start > end ? -1 : 1;
+        start = Math.max(0, start || 0);
+        end = Math.min(items.length, end != null ? end : items.length);
+        for (let i in util.range(start, end, step))
+            yield [i, cache[i] = cache[i] || util.xmlToDom(self.createRow(items[i]), doc)];
+    },
+
+    /**
+     * Forks this completion context to create a new sub-context named
+     * as {this.name}/{name}. The new context is automatically advanced
+     * *offset* characters. If *completer* is provided, it is called
+     * with *self* as its 'this' object, the new context as its first
+     * argument, and any subsequent arguments after *completer* as its
+     * following arguments.
+     *
+     * If *completer* is provided, this function returns its return
+     * value, otherwise it returns the new completion context.
+     *
+     * @param {string} name The name of the new context.
+     * @param {number} offset The offset of the new context relative to
+     *      the current context's offset.
+     * @param {object} self *completer*'s 'this' object. @optional
+     * @param {function|string} completer A completer function to call
+     *      for the new context. If a string is provided, it is
+     *      interpreted as a method to access on *self*.
+     */
+    fork: function fork(name, offset, self, completer) {
+        return this.forkapply(name, offset, self, completer, Array.slice(arguments, fork.length));
+    },
+
+    forkapply: function forkapply(name, offset, self, completer, args) {
+        if (isString(completer))
+            completer = self[completer];
+        let context = this.constructor(this, name, offset);
+        if (this.contextList.indexOf(context) < 0)
+            this.contextList.push(context);
+
+        if (!context.autoComplete && !context.tabPressed && context.editor)
+            context.waitingForTab = true;
+        else if (completer) {
+            let res = completer.apply(self || this, [context].concat(args));
+            if (res && !isArray(res) && !isArray(res.__proto__))
+                res = [k for (k in res)];
+            if (res)
+                context.completions = res;
+            return res;
+        }
+        if (completer)
+            return null;
+        return context;
+    },
+
+    split: function split(name, obj, fn) {
+        const self = this;
+
+        let context = this.fork(name);
+        function alias(prop) {
+            context.__defineGetter__(prop, function () self[prop]);
+            context.__defineSetter__(prop, function (val) self[prop] = val);
+        }
+        alias("_cache");
+        alias("_completions");
+        alias("_generate");
+        alias("_regenerate");
+        alias("itemCache");
+        alias("lastActivated");
+        context.hasItems = true;
+        this.hasItems = false;
+        if (fn)
+            return fn.apply(obj || this, [context].concat(Array.slice(arguments, split.length)));
+        return context;
+    },
+
+    /**
+     * Highlights text in the nsIEditor associated with this completion
+     * context. *length* characters are highlighted from the position
+     * *start*, relative to the current context's offset, with the
+     * selection type *type* as defined in nsISelectionController.
+     *
+     * When called with no arguments, all highlights are removed. When
+     * called with a 0 length, all highlights of type *type* are
+     * removed.
+     *
+     * @param {number} start The position at which to start
+     *      highlighting.
+     * @param {number} length The length of the substring to highlight.
+     * @param {string} type The selection type to highlight with.
+     */
+    highlight: function highlight(start, length, type) {
+        if (arguments.length == 0) {
+            for (let type in this.selectionTypes)
+                this.highlight(0, 0, type);
+            this.selectionTypes = {};
+        }
+        try {
+            // Requires Gecko >= 1.9.1
+            this.selectionTypes[type] = true;
+            const selType = Ci.nsISelectionController["SELECTION_" + type];
+            let sel = this.editor.selectionController.getSelection(selType);
+            if (length == 0)
+                sel.removeAllRanges();
+            else {
+                let range = this.editor.selection.getRangeAt(0).cloneRange();
+                range.setStart(range.startContainer, this.offset + start);
+                range.setEnd(range.startContainer, this.offset + start + length);
+                sel.addRange(range);
+            }
+        }
+        catch (e) {}
+    },
+
+    /**
+     * Tests the given string for a match against the current filter,
+     * taking into account anchoring and case sensitivity rules.
+     *
+     * @param {string} str The string to match.
+     * @returns {boolean} True if the string matches, false otherwise.
+     */
+    match: function match(str) this.matchString(this.filter, str),
+
+    /**
+     * Pushes a new output processor onto the processor chain of
+     * {@link #process}. The provided function is called with the item
+     * and text to process along with a reference to the processor
+     * previously installed in the given *index* of {@link #process}.
+     *
+     * @param {number} index The index into {@link #process}.
+     * @param {function(object, string, function)} func The new
+     *      processor.
+     */
+    pushProcessor: function pushProcess(index, func) {
+        let next = this.process[index];
+        this.process[index] = function (item, text) func(item, text, next);
+    },
+
+    /**
+     * Resets this completion context and all sub-contexts for use in a
+     * new completion cycle. May only be called on the top-level
+     * context.
+     */
+    reset: function reset() {
+        let self = this;
+        if (this.parent)
+            throw Error();
+
+        this.offset = 0;
+        this.process = [template.icon, function (item, k) k];
+        this.filters = [CompletionContext.Filter.text];
+        this.tabPressed = false;
+        this.title = ["Completions"];
+        this.updateAsync = false;
+
+        this.cancelAll();
+
+        if (this.editor) {
+            this.value = this.editor.selection.focusNode.textContent;
+            this._caret = this.editor.selection.focusOffset;
+        }
+        else {
+            this.value = this._value;
+            this._caret = this.value.length;
+        }
+        //for (let key in (k for ([k, v] in Iterator(self.contexts)) if (v.offset > this.caret)))
+        //    delete this.contexts[key];
+        for each (let context in this.contexts) {
+            context.hasItems = false;
+            context.incomplete = false;
+        }
+        this.waitingForTab = false;
+        this.runCount++;
+        for each (let context in this.contextList)
+            context.lastActivated = this.runCount;
+        this.contextList = [];
+    },
+
+    /**
+     * Wait for all subcontexts to complete.
+     *
+     * @param {number} timeout The maximum time, in milliseconds, to wait.
+     *    If 0 or null, wait indefinitely.
+     * @param {boolean} interruptible When true, the call may be interrupted
+     *    via <C-c>, in which case, "Interrupted" may be thrown.
+     */
+    wait: function wait(timeout, interruptable) {
+        this.allItems;
+        return util.waitFor(function () !this.incomplete, this, timeout, interruptable);
+    }
+}, {
+    Sort: {
+        number: function (a, b) parseInt(a.text) - parseInt(b.text) || String.localeCompare(a.text, b.text),
+        unsorted: null
+    },
+
+    Filter: {
+        text: function (item) {
+            let text = Array.concat(item.text);
+            for (let [i, str] in Iterator(text)) {
+                if (this.match(String(str))) {
+                    item.text = String(text[i]);
+                    return true;
+                }
+            }
+            return false;
+        },
+        textDescription: function (item) {
+            return CompletionContext.Filter.text.call(this, item) || this.match(item.description);
+        }
+    }
+});
+
+/**
+ * @instance completion
+ */
+var Completion = Module("completion", {
+    init: function () {
+    },
+
+    get setFunctionCompleter() JavaScript.setCompleter, // Backward compatibility
+
+    Local: function (dactyl, modules, window) ({
+        urlCompleters: {},
+
+        get options() modules.options,
+
+        // FIXME
+        _runCompleter: function _runCompleter(name, filter, maxItems) {
+            let context = modules.CompletionContext(filter);
+            context.maxItems = maxItems;
+            let res = context.fork.apply(context, ["run", 0, this, name].concat(Array.slice(arguments, 3)));
+            if (res) {
+                if (Components.stack.caller.name === "runCompleter") // FIXME
+                    return { items: res.map(function (i) ({ item: i })) };
+                context.contexts["/run"].completions = res;
+            }
+            context.wait(null, true);
+            return context.allItems;
+        },
+
+        runCompleter: function runCompleter(name, filter, maxItems) {
+            return this._runCompleter.apply(this, Array.slice(arguments))
+                       .items.map(function (i) i.item);
+        },
+
+        listCompleter: function listCompleter(name, filter, maxItems) {
+            let context = modules.CompletionContext(filter || "");
+            context.maxItems = maxItems;
+            context.fork.apply(context, ["list", 0, this, name].concat(Array.slice(arguments, 3)));
+            context = context.contexts["/list"];
+            context.wait(null, true);
+
+            let contexts = context.contextList.filter(function (c) c.hasItems && c.items.length);
+            if (!contexts.length)
+                contexts = context.contextList.filter(function (c) c.hasItems).slice(0, 1);
+            if (!contexts.length)
+                contexts = context.contextList.slice(-1);
+
+            modules.commandline.commandOutput(
+                <div highlight="Completions">
+                    { template.map(contexts, function (context)
+                            template.completionRow(context.title, "CompTitle") +
+                            template.map(context.items, function (item) context.createRow(item), null, 100)) }
+                </div>);
+        },
+    }),
+
+    ////////////////////////////////////////////////////////////////////////////////
+    ////////////////////// COMPLETION TYPES ////////////////////////////////////////
+    /////////////////////////////////////////////////////////////////////////////{{{
+
+    // filter a list of urls
+    //
+    // may consist of search engines, filenames, bookmarks and history,
+    // depending on the 'complete' option
+    // if the 'complete' argument is passed like "h", it temporarily overrides the complete option
+    url: function url(context, complete) {
+
+        if (this.options["urlseparator"])
+            var skip = util.regexp("^.*" + this.options["urlseparator"] + "\\s*")
+                           .exec(context.filter);
+
+        if (skip)
+            context.advance(skip[0].length);
+
+        if (/^about:/.test(context.filter))
+            context.fork("about", 6, this, function (context) {
+                context.generate = function () {
+                    const PREFIX = "@mozilla.org/network/protocol/about;1?what=";
+                    return [[k.substr(PREFIX.length), ""] for (k in Cc) if (k.indexOf(PREFIX) == 0)];
+                };
+            });
+
+        if (complete == null)
+            complete = this.options["complete"];
+
+        // Will, and should, throw an error if !(c in opts)
+        Array.forEach(complete, function (c) {
+            let completer = this.urlCompleters[c];
+            context.forkapply(c, 0, this, completer.completer, completer.args);
+        }, this);
+    },
+
+    addUrlCompleter: function addUrlCompleter(opt) {
+        let completer = Completion.UrlCompleter.apply(null, Array.slice(arguments));
+        completer.args = Array.slice(arguments, completer.length);
+        this.urlCompleters[opt] = completer;
+    },
+
+    urls: function (context, tags) {
+        let compare = String.localeCompare;
+        let contains = String.indexOf;
+        if (context.ignoreCase) {
+            compare = util.compareIgnoreCase;
+            contains = function (a, b) a && a.toLowerCase().indexOf(b.toLowerCase()) > -1;
+        }
+
+        if (tags)
+            context.filters.push(function (item) tags.
+                every(function (tag) (item.tags || []).
+                    some(function (t) !compare(tag, t))));
+
+        context.anchored = false;
+        if (!context.title)
+            context.title = ["URL", "Title"];
+
+        context.fork("additional", 0, this, function (context) {
+            context.title[0] += " (additional)";
+            context.filter = context.parent.filter; // FIXME
+            context.completions = context.parent.completions;
+            // For items whose URL doesn't exactly match the filter,
+            // accept them if all tokens match either the URL or the title.
+            // Filter out all directly matching strings.
+            let match = context.filters[0];
+            context.filters[0] = function (item) !match.call(this, item);
+            // and all that don't match the tokens.
+            let tokens = context.filter.split(/\s+/);
+            context.filters.push(function (item) tokens.every(
+                    function (tok) contains(item.url, tok) ||
+                                   contains(item.title, tok)));
+
+            let re = RegExp(tokens.filter(util.identity).map(util.regexp.escape).join("|"), "g");
+            function highlight(item, text, i) process[i].call(this, item, template.highlightRegexp(text, re));
+            let process = context.process;
+            context.process = [
+                function (item, text) highlight.call(this, item, item.text, 0),
+                function (item, text) highlight.call(this, item, text, 1)
+            ];
+        });
+    }
+    //}}}
+}, {
+    UrlCompleter: Struct("name", "description", "completer")
+}, {
+    init: function init(dactyl, modules, window) {
+        init.superapply(this, arguments);
+
+        modules.CompletionContext = Class("CompletionContext", CompletionContext, {
+            init: function init() {
+                this.modules = modules;
+                return init.superapply(this, arguments);
+            },
+
+            get options() this.modules.options
+        });
+    },
+    commands: function (dactyl, modules, window) {
+        const { commands, completion } = modules;
+        commands.add(["contexts"],
+            "List the completion contexts used during completion of an Ex command",
+            function (args) {
+                modules.commandline.commandOutput(
+                    <div highlight="Completions">
+                        { template.completionRow(["Context", "Title"], "CompTitle") }
+                        { template.map(completion.contextList || [], function (item) template.completionRow(item, "CompItem")) }
+                    </div>);
+            },
+            {
+                argCount: "*",
+                completer: function (context) {
+                    let PREFIX = "/ex/contexts";
+                    context.fork("ex", 0, completion, "ex");
+                    completion.contextList = [[k.substr(PREFIX.length), v.title[0]] for ([k, v] in iter(context.contexts)) if (k.substr(0, PREFIX.length) == PREFIX)];
+                },
+                literal: 0
+            });
+    },
+    options: function (dactyl, modules, window) {
+        const { completion, options } = modules;
+        let wildmode = {
+            values: {
+                // Why do we need ""?
+                // Because its description is useful during completion. --Kris
+                "":              "Complete only the first match",
+                "full":          "Complete the next full match",
+                "longest":       "Complete the longest common string",
+                "list":          "If more than one match, list all matches",
+                "list:full":     "List all and complete first match",
+                "list:longest":  "List all and complete the longest common string"
+            },
+            checkHas: function (value, val) {
+                let [first, second] = value.split(":", 2);
+                return first == val || second == val;
+            },
+            has: function () {
+                test = function (val) this.value.some(function (value) this.checkHas(value, val), this);
+                return Array.some(arguments, test, this);
+            }
+        };
+
+        options.add(["altwildmode", "awim"],
+            "Define the behavior of the <A-Tab> key in command-line completion",
+            "stringlist", "list:full",
+            wildmode);
+
+        options.add(["autocomplete", "au"],
+            "Automatically update the completion list on any key press",
+            "regexplist", ".*");
+
+        options.add(["complete", "cpt"],
+            "Items which are completed at the :open prompts",
+            "charlist", config.defaults.complete == null ? "slf" : config.defaults.complete,
+            { get values() values(completion.urlCompleters).toArray() });
+
+        options.add(["wildanchor", "wia"],
+            "Define which completion groups only match at the beginning of their text",
+            "regexplist", "!/ex/(back|buffer|ext|forward|help|undo)");
+
+        options.add(["wildcase", "wic"],
+            "Completion case matching mode",
+            "regexpmap", ".?:smart",
+            {
+                values: {
+                    "smart": "Case is significant when capital letters are typed",
+                    "match": "Case is always significant",
+                    "ignore": "Case is never significant"
+                }
+            });
+
+        options.add(["wildmode", "wim"],
+            "Define the behavior of the <Tab> key in command-line completion",
+            "stringlist", "list:full",
+            wildmode);
+
+        options.add(["wildsort", "wis"],
+            "Define which completion groups are sorted",
+            "regexplist", ".*");
+    }
+});
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/config.jsm b/common/modules/config.jsm
new file mode 100644 (file)
index 0000000..9fb101a
--- /dev/null
@@ -0,0 +1,790 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+let global = this;
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("config", {
+    exports: ["ConfigBase", "Config", "config"],
+    require: ["services", "storage", "util", "template"],
+    use: ["io", "prefs"]
+}, this);
+
+var ConfigBase = Class("ConfigBase", {
+    /**
+     * Called on dactyl startup to allow for any arbitrary application-specific
+     * initialization code. Must call superclass's init function.
+     */
+    init: function init() {
+        this.features.push = deprecated("set.add", function push(feature) set.add(this, feature));
+        if (util.haveGecko("2b"))
+            set.add(this.features, "Gecko2");
+
+        this.timeout(function () {
+            services["dactyl:"].pages.dtd = function () [null,
+                iter(config.dtdExtra,
+                     (["dactyl." + k, v] for ([k, v] in iter(config.dtd))),
+                     (["dactyl." + s, config[s]] for each (s in config.dtdStrings)))
+                  .map(function ([k, v]) ["<!ENTITY ", k, " '", String.replace(v || "null", /'/g, "&apos;"), "'>"].join(""))
+                  .join("\n")]
+        });
+    },
+
+    loadStyles: function loadStyles() {
+        const { highlight } = require("highlight");
+        highlight.styleableChrome = this.styleableChrome;
+        highlight.loadCSS(this.CSS);
+        highlight.loadCSS(this.helpCSS);
+        if (!util.haveGecko("2b"))
+            highlight.loadCSS(<![CDATA[
+                !TabNumber               font-weight: bold; margin: 0px; padding-right: .8ex;
+                !TabIconNumber {
+                    font-weight: bold;
+                    color: white;
+                    text-align: center;
+                    text-shadow: black -1px 0 1px, black 0 1px 1px, black 1px 0 1px, black 0 -1px 1px;
+                }
+            ]]>);
+    },
+
+    get addonID() this.name + "@dactyl.googlecode.com",
+    addon: Class.memoize(function () {
+        let addon;
+        do {
+            addon = (JSMLoader.bootstrap || {}).addon;
+            if (addon && !addon.getResourceURI) {
+                util.reportError(Error(_("addon.unavailable")));
+                yield 10;
+            }
+        }
+        while (addon && !addon.getResourceURI);
+
+        if (!addon)
+            addon = require("addons").AddonManager.getAddonByID(this.addonID);
+        yield addon;
+    }, true),
+
+    /**
+     * The current application locale.
+     */
+    appLocale: Class.memoize(function () services.chromeRegistry.getSelectedLocale("global")),
+
+    /**
+     * The current dactyl locale.
+     */
+    locale: Class.memoize(function () this.bestLocale(this.locales)),
+
+    /**
+     * The current application locale.
+     */
+    locales: Class.memoize(function () {
+        // TODO: Merge with completion.file code.
+        function getDir(str) str.match(/^(?:.*[\/\\])?/)[0];
+
+        let uri = "resource://dactyl-locale/";
+        let jar = io.isJarURL(uri);
+        if (jar) {
+            let prefix = getDir(jar.JAREntry);
+            var res = iter(s.slice(prefix.length).replace(/\/.*/, "") for (s in io.listJar(jar.JARFile, prefix)))
+                        .toArray();
+        }
+        else {
+            res = array(f.leafName
+                        // Fails on FF3: for (f in util.getFile(uri).iterDirectory())
+                        for (f in values(util.getFile(uri).readDirectory()))
+                        if (f.isDirectory())).array;
+        }
+
+        function exists(pkg) {
+            try {
+                services["resource:"].getSubstitution(pkg);
+                return true;
+            }
+            catch (e) {
+                return false;
+            }
+        }
+
+        return array.uniq([this.appLocale, this.appLocale.replace(/-.*/, "")]
+                            .filter(function (locale) exists("dactyl-locale-" + locale))
+                            .concat(res));
+    }),
+
+    /**
+     * Returns the best locale match to the current locale from a list
+     * of available locales.
+     *
+     * @param {[string]} list A list of available locales
+     * @returns {string}
+     */
+    bestLocale: function (list) {
+        let langs = set(list);
+        return values([this.appLocale, this.appLocale.replace(/-.*/, ""),
+                       "en", "en-US", iter(langs).next()])
+            .nth(function (l) set.has(langs, l), 0);
+    },
+
+    haveHg: Class.memoize(function () {
+        if (/pre$/.test(this.addon.version)) {
+            let uri = this.addon.getResourceURI("../.hg");
+            if (uri instanceof Ci.nsIFileURL &&
+                    uri.QueryInterface(Ci.nsIFileURL).file.exists() &&
+                    io.pathSearch("hg"))
+                return ["hg", "-R", uri.file.parent.path];
+        }
+        return null;
+    }),
+
+    branch: Class.memoize(function () {
+        if (this.haveHg)
+            return io.system(this.haveHg.concat(["branch"])).output;
+        return (/pre-hg\d+-(\S*)/.exec(this.version) || [])[1];
+    }),
+
+    /** @property {string} The Dactyl version string. */
+    version: Class.memoize(function () {
+        if (/pre$/.test(this.addon.version)) {
+            let uri = this.addon.getResourceURI("../.hg");
+            if (uri instanceof Ci.nsIFileURL &&
+                    uri.QueryInterface(Ci.nsIFileURL).file.exists() &&
+                    io.pathSearch("hg")) {
+                return io.system(["hg", "-R", uri.file.parent.path,
+                                  "log", "-r.",
+                                  "--template=hg{rev}-" + this.branch + " ({date|isodate})"]).output;
+            }
+        }
+        let version = this.addon.version;
+        if ("@DATE@" !== "@" + "DATE@")
+            version += " (created: @DATE@)";
+        return version;
+    }),
+
+    get fileExt() this.name.slice(0, -5),
+
+    dtd: memoize({
+        get name() config.name,
+        get home() "http://dactyl.sourceforge.net/",
+        get apphome() this.home + this.name,
+        code: "http://code.google.com/p/dactyl/",
+        get issues() this.home + "bug/" + this.name,
+        get plugins() "http://dactyl.sf.net/" + this.name + "/plugins",
+        get faq() this.home + this.name + "/faq",
+
+        "list.mailto": Class.memoize(function () config.name + "@googlegroups.com"),
+        "list.href": Class.memoize(function () "http://groups.google.com/group/" + config.name),
+
+        "hg.latest": Class.memoize(function () this.code + "source/browse/"), // XXX
+        "irc": "irc://irc.oftc.net/#pentadactyl",
+    }),
+
+    dtdExtra: {
+        "xmlns.dactyl": "http://vimperator.org/namespaces/liberator",
+        "xmlns.html":   "http://www.w3.org/1999/xhtml",
+        "xmlns.xul":    "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul",
+
+        "tag.command-line": '<link topic="command-line">command line</link>',
+        "tag.status-line":  '<link topic="status-line">status line</link>',
+    },
+
+    dtdStrings: [
+        "appName",
+        "fileExt",
+        "host",
+        "hostbin",
+        "idName",
+        "name",
+        "version"
+    ],
+
+    helpStyles: /^(Help|StatusLine|REPL)|^(Boolean|Indicator|MoreMsg|Number|Object|Logo|Key(word)?|String)$/,
+    styleHelp: function styleHelp() {
+        if (!this.helpStyled) {
+            const { highlight } = require("highlight");
+            for (let k in keys(highlight.loaded))
+                if (this.helpStyles.test(k))
+                    highlight.loaded[k] = true;
+        }
+        this.helpCSS = true;
+    },
+
+    Local: function Local(dactyl, modules, window) ({
+        init: function init() {
+
+            let append = <e4x xmlns={XUL} xmlns:dactyl={NS}>
+                    <menupopup id="viewSidebarMenu"/>
+                    <broadcasterset id="mainBroadcasterSet"/>
+            </e4x>;
+            for each (let [id, [name, key, uri]] in Iterator(this.sidebars)) {
+                append.XUL::menupopup[0].* +=
+                        <menuitem observes={"pentadactyl-" + id + "Sidebar"} label={name} accesskey={key} xmlns={XUL}/>
+                append.XUL::broadcasterset[0].* +=
+                        <broadcaster id={"pentadactyl-" + id + "Sidebar"}
+                            autoCheck="false" type="checkbox" group="sidebar"
+                            sidebartitle={name} sidebarurl={uri}
+                            oncommand="toggleSidebar(this.id || this.observes);" xmlns={XUL}/>
+            }
+
+            util.overlayWindow(window, { append: append.elements() });
+        },
+
+        browser: Class.memoize(function () window.gBrowser),
+        tabbrowser: Class.memoize(function () window.gBrowser),
+
+        get browserModes() [modules.modes.NORMAL],
+
+        /**
+         * @property {string} The ID of the application's main XUL window.
+         */
+        mainWindowId: window.document.documentElement.id,
+
+        /**
+         * @property {number} The height (px) that is available to the output
+         *     window.
+         */
+        get outputHeight() this.browser.mPanelContainer.boxObject.height,
+
+        tabStrip: Class.memoize(function () window.document.getElementById("TabsToolbar") || this.tabbrowser.mTabContainer),
+    }),
+
+    /**
+     * @property {Object} A mapping of names and descriptions
+     *     of the autocommands available in this application. Primarily used
+     *     for completion results.
+     */
+    autocommands: {},
+
+    /**
+     * @property {Object} A map of :command-complete option values to completer
+     *     function names.
+     */
+    completers: {
+       abbreviation: "abbreviation",
+       altstyle: "alternateStyleSheet",
+       bookmark: "bookmark",
+       buffer: "buffer",
+       charset: "charset",
+       color: "colorScheme",
+       command: "command",
+       dialog: "dialog",
+       dir: "directory",
+       environment: "environment",
+       event: "autocmdEvent",
+       extension: "extension",
+       file: "file",
+       help: "help",
+       highlight: "highlightGroup",
+       history: "history",
+       javascript: "javascript",
+       macro: "macro",
+       mapping: "userMapping",
+       mark: "mark",
+       menu: "menuItem",
+       option: "option",
+       preference: "preference",
+       qmark: "quickmark",
+       runtime: "runtime",
+       search: "search",
+       shellcmd: "shellCommand",
+       toolbar: "toolbar",
+       url: "url",
+       usercommand: "userCommand"
+    },
+
+    /**
+     * @property {Object} Application specific defaults for option values. The
+     *     property names must be the options' canonical names, and the values
+     *     must be strings as entered via :set.
+     */
+    defaults: { guioptions: "rb" },
+    cleanups: {},
+
+    /**
+     * @property {Object} A map of dialogs available via the
+     *      :dialog command. Property names map dialog names to an array
+     *      as follows:
+     *  [0] description - A description of the dialog, used in
+     *                    command completion results for :dialog.
+     *  [1] action - The function executed by :dialog.
+     */
+    dialogs: {},
+
+    /**
+     * @property {set} A list of features available in this
+     *    application. Used extensively in feature test macros. Use
+     *    dactyl.has(feature) to check for a feature's presence
+     *    in this array.
+     */
+    features: {},
+
+    /**
+     * @property {string} The file extension used for command script files.
+     *     This is the name string sans "dactyl".
+     */
+    get fileExtension() this.name.slice(0, -6),
+
+    guioptions: {},
+
+    hasTabbrowser: false,
+
+    /**
+     * @property {string} The name of the application that hosts the
+     *     extension. E.g., "Firefox" or "XULRunner".
+     */
+    host: null,
+
+    /**
+     * @property {[[]]} An array of application specific mode specifications.
+     *     The values of each mode are passed to modes.addMode during
+     *     dactyl startup.
+     */
+    modes: [],
+
+    /**
+     * @property {string} The name of the extension.
+     *    Required.
+     */
+    name: null,
+
+    /**
+     * @property {[string]} A list of extra scripts in the dactyl or
+     *    application namespaces which should be loaded before dactyl
+     *    initialization.
+     */
+    scripts: [],
+
+    sidebars: {},
+
+    /**
+     * @property {string} The leaf name of any temp files created by
+     *     {@link io.createTempFile}.
+     */
+    get tempFile() this.name + ".tmp",
+
+    /**
+     * @constant
+     * @property {string} The default highlighting rules.
+     * See {@link Highlights#loadCSS} for details.
+     */
+    CSS: UTF8(String.replace(<><![CDATA[
+        // <css>
+        Boolean      color: red;
+        Function     color: navy;
+        Null         color: blue;
+        Number       color: blue;
+        Object       color: maroon;
+        String       color: green; white-space: pre;
+
+        Key          font-weight: bold;
+
+        Enabled      color: blue;
+        Disabled     color: red;
+
+        FontFixed                            font-family: monospace !important;
+        FontCode            font-size: 9pt;  font-family: -mox-fixed, monospace !important;
+        FontProportional    font-size: 10pt; font-family: "Droid Sans", "Helvetica LT Std", Helvetica, "DejaVu Sans", Verdana, sans-serif !important;
+
+        // Hack to give these groups slightly higher precedence
+        // than their unadorned variants.
+        CmdCmdLine;[dactyl|highlight]>*  &#x0d; StatusCmdLine;[dactyl|highlight]>*
+        CmdNormal;[dactyl|highlight]     &#x0d; StatusNormal;[dactyl|highlight]
+        CmdErrorMsg;[dactyl|highlight]   &#x0d; StatusErrorMsg;[dactyl|highlight]
+        CmdInfoMsg;[dactyl|highlight]    &#x0d; StatusInfoMsg;[dactyl|highlight]
+        CmdModeMsg;[dactyl|highlight]    &#x0d; StatusModeMsg;[dactyl|highlight]
+        CmdMoreMsg;[dactyl|highlight]    &#x0d; StatusMoreMsg;[dactyl|highlight]
+        CmdQuestion;[dactyl|highlight]   &#x0d; StatusQuestion;[dactyl|highlight]
+        CmdWarningMsg;[dactyl|highlight] &#x0d; StatusWarningMsg;[dactyl|highlight]
+
+        Normal            color: black   !important; background: white       !important; font-weight: normal !important;
+        StatusNormal      color: inherit !important; background: transparent !important;
+        ErrorMsg          color: white   !important; background: red         !important; font-weight: bold !important;
+        InfoMsg           color: black   !important; background: white       !important;
+        StatusInfoMsg     color: inherit !important; background: transparent !important;
+        LineNr            color: orange  !important; background: white       !important;
+        ModeMsg           color: black   !important; background: white       !important;
+        StatusModeMsg     color: inherit !important; background: transparent !important; padding-right: 1em;
+        MoreMsg           color: green   !important; background: white       !important;
+        StatusMoreMsg                                background: transparent !important;
+        Message           white-space: pre-wrap !important; min-width: 100%; width: 100%; padding-left: 4em; text-indent: -4em; display: block;
+        Message String    white-space: pre-wrap;
+        NonText           color: blue; background: transparent !important;
+        *Preview          color: gray;
+        Question          color: green   !important; background: white       !important; font-weight: bold !important;
+        StatusQuestion    color: green   !important; background: transparent !important;
+        WarningMsg        color: red     !important; background: white       !important;
+        StatusWarningMsg  color: red     !important; background: transparent !important;
+
+        CmdLine;>*;;FontFixed   padding: 1px !important;
+        CmdPrompt;.dactyl-commandline-prompt
+        CmdInput;.dactyl-commandline-command
+        CmdOutput         white-space: pre;
+
+
+        CompGroup
+        CompGroup:not(:first-of-type)  margin-top: .5em;
+        CompGroup:last-of-type         padding-bottom: 1.5ex;
+
+        CompTitle            color: magenta; background: white; font-weight: bold;
+        CompTitle>*          padding: 0 .5ex;
+        CompTitleSep         height: 1px; background: magenta; background: -moz-linear-gradient(60deg, magenta, white);
+
+        CompMsg              font-style: italic; margin-left: 16px;
+
+        CompItem
+        CompItem:nth-child(2n+1)    background: rgba(0, 0, 0, .04);
+        CompItem[selected]   background: yellow;
+        CompItem>*           padding: 0 .5ex;
+
+        CompIcon             width: 16px; min-width: 16px; display: inline-block; margin-right: .5ex;
+        CompIcon>img         max-width: 16px; max-height: 16px; vertical-align: middle;
+
+        CompResult           width: 36%; padding-right: 1%; overflow: hidden;
+        CompDesc             color: gray; width: 62%; padding-left: 1em;
+
+        CompLess             text-align: center; height: 0;    line-height: .5ex; padding-top: 1ex;
+        CompLess::after      content: "⌃";
+
+        CompMore             text-align: center; height: .5ex; line-height: .5ex; margin-bottom: -.5ex;
+        CompMore::after      content: "⌄";
+
+
+        EditorEditing;;*   background: #bbb !important; -moz-user-input: none !important; -moz-user-modify: read-only !important;
+        EditorError;;*     background: red !important;
+        EditorBlink1;;*    background: yellow !important;
+        EditorBlink2;;*
+
+        REPL                overflow: auto; max-height: 40em;
+        REPL-R;;;Question
+        REPL-E              white-space: pre-wrap;
+        REPL-P              white-space: pre-wrap; margin-bottom: 1em;
+
+        Usage               width: 100%;
+        UsageBody
+        UsageHead
+        UsageItem
+        UsageItem:nth-of-type(2n)    background: rgba(0, 0, 0, .04);
+
+        Indicator   color: blue; width: 1.5em; text-align: center;
+        Filter      font-weight: bold;
+
+        Keyword     color: red;
+        Tag         color: blue;
+
+        Link                        position: relative; padding-right: 2em;
+        Link:not(:hover)>LinkInfo   opacity: 0; left: 0; width: 1px; height: 1px; overflow: hidden;
+        LinkInfo                    {
+            color: black;
+            position: absolute;
+            left: 100%;
+            padding: 1ex;
+            margin: -1ex -1em;
+            background: rgba(255, 255, 255, .8);
+            border-radius: 1ex;
+        }
+
+        StatusLine;;;FontFixed  {
+            -moz-appearance: none !important;
+            font-weight: bold;
+            background: transparent !important;
+            border: 0px !important;
+            padding-right: 0px !important;
+            min-height: 18px !important;
+            text-shadow: none !important;
+        }
+        StatusLineNormal;[dactyl|highlight]    color: white !important; background: black   !important;
+        StatusLineBroken;[dactyl|highlight]    color: black !important; background: #FFa0a0 !important; /* light-red */
+        StatusLineSecure;[dactyl|highlight]    color: black !important; background: #a0a0FF !important; /* light-blue */
+        StatusLineExtended;[dactyl|highlight]  color: black !important; background: #a0FFa0 !important; /* light-green */
+
+        TabClose;.tab-close-button
+        TabIcon;.tab-icon       min-width: 16px;
+        TabText;.tab-text
+        TabNumber               font-weight: bold; margin: 0px; padding-right: .8ex; cursor: default;
+        TabIconNumber {
+            cursor: default;
+            width: 16px;
+            margin: 0 2px 0 -18px !important;
+            font-weight: bold;
+            color: white;
+            text-align: center;
+            text-shadow: black -1px 0 1px, black 0 1px 1px, black 1px 0 1px, black 0 -1px 1px;
+        }
+
+        Title       color: magenta; font-weight: bold;
+        URL         text-decoration: none; color: green; background: inherit;
+        URL:hover   text-decoration: underline; cursor: pointer;
+        URLExtra    color: gray;
+
+        FrameIndicator;;* {
+            background-color: red;
+            opacity: 0.5;
+            z-index: 999999;
+            position: fixed;
+            top:      0;
+            bottom:   0;
+            left:     0;
+            right:    0;
+        }
+
+        Bell          background-color: black !important;
+
+        Hint;;* {
+            font:        bold 10px "Droid Sans Mono", monospace !important;
+            margin:      -.2ex;
+            padding:     0 0 0 1px;
+            outline:     1px solid rgba(0, 0, 0, .5);
+            background:  rgba(255, 248, 231, .8);
+            color:       black;
+        }
+        Hint[active];;*  background: rgba(255, 253, 208, .8);
+        Hint::after;;*   content: attr(text) !important;
+        HintElem;;*      background-color: yellow  !important; color: black !important;
+        HintActive;;*    background-color: #88FF00 !important; color: black !important;
+        HintImage;;*     opacity: .5 !important;
+
+        Button                  display: inline-block; font-weight: bold; cursor: pointer; color: black; text-decoration: none;
+        Button:hover            text-decoration: underline;
+        Button[collapsed]       visibility: collapse; width: 0;
+        Button::before          content: "["; color: gray; text-decoration: none !important;
+        Button::after           content: "]"; color: gray; text-decoration: none !important;
+        Button:not([collapsed]) ~ Button:not([collapsed])::before  content: "/[";
+
+        Buttons
+
+        DownloadCell                    display: table-cell; padding: 0 1ex;
+
+        Downloads                       display: table; margin: 0; padding: 0;
+        DownloadHead;;;CompTitle        display: table-row;
+        DownloadHead>*;;;DownloadCell
+
+        Download                        display: table-row;
+        Download:not([active])          color: gray;
+
+        Download>*;;;DownloadCell
+        DownloadButtons
+        DownloadPercent
+        DownloadProgress
+        DownloadProgressHave
+        DownloadProgressTotal
+        DownloadSource
+        DownloadState
+        DownloadTime
+        DownloadTitle
+        DownloadTitle>Link>a         max-width: 48ex; overflow: hidden; display: inline-block;
+
+        AddonCell                    display: table-cell; padding: 0 1ex;
+
+        Addons                       display: table; margin: 0; padding: 0;
+        AddonHead;;;CompTitle        display: table-row;
+        AddonHead>*;;;AddonCell
+
+        Addon                        display: table-row;
+
+        Addon>*;;;AddonCell
+        AddonButtons
+        AddonDescription
+        AddonName                    max-width: 48ex; overflow: hidden;
+        AddonStatus
+        AddonVersion
+
+        // </css>
+    ]]></>, /&#x0d;/g, "\n")),
+
+    helpCSS: UTF8(<><![CDATA[
+        // <css>
+        InlineHelpLink                              font-size: inherit !important; font-family: inherit !important;
+
+        Help;;;FontProportional                     line-height: 1.4em;
+
+        HelpInclude                                 margin: 2em 0;
+
+        HelpArg;;;FontCode                          color: #6A97D4;
+        HelpOptionalArg;;;FontCode                  color: #6A97D4;
+
+        HelpBody                                    display: block; margin: 1em auto; max-width: 100ex; padding-bottom: 1em; margin-bottom: 4em; border-bottom-width: 1px;
+        HelpBorder;*;dactyl://help/*                border-color: silver; border-width: 0px; border-style: solid;
+        HelpCode;;;FontCode                         display: block; white-space: pre; margin-left: 2em;
+        HelpTT;html|tt;dactyl://help/*;FontCode
+
+        HelpDefault;;;FontCode                      display: inline-block; margin: -1px 1ex 0 0; white-space: pre; vertical-align: text-top;
+
+        HelpDescription                             display: block; clear: right;
+        HelpDescription[short]                      clear: none;
+        HelpEm;html|em;dactyl://help/*              font-weight: bold; font-style: normal;
+
+        HelpEx;;;FontCode                           display: inline-block; color: #527BBD;
+
+        HelpExample                                 display: block; margin: 1em 0;
+        HelpExample::before                         content: "Example: "; font-weight: bold;
+
+        HelpInfo                                    display: block; width: 20em; margin-left: auto;
+        HelpInfoLabel                               display: inline-block; width: 6em;  color: magenta; font-weight: bold; vertical-align: text-top;
+        HelpInfoValue                               display: inline-block; width: 14em; text-decoration: none;             vertical-align: text-top;
+
+        HelpItem                                    display: block; margin: 1em 1em 1em 10em; clear: both;
+
+        HelpKey;;;FontCode                          color: #102663;
+        HelpKeyword                                 font-weight: bold; color: navy;
+
+        HelpLink;html|a;dactyl://help/*             text-decoration: none !important;
+        HelpLink[href]:hover                        text-decoration: underline !important;
+        HelpLink[href^="mailto:"]::after            content: "✉"; padding-left: .2em;
+        HelpLink[rel=external] {
+            /* Thanks, Wikipedia */
+            background: transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAMAAAC67D+PAAAAFVBMVEVmmcwzmcyZzP8AZswAZv////////9E6giVAAAAB3RSTlP///////8AGksDRgAAADhJREFUGFcly0ESAEAEA0Ei6/9P3sEcVB8kmrwFyni0bOeyyDpy9JTLEaOhQq7Ongf5FeMhHS/4AVnsAZubxDVmAAAAAElFTkSuQmCC) no-repeat scroll right center;
+            padding-right: 13px;
+        }
+
+
+        HelpTOC
+        HelpTOC>ol ol                               margin-left: -1em;
+
+        HelpOrderedList;ol;dactyl://help/*                          margin: 1em 0;
+        HelpOrderedList1;ol[level="1"],ol;dactyl://help/*           list-style: outside decimal; display: block;
+        HelpOrderedList2;ol[level="2"],ol ol;dactyl://help/*        list-style: outside upper-alpha;
+        HelpOrderedList3;ol[level="3"],ol ol ol;dactyl://help/*     list-style: outside lower-roman;
+        HelpOrderedList4;ol[level="4"],ol ol ol ol;dactyl://help/*  list-style: outside decimal;
+
+        HelpList;html|ul;dactyl://help/*      display: block; list-style-position: outside; margin: 1em 0;
+        HelpListItem;html|li;dactyl://help/*  display: list-item;
+
+
+        HelpNote                                    color: red; font-weight: bold;
+
+        HelpOpt;;;FontCode                          color: #106326;
+        HelpOptInfo;;;FontCode                      display: block; margin-bottom: 1ex; padding-left: 4em;
+
+        HelpParagraph;html|p;dactyl://help/*        display: block; margin: 1em 0em;
+        HelpParagraph:first-child                   margin-top: 0;
+        HelpParagraph:last-child                    margin-bottom: 0;
+        HelpSpec;;;FontCode                         display: block; margin-left: -10em; float: left; clear: left; color: #527BBD; margin-right: 1em;
+
+        HelpString;;;FontCode                       color: green; font-weight: normal;
+        HelpString::before                          content: '"';
+        HelpString::after                           content: '"';
+        HelpString[delim]::before                   content: attr(delim);
+        HelpString[delim]::after                    content: attr(delim);
+
+        HelpNews        position: relative;
+        HelpNewsOld     opacity: .7;
+        HelpNewsNew     font-style: italic;
+        HelpNewsTag     font-style: normal; position: absolute; left: 100%; padding-left: 1em; color: #527BBD; opacity: .6; white-space: pre;
+
+        HelpHead;html|h1,html|h2,html|h3,html|h4;dactyl://help/* {
+            font-weight: bold;
+            color: #527BBD;
+            clear: both;
+        }
+        HelpHead1;html|h1;dactyl://help/* {
+            margin: 2em 0 1em;
+            padding-bottom: .2ex;
+            border-bottom-width: 1px;
+            font-size: 2em;
+        }
+        HelpHead2;html|h2;dactyl://help/* {
+            margin: 2em 0 1em;
+            padding-bottom: .2ex;
+            border-bottom-width: 1px;
+            font-size: 1.2em;
+        }
+        HelpHead3;html|h3;dactyl://help/* {
+            margin: 1em 0;
+            padding-bottom: .2ex;
+            font-size: 1.1em;
+        }
+        HelpHead4;html|h4;dactyl://help/* {
+        }
+
+
+        HelpTab;html|dl;dactyl://help/* {
+            display: table;
+            width: 100%;
+            margin: 1em 0;
+            border-bottom-width: 1px;
+            border-top-width: 1px;
+            padding: .5ex 0;
+            table-layout: fixed;
+        }
+        HelpTabColumn;html|column;dactyl://help/*   display: table-column;
+        HelpTabColumn:first-child                   width: 25%;
+        HelpTabTitle;html|dt;dactyl://help/*;FontCode  display: table-cell; padding: .1ex 1ex; font-weight: bold;
+        HelpTabDescription;html|dd;dactyl://help/*  display: table-cell; padding: .3ex 1em; text-indent: -1em; border-width: 0px;
+        HelpTabDescription>*;;dactyl://help/*       text-indent: 0;
+        HelpTabRow;html|dl>html|tr;dactyl://help/*  display: table-row;
+
+        HelpTag;;;FontCode                          display: inline-block; color: #527BBD; margin-left: 1ex; font-weight: normal;
+        HelpTags                                    display: block; float: right; clear: right;
+        HelpTopic;;;FontCode                        color: #102663;
+        HelpType;;;FontCode                         margin-right: 2ex;
+
+        HelpWarning                                 color: red; font-weight: bold;
+
+        HelpXML;;;FontCode                          color: #C5F779; background-color: #444444; font-family: Terminus, Fixed, monospace;
+        HelpXMLBlock {                              white-space: pre; color: #C5F779; background-color: #444444;
+            border: 1px dashed #aaaaaa;
+            display: block;
+            margin-left: 2em;
+            font-family: Terminus, Fixed, monospace;
+        }
+        HelpXMLAttribute                            color: #C5F779;
+        HelpXMLAttribute::after                     color: #E5E5E5; content: "=";
+        HelpXMLComment                              color: #444444;
+        HelpXMLComment::before                      content: "<!--";
+        HelpXMLComment::after                       content: "-->";
+        HelpXMLProcessing                           color: #C5F779;
+        HelpXMLProcessing::before                   color: #444444; content: "<?";
+        HelpXMLProcessing::after                    color: #444444; content: "?>";
+        HelpXMLString                               color: #C5F779; white-space: pre;
+        HelpXMLString::before                       content: '"';
+        HelpXMLString::after                        content: '"';
+        HelpXMLNamespace                            color: #FFF796;
+        HelpXMLNamespace::after                     color: #777777; content: ":";
+        HelpXMLTagStart                             color: #FFF796; white-space: normal; display: inline-block; text-indent: -1.5em; padding-left: 1.5em;
+        HelpXMLTagEnd                               color: #71BEBE;
+        HelpXMLText                                 color: #E5E5E5;
+        // </css>
+    ]]></>)
+}, {
+});
+
+JSMLoader.loadSubScript("resource://dactyl-local-content/config.js", this);
+
+config.INIT = update(Object.create(config.INIT), config.INIT, {
+    init: function init(dactyl, modules, window) {
+        init.superapply(this, arguments);
+
+        let img = window.Image();
+        img.src = this.logo || "resource://dactyl-local-content/logo.png";
+        img.onload = util.wrapCallback(function () {
+            const { highlight } = require("highlight");
+            highlight.loadCSS(<>{"!Logo  {"}
+                     display:    inline-block;
+                     background: url({img.src});
+                     width:      {img.width}px;
+                     height:     {img.height}px;
+                 {"}"}</>);
+            img = null;
+        });
+    },
+
+    load: function load(dactyl, modules, window) {
+        load.superapply(this, arguments);
+
+        this.timeout(function () {
+            if (this.branch && this.branch !== "default" &&
+                    modules.yes_i_know_i_should_not_report_errors_in_these_branches_thanks.indexOf(this.branch) === -1)
+                dactyl.warn(_("warn.notDefaultBranch", config.appName, this.branch));
+        }, 1000);
+    }
+});
+
+endModule();
+
+} catch(e){ if (typeof e === "string") e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 sts=4 et ft=javascript:
diff --git a/common/modules/contexts.jsm b/common/modules/contexts.jsm
new file mode 100644 (file)
index 0000000..15549e3
--- /dev/null
@@ -0,0 +1,646 @@
+// Copyright (c) 2010-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("contexts", {
+    exports: ["Contexts", "Group", "contexts"],
+    use: ["commands", "messages", "options", "services", "storage", "styles", "template", "util"]
+}, this);
+
+var Const = function Const(val) Class.Property({ enumerable: true, value: val });
+
+var Group = Class("Group", {
+    init: function init(name, description, filter, persist) {
+        const self = this;
+
+        this.name = name;
+        this.description = description;
+        this.filter = filter || this.constructor.defaultFilter;
+        this.persist = persist || false;
+        this.hives = [];
+    },
+
+    modifiable: true,
+
+    cleanup: function cleanup() {
+        for (let hive in values(this.hives))
+            util.trapErrors("cleanup", hive);
+
+        this.hives = [];
+        for (let hive in keys(this.hiveMap))
+            delete this[hive];
+    },
+    destroy: function destroy() {
+        for (let hive in values(this.hives))
+            util.trapErrors("destroy", hive);
+    },
+
+    argsExtra: function argsExtra() ({}),
+
+    get toStringParams() [this.name],
+
+    get builtin() this.modules.contexts.builtinGroups.indexOf(this) >= 0,
+
+}, {
+    compileFilter: function (patterns, default_) {
+        if (arguments.length < 2)
+            default_ = false;
+
+        function siteFilter(uri)
+            let (match = array.nth(siteFilter.filters, function (f) f(uri), 0))
+                match ? match.result : default_;
+
+        return update(siteFilter, {
+            toString: function () this.filters.join(","),
+
+            toXML: function (modules) let (uri = modules && modules.buffer.uri)
+                template.map(this.filters,
+                             function (f) <span highlight={uri && f(uri) ? "Filter" : ""}>{f}</span>,
+                             <>,</>),
+
+            filters: Option.parse.sitelist(patterns)
+        });
+    },
+
+    defaultFilter: Class.memoize(function () this.compileFilter(["*"]))
+});
+
+var Contexts = Module("contexts", {
+    Local: function Local(dactyl, modules, window) ({
+        init: function () {
+            const contexts = this;
+            this.modules = modules;
+
+            Object.defineProperty(modules.plugins, "contexts", Const({}));
+
+            this.groupList = [];
+            this.groupMap = {};
+            this.groupsProto = {};
+            this.hives = {};
+            this.hiveProto = {};
+
+            this.builtin = this.addGroup("builtin", "Builtin items");
+            this.user = this.addGroup("user", "User-defined items", null, true);
+            this.builtinGroups = [this.builtin, this.user];
+            this.builtin.modifiable = false;
+
+            this.GroupFlag = Class("GroupFlag", CommandOption, {
+                init: function (name) {
+                    this.name = name;
+
+                    this.type = ArgType("group", function (group) {
+                        return isString(group) ? contexts.getGroup(group, name)
+                                               : group[name];
+                    });
+                },
+
+                get toStringParams() [this.name],
+
+                names: ["-group", "-g"],
+
+                description: "Group to which to add",
+
+                get default() (contexts.context && contexts.context.group || contexts.user)[this.name],
+
+                completer: function (context) modules.completion.group(context)
+            });
+        },
+
+        cleanup: function () {
+            for (let hive in values(this.groupList))
+                util.trapErrors("cleanup", hive);
+        },
+
+        destroy: function () {
+            for (let hive in values(this.groupList))
+                util.trapErrors("destroy", hive);
+
+            for (let [name, plugin] in iter(this.modules.plugins.contexts))
+                if (plugin && "onUnload" in plugin)
+                    util.trapErrors("onUnload", plugin);
+        },
+
+        signals: {
+            "browser.locationChange": function (webProgress, request, uri) {
+                this.flush();
+            }
+        },
+
+        Group: Class("Group", Group, { modules: modules, get hiveMap() modules.contexts.hives }),
+
+        Hives: Class("Hives", Class.Property, {
+            init: function init(name, constructor) {
+                const { contexts } = modules;
+                const self = this;
+
+                if (this.Hive)
+                    return {
+                        enumerable: true,
+
+                        get: function () array(contexts.groups[self.name])
+                    };
+
+                this.Hive = constructor;
+                this.name = name;
+                memoize(contexts.Group.prototype, name, function () {
+                    let group = constructor(this);
+                    this.hives.push(group);
+                    contexts.flush();
+                    return group;
+                });
+
+                memoize(contexts.hives, name,
+                        function () Object.create(Object.create(contexts.hiveProto,
+                                                                { _hive: { value: name } })));
+
+                memoize(contexts.groupsProto, name,
+                        function () [group[name] for (group in values(this.groups)) if (set.has(group, name))]);
+            },
+
+            get toStringParams() [this.name, this.Hive]
+        })
+    }),
+
+    Context: function Context(file, group, args) {
+        const { contexts, io, newContext, plugins, userContext } = this.modules;
+
+        let isPlugin = array.nth(io.getRuntimeDirectories("plugins"),
+                                 function (dir) dir.contains(file, true),
+                                 0);
+        let isRuntime = array.nth(io.getRuntimeDirectories(""),
+                                  function (dir) dir.contains(file, true),
+                                  0);
+
+        let contextPath = file.path;
+        let self = set.has(plugins, contextPath) && plugins.contexts[contextPath];
+
+        if (self) {
+            if (set.has(self, "onUnload"))
+                self.onUnload();
+        }
+        else {
+            let name = isPlugin ? file.getRelativeDescriptor(isPlugin).replace(File.PATH_SEP, "-")
+                                : file.leafName;
+
+            self = update(newContext.apply(null, args || [userContext]), {
+                NAME: Const(name.replace(/\.[^.]*$/, "").replace(/-([a-z])/g, function (m, n1) n1.toUpperCase())),
+
+                PATH: Const(file.path),
+
+                CONTEXT: Const(self),
+
+                unload: Const(function unload() {
+                    if (plugins[this.NAME] === this || plugins[this.PATH] === this)
+                        if (this.onUnload)
+                            this.onUnload();
+
+                    if (plugins[this.NAME] === this)
+                        delete plugins[this.NAME];
+
+                    if (plugins[this.PATH] === this)
+                        delete plugins[this.PATH];
+
+                    if (plugins.contexts[contextPath] === this)
+                        delete plugins.contexts[contextPath];
+
+                    if (!this.GROUP.builtin)
+                        contexts.removeGroup(this.GROUP);
+                })
+            });
+            Class.replaceProperty(plugins, file.path, self);
+
+            // This belongs elsewhere
+            if (isPlugin && args)
+                Object.defineProperty(plugins, self.NAME, {
+                    configurable: true,
+                    enumerable: true,
+                    get: function () self,
+                    set: function (val) {
+                        util.dactyl(val).reportError(FailedAssertion(_("plugin.notReplacingContext", self.NAME), 3, false), true);
+                    }
+                });
+        }
+
+        let path = isRuntime ? file.getRelativeDescriptor(isRuntime) : file.path;
+        let name = isRuntime ? path.replace(/^(plugin|color)s([\\\/])/, "$1$2") : "script-" + path;
+
+        if (!group)
+            group = this.addGroup(commands.nameRegexp
+                                          .iterate(name.replace(/\.[^.]*$/, ""))
+                                          .join("-").replace(/--+/g, "-"),
+                                  "Script group for " + file.path,
+                                  null, false);
+
+        Class.replaceProperty(self, "GROUP", group);
+        Class.replaceProperty(self, "group", group);
+
+        return plugins.contexts[contextPath] = self;
+    },
+
+    Script: function Script(file, group) {
+        return this.Context(file, group, [this.modules.userContext, true]);
+    },
+
+    context: null,
+
+    /**
+     * Returns a frame object describing the currently executing
+     * command, if applicable, otherwise returns the passed frame.
+     *
+     * @param {nsIStackFrame} frame
+     */
+    getCaller: function getCaller(frame) {
+        if (this.context && this.context.file)
+           return {
+                __proto__: frame,
+                filename: this.context.file[0] == "[" ? this.context.file
+                                                      : services.io.newFileURI(File(this.context.file)).spec,
+                lineNumber: this.context.line
+            };
+        return frame;
+    },
+
+    groups: Class.memoize(function () this.matchingGroups(this.modules.buffer.uri)),
+
+    allGroups: Class.memoize(function () Object.create(this.groupsProto, {
+        groups: { value: this.initializedGroups() }
+    })),
+
+    matchingGroups: function (uri) Object.create(this.groupsProto, {
+        groups: { value: this.activeGroups(uri) },
+    }),
+
+    activeGroups: function (uri, doc) {
+        if (!uri)
+            ({ uri, doc }) = this.modules.buffer;
+        return this.initializedGroups().filter(function (g) uri && g.filter(uri, doc));
+    },
+
+    flush: function flush() {
+        delete this.groups;
+        delete this.allGroups;
+    },
+
+    initializedGroups: function (hive)
+        let (need = hive ? [hive] : Object.keys(this.hives))
+            this.groupList.filter(function (group) need.some(set.has(group))),
+
+    addGroup: function addGroup(name, description, filter, persist, replace) {
+        let group = this.getGroup(name);
+        if (group)
+            name = group.name;
+
+        if (!group) {
+            group = this.Group(name, description, filter, persist);
+            this.groupList.unshift(group);
+            this.groupMap[name] = group;
+            this.hiveProto.__defineGetter__(name, function () group[this._hive]);
+        }
+
+        if (replace) {
+            util.trapErrors("cleanup", group);
+            if (description)
+                group.description = description;
+            if (filter)
+                group.filter = filter
+            group.persist = persist;
+        }
+
+        this.flush();
+        return group;
+    },
+
+    removeGroup: function removeGroup(name, filter) {
+        if (isObject(name)) {
+            if (this.groupList.indexOf(name) === -1)
+                return;
+            name = name.name;
+        }
+
+        let group = this.getGroup(name);
+
+        util.assert(!group || !group.builtin, _("group.cantRemoveBuiltin"));
+
+        if (group) {
+            name = group.name;
+            this.groupList.splice(this.groupList.indexOf(group), 1);
+            util.trapErrors("destroy", group);
+        }
+
+        if (this.context && this.context.group === group)
+            this.context.group = null;
+
+        delete this.groupMap[name];
+        delete this.hiveProto[name];
+        this.flush();
+        return group;
+    },
+
+    getGroup: function getGroup(name, hive) {
+        if (name === "default")
+            var group = this.context && this.context.context && this.context.context.GROUP;
+        else if (set.has(this.groupMap, name))
+            group = this.groupMap[name];
+
+        if (group && hive)
+            return group[hive];
+        return group;
+    },
+
+    bindMacro: function (args, default_, params) {
+        const { dactyl, events, modules } = this.modules;
+
+        let process = util.identity;
+
+        if (callable(params))
+            var makeParams = function makeParams(self, args)
+                iter.toObject([k, process(v)]
+                               for ([k, v] in iter(params.apply(self, args))));
+        else if (params)
+            makeParams = function makeParams(self, args)
+                iter.toObject([name, process(args[i])]
+                              for ([i, name] in Iterator(params)));
+
+        let rhs = args.literalArg;
+        let type = ["-builtin", "-ex", "-javascript", "-keys"].reduce(function (a, b) args[b] ? b : a, default_);
+        switch (type) {
+        case "-builtin":
+            let noremap = true;
+            /* fallthrough */
+        case "-keys":
+            let silent = args["-silent"];
+            rhs = events.canonicalKeys(rhs, true);
+            var action = function action() {
+                events.feedkeys(action.macro(makeParams(this, arguments)),
+                                noremap, silent);
+            }
+            action.macro = util.compileMacro(rhs, true);
+            break;
+        case "-ex":
+            action = function action() modules.commands
+                                              .execute(action.macro, makeParams(this, arguments),
+                                                       false, null, action.context);
+            action.macro = util.compileMacro(rhs, true);
+            action.context = this.context && update({}, this.context);
+            break;
+        case "-javascript":
+            if (callable(params))
+                action = dactyl.userEval("(function action() { with (action.makeParams(this, arguments)) {" + args.literalArg + "} })");
+            else
+                action = dactyl.userFunc.apply(dactyl, params.concat(args.literalArg).array);
+            process = function (param) isObject(param) && param.valueOf ? param.valueOf() : param;
+            action.params = params;
+            action.makeParams = makeParams;
+            break;
+        }
+        action.toString = function toString() (type === default_ ? "" : type + " ") + rhs;
+        args = null;
+        return action;
+    },
+
+    withContext: function withContext(defaults, callback, self)
+        this.withSavedValues(["context"], function () {
+            this.context = defaults && update({}, defaults);
+            return callback.call(self, this.context);
+        })
+}, {
+    Hive: Class("Hive", {
+        init: function init(group) {
+            this.group = group;
+        },
+
+        cleanup: function cleanup() {},
+        destroy: function destroy() {},
+
+        get modifiable() this.group.modifiable,
+
+        get argsExtra() this.group.argsExtra,
+        get builtin() this.group.builtin,
+
+        get name() this.group.name,
+        set name(val) this.group.name = val,
+
+        get description() this.group.description,
+        set description(val) this.group.description = val,
+
+        get filter() this.group.filter,
+        set filter(val) this.group.filter = val,
+
+        get persist() this.group.persist,
+        set persist(val) this.group.persist = val,
+
+        prefix: Class.memoize(function () this.name === "builtin" ? "" : this.name + ":"),
+
+        get toStringParams() [this.name]
+    })
+}, {
+    commands: function initCommands(dactyl, modules, window) {
+        const { commands, contexts } = modules;
+
+        commands.add(["gr[oup]"],
+            "Create or select a group",
+            function (args) {
+                if (args.length > 0) {
+                    var name = Option.dequote(args[0]);
+                    util.assert(name !== "builtin", _("group.cantModifyBuiltin"));
+                    util.assert(commands.validName.test(name), _("group.invalidName", name));
+
+                    var group = contexts.getGroup(name);
+                }
+                else if (args.bang)
+                    var group = args.context && args.context.group;
+                else
+                    return void modules.completion.listCompleter("group", "", null, null);
+
+                util.assert(group || name, _("group.noCurrent"));
+
+                let filter = Group.compileFilter(args["-locations"]);
+                if (!group || args.bang)
+                    group = contexts.addGroup(name, args["-description"], filter, !args["-nopersist"], args.bang);
+                else if (!group.builtin) {
+                    if (args.has("-locations"))
+                        group.filter = filter;
+                    if (args.has("-description"))
+                        group.description = args["-description"]
+                    if (args.has("-nopersist"))
+                        group.persist = !args["-nopersist"]
+                }
+
+                if (!group.builtin && args.has("-args")) {
+                    group.argsExtra = contexts.bindMacro({ literalArg: "return " + args["-args"] },
+                                                         "-javascript", util.identity);
+                    group.args = args["-args"];
+                }
+
+                if (args.context)
+                    args.context.group = group;
+
+                util.assert(!group.builtin ||
+                                !["-description", "-locations", "-nopersist"]
+                                    .some(set.has(args.explicitOpts)),
+                            _("group.cantModifyBuiltin"));
+            },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function (context, args) {
+                    if (args.length == 1)
+                        modules.completion.group(context);
+                },
+                keepQuotes: true,
+                options: [
+                    {
+                        names: ["-args", "-a"],
+                        description: "JavaScript Object which augments the arguments passed to commands, mappings, and autocommands",
+                        type: CommandOption.STRING
+                    },
+                    {
+                        names: ["-description", "-desc", "-d"],
+                        description: "A description of this group",
+                        default: ["User-defined group"],
+                        type: CommandOption.STRING
+                    },
+                    {
+                        names: ["-locations", "-locs", "-loc", "-l"],
+                        description: ["The URLs for which this group should be active"],
+                        default: ["*"],
+                        type: CommandOption.LIST
+                    },
+                    {
+                        names: ["-nopersist", "-n"],
+                        description: "Do not save this group to an auto-generated RC file"
+                    }
+                ],
+                serialGroup: 20,
+                serialize: function () [
+                    {
+                        command: this.name,
+                        bang: true,
+                        options: iter([v, typeof group[k] == "boolean" ? null : group[k]]
+                                      // FIXME: this map is expressed multiple times
+                                      for ([k, v] in Iterator({
+                                          args: "-args",
+                                          description: "-description",
+                                          filter: "-locations"
+                                      }))
+                                      if (group[k])).toObject(),
+                        arguments: [group.name],
+                        ignoreDefaults: true
+                    }
+                    for (group in values(contexts.initializedGroups()))
+                    if (!group.builtin && group.persist)
+                ].concat([{ command: this.name, arguments: ["user"] }])
+            });
+
+        commands.add(["delg[roup]"],
+            "Delete a group",
+            function (args) {
+                util.assert(contexts.getGroup(args[0]), _("group.noSuch", args[0]));
+                contexts.removeGroup(args[0]);
+            },
+            {
+                argCount: "1",
+                completer: function (context, args) {
+                    modules.completion.group(context);
+                    context.filters.push(function ({ item }) !item.builtin);
+                }
+            });
+
+        commands.add(["fini[sh]"],
+            "Stop sourcing a script file",
+            function (args) {
+                util.assert(args.context, _("command.finish.illegal"));
+                args.context.finished = true;
+            },
+            { argCount: "0" });
+
+        function checkStack(cmd) {
+            util.assert(contexts.context && contexts.context.stack &&
+                        contexts.context.stack[cmd] && contexts.context.stack[cmd].length,
+                        _("command.conditional.illegal"));
+        }
+        function pop(cmd) {
+            checkStack(cmd);
+            return contexts.context.stack[cmd].pop();
+        }
+        function push(cmd, value) {
+            util.assert(contexts.context, _("command.conditional.illegal"));
+            if (arguments.length < 2)
+                value = contexts.context.noExecute;
+            contexts.context.stack = contexts.context.stack || {};
+            contexts.context.stack[cmd] = (contexts.context.stack[cmd] || []).concat([value]);
+        }
+
+        commands.add(["if"],
+            "Execute commands until the next :elseif, :else, or :endif only if the argument returns true",
+            function (args) { args.context.noExecute = !dactyl.userEval(args[0]); },
+            {
+                always: function (args) { push("if"); },
+                argCount: "1",
+                literal: 0
+            });
+        commands.add(["elsei[f]", "elif"],
+            "Execute commands until the next :elseif, :else, or :endif only if the argument returns true",
+            function (args) {},
+            {
+                always: function (args) {
+                    checkStack("if");
+                    args.context.noExecute = args.context.stack.if.slice(-1)[0] ||
+                        !args.context.noExecute || !dactyl.userEval(args[0]);
+                },
+                argCount: "1",
+                literal: 0
+            });
+        commands.add(["el[se]"],
+            "Execute commands until the next :endif only if the previous conditionals were not executed",
+            function (args) {},
+            {
+                always: function (args) {
+                    checkStack("if");
+                    args.context.noExecute = args.context.stack.if.slice(-1)[0] ||
+                        !args.context.noExecute;
+                },
+                argCount: "0"
+            });
+        commands.add(["en[dif]", "fi"],
+            "End a string of :if/:elseif/:else conditionals",
+            function (args) {},
+            {
+                always: function (args) { args.context.noExecute = pop("if"); },
+                argCount: "0"
+            });
+    },
+    completion: function initCompletion(dactyl, modules, window) {
+        const { completion, contexts } = modules;
+
+        completion.group = function group(context, active) {
+            context.title = ["Group"];
+            let uri = modules.buffer.uri;
+            context.keys = {
+                active: function (group) group.filter(uri),
+                text: "name",
+                description: function (g) <>{g.filter.toXML ? g.filter.toXML(modules) + <>&#xa0;</> : ""}{g.description || ""}</>
+            };
+            context.completions = (active === undefined ? contexts.groupList : contexts.initializedGroups(active))
+                                    .slice(0, -1);
+
+            iter({ Active: true, Inactive: false }).forEach(function ([name, active]) {
+                context.split(name, null, function (context) {
+                    context.title[0] = name + " Groups";
+                    context.filters.push(function ({ item }) !!item.filter(modules.buffer.uri) == active);
+                });
+            });
+        };
+    }
+});
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/downloads.jsm b/common/modules/downloads.jsm
new file mode 100644 (file)
index 0000000..403c1e4
--- /dev/null
@@ -0,0 +1,350 @@
+// Copyright (c) 2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("downloads", {
+    exports: ["Download", "Downloads", "downloads"],
+    use: ["io", "prefs", "services", "util"]
+}, this);
+
+Cu.import("resource://gre/modules/DownloadUtils.jsm", this);
+
+let prefix = "DOWNLOAD_";
+var states = iter([v, k.slice(prefix.length).toLowerCase()]
+                  for ([k, v] in Iterator(Ci.nsIDownloadManager))
+                  if (k.indexOf(prefix) == 0))
+                .toObject();
+
+var Download = Class("Download", {
+    init: function init(id, list) {
+        let self = XPCSafeJSObjectWrapper(services.downloadManager.getDownload(id));
+        self.__proto__ = this;
+        this.instance = this;
+        this.list = list;
+
+        this.nodes = {
+            commandTarget: self
+        };
+        util.xmlToDom(
+            <tr highlight="Download" key="row" xmlns:dactyl={NS} xmlns={XHTML}>
+                <td highlight="DownloadTitle">
+                    <span highlight="Link">
+                        <a key="launch" dactyl:command="download.command"
+                           href={self.target.spec} path={self.targetFile.path}>{self.displayName}</a>
+                        <span highlight="LinkInfo">{self.targetFile.path}</span>
+                    </span>
+                </td>
+                <td highlight="DownloadState" key="state"/>
+                <td highlight="DownloadButtons Buttons">
+                    <a highlight="Button" key="pause">Pause</a>
+                    <a highlight="Button" key="remove">Remove</a>
+                    <a highlight="Button" key="resume">Resume</a>
+                    <a highlight="Button" key="retry">Retry</a>
+                    <a highlight="Button" key="cancel">Cancel</a>
+                    <a highlight="Button" key="delete">Delete</a>
+                </td>
+                <td highlight="DownloadProgress" key="progress">
+                    <span highlight="DownloadProgressHave" key="progressHave"
+                    />/<span highlight="DownloadProgressTotal" key="progressTotal"/>
+                </td>
+                <td highlight="DownloadPercent" key="percent"/>
+                <td highlight="DownloadTime" key="time"/>
+                <td><a highlight="DownloadSource" key="source" href={self.source.spec}>{self.source.spec}</a></td>
+            </tr>,
+            this.list.document, this.nodes);
+
+        self.updateStatus();
+        return self;
+    },
+
+    get status() states[this.state],
+
+    inState: function inState(states) states.indexOf(this.status) >= 0,
+
+    get alive() this.inState(["downloading", "notstarted", "paused", "queued", "scanning"]),
+
+    allowedCommands: Class.memoize(function () let (self = this) ({
+        get cancel() self.cancelable && self.inState(["downloading", "paused", "starting"]),
+        get delete() !this.cancel && self.targetFile.exists(),
+        get launch() self.targetFile.exists() && self.inState(["finished"]),
+        get pause() self.inState(["downloading"]),
+        get remove() self.inState(["blocked_parental", "blocked_policy",
+                                   "canceled", "dirty", "failed", "finished"]),
+        get resume() self.resumable && self.inState(["paused"]),
+        get retry() self.inState(["canceled", "failed"])
+    })),
+
+    command: function command(name) {
+        util.assert(set.has(this.allowedCommands, name), "Unknown command");
+        util.assert(this.allowedCommands[name], "Command not allowed");
+
+        services.downloadManager[name + "Download"](this.id);
+    },
+
+    commands: {
+        delete: function delete_() {
+            this.targetFile.remove(false);
+            this.updateStatus();
+        },
+        launch: function launch() {
+            let self = this;
+            // Behavior mimics that of the builtin Download Manager.
+            function action() {
+                try {
+                    if (this.MIMEInfo && this.MIMEInfo.preferredAction == this.MIMEInfo.useHelperApp)
+                        this.MIMEInfo.launchWithFile(file)
+                    else
+                        file.launch();
+                }
+                catch (e) {
+                    services.externalProtocol.loadUrl(this.target);
+                }
+            }
+
+            let file = io.File(this.targetFile);
+            if (file.isExecutable() && prefs.get("browser.download.manager.alertOnEXEOpen", true))
+                this.list.modules.commandline.input("This will launch an executable download. Continue? (yes/[no]/always) ",
+                    function (resp) {
+                        if (/^a(lways)$/i.test(resp)) {
+                            prefs.set("browser.download.manager.alertOnEXEOpen", false);
+                            resp = "yes";
+                        }
+                        if (/^y(es)?$/i.test(resp))
+                            action.call(self);
+                    });
+            else
+                action.call(this);
+        }
+    },
+
+    compare: function compare(other) String.localeCompare(this.displayName, other.displayName),
+
+    timeRemaining: Infinity,
+
+    updateProgress: function updateProgress() {
+        let self = this.__proto__;
+
+        if (this.amountTransferred === this.size)
+            this.nodes.time.textContent = "";
+        else if (this.speed == 0 || this.size == 0)
+            this.nodes.time.textContent = "Unknown";
+        else {
+            let seconds = (this.size - this.amountTransferred) / this.speed;
+            [, self.timeRemaining] = DownloadUtils.getTimeLeft(seconds, this.timeRemaining);
+            if (this.timeRemaining)
+                this.nodes.time.textContent = util.formatSeconds(this.timeRemaining);
+            else
+                this.nodes.time.textContent = "~1 second";
+        }
+        let total = this.nodes.progressTotal.textContent = this.size ? util.formatBytes(this.size, 1, true) : "Unknown";
+        let suffix = RegExp(/( [a-z]+)?$/i.exec(total)[0] + "$");
+        this.nodes.progressHave.textContent = util.formatBytes(this.amountTransferred, 1, true).replace(suffix, "");
+
+        this.nodes.percent.textContent = this.size ? Math.round(this.amountTransferred * 100 / this.size) + "%" : "";
+    },
+
+    updateStatus: function updateStatus() {
+
+        this.nodes.row[this.alive ? "setAttribute" : "removeAttribute"]("active", "true");
+
+        this.nodes.row.setAttribute("status", this.status);
+        this.nodes.state.textContent = util.capitalize(this.status);
+
+        for (let node in values(this.nodes))
+            if (node.update)
+                node.update();
+
+        this.updateProgress();
+    }
+});
+
+var DownloadList = Class("DownloadList",
+                         XPCOM([Ci.nsIDownloadProgressListener,
+                                Ci.nsIObserver,
+                                Ci.nsISupportsWeakReference]), {
+    init: function init(modules, filter) {
+        this.modules = modules;
+        this.filter = filter && filter.toLowerCase();
+        this.nodes = {
+            commandTarget: this
+        };
+        this.downloads = {};
+    },
+    cleanup: function cleanup() {
+        this.observe.unregister();
+        services.downloadManager.removeListener(this);
+    },
+
+    message: Class.memoize(function () {
+
+        util.xmlToDom(<table highlight="Downloads" key="list" xmlns={XHTML}>
+                        <tr highlight="DownloadHead">
+                            <span>Title</span>
+                            <span>Status</span>
+                            <span/>
+                            <span>Progress</span>
+                            <span/>
+                            <span>Time remaining</span>
+                            <span>Source</span>
+                        </tr>
+                        <tr highlight="Download"><span><div style="min-height: 1ex; /* FIXME */"/></span></tr>
+                        <tr highlight="Download" key="totals" active="true">
+                            <td><span highlight="Title">Totals:</span>&#xa0;<span key="total"/></td>
+                            <td/>
+                            <td highlight="DownloadButtons">
+                                <a highlight="Button" key="clear">Clear</a>
+                            </td>
+                            <td highlight="DownloadProgress" key="progress">
+                                <span highlight="DownloadProgressHave" key="progressHave"
+                                />/<span highlight="DownloadProgressTotal" key="progressTotal"/>
+                            </td>
+                            <td highlight="DownloadPercent" key="percent"/>
+                            <td highlight="DownloadTime" key="time"/>
+                            <td/>
+                        </tr>
+                      </table>, this.document, this.nodes);
+
+        for (let row in iter(services.downloadManager.DBConnection
+                                     .createStatement("SELECT id FROM moz_downloads")))
+            this.addDownload(row.id);
+        this.update();
+
+        util.addObserver(this);
+        services.downloadManager.addListener(this);
+        return this.nodes.list;
+    }),
+
+    addDownload: function addDownload(id) {
+        if (!(id in this.downloads)) {
+            let download = Download(id, this);
+            if (this.filter && download.displayName.indexOf(this.filter) === -1)
+                return;
+
+            this.downloads[id] = download;
+            let index = values(this.downloads).sort(function (a, b) a.compare(b))
+                                              .indexOf(download);
+
+            this.nodes.list.insertBefore(download.nodes.row,
+                                         this.nodes.list.childNodes[index + 1]);
+        }
+    },
+    removeDownload: function removeDownload(id) {
+        if (id in this.downloads) {
+            this.nodes.list.removeChild(this.downloads[id].nodes.row);
+            delete this.downloads[id];
+        }
+    },
+
+    leave: function leave(stack) {
+        if (stack.pop)
+            this.cleanup();
+    },
+
+    allowedCommands: Class.memoize(function () let (self = this) ({
+        get clear() values(self.downloads).some(function (dl) dl.allowedCommands.remove)
+    })),
+
+    commands: {
+        clear: function () {
+            services.downloadManager.cleanUp();
+        }
+    },
+
+    update: function update() {
+        for (let node in values(this.nodes))
+            if (node.update && node.update != update)
+                node.update();
+        this.updateProgress();
+
+        let event = this.document.createEvent("Events");
+        event.initEvent("dactyl-commandupdate", true, false);
+        this.document.dispatchEvent(event);
+    },
+
+    timeRemaining: Infinity,
+
+    updateProgress: function updateProgress() {
+        let downloads = values(this.downloads).toArray();
+
+        let self = Object.create(this);
+        for (let prop in values(["amountTransferred", "size", "speed", "timeRemaining"]))
+            this[prop] = downloads.reduce(function (acc, dl) dl[prop] + acc, 0);
+
+        Download.prototype.updateProgress.call(self);
+
+        let active = downloads.filter(function (dl) dl.alive).length;
+        if (active)
+            this.nodes.total.textContent = active + " active";
+        else for (let key in values(["total", "percent", "time"]))
+            this.nodes[key].textContent = "";
+    },
+
+    observers: {
+        "download-manager-remove-download": function (id) {
+            if (id == null)
+                id = [k for ([k, dl] in iter(this.downloads)) if (dl.allowedCommands.remove)];
+            else
+                id = [id.QueryInterface(Ci.nsISupportsPRUint32).data];
+
+            Array.concat(id).map(this.closure.removeDownload);
+            this.update();
+        }
+    },
+
+    onDownloadStateChange: function (state, download) {
+        try {
+            if (download.id in this.downloads)
+                this.downloads[download.id].updateStatus();
+            else {
+                this.addDownload(download.id);
+
+                this.modules.mow.resize(false);
+                this.nodes.list.scrollIntoView(false);
+            }
+            this.update();
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+    },
+    onProgressChange: function (webProgress, request,
+                                curProgress, maxProgress,
+                                curTotalProgress, maxTotalProgress,
+                                download) {
+        try {
+            if (download.id in this.downloads)
+                this.downloads[download.id].updateProgress();
+            this.updateProgress();
+        }
+        catch (e) {
+            util.reportError(e);
+        }
+    }
+});
+
+var Downloads = Module("downloads", {
+}, {
+}, {
+    commands: function (dactyl, modules, window) {
+        const { commands } = modules;
+
+        commands.add(["downl[oads]", "dl"],
+            "Display the downloads list",
+            function (args) {
+                let downloads = DownloadList(modules, args[0]);
+                modules.commandline.echo(downloads);
+            },
+            {
+                argCount: "?"
+            });
+    }
+});
+
+endModule();
+
+// catch(e){ if (isString(e)) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/finder.jsm b/common/modules/finder.jsm
new file mode 100644 (file)
index 0000000..5edd590
--- /dev/null
@@ -0,0 +1,741 @@
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("finder", {
+    exports: ["RangeFind", "RangeFinder", "rangefinder"],
+    use: ["messages", "services", "util"]
+}, this);
+
+function equals(a, b) XPCNativeWrapper(a) == XPCNativeWrapper(b);
+
+/** @instance rangefinder */
+var RangeFinder = Module("rangefinder", {
+    Local: function (dactyl, modules, window) ({
+        init: function () {
+            this.dactyl = dactyl;
+            this.modules = modules;
+            this.window = window;
+            this.lastFindPattern = "";
+        },
+
+        get rangeFind() {
+            let find = modules.buffer.localStore.rangeFind;
+            if (find && find.stale || !isinstance(find, RangeFind))
+                return this.rangeFind = null;
+            return find;
+        },
+        set rangeFind(val) modules.buffer.localStore.rangeFind = val
+    }),
+
+    get commandline() this.modules.commandline,
+    get modes() this.modules.modes,
+    get options() this.modules.options,
+
+    openPrompt: function (mode) {
+        this.commandline;
+        this.CommandMode(mode).open();
+
+        if (this.rangeFind && equals(this.rangeFind.window.get(), this.window))
+            this.rangeFind.reset();
+        this.find("", mode === this.modes.FIND_BACKWARD);
+    },
+
+    bootstrap: function (str, backward) {
+        let highlighted = this.rangeFind && this.rangeFind.highlighted;
+        let selections = this.rangeFind && this.rangeFind.selections;
+        let linksOnly = false;
+        let regexp = false;
+        let matchCase = this.options["findcase"] === "smart"  ? /[A-Z]/.test(str) :
+                        this.options["findcase"] === "ignore" ? false : true;
+
+        str = str.replace(/\\(.|$)/g, function (m, n1) {
+            if (n1 == "c")
+                matchCase = false;
+            else if (n1 == "C")
+                matchCase = true;
+            else if (n1 == "l")
+                linksOnly = true;
+            else if (n1 == "L")
+                linksOnly = false;
+            else if (n1 == "r")
+                regexp = true;
+            else if (n1 == "R")
+                regexp = false;
+            else
+                return m;
+            return "";
+        });
+
+        // It's possible, with :tabdetach for instance, for the rangeFind to
+        // actually move from one window to another, which breaks things.
+        if (!this.rangeFind
+            || !equals(this.rangeFind.window.get(), this.window)
+            || linksOnly  != !!this.rangeFind.elementPath
+            || regexp     != this.rangeFind.regexp
+            || matchCase  != this.rangeFind.matchCase
+            || !!backward != this.rangeFind.reverse) {
+
+            if (this.rangeFind)
+                this.rangeFind.cancel();
+            this.rangeFind = RangeFind(this.window, matchCase, backward,
+                                       linksOnly && this.options.get("hinttags").matcher,
+                                       regexp);
+            this.rangeFind.highlighted = highlighted;
+            this.rangeFind.selections = selections;
+        }
+        return this.lastFindPattern = str;
+    },
+
+    find: function (pattern, backwards) {
+        let str = this.bootstrap(pattern, backwards);
+        if (!this.rangeFind.find(str))
+            this.dactyl.echoerr(_("finder.notFound", pattern),
+                                this.commandline.FORCE_SINGLELINE);
+
+        return this.rangeFind.found;
+    },
+
+    findAgain: function (reverse) {
+        if (!this.rangeFind)
+            this.find(this.lastFindPattern);
+        else if (!this.rangeFind.find(null, reverse))
+            this.dactyl.echoerr(_("finder.notFound", this.lastFindPattern),
+                                this.commandline.FORCE_SINGLELINE);
+        else if (this.rangeFind.wrapped) {
+            let msg = this.rangeFind.backward ? _("finder.atTop")
+                                              : _("finder.atBottom");
+            this.commandline.echo(msg, "WarningMsg", this.commandline.APPEND_TO_MESSAGES
+                                                   | this.commandline.FORCE_SINGLELINE);
+        }
+        else
+            this.commandline.echo((this.rangeFind.backward ? "?" : "/") + this.lastFindPattern,
+                                  "Normal", this.commandline.FORCE_SINGLELINE);
+
+        if (this.options["hlfind"])
+            this.highlight();
+        this.rangeFind.focus();
+    },
+
+    onCancel: function () {
+        if (this.rangeFind)
+            this.rangeFind.cancel();
+    },
+
+    onChange: function (command) {
+        if (this.options["incfind"]) {
+            command = this.bootstrap(command);
+            this.rangeFind.find(command);
+        }
+    },
+
+    onSubmit: function (command) {
+        if (!this.options["incfind"] || !this.rangeFind || !this.rangeFind.found) {
+            this.clear();
+            this.find(command || this.lastFindPattern, this.modes.extended & this.modes.FIND_BACKWARD);
+        }
+
+        if (this.options["hlfind"])
+            this.highlight();
+        this.rangeFind.focus();
+    },
+
+    /**
+     * Highlights all occurrences of the last sought for string in the
+     * current buffer.
+     */
+    highlight: function () {
+        if (this.rangeFind)
+            this.rangeFind.highlight();
+    },
+
+    /**
+     * Clears all find highlighting.
+     */
+    clear: function () {
+        if (this.rangeFind)
+            this.rangeFind.highlight(true);
+    }
+}, {
+}, {
+    modes: function initModes(dactyl, modules, window) {
+        initModes.require("commandline");
+
+        const { modes } = modules;
+
+        modes.addMode("FIND", {
+            description: "Find mode, active when typing search input",
+            bases: [modes.COMMAND_LINE],
+        });
+        modes.addMode("FIND_FORWARD", {
+            description: "Forward Find mode, active when typing search input",
+            bases: [modes.FIND]
+        });
+        modes.addMode("FIND_BACKWARD", {
+            description: "Backward Find mode, active when typing search input",
+            bases: [modes.FIND]
+        });
+    },
+    commands: function initCommands(dactyl, modules, window) {
+        const { commands, rangefinder } = modules;
+        commands.add(["noh[lfind]"],
+            "Remove the find highlighting",
+            function () { rangefinder.clear(); },
+            { argCount: "0" });
+    },
+    commandline: function initCommandline(dactyl, modules, window) {
+        const { rangefinder } = modules;
+        rangefinder.CommandMode = Class("CommandFindMode", modules.CommandMode, {
+            init: function init(mode) {
+                this.mode = mode;
+                init.supercall(this);
+            },
+
+            historyKey: "find",
+
+            get prompt() this.mode === modules.modes.FIND_BACKWARD ? "?" : "/",
+
+            get onCancel() modules.rangefinder.closure.onCancel,
+            get onChange() modules.rangefinder.closure.onChange,
+            get onSubmit() modules.rangefinder.closure.onSubmit
+        });
+    },
+    mappings: function (dactyl, modules, window) {
+        const { buffer, config, mappings, modes, rangefinder } = modules;
+        var myModes = config.browserModes.concat([modes.CARET]);
+
+        mappings.add(myModes,
+            ["/"], "Find a pattern starting at the current caret position",
+            function () { rangefinder.openPrompt(modes.FIND_FORWARD); });
+
+        mappings.add(myModes,
+            ["?"], "Find a pattern backward of the current caret position",
+            function () { rangefinder.openPrompt(modes.FIND_BACKWARD); });
+
+        mappings.add(myModes,
+            ["n"], "Find next",
+            function () { rangefinder.findAgain(false); });
+
+        mappings.add(myModes,
+            ["N"], "Find previous",
+            function () { rangefinder.findAgain(true); });
+
+        mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["*"],
+            "Find word under cursor",
+            function () {
+                rangefinder.find(buffer.getCurrentWord(), false);
+                rangefinder.findAgain();
+            });
+
+        mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["#"],
+            "Find word under cursor backwards",
+            function () {
+                rangefinder.find(buffer.getCurrentWord(), true);
+                rangefinder.findAgain();
+            });
+
+    },
+    options: function (dactyl, modules, window) {
+        const { options, rangefinder } = modules;
+        const { prefs } = require("prefs");
+
+        // prefs.safeSet("accessibility.typeaheadfind.autostart", false);
+        // The above should be sufficient, but: https://bugzilla.mozilla.org/show_bug.cgi?id=348187
+        prefs.safeSet("accessibility.typeaheadfind", false);
+
+        options.add(["hlfind", "hlf"],
+            "Highlight all /find pattern matches on the current page after submission",
+            "boolean", false, {
+                setter: function (value) {
+                    rangefinder[value ? "highlight" : "clear"]();
+                    return value;
+                }
+            });
+
+        options.add(["findcase", "fc"],
+            "Find case matching mode",
+            "string", "smart",
+            {
+                values: {
+                    "smart": "Case is significant when capital letters are typed",
+                    "match": "Case is always significant",
+                    "ignore": "Case is never significant"
+                }
+            });
+
+        options.add(["incfind", "if"],
+            "Find a pattern incrementally as it is typed rather than awaiting <Return>",
+            "boolean", true);
+    }
+});
+
+/**
+ * @class RangeFind
+ *
+ * A fairly sophisticated typeahead-find replacement. It supports
+ * incremental find very much as the builtin component.
+ * Additionally, it supports several features impossible to
+ * implement using the standard component. Incremental finding
+ * works both forwards and backwards. Erasing characters during an
+ * incremental find moves the selection back to the first
+ * available match for the shorter term. The selection and viewport
+ * are restored when the find is canceled.
+ *
+ * Also, in addition to full support for frames and iframes, this
+ * implementation will begin finding from the position of the
+ * caret in the last active frame. This is contrary to the behavior
+ * of the builtin component, which always starts a find from the
+ * beginning of the first frame in the case of frameset documents,
+ * and cycles through all frames from beginning to end. This makes it
+ * impossible to choose the starting point of a find for such
+ * documents, and represents a major detriment to productivity where
+ * large amounts of data are concerned (e.g., for API documents).
+ */
+var RangeFind = Class("RangeFind", {
+    init: function init(window, matchCase, backward, elementPath, regexp) {
+        this.window = Cu.getWeakReference(window);
+        this.content = window.content;
+
+        this.baseDocument = Cu.getWeakReference(this.content.document);
+        this.elementPath = elementPath || null;
+        this.reverse = Boolean(backward);
+
+        this.finder = services.Find();
+        this.matchCase = Boolean(matchCase);
+        this.regexp = Boolean(regexp);
+
+        this.reset();
+
+        this.highlighted = null;
+        this.selections = [];
+        this.lastString = "";
+    },
+
+    get store() this.content.document.dactylStore = this.content.document.dactylStore || {},
+
+    get backward() this.finder.findBackwards,
+
+    get matchCase() this.finder.caseSensitive,
+    set matchCase(val) this.finder.caseSensitive = Boolean(val),
+
+    get regexp() this.finder.regularExpression || false,
+    set regexp(val) {
+        try {
+            return this.finder.regularExpression = Boolean(val);
+        }
+        catch (e) {
+            return false;
+        }
+    },
+
+    get findString() this.lastString,
+
+    get selectedRange() {
+        let win = this.store.focusedFrame && this.store.focusedFrame.get() || this.content;
+
+        let selection = win.getSelection();
+        return (selection.rangeCount ? selection.getRangeAt(0) : this.ranges[0].range).cloneRange();
+    },
+    set selectedRange(range) {
+        this.range.selection.removeAllRanges();
+        this.range.selection.addRange(range);
+        this.range.selectionController.scrollSelectionIntoView(
+            this.range.selectionController.SELECTION_NORMAL, 0, false);
+
+        this.store.focusedFrame = Cu.getWeakReference(range.startContainer.ownerDocument.defaultView);
+    },
+
+    cancel: function cancel() {
+        this.purgeListeners();
+        this.range.deselect();
+        this.range.descroll();
+    },
+
+    compareRanges: function compareRanges(r1, r2) {
+        try {
+            return this.backward ?  r1.compareBoundaryPoints(r1.END_TO_START, r2)
+                                 : -r1.compareBoundaryPoints(r1.START_TO_END, r2);
+        }
+        catch (e) {
+            util.reportError(e);
+            return 0;
+        }
+    },
+
+    findRange: function findRange(range) {
+        let doc = range.startContainer.ownerDocument;
+        let win = doc.defaultView;
+        let ranges = this.ranges.filter(function (r)
+            r.window === win && RangeFind.contains(r.range, range));
+
+        if (this.backward)
+            return ranges[ranges.length - 1];
+        return ranges[0];
+    },
+
+    findSubRanges: function findSubRanges(range) {
+        let doc = range.startContainer.ownerDocument;
+        for (let elem in this.elementPath(doc)) {
+            let r = RangeFind.nodeRange(elem);
+            if (RangeFind.contains(range, r))
+                yield r;
+        }
+    },
+
+    focus: function focus() {
+        if (this.lastRange)
+            var node = util.evaluateXPath(RangeFind.selectNodePath,
+                                          this.lastRange.commonAncestorContainer).snapshotItem(0);
+        if (node) {
+            node.focus()
+            // Re-highlight collapsed selection
+            this.selectedRange = this.lastRange;
+        }
+    },
+
+    highlight: function highlight(clear) {
+        if (!clear && (!this.lastString || this.lastString == this.highlighted))
+            return;
+        if (clear && !this.highlighted)
+            return;
+
+        if (!clear && this.highlighted)
+            this.highlight(true);
+
+        if (clear) {
+            this.selections.forEach(function (selection) {
+                selection.removeAllRanges();
+            });
+            this.selections = [];
+            this.highlighted = null;
+        }
+        else {
+            this.selections = [];
+            let string = this.lastString;
+            for (let r in this.iter(string)) {
+                let controller = this.range.selectionController;
+                for (let node = r.startContainer; node; node = node.parentNode)
+                    if (node instanceof Ci.nsIDOMNSEditableElement) {
+                        controller = node.editor.selectionController;
+                        break;
+                    }
+
+                let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
+                sel.addRange(r);
+                if (this.selections.indexOf(sel) < 0)
+                    this.selections.push(sel);
+            }
+            this.highlighted = this.lastString;
+            if (this.lastRange)
+                this.selectedRange = this.lastRange;
+            this.addListeners();
+        }
+    },
+
+    indexIter: function (private_) {
+        let idx = this.range.index;
+        if (this.backward)
+            var groups = [util.range(idx + 1, 0, -1), util.range(this.ranges.length, idx, -1)];
+        else
+            var groups = [util.range(idx, this.ranges.length), util.range(0, idx + 1)];
+
+        for (let i in groups[0])
+            yield i;
+
+        if (!private_) {
+            this.wrapped = true;
+            this.lastRange = null;
+            for (let i in groups[1])
+                yield i;
+        }
+    },
+
+    iter: function (word) {
+        let saved = ["lastRange", "lastString", "range"].map(function (s) [s, this[s]], this);
+        try {
+            this.range = this.ranges[0];
+            this.lastRange = null;
+            this.lastString = word;
+            var res;
+            while (res = this.find(null, this.reverse, true))
+                yield res;
+        }
+        finally {
+            saved.forEach(function ([k, v]) this[k] = v, this);
+        }
+    },
+
+    makeFrameList: function (win) {
+        const self = this;
+        win = win.top;
+        let frames = [];
+        let backup = null;
+
+        function pushRange(start, end) {
+            function push(r) {
+                if (r = RangeFind.Range(r, frames.length))
+                    frames.push(r);
+            }
+
+            let range = start.startContainer.ownerDocument.createRange();
+            range.setStart(start.startContainer, start.startOffset);
+            range.setEnd(end.startContainer, end.startOffset);
+
+            if (!self.elementPath)
+                push(range);
+            else
+                for (let r in self.findSubRanges(range))
+                    push(r);
+        }
+        function rec(win) {
+            let doc = win.document;
+            let pageRange = RangeFind.nodeRange(doc.body || doc.documentElement.lastChild);
+            backup = backup || pageRange;
+            let pageStart = RangeFind.endpoint(pageRange, true);
+            let pageEnd = RangeFind.endpoint(pageRange, false);
+
+            for (let frame in array.iterValues(win.frames)) {
+                let range = doc.createRange();
+                if (util.computedStyle(frame.frameElement).visibility == "visible") {
+                    range.selectNode(frame.frameElement);
+                    pushRange(pageStart, RangeFind.endpoint(range, true));
+                    pageStart = RangeFind.endpoint(range, false);
+                    rec(frame);
+                }
+            }
+            pushRange(pageStart, pageEnd);
+        }
+        rec(win);
+        if (frames.length == 0)
+            frames[0] = RangeFind.Range(RangeFind.endpoint(backup, true), 0);
+        return frames;
+    },
+
+    reset: function () {
+        this.ranges = this.makeFrameList(this.content);
+
+        this.startRange = this.selectedRange;
+        this.startRange.collapse(!this.reverse);
+        this.lastRange = this.selectedRange;
+        this.range = this.findRange(this.startRange);
+        this.ranges.first = this.range;
+        this.ranges.forEach(function (range) range.save());
+        this.forward = null;
+        this.found = false;
+    },
+
+    // This doesn't work yet.
+    resetCaret: function () {
+        let equal = RangeFind.equal;
+        let selection = this.win.getSelection();
+        if (selection.rangeCount == 0)
+            selection.addRange(this.pageStart);
+        function getLines() {
+            let orig = selection.getRangeAt(0);
+            function getRanges(forward) {
+                selection.removeAllRanges();
+                selection.addRange(orig);
+                let cur = orig;
+                while (true) {
+                    var last = cur;
+                    this.sel.lineMove(forward, false);
+                    cur = selection.getRangeAt(0);
+                    if (equal(cur, last))
+                        break;
+                    yield cur;
+                }
+            }
+            yield orig;
+            for (let range in getRanges(true))
+                yield range;
+            for (let range in getRanges(false))
+                yield range;
+        }
+        for (let range in getLines()) {
+            if (this.sel.checkVisibility(range.startContainer, range.startOffset, range.startOffset))
+                return range;
+        }
+        return null;
+    },
+
+    find: function (word, reverse, private_) {
+        if (!private_ && this.lastRange && !RangeFind.equal(this.selectedRange, this.lastRange))
+            this.reset();
+
+        this.wrapped = false;
+        this.finder.findBackwards = reverse ? !this.reverse : this.reverse;
+        let again = word == null;
+        if (again)
+            word = this.lastString;
+        if (!this.matchCase)
+            word = word.toLowerCase();
+
+        if (!again && (word === "" || word.indexOf(this.lastString) !== 0 || this.backward)) {
+            if (!private_)
+                this.range.deselect();
+            if (word === "")
+                this.range.descroll();
+            this.lastRange = this.startRange;
+            this.range = this.ranges.first;
+        }
+
+        if (word == "")
+            var range = this.startRange;
+        else
+            for (let i in this.indexIter(private_)) {
+                if (!private_ && this.range.window != this.ranges[i].window && this.range.window != this.ranges[i].window.parent) {
+                    this.range.descroll();
+                    this.range.deselect();
+                }
+                this.range = this.ranges[i];
+
+                let start = RangeFind.sameDocument(this.lastRange, this.range.range) && this.range.intersects(this.lastRange) ?
+                                RangeFind.endpoint(this.lastRange, !(again ^ this.backward)) :
+                                RangeFind.endpoint(this.range.range, !this.backward);
+
+                if (this.backward && !again)
+                    start = RangeFind.endpoint(this.startRange, false);
+
+                var range = this.finder.Find(word, this.range.range, start, this.range.range);
+                if (range)
+                    break;
+            }
+
+        if (range)
+            this.lastRange = range.cloneRange();
+        if (!private_) {
+            this.lastString = word;
+            if (range == null) {
+                this.cancel();
+                this.found = false;
+                return null;
+            }
+            this.found = true;
+        }
+        if (range && (!private_ || private_ < 0))
+            this.selectedRange = range;
+        return range;
+    },
+
+    get stale() this._stale || this.baseDocument.get() != this.content.document,
+    set stale(val) this._stale = val,
+
+    addListeners: function () {
+        for (let range in array.iterValues(this.ranges))
+            range.window.addEventListener("unload", this.closure.onUnload, true);
+    },
+    purgeListeners: function () {
+        for (let range in array.iterValues(this.ranges))
+            try {
+                range.window.removeEventListener("unload", this.closure.onUnload, true);
+            }
+            catch (e if e.result === Cr.NS_ERROR_FAILURE) {}
+    },
+    onUnload: function (event) {
+        this.purgeListeners();
+        if (this.highlighted)
+            this.highlight(true);
+        this.stale = true;
+    }
+}, {
+    Range: Class("RangeFind.Range", {
+        init: function (range, index) {
+            this.index = index;
+
+            this.range = range;
+            this.document = range.startContainer.ownerDocument;
+            this.window = this.document.defaultView;
+            this.docShell = this.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
+                                       .QueryInterface(Ci.nsIDocShell);
+
+            if (this.selection == null)
+                return false;
+
+            this.save();
+        },
+
+        intersects: function (range) RangeFind.intersects(this.range, range),
+
+        save: function () {
+            this.scroll = Point(this.window.pageXOffset, this.window.pageYOffset);
+
+            this.initialSelection = null;
+            if (this.selection.rangeCount)
+                this.initialSelection = this.selection.getRangeAt(0);
+        },
+
+        descroll: function () {
+            this.window.scrollTo(this.scroll.x, this.scroll.y);
+        },
+
+        deselect: function () {
+            if (this.selection) {
+                this.selection.removeAllRanges();
+                if (this.initialSelection)
+                    this.selection.addRange(this.initialSelection);
+            }
+        },
+
+        get selectionController() this.docShell
+                    .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
+                    .QueryInterface(Ci.nsISelectionController),
+        get selection() {
+            try {
+                return this.selectionController.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
+            }
+            catch (e) {
+                return null;
+            }
+        }
+    }),
+    contains: function (range, r) {
+        try {
+            return range.compareBoundaryPoints(range.START_TO_END, r) >= 0 &&
+                   range.compareBoundaryPoints(range.END_TO_START, r) <= 0;
+        }
+        catch (e) {
+            util.reportError(e, true);
+            return false;
+        }
+    },
+    intersects: function (range, r) {
+        try {
+            return r.compareBoundaryPoints(range.START_TO_END, range) >= 0 &&
+                   r.compareBoundaryPoints(range.END_TO_START, range) <= 0;
+        }
+        catch (e) {
+            util.reportError(e, true);
+            return false;
+        }
+    },
+    endpoint: function (range, before) {
+        range = range.cloneRange();
+        range.collapse(before);
+        return range;
+    },
+    equal: function (r1, r2) {
+        try {
+            return !r1.compareBoundaryPoints(r1.START_TO_START, r2) && !r1.compareBoundaryPoints(r1.END_TO_END, r2);
+        }
+        catch (e) {
+            return false;
+        }
+    },
+    nodeRange: function (node) {
+        let range = node.ownerDocument.createRange();
+        try {
+            range.selectNode(node);
+        }
+        catch (e) {}
+        return range;
+    },
+    sameDocument: function (r1, r2) r1 && r2 && r1.endContainer.ownerDocument == r2.endContainer.ownerDocument,
+    selectNodePath: ["a", "xhtml:a", "*[@onclick]"].map(function (p) "ancestor-or-self::" + p).join(" | ")
+});
+
+endModule();
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/highlight.jsm b/common/modules/highlight.jsm
new file mode 100644 (file)
index 0000000..3528894
--- /dev/null
@@ -0,0 +1,437 @@
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("highlight", {
+    exports: ["Highlight", "Highlights", "highlight"],
+    require: ["services", "styles", "util"],
+    use: ["messages", "template"]
+}, this);
+
+var Highlight = Struct("class", "selector", "sites",
+                       "defaultExtends", "defaultValue",
+                       "value", "extends", "agent",
+                       "base", "baseClass", "style");
+Highlight.liveProperty = function (name, prop) {
+    this.prototype.__defineGetter__(name, function () this.get(name));
+    this.prototype.__defineSetter__(name, function (val) {
+        if (isObject(val) && name !== "style") {
+            if (isArray(val))
+                val = Array.slice(val);
+            else
+                val = update({}, val);
+            Object.freeze(val);
+        }
+        this.set(name, val);
+
+        if (name === "value" || name === "extends")
+            for (let h in highlight)
+                if (h.extends.indexOf(this.class) >= 0)
+                    h.style.css = h.css;
+
+        this.style[prop || name] = this[prop || name];
+    });
+}
+Highlight.liveProperty("agent");
+Highlight.liveProperty("extends", "css");
+Highlight.liveProperty("value", "css");
+Highlight.liveProperty("selector", "css");
+Highlight.liveProperty("sites");
+Highlight.liveProperty("style", "css");
+
+Highlight.defaultValue("baseClass", function () /^(\w*)/.exec(this.class)[0]);
+
+Highlight.defaultValue("selector", function () highlight.selector(this.class));
+
+Highlight.defaultValue("sites", function ()
+    this.base ? this.base.sites
+              : ["resource://dactyl*", "dactyl:*", "file://*"].concat(
+                    highlight.styleableChrome));
+
+Highlight.defaultValue("style", function ()
+    styles.system.add("highlight:" + this.class, this.sites, this.css, this.agent, true));
+
+Highlight.defaultValue("defaultExtends", function () []);
+Highlight.defaultValue("defaultValue", function () "");
+Highlight.defaultValue("extends", function () this.defaultExtends);
+Highlight.defaultValue("value", function () this.defaultValue);
+
+update(Highlight.prototype, {
+    get base() this.baseClass != this.class && highlight.highlight[this.baseClass] || null,
+
+    get bases() array.compact(this.extends.map(function (name) highlight.get(name))),
+
+    get inheritedCSS() {
+        if (this.gettingCSS)
+            return "";
+        try {
+            this.gettingCSS = true;
+            return this.bases.map(function (b) b.cssText.replace(/;?\s*$/, "; ")).join("");
+        }
+        finally {
+            this.gettingCSS = false;
+        }
+    },
+
+    get css() this.selector + "{" + this.cssText + "}",
+
+    get cssText() this.inheritedCSS + this.value,
+
+    toString: function () "Highlight(" + this.class + ")\n\t" +
+        [k + ": " + String(v).quote() for ([k, v] in this)] .join("\n\t")
+});
+
+/**
+ * A class to manage highlighting rules.
+ *
+ * @author Kris Maglione <maglione.k@gmail.com>
+ */
+var Highlights = Module("Highlight", {
+    init: function () {
+        this.highlight = {};
+        this.loaded = {};
+    },
+
+    keys: function keys() Object.keys(this.highlight).sort(),
+
+    __iterator__: function () values(this.highlight).sort(function (a, b) String.localeCompare(a.class, b.class))
+                                                    .iterValues(),
+
+    _create: function (agent, args) {
+        let obj = Highlight.apply(Highlight, args);
+
+        if (!isArray(obj.sites))
+            obj.set("sites", obj.sites.split(","));
+        if (!isArray(obj.defaultExtends))
+            obj.set("defaultExtends", obj.defaultExtends.split(","));
+        obj.set("agent", agent);
+
+        obj.set("defaultValue", Styles.append("", obj.get("defaultValue")));
+
+        let old = this.highlight[obj.class];
+        this.highlight[obj.class] = obj;
+        // This *must* come before any other property changes.
+        if (old) {
+            obj.selector = old.selector;
+            obj.style = old.style;
+        }
+
+        if (/^[[>+: ]/.test(args[1]))
+            obj.selector = this.selector(obj.class) + args[1];
+        else if (args[1])
+            obj.selector = this.selector(args[1]);
+
+        if (old && old.value != old.defaultValue)
+            obj.value = old.value;
+
+        if (!old && obj.base && obj.base.style.enabled)
+            obj.style.enabled = true;
+        else
+            this.loaded.__defineSetter__(obj.class, function () {
+                delete this[obj.class];
+                this[obj.class] = true;
+
+                if (obj.class === obj.baseClass)
+                    for (let h in highlight)
+                        if (h.baseClass === obj.class)
+                            this[h.class] = true;
+                obj.style.enabled = true;
+            });
+        return obj;
+    },
+
+    get: function (k) this.highlight[k],
+
+    set: function (key, newStyle, force, append, extend) {
+        let [, class_, selectors] = key.match(/^([a-zA-Z_-]+)(.*)/);
+
+        let highlight = this.highlight[key] || this._create(false, [key]);
+
+        let bases = extend || highlight.extend;
+        if (append) {
+            newStyle = Styles.append(highlight.value || "", newStyle);
+            bases = highlight.extends.concat(bases);
+        }
+
+        if (/^\s*$/.test(newStyle))
+            newStyle = null;
+        if (newStyle == null && extend == null) {
+            if (highlight.defaultValue == null && highight.defaultExtends.length == 0) {
+                highlight.style.enabled = false;
+                delete this.loaded[highlight.class];
+                delete this.highlight[highlight.class];
+                return null;
+            }
+            newStyle = highlight.defaultValue;
+            bases = highlight.defaultExtends;
+        }
+
+        highlight.set("value", newStyle || "");
+        highlight.extends = array.uniq(bases, true);
+        if (force)
+            highlight.style.enabled = true;
+        this.highlight[highlight.class] = highlight;
+        return highlight;
+    },
+
+    /**
+     * Clears all highlighting rules. Rules with default values are
+     * reset.
+     */
+    clear: function () {
+        for (let [k, v] in Iterator(this.highlight))
+            this.set(k, null, true);
+    },
+
+    /**
+     * Highlights a node with the given group, and ensures that said
+     * group is loaded.
+     *
+     * @param {Node} node
+     * @param {string} group
+     */
+    highlightNode: function (node, group, applyBindings) {
+        node.setAttributeNS(NS.uri, "highlight", group);
+
+        let groups = group.split(" ");
+        for each (let group in groups)
+            this.loaded[group] = true;
+
+        if (applyBindings)
+            for each (let group in groups) {
+                if (applyBindings.bindings && group in applyBindings.bindings)
+                    applyBindings.bindings[group](node, applyBindings);
+                else if (group in template.bindings)
+                    template.bindings[group](node, applyBindings);
+            }
+    },
+
+    /**
+     * Gets a CSS selector given a highlight group.
+     *
+     * @param {string} class
+     */
+    selector: function (class_)
+        let (self = this)
+           class_.replace(/(^|[>\s])([A-Z][\w-]+)\b/g,
+            function (m, n1, hl) n1 +
+                (self.highlight[hl] && self.highlight[hl].class != class_
+                    ? self.highlight[hl].selector : "[dactyl|highlight~=" + hl + "]")),
+
+    groupRegexp: util.regexp(<![CDATA[
+        ^
+        (\s* (?:\S|\s\S)+ \s+)
+        \{ ([^}]*) \}
+        \s*
+        $
+    ]]>, "gmx"),
+    sheetRegexp: util.regexp(<![CDATA[
+        ^\s*
+        !? \*?
+             (?P<group>    (?:[^;\s]|\s[^;\s])+ )
+        (?:; (?P<selector> (?:[^;\s]|\s[^;\s])+ )? )?
+        (?:; (?P<sites>    (?:[^;\s]|\s[^;\s])+ )? )?
+        (?:; (?P<extends>  (?:[^;\s]|\s[^;\s])+ )? )?
+        \s*  (?P<css>      .*)
+        $
+    ]]>, "x"),
+
+    /**
+     * Bulk loads new CSS rules, in the format of,
+     *
+     *   Rules     ::= Rule | Rule "\n" Rule
+     *   Rule      ::= Bang? Star? MatchSpec Space Space+ Css
+     *               | Comment
+     *   Comment   ::= Space* "//" *
+     *   Bang      ::= "!"
+     *   Star      ::= "*"
+     *   MatchSpec ::= Class
+     *               | Class ";" Selector
+     *               | Class ";" Selector ";" Sites
+     *               | Class ";" Selector ";" Sites ";" Extends
+     *   CSS       ::= CSSLine | "{" CSSLines "}"
+     *   CSSLines  ::= CSSLine | CSSLine "\n" CSSLines
+     *
+     * Where Class is the name of the sheet, Selector is the CSS
+     * selector for the style, Sites is the comma-separated list of site
+     * filters to apply the style to.
+     *
+     * If Selector is not provided, it defaults to [dactyl|highlight~={Class}].
+     * If it is provided and begins with any of "+", ">" or " ", it is
+     * appended to the default.
+     *
+     * If Sites is not provided, it defaults to the chrome documents of
+     * the main application window, dactyl help files, and any other
+     * dactyl-specific documents.
+     *
+     * If Star is provided, the style is applied as an agent sheet.
+     *
+     * The new styles are lazily activated unless Bang or *eager* is
+     * provided. See {@link Util#xmlToDom}.
+     *
+     * @param {string} css The rules to load. See {@link Highlights#css}.
+     * @param {boolean} eager When true, load all provided rules immediately.
+     */
+    loadCSS: function (css, eager) {
+        String.replace(css, this.groupRegexp, function (m, m1, m2) m1 + " " + m2.replace(/\n\s*/g, " "))
+              .split("\n").filter(function (s) /\S/.test(s) && !/^\s*\/\//.test(s))
+              .forEach(function (highlight) {
+
+            let bang = eager || /^\s*!/.test(highlight);
+            let star = /^\s*!?\*/.test(highlight);
+            highlight = this._create(star, this.sheetRegexp.exec(highlight).slice(1));
+            if (bang)
+                highlight.style.enabled = true;
+       }, this);
+       for (let h in this)
+           h.style.css = h.css;
+    }
+}, {
+}, {
+    commands: function (dactyl, modules) {
+        const { autocommands, commands, completion, CommandOption, config, io } = modules;
+
+        let lastScheme;
+        commands.add(["colo[rscheme]"],
+            "Load a color scheme",
+            function (args) {
+                let scheme = args[0];
+                if (lastScheme)
+                    lastScheme.unload();
+
+                if (scheme == "default")
+                    highlight.clear();
+                else {
+                    lastScheme = modules.io.sourceFromRuntimePath(["colors/" + scheme + "." + config.fileExtension]);
+                    dactyl.assert(lastScheme, _("command.colorscheme.notFound", scheme));
+                }
+                autocommands.trigger("ColorScheme", { name: scheme });
+            },
+            {
+                argCount: "1",
+                completer: function (context) completion.colorScheme(context)
+            });
+
+        commands.add(["hi[ghlight]"],
+            "Set the style of certain display elements",
+            function (args) {
+                let style = <![CDATA[
+                    ;
+                    display: inline-block !important;
+                    position: static !important;
+                    margin: 0px !important; padding: 0px !important;
+                    width: 3em !important; min-width: 3em !important; max-width: 3em !important;
+                    height: 1em !important; min-height: 1em !important; max-height: 1em !important;
+                    overflow: hidden !important;
+                ]]>;
+                let clear = args[0] == "clear";
+                if (clear)
+                    args.shift();
+
+                let [key, css] = args;
+                let modify = css || clear || args["-append"] || args["-link"];
+
+                if (!modify && /&$/.test(key))
+                    [clear, modify, key] = [true, true, key.replace(/&$/, "")];
+
+                dactyl.assert(!(clear && css), _("error.trailing"));
+
+                if (!modify)
+                    modules.commandline.commandOutput(
+                        template.tabular(["Key", "Sample", "Link", "CSS"],
+                            ["padding: 0 1em 0 0; vertical-align: top; max-width: 16em; overflow: hidden;",
+                             "text-align: center"],
+                            ([h.class,
+                              <span style={"text-align: center; line-height: 1em;" + h.value + style}>XXX</span>,
+                              template.map(h.extends, template.highlight),
+                              template.highlightRegexp(h.value, /\b[-\w]+(?=:)/g)]
+                                for (h in highlight)
+                                if (!key || h.class.indexOf(key) > -1))));
+                else if (!key && clear)
+                    highlight.clear();
+                else if (key)
+                    highlight.set(key, css, clear, "-append" in args, args["-link"]);
+                else
+                    util.assert(false, _("error.invalidArgument"));
+            },
+            {
+                // TODO: add this as a standard highlight completion function?
+                completer: function (context, args) {
+                    // Complete a highlight group on :hi clear ...
+                    if (args.completeArg > 0 && args[0] == "clear")
+                        args.completeArg = args.completeArg > 1 ? -1 : 0;
+
+                    if (args.completeArg == 0)
+                        completion.highlightGroup(context);
+                    else if (args.completeArg == 1) {
+                        let hl = highlight.get(args[0]);
+                        if (hl)
+                            context.completions = [[hl.value, "Current Value"], [hl.defaultValue || "", "Default Value"]];
+                        context.fork("css", 0, completion, "css");
+                    }
+                },
+                hereDoc: true,
+                literal: 1,
+                options: [
+                    { names: ["-append", "-a"], description: "Append new CSS to the existing value" },
+                    {
+                        names: ["-link", "-l"],
+                        description: "Link this group to another",
+                        type: CommandOption.LIST,
+                        completer: function (context, args) {
+                            let group = args[0] && highlight.get(args[0]);
+                            if (group)
+                                context.fork("extra", 0, this, function (context) [
+                                     [String(group.extends), "Current Value"],
+                                     [String(group.defaultExtends) || "", "Default Value"]
+                                ]);
+                            context.fork("groups", 0, completion, "highlightGroup");
+                        }
+                    }
+                ],
+                serialize: function () [
+                    {
+                        command: this.name,
+                        arguments: [v.class],
+                        literalArg: v.value
+                    }
+                    for (v in Iterator(highlight))
+                    if (v.value != v.defaultValue)
+                ]
+            });
+    },
+    completion: function (dactyl, modules) {
+        const { completion, config, io } = modules;
+        completion.colorScheme = function colorScheme(context) {
+            let extRe = RegExp("\\." + config.fileExtension + "$");
+
+            context.title = ["Color Scheme", "Runtime Path"];
+            context.keys = { text: function (f) f.leafName.replace(extRe, ""), description: ".parent.path" };
+            context.completions = array.flatten(
+                io.getRuntimeDirectories("colors").map(
+                    function (dir) dir.readDirectory().filter(
+                        function (file) extRe.test(file.leafName))));
+
+        };
+
+        completion.highlightGroup = function highlightGroup(context) {
+            context.title = ["Highlight Group", "Value"];
+            context.completions = [[v.class, v.value] for (v in highlight)];
+        };
+    },
+    javascript: function (dactyl, modules, window) {
+        modules.JavaScript.setCompleter(["get", "set"].map(function (m) highlight[m]),
+            [ function (context, obj, args) Iterator(highlight.highlight) ]);
+        modules.JavaScript.setCompleter(["highlightNode"].map(function (m) highlight[m]),
+            [ null, function (context, obj, args) Iterator(highlight.highlight) ]);
+    }
+});
+
+// catch(e){dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack);}
+
+endModule();
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/io.jsm b/common/modules/io.jsm
new file mode 100644 (file)
index 0000000..a6487a9
--- /dev/null
@@ -0,0 +1,1079 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+// Some code based on Venkman
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("io", {
+    exports: ["IO", "io"],
+    require: ["services"],
+    use: ["config", "messages", "storage", "styles", "template", "util"]
+}, this);
+
+// TODO: why are we passing around strings rather than file objects?
+/**
+ * Provides a basic interface to common system I/O operations.
+ * @instance io
+ */
+var IO = Module("io", {
+    init: function () {
+        this._processDir = services.directory.get("CurWorkD", Ci.nsIFile);
+        this._cwd = this._processDir.path;
+        this._oldcwd = null;
+        this.config = config;
+    },
+
+    Local: function (dactyl, modules, window) let ({ io, plugins } = modules) ({
+
+        init: function init() {
+            this.config = modules.config;
+            this._processDir = services.directory.get("CurWorkD", Ci.nsIFile);
+            this._cwd = this._processDir.path;
+            this._oldcwd = null;
+
+            this._lastRunCommand = ""; // updated whenever the users runs a command with :!
+            this._scriptNames = [];
+
+            this.downloadListener = {
+                onDownloadStateChange: function (state, download) {
+                    if (download.state == services.downloadManager.DOWNLOAD_FINISHED) {
+                        let url   = download.source.spec;
+                        let title = download.displayName;
+                        let file  = download.targetFile.path;
+                        let size  = download.size;
+
+                        dactyl.echomsg({ domains: [util.getHost(url)], message: _("io.downloadFinished", title, file) },
+                                       1, modules.commandline.ACTIVE_WINDOW);
+                        modules.autocommands.trigger("DownloadPost", { url: url, title: title, file: file, size: size });
+                    }
+                },
+                onStateChange:    function () {},
+                onProgressChange: function () {},
+                onSecurityChange: function () {}
+            };
+
+            services.downloadManager.addListener(this.downloadListener);
+        },
+
+        CommandFileMode: Class("CommandFileMode", modules.CommandMode, {
+            init: function init(prompt, params) {
+                init.supercall(this);
+                this.prompt = isArray(prompt) ? prompt : ["Question", prompt];
+                update(this, params);
+            },
+
+            historyKey: "file",
+
+            get mode() modules.modes.FILE_INPUT,
+
+            complete: function (context) {
+                if (this.completer)
+                    this.completer(context);
+
+                context = context.fork("files", 0);
+                modules.completion.file(context);
+                context.filters = context.filters.concat(this.filters || []);
+            }
+        }),
+
+        destroy: function destroy() {
+            services.downloadManager.removeListener(this.downloadListener);
+        },
+
+        /**
+         * Returns all directories named *name* in 'runtimepath'.
+         *
+         * @param {string} name
+         * @returns {nsIFile[])
+         */
+        getRuntimeDirectories: function getRuntimeDirectories(name) {
+            return modules.options.get("runtimepath").files
+                .map(function (dir) dir.child(name))
+                .filter(function (dir) dir.exists() && dir.isDirectory() && dir.isReadable());
+        },
+
+        // FIXME: multiple paths?
+        /**
+         * Sources files found in 'runtimepath'. For each relative path in *paths*
+         * each directory in 'runtimepath' is searched and if a matching file is
+         * found it is sourced. Only the first file found (per specified path) is
+         * sourced unless *all* is specified, then all found files are sourced.
+         *
+         * @param {string[]} paths An array of relative paths to source.
+         * @param {boolean} all Whether all found files should be sourced.
+         */
+        sourceFromRuntimePath: function sourceFromRuntimePath(paths, all) {
+            let dirs = modules.options.get("runtimepath").files;
+            let found = null;
+
+            dactyl.echomsg(_("io.searchingFor", paths.join(" ").quote(), modules.options.get("runtimepath").stringValue), 2);
+
+        outer:
+            for (let dir in values(dirs)) {
+                for (let [,path] in Iterator(paths)) {
+                    let file = dir.child(path);
+
+                    dactyl.echomsg(_("io.searchingFor", file.path.quote()), 3);
+
+                    if (file.exists() && file.isFile() && file.isReadable()) {
+                        found = io.source(file.path, false) || true;
+
+                        if (!all)
+                            break outer;
+                    }
+                }
+            }
+
+            if (!found)
+                dactyl.echomsg(_("io.notInRTP", paths.join(" ").quote()), 1);
+
+            return found;
+        },
+
+        /**
+         * Reads Ex commands, JavaScript or CSS from *filename*.
+         *
+         * @param {string} filename The name of the file to source.
+         * @param {object} params Extra parameters:
+         *      group:  The group in which to execute commands.
+         *      silent: Whether errors should not be reported.
+         */
+        source: function source(filename, params) {
+            const { contexts } = modules;
+            defineModule.loadLog.push("sourcing " + filename);
+
+            if (!isObject(params))
+                params = { silent: params };
+
+            let time = Date.now();
+            return contexts.withContext(null, function () {
+                try {
+                    var file = util.getFile(filename) || io.File(filename);
+
+                    if (!file.exists() || !file.isReadable() || file.isDirectory()) {
+                        if (!params.silent)
+                            dactyl.echoerr(_("io.notReadable", filename.quote()));
+                        return;
+                    }
+
+                    dactyl.echomsg(_("io.sourcing", filename.quote()), 2);
+
+                    let uri = services.io.newFileURI(file);
+
+                    // handle pure JavaScript files specially
+                    if (/\.js$/.test(filename)) {
+                        try {
+                            var context = contexts.Script(file, params.group);
+                            dactyl.loadScript(uri.spec, context);
+                            dactyl.helpInitialized = false;
+                        }
+                        catch (e) {
+                            if (e.fileName)
+                                try {
+                                    e.fileName = util.fixURI(e.fileName);
+                                    if (e.fileName == uri.spec)
+                                        e.fileName = filename;
+                                    e.echoerr = <>{e.fileName}:{e.lineNumber}: {e}</>;
+                                }
+                                catch (e) {}
+                            throw e;
+                        }
+                    }
+                    else if (/\.css$/.test(filename))
+                        styles.registerSheet(uri.spec, false, true);
+                    else {
+                        context = contexts.Context(file, params.group);
+                        modules.commands.execute(file.read(), null, params.silent,
+                                                 null, {
+                            context: context,
+                            file: file.path,
+                            group: context.GROUP,
+                            line: 1
+                        });
+                    }
+
+                    if (this._scriptNames.indexOf(file.path) == -1)
+                        this._scriptNames.push(file.path);
+
+                    dactyl.echomsg(_("io.sourcingEnd", filename.quote()), 2);
+
+                    dactyl.log("Sourced: " + filename, 3);
+                    return context;
+                }
+                catch (e) {
+                    dactyl.reportError(e);
+                    let message = "Sourcing file: " + (e.echoerr || file.path + ": " + e);
+                    if (!params.silent)
+                        dactyl.echoerr(message);
+                }
+                finally {
+                    defineModule.loadLog.push("done sourcing " + filename + ": " + (Date.now() - time) + "ms");
+                }
+            }, this);
+        },
+    }),
+
+    // TODO: there seems to be no way, short of a new component, to change
+    // the process's CWD - see https://bugzilla.mozilla.org/show_bug.cgi?id=280953
+    /**
+     * Returns the current working directory.
+     *
+     * It's not possible to change the real CWD of the process so this
+     * state is maintained internally. External commands run via
+     * {@link #system} are executed in this directory.
+     *
+     * @returns {nsIFile}
+     */
+    get cwd() {
+        let dir = File(this._cwd);
+
+        // NOTE: the directory could have been deleted underneath us so
+        // fallback to the process's CWD
+        if (dir.exists() && dir.isDirectory())
+            return dir;
+        else
+            return this._processDir.clone();
+    },
+
+    /**
+     * Sets the current working directory.
+     *
+     * @param {string} newDir The new CWD. This may be a relative or
+     *     absolute path and is expanded by {@link #expandPath}.
+     */
+    set cwd(newDir) {
+        newDir = newDir && newDir.path || newDir || "~";
+
+        if (newDir == "-") {
+            util.assert(this._oldcwd != null, _("io.noPrevDir"));
+            [this._cwd, this._oldcwd] = [this._oldcwd, this.cwd];
+        }
+        else {
+            let dir = io.File(newDir);
+            util.assert(dir.exists() && dir.isDirectory(), _("io.noSuchDir", dir.path.quote()));
+            dir.normalize();
+            [this._cwd, this._oldcwd] = [dir.path, this.cwd];
+        }
+        return this.cwd;
+    },
+
+    /**
+     * @property {function} File class.
+     * @final
+     */
+    File: Class.memoize(function () let (io = this)
+        Class("File", File, {
+            init: function init(path, checkCWD)
+                init.supercall(this, path, (arguments.length < 2 || checkCWD) && io.cwd)
+        })),
+
+    /**
+     * @property {Object} The current file sourcing context. As a file is
+     *     being sourced the 'file' and 'line' properties of this context
+     *     object are updated appropriately.
+     */
+    sourcing: null,
+
+    expandPath: deprecated("File.expandPath", function expandPath() File.expandPath.apply(File, arguments)),
+
+    /**
+     * Returns the first user RC file found in *dir*.
+     *
+     * @param {File|string} dir The directory to search.
+     * @param {boolean} always When true, return a path whether
+     *     the file exists or not.
+     * @default $HOME.
+     * @returns {nsIFile} The RC file or null if none is found.
+     */
+    getRCFile: function (dir, always) {
+        dir = this.File(dir || "~");
+
+        let rcFile1 = dir.child("." + config.name + "rc");
+        let rcFile2 = dir.child("_" + config.name + "rc");
+
+        if (util.OS.isWindows)
+            [rcFile1, rcFile2] = [rcFile2, rcFile1];
+
+        if (rcFile1.exists() && rcFile1.isFile())
+            return rcFile1;
+        else if (rcFile2.exists() && rcFile2.isFile())
+            return rcFile2;
+        else if (always)
+            return rcFile1;
+        return null;
+    },
+
+    // TODO: make secure
+    /**
+     * Creates a temporary file.
+     *
+     * @returns {File}
+     */
+    createTempFile: function () {
+        let file = services.directory.get("TmpD", Ci.nsIFile);
+        file.append(this.config.tempFile);
+        file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, octal(600));
+
+        Cc["@mozilla.org/uriloader/external-helper-app-service;1"]
+            .getService(Ci.nsPIExternalAppLauncher).deleteTemporaryFileOnExit(file);
+
+        return File(file);
+    },
+
+    /**
+     * Determines whether the given URL string resolves to a JAR URL and
+     * returns the matching nsIJARURI object if it does.
+     *
+     * @param {string} url The URL to check.
+     * @returns {nsIJARURI|null}
+     */
+    isJarURL: function isJarURL(url) {
+        try {
+            let uri = util.newURI(util.fixURI(url));
+            let channel = services.io.newChannelFromURI(uri);
+            channel.cancel(Cr.NS_BINDING_ABORTED);
+            if (channel instanceof Ci.nsIJARChannel)
+                return channel.URI.QueryInterface(Ci.nsIJARURI);
+        }
+        catch (e) {}
+        return null;
+    },
+
+    /**
+     * Returns a list of the contents of the given JAR file which are
+     * children of the given path.
+     *
+     * @param {nsIURI|string} file The URI of the JAR file to list.
+     * @param {string} path The prefix path to search.
+     */
+    listJar: function listJar(file, path) {
+        file = util.getFile(file);
+        if (file) {
+            // let jar = services.zipReader.getZip(file); Crashes.
+            let jar = services.ZipReader(file);
+            try {
+                let filter = RegExp("^" + util.regexp.escape(decodeURI(path))
+                                    + "[^/]*/?$");
+
+                for (let entry in iter(jar.findEntries("*")))
+                    if (filter.test(entry))
+                        yield entry;
+            }
+            finally {
+                jar.close();
+            }
+        }
+    },
+
+    readHeredoc: function (end) {
+        return "";
+    },
+
+    /**
+     * Searches for the given executable file in the system executable
+     * file paths as specified by the PATH environment variable.
+     *
+     * On Windows, if the unadorned filename cannot be found, the
+     * extensions in the semicolon-separated list in the PATHSEP
+     * environment variable are successively appended to the original
+     * name and searched for in turn.
+     *
+     * @param {string} bin The name of the executable to find.
+     */
+    pathSearch: function (bin) {
+        if (bin instanceof File || File.isAbsolutePath(bin))
+            return this.File(bin);
+
+        let dirs = services.environment.get("PATH").split(util.OS.isWindows ? ";" : ":");
+        // Windows tries the CWD first TODO: desirable?
+        if (util.OS.isWindows)
+            dirs = [io.cwd].concat(dirs);
+
+        for (let [, dir] in Iterator(dirs))
+            try {
+                dir = this.File(dir, true);
+
+                let file = dir.child(bin);
+                if (file.exists() && file.isFile() && file.isExecutable())
+                    return file;
+
+                // TODO: couldn't we just palm this off to the start command?
+                // automatically try to add the executable path extensions on windows
+                if (util.OS.isWindows) {
+                    let extensions = services.environment.get("PATHEXT").split(";");
+                    for (let [, extension] in Iterator(extensions)) {
+                        file = dir.child(bin + extension);
+                        if (file.exists())
+                            return file;
+                    }
+                }
+            }
+            catch (e) {}
+        return null;
+    },
+
+    /**
+     * Runs an external program.
+     *
+     * @param {File|string} program The program to run.
+     * @param {string[]} args An array of arguments to pass to *program*.
+     */
+    run: function (program, args, blocking) {
+        args = args || [];
+
+        let file = this.pathSearch(program);
+
+        if (!file || !file.exists()) {
+            util.dactyl.echoerr(_("io.noCommand", program));
+            if (callable(blocking))
+                util.trapErrors(blocking);
+            return -1;
+        }
+
+        let process = services.Process(file);
+        process.run(false, args.map(String), args.length);
+        try {
+            if (callable(blocking))
+                var timer = services.Timer(
+                    function () {
+                        if (!process.isRunning) {
+                            timer.cancel();
+                            util.trapErrors(blocking);
+                        }
+                    },
+                    100, services.Timer.TYPE_REPEATING_SLACK);
+            else if (blocking)
+                while (process.isRunning)
+                    util.threadYield(false, true);
+        }
+        catch (e) {
+            process.kill();
+            throw e;
+        }
+
+        return process.exitValue;
+    },
+
+    // TODO: when https://bugzilla.mozilla.org/show_bug.cgi?id=68702 is
+    // fixed use that instead of a tmpfile
+    /**
+     * Runs *command* in a subshell and returns the output in a string. The
+     * shell used is that specified by the 'shell' option.
+     *
+     * @param {string} command The command to run.
+     * @param {string} input Any input to be provided to the command on stdin.
+     * @returns {object}
+     */
+    system: function (command, input) {
+        util.dactyl.echomsg(_("io.callingShell", command), 4);
+
+        function escape(str) '"' + str.replace(/[\\"$]/g, "\\$&") + '"';
+
+        return this.withTempFiles(function (stdin, stdout, cmd) {
+            if (input instanceof File)
+                stdin = input;
+            else if (input)
+                stdin.write(input);
+
+            let shell = io.pathSearch(storage["options"].get("shell").value);
+            let shcf = storage["options"].get("shellcmdflag").value;
+            util.assert(shell, _("error.invalid", "'shell'"));
+
+            if (isArray(command))
+                command = command.map(escape).join(" ");
+
+            // TODO: implement 'shellredir'
+            if (util.OS.isWindows && !/sh/.test(shell.leafName)) {
+                command = "cd /D " + this.cwd.path + " && " + command + " > " + stdout.path + " 2>&1" + " < " + stdin.path;
+                var res = this.run(shell, shcf.split(/\s+/).concat(command), true);
+            }
+            else {
+                cmd.write("cd " + escape(this.cwd.path) + "\n" +
+                        ["exec", ">" + escape(stdout.path), "2>&1", "<" + escape(stdin.path),
+                         escape(shell.path), shcf, escape(command)].join(" "));
+                res = this.run("/bin/sh", ["-e", cmd.path], true);
+            }
+
+            return {
+                __noSuchMethod__: function (meth, args) this.output[meth].apply(this.output, args),
+                valueOf: function () this.output,
+                output: stdout.read().replace(/^(.*)\n$/, "$1"),
+                returnValue: res,
+                toString: function () this.output
+            };
+        }) || "";
+    },
+
+    /**
+     * Creates a temporary file context for executing external commands.
+     * *func* is called with a temp file, created with {@link #createTempFile},
+     * for each explicit argument. Ensures that all files are removed when
+     * *func* returns.
+     *
+     * @param {function} func The function to execute.
+     * @param {Object} self The 'this' object used when executing func.
+     * @returns {boolean} false if temp files couldn't be created,
+     *     otherwise, the return value of *func*.
+     */
+    withTempFiles: function (func, self, checked) {
+        let args = array(util.range(0, func.length)).map(this.closure.createTempFile).array;
+        try {
+            if (!args.every(util.identity))
+                return false;
+            var res = func.apply(self || this, args);
+        }
+        finally {
+            if (!checked || res !== true)
+                args.forEach(function (f) f && f.remove(false));
+        }
+        return res;
+    }
+}, {
+    /**
+     * @property {string} The value of the $PENTADACTYL_RUNTIME environment
+     *     variable.
+     */
+    get runtimePath() {
+        const rtpvar = config.idName + "_RUNTIME";
+        let rtp = services.environment.get(rtpvar);
+        if (!rtp) {
+            rtp = "~/" + (util.OS.isWindows ? "" : ".") + config.name;
+            services.environment.set(rtpvar, rtp);
+        }
+        return rtp;
+    },
+
+    /**
+     * @property {string} The current platform's path separator.
+     */
+    PATH_SEP: deprecated("File.PATH_SEP", { get: function PATH_SEP() File.PATH_SEP })
+}, {
+    commands: function (dactyl, modules, window) {
+        const { commands, completion, io } = modules;
+
+        commands.add(["cd", "chd[ir]"],
+            "Change the current directory",
+            function (args) {
+                let arg = args[0];
+
+                if (!arg)
+                    arg = "~";
+
+                arg = File.expandPath(arg);
+
+                // go directly to an absolute path or look for a relative path
+                // match in 'cdpath'
+                // TODO: handle ../ and ./ paths
+                if (File.isAbsolutePath(arg)) {
+                    io.cwd = arg;
+                    dactyl.echomsg(io.cwd.path);
+                }
+                else {
+                    let dirs = modules.options.get("cdpath").files;
+                    for (let dir in values(dirs)) {
+                        dir = dir.child(arg);
+
+                        if (dir.exists() && dir.isDirectory() && dir.isReadable()) {
+                            io.cwd = dir;
+                            dactyl.echomsg(io.cwd.path);
+                            return;
+                        }
+                    }
+
+                    dactyl.echoerr(_("io.noSuchDir", arg.quote()));
+                    dactyl.echoerr(_("io.commandFailed"));
+                }
+            }, {
+                argCount: "?",
+                completer: function (context) completion.directory(context, true),
+                literal: 0
+            });
+
+        commands.add(["pw[d]"],
+            "Print the current directory name",
+            function () { dactyl.echomsg(io.cwd.path); },
+            { argCount: "0" });
+
+        commands.add([config.name.replace(/(.)(.*)/, "mk$1[$2rc]")],
+            "Write current key mappings and changed options to the config file",
+            function (args) {
+                dactyl.assert(args.length <= 1, _("io.oneFileAllowed"));
+
+                let file = io.File(args[0] || io.getRCFile(null, true));
+
+                dactyl.assert(!file.exists() || args.bang, _("io.exists", file.path.quote()));
+
+                // TODO: Use a set/specifiable list here:
+                let lines = [cmd.serialize().map(commands.commandToString, cmd) for (cmd in commands.iterator(true)) if (cmd.serialize)];
+                lines = array.flatten(lines);
+
+                lines.unshift('"' + config.version + "\n");
+                lines.push("\n\" vim: set ft=" + config.name + ":");
+
+                try {
+                    file.write(lines.join("\n"));
+                }
+                catch (e) {
+                    dactyl.echoerr(_("io.notWriteable"), file.path.quote());
+                    dactyl.log("Could not write to " + file.path + ": " + e.message); // XXX
+                }
+            }, {
+                argCount: "*", // FIXME: should be "?" but kludged for proper error message
+                bang: true,
+                completer: function (context) completion.file(context, true)
+            });
+
+        commands.add(["mks[yntax]"],
+            "Generate a Vim syntax file",
+            function (args) {
+                let runtime = util.OS.isWindows ? "~/vimfiles/" : "~/.vim/";
+                let file = io.File(runtime + "syntax/" + config.name + ".vim");
+                if (args.length)
+                    file = io.File(args[0]);
+
+                if (file.exists() && file.isDirectory() || args[0] && /\/$/.test(args[0]))
+                    file.append(config.name + ".vim");
+                dactyl.assert(!file.exists() || args.bang, "File exists");
+
+                let template = util.compileMacro(<![CDATA[
+" Vim syntax file
+" Language:         Pentadactyl configuration file
+" Maintainer:       Doug Kearns <dougkearns@gmail.com>
+
+" TODO: make this <name> specific - shared dactyl config?
+
+if exists("b:current_syntax")
+  finish
+endif
+
+let s:cpo_save = &cpo
+set cpo&vim
+
+syn include @javascriptTop syntax/javascript.vim
+unlet b:current_syntax
+
+syn include @cssTop syntax/css.vim
+unlet b:current_syntax
+
+syn match <name>CommandStart "\%(^\s*:\=\)\@<=" nextgroup=<name>Command,<name>AutoCmd
+
+<commands>
+    \ contained
+
+syn match <name>Command "!" contained
+
+syn keyword <name>AutoCmd au[tocmd] contained nextgroup=<name>AutoEventList skipwhite
+
+<autocommands>
+    \ contained
+
+syn match <name>AutoEventList "\(\a\+,\)*\a\+" contained contains=<name>AutoEvent
+
+syn region <name>Set matchgroup=<name>Command start="\%(^\s*:\=\)\@<=\<\%(setl\%[ocal]\|setg\%[lobal]\|set\=\)\=\>"
+    \ end="$" keepend oneline contains=<name>Option,<name>String
+
+<options>
+    \ contained nextgroup=pentadactylSetMod
+
+<toggleoptions>
+execute 'syn match <name>Option "\<\%(no\|inv\)\=\%(' .
+    \ join(s:toggleOptions, '\|') .
+    \ '\)\>!\=" contained nextgroup=<name>SetMod'
+
+syn match <name>SetMod "\%(\<[a-z_]\+\)\@<=&" contained
+
+syn region <name>JavaScript start="\%(^\s*\%(javascript\|js\)\s\+\)\@<=" end="$" contains=@javascriptTop keepend oneline
+syn region <name>JavaScript matchgroup=<name>JavaScriptDelimiter
+    \ start="\%(^\s*\%(javascript\|js\)\s\+\)\@<=<<\s*\z(\h\w*\)"hs=s+2 end="^\z1$" contains=@javascriptTop fold
+
+let s:cssRegionStart = '\%(^\s*sty\%[le]!\=\s\+\%(-\%(n\|name\)\%(\s\+\|=\)\S\+\s\+\)\=[^-]\S\+\s\+\)\@<='
+execute 'syn region <name>Css start="' . s:cssRegionStart . '" end="$" contains=@cssTop keepend oneline'
+execute 'syn region <name>Css matchgroup=<name>CssDelimiter'
+    \ 'start="' . s:cssRegionStart . '<<\s*\z(\h\w*\)"hs=s+2 end="^\z1$" contains=@cssTop fold'
+
+syn match <name>Notation "<[0-9A-Za-z-]\+>"
+
+syn match   <name>Comment +".*$+ contains=<name>Todo,@Spell
+syn keyword <name>Todo FIXME NOTE TODO XXX contained
+
+syn region <name>String start="\z(["']\)" end="\z1" skip="\\\\\|\\\z1" oneline
+
+syn match <name>LineComment +^\s*".*$+ contains=<name>Todo,@Spell
+
+" NOTE: match vim.vim highlighting group names
+hi def link <name>AutoCmd               <name>Command
+hi def link <name>AutoEvent             Type
+hi def link <name>Command               Statement
+hi def link <name>Comment               Comment
+hi def link <name>JavaScriptDelimiter   Delimiter
+hi def link <name>CssDelimiter          Delimiter
+hi def link <name>Notation              Special
+hi def link <name>LineComment           Comment
+hi def link <name>Option                PreProc
+hi def link <name>SetMod                <name>Option
+hi def link <name>String                String
+hi def link <name>Todo                  Todo
+
+let b:current_syntax = "<name>"
+
+let &cpo = s:cpo_save
+unlet s:cpo_save
+
+" vim: tw=130 et ts=4 sw=4:
+]]>, true);
+
+                const WIDTH = 80;
+                function wrap(prefix, items, sep) {
+                    sep = sep || " ";
+                    let width = 0;
+                    let lines = [];
+                    lines.__defineGetter__("last", function () this[this.length - 1]);
+
+                    for (let item in values(items.array || items)) {
+                        if (item.length > width && (!lines.length || lines.last.length > 1)) {
+                            lines.push([prefix]);
+                            width = WIDTH - prefix.length;
+                            prefix = "    \\ ";
+                        }
+                        width -= item.length + sep.length;
+                        lines.last.push(item, sep);
+                    }
+                    lines.last.pop();
+                    return lines.map(function (l) l.join("")).join("\n").replace(/\s+\n/gm, "\n");
+                }
+
+                const { commands, options } = modules;
+                file.write(template({
+                    name: config.name,
+                    autocommands: wrap("syn keyword " + config.name + "AutoEvent ",
+                                       keys(config.autocommands)),
+                    commands: wrap("syn keyword " + config.name + "Command ",
+                                  array(c.specs for (c in commands.iterator())).flatten()),
+                    options: wrap("syn keyword " + config.name + "Option ",
+                                  array(o.names for (o in options) if (o.type != "boolean")).flatten()),
+                    toggleoptions: wrap("let s:toggleOptions = [",
+                                        array(o.realNames for (o in options) if (o.type == "boolean"))
+                                            .flatten().map(String.quote),
+                                        ", ") + "]"
+                }));
+            }, {
+                argCount: "?",
+                bang: true,
+                completer: function (context) completion.file(context, true),
+                literal: 1
+            });
+
+        commands.add(["runt[ime]"],
+            "Source the specified file from each directory in 'runtimepath'",
+            function (args) { io.sourceFromRuntimePath(args, args.bang); },
+            {
+                argCount: "+",
+                bang: true,
+                completer: function (context) completion.runtime(context)
+            }
+        );
+
+        commands.add(["scrip[tnames]"],
+            "List all sourced script names",
+            function () {
+                modules.commandline.commandOutput(
+                    template.tabular(["<SNR>", "Filename"], ["text-align: right; padding-right: 1em;"],
+                        ([i + 1, file] for ([i, file] in Iterator(io._scriptNames)))));  // TODO: add colon and remove column titles for pedantic Vim compatibility?
+            },
+            { argCount: "0" });
+
+        commands.add(["so[urce]"],
+            "Read Ex commands from a file",
+            function (args) {
+                if (args.length > 1)
+                    dactyl.echoerr(_("io.oneFileAllowed"));
+                else
+                    io.source(args[0], { silent: args.bang });
+            }, {
+                argCount: "+", // FIXME: should be "1" but kludged for proper error message
+                bang: true,
+                completer: function (context) completion.file(context, true)
+            });
+
+        commands.add(["!", "run"],
+            "Run a command",
+            function (args) {
+                let arg = args[0] || "";
+
+                // :!! needs to be treated specially as the command parser sets the
+                // bang flag but removes the ! from arg
+                if (args.bang)
+                    arg = "!" + arg;
+
+                // NOTE: Vim doesn't replace ! preceded by 2 or more backslashes and documents it - desirable?
+                // pass through a raw bang when escaped or substitute the last command
+
+                // This is an asinine and irritating feature when we have searchable
+                // command-line history. --Kris
+                if (modules.options["banghist"]) {
+                    // replaceable bang and no previous command?
+                    dactyl.assert(!/((^|[^\\])(\\\\)*)!/.test(arg) || io._lastRunCommand,
+                        "E34: No previous command");
+
+                    arg = arg.replace(/(\\)*!/g,
+                        function (m) /^\\(\\\\)*!$/.test(m) ? m.replace("\\!", "!") : m.replace("!", io._lastRunCommand)
+                    );
+                }
+
+                io._lastRunCommand = arg;
+
+                let result = io.system(arg);
+                if (result.returnValue != 0)
+                    result.output += "\nshell returned " + result.returnValue;
+
+                modules.commandline.command = "!" + arg;
+                modules.commandline.commandOutput(<span highlight="CmdOutput">{result.output}</span>);
+
+                modules.autocommands.trigger("ShellCmdPost", {});
+            }, {
+                argCount: "?", // TODO: "1" - probably not worth supporting weird Vim edge cases. The dream is dead. --djk
+                bang: true,
+                // This is abominably slow.
+                // completer: function (context) completion.shellCommand(context),
+                literal: 0
+            });
+    },
+    completion: function (dactyl, modules, window) {
+        const { completion, io } = modules;
+
+        completion.charset = function (context) {
+            context.anchored = false;
+            context.keys = {
+                text: util.identity,
+                description: function (charset) {
+                    try {
+                        return services.charset.getCharsetTitle(charset);
+                    }
+                    catch (e) {
+                        return charset;
+                    }
+                }
+            };
+            context.generate = function () iter(services.charset.getDecoderList());
+        };
+
+        completion.directory = function directory(context, full) {
+            this.file(context, full);
+            context.filters.push(function (item) item.isdir);
+        };
+
+        completion.environment = function environment(context) {
+            context.title = ["Environment Variable", "Value"];
+            context.generate = function ()
+                io.system(util.OS.isWindows ? "set" : "env")
+                  .output.split("\n")
+                  .filter(function (line) line.indexOf("=") > 0)
+                  .map(function (line) line.match(/([^=]+)=(.*)/).slice(1));
+        };
+
+        completion.file = function file(context, full, dir) {
+            // dir == "" is expanded inside readDirectory to the current dir
+            function getDir(str) str.match(/^(?:.*[\/\\])?/)[0];
+            dir = getDir(dir || context.filter);
+
+            let file = util.getFile(dir);
+            if (file && (!file.exists() || !file.isDirectory()))
+                file = file.parent;
+
+            if (!full)
+                context.advance(dir.length);
+
+            context.title = [full ? "Path" : "Filename", "Type"];
+            context.keys = {
+                text: !full ? "leafName" : function (f) this.path,
+                path: function (f) dir + f.leafName,
+                description: function (f) this.isdir ? "Directory" : "File",
+                isdir: function (f) f.isDirectory(),
+                icon: function (f) this.isdir ? "resource://gre/res/html/folder.png"
+                                              : "moz-icon://" + f.leafName
+            };
+            context.compare = function (a, b) b.isdir - a.isdir || String.localeCompare(a.text, b.text);
+
+            if (modules.options["wildignore"]) {
+                let wig = modules.options.get("wildignore");
+                context.filters.push(function (item) item.isdir || !wig.getKey(this.name));
+            }
+
+            // context.background = true;
+            context.key = dir;
+            let uri = io.isJarURL(dir);
+            if (uri)
+                context.generate = function generate_jar() {
+                    return [
+                        {
+                              isDirectory: function () s.substr(-1) == "/",
+                              leafName: /([^\/]*)\/?$/.exec(s)[1]
+                        }
+                        for (s in io.listJar(uri.JARFile, getDir(uri.JAREntry)))]
+                };
+            else
+                context.generate = function generate_file() {
+                    try {
+                        return io.File(file || dir).readDirectory();
+                    }
+                    catch (e) {}
+                    return [];
+                };
+        };
+
+        completion.runtime = function (context) {
+            for (let [, dir] in Iterator(modules.options["runtimepath"]))
+                context.fork(dir, 0, this, function (context) {
+                    dir = dir.replace("/+$", "") + "/";
+                    completion.file(context, true, dir + context.filter);
+                    context.title[0] = dir;
+                    context.keys.text = function (f) this.path.substr(dir.length);
+                });
+        };
+
+        completion.shellCommand = function shellCommand(context) {
+            context.title = ["Shell Command", "Path"];
+            context.generate = function () {
+                let dirNames = services.environment.get("PATH").split(util.OS.isWindows ? ";" : ":");
+                let commands = [];
+
+                for (let [, dirName] in Iterator(dirNames)) {
+                    let dir = io.File(dirName);
+                    if (dir.exists() && dir.isDirectory())
+                        commands.push([[file.leafName, dir.path] for (file in iter(dir.directoryEntries))
+                                       if (file.isFile() && file.isExecutable())]);
+                }
+
+                return array.flatten(commands);
+            };
+        };
+
+        completion.addUrlCompleter("f", "Local files", function (context, full) {
+            let match = util.regexp(<![CDATA[
+                ^
+                (?P<prefix>
+                    (?P<proto>
+                        (?P<scheme> chrome|resource)
+                        :\/\/
+                    )
+                    [^\/]*
+                )
+                (?P<path> \/[^\/]* )?
+                $
+            ]]>, "x").exec(context.filter);
+            if (match) {
+                if (!match.path) {
+                    context.key = match.proto;
+                    context.advance(match.proto.length);
+                    context.generate = function () util.chromePackages.map(function (p) [p, match.proto + p + "/"]);
+                }
+                else if (match.scheme === "chrome") {
+                    context.key = match.prefix;
+                    context.advance(match.prefix.length + 1);
+                    context.generate = function () iter({
+                        content: "Chrome content",
+                        locale: "Locale-specific content",
+                        skin: "Theme-specific content"
+                    });
+                }
+            }
+            if (!match || match.scheme === "resource" && match.path)
+                if (/^(\.{0,2}|~)\/|^file:/.test(context.filter) || util.getFile(context.filter) || io.isJarURL(context.filter))
+                    completion.file(context, full);
+        });
+    },
+    javascript: function (dactyl, modules, window) {
+        modules.JavaScript.setCompleter([File, File.expandPath],
+            [function (context, obj, args) {
+                context.quote[2] = "";
+                modules.completion.file(context, true);
+            }]);
+
+    },
+    modes: function initModes(dactyl, modules, window) {
+        initModes.require("commandline");
+        const { modes } = modules;
+
+        modes.addMode("FILE_INPUT", {
+            extended: true,
+            description: "Active when selecting a file",
+            bases: [modes.COMMAND_LINE],
+            input: true
+        });
+    },
+    options: function (dactyl, modules, window) {
+        const { completion, options } = modules;
+
+        var shell, shellcmdflag;
+        if (util.OS.isWindows) {
+            shell = "cmd.exe";
+            shellcmdflag = "/c";
+        }
+        else {
+            shell = services.environment.get("SHELL") || "sh";
+            shellcmdflag = "-c";
+        }
+
+        options.add(["banghist", "bh"],
+            "Replace occurrences of ! with the previous command when executing external commands",
+            "boolean", true);
+
+        options.add(["fileencoding", "fenc"],
+            "The character encoding used when reading and writing files",
+            "string", "UTF-8", {
+                completer: function (context) completion.charset(context),
+                getter: function () File.defaultEncoding,
+                setter: function (value) (File.defaultEncoding = value)
+            });
+        options.add(["cdpath", "cd"],
+            "List of directories searched when executing :cd",
+            "stringlist", ["."].concat(services.environment.get("CDPATH").split(/[:;]/).filter(util.identity)).join(","),
+            {
+                get files() this.value.map(function (path) File(path, modules.io.cwd))
+                                .filter(function (dir) dir.exists()),
+                setter: function (value) File.expandPathList(value)
+            });
+
+        options.add(["runtimepath", "rtp"],
+            "List of directories searched for runtime files",
+            "stringlist", IO.runtimePath,
+            {
+                get files() this.value.map(function (path) File(path, modules.io.cwd))
+                                .filter(function (dir) dir.exists())
+            });
+
+        options.add(["shell", "sh"],
+            "Shell to use for executing external commands with :! and :run",
+            "string", shell,
+            { validator: function (val) io.pathSearch(val) });
+
+        options.add(["shellcmdflag", "shcf"],
+            "Flag passed to shell when executing external commands with :! and :run",
+            "string", shellcmdflag,
+            {
+                getter: function (value) {
+                    if (this.hasChanged || !util.OS.isWindows)
+                        return value;
+                    return /sh/.test(options["shell"]) ? "-c" : "/c";
+                }
+            });
+        options["shell"]; // Make sure it's loaded into global storage.
+        options["shellcmdflag"];
+
+        options.add(["wildignore", "wig"],
+            "List of file patterns to ignore when completing file names",
+            "regexplist", "");
+    }
+});
+
+endModule();
+
+} catch(e){ if (isString(e)) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/javascript.jsm b/common/modules/javascript.jsm
new file mode 100644 (file)
index 0000000..0f78615
--- /dev/null
@@ -0,0 +1,902 @@
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+let { getOwnPropertyNames } = Object;
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("javascript", {
+    exports: ["JavaScript", "javascript"],
+    use: ["services", "template", "util"]
+}, this);
+
+let isPrototypeOf = Object.prototype.isPrototypeOf;
+
+// TODO: Clean this up.
+
+var JavaScript = Module("javascript", {
+    init: function () {
+        this._stack = [];
+        this._functions = [];
+        this._top = {};  // The element on the top of the stack.
+        this._last = ""; // The last opening char pushed onto the stack.
+        this._lastNonwhite = ""; // Last non-whitespace character we saw.
+        this._lastChar = "";     // Last character we saw, used for \ escaping quotes.
+        this._str = "";
+
+        this._lastIdx = 0;
+
+        this._cacheKey = null;
+
+        this._nullSandbox = Cu.Sandbox("about:blank");
+    },
+
+    Local: function (dactyl, modules, window) ({
+        init: function init() {
+            this.modules = modules;
+            this.window = window;
+
+            init.supercall(this);
+        },
+    }),
+
+    globals: Class.memoize(function () [
+       [this.modules.userContext, "Global Variables"],
+       [this.modules, "modules"],
+       [this.window, "window"]
+    ]),
+
+    toplevel: Class.memoize(function () this.modules.jsmodules),
+
+    lazyInit: true,
+
+    newContext: function () this.modules.newContext(this.modules.userContext),
+
+    get completers() JavaScript.completers, // For backward compatibility
+
+    // Some object members are only accessible as function calls
+    getKey: function (obj, key) {
+        try {
+            return obj[key];
+        }
+        catch (e) {}
+        return undefined;
+    },
+
+    iter: function iter_(obj, toplevel) {
+        if (obj == null)
+            return;
+
+        let seen = isinstance(obj, ["Sandbox"]) ? set(JavaScript.magicalNames) : {};
+        let globals = values(toplevel && this.window === obj ? this.globalNames : []);
+
+        if (toplevel && isObject(obj) && "wrappedJSObject" in obj)
+            if (!set.add(seen, "wrappedJSObject"))
+                yield "wrappedJSObject";
+
+        for (let key in iter(globals, properties(obj, !toplevel, true)))
+            if (!set.add(seen, key))
+                yield key;
+
+        // Properties aren't visible in an XPCNativeWrapper until
+        // they're accessed.
+        for (let key in properties(this.getKey(obj, "wrappedJSObject"), !toplevel, true))
+            try {
+                if (key in obj && !set.has(seen, key))
+                    yield key;
+            }
+            catch (e) {}
+    },
+
+    objectKeys: function objectKeys(obj, toplevel) {
+        // Things we can dereference
+        if (!obj || ["object", "string", "function"].indexOf(typeof obj) === -1)
+            return [];
+        if (isinstance(obj, ["Sandbox"]) && !toplevel) // Temporary hack.
+            return [];
+        if (isPrototypeOf.call(this.toplevel, obj) && !toplevel)
+            return [];
+
+        let completions = [k for (k in this.iter(obj, toplevel))];
+        if (obj === this.modules) // Hack.
+            completions = completions.concat([k for (k in this.iter(this.modules.jsmodules, toplevel))]);
+        return completions;
+    },
+
+    evalled: function evalled(arg, key, tmp) {
+        let cache = this.context.cache.evalled;
+        let context = this.context.cache.evalContext;
+
+        if (!key)
+            key = arg;
+        if (key in cache)
+            return cache[key];
+
+        context[JavaScript.EVAL_TMP] = tmp;
+        context[JavaScript.EVAL_EXPORT] = function export_(obj) cache[key] = obj;
+        try {
+            if (tmp != null) // Temporary hack until bug 609949 is fixed.
+                this.modules.dactyl.userEval(JavaScript.EVAL_EXPORT + "(" + arg + ")", context, "[Command Line Completion]", 1);
+            else
+                cache[key] = this.modules.dactyl.userEval(arg, context, "[Command Line Completion]", 1);
+
+            return cache[key];
+        }
+        catch (e) {
+            util.reportError(e);
+            this.context.message = "Error: " + e;
+            return null;
+        }
+        finally {
+            delete context[JavaScript.EVAL_TMP];
+        }
+    },
+
+    // Get an element from the stack. If @frame is negative,
+    // count from the top of the stack, otherwise, the bottom.
+    // If @nth is provided, return the @mth value of element @type
+    // of the stack entry at @frame.
+    _get: function (frame, nth, type) {
+        let a = this._stack[frame >= 0 ? frame : this._stack.length + frame];
+        if (type != null)
+            a = a[type];
+        if (nth == null)
+            return a;
+        return a[a.length - nth - 1];
+    },
+
+    // Push and pop the stack, maintaining references to 'top' and 'last'.
+    _push: function push(arg) {
+        this._top = {
+            offset:     this._i,
+            char:       arg,
+            statements: [this._i],
+            dots:       [],
+            fullStatements: [],
+            comma:      [],
+            functions:  []
+        };
+        this._last = this._top.char;
+        this._stack.push(this._top);
+    },
+
+    _pop: function pop(arg) {
+        if (this._i == this.context.caret - 1)
+            this.context.highlight(this._top.offset, 1, "FIND");
+
+        if (this._top.char != arg) {
+            this.context.highlight(this._top.offset, this._i - this._top.offset, "SPELLCHECK");
+            throw Error("Invalid JS");
+        }
+
+        // The closing character of this stack frame will have pushed a new
+        // statement, leaving us with an empty statement. This doesn't matter,
+        // now, as we simply throw away the frame when we pop it, but it may later.
+        if (this._top.statements[this._top.statements.length - 1] == this._i)
+            this._top.statements.pop();
+        this._top = this._get(-2);
+        this._last = this._top.char;
+        return this._stack.pop();
+    },
+
+    _buildStack: function (filter) {
+        // Todo: Fix these one-letter variable names.
+        this._i = 0;
+        this._c = ""; // Current index and character, respectively.
+
+        // Reuse the old stack.
+        if (this._str && filter.substr(0, this._str.length) == this._str) {
+            this.context.highlight(0, 0, "FIND");
+            this._i = this._str.length;
+            if (this.popStatement)
+                this._top.statements.pop();
+        }
+        else {
+            this.context.highlight();
+            this._stack = [];
+            this._functions = [];
+            this._push("#root");
+        }
+
+        // Build a parse stack, discarding entries as opening characters
+        // match closing characters. The stack is walked from the top entry
+        // and down as many levels as it takes us to figure out what it is
+        // that we're completing.
+        this._str = filter;
+        let length = this._str.length;
+        for (; this._i < length; this._lastChar = this._c, this._i++) {
+            this._c = this._str[this._i];
+            if (/['"\/]/.test(this._last)) {
+                if (this._lastChar == "\\") { // Escape. Skip the next char, whatever it may be.
+                    this._c = "";
+                    this._i++;
+                }
+                else if (this._c == this._last)
+                    this._pop(this._c);
+            }
+            else {
+                // A word character following a non-word character, or simply a non-word
+                // character. Start a new statement.
+                if (/[a-zA-Z_$]/.test(this._c) && !/[\w$]/.test(this._lastChar) || !/[\w\s$]/.test(this._c))
+                    this._top.statements.push(this._i);
+
+                // A "." or a "[" dereferences the last "statement" and effectively
+                // joins it to this logical statement.
+                if ((this._c == "." || this._c == "[") && /[\w$\])"']/.test(this._lastNonwhite)
+                || this._lastNonwhite == "." && /[a-zA-Z_$]/.test(this._c))
+                        this._top.statements.pop();
+
+                switch (this._c) {
+                case "(":
+                    // Function call, or if/while/for/...
+                    if (/[\w$]/.test(this._lastNonwhite)) {
+                        this._functions.push(this._i);
+                        this._top.functions.push(this._i);
+                        this._top.statements.pop();
+                    }
+                case '"':
+                case "'":
+                case "/":
+                case "{":
+                case "[":
+                    this._push(this._c);
+                    break;
+                case ".":
+                    this._top.dots.push(this._i);
+                    break;
+                case ")": this._pop("("); break;
+                case "]": this._pop("["); break;
+                case "}": this._pop("{"); // Fallthrough
+                case ";":
+                    this._top.fullStatements.push(this._i);
+                    break;
+                case ",":
+                    this._top.comma.push(this._i);
+                    break;
+                }
+
+                if (/\S/.test(this._c))
+                    this._lastNonwhite = this._c;
+            }
+        }
+
+        this.popStatement = false;
+        if (!/[\w$]/.test(this._lastChar) && this._lastNonwhite != ".") {
+            this.popStatement = true;
+            this._top.statements.push(this._i);
+        }
+
+        this._lastIdx = this._i;
+    },
+
+    // Don't eval any function calls unless the user presses tab.
+    _checkFunction: function (start, end, key) {
+        let res = this._functions.some(function (idx) idx >= start && idx < end);
+        if (!res || this.context.tabPressed || key in this.cache.evalled)
+            return false;
+        this.context.waitingForTab = true;
+        return true;
+    },
+
+    // For each DOT in a statement, prefix it with TMP, eval it,
+    // and save the result back to TMP. The point of this is to
+    // cache the entire path through an object chain, mainly in
+    // the presence of function calls. There are drawbacks. For
+    // instance, if the value of a variable changes in the course
+    // of inputting a command (let foo=bar; frob(foo); foo=foo.bar; ...),
+    // we'll still use the old value. But, it's worth it.
+    _getObj: function (frame, stop) {
+        let statement = this._get(frame, 0, "statements") || 0; // Current statement.
+        let prev = statement;
+        let obj = this.window;
+        let cacheKey;
+        for (let [, dot] in Iterator(this._get(frame).dots.concat(stop))) {
+            if (dot < statement)
+                continue;
+            if (dot > stop || dot <= prev)
+                break;
+
+            let s = this._str.substring(prev, dot);
+            if (prev != statement)
+                s = JavaScript.EVAL_TMP + "." + s;
+            cacheKey = this._str.substring(statement, dot);
+
+            if (this._checkFunction(prev, dot, cacheKey))
+                return [];
+            if (prev != statement && obj == null) {
+                this.context.message = "Error: " + cacheKey.quote() + " is " + String(obj);
+                return [];
+            }
+
+            prev = dot + 1;
+            obj = this.evalled(s, cacheKey, obj);
+        }
+        return [[obj, cacheKey]];
+    },
+
+    _getObjKey: function (frame) {
+        let dot = this._get(frame, 0, "dots") || -1; // Last dot in frame.
+        let statement = this._get(frame, 0, "statements") || 0; // Current statement.
+        let end = (frame == -1 ? this._lastIdx : this._get(frame + 1).offset);
+
+        this._cacheKey = null;
+        let obj = [[this.cache.evalContext, "Local Variables"]].concat(this.globals);
+        // Is this an object dereference?
+        if (dot < statement) // No.
+            dot = statement - 1;
+        else // Yes. Set the object to the string before the dot.
+            obj = this._getObj(frame, dot);
+
+        let [, space, key] = this._str.substring(dot + 1, end).match(/^(\s*)(.*)/);
+        return [dot + 1 + space.length, obj, key];
+    },
+
+    _complete: function (objects, key, compl, string, last) {
+        const self = this;
+
+        if (!getOwnPropertyNames && !services.debugger.isOn && !this.context.message)
+            this.context.message = "For better completion data, please enable the JavaScript debugger (:set jsdebugger)";
+
+        let base = this.context.fork("js", this._top.offset);
+        base.forceAnchored = true;
+        base.filter = last == null ? key : string;
+        let prefix  = last != null ? key : "";
+
+        if (last == null) // We're not looking for a quoted string, so filter out anything that's not a valid identifier
+            base.filters.push(function (item) /^[a-zA-Z_$][\w$]*$/.test(item.text));
+        else {
+            base.quote = [last, function (text) util.escapeString(text, ""), last];
+            if (prefix)
+                base.filters.push(function (item) item.item.indexOf(prefix) === 0);
+        }
+
+        if (!compl) {
+            base.process[1] = function highlight(item, v)
+                template.highlight(typeof v == "xml" ? new String(v.toXMLString()) : v, true, 200);
+
+            // Sort in a logical fashion for object keys:
+            //  Numbers are sorted as numbers, rather than strings, and appear first.
+            //  Constants are unsorted, and appear before other non-null strings.
+            //  Other strings are sorted in the default manner.
+
+            let isnan = function isnan(item) item != '' && isNaN(item);
+            let compare = base.compare;
+
+            base.compare = function (a, b) {
+                if (!isnan(a.key) && !isnan(b.key))
+                    return a.key - b.key;
+                return isnan(b.key) - isnan(a.key) || compare(a, b);
+            };
+
+            base.keys = {
+                text: prefix ? function (text) text.substr(prefix.length) : util.identity,
+                description: function (item) self.getKey(this.obj, item),
+                key: function (item) {
+                    if (!isNaN(key))
+                        return parseInt(key);
+                     if (/^[A-Z_][A-Z0-9_]*$/.test(key))
+                        return "";
+                    return item;
+                }
+            };
+        }
+
+        // We've already listed anchored matches, so don't list them again here.
+        function unanchored(item) util.compareIgnoreCase(item.text.substr(0, this.filter.length), this.filter);
+
+        objects.forEach(function (obj) {
+            let context = base.fork(obj[1]);
+            context.title = [obj[1]];
+            context.keys.obj = function () obj[0];
+            context.key = obj[1] + last;
+            if (obj[0] == this.cache.evalContext)
+                context.regenerate = true;
+
+            obj.ctxt_t = context.fork("toplevel");
+            if (!compl) {
+                obj.ctxt_p = context.fork("prototypes");
+                obj.ctxt_t.generate = function () self.objectKeys(obj[0], true);
+                obj.ctxt_p.generate = function () self.objectKeys(obj[0], false);
+            }
+        }, this);
+
+        // TODO: Make this a generic completion helper function.
+        objects.forEach(function (obj) {
+            obj.ctxt_t.split(obj[1] + "/anchored", this, function (context) {
+                context.anchored = true;
+                if (compl)
+                    compl(context, obj[0]);
+            });
+        });
+
+        if (compl)
+            return;
+
+        objects.forEach(function (obj) {
+            obj.ctxt_p.split(obj[1] + "/anchored", this, function (context) {
+                context.anchored = true;
+                context.title[0] += " (prototypes)";
+            });
+        });
+
+        objects.forEach(function (obj) {
+            obj.ctxt_t.split(obj[1] + "/unanchored", this, function (context) {
+                context.anchored = false;
+                context.title[0] += " (substrings)";
+                context.filters.push(unanchored);
+            });
+        });
+
+        objects.forEach(function (obj) {
+            obj.ctxt_p.split(obj[1] + "/unanchored", this, function (context) {
+                context.anchored = false;
+                context.title[0] += " (prototype substrings)";
+                context.filters.push(unanchored);
+            });
+        });
+    },
+
+    _getKey: function () {
+        if (this._last == "")
+            return "";
+        // After the opening [ upto the opening ", plus '' to take care of any operators before it
+        let key = this._str.substring(this._get(-2, null, "offset") + 1, this._get(-1, null, "offset")) + "''";
+        // Now eval the key, to process any referenced variables.
+        return this.evalled(key);
+    },
+
+    get cache() this.context.cache,
+
+    complete: function _complete(context) {
+        const self = this;
+        this.context = context;
+
+        try {
+            this._buildStack.call(this, context.filter);
+        }
+        catch (e) {
+            this._lastIdx = 0;
+            util.assert(!e.message, e.message);
+            return null;
+        }
+
+        this.context.getCache("evalled", Object);
+        this.context.getCache("evalContext", this.closure.newContext);
+
+        // Okay, have parse stack. Figure out what we're completing.
+
+        // Find any complete statements that we can eval before we eval our object.
+        // This allows for things like:
+        //   let doc = content.document; let elem = doc.createEle<Tab> ...
+        let prev = 0;
+        for (let [, v] in Iterator(this._get(0).fullStatements)) {
+            let key = this._str.substring(prev, v + 1);
+            if (this._checkFunction(prev, v, key))
+                return null;
+            this.evalled(key);
+            prev = v + 1;
+        }
+
+        // If this is a function argument, try to get the function's
+        // prototype and show it.
+        try {
+            let i = (this._get(-2) && this._get(-2).char == "(") ? -2 : -1;
+            if (this._get(i).char == "(") {
+                let [offset, obj, funcName] = this._getObjKey(i - 1);
+                if (obj.length) {
+                    let func = obj[0][0][funcName];
+                    if (callable(func)) {
+                        let [, prefix, args] = /^(function .*?)\((.*?)\)/.exec(Function.prototype.toString.call(func));
+                        let n = this._get(i).comma.length;
+                        args = template.map(Iterator(args.split(", ")),
+                            function ([i, arg]) <span highlight={i == n ? "Filter" : ""}>{arg}</span>,
+                            <>,&#xa0;</>);
+                        this.context.message = <>{prefix}({args})</>;
+                    }
+                }
+            }
+        }
+        catch (e) {}
+
+        // In a string. Check if we're dereferencing an object or
+        // completing a function argument. Otherwise, do nothing.
+        if (this._last == "'" || this._last == '"') {
+
+            // str = "foo[bar + 'baz"
+            // obj = "foo"
+            // key = "bar + ''"
+
+            // The top of the stack is the sting we're completing.
+            // Wrap it in its delimiters and eval it to process escape sequences.
+            let string = this._str.substring(this._get(-1).offset + 1, this._lastIdx).replace(/((?:\\\\)*)\\/, "$1");
+            string = Cu.evalInSandbox(this._last + string + this._last, this._nullSandbox);
+
+            // Is this an object accessor?
+            if (this._get(-2).char == "[") { // Are we inside of []?
+                // Stack:
+                //  [-1]: "...
+                //  [-2]: [...
+                //  [-3]: base statement
+
+                // Yes. If the [ starts at the beginning of a logical
+                // statement, we're in an array literal, and we're done.
+                if (this._get(-3, 0, "statements") == this._get(-2).offset)
+                    return null;
+
+                // Beginning of the statement upto the opening [
+                let obj = this._getObj(-3, this._get(-2).offset);
+
+                return this._complete(obj, this._getKey(), null, string, this._last);
+            }
+
+            // Is this a function call?
+            if (this._get(-2).char == "(") {
+                // Stack:
+                //  [-1]: "...
+                //  [-2]: (...
+                //  [-3]: base statement
+
+                // Does the opening "(" mark a function call?
+                if (this._get(-3, 0, "functions") != this._get(-2).offset)
+                    return null; // No. We're done.
+
+                let [offset, obj, funcName] = this._getObjKey(-3);
+                if (!obj.length)
+                    return null;
+                obj = obj.slice(0, 1);
+
+                try {
+                    let func = obj[0][0][funcName];
+                    var completer = func.dactylCompleter;
+                }
+                catch (e) {}
+                if (!completer)
+                    completer = JavaScript.completers[funcName];
+                if (!completer)
+                    return null;
+
+                // Split up the arguments
+                let prev = this._get(-2).offset;
+                let args = [];
+                for (let [i, idx] in Iterator(this._get(-2).comma)) {
+                    let arg = this._str.substring(prev + 1, idx);
+                    prev = idx;
+                    memoize(args, i, function () self.evalled(arg));
+                }
+                let key = this._getKey();
+                args.push(key + string);
+
+                let compl = function (context, obj) {
+                    let res = completer.call(self, context, funcName, obj, args);
+                    if (res)
+                        context.completions = res;
+                };
+
+                obj[0][1] += "." + funcName + "(... [" + args.length + "]";
+                return this._complete(obj, key, compl, string, this._last);
+            }
+
+            // In a string that's not an obj key or a function arg.
+            // Nothing to do.
+            return null;
+        }
+
+        // str = "foo.bar.baz"
+        // obj = "foo.bar"
+        // key = "baz"
+        //
+        // str = "foo"
+        // obj = [modules, window]
+        // key = "foo"
+
+        let [offset, obj, key] = this._getObjKey(-1);
+
+        // Wait for a keypress before completing when there's no key
+        if (!this.context.tabPressed && key == "" && obj.length > 1) {
+            this.context.waitingForTab = true;
+            this.context.message = "Waiting for key press";
+            return null;
+        }
+
+        if (!/^(?:[a-zA-Z_$][\w$]*)?$/.test(key))
+            return null; // Not a word. Forget it. Can this even happen?
+
+        try { // FIXME
+            var o = this._top.offset;
+            this._top.offset = offset;
+            return this._complete(obj, key);
+        }
+        finally {
+            this._top.offset = o;
+        }
+        return null;
+    },
+
+    magicalNames: Class.memoize(function () Object.getOwnPropertyNames(Cu.Sandbox(this.window), true).sort()),
+
+    /**
+     * A list of properties of the global object which are not
+     * enumerable by any standard method.
+     */
+    globalNames: Class.memoize(function () let (self = this) array.uniq([
+        "Array", "ArrayBuffer", "AttributeName", "Boolean", "Components",
+        "CSSFontFaceStyleDecl", "CSSGroupRuleRuleList", "CSSNameSpaceRule",
+        "CSSRGBColor", "CSSRect", "ComputedCSSStyleDeclaration", "Date",
+        "Error", "EvalError", "Float32Array", "Float64Array", "Function",
+        "HTMLDelElement", "HTMLInsElement", "HTMLSpanElement", "Infinity",
+        "InnerModalContentWindow", "InnerWindow", "Int16Array", "Int32Array",
+        "Int8Array", "InternalError", "Iterator", "JSON", "KeyboardEvent",
+        "Math", "NaN", "Namespace", "Number", "Object", "Proxy", "QName",
+        "ROCSSPrimitiveValue", "RangeError", "ReferenceError", "RegExp",
+        "StopIteration", "String", "SyntaxError", "TypeError", "URIError",
+        "Uint16Array", "Uint32Array", "Uint8Array", "XML",
+        "XMLHttpProgressEvent", "XMLList", "XMLSerializer", "XPCNativeWrapper",
+        "XPCSafeJSWrapper", "XULControllers", "constructor", "decodeURI",
+        "decodeURIComponent", "encodeURI", "encodeURIComponent", "escape",
+        "eval", "isFinite", "isNaN", "isXMLName", "parseFloat", "parseInt",
+        "undefined", "unescape", "uneval"
+    ].concat([k.substr(6) for (k in keys(Ci)) if (/^nsIDOM/.test(k))])
+     .concat([k.substr(3) for (k in keys(Ci)) if (/^nsI/.test(k))])
+     .concat(this.magicalNames)
+     .filter(function (k) k in self.window))),
+
+}, {
+    EVAL_TMP: "__dactyl_eval_tmp",
+    EVAL_EXPORT: "__dactyl_eval_export",
+
+    /**
+     * A map of argument completion functions for named methods. The
+     * signature and specification of the completion function
+     * are fairly complex and yet undocumented.
+     *
+     * @see JavaScript.setCompleter
+     */
+    completers: {},
+
+    /**
+     * Installs argument string completers for a set of functions.
+     * The second argument is an array of functions (or null
+     * values), each corresponding the argument of the same index.
+     * Each provided completion function receives as arguments a
+     * CompletionContext, the 'this' object of the method, and an
+     * array of values for the preceding arguments.
+     *
+     * It is important to note that values in the arguments array
+     * provided to the completers are lazily evaluated the first
+     * time they are accessed, so they should be accessed
+     * judiciously.
+     *
+     * @param {function|function[]} funcs The functions for which to
+     *      install the completers.
+     * @param {function[]} completers An array of completer
+     *      functions.
+     */
+    setCompleter: function (funcs, completers) {
+        funcs = Array.concat(funcs);
+        for (let [, func] in Iterator(funcs)) {
+            func.dactylCompleter = function (context, func, obj, args) {
+                let completer = completers[args.length - 1];
+                if (!completer)
+                    return [];
+                return completer.call(obj, context, obj, args);
+            };
+        }
+        return arguments[0];
+    }
+}, {
+    init: function init(dactyl, modules, window) {
+        init.superapply(this, arguments);
+        modules.JavaScript = Class("JavaScript", JavaScript, { modules: modules, window: window });
+    },
+    completion: function (dactyl, modules, window) {
+        const { completion } = modules;
+        update(modules.completion, {
+            get javascript() modules.javascript.closure.complete,
+            javascriptCompleter: JavaScript // Backwards compatibility
+        });
+    },
+    modes: function initModes(dactyl, modules, window) {
+        initModes.require("commandline");
+        const { modes } = modules;
+
+        modes.addMode("REPL", {
+            description: "JavaScript Read Eval Print Loop",
+            bases: [modes.COMMAND_LINE]
+        });
+    },
+    commandline: function initCommandLine(dactyl, modules, window) {
+        const { Buffer, modes } = modules;
+
+        var REPL = Class("REPL", {
+            init: function init(context) {
+                this.context = context;
+                this.results = [];
+            },
+
+            addOutput: function addOutput(js) {
+                default xml namespace = XHTML;
+                this.count++;
+
+                try {
+                    var result = dactyl.userEval(js, this.context);
+                    var xml = util.objectToString(result, true);
+                }
+                catch (e) {
+                    util.reportError(e);
+                    result = e;
+
+                    if (e.fileName)
+                        e = util.fixURI(e.fileName) + ":" + e.lineNumber + ": " + e;
+                    xml = <span highlight="ErrorMsg">{e}</span>;
+                }
+
+                let prompt = "js" + this.count;
+                Class.replaceProperty(this.context, prompt, result);
+
+                XML.ignoreWhitespace = XML.prettyPrinting = false;
+                let nodes = {};
+                this.rootNode.appendChild(
+                    util.xmlToDom(<e4x>
+                        <div highlight="REPL-E" key="e"><span highlight="REPL-R">{prompt}></span> {js}</div>
+                        <div highlight="REPL-P" key="p">{xml}</div>
+                    </e4x>.elements(), this.document, nodes));
+
+                this.rootNode.scrollTop += nodes.e.getBoundingClientRect().top
+                                         - this.rootNode.getBoundingClientRect().top;
+            },
+
+            count: 0,
+
+            message: Class.memoize(function () {
+                default xml namespace = XHTML;
+                util.xmlToDom(<div highlight="REPL" key="rootNode"/>,
+                              this.document, this);
+
+                return this.rootNode;
+            }),
+
+            __noSuchMethod__: function (meth, args) Buffer[meth].apply(Buffer, [this.rootNode].concat(args))
+        });
+
+        modules.CommandREPLMode = Class("CommandREPLMode", modules.CommandMode, {
+            init: function init(context) {
+                init.supercall(this);
+
+                let self = this;
+                let sandbox = isinstance(context, ["Sandbox"]);
+
+                this.context = modules.newContext(context, !sandbox);
+                this.js = modules.JavaScript();
+                this.js.replContext = this.context;
+                this.js.newContext = function newContext() modules.newContext(self.context, !sandbox);
+
+                this.js.globals = [
+                   [this.context, "REPL Variables"],
+                   [context, "REPL Global"]
+                ].concat(this.js.globals.filter(function ([global]) isPrototypeOf.call(global, context)));
+
+                if (!isPrototypeOf.call(modules.jsmodules, context))
+                    this.js.toplevel = context;
+
+                if (!isPrototypeOf.call(window, context))
+                    this.js.window = context;
+
+                if (this.js.globals.slice(2).some(function ([global]) global === context))
+                    this.js.globals.splice(1);
+
+                this.repl = REPL(this.context);
+            },
+            open: function open(context) {
+                this.updatePrompt();
+
+                modules.mow.echo(this.repl);
+                this.widgets.message = null;
+
+                open.superapply(this, arguments);
+            },
+
+            complete: function complete(context) {
+                context.fork("js", 0, this.js, "complete");
+            },
+
+            historyKey: "javascript",
+
+            mode: modes.REPL,
+
+            accept: function accept() {
+                dactyl.trapErrors(function () { this.repl.addOutput(this.command) }, this);
+
+                this.completions.cleanup();
+                this.history.save();
+                this.history.reset();
+                this.updatePrompt();
+
+                modules.mow.resize();
+            },
+
+            leave: function leave(params) {
+                leave.superapply(this, arguments);
+                if (!params.push)
+                    modes.delay(function () { modes.pop(); });
+            },
+
+            updatePrompt: function updatePrompt() {
+                this.command = "";
+                this.prompt = ["REPL-R", "js" + (this.repl.count + 1) + "> "];
+            }
+        });
+    },
+    commands: function initCommands(dactyl, modules, window) {
+        const { commands } = modules;
+
+        commands.add(["javas[cript]", "js"],
+            "Evaluate a JavaScript string",
+            function (args) {
+                modules.commandline;
+
+                if (args[0] && !args.bang)
+                    dactyl.userEval(args[0]);
+                else {
+                    modules.commandline;
+                    modules.CommandREPLMode(args[0] ? dactyl.userEval(args[0]) : modules.userContext)
+                           .open();
+                }
+            }, {
+                bang: true,
+                completer: function (context) modules.completion.javascript(context),
+                hereDoc: true,
+                literal: 0
+            });
+    },
+    mappings: function initMappings(dactyl, modules, window) {
+        const { mappings, modes } = modules;
+
+        function bind() mappings.add.apply(mappings,
+                                           [[modes.REPL]].concat(Array.slice(arguments)))
+
+        bind(["<Return>"], "Accept the current input",
+             function ({ self }) { self.accept(); });
+
+        bind(["<C-e>"], "Scroll down one line",
+             function ({ self }) { self.repl.scrollVertical("lines", 1); });
+
+        bind(["<C-y>"], "Scroll up one line",
+             function ({ self }) { self.repl.scrollVertical("lines", -1); });
+
+        bind(["<C-d>"], "Scroll down half a page",
+             function ({ self }) { self.repl.scrollVertical("pages", .5); });
+
+        bind(["<C-f>", "<PageDown>"], "Scroll down one page",
+             function ({ self }) { self.repl.scrollVertical("pages", 1); });
+
+        bind(["<C-u>"], "Scroll up half a page",
+             function ({ self }) { self.repl.scrollVertical("pages", -.5); });
+
+        bind(["<C-b>", "<PageUp>"], "Scroll up half a page",
+             function ({ self }) { self.repl.scrollVertical("pages", -1); });
+    },
+    options: function (dactyl, modules, window) {
+        modules.options.add(["jsdebugger", "jsd"],
+            "Enable the JavaScript debugger service for use in JavaScript completion",
+            "boolean", false, {
+                setter: function (value) {
+                    if (services.debugger.isOn != value)
+                        if (value)
+                            (services.debugger.asyncOn || services.debugger.on)(null);
+                        else
+                            services.debugger.off();
+                },
+                getter: function () services.debugger.isOn
+            });
+    }
+});
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/messages.jsm b/common/modules/messages.jsm
new file mode 100644 (file)
index 0000000..4b6e478
--- /dev/null
@@ -0,0 +1,148 @@
+// Copyright (c) 2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("messages", {
+    exports: ["Messages", "messages", "_"],
+    require: ["services", "util"]
+}, this);
+
+// TODO: Lazy instantiation
+var Messages = Module("messages", {
+
+    init: function init(name) {
+        let self = this;
+        name = name || "messages";
+
+        this.bundles = array.uniq([JSMLoader.getTarget("dactyl://locale/" + name + ".properties"),
+                                   JSMLoader.getTarget("dactyl://locale-local/" + name + ".properties"),
+                                   "resource://dactyl-locale/en-US/" + name + ".properties",
+                                   "resource://dactyl-locale-local/en-US/" + name + ".properties"])
+                            .map(services.stringBundle.createBundle)
+                            .filter(function (bundle) { try { bundle.getSimpleEnumeration(); return true; } catch (e) { return false; } });
+
+        this._ = Class("_", String, {
+            init: function _(message) {
+                this.args = arguments;
+            },
+            message: Class.memoize(function () {
+                let message = this.args[0];
+
+                if (this.args.length > 1) {
+                    let args = Array.slice(this.args, 1);
+                    return self.format(message + "-" + args.length, args, null) || self.format(message, args);
+                }
+                return self.get(message);
+            }),
+            valueOf: function valueOf() this.message,
+            toString: function toString() this.message
+        });
+
+        let seen = {};
+        for (let { key } in this.iterate()) {
+            if (!set.add(seen, key))
+                this._[key] = this[key] = {
+                    __noSuchMethod__: function __(prop, args) self._.apply(self, [prop].concat(args))
+                };
+        }
+    },
+
+    iterate: function () let (bundle = this.bundles[0])
+        iter(prop.QueryInterface(Ci.nsIPropertyElement) for (prop in iter(bundle.getSimpleEnumeration()))),
+
+    cleanup: function cleanup() {
+        services.stringBundle.flushBundles();
+    },
+
+    get: function get(value, default_) {
+        for (let bundle in values(this.bundles))
+            try {
+                return bundle.GetStringFromName(value);
+            }
+            catch (e) {}
+
+        // Report error so tests fail, but don't throw
+        if (arguments.length < 2)
+            util.reportError(Error("Invalid locale string: " + value));
+        return arguments.length > 1 ? default_ : value;
+    },
+
+    format: function format(value, args, default_) {
+        for (let bundle in values(this.bundles))
+            try {
+                return bundle.formatStringFromName(value, args, args.length);
+            }
+            catch (e) {}
+
+        // Report error so tests fail, but don't throw
+        if (arguments.length < 3)
+            util.reportError(Error("Invalid locale string: " + value));
+        return arguments.length > 2 ? default_ : value;
+    }
+
+}, {
+    Localized: Class("Localized", Class.Property, {
+        init: function init(prop, obj) {
+            let _prop = "localized_" + prop;
+            if (this.initialized) {
+                /*
+                if (config.locale === "en-US")
+                    return { configurable: true, enumerable: true, value: null, writable: true };
+                */
+
+                obj[_prop] = this.default;
+                return {
+                    get: function get() {
+                        let self = this;
+                        let value = this[_prop];
+
+                        function getter(key, default_) function getter() messages.get([name, key].join("."), default_);
+
+                        let name = [this.constructor.className.toLowerCase(), this.identifier || this.name, prop].join(".");
+                        if (!isObject(value))
+                            value = messages.get(name, value)
+                        else if (isArray(value))
+                            // Deprecated
+                            iter(value).forEach(function ([k, v]) {
+                                if (isArray(v))
+                                    memoize(v, 1, getter(v[0], v[1]));
+                                else
+                                    memoize(value, k, getter(k, v));
+                            });
+                        else
+                            iter(value).forEach(function ([k, v]) {
+                                memoize(value, k, function () messages.get([name, k].join("."), v));
+                            });
+
+                        return Class.replaceProperty(this, prop, value);
+                    },
+                    set: function set(val) this[_prop] = val
+                }
+            }
+            this.default = prop;
+            this.initialized = true;
+        }
+    })
+}, {
+    javascript: function initJavascript(dactyl, modules, window) {
+        modules.JavaScript.setCompleter([this._, this.get, this.format], [
+            function (context) {
+                context.keys = { text: "key", description: "value" };
+                return messages.iterate();
+            }
+        ]);
+    }
+});
+
+var { _ } = messages;
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/options.jsm b/common/modules/options.jsm
new file mode 100644 (file)
index 0000000..1c61a40
--- /dev/null
@@ -0,0 +1,1444 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("options", {
+    exports: ["Option", "Options", "ValueError", "options"],
+    require: ["messages", "storage"],
+    use: ["commands", "completion", "prefs", "services", "styles", "template", "util"]
+}, this);
+
+/** @scope modules */
+
+let ValueError = Class("ValueError", ErrorBase);
+
+// do NOT create instances of this class yourself, use the helper method
+// options.add() instead
+/**
+ * A class representing configuration options. Instances are created by the
+ * {@link Options} class.
+ *
+ * @param {string[]} names The names by which this option is identified.
+ * @param {string} description A short one line description of the option.
+ * @param {string} type The option's value data type (see {@link Option#type}).
+ * @param {string} defaultValue The default value for this option.
+ * @param {Object} extraInfo An optional extra configuration hash. The
+ *     following properties are supported.
+ *         completer   - see {@link Option#completer}
+ *         domains     - see {@link Option#domains}
+ *         getter      - see {@link Option#getter}
+ *         initialValue - Initial value is loaded from getter
+ *         persist     - see {@link Option#persist}
+ *         privateData - see {@link Option#privateData}
+ *         scope       - see {@link Option#scope}
+ *         setter      - see {@link Option#setter}
+ *         validator   - see {@link Option#validator}
+ * @optional
+ * @private
+ */
+var Option = Class("Option", {
+    init: function init(names, description, type, defaultValue, extraInfo) {
+        this.name = names[0];
+        this.names = names;
+        this.realNames = names;
+        this.type = type;
+        this.description = description;
+
+        if (this.type in Option.getKey)
+            this.getKey = Option.getKey[this.type];
+
+        if (this.type in Option.parse)
+            this.parse = Option.parse[this.type];
+
+        if (this.type in Option.stringify)
+            this.stringify = Option.stringify[this.type];
+
+        if (this.type in Option.domains)
+            this.domains = Option.domains[this.type];
+
+        if (this.type in Option.testValues)
+            this.testValues = Option.testValues[this.type];
+
+        this._op = Option.ops[this.type];
+
+        // Need to trigger setter
+        if (extraInfo && "values" in extraInfo && !extraInfo.__lookupGetter__("values")) {
+            this.values = extraInfo.values;
+            delete extraInfo.values;
+        }
+
+        if (extraInfo)
+            update(this, extraInfo);
+
+        if (set.has(this.modules.config.defaults, this.name))
+            defaultValue = this.modules.config.defaults[this.name];
+
+        if (defaultValue !== undefined) {
+            if (this.type == "string")
+                defaultValue = Commands.quote(defaultValue);
+
+            if (isObject(defaultValue))
+                defaultValue = iter(defaultValue).map(function (val) val.map(Option.quote).join(":")).join(",");
+
+            if (isArray(defaultValue))
+                defaultValue = defaultValue.map(Option.quote).join(",");
+
+            this.defaultValue = this.parse(defaultValue);
+        }
+
+        // add no{option} variant of boolean {option} to this.names
+        if (this.type == "boolean")
+            this.names = array([name, "no" + name] for (name in values(names))).flatten().array;
+
+        if (this.globalValue == undefined && !this.initialValue)
+            this.globalValue = this.defaultValue;
+    },
+
+    /**
+     * @property {string} This option's description, as shown in :listoptions.
+     */
+    description: Messages.Localized(""),
+
+    get helpTag() "'" + this.name + "'",
+
+    initValue: function initValue() {
+        util.trapErrors(function () this.value = this.value, this);
+    },
+
+    get isDefault() this.stringValue === this.stringDefaultValue,
+
+    /** @property {value} The option's global value. @see #scope */
+    get globalValue() { try { return options.store.get(this.name, {}).value; } catch (e) { util.reportError(e); throw e; } },
+    set globalValue(val) { options.store.set(this.name, { value: val, time: Date.now() }); },
+
+    /**
+     * Returns *value* as an array of parsed values if the option type is
+     * "charlist" or "stringlist" or else unchanged.
+     *
+     * @param {value} value The option value.
+     * @returns {value|string[]}
+     */
+    parse: function parse(value) Option.dequote(value),
+
+    /**
+     * Returns *values* packed in the appropriate format for the option type.
+     *
+     * @param {value|string[]} values The option value.
+     * @returns {value}
+     */
+    stringify: function stringify(vals) Commands.quote(vals),
+
+    /**
+     * Returns the option's value as an array of parsed values if the option
+     * type is "charlist" or "stringlist" or else the simple value.
+     *
+     * @param {number} scope The scope to return these values from (see
+     *     {@link Option#scope}).
+     * @returns {value|string[]}
+     */
+    get: function get(scope) {
+        if (scope) {
+            if ((scope & this.scope) == 0) // option doesn't exist in this scope
+                return null;
+        }
+        else
+            scope = this.scope;
+
+        let values;
+
+        /*
+        if (config.has("tabs") && (scope & Option.SCOPE_LOCAL))
+            values = tabs.options[this.name];
+         */
+        if ((scope & Option.SCOPE_GLOBAL) && (values == undefined))
+            values = this.globalValue;
+
+        if (this.getter)
+            return util.trapErrors(this.getter, this, values);
+
+        return values;
+    },
+
+    /**
+     * Sets the option's value from an array of values if the option type is
+     * "charlist" or "stringlist" or else the simple value.
+     *
+     * @param {number} scope The scope to apply these values to (see
+     *     {@link Option#scope}).
+     */
+    set: function set(newValues, scope, skipGlobal) {
+        scope = scope || this.scope;
+        if ((scope & this.scope) == 0) // option doesn't exist in this scope
+            return;
+
+        if (this.setter)
+            newValues = this.setter(newValues);
+        if (newValues === undefined)
+            return;
+
+        /*
+        if (config.has("tabs") && (scope & Option.SCOPE_LOCAL))
+            tabs.options[this.name] = newValues;
+        */
+        if ((scope & Option.SCOPE_GLOBAL) && !skipGlobal)
+            this.globalValue = newValues;
+
+        this.hasChanged = true;
+        this.setFrom = null;
+
+        // dactyl.triggerObserver("options." + this.name, newValues);
+    },
+
+    getValues: deprecated("Option#get", "get"),
+    setValues: deprecated("Option#set", "set"),
+    joinValues: deprecated("Option#stringify", "stringify"),
+    parseValues: deprecated("Option#parse", "parse"),
+
+    /**
+     * @property {value} The option's current value. The option's local value,
+     *     or if no local value is set, this is equal to the
+     *     (@link #globalValue).
+     */
+    get value() this.get(),
+    set value(val) this.set(val),
+
+    get stringValue() this.stringify(this.value),
+    set stringValue(value) this.value = this.parse(value),
+
+    get stringDefaultValue() this.stringify(this.defaultValue),
+
+    getKey: function getKey(key) undefined,
+
+    /**
+     * Returns whether the option value contains one or more of the specified
+     * arguments.
+     *
+     * @returns {boolean}
+     */
+    has: function has() Array.some(arguments, function (val) this.value.indexOf(val) >= 0, this),
+
+    /**
+     * Returns whether this option is identified by *name*.
+     *
+     * @param {string} name
+     * @returns {boolean}
+     */
+    hasName: function hasName(name) this.names.indexOf(name) >= 0,
+
+    /**
+     * Returns whether the specified *values* are valid for this option.
+     * @see Option#validator
+     */
+    isValidValue: function isValidValue(values) this.validator(values),
+
+    invalidArgument: function invalidArgument(arg, op) _("error.invalidArgument",
+        this.name + (op || "").replace(/=?$/, "=") + arg),
+
+    /**
+     * Resets the option to its default value.
+     */
+    reset: function reset() {
+        this.value = this.defaultValue;
+    },
+
+    /**
+     * Sets the option's value using the specified set *operator*.
+     *
+     * @param {string} operator The set operator.
+     * @param {value|string[]} values The value (or values) to apply.
+     * @param {number} scope The scope to apply this value to (see
+     *     {@link #scope}).
+     * @param {boolean} invert Whether this is an invert boolean operation.
+     */
+    op: function op(operator, values, scope, invert, str) {
+
+        try {
+            var newValues = this._op(operator, values, scope, invert);
+            if (newValues == null)
+                return "Operator " + operator + " not supported for option type " + this.type;
+
+            if (!this.isValidValue(newValues))
+                return this.invalidArgument(str || this.stringify(values), operator);
+
+            this.set(newValues, scope);
+        }
+        catch (e) {
+            if (!(e instanceof ValueError))
+                util.reportError(e);
+            return this.invalidArgument(str || this.stringify(values), operator) + ": " + e.message;
+        }
+        return null;
+    },
+
+    // Properties {{{2
+
+    /** @property {string} The option's canonical name. */
+    name: null,
+    /** @property {string[]} All names by which this option is identified. */
+    names: null,
+
+    /**
+     * @property {string} The option's data type. One of:
+     *     "boolean"    - Boolean, e.g., true
+     *     "number"     - Integer, e.g., 1
+     *     "string"     - String, e.g., "Pentadactyl"
+     *     "charlist"   - Character list, e.g., "rb"
+     *     "regexplist" - Regexp list, e.g., "^foo,bar$"
+     *     "stringmap"  - String map, e.g., "key:v,foo:bar"
+     *     "regexpmap"  - Regexp map, e.g., "^key:v,foo$:bar"
+     */
+    type: null,
+
+    /**
+     * @property {number} The scope of the option. This can be local, global,
+     *     or both.
+     * @see Option#SCOPE_LOCAL
+     * @see Option#SCOPE_GLOBAL
+     * @see Option#SCOPE_BOTH
+     */
+    scope: 1, // Option.SCOPE_GLOBAL // XXX set to BOTH by default someday? - kstep
+
+    cleanupValue: null,
+
+    /**
+     * @property {function(CompletionContext, Args)} This option's completer.
+     * @see CompletionContext
+     */
+    completer: function completer(context) {
+        if (this.values)
+            context.completions = this.values;
+    },
+
+    /**
+     * @property {[[string, string]]} This option's possible values.
+     * @see CompletionContext
+     */
+    values: Messages.Localized(null),
+
+    /**
+     * @property {function(host, values)} A function which should return a list
+     *     of domains referenced in the given values. Used in determining whether
+     *     to purge the command from history when clearing private data.
+     * @see Command#domains
+     */
+    domains: null,
+
+    /**
+     * @property {function(host, values)} A function which should strip
+     *     references to a given domain from the given values.
+     */
+    filterDomain: function filterDomain(host, values)
+        Array.filter(values, function (val) !this.domains([val]).some(function (val) util.isSubdomain(val, host)), this),
+
+    /**
+     * @property {value} The option's default value. This value will be used
+     *     unless the option is explicitly set either interactively or in an RC
+     *     file or plugin.
+     */
+    defaultValue: null,
+
+    /**
+     * @property {function} The function called when the option value is read.
+     */
+    getter: null,
+
+    /**
+     * @property {boolean} When true, this options values will be saved
+     *     when generating a configuration file.
+     * @default true
+     */
+    persist: true,
+
+    /**
+     * @property {boolean|function(values)} When true, values of this
+     *     option may contain private data which should be purged from
+     *     saved histories when clearing private data. If a function, it
+     *     should return true if an invocation with the given values
+     *     contains private data
+     */
+    privateData: false,
+
+    /**
+     * @property {function} The function called when the option value is set.
+     */
+    setter: null,
+
+    testValues: function testValues(values, validator) validator(values),
+
+    /**
+     * @property {function} The function called to validate the option's value
+     *     when set.
+     */
+    validator: function validator() {
+        if (this.values || this.completer !== Option.prototype.completer)
+            return Option.validateCompleter.apply(this, arguments);
+        return true;
+    },
+
+    /**
+     * @property {boolean} Set to true whenever the option is first set. This
+     *     is useful to see whether it was changed from its default value
+     *     interactively or by some RC file.
+     */
+    hasChanged: false,
+
+    /**
+     * Returns the timestamp when the option's value was last changed.
+     */
+    get lastSet() options.store.get(this.name).time,
+    set lastSet(val) { options.store.set(this.name, { value: this.globalValue, time: Date.now() }); },
+
+    /**
+     * @property {nsIFile} The script in which this option was last set. null
+     *     implies an interactive command.
+     */
+    setFrom: null
+
+}, {
+    /**
+     * @property {number} Global option scope.
+     * @final
+     */
+    SCOPE_GLOBAL: 1,
+
+    /**
+     * @property {number} Local option scope. Options in this scope only
+     *     apply to the current tab/buffer.
+     * @final
+     */
+    SCOPE_LOCAL: 2,
+
+    /**
+     * @property {number} Both local and global option scope.
+     * @final
+     */
+    SCOPE_BOTH: 3,
+
+    has: {
+        toggleAll: function toggleAll() toggleAll.supercall(this, "all") ^ !!toggleAll.superapply(this, arguments),
+    },
+
+    parseRegexp: function parseRegexp(value, result, flags) {
+        let keepQuotes = this && this.keepQuotes;
+        if (isArray(flags)) // Called by Array.map
+            result = flags = undefined;
+
+        if (flags == null)
+            flags = this && this.regexpFlags || "";
+
+        let [, bang, val] = /^(!?)(.*)/.exec(value);
+        let re = util.regexp(Option.dequote(val), flags);
+        re.bang = bang;
+        re.result = result !== undefined ? result : !bang;
+        re.toString = function () Option.unparseRegexp(this, keepQuotes);
+        return re;
+    },
+
+    unparseRegexp: function unparseRegexp(re, quoted) re.bang + Option.quote(util.regexp.getSource(re), /^!|:/) +
+        (typeof re.result === "boolean" ? "" : ":" + (quoted ? re.result : Option.quote(re.result))),
+
+    parseSite: function parseSite(pattern, result, rest) {
+        if (isArray(rest)) // Called by Array.map
+            result = undefined;
+
+        let [, bang, filter] = /^(!?)(.*)/.exec(pattern);
+        filter = Option.dequote(filter);
+
+        return update(Styles.matchFilter(filter), {
+            bang: bang,
+            filter: filter,
+            result: result !== undefined ? result : !bang,
+            toString: function toString() this.bang + Option.quote(this.filter) +
+                (typeof this.result === "boolean" ? "" : ":" + Option.quote(this.result)),
+        });
+    },
+
+    getKey: {
+        stringlist: function stringlist(k) this.value.indexOf(k) >= 0,
+        get charlist() this.stringlist,
+
+        regexplist: function regexplist(k, default_) {
+            for (let re in values(this.value))
+                if (re(k))
+                    return re.result;
+            return arguments.length > 1 ? default_ : null;
+        },
+        get regexpmap() this.regexplist,
+        get sitelist() this.regexplist,
+        get sitemap() this.regexplist
+    },
+
+    domains: {
+        sitelist: function (vals) array.compact(vals.map(function (site) util.getHost(site.filter))),
+        get sitemap() this.sitelist
+    },
+
+    stringify: {
+        charlist:    function (vals) Commands.quote(vals.join("")),
+
+        stringlist:  function (vals) vals.map(Option.quote).join(","),
+
+        stringmap:   function (vals) [Option.quote(k, /:/) + ":" + Option.quote(v) for ([k, v] in Iterator(vals))].join(","),
+
+        regexplist:  function (vals) vals.join(","),
+        get regexpmap() this.regexplist,
+        get sitelist() this.regexplist,
+        get sitemap() this.regexplist
+    },
+
+    parse: {
+        number:     function (value) let (val = Option.dequote(value))
+                            Option.validIf(Number(val) % 1 == 0, "Integer value required") && parseInt(val),
+
+        boolean:    function boolean(value) Option.dequote(value) == "true" || value == true ? true : false,
+
+        charlist:   function charlist(value) Array.slice(Option.dequote(value)),
+
+        stringlist: function stringlist(value) (value === "") ? [] : Option.splitList(value),
+
+        regexplist: function regexplist(value) (value === "") ? [] :
+            Option.splitList(value, true)
+                  .map(function (re) Option.parseRegexp(re, undefined, this.regexpFlags), this),
+
+        sitelist: function sitelist(value) {
+            if (value === "")
+                return [];
+            if (!isArray(value))
+                value = Option.splitList(value, true);
+            return value.map(Option.parseSite);
+        },
+
+        stringmap: function stringmap(value) array.toObject(
+            Option.splitList(value, true).map(function (v) {
+                let [count, key, quote] = Commands.parseArg(v, /:/);
+                return [key, Option.dequote(v.substr(count + 1))];
+            })),
+
+        regexpmap: function regexpmap(value) Option.parse.list.call(this, value, Option.parseRegexp),
+
+        sitemap: function sitemap(value) Option.parse.list.call(this, value, Option.parseSite),
+
+        list: function list(value, parse) let (prev = null)
+            array.compact(Option.splitList(value, true).map(function (v) {
+                let [count, filter, quote] = Commands.parseArg(v, /:/, true);
+
+                let val = v.substr(count + 1);
+                if (!this.keepQuotes)
+                    val = Option.dequote(val);
+
+                if (v.length > count)
+                    return prev = parse.call(this, filter, val);
+                else {
+                    util.assert(prev, "Syntax error", false);
+                    prev.result += "," + v;
+                }
+            }, this))
+    },
+
+    testValues: {
+        regexpmap:  function regexpmap(vals, validator) vals.every(function (re) validator(re.result)),
+        get sitemap() this.regexpmap,
+        stringlist: function stringlist(vals, validator) vals.every(validator, this),
+        stringmap:  function stringmap(vals, validator) values(vals).every(validator, this)
+    },
+
+    dequote: function dequote(value) {
+        let arg;
+        [, arg, Option._quote] = Commands.parseArg(String(value), "");
+        Option._splitAt = 0;
+        return arg;
+    },
+
+    splitList: function splitList(value, keepQuotes) {
+        let res = [];
+        Option._splitAt = 0;
+        while (value.length) {
+            if (count !== undefined)
+                value = value.slice(1);
+            var [count, arg, quote] = Commands.parseArg(value, /,/, keepQuotes);
+            Option._quote = quote; // FIXME
+            res.push(arg);
+            if (value.length > count)
+                Option._splitAt += count + 1;
+            value = value.slice(count);
+        }
+        return res;
+    },
+
+    quote: function quote(str, re) isArray(str) ? str.map(function (s) quote(s, re)).join(",") :
+        Commands.quoteArg[/[\s|"'\\,]|^$/.test(str) || re && re.test && re.test(str)
+            ? (/[\b\f\n\r\t]/.test(str) ? '"' : "'")
+            : ""](str, re),
+
+    ops: {
+        boolean: function boolean(operator, values, scope, invert) {
+            if (operator != "=")
+                return null;
+            if (invert)
+                return !this.value;
+            return values;
+        },
+
+        number: function number(operator, values, scope, invert) {
+            if (invert)
+                values = values[(values.indexOf(String(this.value)) + 1) % values.length];
+
+            let value = parseInt(values);
+            util.assert(Number(values) % 1 == 0,
+                        "E521: Number required after =: " + this.name + "=" + values);
+
+            switch (operator) {
+            case "+":
+                return this.value + value;
+            case "-":
+                return this.value - value;
+            case "^":
+                return this.value * value;
+            case "=":
+                return value;
+            }
+            return null;
+        },
+
+        stringmap: function stringmap(operator, values, scope, invert) {
+            let res = update({}, this.value);
+
+            switch (operator) {
+            // The result is the same.
+            case "+":
+            case "^":
+                return update(res, values);
+            case "-":
+                for (let [k, v] in Iterator(values))
+                    if (v === res[k])
+                        delete res[k];
+                return res;
+            case "=":
+                if (invert) {
+                    for (let [k, v] in Iterator(values))
+                        if (v === res[k])
+                            delete res[k];
+                        else
+                            res[k] = v;
+                    return res;
+                }
+                return values;
+            }
+            return null;
+        },
+
+        stringlist: function stringlist(operator, values, scope, invert) {
+            values = Array.concat(values);
+
+            switch (operator) {
+            case "+":
+                return array.uniq(Array.concat(this.value, values), true);
+            case "^":
+                // NOTE: Vim doesn't prepend if there's a match in the current value
+                return array.uniq(Array.concat(values, this.value), true);
+            case "-":
+                return this.value.filter(function (item) values.indexOf(item) == -1);
+            case "=":
+                if (invert) {
+                    let keepValues = this.value.filter(function (item) values.indexOf(item) == -1);
+                    let addValues  = values.filter(function (item) this.value.indexOf(item) == -1, this);
+                    return addValues.concat(keepValues);
+                }
+                return values;
+            }
+            return null;
+        },
+        get charlist() this.stringlist,
+        get regexplist() this.stringlist,
+        get regexpmap() this.stringlist,
+        get sitelist() this.stringlist,
+        get sitemap() this.stringlist,
+
+        string: function string(operator, values, scope, invert) {
+            if (invert)
+                return values[(values.indexOf(this.value) + 1) % values.length];
+            switch (operator) {
+            case "+":
+                return this.value + values;
+            case "-":
+                return this.value.replace(values, "");
+            case "^":
+                return values + this.value;
+            case "=":
+                return values;
+            }
+            return null;
+        }
+    },
+
+    validIf: function validIf(test, error) {
+        if (test)
+            return true;
+        throw ValueError(error);
+    },
+
+    /**
+     * Validates the specified *values* against values generated by the
+     * option's completer function.
+     *
+     * @param {value|string[]} values The value or array of values to validate.
+     * @returns {boolean}
+     */
+    validateCompleter: function validateCompleter(values) {
+        if (this.values)
+            var acceptable = this.values.array || this.values;
+        else {
+            let context = CompletionContext("");
+            acceptable = context.fork("", 0, this, this.completer);
+            if (!acceptable)
+                acceptable = context.allItems.items.map(function (item) [item.text]);
+        }
+
+        if (isArray(acceptable))
+            acceptable = set(acceptable.map(function ([k]) k));
+
+        if (this.type === "regexpmap" || this.type === "sitemap")
+            return Array.concat(values).every(function (re) set.has(acceptable, re.result));
+
+        return Array.concat(values).every(set.has(acceptable));
+    }
+});
+
+/**
+ * @instance options
+ */
+var Options = Module("options", {
+    Local: function Local(dactyl, modules, window) let ({ contexts } = modules) ({
+        init: function init() {
+            const self = this;
+            this.needInit = [];
+            this._options = [];
+            this._optionMap = {};
+            this.Option = Class("Option", Option, { modules: modules });
+
+            storage.newMap("options", { store: false });
+            storage.addObserver("options", function optionObserver(key, event, option) {
+                // Trigger any setters.
+                let opt = self.get(option);
+                if (event == "change" && opt)
+                    opt.set(opt.globalValue, Option.SCOPE_GLOBAL, true);
+            }, window);
+        },
+
+        dactyl: dactyl,
+
+        /**
+         * Lists all options in *scope* or only those with changed values if
+         * *onlyNonDefault* is specified.
+         *
+         * @param {function(Option)} filter Limit the list
+         * @param {number} scope Only list options in this scope (see
+         *     {@link Option#scope}).
+         */
+        list: function (filter, scope) {
+            if (!scope)
+                scope = Option.SCOPE_BOTH;
+
+            function opts(opt) {
+                for (let opt in Iterator(this)) {
+                    let option = {
+                        __proto__: opt,
+                        isDefault: opt.isDefault,
+                        default:   opt.stringDefaultValue,
+                        pre:       "\u00a0\u00a0", // Unicode nonbreaking space.
+                        value:     <></>
+                    };
+
+                    if (filter && !filter(opt))
+                        continue;
+                    if (!(opt.scope & scope))
+                        continue;
+
+                    if (opt.type == "boolean") {
+                        if (!opt.value)
+                            option.pre = "no";
+                        option.default = (opt.defaultValue ? "" : "no") + opt.name;
+                    }
+                    else if (isArray(opt.value))
+                        option.value = <>={template.map(opt.value, function (v) template.highlight(String(v)), <>,<span style="width: 0; display: inline-block"> </span></>)}</>;
+                    else
+                        option.value = <>={template.highlight(opt.stringValue)}</>;
+                    yield option;
+                }
+            };
+
+            modules.commandline.commandOutput(template.options("Options", opts.call(this), this["verbose"] > 0));
+        },
+
+        cleanup: function cleanup() {
+            for (let opt in this)
+                if (opt.cleanupValue != null)
+                    opt.value = opt.parse(opt.cleanupValue);
+        },
+
+        /**
+         * Adds a new option.
+         *
+         * @param {string[]} names All names for the option.
+         * @param {string} description A description of the option.
+         * @param {string} type The option type (see {@link Option#type}).
+         * @param {value} defaultValue The option's default value.
+         * @param {Object} extra An optional extra configuration hash (see
+         *     {@link Map#extraInfo}).
+         * @optional
+         */
+        add: function (names, description, type, defaultValue, extraInfo) {
+            const self = this;
+
+            if (!extraInfo)
+                extraInfo = {};
+
+            extraInfo.definedAt = contexts.getCaller(Components.stack.caller);
+
+            let name = names[0];
+            if (name in this._optionMap) {
+                this.dactyl.log(_("option.replaceExisting", name.quote()), 1);
+                this.remove(name);
+            }
+
+            let closure = function () self._optionMap[name];
+
+            memoize(this._optionMap, name, function () self.Option(names, description, type, defaultValue, extraInfo));
+            for (let alias in values(names.slice(1)))
+                memoize(this._optionMap, alias, closure);
+
+            if (extraInfo.setter && (!extraInfo.scope || extraInfo.scope & Option.SCOPE_GLOBAL))
+                if (this.dactyl.initialized)
+                    closure().initValue();
+                else
+                    memoize(this.needInit, this.needInit.length, closure);
+
+            this._floptions = (this._floptions || []).concat(name);
+            memoize(this._options, this._options.length, closure);
+
+            // quickly access options with options["wildmode"]:
+            this.__defineGetter__(name, function () this._optionMap[name].value);
+            this.__defineSetter__(name, function (value) { this._optionMap[name].value = value; });
+        }
+    }),
+
+    /** @property {Iterator(Option)} @private */
+    __iterator__: function __iterator__()
+        values(this._options.sort(function (a, b) String.localeCompare(a.name, b.name))),
+
+    allPrefs: deprecated("prefs.getNames", function allPrefs() prefs.getNames.apply(prefs, arguments)),
+    getPref: deprecated("prefs.get", function getPref() prefs.get.apply(prefs, arguments)),
+    invertPref: deprecated("prefs.invert", function invertPref() prefs.invert.apply(prefs, arguments)),
+    listPrefs: deprecated("prefs.list", function listPrefs() { commandline.commandOutput(prefs.list.apply(prefs, arguments)); }),
+    observePref: deprecated("prefs.observe", function observePref() prefs.observe.apply(prefs, arguments)),
+    popContext: deprecated("prefs.popContext", function popContext() prefs.popContext.apply(prefs, arguments)),
+    pushContext: deprecated("prefs.pushContext", function pushContext() prefs.pushContext.apply(prefs, arguments)),
+    resetPref: deprecated("prefs.reset", function resetPref() prefs.reset.apply(prefs, arguments)),
+    safeResetPref: deprecated("prefs.safeReset", function safeResetPref() prefs.safeReset.apply(prefs, arguments)),
+    safeSetPref: deprecated("prefs.safeSet", function safeSetPref() prefs.safeSet.apply(prefs, arguments)),
+    setPref: deprecated("prefs.set", function setPref() prefs.set.apply(prefs, arguments)),
+    withContext: deprecated("prefs.withContext", function withContext() prefs.withContext.apply(prefs, arguments)),
+
+    /**
+     * Returns the option with *name* in the specified *scope*.
+     *
+     * @param {string} name The option's name.
+     * @param {number} scope The option's scope (see {@link Option#scope}).
+     * @optional
+     * @returns {Option} The matching option.
+     */
+    get: function get(name, scope) {
+        if (!scope)
+            scope = Option.SCOPE_BOTH;
+
+        if (this._optionMap[name] && (this._optionMap[name].scope & scope))
+            return this._optionMap[name];
+        return null;
+    },
+
+    /**
+     * Parses a :set command's argument string.
+     *
+     * @param {string} args The :set command's argument string.
+     * @param {Object} modifiers A hash of parsing modifiers. These are:
+     *     scope - see {@link Option#scope}
+     * @optional
+     * @returns {Object} The parsed command object.
+     */
+    parseOpt: function parseOpt(args, modifiers) {
+        let res = {};
+        let matches, prefix, postfix;
+
+        [matches, prefix, res.name, postfix, res.valueGiven, res.operator, res.value] =
+        args.match(/^\s*(no|inv)?([^=]+?)([?&!])?\s*(([-+^]?)=(.*))?\s*$/) || [];
+
+        res.args = args;
+        res.onlyNonDefault = false; // used for :set to print non-default options
+        if (!args) {
+            res.name = "all";
+            res.onlyNonDefault = true;
+        }
+
+        if (matches) {
+            res.option = this.get(res.name, res.scope);
+            if (!res.option && (res.option = this.get(prefix + res.name, res.scope))) {
+                res.name = prefix + res.name;
+                prefix = "";
+            }
+        }
+
+        res.prefix = prefix;
+        res.postfix = postfix;
+
+        res.all = (res.name == "all");
+        res.get = (res.all || postfix == "?" || (res.option && res.option.type != "boolean" && !res.valueGiven));
+        res.invert = (prefix == "inv" || postfix == "!");
+        res.reset = (postfix == "&");
+        res.unsetBoolean = (prefix == "no");
+
+        res.scope = modifiers && modifiers.scope;
+
+        if (!res.option)
+            return res;
+
+        if (res.value === undefined)
+            res.value = "";
+
+        res.optionValue = res.option.get(res.scope);
+
+        try {
+            res.values = res.option.parse(res.value);
+        }
+        catch (e) {
+            res.error = e;
+        }
+
+        return res;
+    },
+
+    /**
+     * Remove the option with matching *name*.
+     *
+     * @param {string} name The name of the option to remove. This can be
+     *     any of the option's names.
+     */
+    remove: function remove(name) {
+        let opt = this.get(name);
+        this._options = this._options.filter(function (o) o != opt);
+        for (let name in values(opt.names))
+            delete this._optionMap[name];
+    },
+
+    /** @property {Object} The options store. */
+    get store() storage.options
+}, {
+}, {
+    commands: function initCommands(dactyl, modules, window) {
+        const { commands, contexts, options } = modules;
+
+        let args = {
+            getMode: function (args) findMode(args["-mode"]),
+            iterate: function (args) {
+                for (let map in mappings.iterate(this.getMode(args)))
+                    for (let name in values(map.names))
+                        yield { name: name, __proto__: map };
+            },
+            format: {
+                description: function (map) (XML.ignoreWhitespace = false, XML.prettyPrinting = false, <>
+                        {options.get("passkeys").has(map.name)
+                            ? <span highlight="URLExtra">(passed by {template.helpLink("'passkeys'")})</span>
+                            : <></>}
+                        {template.linkifyHelp(map.description)}
+                </>)
+            }
+        };
+
+        dactyl.addUsageCommand({
+            name: ["listo[ptions]", "lo"],
+            description: "List all options along with their short descriptions",
+            index: "option",
+            iterate: function (args) options,
+            format: {
+                description: function (opt) (XML.ignoreWhitespace = false, XML.prettyPrinting = false, <>
+                        {opt.scope == Option.SCOPE_LOCAL
+                            ? <span highlight="URLExtra">(buffer local)</span> : ""}
+                        {template.linkifyHelp(opt.description)}
+                </>),
+                help: function (opt) "'" + opt.name + "'"
+            }
+        });
+
+        function setAction(args, modifiers) {
+            let bang = args.bang;
+            if (!args.length)
+                args[0] = "";
+
+            let list = [];
+            function flushList() {
+                let names = set(list.map(function (opt) opt.option ? opt.option.name : ""));
+                if (list.length)
+                    if (list.some(function (opt) opt.all))
+                        options.list(function (opt) !(list[0].onlyNonDefault && opt.isDefault) , list[0].scope);
+                    else
+                        options.list(function (opt) set.has(names, opt.name), list[0].scope);
+                list = [];
+            }
+
+            for (let [, arg] in args) {
+                if (bang) {
+                    let onlyNonDefault = false;
+                    let reset = false;
+                    let invertBoolean = false;
+
+                    if (args[0] == "") {
+                        var name = "all";
+                        onlyNonDefault = true;
+                    }
+                    else {
+                        var [matches, name, postfix, valueGiven, operator, value] =
+                            arg.match(/^\s*?([^=]+?)([?&!])?\s*(([-+^]?)=(.*))?\s*$/);
+                        reset = (postfix == "&");
+                        invertBoolean = (postfix == "!");
+                    }
+
+                    if (name == "all" && reset)
+                        modules.commandline.input("Warning: Resetting all preferences may make " + config.host + " unusable. Continue (yes/[no]): ",
+                            function (resp) {
+                                if (resp == "yes")
+                                    for (let pref in values(prefs.getNames()))
+                                        prefs.reset(pref);
+                            },
+                            { promptHighlight: "WarningMsg" });
+                    else if (name == "all")
+                        commandline.commandOutput(prefs.list(onlyNonDefault, ""));
+                    else if (reset)
+                        prefs.reset(name);
+                    else if (invertBoolean)
+                        prefs.toggle(name);
+                    else if (valueGiven) {
+                        if (value == undefined)
+                            value = "";
+                        else if (value == "true")
+                            value = true;
+                        else if (value == "false")
+                            value = false;
+                        else if (Number(value) % 1 == 0)
+                            value = parseInt(value);
+                        else
+                            value = Option.dequote(value);
+
+                        if (operator)
+                            value = Option.ops[typeof value].call({ value: prefs.get(name) }, operator, value);
+                        prefs.set(name, value);
+                    }
+                    else
+                        modules.commandline.commandOutput(prefs.list(onlyNonDefault, name));
+                    return;
+                }
+
+                let opt = modules.options.parseOpt(arg, modifiers);
+                util.assert(opt, "Error parsing :set command: " + arg);
+
+                let option = opt.option;
+                util.assert(option != null || opt.all, "E518: Unknown option: " + opt.name);
+
+                // reset a variable to its default value
+                if (opt.reset) {
+                    flushList();
+                    if (opt.all) {
+                        for (let option in modules.options)
+                            option.reset();
+                    }
+                    else {
+                        option.reset();
+                    }
+                }
+                // read access
+                else if (opt.get)
+                    list.push(opt);
+                // write access
+                else {
+                    flushList();
+                    if (opt.option.type === "boolean") {
+                        util.assert(!opt.valueGiven, _("error.invalidArgument", arg));
+                        opt.values = !opt.unsetBoolean;
+                    }
+                    else if (/^(string|number)$/.test(opt.option.type) && opt.invert)
+                        opt.values = Option.splitList(opt.value);
+                    try {
+                        var res = opt.option.op(opt.operator || "=", opt.values, opt.scope, opt.invert,
+                                                opt.value);
+                    }
+                    catch (e) {
+                        res = e;
+                    }
+                    if (res)
+                        dactyl.echoerr(res);
+                    option.setFrom = contexts.getCaller(null);
+                }
+            }
+            flushList();
+        }
+
+        function setCompleter(context, args, modifiers) {
+            const { completion } = modules;
+
+            let filter = context.filter;
+
+            if (args.bang) { // list completions for about:config entries
+                if (filter[filter.length - 1] == "=") {
+                    context.advance(filter.length);
+                    filter = filter.substr(0, filter.length - 1);
+
+                    context.pushProcessor(0, function (item, text, next) next(item, text.substr(0, 100)));
+                    context.completions = [
+                            [prefs.get(filter), "Current Value"],
+                            [prefs.defaults.get(filter), "Default Value"]
+                    ].filter(function (k) k[0] != null);
+                    return null;
+                }
+
+                return completion.preference(context);
+            }
+
+            let opt = modules.options.parseOpt(filter, modifiers);
+            let prefix = opt.prefix;
+
+            context.highlight();
+            if (context.filter.indexOf("=") == -1) {
+                if (false && prefix)
+                    context.filters.push(function ({ item }) item.type == "boolean" || prefix == "inv" && isArray(item.values));
+                return completion.option(context, opt.scope, prefix);
+            }
+
+            function error(length, message) {
+                context.message = message;
+                context.highlight(0, length, "SPELLCHECK");
+            }
+
+            let option = opt.option;
+            if (!option)
+                return error(opt.name.length, "No such option: " + opt.name);
+
+            context.advance(context.filter.indexOf("="));
+            if (option.type == "boolean")
+                return error(context.filter.length, "Trailing characters");
+
+            context.advance(1);
+            if (opt.error)
+                return error(context.filter.length, opt.error);
+
+            if (opt.get || opt.reset || !option || prefix)
+                return null;
+
+            if (!opt.value && !opt.operator && !opt.invert) {
+                context.fork("default", 0, this, function (context) {
+                    context.title = ["Extra Completions"];
+                    context.pushProcessor(0, function (item, text, next) next(item, text.substr(0, 100)));
+                    context.completions = [
+                            [option.stringValue, "Current value"],
+                            [option.stringDefaultValue, "Default value"]
+                    ].filter(function (f) f[0] !== "");
+                    context.quote = ["", util.identity, ""];
+                });
+            }
+
+            let optcontext = context.fork("values");
+            modules.completion.optionValue(optcontext, opt.name, opt.operator);
+
+            // Fill in the current values if we're removing
+            if (opt.operator == "-" && isArray(opt.values)) {
+                let have = set([i.text for (i in values(context.allItems.items))]);
+                context = context.fork("current-values", 0);
+                context.anchored = optcontext.anchored;
+                context.maxItems = optcontext.maxItems;
+
+                context.filters.push(function (i) !set.has(have, i.text));
+                modules.completion.optionValue(context, opt.name, opt.operator, null,
+                                       function (context) {
+                                           context.generate = function () option.value.map(function (o) [o, ""]);
+                                       });
+                context.title = ["Current values"];
+            }
+        }
+
+        // TODO: deprecated. This needs to support "g:"-prefixed globals at a
+        // minimum for now.  The coderepos plugins make extensive use of global
+        // variables.
+        commands.add(["let"],
+            "Set or list a variable",
+            function (args) {
+                let globalVariables = dactyl._globalVariables;
+                args = (args[0] || "").trim();
+                function fmt(value) (typeof value == "number"   ? "#" :
+                                     typeof value == "function" ? "*" :
+                                                                  " ") + value;
+                if (!args || args == "g:") {
+                    let str =
+                        <table>
+                        {
+                            template.map(globalVariables, function ([i, value]) {
+                                return <tr>
+                                            <td style="width: 200px;">{i}</td>
+                                            <td>{fmt(value)}</td>
+                                       </tr>;
+                            })
+                        }
+                        </table>;
+                    if (str.text().length() == str.*.length())
+                        dactyl.echomsg(_("variable.none"));
+                    else
+                        dactyl.echo(str, commandline.FORCE_MULTILINE);
+                    return;
+                }
+
+                let matches = args.match(/^([a-z]:)?([\w]+)(?:\s*([-+.])?=\s*(.*)?)?$/);
+                if (matches) {
+                    let [, scope, name, op, expr] = matches;
+                    let fullName = (scope || "") + name;
+
+                    util.assert(scope == "g:" || scope == null,
+                                _("command.let.illegalVar", scope + name));
+                    util.assert(set.has(globalVariables, name) || (expr && !op),
+                                _("command.let.undefinedVar", fullName));
+
+                    if (!expr)
+                        dactyl.echo(fullName + "\t\t" + fmt(globalVariables[name]));
+                    else {
+                        try {
+                            var newValue = dactyl.userEval(expr);
+                        }
+                        catch (e) {}
+                        util.assert(newValue !== undefined,
+                            _("command.let.invalidExpression", expr));
+
+                        let value = newValue;
+                        if (op) {
+                            value = globalVariables[name];
+                            if (op == "+")
+                                value += newValue;
+                            else if (op == "-")
+                                value -= newValue;
+                            else if (op == ".")
+                                value += String(newValue);
+                        }
+                        globalVariables[name] = value;
+                    }
+                }
+                else
+                    dactyl.echoerr(_("command.let.unexpectedChar"));
+            },
+            {
+                deprecated: "the options system",
+                literal: 0
+            }
+        );
+
+        [
+            {
+                names: ["setl[ocal]"],
+                description: "Set local option",
+                modifiers: { scope: Option.SCOPE_LOCAL }
+            },
+            {
+                names: ["setg[lobal]"],
+                description: "Set global option",
+                modifiers: { scope: Option.SCOPE_GLOBAL }
+            },
+            {
+                names: ["se[t]"],
+                description: "Set an option",
+                modifiers: {},
+                extra: {
+                    serialize: function () [
+                        {
+                            command: this.name,
+                            literalArg: [opt.type == "boolean" ? (opt.value ? "" : "no") + opt.name
+                                                               : opt.name + "=" + opt.stringValue]
+                        }
+                        for (opt in modules.options)
+                        if (!opt.getter && !opt.isDefault && (opt.scope & Option.SCOPE_GLOBAL))
+                    ]
+                }
+            }
+        ].forEach(function (params) {
+            commands.add(params.names, params.description,
+                function (args, modifiers) {
+                    setAction(args, update(modifiers, params.modifiers));
+                },
+                update({
+                    bang: true,
+                    completer: setCompleter,
+                    domains: function domains(args) array.flatten(args.map(function (spec) {
+                        try {
+                            let opt = modules.options.parseOpt(spec);
+                            if (opt.option && opt.option.domains)
+                                return opt.option.domains(opt.values);
+                        }
+                        catch (e) {
+                            util.reportError(e);
+                        }
+                        return [];
+                    })),
+                    keepQuotes: true,
+                    privateData: function privateData(args) args.some(function (spec) {
+                        let opt = modules.options.parseOpt(spec);
+                        return opt.option && opt.option.privateData &&
+                            (!callable(opt.option.privateData) ||
+                             opt.option.privateData(opt.values));
+                    })
+                }, params.extra || {}));
+        });
+
+        // TODO: deprecated. This needs to support "g:"-prefixed globals at a
+        // minimum for now.
+        commands.add(["unl[et]"],
+            "Delete a variable",
+            function (args) {
+                for (let [, name] in args) {
+                    name = name.replace(/^g:/, ""); // throw away the scope prefix
+                    if (!set.has(dactyl._globalVariables, name)) {
+                        if (!args.bang)
+                            dactyl.echoerr(_("command.let.noSuch", name));
+                        return;
+                    }
+
+                    delete dactyl._globalVariables[name];
+                }
+            },
+            {
+                argCount: "+",
+                bang: true,
+                deprecated: "the options system"
+            });
+    },
+    completion: function initCompletion(dactyl, modules, window) {
+        const { completion } = modules;
+
+        completion.option = function option(context, scope, prefix) {
+            context.title = ["Option"];
+            context.keys = { text: "names", description: "description" };
+            context.anchored = false;
+            context.completions = modules.options;
+            if (prefix == "inv")
+                context.keys.text = function (opt)
+                    opt.type == "boolean" || isArray(opt.value) ? opt.names.map(function (n) "inv" + n)
+                                                                : opt.names;
+            if (scope)
+                context.filters.push(function ({ item }) item.scope & scope);
+        };
+
+        completion.optionValue = function (context, name, op, curValue, completer) {
+            let opt = modules.options.get(name);
+            completer = completer || opt.completer;
+            if (!completer || !opt)
+                return;
+
+            try {
+                var curValues = curValue != null ? opt.parse(curValue) : opt.value;
+                var newValues = opt.parse(context.filter);
+            }
+            catch (e) {
+                context.message = "Error: " + e;
+                context.completions = [];
+                return;
+            }
+
+            let extra = {};
+            switch (opt.type) {
+            case "boolean":
+                return;
+            case "sitelist":
+            case "regexplist":
+                newValues = Option.splitList(context.filter);
+                // Fallthrough
+            case "stringlist":
+                break;
+            case "charlist":
+                Option._splitAt = newValues.length;
+                break;
+            case "stringmap":
+            case "sitemap":
+            case "regexpmap":
+                let vals = Option.splitList(context.filter);
+                let target = vals.pop() || "";
+
+                let [count, key, quote] = Commands.parseArg(target, /:/, true);
+                let split = Option._splitAt;
+
+                extra.key = Option.dequote(key);
+                extra.value = count < target.length ? Option.dequote(target.substr(count + 1)) : null;
+                extra.values = opt.parse(vals.join(","));
+
+                Option._splitAt = split + (extra.value == null ? 0 : count + 1);
+                break;
+            }
+            // TODO: Highlight when invalid
+            context.advance(Option._splitAt);
+            context.filter = Option.dequote(context.filter);
+
+            context.title = ["Option Value"];
+            context.quote = Commands.complQuote[Option._quote] || Commands.complQuote[""];
+            // Not Vim compatible, but is a significant enough improvement
+            // that it's worth breaking compatibility.
+            if (isArray(newValues)) {
+                context.filters.push(function (i) newValues.indexOf(i.text) == -1);
+                if (op == "+")
+                    context.filters.push(function (i) curValues.indexOf(i.text) == -1);
+                if (op == "-")
+                    context.filters.push(function (i) curValues.indexOf(i.text) > -1);
+            }
+
+            let res = completer.call(opt, context, extra);
+            if (res)
+                context.completions = res;
+        };
+    },
+    javascript: function initJavascript(dactyl, modules, window) {
+        const { options, JavaScript } = modules;
+        JavaScript.setCompleter(options.get, [function () ([o.name, o.description] for (o in options))]);
+    },
+    sanitizer: function initSanitizer(dactyl, modules, window) {
+        const { sanitizer } = modules;
+
+        sanitizer.addItem("options", {
+            description: "Options containing hostname data",
+            action: function sanitize_action(timespan, host) {
+                if (host)
+                    for (let opt in values(modules.options._options))
+                        if (timespan.contains(opt.lastSet * 1000) && opt.domains)
+                            try {
+                                opt.value = opt.filterDomain(host, opt.value);
+                            }
+                            catch (e) {
+                                dactyl.reportError(e);
+                            }
+            },
+            privateEnter: function privateEnter() {
+                for (let opt in values(modules.options._options))
+                    if (opt.privateData && (!callable(opt.privateData) || opt.privateData(opt.value)))
+                        opt.oldValue = opt.value;
+            },
+            privateLeave: function privateLeave() {
+                for (let opt in values(modules.options._options))
+                    if (opt.oldValue != null) {
+                        opt.value = opt.oldValue;
+                        opt.oldValue = null;
+                    }
+            }
+        });
+    }
+});
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/overlay.jsm b/common/modules/overlay.jsm
new file mode 100644 (file)
index 0000000..ea626b4
--- /dev/null
@@ -0,0 +1,330 @@
+// Copyright (c) 2009-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("overlay", {
+    exports: ["ModuleBase"],
+    require: ["config", "services", "util"]
+}, this);
+
+/**
+ * @class ModuleBase
+ * The base class for all modules.
+ */
+var ModuleBase = Class("ModuleBase", {
+    /**
+     * @property {[string]} A list of module prerequisites which
+     * must be initialized before this module is loaded.
+     */
+    requires: [],
+
+    toString: function () "[module " + this.constructor.className + "]"
+});
+
+var Overlay = Module("Overlay", {
+    init: function init() {
+        services["dactyl:"]; // Hack. Force module initialization.
+
+        config.loadStyles();
+
+        util.overlayWindow(config.overlayChrome, function overlay(window) ({
+            init: function onInit(document) {
+                /**
+                 * @constructor Module
+                 *
+                 * Constructs a new ModuleBase class and makes arrangements for its
+                 * initialization. Arguments marked as optional must be either
+                 * entirely elided, or they must have the exact type specified.
+                 * Loading semantics are as follows:
+                 *
+                 *  - A module is guaranteed not to be initialized before any of its
+                 *    prerequisites as listed in its {@see ModuleBase#requires} member.
+                 *  - A module is considered initialized once it's been instantiated,
+                 *    its {@see Class#init} method has been called, and its
+                 *    instance has been installed into the top-level {@see modules}
+                 *    object.
+                 *  - Once the module has been initialized, its module-dependent
+                 *    initialization functions will be called as described hereafter.
+                 * @param {string} name The module's name as it will appear in the
+                 *     top-level {@see modules} object.
+                 * @param {ModuleBase} base The base class for this module.
+                 *     @optional
+                 * @param {Object} prototype The prototype for instances of this
+                 *     object. The object itself is copied and not used as a prototype
+                 *     directly.
+                 * @param {Object} classProperties The class properties for the new
+                 *     module constructor.
+                 *     @optional
+                 * @param {Object} moduleInit The module initialization functions
+                 *     for the new module. Each function is called as soon as the named module
+                 *     has been initialized, but after the module itself. The constructors are
+                 *     guaranteed to be called in the same order that the dependent modules
+                 *     were initialized.
+                 *     @optional
+                 *
+                 * @returns {function} The constructor for the resulting module.
+                 */
+                function Module(name) {
+                    let args = Array.slice(arguments);
+
+                    var base = ModuleBase;
+                    if (callable(args[1]))
+                        base = args.splice(1, 1)[0];
+                    let [, prototype, classProperties, moduleInit] = args;
+                    const module = Class(name, base, prototype, classProperties);
+
+                    module.INIT = moduleInit || {};
+                    module.modules = modules;
+                    module.prototype.INIT = module.INIT;
+                    module.requires = prototype.requires || [];
+                    Module.list.push(module);
+                    Module.constructors[name] = module;
+                    return module;
+                }
+                Module.list = [];
+                Module.constructors = {};
+
+                const BASE = "resource://dactyl-content/";
+
+                const create = window.Object.create || (function () {
+                    window.__dactyl_eval_string = "(function (proto) ({ __proto__: proto }))";
+                    JSMLoader.loadSubScript(BASE + "eval.js", window);
+
+                    let res = window.__dactyl_eval_result;
+                    delete window.__dactyl_eval_string;
+                    delete window.__dactyl_eval_result;
+                    return res;
+                })();
+
+                const jsmodules = { NAME: "jsmodules" };
+                const modules = update(create(jsmodules), {
+                    yes_i_know_i_should_not_report_errors_in_these_branches_thanks: [],
+
+                    jsmodules: jsmodules,
+
+                    get content() this.config.browser.contentWindow || window.content,
+
+                    window: window,
+
+                    Module: Module,
+
+                    load: function load(script) {
+                        for (let [i, base] in Iterator(prefix)) {
+                            try {
+                                JSMLoader.loadSubScript(base + script + ".js", modules, "UTF-8");
+                                return;
+                            }
+                            catch (e) {
+                                if (typeof e !== "string") {
+                                    util.dump("Trying: " + (base + script + ".js") + ":");
+                                    util.reportError(e);
+                                }
+                            }
+                        }
+                        try {
+                            require(jsmodules, script);
+                        }
+                        catch (e) {
+                            util.dump("Loading script " + script + ":");
+                            util.reportError(e);
+                        }
+                    },
+
+                    newContext: function newContext(proto, normal) {
+                        if (normal)
+                            return create(proto);
+                        let sandbox = Components.utils.Sandbox(window, { sandboxPrototype: proto || modules, wantXrays: false });
+                        // Hack:
+                        sandbox.Object = jsmodules.Object;
+                        sandbox.Math = jsmodules.Math;
+                        sandbox.__proto__ = proto || modules;
+                        return sandbox;
+                    },
+
+                    get ownPropertyValues() array.compact(
+                            Object.getOwnPropertyNames(this)
+                                  .map(function (name) Object.getOwnPropertyDescriptor(this, name).value, this)),
+
+                    get moduleList() this.ownPropertyValues.filter(function (mod) mod instanceof this.ModuleBase || mod.isLocalModule, this)
+                });
+                modules.plugins = create(modules);
+                modules.modules = modules;
+                window.dactyl = { modules: modules };
+
+                let prefix = [BASE, "resource://dactyl-local-content/"];
+
+                defineModule.time("load", null, function _load() {
+                    ["addons",
+                     "base",
+                     "io",
+                     "commands",
+                     "completion",
+                     "config",
+                     "contexts",
+                     "downloads",
+                     "finder",
+                     "highlight",
+                     "javascript",
+                     "messages",
+                     "options",
+                     "overlay",
+                     "prefs",
+                     "sanitizer",
+                     "services",
+                     "storage",
+                     "styles",
+                     "template",
+                     "util"
+                    ].forEach(function (name) defineModule.time("load", name, require, null, jsmodules, name));
+
+                    ["dactyl",
+                     "modes",
+                     "commandline",
+                     "abbreviations",
+                     "autocommands",
+                     "buffer",
+                     "editor",
+                     "events",
+                     "hints",
+                     "mappings",
+                     "marks",
+                     "mow",
+                     "statusline"
+                     ].forEach(function (name) defineModule.time("load", name, modules.load, modules, name));
+                }, this);
+            },
+            load: function onLoad(document) {
+                // This is getting to be horrible. --Kris
+
+                var { modules, Module } = window.dactyl.modules;
+                delete window.dactyl;
+
+                const start = Date.now();
+                const deferredInit = { load: {} };
+                const seen = set();
+                const loaded = set();
+                modules.loaded = loaded;
+
+                function load(module, prereq, frame) {
+                    if (isString(module)) {
+                        if (!Module.constructors.hasOwnProperty(module))
+                            modules.load(module);
+                        module = Module.constructors[module];
+                    }
+
+                    try {
+                        if (module.className in loaded)
+                            return;
+                        if (module.className in seen)
+                            throw Error("Module dependency loop.");
+                        set.add(seen, module.className);
+
+                        for (let dep in values(module.requires))
+                            load(Module.constructors[dep], module.className);
+
+                        defineModule.loadLog.push("Load" + (isString(prereq) ? " " + prereq + " dependency: " : ": ") + module.className);
+                        if (frame && frame.filename)
+                            defineModule.loadLog.push(" from: " + util.fixURI(frame.filename) + ":" + frame.lineNumber);
+
+                        let obj = defineModule.time(module.className, "init", module);
+                        Class.replaceProperty(modules, module.className, obj);
+                        loaded[module.className] = true;
+
+                        if (loaded.dactyl && obj.signals)
+                            modules.dactyl.registerObservers(obj);
+
+                        frob(module.className);
+                    }
+                    catch (e) {
+                        util.dump("Loading " + (module && module.className) + ":");
+                        util.reportError(e);
+                    }
+                    return modules[module.className];
+                }
+
+                function deferInit(name, INIT, mod) {
+                    let init = deferredInit[name] = deferredInit[name] || {};
+                    let className = mod.className || mod.constructor.className;
+
+                    init[className] = function callee() {
+                        if (!callee.frobbed)
+                            defineModule.time(className, name, INIT[name], mod,
+                                              modules.dactyl, modules, window);
+                        callee.frobbed = true;
+                    };
+
+                    INIT[name].require = function (name) { init[name](); };
+                }
+
+                function frobModules() {
+                    Module.list.forEach(function frobModule(mod) {
+                        if (!mod.frobbed) {
+                            modules.__defineGetter__(mod.className, function () {
+                                delete modules[mod.className];
+                                return load(mod.className, null, Components.stack.caller);
+                            });
+                            Object.keys(mod.prototype.INIT)
+                                  .forEach(function (name) { deferInit(name, mod.prototype.INIT, mod); });
+                        }
+                        mod.frobbed = true;
+                    });
+                }
+                defineModule.modules.forEach(function defModule(mod) {
+                    let names = set(Object.keys(mod.INIT));
+                    if ("init" in mod.INIT)
+                        set.add(names, "init");
+
+                    keys(names).forEach(function (name) { deferInit(name, mod.INIT, mod); });
+                });
+
+                function frob(name) { values(deferredInit[name] || {}).forEach(call); }
+
+                frobModules();
+                frob("init");
+                modules.config.scripts.forEach(modules.load);
+                frobModules();
+
+                defineModule.modules.forEach(function defModule({ lazyInit, constructor: { className } }) {
+                    if (!lazyInit) {
+                        frob(className);
+                        Class.replaceProperty(modules, className, modules[className]);
+                    }
+                    else
+                        modules.__defineGetter__(className, function () {
+                            delete modules[className];
+                            frob(className);
+                            return modules[className] = modules[className];
+                        });
+                });
+
+                // Module.list.forEach(load);
+                frob("load");
+                modules.times = update({}, defineModule.times);
+
+                defineModule.loadLog.push("Loaded in " + (Date.now() - start) + "ms");
+
+                modules.events.listen(window, "unload", function onUnload() {
+                    window.removeEventListener("unload", onUnload.wrapped, false);
+
+                    for each (let mod in modules.moduleList.reverse()) {
+                        mod.stale = true;
+
+                        if ("destroy" in mod)
+                            util.trapErrors("destroy", mod);
+                    }
+                }, false);
+            }
+        }));
+    }
+});
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/prefs.jsm b/common/modules/prefs.jsm
new file mode 100644 (file)
index 0000000..981a6be
--- /dev/null
@@ -0,0 +1,357 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("prefs", {
+    exports: ["Prefs", "localPrefs", "prefs"],
+    require: ["services", "util"],
+    use: ["config", "messages", "template"]
+}, this);
+
+var Prefs = Module("prefs", XPCOM([Ci.nsIObserver, Ci.nsISupportsWeakReference]), {
+    SAVED: "extensions.dactyl.saved.",
+    RESTORE: "extensions.dactyl.restore.",
+    INIT: {},
+
+    init: function (branch, defaults) {
+        this._prefContexts = [];
+
+        this.branch = services.pref[defaults ? "getDefaultBranch" : "getBranch"](branch || "");
+        if (this.branch instanceof Ci.nsIPrefBranch2)
+            this.branch.QueryInterface(Ci.nsIPrefBranch2);
+
+        this.defaults = defaults ? this : this.constructor(branch, true);
+
+        if (!defaults)
+            this.restore();
+
+        this._observers = {};
+    },
+
+    cleanup: function cleanup() {
+        if (this.defaults != this)
+            this.defaults.cleanup();
+        this._observers = {};
+        if (this.observe) {
+            this.branch.removeObserver("", this);
+            this.observe.unregister();
+            delete this.observe;
+        }
+    },
+
+    observe: null,
+    observers: {
+        "nsPref:changed": function (subject, data) {
+            let observers = this._observers[data];
+            if (observers) {
+                let value = this.get(data, false);
+                this._observers[data] = observers.filter(function (callback) {
+                    if (!callback.get())
+                        return false;
+                    util.trapErrors(callback.get(), null, value);
+                    return true;
+                });
+            }
+        }
+    },
+
+    /**
+     * Adds a new preference observer for the given preference.
+     *
+     * @param {string} pref The preference to observe.
+     * @param {function(object)} callback The callback, called with the
+     *    new value of the preference whenever it changes.
+     */
+    watch: function (pref, callback, strong) {
+        if (!this.observe) {
+            util.addObserver(this);
+            this.branch.addObserver("", this, false);
+        }
+
+        if (!this._observers[pref])
+            this._observers[pref] = [];
+        this._observers[pref].push(!strong ? Cu.getWeakReference(callback) : { get: function () callback });
+    },
+
+    /**
+     * Lists all preferences matching *filter* or only those with changed
+     * values if *onlyNonDefault* is specified.
+     *
+     * @param {boolean} onlyNonDefault Limit the list to prefs with a
+     *     non-default value.
+     * @param {string} filter The list filter. A null filter lists all
+     *     prefs.
+     * @optional
+     */
+    list: function list(onlyNonDefault, filter) {
+        if (!filter)
+            filter = "";
+
+        let prefArray = this.getNames();
+        prefArray.sort();
+        function prefs() {
+            for (let [, pref] in Iterator(prefArray)) {
+                let userValue = services.pref.prefHasUserValue(pref);
+                if (onlyNonDefault && !userValue || pref.indexOf(filter) == -1)
+                    continue;
+
+                let value = this.get(pref);
+
+                let option = {
+                    isDefault: !userValue,
+                    default:   this.defaults.get(pref, null),
+                    value:     <>={template.highlight(value, true, 100)}</>,
+                    name:      pref,
+                    pre:       "\u00a0\u00a0" // Unicode nonbreaking space.
+                };
+
+                yield option;
+            }
+        };
+
+        return template.options(config.host + " Preferences", prefs.call(this));
+    },
+
+    /**
+     * Returns the value of a preference.
+     *
+     * @param {string} name The preference name.
+     * @param {value} defaultValue The value to return if the preference
+     *     is unset.
+     */
+    get: function get(name, defaultValue) {
+        if (defaultValue == null)
+            defaultValue = null;
+
+        let type = this.branch.getPrefType(name);
+        try {
+            switch (type) {
+            case Ci.nsIPrefBranch.PREF_STRING:
+                let value = this.branch.getComplexValue(name, Ci.nsISupportsString).data;
+                // try in case it's a localized string (will throw an exception if not)
+                if (!this.branch.prefIsLocked(name) && !this.branch.prefHasUserValue(name) &&
+                    RegExp("chrome://.+/locale/.+\\.properties").test(value))
+                        value = this.branch.getComplexValue(name, Ci.nsIPrefLocalizedString).data;
+                return value;
+            case Ci.nsIPrefBranch.PREF_INT:
+                return this.branch.getIntPref(name);
+            case Ci.nsIPrefBranch.PREF_BOOL:
+                return this.branch.getBoolPref(name);
+            default:
+                return defaultValue;
+            }
+        }
+        catch (e) {
+            return defaultValue;
+        }
+    },
+
+    getDefault: deprecated("Prefs#defaults.get", function getDefault(name, defaultValue) this.defaults.get(name, defaultValue)),
+
+    /**
+     * Returns the names of all preferences.
+     *
+     * @param {string} branch The branch in which to search preferences.
+     *     @default ""
+     */
+    getNames: function (branch) this.branch.getChildList(branch || "", { value: 0 }),
+
+    _checkSafe: function (name, message, value) {
+        let curval = this.get(name, null);
+        if (arguments.length > 2 && curval === value)
+            return;
+        let defval = this.defaults.get(name, null);
+        let saved  = this.get(this.SAVED + name);
+
+        if (saved == null && curval != defval || saved != null && curval != saved) {
+            let msg = "Warning: setting preference " + name + ", but it's changed from its default value.";
+            if (message)
+                msg = template.linkifyHelp(msg + " " + message);
+            util.dactyl.warn(msg);
+        }
+    },
+
+    /**
+     * Resets the preference *name* to *value* but warns the user if the value
+     * is changed from its default.
+     *
+     * @param {string} name The preference name.
+     * @param {value} value The new preference value.
+     */
+    safeReset: function (name, message) {
+        this._checkSafe(name, message);
+        this.reset(name);
+        this.reset(this.SAVED + name);
+    },
+
+    /**
+     * Sets the preference *name* to *value* but warns the user if the value is
+     * changed from its default.
+     *
+     * @param {string} name The preference name.
+     * @param {value} value The new preference value.
+     */
+    safeSet: function (name, value, message, skipSave) {
+        this._checkSafe(name, message, value);
+        this.set(name, value);
+        this[skipSave ? "reset" : "set"](this.SAVED + name, value);
+    },
+
+    /**
+     * Sets the preference *name* to *value*.
+     *
+     * @param {string} name The preference name.
+     * @param {value} value The new preference value.
+     */
+    set: function (name, value) {
+        if (this._prefContexts.length) {
+            let val = this.get(name, null);
+            if (val != null)
+                this._prefContexts[this._prefContexts.length - 1][name] = val;
+        }
+
+        function assertType(needType)
+            util.assert(type === Ci.nsIPrefBranch.PREF_INVALID || type === needType,
+                type === Ci.nsIPrefBranch.PREF_INT
+                                ? "E521: Number required after =: " + name + "=" + value
+                                : "E474: Invalid argument: " + name + "=" + value);
+
+        let type = this.branch.getPrefType(name);
+        switch (typeof value) {
+        case "string":
+            assertType(Ci.nsIPrefBranch.PREF_STRING);
+
+            this.branch.setComplexValue(name, Ci.nsISupportsString, services.String(value));
+            break;
+        case "number":
+            assertType(Ci.nsIPrefBranch.PREF_INT);
+
+            this.branch.setIntPref(name, value);
+            break;
+        case "boolean":
+            assertType(Ci.nsIPrefBranch.PREF_BOOL);
+
+            this.branch.setBoolPref(name, value);
+            break;
+        default:
+            throw FailedAssertion("Unknown preference type: " + typeof value + " (" + name + "=" + value + ")");
+        }
+    },
+
+    /**
+     * Saves the current value of a preference to be restored at next
+     * startup.
+     *
+     * @param {string} name The preference to save.
+     */
+    save: function (name) {
+        let val = this.get(name);
+        this.set(this.RESTORE + name, val);
+        this.safeSet(name, val);
+    },
+
+    /**
+     * Restores saved preferences in the given branch.
+     *
+     * @param {string} branch The branch from which to restore
+     *      preferences. @optional
+     */
+    restore: function (branch) {
+        this.getNames(this.RESTORE + (branch || "")).forEach(function (pref) {
+            this.safeSet(pref.substr(this.RESTORE.length), this.get(pref), null, true);
+            this.reset(pref);
+        }, this);
+    },
+
+    /**
+     * Resets the preference *name* to its default value.
+     *
+     * @param {string} name The preference name.
+     */
+    reset: function (name) {
+        try {
+            this.branch.clearUserPref(name);
+        }
+        catch (e) {} // ignore - thrown if not a user set value
+    },
+
+    /**
+     * Toggles the value of the boolean preference *name*.
+     *
+     * @param {string} name The preference name.
+     */
+    toggle: function (name) {
+        util.assert(this.branch.getPrefType(name) === Ci.nsIPrefBranch.PREF_BOOL,
+                    _("error.trailing", name + "!"));
+        this.set(name, !this.get(name));
+    },
+
+    /**
+     * Pushes a new preference context onto the context stack.
+     *
+     * @see #withContext
+     */
+    pushContext: function () {
+        this._prefContexts.push({});
+    },
+
+    /**
+     * Pops the top preference context from the stack.
+     *
+     * @see #withContext
+     */
+    popContext: function () {
+        for (let [k, v] in Iterator(this._prefContexts.pop()))
+            this.set(k, v);
+    },
+
+    /**
+     * Executes *func* with a new preference context. When *func* returns, the
+     * context is popped and any preferences set via {@link #setPref} or
+     * {@link #invertPref} are restored to their previous values.
+     *
+     * @param {function} func The function to call.
+     * @param {Object} func The 'this' object with which to call *func*
+     * @see #pushContext
+     * @see #popContext
+     */
+    withContext: function (func, self) {
+        try {
+            this.pushContext();
+            return func.call(self);
+        }
+        finally {
+            this.popContext();
+        }
+    }
+}, {
+}, {
+    completion: function (dactyl, modules) {
+        modules.completion.preference = function preference(context) {
+            context.anchored = false;
+            context.title = [config.host + " Preference", "Value"];
+            context.keys = { text: function (item) item, description: function (item) prefs.get(item) };
+            context.completions = prefs.getNames();
+        };
+    },
+    javascript: function (dactyl, modules) {
+        modules.JavaScript.setCompleter([this.get, this.safeSet, this.set, this.reset, this.toggle],
+                [function (context) (context.anchored=false, this.getNames().map(function (pref) [pref, ""]))]);
+    }
+});
+
+var localPrefs = Prefs("extensions.dactyl.");
+defineModule.modules.push(localPrefs);
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/sanitizer.jsm b/common/modules/sanitizer.jsm
new file mode 100644 (file)
index 0000000..c7f3ac9
--- /dev/null
@@ -0,0 +1,679 @@
+// Copyright (c) 2009 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2009-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+// TODO:
+//   - fix Sanitize autocommand
+//   - add warning for TIMESPAN_EVERYTHING?
+
+// FIXME:
+//   - finish 1.9.0 support if we're going to support sanitizing in Melodactyl
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("sanitizer", {
+    exports: ["Range", "Sanitizer", "sanitizer"],
+    use: ["config"],
+    require: ["messages", "prefs", "services", "storage", "template", "util"],
+}, this);
+
+let tmp = {};
+JSMLoader.loadSubScript("chrome://browser/content/sanitize.js", tmp);
+tmp.Sanitizer.prototype.__proto__ = Class.prototype;
+
+var Range = Struct("min", "max");
+update(Range.prototype, {
+    contains: function (date) date == null ||
+        (this.min == null || date >= this.min) && (this.max == null || date <= this.max),
+
+    get isEternity() this.max == null && this.min == null,
+    get isSession() this.max == null && this.min == sanitizer.sessionStart,
+
+    get native() this.isEternity ? null : [range.min || 0, range.max == null ? Number.MAX_VALUE : range.max]
+});
+
+var Item = Class("SanitizeItem", {
+    init: function (name, params) {
+        this.name = name;
+        this.description = params.description;
+    },
+
+    // Hack for completion:
+    "0": Class.Property({ get: function () this.name }),
+    "1": Class.Property({ get: function () this.description }),
+
+    description: Messages.Localized(""),
+
+    get cpdPref() (this.builtin ? "" : Item.PREFIX) + Item.BRANCH + Sanitizer.argToPref(this.name),
+    get shutdownPref() (this.builtin ? "" : Item.PREFIX) + Item.SHUTDOWN_BRANCH + Sanitizer.argToPref(this.name),
+    get cpd() prefs.get(this.cpdPref),
+    get shutdown() prefs.get(this.shutdownPref),
+
+    shouldSanitize: function (shutdown) (!shutdown || this.builtin || this.persistent) &&
+        prefs.get(shutdown ? this.shutdownPref : this.pref)
+}, {
+    PREFIX: localPrefs.branch.root,
+    BRANCH: "privacy.cpd.",
+    SHUTDOWN_BRANCH: "privacy.clearOnShutdown."
+});
+
+var Sanitizer = Module("sanitizer", XPCOM([Ci.nsIObserver, Ci.nsISupportsWeakReference], tmp.Sanitizer), {
+    sessionStart: Date.now() * 1000,
+
+    init: function () {
+        const self = this;
+
+        util.addObserver(this);
+
+        services.add("contentprefs", "@mozilla.org/content-pref/service;1", Ci.nsIContentPrefService);
+        services.add("cookies",      "@mozilla.org/cookiemanager;1",        [Ci.nsICookieManager, Ci.nsICookieManager2,
+                                                                             Ci.nsICookieService]);
+        services.add("loginmanager", "@mozilla.org/login-manager;1",        Ci.nsILoginManager);
+        services.add("permissions",  "@mozilla.org/permissionmanager;1",    Ci.nsIPermissionManager);
+
+        this.itemMap = {};
+
+        this.addItem("all", { description: "Sanitize all items", shouldSanitize: function () false });
+        // Builtin items
+        this.addItem("cache",       { builtin: true, description: "Cache" });
+        this.addItem("downloads",   { builtin: true, description: "Download history" });
+        this.addItem("formdata",    { builtin: true, description: "Saved form and search history" });
+        this.addItem("offlineapps", { builtin: true, description: "Offline website data" });
+        this.addItem("passwords",   { builtin: true, description: "Saved passwords" });
+        this.addItem("sessions",    { builtin: true, description: "Authenticated sessions" });
+
+        // These builtin methods don't support hosts or otherwise have
+        // insufficient granularity
+        this.addItem("cookies", {
+            builtin: true,
+            description: "Cookies",
+            persistent: true,
+            action: function (range, host) {
+                for (let c in Sanitizer.iterCookies(host))
+                    if (range.contains(c.creationTime) || timespan.isSession && c.isSession)
+                        services.cookies.remove(c.host, c.name, c.path, false);
+            },
+            override: true
+        });
+        this.addItem("history", {
+            builtin: true,
+            description: "Browsing history",
+            persistent: true,
+            sessionHistory: true,
+            action: function (range, host) {
+                if (host)
+                    services.history.removePagesFromHost(host, true);
+                else
+                    services.history.removeVisitsByTimeframe(this.range.min, this.range.max);
+
+                if (!host)
+                    services.observer.notifyObservers(null, "browser:purge-session-history", "");
+
+                if (!host || util.isDomainURL(prefs.get("general.open_location.last_url"), host))
+                    prefs.reset("general.open_location.last_url");
+            },
+            override: true
+        });
+        if (services.has("privateBrowsing"))
+            this.addItem("host", {
+                description: "All data from the given host",
+                action: function (range, host) {
+                    if (host)
+                        services.privateBrowsing.removeDataFromDomain(host);
+                }
+            });
+        this.addItem("sitesettings", {
+            builtin: true,
+            description: "Site preferences",
+            persistent: true,
+            action: function (range, host) {
+                if (range.isSession)
+                    return;
+                if (host) {
+                    for (let p in Sanitizer.iterPermissions(host)) {
+                        services.permissions.remove(util.createURI(p.host), p.type);
+                        services.permissions.add(util.createURI(p.host), p.type, 0);
+                    }
+                    for (let p in iter(services.contentprefs.getPrefs(util.createURI(host))))
+                        services.contentprefs.removePref(util.createURI(host), p.QueryInterface(Ci.nsIProperty).name);
+                }
+                else {
+                    // "Allow this site to open popups" ...
+                    services.permissions.removeAll();
+                    // Zoom level, ...
+                    services.contentprefs.removeGroupedPrefs();
+                }
+
+                // "Never remember passwords" ...
+                for each (let domain in services.loginmanager.getAllDisabledHosts())
+                    if (!host || util.isSubdomain(domain, host))
+                        services.loginmanager.setLoginSavingEnabled(host, true);
+            },
+            override: true
+        });
+
+        function ourItems(persistent) [
+            item for (item in values(self.itemMap))
+            if (!item.builtin && (!persistent || item.persistent) && item.name !== "all")
+        ];
+
+        function prefOverlay(branch, persistent, local) update(Object.create(local), {
+            before: array.toObject([
+                [branch.substr(Item.PREFIX.length) + "history",
+                    <preferences xmlns={XUL}>{
+                      template.map(ourItems(persistent), function (item)
+                        <preference type="bool" id={branch + item.name} name={branch + item.name}/>)
+                    }</preferences>.*::*]
+            ]),
+            init: function init(win) {
+                let pane = win.document.getElementById("SanitizeDialogPane");
+                for (let [, pref] in iter(pane.preferences))
+                    pref.updateElements();
+                init.superapply(this, arguments);
+            }
+        });
+
+        let (branch = Item.PREFIX + Item.SHUTDOWN_BRANCH) {
+            util.overlayWindow("chrome://browser/content/preferences/sanitize.xul",
+                               function (win) prefOverlay(branch, true, {
+                append: {
+                    SanitizeDialogPane:
+                        <groupbox orient="horizontal" xmlns={XUL}>
+                          <caption label={config.appName + " (see :help privacy)"}/>
+                          <grid flex="1">
+                            <columns><column flex="1"/><column flex="1"/></columns>
+                            <rows>{
+                              let (items = ourItems(true))
+                                 template.map(util.range(0, Math.ceil(items.length / 2)), function (i)
+                                   <row xmlns={XUL}>{
+                                     template.map(items.slice(i * 2, i * 2 + 2), function (item)
+                                       <checkbox xmlns={XUL} label={item.description} preference={branch + item.name}/>)
+                                   }</row>)
+                            }</rows>
+                          </grid>
+                        </groupbox>
+                }
+            }));
+        }
+        let (branch = Item.PREFIX + Item.BRANCH) {
+            util.overlayWindow("chrome://browser/content/sanitize.xul",
+                               function (win) prefOverlay(branch, false, {
+                append: {
+                    itemList: <>
+                        <listitem xmlns={XUL} label="See :help privacy for the following:" disabled="true" style="font-style: italic; font-weight: bold;"/>
+                        {
+                          template.map(ourItems(), function ([item, desc])
+                            <listitem xmlns={XUL} type="checkbox"
+                                      label={config.appName + " " + desc}
+                                      preference={branch + item}
+                                      onsyncfrompreference="return gSanitizePromptDialog.onReadGeneric();"/>)
+                        }
+                    </>
+                },
+                init: function (win) {
+                    let elem =  win.document.getElementById("itemList");
+                    elem.setAttribute("rows", elem.itemCount);
+                    win.Sanitizer = Class("Sanitizer", win.Sanitizer, {
+                        sanitize: function sanitize() {
+                            self.withSavedValues(["sanitizing"], function () {
+                                self.sanitizing = true;
+                                sanitize.superapply(this, arguments);
+                                sanitizer.sanitizeItems([item.name for (item in values(self.itemMap))
+                                                         if (item.shouldSanitize(false))],
+                                                        Range.fromArray(this.range || []));
+                            }, this);
+                        }
+                    });
+                }
+            }));
+        }
+    },
+
+    addItem: function addItem(name, params) {
+        let item = this.itemMap[name] || Item(name, params);
+        this.itemMap[name] = item;
+
+        for (let [k, prop] in iterOwnProperties(params))
+            if (!("value" in prop) || !callable(prop.value) && !(k in item))
+                Object.defineProperty(item, k, prop);
+
+        let names = set([name].concat(params.contains || []).map(function (e) "clear-" + e));
+        if (params.action)
+            storage.addObserver("sanitizer",
+                function (key, event, arg) {
+                    if (event in names)
+                        params.action.apply(params, arg);
+                },
+                Class.objectGlobal(params.action));
+
+        if (params.privateEnter || params.privateLeave)
+            storage.addObserver("private-mode",
+                function (key, event, arg) {
+                    let meth = params[arg ? "privateEnter" : "privateLeave"];
+                    if (meth)
+                        meth.call(params);
+                },
+                Class.objectGlobal(params.action));
+    },
+
+    observers: {
+        "browser:purge-domain-data": function (subject, host) {
+            storage.fireEvent("sanitize", "domain", host);
+            // If we're sanitizing, our own sanitization functions will already
+            // be called, and with much greater granularity. Only process this
+            // event if it's triggered externally.
+            if (!this.sanitizing)
+                this.sanitizeItems(null, Range(), data);
+        },
+        "browser:purge-session-history": function (subject, data) {
+            // See above.
+            if (!this.sanitizing)
+                this.sanitizeItems(null, Range(this.sessionStart), null, "sessionHistory");
+        },
+        "quit-application-granted": function (subject, data) {
+            if (this.runAtShutdown && !this.sanitizeItems(null, Range(), null, "shutdown"))
+                this.ranAtShutdown = true;
+        },
+        "private-browsing": function (subject, data) {
+            if (data == "enter")
+                storage.privateMode = true;
+            else if (data == "exit")
+                storage.privateMode = false;
+            storage.fireEvent("private-mode", "change", storage.privateMode);
+        }
+    },
+
+    get ranAtShutdown()    localPrefs.get("didSanitizeOnShutdown"),
+    set ranAtShutdown(val) localPrefs.set("didSanitizeOnShutdown", Boolean(val)),
+    get runAtShutdown()    prefs.get("privacy.sanitize.sanitizeOnShutdown"),
+    set runAtShutdown(val) prefs.set("privacy.sanitize.sanitizeOnShutdown", Boolean(val)),
+
+    sanitize: function (items, range)
+        this.withSavedValues(["sanitizing"], function () {
+            this.sanitizing = true;
+            let errors = this.sanitizeItems(items, range, null);
+
+            for (let itemName in values(items)) {
+                try {
+                    let item = this.items[Sanitizer.argToPref(itemName)];
+                    if (item && !this.itemMap[itemName].override) {
+                        item.range = range.native;
+                        if ("clear" in item && item.canClear)
+                            item.clear();
+                    }
+                }
+                catch (e) {
+                    errors = errors || {};
+                    errors[itemName] = e;
+                    util.dump("Error sanitizing " + itemName);
+                    util.reportError(e);
+                }
+            }
+            return errors;
+        }),
+
+    sanitizeItems: function (items, range, host, key)
+        this.withSavedValues(["sanitizing"], function () {
+            this.sanitizing = true;
+            if (items == null)
+                items = Object.keys(this.itemMap);
+
+            let errors;
+            for (let itemName in values(items))
+                try {
+                    if (!key || this.itemMap[itemName][key])
+                        storage.fireEvent("sanitizer", "clear-" + itemName, [range, host]);
+                }
+                catch (e) {
+                    errors = errors || {};
+                    errors[itemName] = e;
+                    util.dump("Error sanitizing " + itemName);
+                    util.reportError(e);
+                }
+            return errors;
+        })
+}, {
+    PERMS: {
+        unset:   0,
+        allow:   1,
+        deny:    2,
+        session: 8
+    },
+    UNPERMS: Class.memoize(function () iter(this.PERMS).map(Array.reverse).toObject()),
+    COMMANDS: {
+        unset:   "Unset",
+        allow:   "Allowed",
+        deny:    "Denied",
+        session: "Allowed for the current session",
+        list:    "List all cookies for domain",
+        clear:   "Clear all cookies for domain",
+        "clear-persistent": "Clear all persistent cookies for domain",
+        "clear-session":    "Clear all session cookies for domain"
+    },
+
+    argPrefMap: {
+        offlineapps:  "offlineApps",
+        sitesettings: "siteSettings"
+    },
+    argToPref: function (arg) Sanitizer.argPrefMap[arg] || arg,
+    prefToArg: function (pref) pref.replace(/.*\./, "").toLowerCase(),
+
+    iterCookies: function iterCookies(host) {
+        let iterator = host ? services.cookies.getCookiesFromHost(host)
+                            : services.cookies;
+        for (let c in iter(iterator))
+            yield c.QueryInterface(Ci.nsICookie2);
+    },
+    iterPermissions: function iterPermissions(host) {
+        for (let p in iter(services.permissions)) {
+            p.QueryInterface(Ci.nsIPermission);
+            if (!host || util.isSubdomain(p.host, host))
+                yield p;
+        }
+    }
+}, {
+    load: function (dactyl, modules, window) {
+        if (sanitizer.runAtShutdown && !sanitizer.ranAtShutdown)
+            sanitizer.sanitizeItems(null, Range(), null, "shutdown");
+        sanitizer.ranAtShutdown = false;
+    },
+    autocommands: function (dactyl, modules, window) {
+        storage.addObserver("private-mode",
+            function (key, event, value) {
+                modules.autocommands.trigger("PrivateMode", { state: value });
+            }, window);
+        storage.addObserver("sanitizer",
+            function (key, event, value) {
+                if (event == "domain")
+                    modules.autocommands.trigger("SanitizeDomain", { domain: value });
+                else if (!value[1])
+                    modules.autocommands.trigger("Sanitize", { name: event.substr("clear-".length), domain: value[1] });
+            }, window);
+    },
+    commands: function (dactyl, modules, window) {
+        const { commands } = modules;
+        commands.add(["sa[nitize]"],
+            "Clear private data",
+            function (args) {
+                dactyl.assert(!modules.options['private'], "Cannot sanitize items in private mode");
+
+                let timespan = args["-timespan"] || modules.options["sanitizetimespan"];
+
+                let range = Range();
+                let [match, num, unit] = /^(\d+)([mhdw])$/.exec(timespan) || [];
+                range[args["-older"] ? "max" : "min"] =
+                    match ? 1000 * (Date.now() - 1000 * parseInt(num, 10) * { m: 60, h: 3600, d: 3600 * 24, w: 3600 * 24 * 7 }[unit])
+                          : (timespan[0] == "s" ? sanitizer.sessionStart : null);
+
+                let items = args.slice();
+                if (args["-host"] && !args.length)
+                    args[0] = "all";
+
+                if (args.bang) {
+                    dactyl.assert(args.length == 0, _("error.trailing"));
+                    items = Object.keys(sanitizer.itemMap).filter(
+                        function (k) modules.options.get("sanitizeitems").has(k));
+                }
+                else
+                    dactyl.assert(modules.options.get("sanitizeitems").validator(items), "Valid items required");
+
+                if (items.indexOf("all") >= 0)
+                    items = Object.keys(sanitizer.itemMap).filter(function (k) items.indexOf(k) === -1);
+
+                sanitizer.range = range.native;
+                sanitizer.ignoreTimespan = range.min == null;
+                sanitizer.sanitizing = true;
+                if (args["-host"]) {
+                    args["-host"].forEach(function (host) {
+                        sanitizer.sanitizing = true;
+                        sanitizer.sanitizeItems(items, range, host)
+                    });
+                }
+                else
+                    sanitizer.sanitize(items, range);
+            },
+            {
+                argCount: "*", // FIXME: should be + and 0
+                bang: true,
+                completer: function (context) {
+                    context.title = ["Privacy Item", "Description"];
+                    context.completions = modules.options.get("sanitizeitems").values;
+                },
+                domains: function (args) args["-host"] || [],
+                options: [
+                    {
+                        names: ["-host", "-h"],
+                        description: "Only sanitize items referring to listed host or hosts",
+                        completer: function (context, args) {
+                            context.filters.push(function (item)
+                                !args["-host"].some(function (host) util.isSubdomain(item.text, host)));
+                            modules.completion.domain(context);
+                        },
+                        type: modules.CommandOption.LIST,
+                    }, {
+                        names: ["-older", "-o"],
+                        description: "Sanitize items older than timespan",
+                        type: modules.CommandOption.NOARG
+                    }, {
+                        names: ["-timespan", "-t"],
+                        description: "Timespan for which to sanitize items",
+                        completer: function (context) modules.options.get("sanitizetimespan").completer(context),
+                        type: modules.CommandOption.STRING,
+                        validator: function (arg) modules.options.get("sanitizetimespan").validator(arg),
+                    }
+                ],
+                privateData: true
+            });
+
+            function getPerms(host) {
+                let uri = util.createURI(host);
+                if (uri)
+                    return Sanitizer.UNPERMS[services.permissions.testPermission(uri, "cookie")];
+                return "unset";
+            }
+            function setPerms(host, perm) {
+                let uri = util.createURI(host);
+                services.permissions.remove(uri, "cookie");
+                services.permissions.add(uri, "cookie", Sanitizer.PERMS[perm]);
+            }
+            commands.add(["cookies", "ck"],
+                "Change cookie permissions for sites",
+                function (args) {
+                    let host = args.shift();
+                    let session = true;
+                    if (!args.length)
+                        args = modules.options["cookies"];
+
+                    for (let [, cmd] in Iterator(args))
+                        switch (cmd) {
+                        case "clear":
+                            for (let c in Sanitizer.iterCookies(host))
+                                services.cookies.remove(c.host, c.name, c.path, false);
+                            break;
+                        case "clear-persistent":
+                            session = false;
+                        case "clear-session":
+                            for (let c in Sanitizer.iterCookies(host))
+                                if (c.isSession == session)
+                                    services.cookies.remove(c.host, c.name, c.path, false);
+                            return;
+
+                        case "list":
+                            modules.commandline.commandOutput(template.tabular(
+                                ["Host", "Expiry (UTC)", "Path", "Name", "Value"],
+                                ["padding-right: 1em", "padding-right: 1em", "padding-right: 1em", "max-width: 12em; overflow: hidden;", "padding-left: 1ex;"],
+                                ([c.host,
+                                  c.isSession ? <span highlight="Enabled">session</span>
+                                              : (new Date(c.expiry * 1000).toJSON() || "Never").replace(/:\d\d\.000Z/, "").replace("T", " ").replace(/-/g, "/"),
+                                  c.path,
+                                  c.name,
+                                  c.value]
+                                  for (c in Sanitizer.iterCookies(host)))));
+                            return;
+                        default:
+                            util.assert(cmd in Sanitizer.PERMS, _("error.invalidArgument"));
+                            setPerms(host, cmd);
+                        }
+                }, {
+                    argCount: "+",
+                    completer: function (context, args) {
+                        switch (args.completeArg) {
+                        case 0:
+                            modules.completion.visibleHosts(context);
+                            context.title[1] = "Current Permissions";
+                            context.keys.description = function desc(host) {
+                                let count = [0, 0];
+                                for (let c in Sanitizer.iterCookies(host))
+                                    count[c.isSession + 0]++;
+                                return <>{Sanitizer.COMMANDS[getPerms(host)]} (session: {count[1]} persistent: {count[0]})</>;
+                            };
+                            break;
+                        case 1:
+                            context.completions = Sanitizer.COMMANDS;
+                            break;
+                        }
+                    },
+                });
+    },
+    completion: function (dactyl, modules, window) {
+        modules.completion.visibleHosts = function completeHosts(context) {
+            let res = util.visibleHosts(window.content);
+            if (context.filter && !res.some(function (host) host.indexOf(context.filter) >= 0))
+                res.push(context.filter);
+
+            context.title = ["Domain"];
+            context.anchored = false;
+            context.compare = modules.CompletionContext.Sort.unsorted;
+            context.keys = { text: util.identity, description: util.identity };
+            context.completions = res;
+        };
+    },
+    options: function (dactyl, modules) {
+        const options = modules.options;
+        if (services.has("privateBrowsing"))
+            options.add(["private", "pornmode"],
+                "Set the 'private browsing' option",
+                "boolean", false,
+                {
+                    initialValue: true,
+                    getter: function () services.privateBrowsing.privateBrowsingEnabled,
+                    setter: function (value) {
+                        if (services.privateBrowsing.privateBrowsingEnabled != value)
+                            services.privateBrowsing.privateBrowsingEnabled = value;
+                    },
+                    persist: false
+                });
+
+        options.add(["sanitizeitems", "si"],
+            "The default list of private items to sanitize",
+            "stringlist", "all",
+            {
+                get values() values(sanitizer.itemMap).toArray(),
+                has: modules.Option.has.toggleAll,
+                validator: function (values) values.length &&
+                    values.every(function (val) val === "all" || set.has(sanitizer.itemMap, val))
+            });
+
+        options.add(["sanitizeshutdown", "ss"],
+            "The items to sanitize automatically at shutdown",
+            "stringlist", "",
+            {
+                initialValue: true,
+                get values() [i for (i in values(sanitizer.itemMap)) if (i.persistent || i.builtin)],
+                getter: function () !sanitizer.runAtShutdown ? [] : [
+                    item.name for (item in values(sanitizer.itemMap))
+                    if (item.shouldSanitize(true))
+                ],
+                setter: function (value) {
+                    if (value.length === 0)
+                        sanitizer.runAtShutdown = false;
+                    else {
+                        sanitizer.runAtShutdown = true;
+                        let have = set(value);
+                        for (let item in values(sanitizer.itemMap))
+                            prefs.set(item.shutdownPref,
+                                      Boolean(set.has(have, item.name) ^ set.has(have, "all")));
+                    }
+                    return value;
+                }
+            });
+
+        options.add(["sanitizetimespan", "sts"],
+            "The default sanitizer time span",
+            "string", "all",
+            {
+                completer: function (context) {
+                    context.compare = context.constructor.Sort.Unsorted;
+                    context.completions = this.values;
+                },
+                values: {
+                    "all":     "Everything",
+                    "session": "The current session",
+                    "10m":     "Last ten minutes",
+                    "1h":      "Past hour",
+                    "1d":      "Past day",
+                    "1w":      "Past week"
+                },
+                validator: function (value) /^(a(ll)?|s(ession)|\d+[mhdw])$/.test(value)
+            });
+
+        options.add(["cookies", "ck"],
+            "The default mode for newly added cookie permissions",
+            "stringlist", "session",
+            { get values() iter(Sanitizer.COMMANDS) });
+
+        options.add(["cookieaccept", "ca"],
+            "When to accept cookies",
+            "string", "all",
+            {
+                PREF: "network.cookie.cookieBehavior",
+                values: [
+                    ["all", "Accept all cookies"],
+                    ["samesite", "Accept all non-third-party cookies"],
+                    ["none", "Accept no cookies"]
+                ],
+                getter: function () (this.values[prefs.get(this.PREF)] || ["all"])[0],
+                setter: function (val) {
+                    prefs.set(this.PREF, this.values.map(function (i) i[0]).indexOf(val));
+                    return val;
+                },
+                initialValue: true,
+                persist: false
+            });
+
+        options.add(["cookielifetime", "cl"],
+            "The lifetime for which to accept cookies",
+            "string", "default", {
+                PREF: "network.cookie.lifetimePolicy",
+                PREF_DAYS: "network.cookie.lifetime.days",
+                values: [
+                    ["default", "The lifetime requested by the setter"],
+                    ["prompt",  "Always prompt for a lifetime"],
+                    ["session", "The current session"]
+                ],
+                getter: function () (this.values[prefs.get(this.PREF)] || [prefs.get(this.PREF_DAYS)])[0],
+                setter: function (value) {
+                    let val = this.values.map(function (i) i[0]).indexOf(value);
+                    if (val > -1)
+                        prefs.set(this.PREF, val);
+                    else {
+                        prefs.set(this.PREF, 3);
+                        prefs.set(this.PREF_DAYS, parseInt(value));
+                    }
+                },
+                initialValue: true,
+                persist: false,
+                validator: function (val) parseInt(val) == val || modules.Option.validateCompleter.call(this, val)
+            });
+    }
+});
+
+endModule();
+
+} catch(e){dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack);}
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/services.jsm b/common/modules/services.jsm
new file mode 100644 (file)
index 0000000..bceb265
--- /dev/null
@@ -0,0 +1,187 @@
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+var global = this;
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("services", {
+    exports: ["services"],
+    use: ["util"]
+}, this);
+
+/**
+ * A lazily-instantiated XPCOM class and service cache.
+ */
+var Services = Module("Services", {
+    init: function () {
+        this.services = {};
+
+        this.add("annotation",          "@mozilla.org/browser/annotation-service;1",        "nsIAnnotationService");
+        this.add("appShell",            "@mozilla.org/appshell/appShellService;1",          "nsIAppShellService");
+        this.add("appStartup",          "@mozilla.org/toolkit/app-startup;1",               "nsIAppStartup");
+        this.add("autoCompleteSearch",  "@mozilla.org/autocomplete/search;1?name=history",  "nsIAutoCompleteSearch");
+        this.add("bookmarks",           "@mozilla.org/browser/nav-bookmarks-service;1",     "nsINavBookmarksService");
+        this.add("browserSearch",       "@mozilla.org/browser/search-service;1",            "nsIBrowserSearchService");
+        this.add("cache",               "@mozilla.org/network/cache-service;1",             "nsICacheService");
+        this.add("charset",             "@mozilla.org/charset-converter-manager;1",         "nsICharsetConverterManager");
+        this.add("chromeRegistry",      "@mozilla.org/chrome/chrome-registry;1",            "nsIXULChromeRegistry");
+        this.add("commandLineHandler",  "@mozilla.org/commandlinehandler/general-startup;1?type=dactyl");
+        this.add("console",             "@mozilla.org/consoleservice;1",                    "nsIConsoleService");
+        this.add("dactyl:",             "@mozilla.org/network/protocol;1?name=dactyl");
+        this.add("debugger",            "@mozilla.org/js/jsd/debugger-service;1",           "jsdIDebuggerService");
+        this.add("directory",           "@mozilla.org/file/directory_service;1",            "nsIProperties");
+        this.add("downloadManager",     "@mozilla.org/download-manager;1",                  "nsIDownloadManager");
+        this.add("environment",         "@mozilla.org/process/environment;1",               "nsIEnvironment");
+        this.add("extensionManager",    "@mozilla.org/extensions/manager;1",                "nsIExtensionManager");
+        this.add("externalProtocol",    "@mozilla.org/uriloader/external-protocol-service;1", "nsIExternalProtocolService");
+        this.add("favicon",             "@mozilla.org/browser/favicon-service;1",           "nsIFaviconService");
+        this.add("focus",               "@mozilla.org/focus-manager;1",                     "nsIFocusManager");
+        this.add("history",             "@mozilla.org/browser/global-history;2",
+                 ["nsIBrowserHistory", "nsIGlobalHistory3", "nsINavHistoryService", "nsPIPlacesDatabase"]);
+        this.add("io",                  "@mozilla.org/network/io-service;1",                "nsIIOService");
+        this.add("json",                "@mozilla.org/dom/json;1",                          "nsIJSON", "createInstance");
+        this.add("livemark",            "@mozilla.org/browser/livemark-service;2",          "nsILivemarkService");
+        this.add("mime",                "@mozilla.org/mime;1",                              "nsIMIMEService");
+        this.add("observer",            "@mozilla.org/observer-service;1",                  "nsIObserverService");
+        this.add("pref",                "@mozilla.org/preferences-service;1",               ["nsIPrefBranch2", "nsIPrefService"]);
+        this.add("privateBrowsing",     "@mozilla.org/privatebrowsing;1",                   "nsIPrivateBrowsingService");
+        this.add("profile",             "@mozilla.org/toolkit/profile-service;1",           "nsIToolkitProfileService");
+        this.add("resource:",           "@mozilla.org/network/protocol;1?name=resource",    ["nsIProtocolHandler", "nsIResProtocolHandler"]);
+        this.add("runtime",             "@mozilla.org/xre/runtime;1",                       ["nsIXULAppInfo", "nsIXULRuntime"]);
+        this.add("rdf",                 "@mozilla.org/rdf/rdf-service;1",                   "nsIRDFService");
+        this.add("sessionStore",        "@mozilla.org/browser/sessionstore;1",              "nsISessionStore");
+        this.add("stringBundle",        "@mozilla.org/intl/stringbundle;1",                 "nsIStringBundleService");
+        this.add("stylesheet",          "@mozilla.org/content/style-sheet-service;1",       "nsIStyleSheetService");
+        this.add("subscriptLoader",     "@mozilla.org/moz/jssubscript-loader;1",            "mozIJSSubScriptLoader");
+        this.add("tagging",             "@mozilla.org/browser/tagging-service;1",           "nsITaggingService");
+        this.add("threading",           "@mozilla.org/thread-manager;1",                    "nsIThreadManager");
+        this.add("urifixup",            "@mozilla.org/docshell/urifixup;1",                 "nsIURIFixup");
+        this.add("versionCompare",      "@mozilla.org/xpcom/version-comparator;1",          "nsIVersionComparator");
+        this.add("windowMediator",      "@mozilla.org/appshell/window-mediator;1",          "nsIWindowMediator");
+        this.add("windowWatcher",       "@mozilla.org/embedcomp/window-watcher;1",          "nsIWindowWatcher");
+        this.add("zipReader",           "@mozilla.org/libjar/zip-reader-cache;1",           "nsIZipReaderCache");
+
+        this.addClass("CharsetConv",  "@mozilla.org/intl/scriptableunicodeconverter", "nsIScriptableUnicodeConverter", "charset");
+        this.addClass("File",         "@mozilla.org/file/local;1",                 "nsILocalFile");
+        this.addClass("file:",        "@mozilla.org/network/protocol;1?name=file", "nsIFileProtocolHandler");
+        this.addClass("Find",         "@mozilla.org/embedcomp/rangefind;1",        "nsIFind");
+        this.addClass("HtmlConverter","@mozilla.org/widget/htmlformatconverter;1", "nsIFormatConverter");
+        this.addClass("HtmlEncoder",  "@mozilla.org/layout/htmlCopyEncoder;1",     "nsIDocumentEncoder");
+        this.addClass("InputStream",  "@mozilla.org/scriptableinputstream;1",      "nsIScriptableInputStream", "init");
+        this.addClass("Persist",      "@mozilla.org/embedding/browser/nsWebBrowserPersist;1", "nsIWebBrowserPersist");
+        this.addClass("Pipe",         "@mozilla.org/pipe;1",                       "nsIPipe", "init");
+        this.addClass("Process",      "@mozilla.org/process/util;1",               "nsIProcess", "init");
+        this.addClass("StreamChannel","@mozilla.org/network/input-stream-channel;1",
+                      ["nsIChannel", "nsIInputStreamChannel", "nsIRequest"], "setURI");
+        this.addClass("StreamCopier", "@mozilla.org/network/async-stream-copier;1","nsIAsyncStreamCopier", "init");
+        this.addClass("String",       "@mozilla.org/supports-string;1",            "nsISupportsString", "data");
+        this.addClass("StringStream", "@mozilla.org/io/string-input-stream;1",     "nsIStringInputStream", "data");
+        this.addClass("Transfer",     "@mozilla.org/transfer;1",                   "nsITransfer", "init");
+        this.addClass("Timer",        "@mozilla.org/timer;1",                      "nsITimer", "initWithCallback");
+        this.addClass("Xmlhttp",      "@mozilla.org/xmlextras/xmlhttprequest;1",   "nsIXMLHttpRequest");
+        this.addClass("XPathEvaluator", "@mozilla.org/dom/xpath-evaluator;1",      "nsIDOMXPathEvaluator");
+        this.addClass("ZipReader",    "@mozilla.org/libjar/zip-reader;1",          "nsIZipReader", "open");
+        this.addClass("ZipWriter",    "@mozilla.org/zipwriter;1",                  "nsIZipWriter");
+    },
+    reinit: function () {},
+
+    _create: function (classes, ifaces, meth, init, args) {
+        try {
+            let res = Cc[classes][meth || "getService"]();
+            if (!ifaces)
+                return res["wrapped" + "JSObject"]; // Kill stupid validator warning
+            Array.concat(ifaces).forEach(function (iface) res.QueryInterface(Ci[iface]));
+            if (init && args.length) {
+                try {
+                    var isCallable = callable(res[init]);
+                }
+                catch (e) {} // Ugh.
+
+                if (isCallable)
+                    res[init].apply(res, args);
+                else
+                    res[init] = args[0];
+            }
+            return res;
+        }
+        catch (e) {
+            if (typeof util !== "undefined")
+                util.reportError(e);
+            else
+                dump("dactyl: Service creation failed for '" + classes + "': " + e + "\n" + (e.stack || Error(e).stack));
+            return null;
+        }
+    },
+
+    /**
+     * Adds a new XPCOM service to the cache.
+     *
+     * @param {string} name The service's cache key.
+     * @param {string} class The class's contract ID.
+     * @param {string|string[]} ifaces The interface or array of
+     *     interfaces implemented by this service.
+     * @param {string} meth The name of the function used to instantiate
+     *     the service.
+     */
+    add: function (name, class_, ifaces, meth) {
+        const self = this;
+        this.services[name] = { class: class_, interfaces: Array.concat(ifaces || []) };
+        if (name in this && ifaces && !this.__lookupGetter__(name) && !(this[name] instanceof Ci.nsISupports))
+            throw TypeError();
+        memoize(this, name, function () self._create(class_, ifaces, meth));
+    },
+
+    /**
+     * Adds a new XPCOM class to the cache.
+     *
+     * @param {string} name The class's cache key.
+     * @param {string} class_ The class's contract ID.
+     * @param {nsISupports|nsISupports[]} ifaces The interface or array of
+     *     interfaces implemented by this class.
+     * @param {string} init Name of a property or method used to initialise the
+     *     class. See {@link #_create}.
+     */
+    addClass: function (name, class_, ifaces, init) {
+        const self = this;
+        this[name] = function () self._create(class_, ifaces, "createInstance", init, arguments);
+        update.apply(null, [this[name]].concat([Ci[i] for each (i in Array.concat(ifaces))]));
+        return this[name];
+    },
+
+    /**
+     * Returns a new instance of the cached class with the specified name.
+     *
+     * @param {string} name The class's cache key.
+     */
+    create: function (name) this[name[0].toUpperCase() + name.substr(1)],
+
+    /**
+     * Returns the cached service with the specified name.
+     *
+     * @param {string} name The service's cache key.
+     */
+    get: function (name) this[name],
+
+    /**
+     * Returns true if the given service is available.
+     *
+     * @param {string} name The service's cache key.
+     */
+    has: function (name) set.has(this.services, name) && this.services[name].class in Cc &&
+        this.services[name].interfaces.every(function (iface) iface in Ci)
+}, {
+}, {
+    javascript: function (dactyl, modules) {
+        modules.JavaScript.setCompleter(this.get, [function () [[k, v] for ([k, v] in Iterator(services)) if (v instanceof Ci.nsISupports)]]);
+    }
+});
+
+endModule();
+
+} catch(e){dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack);}
+
+// vim: set fdm=marker sw=4 sts=4 et ft=javascript:
diff --git a/common/modules/storage.jsm b/common/modules/storage.jsm
new file mode 100644 (file)
index 0000000..28ad0e8
--- /dev/null
@@ -0,0 +1,620 @@
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("storage", {
+    exports: ["File", "Storage", "storage"],
+    require: ["services", "util"]
+}, this);
+
+var win32 = /^win(32|nt)$/i.test(services.runtime.OS);
+var myObject = JSON.parse("{}").constructor;
+
+function loadData(name, store, type) {
+    try {
+        let data = storage.infoPath.child(name).read();
+        let result = JSON.parse(data);
+        if (result instanceof type)
+            return result;
+    }
+    catch (e) {}
+}
+
+function saveData(obj) {
+    if (obj.privateData && storage.privateMode)
+        return;
+    if (obj.store && storage.infoPath)
+        storage.infoPath.child(obj.name).write(obj.serial);
+}
+
+var StoreBase = Class("StoreBase", {
+    OPTIONS: ["privateData", "replacer"],
+
+    fireEvent: function (event, arg) { storage.fireEvent(this.name, event, arg); },
+
+    get serial() JSON.stringify(this._object, this.replacer),
+
+    init: function (name, store, load, options) {
+        this._load = load;
+
+        this.__defineGetter__("store", function () store);
+        this.__defineGetter__("name", function () name);
+        for (let [k, v] in Iterator(options))
+            if (this.OPTIONS.indexOf(k) >= 0)
+                this[k] = v;
+        this.reload();
+    },
+
+    changed: function () { this.timer.tell(); },
+
+    reload: function reload() {
+        this._object = this._load() || this._constructor();
+        this.fireEvent("change", null);
+    },
+
+    delete: function delete_() {
+        delete storage.keys[this.name];
+        delete storage[this.name];
+        storage.infoPath.child(this.name).remove(false);
+    },
+
+    save: function () { saveData(this); },
+
+    __iterator__: function () Iterator(this._object)
+});
+
+var ArrayStore = Class("ArrayStore", StoreBase, {
+    _constructor: Array,
+
+    get length() this._object.length,
+
+    set: function set(index, value) {
+        var orig = this._object[index];
+        this._object[index] = value;
+        this.fireEvent("change", index);
+    },
+
+    push: function push(value) {
+        this._object.push(value);
+        this.fireEvent("push", this._object.length);
+    },
+
+    pop: function pop(value) {
+        var res = this._object.pop();
+        this.fireEvent("pop", this._object.length);
+        return res;
+    },
+
+    truncate: function truncate(length, fromEnd) {
+        var res = this._object.length;
+        if (this._object.length > length) {
+            if (fromEnd)
+                this._object.splice(0, this._object.length - length);
+            this._object.length = length;
+            this.fireEvent("truncate", length);
+        }
+        return res;
+    },
+
+    // XXX: Awkward.
+    mutate: function mutate(funcName) {
+        var _funcName = funcName;
+        arguments[0] = this._object;
+        this._object = Array[_funcName].apply(Array, arguments);
+        this.fireEvent("change", null);
+    },
+
+    get: function get(index) index >= 0 ? this._object[index] : this._object[this._object.length + index]
+});
+
+var ObjectStore = Class("ObjectStore", StoreBase, {
+    _constructor: myObject,
+
+    clear: function () {
+        this._object = {};
+        this.fireEvent("clear");
+    },
+
+    get: function get(key, default_) {
+        return key in this._object  ? this._object[key] :
+               arguments.length > 1 ? this.set(key, default_) :
+                                      undefined;
+    },
+
+    keys: function keys() Object.keys(this._object),
+
+    remove: function remove(key) {
+        var res = this._object[key];
+        delete this._object[key];
+        this.fireEvent("remove", key);
+        return res;
+    },
+
+    set: function set(key, val) {
+        var defined = key in this._object;
+        var orig = this._object[key];
+        this._object[key] = val;
+        if (!defined)
+            this.fireEvent("add", key);
+        else if (orig != val)
+            this.fireEvent("change", key);
+        return val;
+    }
+});
+
+var Storage = Module("Storage", {
+    alwaysReload: {},
+
+    init: function () {
+        this.cleanup();
+    },
+
+    cleanup: function () {
+        this.saveAll();
+
+        for (let key in keys(this.keys)) {
+            if (this[key].timer)
+                this[key].timer.flush();
+            delete this[key];
+        }
+        for (let ary in values(this.observers))
+            for (let obj in values(ary))
+                if (obj.ref && obj.ref.get())
+                    delete obj.ref.get().dactylStorageRefs;
+
+        this.keys = {};
+        this.observers = {};
+    },
+
+    exists: function exists(name) this.infoPath.child(name).exists(),
+
+    newObject: function newObject(key, constructor, params) {
+        if (params == null || !isObject(params))
+            throw Error("Invalid argument type");
+
+        if (!(key in this.keys) || params.reload || this.alwaysReload[key]) {
+            if (key in this && !(params.reload || this.alwaysReload[key]))
+                throw Error();
+            let load = function () loadData(key, params.store, params.type || myObject);
+
+            this.keys[key] = new constructor(key, params.store, load, params);
+            this.keys[key].timer = new Timer(1000, 10000, function () storage.save(key));
+            this.__defineGetter__(key, function () this.keys[key]);
+        }
+        return this.keys[key];
+    },
+
+    newMap: function newMap(key, options) {
+        return this.newObject(key, ObjectStore, options);
+    },
+
+    newArray: function newArray(key, options) {
+        return this.newObject(key, ArrayStore, update({ type: Array }, options));
+    },
+
+    addObserver: function addObserver(key, callback, ref) {
+        if (ref) {
+            if (!ref.dactylStorageRefs)
+                ref.dactylStorageRefs = [];
+            ref.dactylStorageRefs.push(callback);
+            var callbackRef = Cu.getWeakReference(callback);
+        }
+        else {
+            callbackRef = { get: function () callback };
+        }
+        this.removeDeadObservers();
+        if (!(key in this.observers))
+            this.observers[key] = [];
+        if (!this.observers[key].some(function (o) o.callback.get() == callback))
+            this.observers[key].push({ ref: ref && Cu.getWeakReference(ref), callback: callbackRef });
+    },
+
+    removeObserver: function (key, callback) {
+        this.removeDeadObservers();
+        if (!(key in this.observers))
+            return;
+        this.observers[key] = this.observers[key].filter(function (elem) elem.callback.get() != callback);
+        if (this.observers[key].length == 0)
+            delete obsevers[key];
+    },
+
+    removeDeadObservers: function () {
+        for (let [key, ary] in Iterator(this.observers)) {
+            this.observers[key] = ary = ary.filter(function (o) o.callback.get() && (!o.ref || o.ref.get() && o.ref.get().dactylStorageRefs));
+            if (!ary.length)
+                delete this.observers[key];
+        }
+    },
+
+    fireEvent: function fireEvent(key, event, arg) {
+        this.removeDeadObservers();
+        if (key in this.observers)
+            // Safe, since we have our own Array object here.
+            for each (let observer in this.observers[key])
+                observer.callback.get()(key, event, arg);
+        if (key in this.keys)
+            this[key].timer.tell();
+    },
+
+    load: function load(key) {
+        if (this[key].store && this[key].reload)
+            this[key].reload();
+    },
+
+    save: function save(key) {
+        if (this[key])
+            saveData(this.keys[key]);
+    },
+
+    saveAll: function storeAll() {
+        for each (let obj in this.keys)
+            saveData(obj);
+    },
+
+    _privateMode: false,
+    get privateMode() this._privateMode,
+    set privateMode(val) {
+        if (val && !this._privateMode)
+            this.saveAll();
+        if (!val && this._privateMode)
+            for (let key in this.keys)
+                this.load(key);
+        return this._privateMode = Boolean(val);
+    }
+}, {
+    Replacer: {
+        skipXpcom: function skipXpcom(key, val) val instanceof Ci.nsISupports ? null : val
+    }
+}, {
+    init: function init(dactyl, modules) {
+        init.superapply(this, arguments);
+        storage.infoPath = File(modules.IO.runtimePath.replace(/,.*/, ""))
+                             .child("info").child(dactyl.profileName);
+    },
+
+    cleanup: function (dactyl, modules, window) {
+        delete window.dactylStorageRefs;
+        this.removeDeadObservers();
+    }
+});
+
+/**
+ * @class File A class to wrap nsIFile objects and simplify operations
+ * thereon.
+ *
+ * @param {nsIFile|string} path Expanded according to {@link IO#expandPath}
+ * @param {boolean} checkPWD Whether to allow expansion relative to the
+ *          current directory. @default true
+ */
+var File = Class("File", {
+    init: function (path, checkPWD) {
+        let file = services.File();
+
+        if (path instanceof Ci.nsIFile)
+            file = path.QueryInterface(Ci.nsIFile).clone();
+        else if (/file:\/\//.test(path))
+            file = services["file:"]().getFileFromURLSpec(path);
+        else {
+            try {
+                let expandedPath = File.expandPath(path);
+
+                if (!File.isAbsolutePath(expandedPath) && checkPWD)
+                    file = checkPWD.child(expandedPath);
+                else
+                    file.initWithPath(expandedPath);
+            }
+            catch (e) {
+                util.reportError(e);
+                return File.DoesNotExist(path, e);
+            }
+        }
+        let self = XPCSafeJSObjectWrapper(file.QueryInterface(Ci.nsILocalFile));
+        self.__proto__ = this;
+        return self;
+    },
+
+    /**
+     * Iterates over the objects in this directory.
+     */
+    iterDirectory: function () {
+        if (!this.exists())
+            throw Error("File does not exist");
+        if (!this.isDirectory())
+            throw Error("Not a directory");
+        for (let file in iter(this.directoryEntries))
+            yield File(file);
+    },
+
+    /**
+     * Returns a new file for the given child of this directory entry.
+     */
+    child: function (name) {
+        let f = this.constructor(this);
+        for each (let elem in name.split(File.pathSplit))
+            f.append(elem);
+        return f;
+    },
+
+    /**
+     * Reads this file's entire contents in "text" mode and returns the
+     * content as a string.
+     *
+     * @param {string} encoding The encoding from which to decode the file.
+     *          @default options["fileencoding"]
+     * @returns {string}
+     */
+    read: function (encoding) {
+        let ifstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream);
+        ifstream.init(this, -1, 0, 0);
+
+        return File.readStream(ifstream, encoding);
+    },
+
+    /**
+     * Returns the list of files in this directory.
+     *
+     * @param {boolean} sort Whether to sort the returned directory
+     *     entries.
+     * @returns {nsIFile[]}
+     */
+    readDirectory: function (sort) {
+        if (!this.isDirectory())
+            throw Error("Not a directory");
+
+        let array = [e for (e in this.iterDirectory())];
+        if (sort)
+            array.sort(function (a, b) b.isDirectory() - a.isDirectory() || String.localeCompare(a.path, b.path));
+        return array;
+    },
+
+    /**
+     * Returns a new nsIFileURL object for this file.
+     *
+     * @returns {nsIFileURL}
+     */
+    toURI: function toURI() services.io.newFileURI(this),
+
+    /**
+     * Writes the string *buf* to this file.
+     *
+     * @param {string} buf The file content.
+     * @param {string|number} mode The file access mode, a bitwise OR of
+     *     the following flags:
+     *       {@link #MODE_RDONLY}:   0x01
+     *       {@link #MODE_WRONLY}:   0x02
+     *       {@link #MODE_RDWR}:     0x04
+     *       {@link #MODE_CREATE}:   0x08
+     *       {@link #MODE_APPEND}:   0x10
+     *       {@link #MODE_TRUNCATE}: 0x20
+     *       {@link #MODE_SYNC}:     0x40
+     *     Alternatively, the following abbreviations may be used:
+     *       ">"  is equivalent to {@link #MODE_WRONLY} | {@link #MODE_CREATE} | {@link #MODE_TRUNCATE}
+     *       ">>" is equivalent to {@link #MODE_WRONLY} | {@link #MODE_CREATE} | {@link #MODE_APPEND}
+     * @default ">"
+     * @param {number} perms The file mode bits of the created file. This
+     *     is only used when creating a new file and does not change
+     *     permissions if the file exists.
+     * @default 0644
+     * @param {string} encoding The encoding to used to write the file.
+     * @default options["fileencoding"]
+     */
+    write: function (buf, mode, perms, encoding) {
+        let ofstream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(Ci.nsIFileOutputStream);
+        function getStream(defaultChar) {
+            let stream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance(Ci.nsIConverterOutputStream);
+            stream.init(ofstream, encoding, 0, defaultChar);
+            return stream;
+        }
+        if (buf instanceof File)
+            buf = buf.read();
+
+        if (!encoding)
+            encoding = File.defaultEncoding;
+
+        if (mode == ">>")
+            mode = File.MODE_WRONLY | File.MODE_CREATE | File.MODE_APPEND;
+        else if (!mode || mode == ">")
+            mode = File.MODE_WRONLY | File.MODE_CREATE | File.MODE_TRUNCATE;
+
+        if (!perms)
+            perms = octal(644);
+        if (!this.exists()) // OCREAT won't create the directory
+            this.create(this.NORMAL_FILE_TYPE, perms);
+
+        ofstream.init(this, mode, perms, 0);
+        try {
+            if (callable(buf))
+                buf(ofstream.QueryInterface(Ci.nsIOutputStream));
+            else {
+                var ocstream = getStream(0);
+                ocstream.writeString(buf);
+            }
+        }
+        catch (e if callable(buf) && e.result == Cr.NS_ERROR_LOSS_OF_SIGNIFICANT_DATA) {
+            ocstream = getStream("?".charCodeAt(0));
+            ocstream.writeString(buf);
+            return false;
+        }
+        finally {
+            try {
+                ocstream.close();
+            }
+            catch (e) {}
+            ofstream.close();
+        }
+        return true;
+    }
+}, {
+    /**
+     * @property {number} Open for reading only.
+     * @final
+     */
+    MODE_RDONLY: 0x01,
+
+    /**
+     * @property {number} Open for writing only.
+     * @final
+     */
+    MODE_WRONLY: 0x02,
+
+    /**
+     * @property {number} Open for reading and writing.
+     * @final
+     */
+    MODE_RDWR: 0x04,
+
+    /**
+     * @property {number} If the file does not exist, the file is created.
+     *     If the file exists, this flag has no effect.
+     * @final
+     */
+    MODE_CREATE: 0x08,
+
+    /**
+     * @property {number} The file pointer is set to the end of the file
+     *     prior to each write.
+     * @final
+     */
+    MODE_APPEND: 0x10,
+
+    /**
+     * @property {number} If the file exists, its length is truncated to 0.
+     * @final
+     */
+    MODE_TRUNCATE: 0x20,
+
+    /**
+     * @property {number} If set, each write will wait for both the file
+     *     data and file status to be physically updated.
+     * @final
+     */
+    MODE_SYNC: 0x40,
+
+    /**
+     * @property {number} With MODE_CREATE, if the file does not exist, the
+     *     file is created. If the file already exists, no action and NULL
+     *     is returned.
+     * @final
+     */
+    MODE_EXCL: 0x80,
+
+    /**
+     * @property {string} The current platform's path separator.
+     */
+    PATH_SEP: Class.memoize(function () {
+        let f = services.directory.get("CurProcD", Ci.nsIFile);
+        f.append("foo");
+        return f.path.substr(f.parent.path.length, 1);
+    }),
+
+    pathSplit: Class.memoize(function () util.regexp("(?:/|" + util.regexp.escape(this.PATH_SEP) + ")", "g")),
+
+    DoesNotExist: function (path, error) ({
+        path: path,
+        exists: function () false,
+        __noSuchMethod__: function () { throw error || Error("Does not exist"); }
+    }),
+
+    defaultEncoding: "UTF-8",
+
+    /**
+     * Expands "~" and environment variables in *path*.
+     *
+     * "~" is expanded to to the value of $HOME. On Windows if this is not
+     * set then the following are tried in order:
+     *   $USERPROFILE
+     *   ${HOMDRIVE}$HOMEPATH
+     *
+     * The variable notation is $VAR (terminated by a non-word character)
+     * or ${VAR}. %VAR% is also supported on Windows.
+     *
+     * @param {string} path The unexpanded path string.
+     * @param {boolean} relative Whether the path is relative or absolute.
+     * @returns {string}
+     */
+    expandPath: function (path, relative) {
+        function getenv(name) services.environment.get(name);
+
+        // expand any $ENV vars - this is naive but so is Vim and we like to be compatible
+        // TODO: Vim does not expand variables set to an empty string (and documents it).
+        // Kris reckons we shouldn't replicate this 'bug'. --djk
+        // TODO: should we be doing this for all paths?
+        function expand(path) path.replace(
+            !win32 ? /\$(\w+)\b|\${(\w+)}/g
+                   : /\$(\w+)\b|\${(\w+)}|%(\w+)%/g,
+            function (m, n1, n2, n3) getenv(n1 || n2 || n3) || m
+        );
+        path = expand(path);
+
+        // expand ~
+        // Yuck.
+        if (!relative && RegExp("~(?:$|[/" + util.regexp.escape(File.PATH_SEP) + "])").test(path)) {
+            // Try $HOME first, on all systems
+            let home = getenv("HOME");
+
+            // Windows has its own idiosyncratic $HOME variables.
+            if (win32 && (!home || !File(home).exists()))
+                home = getenv("USERPROFILE") ||
+                       getenv("HOMEDRIVE") + getenv("HOMEPATH");
+
+            path = home + path.substr(1);
+        }
+
+        // TODO: Vim expands paths twice, once before checking for ~, once
+        // after, but doesn't document it. Is this just a bug? --Kris
+        path = expand(path);
+        return path.replace("/", File.PATH_SEP, "g");
+    },
+
+    expandPathList: function (list) list.map(this.expandPath),
+
+    readStream: function (ifstream, encoding) {
+        try {
+            var icstream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream);
+            icstream.init(ifstream, encoding || File.defaultEncoding, 4096, // 4096 bytes buffering
+                          Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER);
+            let buffer = [];
+            let str = {};
+            while (icstream.readString(4096, str) != 0)
+                buffer.push(str.value);
+            return buffer.join("");
+        }
+        finally {
+            icstream.close();
+            ifstream.close();
+        }
+    },
+
+    isAbsolutePath: function (path) {
+        try {
+            services.File().initWithPath(path);
+            return true;
+        }
+        catch (e) {
+            return false;
+        }
+    },
+
+    joinPaths: function (head, tail, cwd) {
+        let path = this(head, cwd);
+        try {
+            // FIXME: should only expand environment vars and normalize path separators
+            path.appendRelativePath(this.expandPath(tail, true));
+        }
+        catch (e) {
+            return File.DoesNotExist(e);
+        }
+        return path;
+    },
+
+    replacePathSep: function (path) path.replace("/", File.PATH_SEP, "g")
+});
+
+endModule();
+
+// catch(e){dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack);}
+
+// vim: set fdm=marker sw=4 sts=4 et ft=javascript:
diff --git a/common/modules/styles.jsm b/common/modules/styles.jsm
new file mode 100644 (file)
index 0000000..adfc725
--- /dev/null
@@ -0,0 +1,752 @@
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("styles", {
+    exports: ["Style", "Styles", "styles"],
+    require: ["services", "util"],
+    use: ["contexts", "messages", "template"]
+}, this);
+
+function cssUri(css) "chrome-data:text/css," + encodeURI(css);
+var namespace = "@namespace html " + XHTML.uri.quote() + ";\n" +
+                "@namespace xul " + XUL.uri.quote() + ";\n" +
+                "@namespace dactyl " + NS.uri.quote() + ";\n";
+
+var Sheet = Struct("name", "id", "sites", "css", "hive", "agent");
+Sheet.liveProperty = function (name) {
+    let i = this.prototype.members[name];
+    this.prototype.__defineGetter__(name, function () this[i]);
+    this.prototype.__defineSetter__(name, function (val) {
+        if (isArray(val))
+            val = Array.slice(val);
+        if (isArray(val))
+            Object.freeze(val);
+        this[i] = val;
+        this.enabled = this.enabled;
+    });
+}
+Sheet.liveProperty("agent");
+Sheet.liveProperty("css");
+Sheet.liveProperty("sites");
+update(Sheet.prototype, {
+    formatSites: function (uris)
+          template.map(this.sites,
+                       function (filter) <span highlight={uris.some(Styles.matchFilter(filter)) ? "Filter" : ""}>{filter}</span>,
+                       <>,</>),
+
+    remove: function () { this.hive.remove(this); },
+
+    get uri() "dactyl://style/" + this.id + "/" + this.hive.name + "/" + (this.name || ""),
+
+    get enabled() this._enabled,
+    set enabled(on) {
+        if (on != this._enabled || this.fullCSS != this._fullCSS) {
+            if (on)
+                this.enabled = false;
+            else if (!this._fullCSS)
+                return;
+
+            let meth = on ? "registerSheet" : "unregisterSheet";
+            styles[meth](this.uri, on ? this.agent : this._agent);
+
+            this._agent = this.agent;
+            this._enabled = Boolean(on);
+            this._fullCSS = this.fullCSS;
+        }
+    },
+
+    match: function (uri) {
+        if (isString(uri))
+            uri = util.newURI(uri);
+        return this.sites.some(function (site) Styles.matchFilter(site, uri));
+    },
+
+    get fullCSS() {
+        let filter = this.sites;
+        let css = this.css;
+        if (filter[0] == "*")
+            return namespace + css;
+
+        let selectors = filter.map(function (part)
+                                    (/[*]$/.test(part)   ? "url-prefix" :
+                                     /[\/:]/.test(part)  ? "url"
+                                                         : "domain")
+                                    + '("' + part.replace(/"/g, "%22").replace(/\*$/, "") + '")')
+                              .join(",\n               ");
+        return "/* " + this.uri + (this.agent ? " (agent)" : "") + " */\n\n"
+             + namespace + "\n@-moz-document " + selectors + " {\n\n" + css + "\n\n}\n";
+    }
+});
+
+var Hive = Class("Hive", {
+    init: function (name, persist) {
+        this.name = name;
+        this.sheets = [];
+        this.names = {};
+        this.refs = [];
+        this.persist = persist;
+    },
+
+    get modifiable() this.name !== "system",
+
+    addRef: function (obj) {
+        this.refs.push(Cu.getWeakReference(obj));
+        this.dropRef(null);
+    },
+    dropRef: function (obj) {
+        this.refs = this.refs.filter(function (ref) ref.get() && ref.get() !== obj);
+        if (!this.refs.length) {
+            this.cleanup();
+            styles.hives = styles.hives.filter(function (h) h !== this, this);
+        }
+    },
+
+    cleanup: function cleanup() {
+        for (let sheet in values(this.sheets))
+            sheet.enabled = false;
+    },
+
+    __iterator__: function () Iterator(this.sheets),
+
+    get sites() array(this.sheets).map(function (s) s.sites).flatten().uniq().array,
+
+    /**
+     * Add a new style sheet.
+     *
+     * @param {string} name The name given to the style sheet by
+     *     which it may be later referenced.
+     * @param {string} filter The sites to which this sheet will
+     *     apply. Can be a domain name or a URL. Any URL ending in
+     *     "*" is matched as a prefix.
+     * @param {string} css The CSS to be applied.
+     * @param {boolean} agent If true, the sheet is installed as an
+     *     agent sheet.
+     * @param {boolean} lazy If true, the sheet is not initially enabled.
+     * @returns {Sheet}
+     */
+    add: function add(name, filter, css, agent, lazy) {
+
+        if (!isArray(filter))
+            filter = filter.split(",");
+        if (name && name in this.names) {
+            var sheet = this.names[name];
+            sheet.agent = agent;
+            sheet.css = String(css);
+            sheet.sites = filter;
+        }
+        else {
+            sheet = Sheet(name, styles._id++, filter.filter(util.identity), String(css), this, agent);
+            this.sheets.push(sheet);
+        }
+
+        styles.allSheets[sheet.id] = sheet;
+
+        if (!lazy)
+            sheet.enabled = true;
+
+        if (name)
+            this.names[name] = sheet;
+        return sheet;
+    },
+
+    /**
+     * Get a sheet with a given name or index.
+     *
+     * @param {string or number} sheet The sheet to retrieve. Strings indicate
+     *     sheet names, while numbers indicate indices.
+     */
+    get: function get(sheet) {
+        if (typeof sheet === "number")
+            return this.sheets[sheet];
+        return this.names[sheet];
+    },
+
+    /**
+     * Find sheets matching the parameters. See {@link #addSheet}
+     * for parameters.
+     *
+     * @param {string} name
+     * @param {string} filter
+     * @param {string} css
+     * @param {number} index
+     */
+    find: function find(name, filter, css, index) {
+        // Grossly inefficient.
+        let matches = [k for ([k, v] in Iterator(this.sheets))];
+        if (index)
+            matches = String(index).split(",").filter(function (i) i in this.sheets, this);
+        if (name)
+            matches = matches.filter(function (i) this.sheets[i].name == name, this);
+        if (css)
+            matches = matches.filter(function (i) this.sheets[i].css == css, this);
+        if (filter)
+            matches = matches.filter(function (i) this.sheets[i].sites.indexOf(filter) >= 0, this);
+        return matches.map(function (i) this.sheets[i], this);
+    },
+
+    /**
+     * Remove a style sheet. See {@link #addSheet} for parameters.
+     * In cases where *filter* is supplied, the given filters are removed from
+     * matching sheets. If any remain, the sheet is left in place.
+     *
+     * @param {string} name
+     * @param {string} filter
+     * @param {string} css
+     * @param {number} index
+     */
+    remove: function remove(name, filter, css, index) {
+        let self = this;
+        if (arguments.length == 1) {
+            var matches = [name];
+            name = null;
+        }
+
+        if (filter && filter.indexOf(",") > -1)
+            return filter.split(",").reduce(
+                function (n, f) n + self.removeSheet(name, f, index), 0);
+
+        if (filter == undefined)
+            filter = "";
+
+        if (!matches)
+            matches = this.findSheets(name, filter, css, index);
+        if (matches.length == 0)
+            return null;
+
+        for (let [, sheet] in Iterator(matches.reverse())) {
+            if (filter) {
+                let sites = sheet.sites.filter(function (f) f != filter);
+                if (sites.length) {
+                    sheet.sites = sites;
+                    continue;
+                }
+            }
+            sheet.enabled = false;
+            if (sheet.name)
+                delete this.names[sheet.name];
+            delete styles.allSheets[sheet.id];
+        }
+        this.sheets = this.sheets.filter(function (s) matches.indexOf(s) == -1);
+        return matches.length;
+    },
+});
+
+/**
+ * Manages named and unnamed user style sheets, which apply to both
+ * chrome and content pages.
+ *
+ * @author Kris Maglione <maglione.k@gmail.com>
+ */
+var Styles = Module("Styles", {
+    Local: function (dactyl, modules, window) ({
+        cleanup: function () {}
+    }),
+
+    init: function () {
+        this._id = 0;
+        this.cleanup();
+        this.allSheets = {};
+
+        services["dactyl:"].providers["style"] = function styleProvider(uri) {
+            let id = /^\/(\d*)/.exec(uri.path)[1];
+            if (set.has(styles.allSheets, id))
+                return ["text/css", unescape(encodeURI(styles.allSheets[id].fullCSS))];
+            return null;
+        };
+    },
+
+    cleanup: function cleanup() {
+        for each (let hive in this.hives)
+            util.trapErrors("cleanup", hive);
+        this.hives = [];
+        this.user = this.addHive("user", this, true);
+        this.system = this.addHive("system", this, false);
+    },
+
+    addHive: function addHive(name, ref, persist) {
+        let hive = array.nth(this.hives, function (h) h.name === name, 0);
+        if (!hive) {
+            hive = Hive(name, persist);
+            this.hives.push(hive);
+        }
+        hive.persist = persist;
+        if (ref)
+            hive.addRef(ref);
+        return hive;
+    },
+
+    __iterator__: function () Iterator(this.user.sheets.concat(this.system.sheets)),
+
+    _proxy: function (name, args)
+        let (obj = this[args[0] ? "system" : "user"])
+            obj[name].apply(obj, Array.slice(args, 1)),
+
+    addSheet: deprecated("Styles#{user,system}.add", function addSheet() this._proxy("add", arguments)),
+    findSheets: deprecated("Styles#{user,system}.find", function findSheets() this._proxy("find", arguments)),
+    get: deprecated("Styles#{user,system}.get", function get() this._proxy("get", arguments)),
+    removeSheet: deprecated("Styles#{user,system}.remove", function removeSheet() this._proxy("remove", arguments)),
+
+    userSheets: Class.Property({ get: deprecated("Styles#user.sheets", function userSheets() this.user.sheets) }),
+    systemSheets: Class.Property({ get: deprecated("Styles#system.sheets", function systemSheets() this.system.sheets) }),
+    userNames: Class.Property({ get: deprecated("Styles#user.names", function userNames() this.user.names) }),
+    systemNames: Class.Property({ get: deprecated("Styles#system.names", function systemNames() this.system.names) }),
+    sites: Class.Property({ get: deprecated("Styles#user.sites", function sites() this.user.sites) }),
+
+    list: function list(content, filter, name, hives) {
+        const { commandline, dactyl } = this.modules;
+
+        hives = hives || styles.hives.filter(function (h) h.modifiable && h.sheets.length);
+
+        function sheets(group)
+            group.sheets.slice()
+                 .sort(function (a, b) a.name && b.name ? String.localeCompare(a.name, b.name)
+                                                        : !!b.name - !!a.name || a.id - b.id);
+
+        let uris = util.visibleURIs(content);
+
+        let list = <table>
+                <tr highlight="Title">
+                    <td/>
+                    <td/>
+                    <td style="padding-right: 1em;">Name</td>
+                    <td style="padding-right: 1em;">Filter</td>
+                    <td style="padding-right: 1em;">CSS</td>
+                </tr>
+                <col style="min-width: 4em; padding-right: 1em;"/>
+                <col style="min-width: 1em; text-align: center; color: red; font-weight: bold;"/>
+                <col style="padding: 0 1em 0 1ex; vertical-align: top;"/>
+                <col style="padding: 0 1em 0 0; vertical-align: top;"/>
+                {
+                    template.map(hives, function (hive) let (i = 0)
+                        <tr style="height: .5ex;"/> +
+                        template.map(sheets(hive), function (sheet)
+                            <tr>
+                                <td highlight="Title">{!i++ ? hive.name : ""}</td>
+                                <td>{sheet.enabled ? "" : UTF8("×")}</td>
+                                <td>{sheet.name || hive.sheets.indexOf(sheet)}</td>
+                                <td>{sheet.formatSites(uris)}</td>
+                                <td>{sheet.css}</td>
+                            </tr>) +
+                        <tr style="height: .5ex;"/>)
+                }
+                </table>;
+
+        // TODO: Move this to an ItemList to show this automatically
+        if (list.*.length() === list.text().length() + 5)
+            dactyl.echomsg(_("style.none"));
+        else
+            commandline.commandOutput(list);
+    },
+
+    registerSheet: function registerSheet(url, agent, reload) {
+        let uri = services.io.newURI(url, null, null);
+        if (reload)
+            this.unregisterSheet(url, agent);
+
+        let type = services.stylesheet[agent ? "AGENT_SHEET" : "USER_SHEET"];
+        if (reload || !services.stylesheet.sheetRegistered(uri, type))
+            services.stylesheet.loadAndRegisterSheet(uri, type);
+    },
+
+    unregisterSheet: function unregisterSheet(url, agent) {
+        let uri = services.io.newURI(url, null, null);
+        let type = services.stylesheet[agent ? "AGENT_SHEET" : "USER_SHEET"];
+        if (services.stylesheet.sheetRegistered(uri, type))
+            services.stylesheet.unregisterSheet(uri, type);
+    },
+}, {
+    append: function (dest, src, sort) {
+        let props = {};
+        for each (let str in [dest, src])
+            for (let prop in Styles.propertyIter(str))
+                props[prop.name] = prop.value;
+
+        return Object.keys(props)[sort ? "sort" : "slice"]()
+                     .map(function (prop) prop + ": " + props[prop] + ";")
+                     .join(" ");
+    },
+
+    completeSite: function (context, content, group) {
+        group = group || styles.user;
+        context.anchored = false;
+        try {
+            context.fork("current", 0, this, function (context) {
+                context.title = ["Current Site"];
+                context.completions = [
+                    [content.location.host, "Current Host"],
+                    [content.location.href, "Current URL"]
+                ];
+            });
+        }
+        catch (e) {}
+
+        let uris = util.visibleURIs(content);
+
+        context.generate = function () values(group.sites);
+
+        context.keys.text = util.identity;
+        context.keys.description = function (site) this.sheets.length + " sheet" + (this.sheets.length == 1 ? "" : "s") + ": " +
+            array.compact(this.sheets.map(function (s) s.name)).join(", ");
+        context.keys.sheets = function (site) group.sheets.filter(function (s) s.sites.indexOf(site) >= 0);
+        context.keys.active = function (site) uris.some(Styles.matchFilter(site));
+
+        Styles.splitContext(context, "Sites");
+    },
+
+    /**
+     * A curried function which determines which host names match a
+     * given stylesheet filter. When presented with one argument,
+     * returns a matcher function which, given one nsIURI argument,
+     * returns true if that argument matches the given filter. When
+     * given two arguments, returns true if the second argument matches
+     * the given filter.
+     *
+     * @param {string} filter The URI filter to match against.
+     * @param {nsIURI} uri The location to test.
+     * @returns {nsIURI -> boolean}
+     */
+    matchFilter: function (filter) {
+        if (filter === "*")
+            var test = function test(uri) true;
+        else if (!/^(?:[a-z-]+:|[a-z-.]+$)/.test(filter)) {
+            let re = util.regexp(filter);
+            test = function test(uri) re.test(uri.spec);
+        }
+        else if (/[*]$/.test(filter)) {
+            let re = RegExp("^" + util.regexp.escape(filter.substr(0, filter.length - 1)));
+            test = function test(uri) re.test(uri.spec);
+        }
+        else if (/[\/:]/.test(filter))
+            test = function test(uri) uri.spec === filter;
+        else
+            test = function test(uri) { try { return util.isSubdomain(uri.host, filter); } catch (e) { return false; } };
+        test.toString = function toString() filter;
+        if (arguments.length < 2)
+            return test;
+        return test(arguments[1]);
+    },
+
+    splitContext: function splitContext(context, title) {
+        for (let item in Iterator({ Active: true, Inactive: false })) {
+            let [name, active] = item;
+            context.split(name, null, function (context) {
+                context.title[0] = name + " " + (title || "Sheets");
+                context.filters.push(function (item) !!item.active == active);
+            });
+        }
+    },
+
+    propertyIter: function (str, always) {
+        let i = 0;
+        for (let match in this.propertyPattern.iterate(str)) {
+            if (match.value || always && match.name || match.wholeMatch === match.preSpace && always && !i++)
+                yield match;
+            if (!/;/.test(match.postSpace))
+                break;
+        }
+    },
+
+    propertyPattern: util.regexp(<![CDATA[
+            (?:
+                (?P<preSpace> <space>*)
+                (?P<name> [-a-z]*)
+                (?:
+                    <space>* : \s* (?P<value>
+                        (?:
+                            [-\w]+
+                            (?:
+                                \s* \( \s*
+                                    (?: <string> | [^)]*  )
+                                \s* (?: \) | $)
+                            )?
+                            \s*
+                            | \s* <string> \s*
+                            | <space>*
+                            | [^;}]*
+                        )*
+                    )
+                )?
+            )
+            (?P<postSpace> <space>* (?: ; | $) )
+        ]]>, "gix",
+        {
+            space: /(?: \s | \/\* .*? \*\/ )/,
+            string: /(?:" (?:[^\\"]|\\.)* (?:"|$) | '(?:[^\\']|\\.)* (?:'|$) )/
+        }),
+
+    patterns: memoize({
+        get property() util.regexp(<![CDATA[
+                (?:
+                    (?P<preSpace> <space>*)
+                    (?P<name> [-a-z]*)
+                    (?:
+                        <space>* : \s* (?P<value>
+                            <token>*
+                        )
+                    )?
+                )
+                (?P<postSpace> <space>* (?: ; | $) )
+            ]]>, "gix", this),
+
+        get function() util.regexp(<![CDATA[
+                (?P<function>
+                    \s* \( \s*
+                        (?: <string> | [^)]*  )
+                    \s* (?: \) | $)
+                )
+            ]]>, "gx", this),
+
+        space: /(?: \s | \/\* .*? \*\/ )/,
+
+        get string() util.regexp(<![CDATA[
+                (?P<string>
+                    " (?:[^\\"]|\\.)* (?:"|$) |
+                    ' (?:[^\\']|\\.)* (?:'|$)
+                )
+            ]]>, "gx", this),
+
+        get token() util.regexp(<![CDATA[
+            (?P<token>
+                (?P<word> [-\w]+)
+                <function>?
+                \s*
+                | (?P<important> !important\b)
+                | \s* <string> \s*
+                | <space>+
+                | [^;}\s]+
+            )
+        ]]>, "gix", this)
+    })
+}, {
+    commands: function (dactyl, modules, window) {
+        const { commands, contexts, styles } = modules;
+
+        function sheets(context, args, filter) {
+            let uris = util.visibleURIs(window.content);
+            context.compare = modules.CompletionContext.Sort.number;
+            context.generate = function () args["-group"].sheets;
+            context.keys.active = function (sheet) uris.some(sheet.closure.match);
+            context.keys.description = function (sheet) <>{sheet.formatSites(uris)}: {sheet.css.replace("\n", "\\n")}</>
+            if (filter)
+                context.filters.push(function ({ item }) filter(item));
+            Styles.splitContext(context);
+        }
+
+        function nameFlag(filter) ({
+            names: ["-name", "-n"],
+            description: "The name of this stylesheet",
+            type: modules.CommandOption.STRING,
+            completer: function (context, args) {
+                context.keys.text = function (sheet) sheet.name;
+                context.filters.unshift(function ({ item }) item.name);
+                sheets(context, args, filter);
+            }
+        });
+
+        commands.add(["sty[le]"],
+            "Add or list user styles",
+            function (args) {
+                let [filter, css] = args;
+
+                if (!css)
+                    styles.list(window.content, filter, args["-name"], args.explicitOpts["-group"] ? [args["-group"]] : null);
+                else {
+                    util.assert(args["-group"].modifiable && args["-group"].hive.modifiable,
+                                "Cannot modify styles in the builtin group");
+
+                    if (args["-append"]) {
+                        let sheet = args["-group"].get(args["-name"]);
+                        if (sheet) {
+                            filter = sheet.sites.concat(filter).join(",");
+                            css = sheet.css + " " + css;
+
+                        }
+                    }
+                    let style = args["-group"].add(args["-name"], filter, css, args["-agent"]);
+
+                    if (args["-nopersist"] || !args["-append"] || style.persist === undefined)
+                        style.persist = !args["-nopersist"];
+                }
+            },
+            {
+                bang: true,
+                completer: function (context, args) {
+                    let compl = [];
+                    let sheet = args["-group"].get(args["-name"]);
+                    if (args.completeArg == 0) {
+                        if (sheet)
+                            context.completions = [[sheet.sites.join(","), "Current Value"]];
+                        context.fork("sites", 0, Styles, "completeSite", window.content, args["-group"]);
+                    }
+                    else if (args.completeArg == 1) {
+                        if (sheet)
+                            context.completions = [[sheet.css, "Current Value"]];
+                        context.fork("css", 0, modules.completion, "css");
+                    }
+                },
+                hereDoc: true,
+                literal: 1,
+                options: [
+                    { names: ["-agent", "-A"],  description: "Apply style as an Agent sheet" },
+                    { names: ["-append", "-a"], description: "Append site filter and css to an existing, matching sheet" },
+                    contexts.GroupFlag("styles"),
+                    nameFlag(),
+                    { names: ["-nopersist", "-N"], description: "Do not save this style to an auto-generated RC file" }
+                ],
+                serialize: function ()
+                    array(styles.hives)
+                        .filter(function (hive) hive.persist)
+                        .map(function (hive)
+                             hive.sheets.filter(function (style) style.persist)
+                                 .sort(function (a, b) String.localeCompare(a.name || "", b.name || ""))
+                                 .map(function (style) ({
+                                    command: "style",
+                                    arguments: [style.sites.join(",")],
+                                    literalArg: style.css,
+                                    options: update({
+                                            "-group": hive.name,
+                                        },
+                                        style.name ? { "-name": style.name } : {})
+                                })))
+                        .flatten().array
+            });
+
+        [
+            {
+                name: ["stylee[nable]", "stye[nable]"],
+                desc: "Enable a user style sheet",
+                action: function (sheet) sheet.enabled = true,
+                filter: function (sheet) !sheet.enabled
+            },
+            {
+                name: ["styled[isable]", "styd[isable]"],
+                desc: "Disable a user style sheet",
+                action: function (sheet) sheet.enabled = false,
+                filter: function (sheet) sheet.enabled
+            },
+            {
+                name: ["stylet[oggle]", "styt[oggle]"],
+                desc: "Toggle a user style sheet",
+                action: function (sheet) sheet.enabled = !sheet.enabled
+            },
+            {
+                name: ["dels[tyle]"],
+                desc: "Remove a user style sheet",
+                action: function (sheet) sheet.remove(),
+            }
+        ].forEach(function (cmd) {
+            commands.add(cmd.name, cmd.desc,
+                function (args) {
+                    dactyl.assert(args.bang ^ !!(args[0] || args[1] || args["-name"] || args["-index"]),
+                                  "Argument or ! required");
+
+                    args["-group"].find(args["-name"], args[0], args.literalArg, args["-index"])
+                                  .forEach(cmd.action);
+                }, {
+                    bang: true,
+                    completer: function (context, args) {
+                        let uris = util.visibleURIs(window.content);
+
+                        Styles.completeSite(context, window.content, args["-group"]);
+                        if (cmd.filter)
+                            context.filters.push(function ({ sheets }) sheets.some(cmd.filter));
+                    },
+                    literal: 1,
+                    options: [
+                        contexts.GroupFlag("styles"),
+                        {
+                            names: ["-index", "-i"],
+                            type: modules.CommandOption.INT,
+                            completer: function (context, args) {
+                                context.keys.text = function (sheet) args["-group"].sheets.indexOf(sheet);
+                                sheets(context, args, cmd.filter);
+                            }
+                        },
+                        nameFlag(cmd.filter)
+                    ]
+                });
+        });
+    },
+    contexts: function (dactyl, modules, window) {
+        modules.contexts.Hives("styles",
+            Class("LocalHive", Contexts.Hive, {
+                init: function init(group) {
+                    init.superapply(this, arguments);
+                    this.hive = styles.addHive(group.name, this, this.persist);
+                },
+
+                get names() this.hive.names,
+                get sheets() this.hive.sheets,
+                get sites() this.hive.sites,
+
+                __noSuchMethod__: function __noSuchMethod__(meth, args) {
+                    return this.hive[meth].apply(this.hive, args);
+                },
+
+                destroy: function () {
+                    this.hive.dropRef(this);
+                }
+            }));
+    },
+    completion: function (dactyl, modules, window) {
+        const names = Array.slice(util.computedStyle(window.document.createElement("div")));
+        modules.completion.css = function (context) {
+            context.title = ["CSS Property"];
+            context.keys = { text: function (p) p + ":", description: function () "" };
+
+            for (let match in Styles.propertyIter(context.filter, true))
+                var lastMatch = match;
+
+            if (lastMatch != null && !lastMatch.value && !lastMatch.postSpace) {
+                context.advance(lastMatch.index + lastMatch.preSpace.length);
+                context.completions = names;
+            }
+        };
+    },
+    javascript: function (dactyl, modules, window) {
+        modules.JavaScript.setCompleter(["get", "add", "remove", "find"].map(function (m) styles.user[m]),
+            [ // Prototype: (name, filter, css, index)
+                function (context, obj, args) this.names,
+                function (context, obj, args) Styles.completeSite(context, window.content),
+                null,
+                function (context, obj, args) this.sheets
+            ]);
+    },
+    template: function () {
+        let patterns = Styles.patterns;
+
+        template.highlightCSS = function highlightCSS(css) {
+            XML.prettyPrinting = XML.ignoreWhitespace = false;
+
+            return this.highlightRegexp(css, patterns.property, function (match) <>{
+                match.preSpace}{template.filter(match.name)}: {
+
+                    template.highlightRegexp(match.value, patterns.token, function (match) {
+                        if (match.function)
+                            return <>{template.filter(match.word)}{
+                                template.highlightRegexp(match.function, patterns.string,
+                                    function (match) <span highlight="String">{match.string}</span>)
+                            }</>;
+                        if (match.important == "!important")
+                            return <span highlight="String">{match.important}</span>;
+                        if (match.string)
+                            return <span highlight="String">{match.string}</span>;
+                        return template.highlightRegexp(match.wholeMatch, /^(\d+)(em|ex|px|in|cm|mm|pt|pc)?/g,
+                                                        function (m, n, u) <><span highlight="Number">{n}</span><span highlight="Object">{u || ""}</span></>);
+                    })
+
+                }{ match.postSpace }</>
+            )
+        }
+    },
+});
+
+endModule();
+
+// catch(e){dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack);}
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/template.jsm b/common/modules/template.jsm
new file mode 100644 (file)
index 0000000..8a398c2
--- /dev/null
@@ -0,0 +1,511 @@
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+defineModule("template", {
+    exports: ["Binding", "Template", "template"],
+    require: ["util"],
+    use: ["messages", "services"]
+}, this);
+
+default xml namespace = XHTML;
+
+var Binding = Class("Binding", {
+    init: function (node, nodes) {
+        this.node = node;
+        this.nodes = nodes;
+        node.dactylBinding = this;
+
+        Object.defineProperties(node, this.constructor.properties);
+
+        for (let [event, handler] in values(this.constructor.events))
+            node.addEventListener(event, handler, false);
+    },
+
+    set collapsed(collapsed) {
+        if (collapsed)
+            this.setAttribute("collapsed", "true");
+        else
+            this.removeAttribute("collapsed");
+    },
+    get collapsed() !!this.getAttribute("collapsed"),
+
+    __noSuchMethod__: Class.Property({
+        configurable: true,
+        writeable: true,
+        value: function __noSuchMethod__(meth, args) {
+            return this.node[meth].apply(this.node, args);
+        }
+    })
+}, {
+    get bindings() {
+        let bindingProto = Object.getPrototypeOf(Binding.prototype);
+        for (let obj = this.prototype; obj !== bindingProto; obj = Object.getPrototypeOf(obj))
+            yield obj;
+    },
+
+    bind: function bind(func) function bound() {
+        try {
+            return func.apply(this.dactylBinding, arguments);
+        }
+        catch (e) {
+            util.reportError(e);
+            throw e;
+        }
+    },
+
+    events: Class.memoize(function () {
+        let res = [];
+        for (let obj in this.bindings)
+            if (Object.getOwnPropertyDescriptor(obj, "events"))
+                for (let [event, handler] in Iterator(obj.events))
+                    res.push([event, this.bind(handler)]);
+        return res;
+    }),
+
+    properties: Class.memoize(function () {
+        let res = {};
+        for (let obj in this.bindings)
+            for (let prop in properties(obj)) {
+                let desc = Object.getOwnPropertyDescriptor(obj, prop);
+                if (desc.enumerable) {
+                    for (let k in values(["get", "set", "value"]))
+                        if (typeof desc[k] === "function")
+                            desc[k] = this.bind(desc[k]);
+                    res[prop] = desc;
+                }
+            }
+        return res;
+    })
+});
+
+var Template = Module("Template", {
+    add: function add(a, b) a + b,
+    join: function join(c) function (a, b) a + c + b,
+
+    map: function map(iter, func, sep, interruptable) {
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        if (iter.length) // FIXME: Kludge?
+            iter = array.iterValues(iter);
+        let res = <></>;
+        let n = 0;
+        for each (let i in Iterator(iter)) {
+            let val = func(i, n);
+            if (val == undefined)
+                continue;
+            if (n++ && sep)
+                res += sep;
+            if (interruptable && n % interruptable == 0)
+                util.threadYield(true, true);
+            res += val;
+        }
+        return res;
+    },
+
+    bindings: {
+        Button: Class("Button", Binding, {
+            init: function init(node, params) {
+                init.supercall(this, node);
+
+                this.target = params.commandTarget;
+            },
+
+            get command() this.getAttribute("command") || this.getAttribute("key"),
+
+            events: {
+                "click": function onClick(event) {
+                    event.preventDefault();
+                    if (this.commandAllowed) {
+                        if (set.has(this.target.commands || {}, this.command))
+                            this.target.commands[this.command].call(this.target);
+                        else
+                            this.target.command(this.command);
+                    }
+                }
+            },
+
+            get commandAllowed() {
+                if (set.has(this.target.allowedCommands || {}, this.command))
+                    return this.target.allowedCommands[this.command];
+                if ("commandAllowed" in this.target)
+                    return this.target.commandAllowed(this.command);
+                return true;
+            },
+
+            update: function update() {
+                let collapsed = this.collapsed;
+                this.collapsed = !this.commandAllowed;
+
+                if (collapsed == this.commandAllowed) {
+                    let event = this.node.ownerDocument.createEvent("Events");
+                    event.initEvent("dactyl-commandupdate", true, false);
+                    this.node.ownerDocument.dispatchEvent(event);
+                }
+            }
+        }),
+
+        Events: Class("Events", Binding, {
+            init: function init(node, params) {
+                init.supercall(this, node);
+
+                let obj = params.eventTarget;
+                let events = obj[this.getAttribute("events") || "events"];
+
+                for (let [event, handler] in Iterator(events))
+                    node.addEventListener(event, obj.closure(handler), false);
+            }
+        })
+    },
+
+    bookmarkDescription: function (item, text)
+    <>
+        {
+            !(item.extra && item.extra.length) ? "" :
+            <span highlight="URLExtra">
+                ({
+                    template.map(item.extra, function (e)
+                    <>{e[0]}: <span highlight={e[2]}>{e[1]}</span></>,
+                    <>&#xa0;</>)
+                })&#xa0;</span>
+        }
+        <a xmlns:dactyl={NS} identifier={item.id || ""} dactyl:command={item.command || ""}
+           href={item.item.url} highlight="URL">{text || ""}</a>
+    </>,
+
+    filter: function (str) <span highlight="Filter">{str}</span>,
+
+    completionRow: function completionRow(item, highlightGroup) {
+        if (typeof icon == "function")
+            icon = icon();
+
+        if (highlightGroup) {
+            var text = item[0] || "";
+            var desc = item[1] || "";
+        }
+        else {
+            var text = this.processor[0].call(this, item, item.result);
+            var desc = this.processor[1].call(this, item, item.description);
+        }
+
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        // <e4x>
+        return <div highlight={highlightGroup || "CompItem"} style="white-space: nowrap">
+                   <!-- The non-breaking spaces prevent empty elements
+                      - from pushing the baseline down and enlarging
+                      - the row.
+                      -->
+                   <li highlight="CompResult">{text}&#xa0;</li>
+                   <li highlight="CompDesc">{desc}&#xa0;</li>
+               </div>;
+        // </e4x>
+    },
+
+    helpLink: function (token, text, type) {
+        if (!services["dactyl:"].initialized)
+            util.dactyl.initHelp();
+
+        let topic = token; // FIXME: Evil duplication!
+        if (/^\[.*\]$/.test(topic))
+            topic = topic.slice(1, -1);
+        else if (/^n_/.test(topic))
+            topic = topic.slice(2);
+
+        if (services["dactyl:"].initialized && !set.has(services["dactyl:"].HELP_TAGS, topic))
+            return <span highlight={type || ""}>{text || token}</span>;
+
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        type = type || (/^'.*'$/.test(token)   ? "HelpOpt" :
+                        /^\[.*\]$|^E\d{3}$/.test(token) ? "HelpTopic" :
+                        /^:\w/.test(token)     ? "HelpEx"  : "HelpKey");
+
+        return <a highlight={"InlineHelpLink " + type} tag={topic} href={"dactyl://help-tag/" + topic} dactyl:command="dactyl.help" xmlns:dactyl={NS}>{text || topic}</a>;
+    },
+    HelpLink: function (token) {
+        if (!services["dactyl:"].initialized)
+            util.dactyl.initHelp();
+
+        let topic = token; // FIXME: Evil duplication!
+        if (/^\[.*\]$/.test(topic))
+            topic = topic.slice(1, -1);
+        else if (/^n_/.test(topic))
+            topic = topic.slice(2);
+
+        if (services["dactyl:"].initialized && !set.has(services["dactyl:"].HELP_TAGS, topic))
+            return <>{token}</>;
+
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        let tag = (/^'.*'$/.test(token)            ? "o" :
+                   /^\[.*\]$|^E\d{3}$/.test(token) ? "t" :
+                   /^:\w/.test(token)              ? "ex"  : "k");
+
+        topic = topic.replace(/^'(.*)'$/, "$1");
+        return <{tag} xmlns={NS}>{topic}</{tag}>;
+    },
+    linkifyHelp: function linkifyHelp(str, help) {
+        let re = util.regexp(<![CDATA[
+            (?P<pre> [/\s]|^)
+            (?P<tag> '[\w-]+' | :(?:[\w-]+!?|!) | (?:._)?<[\w-]+>\w* | [a-zA-Z]_\w+ | \[[\w-]+\] | E\d{3} )
+            (?=      [[\)!,:;./\s]|$)
+        ]]>, "gx");
+        return this.highlightSubstrings(str, (function () {
+            for (let res in re.iterate(str))
+                yield [res.index + res.pre.length, res.tag.length];
+        })(), template[help ? "HelpLink" : "helpLink"]);
+    },
+
+    // if "processStrings" is true, any passed strings will be surrounded by " and
+    // any line breaks are displayed as \n
+    highlight: function highlight(arg, processStrings, clip) {
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        // some objects like window.JSON or getBrowsers()._browsers need the try/catch
+        try {
+            let str = clip ? util.clip(String(arg), clip) : String(arg);
+            switch (arg == null ? "undefined" : typeof arg) {
+            case "number":
+                return <span highlight="Number">{str}</span>;
+            case "string":
+                if (processStrings)
+                    str = str.quote();
+                return <span highlight="String">{str}</span>;
+            case "boolean":
+                return <span highlight="Boolean">{str}</span>;
+            case "function":
+                // Vim generally doesn't like /foo*/, because */ looks like a comment terminator.
+                // Using /foo*(:?)/ instead.
+                if (processStrings)
+                    return <span highlight="Function">{str.replace(/\{(.|\n)*(?:)/g, "{ ... }")}</span>;
+                    <>}</>; /* Vim */
+                return <>{arg}</>;
+            case "undefined":
+                return <span highlight="Null">{arg}</span>;
+            case "object":
+                if (arg instanceof Ci.nsIDOMElement)
+                    return util.objectToString(arg, false);
+                // for java packages value.toString() would crash so badly
+                // that we cannot even try/catch it
+                if (/^\[JavaPackage.*\]$/.test(arg))
+                    return <>[JavaPackage]</>;
+                if (processStrings && false)
+                    str = template.highlightFilter(str, "\n", function () <span highlight="NonText">^J</span>);
+                return <span highlight="Object">{str}</span>;
+            case "xml":
+                return arg;
+            default:
+                return <![CDATA[<unknown type>]]>;
+            }
+        }
+        catch (e) {
+            return <![CDATA[<unknown>]]>;
+        }
+    },
+
+    highlightFilter: function highlightFilter(str, filter, highlight) {
+        return this.highlightSubstrings(str, (function () {
+            if (filter.length == 0)
+                return;
+            let lcstr = String.toLowerCase(str);
+            let lcfilter = filter.toLowerCase();
+            let start = 0;
+            while ((start = lcstr.indexOf(lcfilter, start)) > -1) {
+                yield [start, filter.length];
+                start += filter.length;
+            }
+        })(), highlight || template.filter);
+    },
+
+    highlightRegexp: function highlightRegexp(str, re, highlight) {
+        return this.highlightSubstrings(str, (function () {
+            for (let res in util.regexp.iterate(re, str))
+                yield [res.index, res[0].length, res.wholeMatch ? [res] : res];
+        })(), highlight || template.filter);
+    },
+
+    highlightSubstrings: function highlightSubstrings(str, iter, highlight) {
+        XML.ignoreWhitespace = XML.prettyPrinting = false;
+        if (typeof str == "xml")
+            return str;
+        if (str == "")
+            return <>{str}</>;
+
+        str = String(str).replace(" ", "\u00a0");
+        let s = <></>;
+        let start = 0;
+        let n = 0, _i;
+        for (let [i, length, args] in iter) {
+            if (i == _i || i < _i)
+                break;
+            _i = i;
+
+            XML.ignoreWhitespace = false;
+            s += <>{str.substring(start, i)}</>;
+            s += highlight.apply(this, Array.concat(args || str.substr(i, length)));
+            start = i + length;
+        }
+        return s + <>{str.substr(start)}</>;
+    },
+
+    highlightURL: function highlightURL(str, force) {
+        if (force || /^[a-zA-Z]+:\/\//.test(str))
+            return <a highlight="URL" href={str}>{str}</a>;
+        else
+            return str;
+    },
+
+    icon: function (item, text) <>
+        <span highlight="CompIcon">{item.icon ? <img src={item.icon}/> : <></>}</span><span class="td-strut"/>{text}
+    </>,
+
+    jumps: function jumps(index, elems) {
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        // <e4x>
+        return <table>
+                <tr style="text-align: left;" highlight="Title">
+                    <th colspan="2">jump</th><th>title</th><th>URI</th>
+                </tr>
+                {
+                    this.map(Iterator(elems), function ([idx, val])
+                    <tr>
+                        <td class="indicator">{idx == index ? ">" : ""}</td>
+                        <td>{Math.abs(idx - index)}</td>
+                        <td style="width: 250px; max-width: 500px; overflow: hidden;">{val.title}</td>
+                        <td><a href={val.URI.spec} highlight="URL jump-list">{val.URI.spec}</a></td>
+                    </tr>)
+                }
+            </table>;
+        // </e4x>
+    },
+
+    options: function options(title, opts, verbose) {
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        // <e4x>
+        return <table>
+                <tr highlight="Title" align="left">
+                    <th>--- {title} ---</th>
+                </tr>
+                {
+                    this.map(opts, function (opt)
+                    <tr>
+                        <td>
+                            <div highlight="Message"
+                            ><span style={opt.isDefault ? "" : "font-weight: bold"}>{opt.pre}{opt.name}</span><span>{opt.value}</span>{
+                                opt.isDefault || opt.default == null ? "" : <span class="extra-info"> (default: {opt.default})</span>
+                            }</div>{
+                                verbose && opt.setFrom ? <div highlight="Message">       Last set from {template.sourceLink(opt.setFrom)}</div> : <></>
+                            }
+                        </td>
+                    </tr>)
+                }
+            </table>;
+        // </e4x>
+    },
+
+    sourceLink: function (frame) {
+        let url = util.fixURI(frame.filename || "unknown");
+        let path = util.urlPath(url);
+
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        return <a xmlns:dactyl={NS} dactyl:command="buffer.viewSource"
+            href={url} path={path} line={frame.lineNumber}
+            highlight="URL">{
+            path + ":" + frame.lineNumber
+        }</a>;
+    },
+
+    table: function table(title, data, indent) {
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        let table = // <e4x>
+            <table>
+                <tr highlight="Title" align="left">
+                    <th colspan="2">{title}</th>
+                </tr>
+                {
+                    this.map(data, function (datum)
+                    <tr>
+                       <td style={"font-weight: bold; min-width: 150px; padding-left: " + (indent || "2ex")}>{datum[0]}</td>
+                       <td>{datum[1]}</td>
+                    </tr>)
+                }
+            </table>;
+        // </e4x>
+        if (table.tr.length() > 1)
+            return table;
+    },
+
+    tabular: function tabular(headings, style, iter) {
+        // TODO: This might be mind-bogglingly slow. We'll see.
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        // <e4x>
+        return <table>
+                <tr highlight="Title" align="left">
+                {
+                    this.map(headings, function (h)
+                    <th>{h}</th>)
+                }
+                </tr>
+                {
+                    this.map(iter, function (row)
+                    <tr>
+                    {
+                        template.map(Iterator(row), function ([i, d])
+                        <td style={style[i] || ""}>{d}</td>)
+                    }
+                    </tr>)
+                }
+            </table>;
+        // </e4x>
+    },
+
+    usage: function usage(iter, format) {
+        XML.ignoreWhitespace = false; XML.prettyPrinting = false;
+        format = format || {};
+        let desc = format.description || function (item) template.linkifyHelp(item.description);
+        let help = format.help || function (item) item.name;
+        function sourceLink(frame) {
+            let source = template.sourceLink(frame);
+            source.@NS::hint = source.text();
+            return source;
+        }
+        // <e4x>
+        return <table>
+            { format.headings ?
+                <thead highlight="UsageHead">
+                    <tr highlight="Title" align="left">
+                    {
+                        this.map(format.headings, function (h) <th>{h}</th>)
+                    }
+                    </tr>
+                </thead> : ""
+            }
+            { format.columns ?
+                <colgroup>
+                {
+                    this.map(format.columns, function (c) <col style={c}/>)
+                }
+                </colgroup> : ""
+            }
+            <tbody highlight="UsageBody">{
+                this.map(iter, function (item)
+                <tr highlight="UsageItem">
+                    <td style="padding-right: 2em;">
+                        <span highlight="Usage Link">{
+                            let (name = item.name || item.names[0], frame = item.definedAt)
+                                !frame ? name :
+                                    template.helpLink(help(item), name, "Title") +
+                                    <span highlight="LinkInfo" xmlns:dactyl={NS}>Defined at {sourceLink(frame)}</span>
+                        }</span>
+                    </td>
+                    { item.columns ? template.map(item.columns, function (c) <td>{c}</td>) : "" }
+                    <td>{desc(item)}</td>
+                </tr>)
+            }</tbody>
+        </table>;
+        // </e4x>
+    }
+});
+
+endModule();
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/modules/util.jsm b/common/modules/util.jsm
new file mode 100644 (file)
index 0000000..673366a
--- /dev/null
@@ -0,0 +1,1838 @@
+// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+try {
+
+Components.utils.import("resource://dactyl/bootstrap.jsm");
+    let frag=1;
+defineModule("util", {
+    exports: ["frag", "FailedAssertion", "Math", "NS", "Point", "Util", "XBL", "XHTML", "XUL", "util"],
+    require: ["services"],
+    use: ["commands", "config", "highlight", "storage", "template"]
+}, this);
+
+var XBL = Namespace("xbl", "http://www.mozilla.org/xbl");
+var XHTML = Namespace("html", "http://www.w3.org/1999/xhtml");
+var XUL = Namespace("xul", "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+var NS = Namespace("dactyl", "http://vimperator.org/namespaces/liberator");
+default xml namespace = XHTML;
+
+var FailedAssertion = Class("FailedAssertion", ErrorBase, {
+    init: function init(message, level, noTrace) {
+        if (noTrace !== undefined)
+            this.noTrace = noTrace;
+        init.supercall(this, message, level);
+    },
+
+    level: 3,
+
+    noTrace: true
+});
+
+var Point = Struct("x", "y");
+
+var wrapCallback = function wrapCallback(fn) {
+    fn.wrapper = function wrappedCallback () {
+        try {
+            return fn.apply(this, arguments);
+        }
+        catch (e) {
+            util.reportError(e);
+            return undefined;
+        }
+    };
+    fn.wrapper.wrapped = fn;
+    return fn.wrapper;
+}
+
+var getAttr = function getAttr(elem, ns, name)
+    elem.hasAttributeNS(ns, name) ? elem.getAttributeNS(ns, name) : null;
+var setAttr = function setAttr(elem, ns, name, val) {
+    if (val == null)
+        elem.removeAttributeNS(ns, name);
+    else
+        elem.setAttributeNS(ns, name, val);
+}
+
+var Util = Module("Util", XPCOM([Ci.nsIObserver, Ci.nsISupportsWeakReference]), {
+    init: function () {
+        this.Array = array;
+
+        this.addObserver(this);
+        this.overlays = {};
+    },
+
+    cleanup: function cleanup() {
+        for (let { document: doc } in iter(services.windowMediator.getEnumerator(null))) {
+            for (let elem in values(doc.dactylOverlayElements || []))
+                if (elem.parentNode)
+                    elem.parentNode.removeChild(elem);
+
+            for (let [elem, ns, name, orig, value] in values(doc.dactylOverlayAttributes || []))
+                if (getAttr(elem, ns, name) === value)
+                    setAttr(elem, ns, name, orig);
+
+            delete doc.dactylOverlayElements;
+            delete doc.dactylOverlayAttributes;
+            delete doc.dactylOverlays;
+        }
+    },
+
+    // FIXME: Only works for Pentadactyl
+    get activeWindow() services.windowMediator.getMostRecentWindow("navigator:browser"),
+    dactyl: update(function dactyl(obj) {
+        if (obj)
+            var global = Class.objectGlobal(obj);
+        return {
+            __noSuchMethod__: function (meth, args) {
+                let win = util.activeWindow;
+                var dactyl = global && global.dactyl || win && win.dactyl;
+                if (!dactyl)
+                    return null;
+
+                let prop = dactyl[meth];
+                if (callable(prop))
+                    return prop.apply(dactyl, args);
+                return prop;
+            }
+        };
+    }, {
+        __noSuchMethod__: function () this().__noSuchMethod__.apply(null, arguments)
+    }),
+
+    /**
+     * Registers a obj as a new observer with the observer service. obj.observe
+     * must be an object where each key is the name of a target to observe and
+     * each value is a function(subject, data) to be called when the given
+     * target is broadcast. obj.observe will be replaced with a new opaque
+     * function. The observer is automatically unregistered on application
+     * shutdown.
+     *
+     * @param {object} obj
+     */
+    addObserver: function (obj) {
+        if (!obj.observers)
+            obj.observers = obj.observe;
+
+        function register(meth) {
+            for (let target in set(["dactyl-cleanup-modules", "quit-application"].concat(Object.keys(obj.observers))))
+                try {
+                    services.observer[meth](obj, target, true);
+                }
+                catch (e) {}
+        }
+
+        Class.replaceProperty(obj, "observe",
+            function (subject, target, data) {
+                try {
+                    if (target == "quit-application" || target == "dactyl-cleanup-modules")
+                        register("removeObserver");
+                    if (obj.observers[target])
+                        obj.observers[target].call(obj, subject, data);
+                }
+                catch (e) {
+                    if (typeof util === "undefined")
+                        dump("dactyl: error: " + e + "\n" + (e.stack || Error().stack).replace(/^/gm, "dactyl:    "));
+                    else
+                        util.reportError(e);
+                }
+            });
+
+        obj.observe.unregister = function () register("removeObserver");
+        register("addObserver");
+    },
+
+    /*
+     * Tests a condition and throws a FailedAssertion error on
+     * failure.
+     *
+     * @param {boolean} condition The condition to test.
+     * @param {string} message The message to present to the
+     *     user on failure.
+     */
+    assert: function (condition, message, quiet) {
+        if (!condition)
+            throw FailedAssertion(message, 1, quiet === undefined ? true : quiet);
+    },
+
+    /**
+     * Capitalizes the first character of the given string.
+     * @param {string} str The string to capitalize
+     * @returns {string}
+     */
+    capitalize: function capitalize(str) str && str[0].toUpperCase() + str.slice(1),
+
+    /**
+     * Returns a RegExp object that matches characters specified in the range
+     * expression *list*, or signals an appropriate error if *list* is invalid.
+     *
+     * @param {string} list Character list, e.g., "a b d-xA-Z" produces /[abd-xA-Z]/.
+     * @param {string} accepted Character range(s) to accept, e.g. "a-zA-Z" for
+     *     ASCII letters. Used to validate *list*.
+     * @returns {RegExp}
+     */
+    charListToRegexp: function charListToRegexp(list, accepted) {
+        list = list.replace(/\s+/g, "");
+
+        // check for chars not in the accepted range
+        this.assert(RegExp("^[" + accepted + "-]+$").test(list),
+                    "Character list outside the range " + accepted.quote());
+
+        // check for illegal ranges
+        for (let [match] in this.regexp.iterate(/.-./g, list))
+            this.assert(match.charCodeAt(0) <= match.charCodeAt(2),
+                        "Invalid character range: " + list.slice(list.indexOf(match)))
+
+        return RegExp("[" + util.regexp.escape(list) + "]");
+    },
+
+    get chromePackages() {
+        // Horrible hack.
+        let res = {};
+        function process(manifest) {
+            for each (let line in manifest.split(/\n+/)) {
+                let match = /^\s*(content|skin|locale|resource)\s+([^\s#]+)\s/.exec(line);
+                if (match)
+                    res[match[2]] = true;
+            }
+        }
+        function processJar(file) {
+            let jar = services.ZipReader(file);
+            if (jar) {
+                if (jar.hasEntry("chrome.manifest"))
+                    process(File.readStream(jar.getInputStream("chrome.manifest")));
+                jar.close();
+            }
+        }
+
+        for each (let dir in ["UChrm", "AChrom"]) {
+            dir = File(services.directory.get(dir, Ci.nsIFile));
+            if (dir.exists() && dir.isDirectory())
+                for (let file in dir.iterDirectory())
+                    if (/\.manifest$/.test(file.leafName))
+                        process(file.read());
+
+            dir = File(dir.parent);
+            if (dir.exists() && dir.isDirectory())
+                for (let file in dir.iterDirectory())
+                    if (/\.jar$/.test(file.leafName))
+                        processJar(file);
+
+            dir = dir.child("extensions");
+            if (dir.exists() && dir.isDirectory())
+                for (let ext in dir.iterDirectory()) {
+                    if (/\.xpi$/.test(ext.leafName))
+                        processJar(ext);
+                    else {
+                        if (ext.isFile())
+                            ext = File(ext.read().replace(/\n*$/, ""));
+                        let mf = ext.child("chrome.manifest");
+                        if (mf.exists())
+                            process(mf.read());
+                    }
+                }
+        }
+        return Object.keys(res).sort();
+    },
+
+    /**
+     * Returns a shallow copy of *obj*.
+     *
+     * @param {Object} obj
+     * @returns {Object}
+     */
+    cloneObject: function cloneObject(obj) {
+        if (isArray(obj))
+            return obj.slice();
+        let newObj = {};
+        for (let [k, v] in Iterator(obj))
+            newObj[k] = v;
+        return newObj;
+    },
+
+    /**
+     * Clips a string to a given length. If the input string is longer
+     * than *length*, an ellipsis is appended.
+     *
+     * @param {string} str The string to truncate.
+     * @param {number} length The length of the returned string.
+     * @returns {string}
+     */
+    clip: function clip(str, length) {
+        return str.length <= length ? str : str.substr(0, length - 3) + "...";
+    },
+
+    /**
+     * Compares two strings, case insensitively. Return values are as
+     * in String#localeCompare.
+     *
+     * @param {string} a
+     * @param {string} b
+     * @returns {number}
+     */
+    compareIgnoreCase: function compareIgnoreCase(a, b) String.localeCompare(a.toLowerCase(), b.toLowerCase()),
+
+    compileFormat: function compileFormat(format) {
+        let stack = [frame()];
+        stack.__defineGetter__("top", function () this[this.length - 1]);
+
+        function frame() update(
+            function _frame(obj)
+                _frame === stack.top || _frame.valid(obj) ?
+                    _frame.elements.map(function (e) callable(e) ? e(obj) : e).join("") : "",
+            {
+                elements: [],
+                seen: {},
+                valid: function (obj) this.elements.every(function (e) !e.test || e.test(obj))
+            });
+
+        let end = 0;
+        for (let match in util.regexp.iterate(/(.*?)%(.)/gy, format)) {
+
+            let [, prefix, char] = match;
+            end += match[0].length;
+
+            if (prefix)
+                stack.top.elements.push(prefix);
+            if (char === "%")
+                stack.top.elements.push("%");
+            else if (char === "[") {
+                let f = frame();
+                stack.top.elements.push(f);
+                stack.push(f);
+            }
+            else if (char === "]") {
+                stack.pop();
+                util.assert(stack.length, "Unmatched %] in format");
+            }
+            else {
+                let quote = function quote(obj, char) obj[char];
+                if (char !== char.toLowerCase())
+                    quote = function quote(obj, char) Commands.quote(obj[char]);
+                char = char.toLowerCase();
+
+                stack.top.elements.push(update(
+                    function (obj) obj[char] != null ? quote(obj, char) : "",
+                    { test: function (obj) obj[char] != null }));
+
+                for (let elem in array.iterValues(stack))
+                    elem.seen[char] = true;
+            }
+        }
+        if (end < format.length)
+            stack.top.elements.push(format.substr(end));
+
+        util.assert(stack.length === 1, "Unmatched %[ in format");
+        return stack.top;
+    },
+
+    compileMacro: function compileMacro(macro, keepUnknown) {
+        let stack = [frame()];
+        stack.__defineGetter__("top", function () this[this.length - 1]);
+
+        let unknown = util.identity;
+        if (!keepUnknown)
+            unknown = function () "";
+
+        function frame() update(
+            function _frame(obj)
+                _frame === stack.top || _frame.valid(obj) ?
+                    _frame.elements.map(function (e) callable(e) ? e(obj) : e).join("") : "",
+            {
+                elements: [],
+                seen: {},
+                valid: function (obj) this.elements.every(function (e) !e.test || e.test(obj))
+            });
+
+        let defaults = { lt: "<", gt: ">" };
+
+        let re = util.regexp(<![CDATA[
+            ([^]*?) // 1
+            (?:
+                (<\{) | // 2
+                (< ((?:[a-z]-)?[a-z-]+?) >) | // 3 4
+                (\}>) // 5
+            )
+        ]]>, "gixy");
+        macro = String(macro);
+        let end = 0;
+        for (let match in re.iterate(macro)) {
+            let [, prefix, open, full, macro, close] = match;
+            end += match[0].length;
+
+            if (prefix)
+                stack.top.elements.push(prefix);
+            if (open) {
+                let f = frame();
+                stack.top.elements.push(f);
+                stack.push(f);
+            }
+            else if (close) {
+                stack.pop();
+                util.assert(stack.length, "Unmatched %] in macro");
+            }
+            else {
+                let [, flags, name] = /^((?:[a-z]-)*)(.*)/.exec(macro);
+                flags = set(flags);
+
+                let quote = util.identity;
+                if (flags.q)
+                    quote = function quote(obj) typeof obj === "number" ? obj : String.quote(obj);
+                if (flags.e)
+                    quote = function quote(obj) "";
+
+                if (set.has(defaults, name))
+                    stack.top.elements.push(quote(defaults[name]));
+                else {
+                    stack.top.elements.push(update(
+                        function (obj) obj[name] != null ? quote(obj[name]) : set.has(obj, name) ? "" : unknown(full),
+                        { test: function (obj) obj[name] != null && obj[name] !== false && (!flags.e || obj[name] != "") }));
+
+                    for (let elem in array.iterValues(stack))
+                        elem.seen[name] = true;
+                }
+            }
+        }
+        if (end < macro.length)
+            stack.top.elements.push(macro.substr(end));
+
+        util.assert(stack.length === 1, "Unmatched <{ in macro");
+        return stack.top;
+    },
+
+    compileMatcher: function compileMatcher(list) {
+        let xpath = [], css = [];
+        for (let elem in values(list))
+            if (/^xpath:/.test(elem))
+                xpath.push(elem.substr(6));
+            else
+                css.push(elem);
+
+        return update(
+            function matcher(node) {
+                if (matcher.xpath)
+                    for (let elem in util.evaluateXPath(matcher.xpath, node))
+                        yield elem;
+
+                if (matcher.css)
+                    for (let [, elem] in iter(node.querySelectorAll(matcher.css)))
+                        yield elem;
+            }, {
+                css: css.join(", "),
+                xpath: xpath.join(" | ")
+            });
+    },
+
+    validateMatcher: function validateMatcher(values) {
+        let evaluator = services.XPathEvaluator();
+        let node = util.xmlToDom(<div/>, document);
+        return this.testValues(values, function (value) {
+            if (/^xpath:/.test(value))
+                evaluator.createExpression(value.substr(6), util.evaluateXPath.resolver);
+            else
+                node.querySelector(value);
+            return true;
+        });
+    },
+
+    /**
+     * Returns an object representing a Node's computed CSS style.
+     *
+     * @param {Node} node
+     * @returns {Object}
+     */
+    computedStyle: function computedStyle(node) {
+        while (!(node instanceof Ci.nsIDOMElement) && node.parentNode)
+            node = node.parentNode;
+        try {
+            var res = node.ownerDocument.defaultView.getComputedStyle(node, null);
+        }
+        catch (e) {}
+        if (res == null) {
+            util.dumpStack(_("error.nullComputedStyle", node));
+            Cu.reportError(Error(_("error.nullComputedStyle", node)));
+            return {};
+        }
+        return res;
+    },
+
+    /**
+     * Converts any arbitrary string into an URI object. Returns null on
+     * failure.
+     *
+     * @param {string} str
+     * @returns {nsIURI|null}
+     */
+    createURI: function createURI(str) {
+        try {
+            return services.urifixup.createFixupURI(str, services.urifixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP);
+        }
+        catch (e) {
+            return null;
+        }
+    },
+
+    /**
+     * Expands brace globbing patterns in a string.
+     *
+     * Example:
+     *     "a{b,c}d" => ["abd", "acd"]
+     *
+     * @param {string} pattern The pattern to deglob.
+     * @returns [string] The resulting strings.
+     */
+    debrace: function debrace(pattern) {
+        if (pattern.indexOf("{") == -1)
+            return [pattern];
+
+        function split(pattern, re, fn, dequote) {
+            let end = 0, match, res = [];
+            while (match = re.exec(pattern)) {
+                end = match.index + match[0].length;
+                res.push(match[1]);
+                if (fn)
+                    fn(match);
+            }
+            res.push(pattern.substr(end));
+            return res.map(function (s) util.dequote(s, dequote));
+        }
+        let patterns = [], res = [];
+        let substrings = split(pattern, /((?:[^\\{]|\\.)*)\{((?:[^\\}]|\\.)*)\}/gy,
+            function (match) {
+                patterns.push(split(match[2], /((?:[^\\,]|\\.)*),/gy,
+                    null, ",{}"));
+            }, "{}");
+        function rec(acc) {
+            if (acc.length == patterns.length)
+                res.push(array(substrings).zip(acc).flatten().join(""));
+            else
+                for (let [, pattern] in Iterator(patterns[acc.length]))
+                    rec(acc.concat(pattern));
+        }
+        rec([]);
+        return res;
+    },
+
+    /**
+     * Removes certain backslash-quoted characters while leaving other
+     * backslash-quoting sequences untouched.
+     *
+     * @param {string} pattern The string to unquote.
+     * @param {string} chars The characters to unquote.
+     * @returns {string}
+     */
+    dequote: function dequote(pattern, chars)
+        pattern.replace(/\\(.)/, function (m0, m1) chars.indexOf(m1) >= 0 ? m1 : m0),
+
+    domToString: function (node, html) {
+        if (node instanceof Ci.nsISelection && node.isCollapsed)
+            return "";
+
+        if (node instanceof Ci.nsIDOMNode) {
+            let range = node.ownerDocument.createRange();
+            range.selectNode(node);
+            node = range;
+        }
+        let doc = (node.getRangeAt ? node.getRangeAt(0) : node).startContainer.ownerDocument;
+
+        let encoder = services.HtmlEncoder();
+        encoder.init(doc, "text/unicode", encoder.OutputRaw|encoder.OutputPreformatted);
+        if (node instanceof Ci.nsISelection)
+            encoder.setSelection(node);
+        else if (node instanceof Ci.nsIDOMRange)
+            encoder.setRange(node);
+
+        let str = services.String(encoder.encodeToString());
+        if (html)
+            return str.data;
+
+        let [result, length] = [{}, {}];
+        services.HtmlConverter().convert("text/html", str, str.data.length*2, "text/unicode", result, length);
+        return result.value.QueryInterface(Ci.nsISupportsString).data;
+    },
+
+    /**
+     * Prints a message to the console. If *msg* is an object it is pretty
+     * printed.
+     *
+     * @param {string|Object} msg The message to print.
+     */
+    dump: defineModule.dump,
+
+    stackLines: function (stack) {
+        let lines = [];
+        let match, re = /([^]*?)@([^@\n]*)(?:\n|$)/g;
+        while (match = re.exec(stack))
+            lines.push(match[1].replace(/\n/g, "\\n").substr(0, 80) + "@" +
+                       util.fixURI(match[2]));
+        return lines;
+    },
+
+    /**
+     * Dumps a stack trace to the console.
+     *
+     * @param {string} msg The trace message.
+     * @param {number} frames The number of frames to print.
+     */
+    dumpStack: function dumpStack(msg, frames) {
+        let stack = util.stackLines(Error().stack);
+        stack = stack.slice(1, 1 + (frames || stack.length)).join("\n").replace(/^/gm, "    ");
+        util.dump((arguments.length == 0 ? "Stack" : msg) + "\n" + stack + "\n");
+    },
+
+    /**
+     * The set of input element type attribute values that mark the element as
+     * an editable field.
+     */
+    editableInputs: set(["date", "datetime", "datetime-local", "email", "file",
+                         "month", "number", "password", "range", "search",
+                         "tel", "text", "time", "url", "week"]),
+
+    /**
+     * Converts HTML special characters in *str* to the equivalent HTML
+     * entities.
+     *
+     * @param {string} str
+     * @returns {string}
+     */
+    escapeHTML: function escapeHTML(str) {
+        return str.replace(/&/g, "&amp;").replace(/</g, "&lt;");
+    },
+
+    /**
+     * Escapes quotes, newline and tab characters in *str*. The returned string
+     * is delimited by *delimiter* or " if *delimiter* is not specified.
+     * {@see String#quote}.
+     *
+     * @param {string} str
+     * @param {string} delimiter
+     * @returns {string}
+     */
+    escapeString: function escapeString(str, delimiter) {
+        if (delimiter == undefined)
+            delimiter = '"';
+        return delimiter + str.replace(/([\\'"])/g, "\\$1").replace("\n", "\\n", "g").replace("\t", "\\t", "g") + delimiter;
+    },
+
+    /**
+     * Evaluates an XPath expression in the current or provided
+     * document. It provides the xhtml, xhtml2 and dactyl XML
+     * namespaces. The result may be used as an iterator.
+     *
+     * @param {string} expression The XPath expression to evaluate.
+     * @param {Node} elem The context element.
+     * @default The current document.
+     * @param {boolean} asIterator Whether to return the results as an
+     *     XPath iterator.
+     * @returns {Object} Iterable result of the evaluation.
+     */
+    evaluateXPath: update(
+        function evaluateXPath(expression, elem, asIterator) {
+            try {
+                if (!elem)
+                    elem = util.activeWindow.content.document;
+                let doc = elem.ownerDocument || elem;
+                if (isArray(expression))
+                    expression = util.makeXPath(expression);
+
+                let result = doc.evaluate(expression, elem,
+                    evaluateXPath.resolver,
+                    asIterator ? Ci.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE : Ci.nsIDOMXPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
+                    null
+                );
+
+                return Object.create(result, {
+                    __iterator__: {
+                        value: asIterator ? function () { let elem; while ((elem = this.iterateNext())) yield elem; }
+                                          : function () { for (let i = 0; i < this.snapshotLength; i++) yield this.snapshotItem(i); }
+                    }
+                });
+            }
+            catch (e) {
+                throw e.stack ? e : Error(e);
+            }
+        },
+        {
+            resolver: function lookupNamespaceURI(prefix) ({
+                    xul: XUL.uri,
+                    xhtml: XHTML.uri,
+                    xhtml2: "http://www.w3.org/2002/06/xhtml2",
+                    dactyl: NS.uri
+                }[prefix] || null)
+        }),
+
+    extend: function extend(dest) {
+        Array.slice(arguments, 1).filter(util.identity).forEach(function (src) {
+            for (let [k, v] in Iterator(src)) {
+                let get = src.__lookupGetter__(k),
+                    set = src.__lookupSetter__(k);
+                if (!get && !set)
+                    dest[k] = v;
+                if (get)
+                    dest.__defineGetter__(k, get);
+                if (set)
+                    dest.__defineSetter__(k, set);
+            }
+        });
+        return dest;
+    },
+
+    /**
+     * Converts *bytes* to a pretty printed data size string.
+     *
+     * @param {number} bytes The number of bytes.
+     * @param {string} decimalPlaces The number of decimal places to use if
+     *     *humanReadable* is true.
+     * @param {boolean} humanReadable Use byte multiples.
+     * @returns {string}
+     */
+    formatBytes: function formatBytes(bytes, decimalPlaces, humanReadable) {
+        const unitVal = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
+        let unitIndex = 0;
+        let tmpNum = parseInt(bytes, 10) || 0;
+        let strNum = [tmpNum + ""];
+
+        if (humanReadable) {
+            while (tmpNum >= 1024) {
+                tmpNum /= 1024;
+                if (++unitIndex > (unitVal.length - 1))
+                    break;
+            }
+
+            let decPower = Math.pow(10, decimalPlaces);
+            strNum = ((Math.round(tmpNum * decPower) / decPower) + "").split(".", 2);
+
+            if (!strNum[1])
+                strNum[1] = "";
+
+            while (strNum[1].length < decimalPlaces) // pad with "0" to the desired decimalPlaces)
+                strNum[1] += "0";
+        }
+
+        for (let u = strNum[0].length - 3; u > 0; u -= 3) // make a 10000 a 10,000
+            strNum[0] = strNum[0].substr(0, u) + "," + strNum[0].substr(u);
+
+        if (unitIndex) // decimalPlaces only when > Bytes
+            strNum[0] += "." + strNum[1];
+
+        return strNum[0] + " " + unitVal[unitIndex];
+    },
+
+    /**
+     * Converts *seconds* into a human readable time string.
+     *
+     * @param {number} seconds
+     * @returns {string}
+     */
+    formatSeconds: function formatSeconds(seconds) {
+        function pad(n, val) ("0000000" + val).substr(-Math.max(n, String(val).length));
+        function div(num, denom) [Math.round(num / denom), Math.round(num % denom)];
+        let days, hours, minutes;
+
+        [minutes, seconds] = div(seconds, 60);
+        [hours, minutes]   = div(minutes, 60);
+        [days, hours]      = div(hours,   24);
+        if (days)
+            return days + " days " + hours + " hours"
+        if (hours)
+            return hours + "h " + minutes + "m";
+        if (minutes)
+            return minutes + ":" + pad(2, seconds);
+        return seconds + "s";
+    },
+
+    /**
+     * Returns the file which backs a given URL, if available.
+     *
+     * @param {nsIURI} uri The URI for which to find a file.
+     * @returns {File|null}
+     */
+    getFile: function getFile(uri) {
+        try {
+            if (isString(uri))
+                uri = util.newURI(util.fixURI(uri));
+
+            if (uri instanceof Ci.nsIFileURL)
+                return File(uri.QueryInterface(Ci.nsIFileURL).file);
+
+            let channel = services.io.newChannelFromURI(uri);
+            channel.cancel(Cr.NS_BINDING_ABORTED);
+            if (channel instanceof Ci.nsIFileChannel)
+                return File(channel.QueryInterface(Ci.nsIFileChannel).file);
+        }
+        catch (e) {}
+        return null;
+    },
+
+    /**
+     * Returns the host for the given URL, or null if invalid.
+     *
+     * @param {string} url
+     * @returns {string|null}
+     */
+    getHost: function (url) {
+        try {
+            return util.createURI(url).host;
+        }
+        catch (e) {}
+        return null;
+    },
+
+    /**
+     * Returns true if the current Gecko runtime is of the given version
+     * or greater.
+     *
+     * @param {string} ver The required version.
+     * @returns {boolean}
+     */
+    haveGecko: function (ver) services.versionCompare.compare(services.runtime.platformVersion, ver) >= 0,
+
+    /**
+     * Sends a synchronous or asynchronous HTTP request to *url* and returns
+     * the XMLHttpRequest object. If *callback* is specified the request is
+     * asynchronous and the *callback* is invoked with the object as its
+     * argument.
+     *
+     * @param {string} url
+     * @param {function(XMLHttpRequest)} callback
+     * @returns {XMLHttpRequest}
+     */
+    httpGet: function httpGet(url, callback, self) {
+        let params = callback;
+        if (!isObject(params))
+            params = { callback: params && function () callback.apply(self, arguments) };
+
+        try {
+            let xmlhttp = services.Xmlhttp();
+            xmlhttp.mozBackgroundRequest = true;
+
+            let async = params.callback || params.onload || params.onerror;
+            if (async) {
+                xmlhttp.onload = function handler(event) { util.trapErrors(params.onload || params.callback, params, xmlhttp, event) };
+                xmlhttp.onerror = function handler(event) { util.trapErrors(params.onerror || params.callback, params, xmlhttp, event) };
+            }
+            if (params.mimeType)
+                xmlhttp.overrideMimeType(params.mimeType);
+
+            xmlhttp.open(params.method || "GET", url, async,
+                         params.user, params.pass);
+
+            xmlhttp.send(null);
+            return xmlhttp;
+        }
+        catch (e) {
+            util.dactyl.log("Error opening " + String.quote(url) + ": " + e, 1);
+            return null;
+        }
+    },
+
+    /**
+     * The identity function.
+     *
+     * @param {Object} k
+     * @returns {Object}
+     */
+    identity: function identity(k) k,
+
+    /**
+     * Returns the intersection of two rectangles.
+     *
+     * @param {Object} r1
+     * @param {Object} r2
+     * @returns {Object}
+     */
+    intersection: function (r1, r2) ({
+        get width()  this.right - this.left,
+        get height() this.bottom - this.top,
+        left: Math.max(r1.left, r2.left),
+        right: Math.min(r1.right, r2.right),
+        top: Math.max(r1.top, r2.top),
+        bottom: Math.min(r1.bottom, r2.bottom)
+    }),
+
+    /**
+     * Returns true if the given stack frame resides in Dactyl code.
+     *
+     * @param {nsIStackFrame} frame
+     * @returns {boolean}
+     */
+    isDactyl: Class.memoize(function () {
+        let base = util.regexp.escape(Components.stack.filename.replace(/[^\/]+$/, ""));
+        let re = RegExp("^(?:.* -> )?(?:resource://dactyl(?!-content/eval.js)|" + base + ")\\S+$");
+        return function isDactyl(frame) re.test(frame.filename);
+    }),
+
+    /**
+     * Returns true if *url* is in the domain *domain*.
+     *
+     * @param {string} url
+     * @param {string} domain
+     * @returns {boolean}
+     */
+    isDomainURL: function isDomainURL(url, domain) util.isSubdomain(util.getHost(url), domain),
+
+    /** Dactyl's notion of the current operating system platform. */
+    OS: memoize({
+        _arch: services.runtime.OS,
+        /**
+         * @property {string} The normalised name of the OS. This is one of
+         *     "Windows", "Mac OS X" or "Unix".
+         */
+        get name() this.isWindows ? "Windows" : this.isMacOSX ? "Mac OS X" : "Unix",
+        /** @property {boolean} True if the OS is Windows. */
+        get isWindows() this._arch == "WINNT",
+        /** @property {boolean} True if the OS is Mac OS X. */
+        get isMacOSX() this._arch == "Darwin",
+        /** @property {boolean} True if the OS is some other *nix variant. */
+        get isUnix() !this.isWindows && !this.isMacOSX,
+        /** @property {RegExp} A RegExp which matches illegal characters in path components. */
+        get illegalCharacters() this.isWindows ? /[<>:"/\\|?*\x00-\x1f]/g : /\//g
+    }),
+
+    /**
+     * Returns true if *host* is a subdomain of *domain*.
+     *
+     * @param {string} host The host to check.
+     * @param {string} domain The base domain to check the host against.
+     * @returns {boolean}
+     */
+    isSubdomain: function isSubdomain(host, domain) {
+        if (host == null)
+            return false;
+        let idx = host.lastIndexOf(domain);
+        return idx > -1 && idx + domain.length == host.length && (idx == 0 || host[idx - 1] == ".");
+    },
+
+    /**
+     * Returns true if the given DOM node is currently visible.
+     *
+     * @param {Node} node
+     * @returns {boolean}
+     */
+    isVisible: function (node) {
+        let style = util.computedStyle(node);
+        return style.visibility == "visible" && style.display != "none";
+    },
+
+    /**
+     * Iterates over all currently open documents, including all
+     * top-level window and sub-frames thereof.
+     */
+    iterDocuments: function iterDocuments() {
+        let windows = services.windowMediator.getXULWindowEnumerator(null);
+        while (windows.hasMoreElements()) {
+            let window = windows.getNext().QueryInterface(Ci.nsIXULWindow);
+            for each (let type in ["typeChrome", "typeContent"]) {
+                let docShells = window.docShell.getDocShellEnumerator(Ci.nsIDocShellTreeItem[type],
+                                                                      Ci.nsIDocShell.ENUMERATE_FORWARDS);
+                while (docShells.hasMoreElements())
+                    let (viewer = docShells.getNext().QueryInterface(Ci.nsIDocShell).contentViewer) {
+                        if (viewer)
+                            yield viewer.DOMDocument;
+                    }
+            }
+        }
+    },
+
+    /**
+     * Returns an XPath union expression constructed from the specified node
+     * tests. An expression is built with node tests for both the null and
+     * XHTML namespaces. See {@link Buffer#evaluateXPath}.
+     *
+     * @param nodes {Array(string)}
+     * @returns {string}
+     */
+    makeXPath: function makeXPath(nodes) {
+        return array(nodes).map(util.debrace).flatten()
+                           .map(function (node) [node, "xhtml:" + node]).flatten()
+                           .map(function (node) "//" + node).join(" | ");
+    },
+
+    map: deprecated("iter.map", function map(obj, fn, self) iter(obj).map(fn, self).toArray()),
+    writeToClipboard: deprecated("dactyl.clipboardWrite", function writeToClipboard(str, verbose) util.dactyl.clipboardWrite(str, verbose)),
+    readFromClipboard: deprecated("dactyl.clipboardRead", function readFromClipboard() util.dactyl.clipboardRead(false)),
+
+    /**
+     * Converts a URI string into a URI object.
+     *
+     * @param {string} uri
+     * @returns {nsIURI}
+     */
+    // FIXME: createURI needed too?
+    newURI: function (uri, charset, base) services.io.newURI(uri, charset, base),
+
+    /**
+     * Removes leading garbage prepended to URIs by the subscript
+     * loader.
+     */
+    fixURI: function fixURI(url) String.replace(url, /.* -> /, ""),
+
+    /**
+     * Pretty print a JavaScript object. Use HTML markup to color certain items
+     * if *color* is true.
+     *
+     * @param {Object} object The object to pretty print.
+     * @param {boolean} color Whether the output should be colored.
+     * @returns {string}
+     */
+    objectToString: function objectToString(object, color) {
+        // Use E4X literals so html is automatically quoted
+        // only when it's asked for. No one wants to see &lt;
+        // on their console or :map :foo in their buffer
+        // when they expect :map <C-f> :foo.
+        XML.prettyPrinting = false;
+        XML.ignoreWhitespace = false;
+
+        if (object == null)
+            return object + "\n";
+
+        if (!isObject(object))
+            return String(object);
+
+        function namespaced(node) {
+            var ns = NAMESPACES[node.namespaceURI] || /^(?:(.*?):)?/.exec(node.name)[0];
+            if (!ns)
+                return node.localName;
+            if (color)
+                return <><span highlight="HelpXMLNamespace">{ns}</span>{node.localName}</>
+            return ns + ":" + node.localName;
+        }
+
+        if (object instanceof Ci.nsIDOMElement) {
+            const NAMESPACES = array.toObject([
+                [NS, "dactyl"],
+                [XHTML, "html"],
+                [XUL, "xul"]
+            ]);
+            let elem = object;
+            if (elem.nodeType == elem.TEXT_NODE)
+                return elem.data;
+
+            try {
+                let hasChildren = elem.firstChild && (!/^\s*$/.test(elem.firstChild) || elem.firstChild.nextSibling)
+                if (color)
+                    return <span highlight="HelpXMLBlock"><span highlight="HelpXMLTagStart">&lt;{
+                            namespaced(elem)} {
+                                template.map(array.iterValues(elem.attributes),
+                                    function (attr)
+                                        <span highlight="HelpXMLAttribute">{namespaced(attr)}</span> +
+                                        <span highlight="HelpXMLString">{attr.value}</span>,
+                                    <> </>)
+                            }{ !hasChildren ? "/>" : ">"
+                        }</span>{ !hasChildren ? "" : <>...</> +
+                            <span highlight="HtmlTagEnd">&lt;{namespaced(elem)}></span>
+                    }</span>;
+
+                let tag = "<" + [namespaced(elem)].concat(
+                    [namespaced(a) + "=" +  template.highlight(a.value, true)
+                     for ([i, a] in array.iterItems(elem.attributes))]).join(" ");
+                return tag + (!hasChildren ? "/>" : ">...</" + namespaced(elem) + ">");
+            }
+            catch (e) {
+                return {}.toString.call(elem);
+            }
+        }
+
+        try { // for window.JSON
+            var obj = String(object);
+        }
+        catch (e) {
+            obj = Object.prototype.toString.call(obj);
+        }
+        obj = template.highlightFilter(util.clip(obj, 150), "\n", !color ? function () "^J" : function () <span highlight="NonText">^J</span>);
+        let string = <><span highlight="Title Object">{obj}</span>::&#x0a;</>;
+
+        let keys = [];
+
+        // window.content often does not want to be queried with "var i in object"
+        try {
+            let hasValue = !("__iterator__" in object || isinstance(object, ["Generator", "Iterator"]));
+            if (object.dactyl && object.modules && object.modules.modules == object.modules) {
+                object = Iterator(object);
+                hasValue = false;
+            }
+            for (let i in object) {
+                let value = <![CDATA[<no value>]]>;
+                try {
+                    value = object[i];
+                }
+                catch (e) {}
+                if (!hasValue) {
+                    if (isArray(i) && i.length == 2)
+                        [i, value] = i;
+                    else
+                        var noVal = true;
+                }
+
+                value = template.highlight(value, true, 150);
+                let key = <span highlight="Key">{i}</span>;
+                if (!isNaN(i))
+                    i = parseInt(i);
+                else if (/^[A-Z_]+$/.test(i))
+                    i = "";
+                keys.push([i, <>{key}{noVal ? "" : <>: {value}</>}&#x0a;</>]);
+            }
+        }
+        catch (e) {}
+
+        function compare(a, b) {
+            if (!isNaN(a[0]) && !isNaN(b[0]))
+                return a[0] - b[0];
+            return String.localeCompare(a[0], b[0]);
+        }
+        string += template.map(keys.sort(compare), function (f) f[1]);
+        return color ? <div style="white-space: pre-wrap;">{string}</div> : [s for each (s in string)].join("");
+    },
+
+    observers: {
+        "dactyl-cleanup-modules": function () {
+            defineModule.loadLog.push("dactyl: util: observe: dactyl-cleanup-modules");
+
+            for (let module in values(defineModule.modules))
+                if (module.cleanup) {
+                    util.dump("cleanup: " + module.constructor.className);
+                    util.trapErrors(module.cleanup, module);
+                }
+
+            JSMLoader.cleanup();
+
+            if (!this.rehashing)
+                services.observer.addObserver(this, "dactyl-rehash", true);
+        },
+        "dactyl-rehash": function () {
+            services.observer.removeObserver(this, "dactyl-rehash");
+
+            defineModule.loadLog.push("dactyl: util: observe: dactyl-rehash");
+            if (!this.rehashing)
+                for (let module in values(defineModule.modules)) {
+                    defineModule.loadLog.push("dactyl: util: init(" + module + ")");
+                    if (module.reinit)
+                        module.reinit();
+                    else
+                        module.init();
+                }
+        },
+        "dactyl-purge": function () {
+            this.rehashing = 1;
+        },
+        "toplevel-window-ready": function (window, data) {
+            window.addEventListener("DOMContentLoaded", wrapCallback(function listener(event) {
+                if (event.originalTarget === window.document) {
+                    window.removeEventListener("DOMContentLoaded", listener.wrapper, true);
+                    util._loadOverlays(window);
+                }
+            }), true);
+        },
+        "chrome-document-global-created": function (window, uri) { this.observe(window, "toplevel-window-ready", null); },
+        "content-document-global-created": function (window, uri) { this.observe(window, "toplevel-window-ready", null); }
+    },
+
+    _loadOverlays: function _loadOverlays(window) {
+        if (!window.dactylOverlays)
+            window.dactylOverlays = [];
+
+        for each (let obj in util.overlays[window.document.documentURI] || []) {
+            if (window.dactylOverlays.indexOf(obj) >= 0)
+                continue;
+            window.dactylOverlays.push(obj);
+            this._loadOverlay(window, obj(window));
+        }
+    },
+
+    _loadOverlay: function _loadOverlay(window, obj) {
+        let doc = window.document;
+        if (!doc.dactylOverlayElements) {
+            doc.dactylOverlayElements = [];
+            doc.dactylOverlayAttributes = [];
+        }
+
+        function overlay(key, fn) {
+            if (obj[key]) {
+                let iterator = Iterator(obj[key]);
+                if (!isObject(obj[key]))
+                    iterator = ([elem.@id, elem.elements(), elem.@*::*.(function::name() != "id")] for each (elem in obj[key]));
+
+                for (let [elem, xml, attr] in iterator) {
+                    if (elem = doc.getElementById(elem)) {
+                        let node = util.xmlToDom(xml, doc, obj.objects);
+                        if (!(node instanceof Ci.nsIDOMDocumentFragment))
+                            doc.dactylOverlayElements.push(node);
+                        else
+                            for (let n in array.iterValues(node.childNodes))
+                                doc.dactylOverlayElements.push(n);
+
+                        fn(elem, node);
+                        for each (let attr in attr || []) {
+                            let ns = attr.namespace(), name = attr.localName();
+                            doc.dactylOverlayAttributes.push([elem, ns, name, getAttr(elem, ns, name), String(attr)]);
+                            if (attr.name() != "highlight")
+                                elem.setAttributeNS(ns, name, String(attr));
+                            else
+                                highlight.highlightNode(elem, String(attr));
+                        }
+                    }
+                }
+            }
+        }
+
+        overlay("before", function (elem, dom) elem.parentNode.insertBefore(dom, elem));
+        overlay("after", function (elem, dom) elem.parentNode.insertBefore(dom, elem.nextSibling));
+        overlay("append", function (elem, dom) elem.appendChild(dom));
+        overlay("prepend", function (elem, dom) elem.insertBefore(dom, elem.firstChild));
+        if (obj.init)
+            obj.init(window);
+
+        if (obj.load)
+            if (doc.readyState === "complete")
+                obj.load(window);
+            else
+                doc.addEventListener("load", wrapCallback(function load(event) {
+                    if (event.originalTarget === event.target) {
+                        doc.removeEventListener("load", load.wrapper, true);
+                        obj.load(window, event);
+                    }
+                }), true);
+    },
+
+    overlayObject: function (object, overrides) {
+        let original = Object.create(object);
+        overrides = update(Object.create(original), overrides);
+
+        Object.getOwnPropertyNames(overrides).forEach(function (k) {
+            let orig, desc = Object.getOwnPropertyDescriptor(overrides, k);
+            if (desc.value instanceof Class.Property)
+                desc = desc.value.init(k) || desc.value;
+
+            for (let obj = object; obj && !orig; obj = Object.getPrototypeOf(obj))
+                if (orig = Object.getOwnPropertyDescriptor(obj, k))
+                    Object.defineProperty(original, k, orig);
+
+            // Guard against horrible add-ons that use eval-based monkey
+            // patching.
+            if (callable(desc.value)) {
+                let value = desc.value;
+
+                let sentinel = "(function DactylOverlay() {}())"
+                value.toString = function toString() toString.toString.call(this).replace(/\}?$/, sentinel + "; $&");
+                value.toSource = function toSource() toString.toSource.call(this).replace(/\}?$/, sentinel + "; $&");
+
+                delete desc.value;
+                delete desc.writable;
+                desc.get = function get() value;
+                desc.set = function set(val) {
+                    if (String(val).indexOf(sentinel) < 0)
+                        Class.replaceProperty(this, k, val);
+                    else {
+                        let package_ = util.newURI(util.fixURI(Components.stack.caller.filename)).host;
+                        util.reportError(Error(_("error.monkeyPatchOverlay", package_)));
+                        util.dactyl.echoerr(_("error.monkeyPatchOverlay", package_));
+                    }
+                };
+            }
+
+            Object.defineProperty(object, k, desc);
+        }, this);
+
+        return function unwrap() {
+            for each (let k in Object.getOwnPropertyNames(original))
+                Object.defineProperty(object, k, Object.getOwnPropertyDescriptor(original, k));
+        };
+    },
+
+    overlayWindow: function (url, fn) {
+        if (url instanceof Ci.nsIDOMWindow)
+            util._loadOverlay(url, fn);
+        else {
+            Array.concat(url).forEach(function (url) {
+                if (!this.overlays[url])
+                    this.overlays[url] = [];
+                this.overlays[url].push(fn);
+            }, this);
+
+            for (let doc in util.iterDocuments())
+                if (["interactive", "complete"].indexOf(doc.readyState) >= 0)
+                    this._loadOverlays(doc.defaultView);
+                else
+                    this.observe(doc.defaultView, "toplevel-window-ready");
+        }
+    },
+
+    /**
+     * Parses the fields of a form and returns a URL/POST-data pair
+     * that is the equivalent of submitting the form.
+     *
+     * @param {nsINode} field One of the fields of the given form.
+     * @returns {array}
+     */
+    // Nuances gleaned from browser.jar/content/browser/browser.js
+    parseForm: function parseForm(field) {
+        function encode(name, value, param) {
+            param = param ? "%s" : "";
+            if (post)
+                return name + "=" + encodeComponent(value + param);
+            return encodeComponent(name) + "=" + encodeComponent(value) + param;
+        }
+
+        let form = field.form;
+        let doc = form.ownerDocument;
+
+        let charset = doc.characterSet;
+        let converter = services.CharsetConv(charset);
+        for each (let cs in form.acceptCharset.split(/\s*,\s*|\s+/)) {
+            let c = services.CharsetConv(cs);
+            if (c) {
+                converter = services.CharsetConv(cs);
+                charset = cs;
+            }
+        }
+
+        let uri = util.newURI(doc.baseURI.replace(/\?.*/, ""), charset);
+        let url = util.newURI(form.action, charset, uri).spec;
+
+        let post = form.method.toUpperCase() == "POST";
+
+        let encodeComponent = encodeURIComponent;
+        if (charset !== "UTF-8")
+            encodeComponent = function encodeComponent(str)
+                escape(converter.ConvertFromUnicode(str) + converter.Finish());
+
+        let elems = [];
+        if (field instanceof Ci.nsIDOMHTMLInputElement && field.type == "submit")
+            elems.push(encode(field.name, field.value));
+
+        for (let [, elem] in iter(form.elements)) {
+            if (set.has(util.editableInputs, elem.type)
+                    || /^(?:hidden|textarea)$/.test(elem.type)
+                    || elem.checked && /^(?:checkbox|radio)$/.test(elem.type))
+                elems.push(encode(elem.name, elem.value, elem === field));
+            else if (elem instanceof Ci.nsIDOMHTMLSelectElement) {
+                for (let [, opt] in Iterator(elem.options))
+                    if (opt.selected)
+                        elems.push(encode(elem.name, opt.value));
+            }
+        }
+        if (post)
+            return [url, elems.join('&'), charset, elems];
+        return [url + "?" + elems.join('&'), null, charset, elems];
+    },
+
+    /**
+     * A generator that returns the values between *start* and *end*, in *step*
+     * increments.
+     *
+     * @param {number} start The interval's start value.
+     * @param {number} end The interval's end value.
+     * @param {boolean} step The value to step the range by. May be
+     *     negative. @default 1
+     * @returns {Iterator(Object)}
+     */
+    range: function range(start, end, step) {
+        if (!step)
+            step = 1;
+        if (step > 0) {
+            for (; start < end; start += step)
+                yield start;
+        }
+        else {
+            while (start > end)
+                yield start += step;
+        }
+    },
+
+    /**
+     * An interruptible generator that returns all values between *start* and
+     * *end*. The thread yields every *time* milliseconds.
+     *
+     * @param {number} start The interval's start value.
+     * @param {number} end The interval's end value.
+     * @param {number} time The time in milliseconds between thread yields.
+     * @returns {Iterator(Object)}
+     */
+    interruptibleRange: function interruptibleRange(start, end, time) {
+        let endTime = Date.now() + time;
+        while (start < end) {
+            if (Date.now() > endTime) {
+                util.threadYield(true, true);
+                endTime = Date.now() + time;
+            }
+            yield start++;
+        }
+    },
+
+    /**
+     * Creates a new RegExp object based on the value of expr stripped
+     * of all white space and interpolated with the values from tokens.
+     * If tokens, any string in the form of <key> in expr is replaced
+     * with the value of the property, 'key', from tokens, if that
+     * property exists. If the property value is itself a RegExp, its
+     * source is substituted rather than its string value.
+     *
+     * Additionally, expr is stripped of all JavaScript comments.
+     *
+     * This is similar to Perl's extended regular expression format.
+     *
+     * @param {string|XML} expr The expression to compile into a RegExp.
+     * @param {string} flags Flags to apply to the new RegExp.
+     * @param {object} tokens The tokens to substitute. @optional
+     * @returns {RegExp} A custom regexp object.
+     */
+    regexp: update(function (expr, flags, tokens) {
+        flags = flags || [k for ([k, v] in Iterator({ g: "global", i: "ignorecase", m: "multiline", y: "sticky" }))
+                          if (expr[v])].join("");
+
+        if (isinstance(expr, ["RegExp"]))
+            expr = expr.source;
+
+        expr = String.replace(expr, /\\(.)/, function (m, m1) {
+            if (m1 === "c")
+                flags = flags.replace(/i/g, "") + "i";
+            else if (m === "C")
+                flags = flags.replace(/i/g, "");
+            else
+                return m;
+            return "";
+        });
+
+        // Replace replacement <tokens>.
+        if (tokens)
+            expr = String.replace(expr, /(\(?P)?<(\w+)>/g, function (m, n1, n2) !n1 && set.has(tokens, n2) ? tokens[n2].dactylSource || tokens[n2].source || tokens[n2] : m);
+
+        // Strip comments and white space.
+        if (/x/.test(flags))
+            expr = String.replace(expr, /(\\.)|\/\/[^\n]*|\/\*[^]*?\*\/|\s+/gm, function (m, m1) m1 || "");
+
+        // Replace (?P<named> parameters)
+        if (/\(\?P</.test(expr)) {
+            var source = expr;
+            let groups = ["wholeMatch"];
+            expr = expr.replace(/((?:[^[(\\]|\\.|\[(?:[^\]]|\\.)*\])*)\((?:\?P<([^>]+)>|(\?))?/gy,
+                function (m0, m1, m2, m3) {
+                    if (!m3)
+                        groups.push(m2 || "-group-" + groups.length);
+                    return m1 + "(" + (m3 || "");
+                });
+            var struct = Struct.apply(null, groups);
+        }
+
+        let res = update(RegExp(expr, flags.replace("x", "")), {
+            closure: Class.Property(Object.getOwnPropertyDescriptor(Class.prototype, "closure")),
+            dactylPropertyNames: ["exec", "match", "test", "toSource", "toString", "global", "ignoreCase", "lastIndex", "multiLine", "source", "sticky"],
+            iterate: function (str, idx) util.regexp.iterate(this, str, idx)
+        });
+
+        // Return a struct with properties for named parameters if we
+        // have them.
+        if (struct)
+            update(res, {
+                exec: function exec() let (match = exec.superapply(this, arguments)) match && struct.fromArray(match),
+                dactylSource: source, struct: struct
+            });
+        return res;
+    }, {
+        /**
+         * Escapes Regular Expression special characters in *str*.
+         *
+         * @param {string} str
+         * @returns {string}
+         */
+        escape: function regexp_escape(str) str.replace(/([\\{}()[\]^$.?*+|])/g, "\\$1"),
+
+        /**
+         * Given a RegExp, returns its source in the form showable to the user.
+         *
+         * @param {RegExp} re The regexp showable source of which is to be returned.
+         * @returns {string}
+         */
+        getSource: function regexp_getSource(re) re.source.replace(/\\(.)/g, function (m0, m1) m1 === "/" ? "/" : m0),
+
+        /**
+         * Iterates over all matches of the given regexp in the given
+         * string.
+         *
+         * @param {RegExp} regexp The regular expression to execute.
+         * @param {string} string The string to search.
+         * @param {number} lastIndex The index at which to begin searching. @optional
+         */
+        iterate: function iterate(regexp, string, lastIndex) iter(function () {
+            regexp.lastIndex = lastIndex = lastIndex || 0;
+            let match;
+            while (match = regexp.exec(string)) {
+                lastIndex = regexp.lastIndex;
+                yield match;
+                regexp.lastIndex = lastIndex;
+                if (match[0].length == 0 || !regexp.global)
+                    break;
+            }
+        }())
+    }),
+
+    rehash: function (args) {
+        JSMLoader.commandlineArgs = args;
+        this.timeout(function () {
+            this.rehashing = true;
+            let addon = config.addon;
+            addon.userDisabled = true;
+            addon.userDisabled = false;
+        });
+    },
+
+    errorCount: 0,
+    errors: Class.memoize(function () []),
+    maxErrors: 15,
+    reportError: function (error) {
+        if (error.noTrace)
+            return;
+
+        if (isString(error))
+            error = Error(error);
+
+        if (Cu.reportError)
+            Cu.reportError(error);
+
+        try {
+            this.errorCount++;
+
+            let obj = update({}, error, {
+                toString: function () String(error),
+                stack: <>{util.stackLines(String(error.stack || Error().stack)).join("\n").replace(/^/mg, "\t")}</>
+            });
+
+            this.errors.push([new Date, obj + "\n" + obj.stack]);
+            this.errors = this.errors.slice(-this.maxErrors);
+            this.errors.toString = function () [k + "\n" + v for ([k, v] in array.iterValues(this))].join("\n\n");
+
+            this.dump(String(error));
+            this.dump(obj);
+            this.dump("");
+        }
+        catch (e) {
+            try {
+                this.dump(String(error));
+                this.dump(util.stackLines(error.stack).join("\n"));
+            }
+            catch (e) { dump(e + "\n"); }
+        }
+
+        // ctypes.open("libc.so.6").declare("kill", ctypes.default_abi, ctypes.void_t, ctypes.int, ctypes.int)(
+        //     ctypes.open("libc.so.6").declare("getpid", ctypes.default_abi, ctypes.int)(), 2)
+    },
+
+    /**
+     * Given a domain, returns an array of all non-toplevel subdomains
+     * of that domain.
+     *
+     * @param {string} host The host for which to find subdomains.
+     * @returns {[string]}
+     */
+    subdomains: function subdomains(host) {
+        if (/(^|\.)\d+$|:.*:/.test(host))
+            // IP address or similar
+            return [host];
+
+        let base = host.replace(/.*\.(.+?\..+?)$/, "$1");
+        try {
+            base = services.tld.getBaseDomainFromHost(host);
+        }
+        catch (e) {}
+
+        let ary = host.split(".");
+        ary = [ary.slice(i).join(".") for (i in util.range(ary.length - 1, 0, -1))];
+        return ary.filter(function (h) h.length >= base.length);
+    },
+
+    /**
+     * Scrolls an element into view if and only if it's not already
+     * fully visible.
+     *
+     * @param {Node} elem The element to make visible.
+     */
+    scrollIntoView: function scrollIntoView(elem, alignWithTop) {
+        let win = elem.ownerDocument.defaultView;
+        let rect = elem.getBoundingClientRect();
+        if (!(rect && rect.bottom <= win.innerHeight && rect.top >= 0 && rect.left < win.innerWidth && rect.right > 0))
+            elem.scrollIntoView(arguments.length > 1 ? alignWithTop : Math.abs(rect.top) < Math.abs(win.innerHeight - rect.bottom));
+    },
+
+    /**
+     * Returns the selection controller for the given window.
+     *
+     * @param {Window} window
+     * @returns {nsISelectionController}
+     */
+    selectionController: function (win)
+        win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
+           .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
+           .QueryInterface(Ci.nsISelectionController),
+
+    /**
+     * Suspend execution for at least *delay* milliseconds. Functions by
+     * yielding execution to the next item in the main event queue, and
+     * so may lead to unexpected call graphs, and long delays if another
+     * handler yields execution while waiting.
+     *
+     * @param {number} delay The time period for which to sleep in milliseconds.
+     */
+    sleep: function (delay) {
+        let mainThread = services.threading.mainThread;
+
+        let end = Date.now() + delay;
+        while (Date.now() < end)
+            mainThread.processNextEvent(true);
+        return true;
+    },
+
+    highlightFilter: function highlightFilter(str, filter, highlight) {
+        return this.highlightSubstrings(str, (function () {
+            if (filter.length == 0)
+                return;
+            let lcstr = String.toLowerCase(str);
+            let lcfilter = filter.toLowerCase();
+            let start = 0;
+            while ((start = lcstr.indexOf(lcfilter, start)) > -1) {
+                yield [start, filter.length];
+                start += filter.length;
+            }
+        })(), highlight || template.filter);
+    },
+
+    /**
+     * Behaves like String.split, except that when *limit* is reached,
+     * the trailing element contains the entire trailing portion of the
+     * string.
+     *
+     *     util.split("a, b, c, d, e", /, /, 3) -> ["a", "b", "c, d, e"]
+     *
+     * @param {string} str The string to split.
+     * @param {RegExp|string} re The regular expression on which to split the string.
+     * @param {number} limit The maximum number of elements to return.
+     * @returns {[string]}
+     */
+    split: function (str, re, limit) {
+        re.lastIndex = 0;
+        if (!re.global)
+            re = RegExp(re.source || re, "g");
+        let match, start = 0, res = [];
+        while (--limit && (match = re.exec(str)) && match[0].length) {
+            res.push(str.substring(start, match.index));
+            start = match.index + match[0].length;
+        }
+        res.push(str.substring(start));
+        return res;
+    },
+
+    /**
+     * Split a string on literal occurrences of a marker.
+     *
+     * Specifically this ignores occurrences preceded by a backslash, or
+     * contained within 'single' or "double" quotes.
+     *
+     * It assumes backslash escaping on strings, and will thus not count quotes
+     * that are preceded by a backslash or within other quotes as starting or
+     * ending quoted sections of the string.
+     *
+     * @param {string} str
+     * @param {RegExp} marker
+     * @returns {[string]}
+     */
+    splitLiteral: function splitLiteral(str, marker) {
+        let results = [];
+        let resep = RegExp(/^(([^\\'"]|\\.|'([^\\']|\\.)*'|"([^\\"]|\\.)*")*?)/.source + marker.source);
+        let cont = true;
+
+        while (cont) {
+            cont = false;
+            str = str.replace(resep, function (match, before) {
+                results.push(before);
+                cont = match !== "";
+                return "";
+            });
+        }
+
+        results.push(str);
+        return results;
+    },
+
+    yielders: 0,
+    threadYield: function (flush, interruptable) {
+        this.yielders++;
+        try {
+            let mainThread = services.threading.mainThread;
+            /* FIXME */
+            util.interrupted = false;
+            do {
+                mainThread.processNextEvent(!flush);
+                if (util.interrupted)
+                    throw new Error("Interrupted");
+            }
+            while (flush === true && mainThread.hasPendingEvents());
+        }
+        finally {
+            this.yielders--;
+        }
+    },
+
+    waitFor: function waitFor(test, self, timeout, interruptable) {
+        let end = timeout && Date.now() + timeout, result;
+
+        let timer = services.Timer(function () {}, 10, services.Timer.TYPE_REPEATING_SLACK);
+        try {
+            while (!(result = test.call(self)) && (!end || Date.now() < end))
+                this.threadYield(false, interruptable);
+        }
+        finally {
+            timer.cancel();
+        }
+        return result;
+    },
+
+    yieldable: function yieldable(func)
+        function magic() {
+            let gen = func.apply(this, arguments);
+            (function next() {
+                try {
+                    util.timeout(next, gen.next());
+                }
+                catch (e if e instanceof StopIteration) {};
+            })();
+        },
+
+    wrapCallback: wrapCallback,
+
+    /**
+     * Returns the top-level chrome window for the given window.
+     *
+     * @param {Window} win The child window.
+     * @returns {Window} The top-level parent window.
+     */
+    topWindow: function topWindow(win)
+            win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
+               .QueryInterface(Ci.nsIDocShellTreeItem).rootTreeItem
+               .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow),
+
+    /**
+     * Traps errors in the called function, possibly reporting them.
+     *
+     * @param {function} func The function to call
+     * @param {object} self The 'this' object for the function.
+     */
+    trapErrors: function trapErrors(func, self) {
+        try {
+            if (isString(func))
+                func = self[func];
+            return func.apply(self || this, Array.slice(arguments, 2));
+        }
+        catch (e) {
+            util.reportError(e);
+            return undefined;
+        }
+    },
+
+    urlPath: function urlPath(url) {
+        try {
+            return util.getFile(url).path;
+        }
+        catch (e) {
+            return url;
+        }
+    },
+
+    visibleHosts: function (win) {
+        let res = [], seen = {};
+        (function rec(frame) {
+            try {
+                res = res.concat(util.subdomains(frame.location.host));
+            }
+            catch (e) {}
+            Array.forEach(frame.frames, rec);
+        })(win);
+        return res.filter(function (h) !set.add(seen, h));
+    },
+
+    visibleURIs: function (win) {
+        let res = [], seen = {};
+        (function rec(frame) {
+            try {
+                res = res.concat(util.newURI(frame.location.href));
+            }
+            catch (e) {}
+            Array.forEach(frame.frames, rec);
+        })(win);
+        return res.filter(function (h) !set.add(seen, h.spec));
+    },
+
+    /**
+     * Converts an E4X XML literal to a DOM node. Any attribute named
+     * highlight is present, it is transformed into dactyl:highlight,
+     * and the named highlight groups are guaranteed to be loaded.
+     *
+     * @param {Node} node
+     * @param {Document} doc
+     * @param {Object} nodes If present, nodes with the "key" attribute are
+     *     stored here, keyed to the value thereof.
+     * @returns {Node}
+     */
+    xmlToDom: function xmlToDom(node, doc, nodes) {
+        XML.prettyPrinting = false;
+        if (typeof node === "string") // Sandboxes can't currently pass us XML objects.
+            node = XML(node);
+
+        if (node.length() != 1) {
+            let domnode = doc.createDocumentFragment();
+            for each (let child in node)
+                domnode.appendChild(xmlToDom(child, doc, nodes));
+            return domnode;
+        }
+
+        switch (node.nodeKind()) {
+        case "text":
+            return doc.createTextNode(String(node));
+        case "element":
+            let domnode = doc.createElementNS(node.namespace(), node.localName());
+
+            for each (let attr in node.@*::*)
+                if (attr.name() != "highlight")
+                    domnode.setAttributeNS(attr.namespace(), attr.localName(), String(attr));
+
+            for each (let child in node.*::*)
+                domnode.appendChild(xmlToDom(child, doc, nodes));
+            if (nodes && node.@key)
+                nodes[node.@key] = domnode;
+
+            if ("@highlight" in node)
+                highlight.highlightNode(domnode, String(node.@highlight), nodes || true);
+            return domnode;
+        default:
+            return null;
+        }
+    }
+}, {
+    Array: array
+});
+
+/**
+ * Math utility methods.
+ * @singleton
+ */
+var GlobalMath = Math;
+var Math = update(Object.create(GlobalMath), {
+    /**
+     * Returns the specified *value* constrained to the range *min* - *max*.
+     *
+     * @param {number} value The value to constrain.
+     * @param {number} min The minimum constraint.
+     * @param {number} max The maximum constraint.
+     * @returns {number}
+     */
+    constrain: function constrain(value, min, max) Math.min(Math.max(min, value), max)
+});
+
+endModule();
+
+} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
+
+// vim: set fdm=marker sw=4 ts=4 et ft=javascript:
diff --git a/common/process_manifest.awk b/common/process_manifest.awk
new file mode 100644 (file)
index 0000000..8a51393
--- /dev/null
@@ -0,0 +1,20 @@
+BEGIN {
+    chrome = "chrome"
+    if (suffix)
+        chrome = suffix
+}
+{ content = $1 ~ /^(content|skin|locale|resource)$/ }
+content && $NF ~ /^[a-z]/ { $NF = "/" name "/" $NF }
+content {
+    sub(/^\.\./, "", $NF);
+    if (isjar)
+           $NF = "jar:chrome/" name ".jar!" $NF
+    else
+           $NF = chrome $NF
+}
+{
+    sub("^\\.\\./common/", "", $NF)
+    print
+}
+
+# vim:se sts=4 sw=4 et ft=awk:
diff --git a/common/skin/dactyl.css b/common/skin/dactyl.css
new file mode 100644 (file)
index 0000000..57b01d3
--- /dev/null
@@ -0,0 +1,229 @@
+@namespace dactyl url("http://vimperator.org/namespaces/liberator");
+@namespace html url("http://www.w3.org/1999/xhtml");
+@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+
+/* Applied to all content */
+[dactyl|activeframe] {
+    -moz-binding: url(resource://dactyl-content/bindings.xml#frame) !important;
+}
+
+[dactyl|highlight~=hints] {
+    -moz-binding: url(resource://dactyl-content/bindings.xml#hints) !important;
+}
+
+[dactyl|highlight~=HintImage],
+[dactyl|highlight~=Hint] {
+    z-index: 50000;
+    position: absolute !important;
+}
+input[type=file][dactyl|highlight~=HintActive],
+input[type=file][dactyl|highlight~=HintElem] {
+    opacity: 1 !important;
+}
+
+@-moz-document
+    url-prefix(dactyl:) {
+
+[dactyl|highlight~=HelpDefault] {
+    -moz-binding: url(resource://dactyl-content/bindings.xml#compitem-td);
+}
+[dactyl|highlight~=HelpDefault] > .td-span {
+    width: auto;
+}
+
+}
+
+/* Applied only to completion buffer and MOW */
+@-moz-document
+    url-prefix(dactyl:),
+    url-prefix(resource://dactyl) {
+
+*:-moz-loading, *:-moz-broken { display: none !important; }
+
+[dactyl|highlight~=Completions] {
+    width: 100%;
+    display: table;
+}
+[dactyl|highlight~=CompItem],
+[dactyl|highlight~=CompTitle] {
+    display: table-row;
+}
+[dactyl|highlight~=Completions] > ul {
+    display: table-row;
+}
+[dactyl|highlight~=CompItem] > *,
+[dactyl|highlight~=CompTitle] > * {
+    -moz-binding: url(resource://dactyl-content/bindings.xml#compitem-td);
+    display: table-cell;
+    vertical-align: middle;
+}
+
+[dactyl|highlight~=CompMsg] {
+    height: 1.5em;
+    line-height: 1.5em !important;
+}
+
+.td-span {
+    display: inline-block;
+    overflow: visible;
+    width: 0px;
+    height: 1.5em;
+    line-height: 1.5em !important;
+}
+.td-strut {
+    display: inline-block;
+    vertical-align: middle;
+    height: 16px;
+    width: 0px;
+}
+
+.times-executed, .time-average { color: green; }
+.time-total { color: red; }
+
+}
+
+@-moz-document
+    url-prefix(dactyl:),
+    url-prefix(resource://dactyl) {
+
+*:-moz-any-link {
+    color: green;
+}
+*:-moz-any-link:hover {
+    text-decoration: underline;
+}
+}
+
+/* Applied to completion buffer, MOW, browser window */
+@-moz-document
+    url-prefix(chrome:),
+    url-prefix(dactyl:),
+    url-prefix(resource:) {
+
+#TabsToolbar .tab-icon-image, .tab-throbber { -moz-box-ordinal-group: 10; }
+[dactyl|highlight~=tab-number]              { -moz-box-ordinal-group: 20; }
+.tab-text, .tab-label, .tab-close-button    { -moz-box-ordinal-group: 50; }
+
+[dactyl|highlight~=Bell] {
+    -moz-appearance: none !important;
+}
+window[dactyl|highlight~=Bell] > * {
+    opacity: 0 !important;
+}
+
+[dactyl|highlight~=CmdLine] {
+    color: inherit !important;
+}
+
+.dactyl-status-field-url {
+    color: inherit !important;
+}
+
+[dactyl|highlight~=CmdLine],
+[dactyl|highlight~=CmdLine] > [dactyl|highlight~=CmdLine] {
+    padding: 0px !important;
+}
+
+label[collapsed=true] {
+    height: 0px;
+    width: 0px;
+}
+
+.dactyl-container > * {
+    font-family: inherit;
+}
+
+.dactyl-completions {
+    -moz-user-focus: ignore;
+    border-width: 0px !important;
+    border-top: 1px solid black !important;
+}
+
+/* fixes the min-height: 22px from firefox */
+statusbarpanel {
+    -moz-appearance: none !important;
+    border: 0 !important;
+       min-height: 18px !important;
+    background: transparent;
+    text-shadow: inherit !important;
+}
+
+/* no longer at the window's bottom right corner */
+.statusbar-resizerpanel {
+    display: none;
+}
+#statusbar-display,
+#statusbar-progresspanel,
+#status-bar > #statusTextBox > #statusText {
+    display: none;
+    visibility: collapse;
+}
+
+.dactyl-commandline-prompt {
+    /* background-color: inherit; */
+    margin: 0px;
+    padding: 0px;
+}
+.dactyl-commandline-command {
+    /* background-color: inherit !important; */
+    color: inherit !important;
+    margin: 0px;
+}
+.dactyl-commandline-command html|*:focus {
+    outline-width: 0px !important
+}
+.dactyl-commandline-command .textbox-search-icons {
+    visibility: collapse !important;
+}
+#dactyl-message {
+    margin: 0px;
+}
+
+#sidebar {
+    max-width: 90% !important;
+    min-width: 10% !important;
+}
+
+/* MOW */
+
+.dactyl-completions,
+#dactyl-multiline-output,
+#dactyl-multiline-input {
+    background-color: white;
+    color: black;
+}
+
+.dactyl-completions-content,
+#dactyl-multiline-output-content,
+#dactyl-multiline-input {
+    white-space: pre;
+    font-family: -moz-fixed;
+    margin: 0px;
+}
+
+#dactyl-commandline-prompt *,
+#dactyl-commandline-command {
+    font: inherit;
+}
+
+.dactyl-completions-content table,
+#dactyl-multiline-output-content table {
+    white-space: inherit;
+    border-spacing: 0px;
+}
+
+.dactyl-completions-content td,
+#dactyl-multiline-output-content td,
+.dactyl-completions-content th,
+#dactyl-multiline-output-content th {
+    padding: 0px 2px;
+}
+
+/* for Teledactyl's composer */
+#content-frame, #appcontent {
+    border: 0px;
+}
+
+}
+
+/* vim: set fdm=marker sw=4 ts=4 et: */
diff --git a/common/tests/functional/dactyl.jsm b/common/tests/functional/dactyl.jsm
new file mode 100644 (file)
index 0000000..9b2a20e
--- /dev/null
@@ -0,0 +1,518 @@
+var utils = {}; Components.utils.import(/([^ ]+\/)[^\/]+$/.exec(Components.stack.filename)[1] + "utils.jsm", utils);
+
+var EXPORTED_SYMBOLS = ["Controller"];
+
+const { module, NS } = utils;
+
+var elementslib = module("resource://mozmill/modules/elementslib.js");
+var frame = module("resource://mozmill/modules/frame.js");
+var jumlib = module("resource://mozmill/modules/jum.js");
+
+function wrapAssertNoErrors(func, message) {
+    return function wrapped(arg) this.assertNoErrors(func, this, arguments, message || arg);
+}
+
+function assertMessage(funcName, want, got, message) {
+    if (typeof want === "string")
+        return utils.assertEqual(funcName, want, got, message);
+    else if (typeof want === "function") {
+        var res = want(got);
+        if (res === undefined)
+            return true;
+        return utils.test(res, {
+            function: funcName,
+            want: want, got: got,
+            comment: message
+        });
+    }
+    else
+        return utils.test(want.test(got), {
+            function: funcName,
+            want: want, got: got,
+            comment: message
+        });
+}
+
+/**
+ * A controller for simulating Dactyl related user actions and for making
+ * assertions about the expected outcomes of such actions.
+ *
+ * @param {MozMillController} controller The browser's MozMill controller.
+ */
+function Controller(controller) {
+    var self = this;
+    this.controller = controller;
+
+    /**
+     * @property {object} The dactyl modules namespace, to be used
+     * sparingly in tests.
+     */
+    this.modules = controller.window.dactyl.modules;
+
+    this.errorCount = 0;
+
+    this._countBeep = function countBeep() {
+        self.beepCount++;
+    }
+    this.errors = [];
+    this._countError = function countError(message, highlight) {
+        if (/\b(Error|Warning)Msg\b/.test(highlight))
+            self.errors.push(String(message));
+    }
+    this.modules.dactyl.registerObserver("beep", this._countBeep);
+    this.modules.dactyl.registerObserver("echoLine", this._countError);
+    this.modules.dactyl.registerObserver("echoMultiline", this._countError);
+
+    this.resetErrorCount();
+}
+
+Controller.prototype = {
+
+    teardown: function () {
+        this.modules.dactyl.unregisterObserver("beep", this._countBeep);
+        this.modules.dactyl.unregisterObserver("echoLine", this._countError);
+        this.modules.dactyl.unregisterObserver("echoMultiline", this._countError);
+    },
+
+    beepCount: 0,
+    errorCount: 0,
+
+    /**
+     * Asserts that an error message is displayed during the execution
+     * of *func*.
+     *
+     * @param {function} func A function to call during before the
+     *      assertion takes place.
+     * @param {object} self The 'this' object to be used during the call
+     *      of *func*. @optional
+     * @param {Array} args Arguments to be passed to *func*. @optional
+     * @param {string} message The message to display upon assertion failure. @optional
+     */
+    assertMessageError: function (func, self, args, message) {
+        let errorCount = this.errors.length;
+        this.assertNoErrors(func, self, args, message);
+        // dump("assertMessageError " + errorCount + " " + this.errorMessageCount + "\n");
+        return utils.assert('dactyl.assertMessageError', this.errors.length > errorCount,
+                            "Expected error but got none" + (message ? ": " + message : ""));
+    },
+
+    /**
+     * Asserts that any output message text content matches *text*.
+     *
+     * @param {string|RegExp|function} want The expected text of the expected message line.
+     * @param {string} message The message to display upon assertion failure.
+     */
+    assertMessage: function (want, message) {
+        return assertMessage('dactyl.assertMessage', want,
+                             this.readMessageWindow() || this.readMessageLine(),
+                             message);
+    },
+
+    /**
+     * Asserts that the output message line text content matches *text*.
+     *
+     * @param {string|RegExp|function} want The expected text of the expected message line.
+     * @param {string} message The message to display upon assertion failure.
+     */
+    assertMessageLine: function (want, message) {
+        return assertMessage('dactyl.assertMessageLine', want,
+                             this.readMessageLine(),
+                             message);
+    },
+
+    /**
+     * Asserts that the output message window text content matches *text*.
+     *
+     * @param {string|RegExp|function} want The expected text of the message window.
+     * @param {string} message The message to display upon assertion failure.
+     */
+    assertMessageWindow: function (want, message) {
+        return assertMessage('dactyl.assertMessageWindow', want,
+                             this.readMessageWindow(),
+                             message);
+    },
+
+    /**
+     * Asserts that the output message line text is an error and content matches *text*.
+     *
+     * @param {string|RegExp|function} want The expected text of the expected message line.
+     * @param {string} message The message to display upon assertion failure.
+     */
+    assertErrorMessage: function (want, message) {
+        return assertMessage('dactyl.assertMessageError', want,
+                             this.readMessageLine(),
+                             message) &&
+               assertMessage('dactyl.assertMessageError', /\bErrorMsg\b/,
+                             this.elements.message.getAttributeNS(NS, "highlight"),
+                             message);
+    },
+
+    /**
+     * Asserts that the multi-line output window is in the given state.
+     *
+     * @param {boolean} open True if the window is expected to be open.
+     * @param {string} message The message to display upon assertion failure. @optional
+     */
+    assertMessageWindowOpen: function (open, message) {
+        return utils.assertEqual('dactyl.assertMessageWindowOpen', open,
+                                 !this.elements.multilineContainer.collapsed,
+                                 message || "Multi-line output not in the expected state");
+    },
+
+    /**
+     * Asserts that the no errors have been reported since the last call
+     * to resetErrorCount.
+     *
+     * @param {function} func A function to call during before the
+     *      assertion takes place. When present, the current error count
+     *      is reset before execution.
+     *      @optional
+     * @param {object} self The 'this' object to be used during the call
+     *      of *func*. @optional
+     * @param {Array} args Arguments to be passed to *func*. @optional
+     * @param {string} message The message to display upon assertion failure. @optional
+     */
+    assertNoErrors: function (func, self, args, message) {
+        let msg = message ? ": " + message : "";
+
+        let beepCount = this.beepCount;
+        let errorCount = this.errorCount;
+        if (func) {
+            errorCount = this.modules.util.errorCount;
+
+            try {
+                var returnVal = func.apply(self || this, args || []);
+            }
+            catch (e) {
+                this.modules.util.reportError(e);
+            }
+        }
+
+        if (this.beepCount > beepCount)
+            frame.log({
+                function: "dactyl.beepMonitor",
+                want: beepCount, got: this.beepCount,
+                comment: "Got " + (this.beepCount - beepCount) + " beeps during execution" + msg
+            });
+
+        if (errorCount != this.modules.util.errorCount)
+            var errors = this.modules.util.errors.slice(errorCount - this.modules.util.errorCount)
+                             .join("\n");
+
+        var res = utils.assertEqual('dactyl.assertNoErrors',
+                                    errorCount, this.modules.util.errorCount,
+                                    "Errors were reported during the execution of this test" + msg + "\n" + errors);
+
+        return returnVal === undefined ? res : returnVal;
+    },
+
+    /**
+     * Asserts that the no error messages are reported during the call
+     * of *func*.
+     *
+     * @param {function} func A function to call during before the
+     *      assertion takes place. When present, the current error count
+     *      is reset before execution.
+     *      @optional
+     * @param {object} self The 'this' object to be used during the call
+     *      of *func*. @optional
+     * @param {Array} args Arguments to be passed to *func*. @optional
+     * @param {string} message The message to display upon assertion failure. @optional
+     */
+    assertNoErrorMessages: function (func, self, args, message) {
+        let msg = message ? ": " + message : "";
+        let count = this.errors.length;
+
+        try {
+            func.apply(self || this, args || []);
+        }
+        catch (e) {
+            this.modules.util.reportError(e);
+        }
+
+        return utils.assertEqual('dactyl.assertNoErrorMessages', count, this.errors.length,
+                                 "Error messsages were reported" + msg + ":\n\t" +
+                                 this.errors.slice(count).join("\n\t"));
+    },
+
+    /**
+     * Resets the error count used to determine whether new errors were
+     * reported during the execution of a test.
+     */
+    resetErrorCount: function () {
+        this.errorCount = this.modules.util.errorCount;
+    },
+
+    /**
+     * Wraps the given function such that any errors triggered during
+     * its execution will trigger a failed assertion.
+     *
+     * @param {function} func The function to wrap.
+     * @param {string} message The message to display upon assertion failure. @optional
+     */
+    wrapAssertNoErrors: function (func, message) {
+        let self = this;
+        return function wrapped() self.assertNoErrors(func, this, arguments, message);
+    },
+
+    /**
+     * Asserts that the current window selection matches *text*.
+     *
+     * @param {string|RegExp|function} text The expected text of the current selection.
+     * @param {string} message The message to display upon assertion failure.
+     */
+    assertSelection: function (want, message) {
+        return assertMessage('dactyl.assertSelection', want,
+                             String(this.controller.window.content.getSelection()),
+                             message);
+    },
+
+    /**
+     * @property {string} The name of dactyl's current key handling
+     * mode.
+     */
+    get currentMode() this.modules.modes.main.name,
+
+    /**
+     * @property {object} A map of dactyl widgets to be used sparingly
+     * for focus assertions.
+     */
+    get elements() let (self = this) ({
+        /**
+         * @property {HTMLInputElement} The command line's command input box
+         */
+        get commandInput() self.modules.commandline.widgets.active.command.inputField,
+        /**
+         * @property {Node|null} The currently focused node.
+         */
+        get focused() self.controller.window.document.commandDispatcher.focusedElement,
+        /**
+         * @property {HTMLInputElement} The message bar's command input box
+         */
+        get message() self.modules.commandline.widgets.active.message,
+        /**
+         * @property {Node} The multi-line output window.
+         */
+        get multiline() self.modules.commandline.widgets.multilineOutput,
+        /**
+         * @property {Node} The multi-line output container.
+         */
+        get multilineContainer() self.modules.commandline.widgets.mowContainer,
+    }),
+
+    /**
+     * Returns dactyl to normal mode.
+     */
+    setNormalMode: wrapAssertNoErrors(function () {
+        // XXX: Normal mode test
+        for (let i = 0; i < 15 && this.modules.modes.stack.length > 1; i++)
+            this.controller.keypress(null, "VK_ESCAPE", {});
+
+        this.controller.keypress(null, "l", { ctrlKey: true });
+
+        utils.assert("dactyl.setNormalMode", this.modules.modes.stack.length == 1,
+                     "Failed to return to Normal mode");
+
+        this.assertMessageWindowOpen(false, "Returning to normal mode: Multi-line output not closed");
+        this.assertMessageLine(function (msg) !msg, "Returning to normal mode: Message not cleared");
+    }, "Returning to normal mode"),
+
+    /**
+     * Returns dactyl to Ex mode.
+     */
+    setExMode: wrapAssertNoErrors(function () {
+        if (this.currentMode !== "EX") {
+            this.setNormalMode();
+            this.controller.keypress(null, ":", {});
+        }
+        else {
+            this.elements.commandInput.value = "";
+        }
+    }),
+
+    /**
+     * Runs a Vi command.
+     *
+     * @param {string|Array} keys Either a string of simple keys suitable for
+     *     {@link MozMillController#type} or an array of keysym - modifier
+     *     pairs suitable for {@link MozMillController#keypress}.
+     */
+    runViCommand: wrapAssertNoErrors(function (keys) {
+        if (typeof keys == "string")
+            keys = [[k] for each (k in keys)];
+        keys.forEach(function ([key, modifiers]) { this.controller.keypress(null, key, modifiers || {}); }, this);
+    }),
+
+    /**
+     * Runs an Ex command.
+     *
+     * @param {string} cmd The Ex command string as entered on the command
+     *     line.
+     * @param {object} args An args object by means of which to execute
+     *     the command. If absent *cmd* is parsed as a complete
+     *     arguments string. @optional
+     */
+    // TODO: Use execution code from commandline.js to catch more
+    // possible errors without being insanely inefficient after the
+    // merge.
+    runExCommand: wrapAssertNoErrors(function (cmd, args) {
+        this.setNormalMode();
+        try {
+            // Force async commands to wait for their output to be ready
+            // before returning.
+            this.modules.commandline.savingOutput = true;
+            if (args)
+                this.modules.ex[cmd](args);
+            else if (true)
+                this.modules.commands.execute(cmd, null, false, null,
+                                             { file: "[Command Line]", line: 1 });
+            else {
+                var doc = this.controller.window.document;
+                var event = doc.createEvent("Events");
+                event.initEvent("dactyl.execute", false, false);
+                doc.documentElement.setAttribute("dactyl-execute", cmd);
+                doc.documentElement.dispatchEvent(event);
+            }
+        }
+        finally {
+            this.modules.commandline.savingOutput = false;
+        }
+    }),
+
+    /**
+     * Triggers a completion function with the given arguments an
+     * ensures that no errors have occurred during the process.
+     *
+     * @param {object} self The 'this' object for which to trigger the
+     *     completer.
+     * @param {function|string} func The method or method name to call.
+     * @param {string} string The method or method name to call. @optional
+     * @param {string} message The message to display upon assertion failure. @optional
+     * @param {...} Extra arguments are passed to the completion
+     *     function directly.
+     */
+    testCompleter: wrapAssertNoErrors(function testCompleter(self, func, string, message) {
+        var context = this.modules.CompletionContext(string || "");
+        context.tabPressed = true;
+        context.forkapply("completions", 0, self, func, Array.slice(arguments, testCompleter.length));
+
+        utils.assert("dactyl.runCompletions", context.wait(5000),
+                     message || "Completion failed: " + self + "." + func);
+
+        for (var [, ctxt] in Iterator(context.contextList))
+            for (var [, item] in Iterator(ctxt.items))
+                ctxt.createRow(item);
+
+        return context;
+    }),
+
+    /**
+     * Triggers Ex completion for the given command string and ensures
+     * that no errors have occurred during the process.
+     *
+     * @param {string} cmd The Ex command string as entered on the command
+     *     line.
+     * @param {boolean} longWay Whether to test the completion by
+     *     entering it into the command line and dispatching a <Tab> key
+     *     press.
+     */
+    runExCompletion: wrapAssertNoErrors(function (cmd, longWay) {
+        // dump("runExCompletion " + cmd + "\n");
+        if (!longWay) {
+            var context = this.modules.CompletionContext(cmd);
+            context.tabPressed = true;
+            context.fork("ex", 0, this.modules.completion, "ex");
+
+            utils.assert("dactyl.runCompletions", context.wait(5000),
+                         "Completion failed: " + cmd.quote());
+
+            for (var [, ctxt] in Iterator(context.contextList))
+                for (var [, item] in Iterator(ctxt.items))
+                    ctxt.createRow(item);
+
+            return context;
+        }
+        else {
+            this.setExMode();
+
+            utils.assertEqual("dactyl.assertCommandLineFocused",
+                              this.elements.commandInput,
+                              this.elements.focused,
+                              "Running Ex Completion: The command line is not focused");
+
+            if (true) {
+                let input = this.elements.commandInput;
+                input.value = cmd;
+
+                var event = input.ownerDocument.createEvent("Events");
+                event.initEvent("change", true, false);
+                input.dispatchEvent(event);
+            }
+            else {
+                this.controller.type(null, cmd);
+
+                utils.assertEqual("dactyl.runExCompletion", cmd,
+                                  this.elements.commandInput.editor.rootElement.firstChild.textContent,
+                                  "Command line does not have the expected value: " + cmd);
+            }
+
+            this.controller.keypress(null, "VK_TAB", {});
+
+            // XXX
+            if (this.modules.commandline._tabTimer)
+                this.modules.commandline._tabTimer.flush();
+            else if (this.modules.commandline.commandSession && this.modules.commandline.commandSession.completions)
+                this.modules.commandline.commandSession.completions.tabTimer.flush();
+        }
+    }),
+
+    /**
+     * Returns the text content of the output message line.
+     *
+     * @returns {string} The message line text content.
+     */
+    readMessageLine: function () {
+        return this.elements.message.value;
+    },
+
+    /**
+     * Returns the text content of the output message window.
+     *
+     * @returns {string} The message window text content.
+     */
+    readMessageWindow: function () {
+        if (!this.elements.multilineContainer.collapsed)
+            return this.elements.multiline.contentDocument.body.textContent;
+        return "";
+    },
+
+    /**
+     * Opens the output message window by echoing a single newline character.
+     */
+    openMessageWindow: wrapAssertNoErrors(function () {
+        this.modules.dactyl.echo("\n");
+    }, "Opening message window"),
+
+    /**
+     * Clears the current message.
+     */
+    clearMessage: function () {
+        this.elements.message.value = ""; // XXX
+    },
+
+    /**
+     * Closes the output message window if open.
+     */
+    closeMessageWindow: wrapAssertNoErrors(function () {
+        for (let i = 0; i < 15 && !this.elements.multilineContainer.collapsed; i++)
+            this.controller.keypress(null, "VK_ESCAPE", {});
+        this.assertMessageWindowOpen(false, "Clearing message window failed");
+    }, "Clearing message window"),
+
+    /**
+     * @property {string} The specific Dactyl application. Eg. Pentadactyl
+     */
+    get applicationName() this.modules.config.appName // XXX
+};
+
+// vim: sw=4 ts=8 et ft=javascript:
diff --git a/common/tests/functional/data/find.html b/common/tests/functional/data/find.html
new file mode 100644 (file)
index 0000000..6752df1
--- /dev/null
@@ -0,0 +1,13 @@
+<title>Test Find Commands</title>
+
+<p>
+A (play /ˈeɪ/; named a, plural aes) is the first letter and a vowel in the basic modern Latin alphabet. It is similar to the Ancient Greek letter Alpha, from which it derives.
+</p>
+
+<p>
+Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+</p>
+
+<p>
+π (sometimes written pi) is a mathematical constant whose value is the ratio of any circle's circumference to its diameter in the Euclidean plane; this is the same value as the ratio of a circle's area to the square of its radius. It is approximately equal to 3.14159265 in the usual decimal notation. Many formulae from mathematics, science, and engineering involve π, which makes it one of the most important mathematical constants.
+</p>
diff --git a/common/tests/functional/shared-modules/addons.js b/common/tests/functional/shared-modules/addons.js
new file mode 100644 (file)
index 0000000..9216708
--- /dev/null
@@ -0,0 +1,1284 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Geo Mealer <gmealer@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+// Include required modules
+var domUtils = require("dom-utils");
+var prefs = require("prefs");
+var tabs = require("tabs");
+var utils = require("utils");
+
+
+const TIMEOUT = 5000;
+const TIMEOUT_DOWNLOAD = 15000;
+const TIMEOUT_SEARCH = 30000;
+
+var pm = Cc["@mozilla.org/permissionmanager;1"].
+         getService(Ci.nsIPermissionManager);
+
+// AMO Preview site
+const AMO_PREVIEW_DOMAIN = "addons.allizom.org";
+const AMO_PREVIEW_SITE = "https://" + AMO_PREVIEW_DOMAIN;
+
+// Available search filters
+const SEARCH_FILTER = [
+  "local",
+  "remote"
+];
+
+// Preferences which have to be changed to make sure we do not interact with the
+// official AMO page but the preview site instead
+const AMO_PREFERENCES = [
+  {name: "extensions.getAddons.browseAddons", old: "addons.mozilla.org", new: AMO_PREVIEW_DOMAIN},
+  {name: "extensions.getAddons.recommended.browseURL", old: "addons.mozilla.org", new: AMO_PREVIEW_DOMAIN},
+  {name: "extensions.getAddons.recommended.url", old: "services.addons.mozilla.org", new: AMO_PREVIEW_DOMAIN},
+  {name: "extensions.getAddons.search.browseURL", old: "addons.mozilla.org", new: AMO_PREVIEW_DOMAIN},
+  {name: "extensions.getAddons.search.url", old: "services.addons.mozilla.org", new: AMO_PREVIEW_DOMAIN},
+  {name: "extensions.getMoreThemesURL", old: "addons.mozilla.org", new: AMO_PREVIEW_DOMAIN}
+];
+
+/**
+ * Constructor
+ */
+function addonsManager(aController) {
+  this._controller = aController;
+  this._tabBrowser = new tabs.tabBrowser(this._controller);
+}
+
+/**
+ * Addons Manager class
+ */
+addonsManager.prototype = {
+
+  ///////////////////////////////
+  // Global section
+  ///////////////////////////////
+
+  /**
+   * Get the controller of the window
+   *
+   * @returns Mozmill Controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns URL's of external DTD files
+   * @type {array of string}
+   */
+  get dtds() {
+    var dtds = [
+      "chrome://mozapps/locale/extensions/extensions.dtd",
+      "chrome://browser/locale/browser.dtd"
+    ];
+
+    return dtds;
+  },
+
+  /**
+   * Open the Add-ons Manager
+   *
+   * @param {object} aSpec
+   *        Information how to open the Add-ons Manager
+   *        Elements: type    - Event, can be menu, or shortcut
+   *                            [optional - default: menu]
+   *                  waitFor - Wait until the Add-ons Manager has been opened
+   *                            [optional - default: true]
+   *
+   *
+   * @returns Reference the tab with the Add-ons Manager open
+   * @type {object}
+   *       Elements: controller - Mozmill Controller of the window
+   *                 index - Index of the tab
+   */
+  open : function addonsManager_open(aSpec) {
+    var spec = aSpec || { };
+    var type = (spec.type == undefined) ? "menu" : spec.type;
+    var waitFor = (spec.waitFor == undefined) ? true : spec.waitFor;
+
+    switch (type) {
+      case "menu":
+        var menuItem = new elementslib.Elem(this._controller.
+                                            menus["tools-menu"].menu_openAddons);
+        this._controller.click(menuItem);
+        break;
+      case "shortcut":
+        var cmdKey = utils.getEntity(this.dtds, "addons.commandkey");
+        this._controller.keypress(null, cmdKey, {accelKey: true, shiftKey: true});
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown event type - " +
+                        event.type);
+    }
+
+    return waitFor ? this.waitForOpened() : null;
+  },
+
+  /**
+   * Check if the Add-ons Manager is open
+   *
+   * @returns True if the Add-ons Manager is open
+   * @type {boolean}
+   */
+  get isOpen() {
+    return (this.getTabs().length > 0);
+  },
+
+  /**
+   * Waits until the Addons Manager has been opened and returns its controller
+   *
+   * @param {object} aSpec
+   *        Object with parameters for customization
+   *        Elements: timeout - Duration to wait for the target state
+   *                            [optional - default: 5s]
+   *
+   * @returns Currently selected tab
+   */
+  waitForOpened : function addonsManager_waitforOpened(aSpec) {
+    var spec = aSpec || { };
+    var timeout = (spec.timeout == undefined) ? TIMEOUT : spec.timeout;
+
+    // TODO: restore after 1.5.1 has landed
+    // var self = this;
+    //
+    // mozmill.utils.waitFor(function() {
+    //   return self.isOpen;
+    // }, timeout, 100, "Add-ons Manager has been opened");
+
+    mozmill.utils.waitForEval("subject.isOpen", timeout, 100, this);
+
+    // The first tab found will be the selected one
+    var tab = this.getTabs()[0];
+    tab.controller.waitForPageLoad();
+
+    return tab;
+  },
+
+  /**
+   * Close the Addons Manager
+   *
+   * @param {object} aSpec
+   *        Information about the event to send
+   *        Elements: type - Event type (closeButton, menu, middleClick, shortcut)
+   */
+  close : function addonsManager_close(aSpec) {
+    this._tabBrowser.closeTab(aSpec);
+  },
+
+  /**
+   * Retrieves the list of open add-ons manager tabs
+   *
+   * @returns List of open tabs
+   * @type {array of object}
+   *       Elements: controller - MozMillController
+   *                 index      - Index of the tab
+   */
+  getTabs : function addonsManager_getTabs() {
+    return tabs.getTabsWithURL("about:addons");
+  },
+
+  /**
+   * Opens the utils button menu and clicks the specified menu entry
+   *
+   * @param {object} aSpec
+   *        Information about the menu
+   *        Elements: item - menu item to click (updateNow, viewUpdates,
+   *                         installFromFile, autoUpdateDefault,
+   *                         resetAddonUpdatesToAutomatic,
+   *                         resetAddonUpdatesToManual)
+   */
+  handleUtilsButton : function addonsManager_handleUtilsButton(aSpec) {
+    var spec = aSpec || { };
+    var item = spec.item;
+
+    if (!item)
+      throw new Error(arguments.callee.name + ": Menu item not specified.");
+
+    var button = this.getElement({type: "utilsButton"});
+    var menu = this.getElement({type: "utilsButton_menu"});
+
+    try {
+      this._controller.click(button);
+
+      // Click the button and wait until menu has been opened
+
+      // TODO: restore after 1.5.1 has landed
+      // mozmill.utils.waitFor(function() {
+      //   return menu.getNode() && menu.getNode().state == "open";
+      // }, TIMEOUT, 100, "Menu of utils button has been opened.");
+
+      mozmill.utils.waitForEval("subject && subject.state == 'open'",
+                                TIMEOUT, 100, menu.getNode());
+
+      // Click the given menu entry and make sure the
+      var menuItem = this.getElement({
+        type: "utilsButton_menuItem",
+        value: "#utils-" + item
+      });
+
+      this._controller.click(menuItem);
+    } finally {
+      // Make sure the menu has been closed
+      this._controller.keypress(menu, "VK_ESCAPE", {});
+
+      // TODO: restore after 1.5.1 has landed
+      // mozmill.utils.waitFor(function() {
+      //   return menu.getNode() && menu.getNode().state == "closed";
+      // }, TIMEOUT, 100, "Menu of utils button has been closed.");
+
+      mozmill.utils.waitForEval("subject && subject.state == 'closed'",
+                                TIMEOUT, 100, menu.getNode());
+    }
+  },
+
+
+  ///////////////////////////////
+  // Add-on section
+  ///////////////////////////////
+
+  /**
+   * Check if the specified add-on is compatible
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   *
+   * @returns True if the add-on is compatible
+   * @type {ElemBase}
+   */
+  isAddonCompatible : function addonsManager_isAddonCompatible(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+
+    if (!addon)
+      throw new Error(arguments.callee.name + ": Add-on not specified.");
+
+    // XXX: Bug 599702 doens't give enough information which type of notification
+    return addon.getNode().getAttribute("notification") != "warning";
+  },
+
+  /**
+   * Check if the specified add-on is enabled
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   *
+   * @returns True if the add-on is enabled
+   * @type {ElemBase}
+   */
+  isAddonEnabled : function addonsManager_isAddonEnabled(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+
+    if (!addon)
+      throw new Error(arguments.callee.name + ": Add-on not specified.");
+
+    return addon.getNode().getAttribute("active") == "true";
+  },
+
+  /**
+   * Check if the specified add-on is installed
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   *
+   * @returns True if the add-on is installed
+   * @type {ElemBase}
+   */
+  isAddonInstalled : function addonsManager_isAddonInstalled(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+
+    if (!addon)
+      throw new Error(arguments.callee.name + ": Add-on not specified.");
+
+    // Bug 600502 : Add-ons in search view are not initialized correctly
+    return addon.getNode().getAttribute("remote") == "false" &&
+           addon.getNode().getAttribute("status") == "installed";
+  },
+
+  /**
+   * Enables the specified add-on
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   */
+  enableAddon : function addonsManager_enableAddon(aSpec) {
+    var spec = aSpec || { };
+    spec.button = "enable";
+
+    var button = this.getAddonButton(spec);
+    this._controller.click(button);
+  },
+
+  /**
+   * Disables the specified add-on
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   */
+  disableAddon : function addonsManager_disableAddon(aSpec) {
+    var spec = aSpec || { };
+    spec.button = "disable";
+
+    var button = this.getAddonButton(spec);
+    this._controller.click(button);
+  },
+
+  /**
+   * Installs the specified add-on
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon   - Add-on element
+   *                  waitFor - Wait until the category has been selected
+   *                            [optional - default: true]
+   *                  timeout - Duration to wait for the download
+   *                            [optional - default: 15s]
+   */
+  installAddon : function addonsManager_installAddon(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+    var timeout = spec.timeout;
+    var button = "install";
+    var waitFor = (spec.waitFor == undefined) ? true : spec.waitFor;
+
+    var button = this.getAddonButton({addon: addon, button: button});
+    this._controller.click(button);
+
+    if (waitFor)
+      this.waitForDownloaded({addon: addon, timeout: timeout});
+  },
+
+  /**
+   * Removes the specified add-on
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   */
+  removeAddon : function addonsManager_removeAddon(aSpec) {
+    var spec = aSpec || { };
+    spec.button = "remove";
+
+    var button = this.getAddonButton(spec);
+    this._controller.click(button);
+  },
+
+  /**
+   * Undo the last action performed for the given add-on
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   */
+  undo : function addonsManager_undo(aSpec) {
+    var spec = aSpec || { };
+    spec.link = "undo";
+
+    var link = this.getAddonLink(spec);
+    this._controller.click(link);
+  },
+
+  /**
+   * Returns the addons from the currently selected view which match the
+   * filter criteria
+   *
+   * @param {object} aSpec
+   *        Information about the filter to apply
+   *        Elements: attribute - DOM attribute of the wanted addon
+   *                              [optional - default: ""]
+   *                  value     - Value of the DOM attribute
+   *                              [optional - default: ""]
+   *
+   * @returns List of addons
+   * @type {array of ElemBase}
+   */
+  getAddons : function addonsManager_addons(aSpec) {
+    var spec = aSpec || {};
+
+    return this.getElements({
+      type: "addons",
+      subtype: spec.attribute,
+      value: spec.value,
+      parent: this.selectedView
+    });
+  },
+
+  /**
+   * Returns the element of the specified add-ons button
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon  - Add-on element
+   *                  button - Button (disable, enable, preferences, remove)
+   *
+   * @returns Add-on button
+   * @type {ElemBase}
+   */
+  getAddonButton : function addonsManager_getAddonButton(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+    var button = spec.button;
+
+    if (!button)
+      throw new Error(arguments.callee.name + ": Button not specified.");
+
+    return this.getAddonChildElement({addon: addon, type: button + "Button"});
+  },
+
+  /**
+   * Returns the element of the specified add-ons link
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon - Add-on element
+   *                  link  - Link
+   *                            List view (more, restart, undo)
+   *                            Detail view (findUpdates, restart, undo)
+   *
+   * @return Add-on link
+   * @type {ElemBase}
+   */
+  getAddonLink : function addonsManager_getAddonLink(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+    var link = spec.link;
+
+    if (!link)
+      throw new Error(arguments.callee.name + ": Link not specified.");
+
+    return this.getAddonChildElement({addon: addon, type: link + "Link"});
+  },
+
+  /**
+   * Returns the element of the specified add-ons radio group
+   *
+   * @param {object} aSpec
+   *        Information on which add-on to operate on
+   *        Elements: addon      - Add-on element
+   *                  radiogroup - Radiogroup
+   *                                 Detail View (autoUpdate)
+   *
+   * @returns Add-on radiogroup
+   * @type {ElemBase}
+   */
+  getAddonRadiogroup : function addonsManager_getAddonRadiogroup(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+    var radiogroup = spec.radiogroup;
+
+    if (!radiogroup)
+      throw new Error(arguments.callee.name + ": Radiogroup not specified.");
+
+    return this.getAddonChildElement({addon: addon, type: radiogroup + "Radiogroup"});
+  },
+
+  /**
+   * Retrieve the given child element of the specified add-on
+   *
+   * @param {object} aSpec
+   *        Information for getting the add-ons child node
+   *        Elements: addon     - Add-on element
+   *                  type      - Type of the element
+   *                              [optional - default: use attribute/value]
+   *                  attribute - DOM attribute of the node
+   *                  value     - Value of the DOM attribute
+   *
+   * @returns Element
+   * @type {ElemBase}
+   */
+  getAddonChildElement : function addonsManager_getAddonChildElement(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+    var attribute = spec.attribute;
+    var value = spec.value;
+    var type = spec.type;
+
+    if (!addon)
+      throw new Error(arguments.callee.name + ": Add-on not specified.");
+
+    // If no type has been set retrieve a general element which needs an
+    // attribute and value
+    if (!type) {
+      type = "element";
+
+      if (!attribute)
+        throw new Error(arguments.callee.name + ": DOM attribute not specified.");
+      if (!value)
+        throw new Error(arguments.callee.name + ": Value not specified.");
+    }
+
+    // For the details view the elements don't have anonymous nodes
+    if (this.selectedView.getNode().id == "detail-view") {
+      return this.getElement({
+        type: "detailView_" + type,
+        subtype: attribute,
+        value: value
+      });
+    } else {
+      return this.getElement({
+        type: "listView_" + type,
+        subtype: attribute,
+        value: value,
+        parent: addon
+      });
+    }
+  },
+
+  /**
+   * Wait until the specified add-on has been downloaded
+   *
+   * @param {object} aSpec
+   *        Object with parameters for customization
+   *        Elements: addon   - Add-on element to wait for being downloaded
+   *                  timeout - Duration to wait for the target state
+   *                            [optional - default: 15s]
+   */
+  waitForDownloaded : function addonsManager_waitForDownloaded(aSpec) {
+    var spec = aSpec || { };
+    var addon = spec.addon;
+    var timeout = (spec.timeout == undefined) ? TIMEOUT_DOWNLOAD : spec.timeout;
+
+    if (!addon)
+      throw new Error(arguments.callee.name + ": Add-on not specified.");
+
+    var self = this;
+    var node = addon.getNode();
+
+    // TODO: restore after 1.5.1 has landed
+    // mozmill.utils.waitFor(function () {
+    //   return node.getAttribute("pending") == "install" &&
+    //          node.getAttribute("status") != "installing";
+    // }, timeout, 100, "'" + node.getAttribute("name") + "' has been downloaded");
+
+    mozmill.utils.waitForEval("subject.getAttribute('pending') == 'install' &&" +
+                              "subject.getAttribute('status') != 'installing'",
+                              timeout, 100, node);
+  },
+
+
+  ///////////////////////////////
+  // Category section
+  ///////////////////////////////
+
+  /**
+   * Retrieve the currently selected category
+   *
+   * @returns Element which represents the currently selected category
+   * @type {ElemBase}
+   */
+  get selectedCategory() {
+    return this.getCategories({attribute: "selected", value: "true"})[0];
+  },
+
+  /**
+   * Returns the categories which match the filter criteria
+   *
+   * @param {object} aSpec
+   *        Information about the filter to apply
+   *        Elements: attribute - DOM attribute of the wanted category
+   *                              [optional - default: ""]
+   *                  value     - Value of the DOM attribute
+   *                              [optional - default: ""]
+   *
+   * @returns List of categories
+   * @type {array of ElemBase}
+   */
+  getCategories : function addonsManager_categories(aSpec) {
+    var spec = aSpec || { };
+
+    var categories = this.getElements({
+      type: "categories",
+      subtype: spec.attribute,
+      value: spec.value
+    });
+
+    if (categories.length == 0)
+      throw new Error(arguments.callee.name + ": Categories could not be found.");
+
+    return categories;
+  },
+
+  /**
+   * Get the category element for the specified id
+   *
+   * @param {object} aSpec
+   *        Information for getting a category
+   *        Elements: id - Category id (search, discover, languages,
+   *                       searchengines, extensions, themes, plugins,
+   *                       availableUpdates, recentUpdates)
+   *
+   * @returns Category
+   * @type {ElemBase}
+   */
+  getCategoryById : function addonsManager_getCategoryById(aSpec) {
+    var spec = aSpec || { };
+    var id = spec.id;
+
+    if (!id)
+      throw new Error(arguments.callee.name + ": Category ID not specified.");
+
+    return this.getCategories({
+      attribute: "id",
+      value: "category-" + id
+    })[0];
+  },
+
+  /**
+   * Get the ID of the given category element
+   *
+   * @param {object} aSpec
+   *        Information for getting a category
+   *        Elements: category - Category to get the id from
+   *
+   * @returns Category Id
+   * @type {string}
+   */
+  getCategoryId : function addonsManager_getCategoryId(aSpec) {
+    var spec = aSpec || { };
+    var category = spec.category;
+
+    if (!category)
+      throw new Error(arguments.callee.name + ": Category not specified.");
+
+    return category.getNode().id;
+  },
+
+  /**
+   * Select the given category
+   *
+   * @param {object} aSpec
+   *        Information for selecting a category
+   *        Elements: category - Category element
+   *                  waitFor  - Wait until the category has been selected
+   *                             [optional - default: true]
+   */
+  setCategory : function addonsManager_setCategory(aSpec) {
+    var spec = aSpec || { };
+    var category = spec.category;
+    var waitFor = (spec.waitFor == undefined) ? true : spec.waitFor;
+
+    if (!category)
+      throw new Error(arguments.callee.name + ": Category not specified.");
+
+    this._controller.click(category);
+
+    if (waitFor)
+      this.waitForCategory({category: category});
+  },
+
+  /**
+   * Select the category with the given id
+   *
+   * @param {object} aSpec
+   *        Information for selecting a category
+   *        Elements: id      - Category id (search, discover, languages,
+   *                            searchengines, extensions, themes, plugins,
+   *                            availableUpdates, recentUpdates)
+   *                  waitFor - Wait until the category has been selected
+   *                            [optional - default: true]
+   */
+  setCategoryById : function addonsManager_setCategoryById(aSpec) {
+    var spec = aSpec || { };
+    var id = spec.id;
+    var waitFor = (spec.waitFor == undefined) ? true : spec.waitFor;
+
+    if (!id)
+      throw new Error(arguments.callee.name + ": Category ID not specified.");
+
+    // Retrieve the category and set it as active
+    var category = this.getCategoryById({id: id});
+    if (category)
+      this.setCategory({category: category, waitFor: waitFor});
+    else
+      throw new Error(arguments.callee.name + ": Category '" + id + " not found.");
+  },
+
+  /**
+   * Wait until the specified category has been selected
+   *
+   * @param {object} aSpec
+   *        Object with parameters for customization
+   *        Elements: category - Category element to wait for
+   *                  timeout - Duration to wait for the target state
+   *                            [optional - default: 5s]
+   */
+  waitForCategory : function addonsManager_waitForCategory(aSpec) {
+    var spec = aSpec || { };
+    var category = spec.category;
+    var timeout = (spec.timeout == undefined) ? TIMEOUT : spec.timeout;
+
+    if (!category)
+      throw new Error(arguments.callee.name + ": Category not specified.");
+
+    // TODO: restore after 1.5.1 has landed
+    // var self = this;
+    // mozmill.utils.waitFor(function () {
+    //   return self.selectedCategory.getNode() == category.getNode();
+    // }, timeout, 100, "Category '" + category.getNode().id + "' has been set");
+
+    mozmill.utils.waitForEval("subject.self.selectedCategory.getNode() == subject.aCategory.getNode()",
+                               timeout, 100,
+                               {self: this, aCategory: category});
+  },
+
+  ///////////////////////////////
+  // Search section
+  ///////////////////////////////
+
+  /**
+   * Clear the search field
+   */
+  clearSearchField : function addonsManager_clearSearchField() {
+    var textbox = this.getElement({type: "search_textbox"});
+    var cmdKey = utils.getEntity(this.dtds, "selectAllCmd.key");
+
+    this._controller.keypress(textbox, cmdKey, {accelKey: true});
+    this._controller.keypress(textbox, 'VK_DELETE', {});
+  },
+
+  /**
+   * Search for a specified add-on
+   *
+   * @param {object} aSpec
+   *        Information to execute the search
+   *        Elements: value   - Search term
+   *                  timeout - Duration to wait for search results
+   *                            [optional - default: 30s]
+   *                  waitFor - Wait until the search has been finished
+   *                            [optional - default: true]
+   */
+  search : function addonsManager_search(aSpec) {
+    var spec = aSpec || { };
+    var value = spec.value;
+    var timeout = (spec.timeout == undefined) ? TIMEOUT_SEARCH : spec.timeout;
+    var waitFor = (spec.waitFor == undefined) ? true : spec.waitFor;
+
+    if (!value)
+      throw new Error(arguments.callee.name + ": Search term not specified.");
+
+    var textbox = this.getElement({type: "search_textbox"});
+
+    this.clearSearchField();
+    this._controller.type(textbox, value);
+    this._controller.keypress(textbox, "VK_RETURN", {});
+
+    if (waitFor)
+      this.waitForSearchFinished();
+  },
+
+  /**
+   * Check if a search is active
+   *
+   * @returns State of the search
+   * @type {boolean}
+   */
+  get isSearching() {
+    var throbber = this.getElement({type: "search_throbber"});
+    return throbber.getNode().hasAttribute("active");
+  },
+
+  /**
+   * Retrieve the currently selected search filter
+   *
+   * @returns Element which represents the currently selected search filter
+   * @type {ElemBase}
+   */
+  get selectedSearchFilter() {
+    var filter = this.getSearchFilter({attribute: "selected", value: "true"});
+
+    return (filter.length > 0) ? filter[0] : undefined;
+  },
+
+  /**
+   * Set the currently selected search filter status
+   *
+   * @param {string} aValue
+   *        Filter for the search results (local, remote)
+   */
+  set selectedSearchFilter(aValue) {
+    var filter = this.getSearchFilter({attribute: "value", value: aValue});
+
+    if (SEARCH_FILTER.indexOf(aValue) == -1)
+      throw new Error(arguments.callee.name + ": '" + aValue +
+                      "' is not a valid search filter");
+
+    if (filter.length > 0) {
+      this._controller.click(filter[0]);
+      this.waitForSearchFilter({filter: filter[0]});
+    }
+  },
+
+  /**
+   * Returns the available search filters which match the filter criteria
+   *
+   * @param {object} aSpec
+   *        Information about the filter to apply
+   *        Elements: attribute - DOM attribute of the wanted filter
+   *                              [optional - default: ""]
+   *                  value     - Value of the DOM attribute
+   *                              [optional - default: ""]
+   *
+   * @returns List of search filters
+   * @type {array of ElemBase}
+   */
+  getSearchFilter : function addonsManager_getSearchFilter(aSpec) {
+    var spec = aSpec || { };
+
+    return this.getElements({
+      type: "search_filterRadioButtons",
+      subtype: spec.attribute,
+      value: spec.value
+    });
+  },
+
+  /**
+   * Get the search filter element for the specified value
+   *
+   * @param {string} aValue
+   *        Search filter value (local, remote)
+   *
+   * @returns Search filter element
+   * @type {ElemBase}
+   */
+  getSearchFilterByValue : function addonsManager_getSearchFilterByValue(aValue) {
+    if (!aValue)
+      throw new Error(arguments.callee.name + ": Search filter value not specified.");
+
+    return this.getElement({
+      type: "search_filterRadioGroup",
+      subtype: "value",
+      value: aValue
+    });
+  },
+
+  /**
+   * Get the value of the given search filter element
+   *
+   * @param {object} aSpec
+   *        Information for getting the views matched by the criteria
+   *        Elements: filter - Filter element
+   *
+   * @returns Value of the search filter
+   * @type {string}
+   */
+  getSearchFilterValue : function addonsManager_getSearchFilterValue(aSpec) {
+    var spec = aSpec || { };
+    var filter = spec.filter;
+
+    if (!filter)
+      throw new Error(arguments.callee.name + ": Search filter not specified.");
+
+    return filter.getNode().value;
+  },
+
+  /**
+   * Waits until the specified search filter has been selected
+   *
+   * @param {object} aSpec
+   *        Object with parameters for customization
+   *        Elements: filter  - Filter element to wait for
+   *                  timeout - Duration to wait for the target state
+   *                            [optional - default: 5s]
+   */
+  waitForSearchFilter : function addonsManager_waitForSearchFilter(aSpec) {
+    var spec = aSpec || { };
+    var filter = spec.filter;
+    var timeout = (spec.timeout == undefined) ? TIMEOUT : spec.timeout;
+
+    if (!filter)
+      throw new Error(arguments.callee.name + ": Search filter not specified.");
+
+    // TODO: restore after 1.5.1 has landed
+    // var self = this;
+    //
+    // mozmill.utils.waitFor(function () {
+    //   return self.selectedSearchFilter.getNode() == filter.getNode();
+    // }, timeout, 100, "Search filter '" + filter.getNode().value + "' has been set");
+
+    mozmill.utils.waitForEval("subject.self.selectedSearchFilter.getNode() == subject.aFilter.getNode()",
+                              timeout, 100,
+                              {self: this, aFilter: filter});
+  },
+
+  /**
+   * Returns the list of add-ons found by the selected filter
+   *
+   * @returns List of add-ons
+   * @type {ElemBase}
+   */
+  getSearchResults : function addonsManager_getSearchResults() {
+    var filterValue = this.getSearchFilterValue({
+      filter: this.selectedSearchFilter
+    });
+
+    switch (filterValue) {
+      case "local":
+        return this.getAddons({attribute: "status", value: "installed"});
+      case "remote":
+        return this.getAddons({attribute: "remote", value: "true"});
+      default:
+        throw new Error(arguments.callee.name + ": Unknown search filter '" +
+                        filterValue + "' selected");
+    }
+  },
+
+  /**
+   * Waits until the active search has been finished
+   *
+   * @param {object} aSpec
+   *        Object with parameters for customization
+   *        Elements: timeout - Duration to wait for the target state
+   */
+  waitForSearchFinished : function addonsManager_waitForSearchFinished(aSpec) {
+    var spec = aSpec || { };
+    var timeout = (spec.timeout == undefined) ? TIMEOUT_SEARCH : spec.timeout;
+
+    // TODO: restore after 1.5.1 has landed
+    // var self = this;
+    //
+    // mozmill.utils.waitFor(function () {
+    //   return self.isSearching == false;
+    // }, timeout, 100, "Search has been finished");
+
+    mozmill.utils.waitForEval("subject.isSearching == false",
+                              timeout, 100, this);
+  },
+
+  ///////////////////////////////
+  // View section
+  ///////////////////////////////
+
+  /**
+   * Returns the views which match the filter criteria
+   *
+   * @param {object} aSpec
+   *        Information for getting the views matched by the criteria
+   *        Elements: attribute - DOM attribute of the node
+   *                              [optional - default: ""]
+   *                  value     - Value of the DOM attribute
+   *                              [optional - default: ""]
+   *
+   * @returns Filtered list of views
+   * @type {array of ElemBase}
+   */
+  getViews : function addonsManager_getViews(aSpec) {
+    var spec = aSpec || { };
+    var attribute = spec.attribute;
+    var value = spec.value;
+
+    return this.getElements({type: "views", subtype: attribute, value: value});
+  },
+
+  /**
+   * Check if the details view is active
+   *
+   * @returns True if the default view is selected
+   * @type {boolean}
+   */
+  get isDetailViewActive() {
+    return (this.selectedView.getNode().id == "detail-view");
+  },
+
+  /**
+   * Retrieve the currently used view
+   *
+   * @returns Element which represents the currently selected view
+   * @type {ElemBase}
+   */
+  get selectedView() {
+    var viewDeck = this.getElement({type: "viewDeck"});
+    var views = this.getViews();
+
+    return views[viewDeck.getNode().selectedIndex];
+  },
+
+
+  ///////////////////////////////
+  // UI Elements section
+  ///////////////////////////////
+
+  /**
+   * Retrieve an UI element based on the given specification
+   *
+   * @param {object} aSpec
+   *        Information of the UI elements which should be retrieved
+   *        Elements: type     - Identifier of the element
+   *                  subtype  - Attribute of the element to filter
+   *                             [optional - default: ""]
+   *                  value    - Value of the attribute to filter
+   *                             [optional - default: ""]
+   *                  parent   - Parent of the to find element
+   *                             [optional - default: document]
+   *
+   * @returns Element which has been found
+   * @type {ElemBase}
+   */
+  getElement : function addonsManager_getElement(aSpec) {
+    var elements = this.getElements(aSpec);
+
+    return (elements.length > 0) ? elements[0] : undefined;
+  },
+
+  /**
+   * Retrieve list of UI elements based on the given specification
+   *
+   * @param {object} aSpec
+   *        Information of the UI elements which should be retrieved
+   *        Elements: type     - Identifier of the element
+   *                  subtype  - Attribute of the element to filter
+   *                             [optional - default: ""]
+   *                  value    - Value of the attribute to filter
+   *                             [optional - default: ""]
+   *                  parent   - Parent of the to find element
+   *                             [optional - default: document]
+   *
+   * @returns Elements which have been found
+   * @type {array of ElemBase}
+   */
+  getElements : function addonsManager_getElements(aSpec) {
+    var spec = aSpec || { };
+    var type = spec.type;
+    var subtype = spec.subtype;
+    var value = spec.value;
+    var parent = spec.parent;
+
+    var root = parent ? parent.getNode() : this._controller.tabs.activeTab;
+    var nodeCollector = new domUtils.nodeCollector(root);
+
+    switch (type) {
+      // Add-ons
+      case "addons":
+        nodeCollector.queryNodes(".addon").filterByDOMProperty(subtype, value);
+        break;
+      case "addonsList":
+        nodeCollector.queryNodes("#addon-list");
+        break;
+      // Categories
+      case "categoriesList":
+        nodeCollector.queryNodes("#categories");
+        break;
+      case "categories":
+        nodeCollector.queryNodes(".category").filterByDOMProperty(subtype, value);
+        break;
+      // Detail view
+      case "detailView_element":
+        nodeCollector.queryNodes(value);
+        break;
+      case "detailView_disableButton":
+        nodeCollector.queryNodes("#detail-disable");
+        break;
+      case "detailView_enableButton":
+        nodeCollector.queryNodes("#detail-enable");
+        break;
+      case "detailView_installButton":
+        nodeCollector.queryNodes("#detail-install");
+        break;
+      case "detailView_preferencesButton":
+        nodeCollector.queryNodes("#detail-prefs");
+        break;
+      case "detailView_removeButton":
+        nodeCollector.queryNodes("#detail-uninstall");
+        break;
+      case "detailView_findUpdatesLink":
+        nodeCollector.queryNodes("#detail-findUpdates");
+        break;
+      // Bug 599771 - button-link's are missing id or anonid
+      //case "detailView_restartLink":
+      //  nodeCollector.queryNodes("#detail-restart");
+      //  break;
+      case "detailView_undoLink":
+        nodeCollector.queryNodes("#detail-undo");
+        break;
+      case "detailView_findUpdatesRadiogroup":
+        nodeCollector.queryNodes("#detail-findUpdates");
+        break;
+      // List view
+      case "listView_element":
+        nodeCollector.queryAnonymousNodes(subtype, value);
+        break;
+      case "listView_disableButton":
+        nodeCollector.queryAnonymousNodes("anonid", "disable-btn");
+        break;
+      case "listView_enableButton":
+        nodeCollector.queryAnonymousNodes("anonid", "enable-btn");
+        break;
+      case "listView_installButton":
+        // There is another binding we will have to skip
+        nodeCollector.queryAnonymousNodes("anonid", "install-status");
+        nodeCollector.root = nodeCollector.nodes[0];
+        nodeCollector.queryAnonymousNodes("anonid", "install-remote");
+        break;
+      case "listView_preferencesButton":
+        nodeCollector.queryAnonymousNodes("anonid", "preferences-btn");
+        break;
+      case "listView_removeButton":
+        nodeCollector.queryAnonymousNodes("anonid", "remove-btn");
+        break;
+      case "listView_moreLink":
+        // Bug 599771 - button-link's are missing id or anonid
+        nodeCollector.queryAnonymousNodes("class", "details button-link");
+        break;
+      // Bug 599771 - button-link's are missing id or anonid
+      //case "listView_restartLink":
+      //  nodeCollector.queryAnonymousNodes("anonid", "restart");
+      //  break;
+      case "listView_undoLink":
+        nodeCollector.queryAnonymousNodes("anonid", "undo");
+        break;
+      case "listView_cancelDownload":
+        // There is another binding we will have to skip
+        nodeCollector.queryAnonymousNodes("anonid", "install-status");
+        nodeCollector.root = nodeCollector.nodes[0];
+        nodeCollector.queryAnonymousNodes("anonid", "cancel");
+        break;
+      case "listView_pauseDownload":
+        // There is another binding we will have to skip
+        nodeCollector.queryAnonymousNodes("anonid", "install-status");
+        nodeCollector.root = nodeCollector.nodes[0];
+        nodeCollector.queryAnonymousNodes("anonid", "pause");
+        break;
+      case "listView_progressDownload":
+        // There is another binding we will have to skip
+        nodeCollector.queryAnonymousNodes("anonid", "install-status");
+        nodeCollector.root = nodeCollector.nodes[0];
+        nodeCollector.queryAnonymousNodes("anonid", "progress");
+        break;
+      // Search
+      // Bug 599775 - Controller needs to handle radio groups correctly
+      // Means for now we have to use the radio buttons
+      case "search_filterRadioButtons":
+        nodeCollector.queryNodes(".search-filter-radio").filterByDOMProperty(subtype, value);
+        break;
+      case "search_filterRadioGroup":
+        nodeCollector.queryNodes("#search-filter-radiogroup");
+        break;
+      case "search_textbox":
+        nodeCollector.queryNodes("#header-search");
+        break;
+      case "search_throbber":
+        nodeCollector.queryNodes("#header-searching");
+        break;
+      // Utils
+      case "utilsButton":
+        nodeCollector.queryNodes("#header-utils-btn");
+        break;
+      case "utilsButton_menu":
+        nodeCollector.queryNodes("#utils-menu");
+        break;
+      case "utilsButton_menuItem":
+        nodeCollector.queryNodes(value);
+        break;
+      // Views
+      case "viewDeck":
+        nodeCollector.queryNodes("#view-port");
+        break;
+      case "views":
+        nodeCollector.queryNodes(".view-pane").filterByDOMProperty(subtype, value);
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return nodeCollector.elements;
+  }
+};
+
+/**
+ * Whitelist permission for the specified domain
+ * @param {string} aDomain
+ *        The domain to add the permission for
+ */
+function addToWhiteList(aDomain) {
+  pm.add(utils.createURI(aDomain),
+         "install",
+         Ci.nsIPermissionManager.ALLOW_ACTION);
+}
+
+/**
+ * Remove whitelist permission for the specified host
+ * @param {string} aHost
+ *        The host whose permission will be removed
+ */
+function removeFromWhiteList(aHost) {
+  pm.remove(aHost, "install");
+}
+
+/**
+ * Reset all preferences which point to the preview sub domain
+ */
+function resetAmoPreviewUrls() {
+  var prefSrv = prefs.preferences;
+
+  for each (var preference in AMO_PREFERENCES) {
+    prefSrv.clearUserPref(preference.name);
+  }
+}
+
+/**
+ *  Updates all necessary preferences to the preview sub domain
+ */
+function useAmoPreviewUrls() {
+  var prefSrv = prefs.preferences;
+
+  for each (var preference in AMO_PREFERENCES) {
+    var pref = prefSrv.getPref(preference.name, "");
+    prefSrv.setPref(preference.name,
+                    pref.replace(preference.old, preference.new));
+  }
+}
+
+// Export of variables
+exports.AMO_PREVIEW_DOMAIN = AMO_PREVIEW_DOMAIN;
+exports.AMO_PREVIEW_SITE = AMO_PREVIEW_SITE;
+
+// Export of functions
+exports.addToWhiteList = addToWhiteList;
+exports.removeFromWhiteList = removeFromWhiteList;
+exports.resetAmoPreviewUrls = resetAmoPreviewUrls;
+exports.useAmoPreviewUrls = useAmoPreviewUrls;
+
+// Export of classes
+exports.addonsManager = addonsManager;
diff --git a/common/tests/functional/shared-modules/dom-utils.js b/common/tests/functional/shared-modules/dom-utils.js
new file mode 100644 (file)
index 0000000..9e6b701
--- /dev/null
@@ -0,0 +1,685 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Adrian Kalla <akalla@aviary.pl>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+// Include required modules
+var modalDialog = require("modal-dialog");
+var utils = require("utils");
+
+
+/**
+ * Unwraps a node which is wrapped into a XPCNativeWrapper or XrayWrapper
+ *
+ * @param {DOMnode} Wrapped DOM node
+ * @returns {DOMNode} Unwrapped DOM node
+ */
+function unwrapNode(aNode) {
+  var node = aNode;
+
+  if (node) {
+    // unwrap is not available on older branches (3.5 and 3.6) - Bug 533596
+    if ("unwrap" in XPCNativeWrapper) {
+      node = XPCNativeWrapper.unwrap(node);
+    }
+    else if ("wrappedJSObject" in node) {
+      node = node.wrappedJSObject;
+    }
+  }
+
+  return node;
+}
+
+
+/**
+ * DOMWalker Constructor
+ *
+ * @param {MozMillController} controller
+ *        MozMill controller of the window to operate on.
+ * @param {Function} callbackFilter
+ *        callback-method to filter nodes
+ * @param {Function} callbackNodeTest
+ *        callback-method to test accepted nodes
+ * @param {Function} callbackResults
+ *        callback-method to process the results
+ *        [optional - default: undefined]
+ */
+function DOMWalker(controller, callbackFilter, callbackNodeTest,
+                   callbackResults) {
+
+  this._controller = controller;
+  this._callbackFilter = callbackFilter;
+  this._callbackNodeTest = callbackNodeTest;
+  this._callbackResults = callbackResults;
+}
+
+DOMWalker.FILTER_ACCEPT = 1;
+DOMWalker.FILTER_REJECT = 2;
+DOMWalker.FILTER_SKIP = 3;
+
+DOMWalker.GET_BY_ID = "id";
+DOMWalker.GET_BY_SELECTOR = "selector";
+
+DOMWalker.WINDOW_CURRENT = 1;
+DOMWalker.WINDOW_MODAL = 2;
+DOMWalker.WINDOW_NEW = 4;
+
+DOMWalker.prototype = {
+  /**
+   * Returns the filter-callback
+   *
+   * @returns Function
+   */
+  get callbackFilter() {
+    return this._callbackFilter;
+  },
+
+  /**
+   * Returns the node-testing-callback
+   *
+   * @returns Function
+   */
+  get callbackNodeTest() {
+    return this._callbackNodeTest;
+  },
+
+  /**
+   * Returns the results-callback
+   *
+   * @returns Function
+   */
+  get callbackResults() {
+    return this._callbackResults;
+  },
+
+  /**
+   * Returns the MozMill controller
+   *
+   * @returns Mozmill controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * The main DOMWalker function.
+   *
+   * It start's the _walk-method for a given window or other dialog, runs
+   * a callback to process the results for that window/dialog.
+   * After that switches to provided new windows/dialogs.
+   *
+   * @param {array of objects} ids
+   *        Contains informations on the elements to open while
+   *        Object-elements:  getBy         - attribute-name of the attribute
+   *                                          containing the identification
+   *                                          information for the opener-element
+   *                          subContent    - array of ids of the opener-elements
+   *                                          in the window with the value of
+   *                                          the above getBy-attribute
+   *                          target        - information, where the new
+   *                                          elements will be opened
+   *                                          [1|2|4]
+   *                          title         - title of the opened dialog/window
+   *                          waitFunction  - The function used as an argument
+   *                                          for MozmillController.waitFor to
+   *                                          wait before starting the walk.
+   *                                          [optional - default: no waiting]
+   *                          windowHandler - Window instance
+   *                                          [only needed for some tests]
+   *
+   * @param {Node} root
+   *        Node to start testing from
+   *        [optional - default: this._controller.window.document.documentElement]
+   * @param {Function} waitFunction
+   *        The function used as an argument for MozmillController.waitFor to
+   *        wait before starting the walk.
+   *        [optional - default: no waiting]
+   */
+  walk : function DOMWalker_walk(ids, root, waitFunction) {
+    if (typeof waitFunction == 'function')
+      this._controller.waitFor(waitFunction());
+
+    if (!root)
+      root = this._controller.window.document.documentElement;
+
+    var resultsArray = this._walk(root);
+
+    if (typeof this._callbackResults == 'function')
+      this._callbackResults(this._controller, resultsArray);
+
+    if (ids)
+      this._prepareTargetWindows(ids);
+  },
+
+  /**
+   * DOMWalker_filter filters a given node by submitting it to the
+   * this._callbackFilter method to decide, if it should be submitted to
+   * a provided this._callbackNodeTest method for testing (that hapens in case
+   * of FILTER_ACCEPT).
+   * In case of FILTER_ACCEPT and FILTER_SKIP, the children of such a node
+   * will be filtered recursively.
+   * Nodes with the nodeStatus "FILTER_REJECT" and their descendants will be
+   * completetly ignored.
+   *
+   * @param {Node} node
+   *        Node to filter
+   * @param {array of elements} collectedResults
+   *        An array with gathered all results from testing a given element
+   * @returns An array with gathered all results from testing a given element
+   * @type {array of elements}
+   */
+  _filter : function DOMWalker_filter(node, collectedResults) {
+    var nodeStatus = this._callbackFilter(node);
+
+    var nodeTestResults = [];
+
+    switch (nodeStatus) {
+      case DOMWalker.FILTER_ACCEPT:
+        nodeTestResults = this._callbackNodeTest(node);
+        collectedResults = collectedResults.concat(nodeTestResults);
+        // no break here as we have to perform the _walk below too
+      case DOMWalker.FILTER_SKIP:
+        nodeTestResults = this._walk(node);
+        break;
+      default:
+        break;
+    }
+
+    collectedResults = collectedResults.concat(nodeTestResults);
+
+    return collectedResults;
+  },
+
+  /**
+   * Retrieves and returns a wanted node based on the provided identification
+   * set.
+   *
+   * @param {array of objects} idSet
+   *        Contains informations on the elements to open while
+   *        Object-elements:  getBy         - attribute-name of the attribute
+   *                                          containing the identification
+   *                                          information for the opener-element
+   *                          subContent    - array of ids of the opener-elements
+   *                                          in the window with the value of
+   *                                          the above getBy-attribute
+   *                          target        - information, where the new
+   *                                          elements will be opened
+   *                                          [1|2|4]
+   *                          title         - title of the opened dialog/window
+   *                          waitFunction  - The function used as an argument
+   *                                          for MozmillController.waitFor to
+   *                                          wait before starting the walk.
+   *                                          [optional - default: no waiting]
+   *                          windowHandler - Window instance
+   *                                          [only needed for some tests]
+   *
+   * @returns Node
+   * @type {Node}
+   */
+  _getNode : function DOMWalker_getNode(idSet) {
+    var doc = this._controller.window.document;
+
+    // QuerySelector seems to be unusuale for id's in this case:
+    // https://developer.mozilla.org/En/Code_snippets/QuerySelector
+    switch (idSet.getBy) {
+      case DOMWalker.GET_BY_ID:
+        return doc.getElementById(idSet[idSet.getBy]);
+      case DOMWalker.GET_BY_SELECTOR:
+        return doc.querySelector(idSet[idSet.getBy]);
+      default:
+        throw new Error("Not supported getBy-attribute: " + idSet.getBy);
+    }
+  },
+
+  /**
+   * Main entry point to open new elements like windows, tabpanels, prefpanes,
+   * dialogs
+   *
+   * @param {array of objects} ids
+   *        Contains informations on the elements to open while
+   *        Object-elements:  getBy         - attribute-name of the attribute
+   *                                          containing the identification
+   *                                          information for the opener-element
+   *                          subContent    - array of ids of the opener-elements
+   *                                          in the window with the value of
+   *                                          the above getBy-attribute
+   *                          target        - information, where the new
+   *                                          elements will be opened
+   *                                          [1|2|4]
+   *                          title         - title of the opened dialog/window
+   *                          waitFunction  - The function used as an argument
+   *                                          for MozmillController.waitFor to
+   *                                          wait before starting the walk.
+   *                                          [optional - default: no waiting]
+   *                          windowHandler - Window instance
+   *                                          [only needed for some tests]
+   */
+  _prepareTargetWindows : function DOMWalker_prepareTargetWindows(ids) {
+    var doc = this._controller.window.document;
+
+    // Go through all the provided ids
+    for (var i = 0; i < ids.length; i++) {
+      var node = this._getNode(ids[i]);
+
+      // Go further only, if the needed element exists
+      if (node) {
+        var idSet = ids[i];
+
+        // Decide if what we want to open is a new normal/modal window or if it
+        // will be opened in the current window.
+        switch (idSet.target) {
+          case DOMWalker.WINDOW_CURRENT:
+            this._processNode(node, idSet);
+            break;
+          case DOMWalker.WINDOW_MODAL:
+            // Modal windows have to be able to access that informations
+            var modalInfos = {ids : idSet.subContent,
+                              callbackFilter :  this._callbackFilter,
+                              callbackNodeTest : this._callbackNodeTest,
+                              callbackResults : this._callbackResults,
+                              waitFunction : idSet.waitFunction}
+            persisted.modalInfos = modalInfos;
+
+            var md = new modalDialog.modalDialog(this._controller.window);
+            md.start(this._modalWindowHelper);
+
+            this._processNode(node, idSet);
+            md.waitForDialog();
+            break;
+          case DOMWalker.WINDOW_NEW:
+            this._processNode(node, idSet);
+
+            // Get the new non-modal window controller
+            var controller = utils.handleWindow('title', idSet.title,
+                                                undefined, false);
+
+            // Start a new DOMWalker instance
+            let domWalker = new DOMWalker(controller, this._callbackFilter,
+                                          this._callbackNodeTest,
+                                          this._callbackResults);
+            domWalker.walk(idSet.subContent,
+                           controller.window.document.documentElement,
+                           idSet.waitFunction);
+
+            // Close the window
+            controller.window.close();
+            break;
+          default:
+            throw new Error("Node does not exist: " + ids[i][ids[i].getBy]);
+        }
+      }
+    }
+  },
+
+  /**
+   * Opens new windows/dialog and starts the DOMWalker.walk() in case of dialogs
+   * in existing windows.
+   *
+   * @param {Node} activeNode
+   *        Node that holds the information which way
+   *        to open the new window/dialog
+   * @param {object} idSet
+   *        ID set for the element to open
+   */
+  _processNode: function DOMWalker_processNode(activeNode, idSet) {
+    var doc = this._controller.window.document;
+    var nodeToProcess = this._getNode(idSet);
+
+    // Opens a new window/dialog through a menulist and runs DOMWalker.walk()
+    // for it.
+    // If the wanted window/dialog is already selected, just run this function
+    // recursively for it's descendants.
+    if (activeNode.localName == "menulist") {
+      if (nodeToProcess.value != idSet.value) {
+        var dropDown = new elementslib.Elem(nodeToProcess);
+        this._controller.waitForElement(dropDown);
+
+        this._controller.select(dropDown, null, null, idSet.value);
+
+        this._controller.waitFor(function() {
+          return nodeToProcess.value == idSet.value;
+        }, "The menu item did not load in time: " + idSet.value);
+
+        // If the target is a new modal/non-modal window, this.walk() has to be
+        // started by the method opening that window. If not, we do it here.
+        if (idSet.target == DOMWalker.WINDOW_CURRENT)
+          this.walk(idSet.subContent, null, idSet.waitFunction);
+      } else if (nodeToProcess.selected && idSet.subContent &&
+                 idSet.subContent.length > 0) {
+        this._prepareTargetWindows(idSet.subContent);
+      }
+    }
+
+    // Opens a new prefpane using a provided windowHandler object
+    // and runs DOMWalker.walk() for it.
+    // If the wanted prefpane is already selected, just run this function
+    // recursively for it's descendants.
+    else if (activeNode.localName == "prefpane") {
+      var windowHandler = idSet.windowHandler;
+
+      if (windowHandler.paneId != idSet.id) {
+        windowHandler.paneId = idSet.id;
+
+        // Wait for the pane's content to load and to be fully displayed
+        this._controller.waitFor(function() {
+          return (nodeToProcess.loaded &&
+                  (!mozmill.isMac ||
+                   nodeToProcess.style.opacity == 1 ||
+                   nodeToProcess.style.opacity == null));
+        }, "The pane did not load in time: " + idSet.id);
+
+        // If the target is a new modal/non-modal window, this.walk() has to be
+        // started by the method opening that window. If not, we do it here.
+        if (idSet.target == DOMWalker.WINDOW_CURRENT)
+          this.walk(idSet.subContent, null, idSet.waitFunction);
+      } else if (windowHandler.paneId == idSet.id && idSet.subContent &&
+                 idSet.subContent.length > 0) {
+        this._prepareTargetWindows(idSet.subContent);
+      }
+    }
+
+    // Switches to another tab and runs DOMWalker.walk() for it.
+    // If the wanted tabpanel is already selected, just run this function
+    // recursively for it's descendants.
+    else if (activeNode.localName == "tab") {
+      if (nodeToProcess.selected != true) {
+        this._controller.click(new elementslib.Elem(nodeToProcess));
+
+        // If the target is a new modal/non-modal window, this.walk() has to be
+        // started by the method opening that window. If not, we do it here.
+        if (idSet.target == DOMWalker.WINDOW_CURRENT)
+          this.walk(idSet.subContent, null, idSet.waitFunction);
+      } else if (nodeToProcess.selected && idSet.subContent
+                 && idSet.subContent.length > 0) {
+        this._prepareTargetWindows(idSet.subContent);
+      }
+    }
+
+    // Opens a new dialog/window by clicking on an object and runs
+    // DOMWalker.walk() for it.
+    else {
+      this._controller.click(new elementslib.Elem(nodeToProcess));
+
+      // If the target is a new modal/non-modal window, this.walk() has to be
+      // started by the method opening that window. If not, we do it here.
+      if (idSet.target == DOMWalker.WINDOW_CURRENT)
+        this.walk(idSet.subContent, null, idSet.waitFunction);
+    }
+  },
+
+  /**
+   * DOMWalker_walk goes recursively through the DOM, starting with a provided
+   * root-node and filters the nodes using the this._filter method.
+   *
+   * @param {Node} root
+   *        Node to start testing from
+   *        [optional - default: this._controller.window.document.documentElement]
+   * @returns An array with gathered all results from testing a given element
+   * @type {array of elements}
+   */
+  _walk : function DOMWalker__walk(root) {
+    if (!root.childNodes)
+      throw new Error("root.childNodes does not exist");
+
+    var collectedResults = [];
+
+    // There seems to be no other way to get to the nodes hidden in the
+    // "_buttons" object (see Bug 614949)
+    if (root._buttons) {
+      for each (button in root._buttons) {
+        collectedResults = this._filter(button, collectedResults);
+      }
+    }
+
+    for (var i = 0; i < root.childNodes.length; i++) {
+      collectedResults = this._filter(root.childNodes[i], collectedResults);
+    }
+
+    return collectedResults;
+  },
+
+  /**
+   * Callback function to handle new windows
+   *
+   * @param {MozMillController} controller
+   *        MozMill controller of the new window to operate on.
+   */
+  _modalWindowHelper: function DOMWalker_modalWindowHelper(controller) {
+    let domWalker = new DOMWalker(controller,
+                                  persisted.modalInfos.callbackFilter,
+                                  persisted.modalInfos.callbackNodeTest,
+                                  persisted.modalInfos.callbackResults);
+    domWalker.walk(persisted.modalInfos.ids,
+                   controller.window.document.documentElement,
+                   persisted.modalInfos.waitFunction);
+
+    delete persisted.modalInfos;
+
+    controller.window.close();
+  }
+}
+
+/**
+ * Default constructor
+ *
+ * @param {object} aRoot
+ *        Root node in the DOM to use as parent
+ */
+function nodeCollector(aRoot) {
+  this._root = aRoot.wrappedJSObject ? aRoot.wrappedJSObject : aRoot;
+  this._document = this._root.ownerDocument ? this._root.ownerDocument : this._root;
+  this._nodes = [ ];
+}
+
+/**
+ * Node collector class
+ */
+nodeCollector.prototype = {
+  /**
+   * Converts current nodes to elements
+   *
+   * @returns List of elements
+   * @type {array of ElemBase}
+   */
+  get elements() {
+    var elements = [ ];
+
+    Array.forEach(this._nodes, function(element) {
+      elements.push(new elementslib.Elem(element));
+    });
+
+    return elements;
+  },
+
+  /**
+   * Get the current list of DOM nodes
+   *
+   * @returns List of nodes
+   * @type {array of object}
+   */
+  get nodes() {
+    return this._nodes;
+  },
+
+  /**
+   * Sets current nodes to entries from the node list
+   *
+   * @param {array of objects} aNodeList
+   *        List of DOM nodes to set
+   */
+  set nodes(aNodeList) {
+    if (aNodeList) {
+      this._nodes = [ ];
+
+      Array.forEach(aNodeList, function(node) {
+        this._nodes.push(node);
+      }, this);
+    }
+  },
+
+  /**
+   * Get the root node used as parent for a node collection
+   *
+   * @returns Current root node
+   * @type {object}
+   */
+  get root() {
+    return this._root;
+  },
+
+  /**
+   * Sets root node to the specified DOM node
+   *
+   * @param {object} aRoot
+   *        DOM node to use as root for node collection
+   */
+  set root(aRoot) {
+    if (aRoot) {
+      this._root = aRoot;
+      this._nodes = [ ];
+    }
+  },
+
+  /**
+   * Filter nodes given by the specified callback function
+   *
+   * @param {function} aCallback
+   *        Function to test each element of the array.
+   *        Elements: node, index (optional) , array (optional)
+   * @param {object} aThisObject
+   *        Object to use as 'this' when executing callback.
+   *        [optional - default: function scope]
+   *
+   * @returns The class instance
+   * @type {object}
+   */
+  filter : function nodeCollector_filter(aCallback, aThisObject) {
+    if (!aCallback)
+      throw new Error(arguments.callee.name + ": No callback specified");
+
+    this.nodes = Array.filter(this.nodes, aCallback, aThisObject);
+
+    return this;
+  },
+
+  /**
+   * Filter nodes by DOM property and its value
+   *
+   * @param {string} aProperty
+   *        Property to filter for
+   * @param {string} aValue
+   *        Expected value of the DOM property
+   *        [optional - default: n/a]
+   *
+   * @returns The class instance
+   * @type {object}
+   */
+  filterByDOMProperty : function nodeCollector_filterByDOMProperty(aProperty, aValue) {
+    return this.filter(function(node) {
+      if (aProperty && aValue)
+        return node.getAttribute(aProperty) == aValue;
+      else if (aProperty)
+        return node.hasAttribute(aProperty);
+      else
+        return true;
+    });
+  },
+
+  /**
+   * Filter nodes by JS property and its value
+   *
+   * @param {string} aProperty
+   *        Property to filter for
+   * @param {string} aValue
+   *        Expected value of the JS property
+   *        [optional - default: n/a]
+   *
+   * @returns The class instance
+   * @type {object}
+   */
+  filterByJSProperty : function nodeCollector_filterByJSProperty(aProperty, aValue) {
+    return this.filter(function(node) {
+      if (aProperty && aValue)
+        return node.aProperty == aValue;
+      else if (aProperty)
+        return node.aProperty !== undefined;
+      else
+        return true;
+    });
+  },
+
+  /**
+   * Find anonymouse nodes with the specified attribute and value
+   *
+   * @param {string} aAttribute
+   *        DOM attribute of the wanted node
+   * @param {string} aValue
+   *        Value of the DOM attribute
+   *
+   * @returns The class instance
+   * @type {object}
+   */
+  queryAnonymousNodes : function nodeCollector_queryAnonymousNodes(aAttribute, aValue) {
+    var node = this._document.getAnonymousElementByAttribute(this._root,
+                                                             aAttribute,
+                                                             aValue);
+    this.nodes = node ? [node] : [ ];
+
+    return this;
+  },
+
+  /**
+   * Find nodes with the specified selector
+   *
+   * @param {string} aSelector
+   *        jQuery like element selector string
+   *
+   * @returns The class instance
+   * @type {object}
+   */
+  queryNodes : function nodeCollector_queryNodes(aSelector) {
+    this.nodes = this._root.querySelectorAll(aSelector);
+
+    return this;
+  }
+}
+
+// Exports of functions
+exports.unwrapNode = unwrapNode;
+
+// Exports of classes
+exports.DOMWalker = DOMWalker;
+exports.nodeCollector = nodeCollector;
diff --git a/common/tests/functional/shared-modules/downloads.js b/common/tests/functional/shared-modules/downloads.js
new file mode 100644 (file)
index 0000000..d06cba0
--- /dev/null
@@ -0,0 +1,411 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Anthony Hughes <anthony.s.hughes@gmail.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The DownloadsAPI adds support for download related functions. It also gives
+ * access to the Download Manager.
+ *
+ * @version 1.0.1
+ */
+
+// Include required modules
+var utils = require("utils");
+
+const gTimeout = 5000;
+
+/**
+ * List of available download states
+ */
+const downloadState = {
+  notStarted      : -1,
+  downloading     : 0,
+  finished        : 1,
+  failed          : 2,
+  canceled        : 3,
+  paused          : 4,
+  queued          : 5,
+  blockedParental : 6,
+  scanning        : 7,
+  dirty           : 8,
+  blockedPolicy   : 9
+}
+
+/**
+ * Constructor
+ */
+function downloadManager() {
+  this._controller = null;
+  this.downloadState = downloadState;
+
+  this._dms = Cc["@mozilla.org/download-manager;1"].
+              getService(Ci.nsIDownloadManager);
+}
+
+/**
+ * Download Manager class
+ */
+downloadManager.prototype = {
+  /**
+   * Returns the controller of the current window
+   *
+   * @returns Mozmill Controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Returns the number of currently active downloads
+   *
+   * @returns Number of active downloads
+   * @type {number}
+   */
+  get activeDownloadCount() {
+    return this._dms.activeDownloadCount;
+  },
+
+  /**
+   * Cancel all active downloads
+   */
+  cancelActiveDownloads : function downloadManager_cancelActiveDownloads() {
+    // Get a list of all active downloads (nsISimpleEnumerator)
+    var downloads = this._dms.activeDownloads;
+
+    // Iterate through each active download and cancel it
+    while (downloads.hasMoreElements()) {
+      var download = downloads.getNext().QueryInterface(Ci.nsIDownload);
+      this._dms.cancelDownload(download.id);
+    }
+  },
+
+  /**
+   * Remove all downloads from the database
+   */
+  cleanUp : function downloadManager_cleanUp()
+  {
+    this._dms.cleanUp();
+  },
+
+  /**
+   * Cancel any active downloads, remove the files, and clean
+   * up the Download Manager database
+   *
+   * @param {Array of download} downloads
+   *        Downloaded files which should be deleted (optional)
+   */
+  cleanAll : function downloadManager_cleanAll(downloads) {
+    // Cancel any active downloads
+    this.cancelActiveDownloads();
+
+    // If no downloads have been specified retrieve the list from the database
+    if (downloads === undefined || downloads.length == 0)
+      downloads = this.getAllDownloads();
+    else
+      downloads = downloads.concat(this.getAllDownloads());
+
+    // Delete all files referred to in the Download Manager
+    this.deleteDownloadedFiles(downloads);
+
+    // Clean any entries from the Download Manager database
+    this.cleanUp();
+  },
+
+  /**
+   * Close the download manager
+   *
+   * @param {boolean} force
+   *        Force the closing of the DM window
+   */
+  close : function downloadManager_close(force) {
+    var windowCount = mozmill.utils.getWindows().length;
+
+    if (this._controller) {
+      // Check if we should force the closing of the DM window
+      if (force) {
+        this._controller.window.close();
+      } else {
+        var cmdKey = utils.getEntity(this.getDtds(), "cmd.close.commandKey");
+        this._controller.keypress(null, cmdKey, {accelKey: true});
+      }
+
+      this._controller.waitForEval("subject.getWindows().length == " + (windowCount - 1),
+                                   gTimeout, 100, mozmill.utils);
+      this._controller = null;
+    }
+  },
+
+  /**
+   * Delete all downloads from the local drive
+   *
+   * @param {download} downloads
+   *        List of downloaded files
+   */
+  deleteDownloadedFiles : function downloadManager_deleteDownloadedFiles(downloads) {
+    downloads.forEach(function(download) {
+      try {
+        var file = getLocalFileFromNativePathOrUrl(download.target);
+        file.remove(false);
+      } catch (ex) {
+      }
+    });
+  },
+
+  /**
+   * Get the list of all downloaded files in the database
+   *
+   * @returns List of downloads
+   * @type {Array of download}
+   */
+  getAllDownloads : function downloadManager_getAllDownloads() {
+    var dbConn = this._dms.DBConnection;
+    var stmt = null;
+
+    if (dbConn.schemaVersion < 3)
+      return new Array();
+
+    // Run a SQL query and iterate through all results which have been found
+    var downloads = [];
+    stmt = dbConn.createStatement("SELECT * FROM moz_downloads");
+    while (stmt.executeStep()) {
+      downloads.push({
+        id: stmt.row.id, name: stmt.row.name, target: stmt.row.target,
+        tempPath: stmt.row.tempPath, startTime: stmt.row.startTime,
+        endTime: stmt.row.endTime, state: stmt.row.state,
+        referrer: stmt.row.referrer, entityID: stmt.row.entityID,
+        currBytes: stmt.row.currBytes, maxBytes: stmt.row.maxBytes,
+        mimeType : stmt.row.mimeType, autoResume: stmt.row.autoResume,
+        preferredApplication: stmt.row.preferredApplication,
+        preferredAction: stmt.row.preferredAction
+      });
+    };
+    stmt.reset();
+
+    return downloads;
+  },
+
+  /**
+   * Gets the download state of the given download
+   *
+   * @param {ElemBase} download
+   *        Download which state should be checked
+   */
+  getDownloadState : function downloadManager_getDownloadState(download) {
+    return download.getNode().getAttribute('state');
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function downloadManager_getDtds() {
+    var dtds = ["chrome://browser/locale/browser.dtd",
+                "chrome://mozapps/locale/downloads/downloads.dtd"];
+    return dtds;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type {ElemBase}
+   */
+  getElement : function downloadManager_getElement(spec) {
+    var elem = null;
+
+    switch(spec.type) {
+      /**
+       * subtype: subtype of property to match
+       * value: value of property to match
+       */
+      case "download":
+        // Use a temporary lookup to get the download item
+        var download = new elementslib.Lookup(this._controller.window.document,
+                                              '/id("downloadManager")/id("downloadView")/' +
+                                              '{"' + spec.subtype + '":"' + spec.value + '"}');
+        this._controller.waitForElement(download, gTimeout);
+
+        // Use its download id to construct the real lookup expression
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      '/id("downloadManager")/id("downloadView")/' +
+                                      'id("' + download.getNode().getAttribute('id') + '")');
+        break;
+
+      /**
+       * subtype: Identifier of the specified download button (cancel, pause, resume, retry)
+       * value: Entry (download) of the download list
+       */
+      case "download_button":
+        // XXX: Bug 555347 - There are outstanding events to process
+        this._controller.sleep(0);
+
+        elem = new elementslib.Lookup(this._controller.window.document, spec.value.expression +
+                                      '/anon({"flex":"1"})/[1]/[1]/{"cmd":"cmd_' + spec.subtype + '"}');
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Open the Download Manager
+   *
+   * @param {MozMillController} controller
+   *        MozMillController of the window to operate on
+   * @param {boolean} shortcut
+   *        If true the keyboard shortcut is used
+   */
+  open : function downloadManager_open(controller, shortcut) {
+    if (shortcut) {
+      if (mozmill.isLinux) {
+        var cmdKey = utils.getEntity(this.getDtds(), "downloadsUnix.commandkey");
+        controller.keypress(null, cmdKey, {ctrlKey: true, shiftKey: true});
+      } else {
+        var cmdKey = utils.getEntity(this.getDtds(), "downloads.commandkey");
+        controller.keypress(null, cmdKey, {accelKey: true});
+      }
+    } else {
+      controller.click(new elementslib.Elem(controller.menus["tools-menu"].menu_openDownloads));
+    }
+
+    controller.sleep(500);
+    this.waitForOpened(controller);
+  },
+
+  /**
+   * Wait for the given download state
+   *
+   * @param {MozMillController} controller
+   *        MozMillController of the window to operate on
+   * @param {downloadState} state
+   *        Expected state of the download
+   * @param {number} timeout
+   *        Timeout for waiting for the download state (optional)
+   */
+  waitForDownloadState : function downloadManager_waitForDownloadState(download, state, timeout) {
+    this._controller.waitForEval("subject.manager.getDownloadState(subject.download) == subject.state", timeout, 100,
+                                 {manager: this, download: download, state: state});
+  },
+
+  /**
+   * Wait until the Download Manager has been opened
+   *
+   * @param {MozMillController} controller
+   *        MozMillController of the window to operate on
+   */
+  waitForOpened : function downloadManager_waitForOpened(controller) {
+    this._controller = utils.handleWindow("type", "Download:Manager",
+                                          undefined, false);
+  }
+};
+
+/**
+ * Download the file of unkown type from the given location by saving it
+ * automatically to disk
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the browser window
+ * @param {string} url
+ *        URL of the file which has to be downloaded
+ */
+var downloadFileOfUnknownType = function(controller, url) {
+  controller.open(url);
+
+  // Wait until the unknown content type dialog has been opened
+  controller.waitForEval("subject.getMostRecentWindow('').document.documentElement.id == 'unknownContentType'",
+                         gTimeout, 100, mozmill.wm);
+
+  utils.handleWindow("type", "", function (controller) {
+    // Select to save the file directly
+    var saveFile = new elementslib.ID(controller.window.document, "save");
+    controller.waitThenClick(saveFile, gTimeout);
+    controller.waitForEval("subject.selected == true", gTimeout, 100,
+                           saveFile.getNode());
+
+    // Wait until the OK button has been enabled and click on it
+    var button = new elementslib.Lookup(controller.window.document,
+                                        '/id("unknownContentType")/anon({"anonid":"buttons"})/{"dlgtype":"accept"}');
+    controller.waitForElement(button, gTimeout);
+    controller.waitForEval("subject.okButton.hasAttribute('disabled') == false", gTimeout, 100,
+                           {okButton: button.getNode()});
+    controller.click(button);
+  });
+}
+
+/**
+ * Get a local file from a native path or URL
+ *
+ * @param {string} aPathOrUrl
+ *        Native path or URL of the file
+ * @see http://mxr.mozilla.org/mozilla-central/source/toolkit/mozapps/downloads/content/downloads.js#1309
+ */
+function getLocalFileFromNativePathOrUrl(aPathOrUrl) {
+  if (aPathOrUrl.substring(0,7) == "file://") {
+    // if this is a URL, get the file from that
+    let ioSvc = Cc["@mozilla.org/network/io-service;1"]
+                   .getService(Ci.nsIIOService);
+
+    // XXX it's possible that using a null char-set here is bad
+    const fileUrl = ioSvc.newURI(aPathOrUrl, null, null)
+                         .QueryInterface(Ci.nsIFileURL);
+    return fileUrl.file.clone().QueryInterface(Ci.nsILocalFile);
+  } else {
+    // if it's a pathname, create the nsILocalFile directly
+    var f = new nsLocalFile(aPathOrUrl);
+    return f;
+  }
+}
+
+// Export of variables
+exports.downloadState = downloadState;
+
+// Export of functions
+exports.downloadFileOfUnknownType = downloadFileOfUnknownType;
+exports.getLocalFileFromNativePathOrUrl = getLocalFileFromNativePathOrUrl;
+
+// Export of classes
+exports.downloadManager = downloadManager;
diff --git a/common/tests/functional/shared-modules/localization.js b/common/tests/functional/shared-modules/localization.js
new file mode 100644 (file)
index 0000000..fd405ab
--- /dev/null
@@ -0,0 +1,307 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Adrian Kalla <akalla@aviary.pl>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+// Include required modules
+var domUtils = require("dom-utils");
+var screenshot = require("screenshot");
+var utils = require("utils");
+
+const jumlib = {};
+Components.utils.import("resource://mozmill/modules/jum.js", jumlib);
+
+/**
+ * Callback function for parsing the results of testing for duplicated
+ * access keys.
+ *
+ * This function processes the access keys found in one access keys scope
+ * looking for access keys that are listed more than one time.
+ * At the end, it calls the screenshot.create to create a screenshot with the
+ * elements containing the broken access keys highlighted.
+ *
+ * @param {array of array of object} accessKeysSet
+ * @param {MozmillController} controller
+ */
+function checkAccessKeysResults(controller, accessKeysSet) {
+  // Sort the access keys to have them in a A->Z order
+  var accessKeysList = accessKeysSet.sort();
+
+  // List of access keys
+  var aKeysList = [];
+
+  // List of values to identify the access keys
+  var valueList = [];
+
+  // List of rectangles of nodes containing access keys
+  var rects = [];
+
+  // List of rectangles of nodes with broken access keys
+  var badRects = [];
+
+  // Makes lists of all access keys and the values the access keys are in
+  for (var i = 0; i < accessKeysList.length; i++) {
+    var accessKey = accessKeysList[i][0];
+    var node = accessKeysList[i][1];
+
+    // Set the id and label to be shown in the console
+    var id = node.id || "(id is undefined)";
+    var label = node.label || "(label is undefined)";
+
+    var box = node.boxObject;
+
+    var innerIds = [];
+    var innerRects = [];
+
+    // if the access key is already in our list, take it out to replace it
+    // later
+    if (accessKey == aKeysList[aKeysList.length-1]) {
+      innerIds = valueList.pop();
+      innerRects = rects.pop();
+    } else {
+      aKeysList.push([accessKey]);
+    }
+    innerIds.push("[id: " + id + ", label: " + label + "]");
+    valueList.push(innerIds);
+    innerRects.push([box.x, box.y, box.width, box.height]);
+    rects.push(innerRects);
+  }
+
+  // Go through all access keys and find the duplicated ones
+  for (var i = 0; i < valueList.length; i++) {
+    // Only access keys contained in more than one node are the ones we are
+    // looking for
+    if (valueList[i].length > 1) {
+      for (var j = 0; j < rects[i].length; j++) {
+        badRects.push(rects[i][j]);
+      }
+      jumlib.assert(false, 'accessKey: ' + aKeysList[i] +
+                    ' found in string\'s: ' + valueList[i].join(", "));
+    }
+  }
+
+  // If we have found broken access keys, make a screenshot
+  if (badRects.length > 0) {
+    screenshot.create(controller, badRects);
+  }
+}
+
+/**
+ * Callback function for testing for cropped elements.
+ *
+ * Checks if the XUL boxObject has screen coordinates outside of
+ * the screen coordinates of its parent. If there's no parent, return.
+ *
+ * @param {node} child
+ * @returns List of boxes that can be highlighted on a screenshot
+ * @type {array of array of int}
+ */
+function checkDimensions(child) {
+  if (!child.boxObject)
+    return [];
+  var childBox = child.boxObject;
+  var parent = childBox.parentBox;
+
+  // toplevel element or hidden elements, like script tags
+  if (!parent || parent == child.element || !parent.boxObject) {
+    return [];
+  }
+  var parentBox = parent.boxObject;
+
+  var badRects = [];
+
+  // check width
+  if (childBox.height && childBox.screenX < parentBox.screenX) {
+    badRects.push([childBox.x, childBox.y, parentBox.x - childBox.x,
+                   childBox.height]);
+    jumlib.assert(false, 'Node is cut off at the left: ' +
+                  _reportNode(child) + '. Parent node: ' + _reportNode(parent));
+  }
+  if (childBox.height && childBox.screenX + childBox.width >
+      parentBox.screenX + parentBox.width) {
+    badRects.push([parentBox.x + parentBox.width, childBox.y,
+                   childBox.x + childBox.width - parentBox.x - parentBox.width,
+                   childBox.height]);
+    jumlib.assert(false, 'Node is cut off at the right: ' +
+                  _reportNode(child) + '. Parent node: ' + _reportNode(parent));
+  }
+
+  // check height
+  // We don't want to test menupopup's, as they always report the full height
+  // of all items in the popup
+  if (child.nodeName != 'menupopup' && parent.nodeName != 'menupopup') {
+    if (childBox.width && childBox.screenY < parentBox.screenY) {
+      badRects.push([childBox.x, childBox.y, parentBox.y - childBox.y,
+                     childBox.width]);
+      jumlib.assert(false, 'Node is cut off at the top: ' +
+                    _reportNode(child) + '. Parent node: ' + _reportNode(parent));
+    }
+    if (childBox.width && childBox.screenY + childBox.height >
+        parentBox.screenY + parentBox.height) {
+      badRects.push([childBox.x, parentBox.y + parentBox.height,
+                     childBox.width,
+                     childBox.y + childBox.height - parentBox.y - parentBox.height]);
+      jumlib.assert(false, 'Node is cut off at the bottom: ' +
+                    _reportNode(child) + '. Parent node: ' + _reportNode(parent));
+    }
+  }
+
+  return badRects;
+}
+
+/**
+ * Filters out nodes which should not be tested because they are not in the
+ * current access key scope.
+ *
+ * @param {node} node
+ * @returns Filter status of the given node
+ * @type {array of array of int}
+ */
+function filterAccessKeys(node) {
+  // Menus will need a separate filter set
+  var notAllowedLocalNames = ["menu", "menubar", "menupopup", "popupset"];
+
+  if (!node.disabled && !node.collapsed && !node.hidden &&
+      notAllowedLocalNames.indexOf(node.localName) == -1) {
+    // Code specific to the preferences panes to reject out not visible nodes
+    // in the panes.
+    if (node.parentNode && (node.parentNode.localName == "prefwindow" &&
+                            node.parentNode.currentPane.id != node.id) ||
+        ((node.parentNode.localName == "tabpanels" ||
+          node.parentNode.localName == "deck") &&
+          node.parentNode.selectedPanel.id != node.id)) {
+      return domUtils.DOMWalker.FILTER_REJECT;
+      // end of the specific code
+    } else if (node.accessKey) {
+      return domUtils.DOMWalker.FILTER_ACCEPT;
+    } else {
+      return domUtils.DOMWalker.FILTER_SKIP;
+    }
+  } else {
+    // we don't want to test not visible elements
+    return domUtils.DOMWalker.FILTER_REJECT;
+  }
+}
+
+/**
+ * Filters out nodes which should not be tested because they are not visible
+ *
+ * @param {node} node
+ * @returns Filter status of the given node
+ * @type {array of array of int}
+ */
+function filterCroppedNodes(node) {
+  if (!node.boxObject) {
+    return domUtils.DOMWalker.FILTER_SKIP;
+  } else {
+    if (!node.disabled && !node.collapsed && !node.hidden) {
+      // Code specific to the preferences panes to reject out not visible nodes
+      // in the panes.
+      if (node.parentNode && (node.parentNode.localName == "prefwindow" &&
+                              node.parentNode.currentPane.id != node.id) ||
+          ((node.parentNode.localName == "tabpanels" ||
+            node.parentNode.localName == "deck") &&
+           node.parentNode.selectedPanel.id != node.id)) {
+        return domUtils.DOMWalker.FILTER_REJECT;
+        // end of the specific code
+      } else {
+        return domUtils.DOMWalker.FILTER_ACCEPT;
+      }
+    } else {
+      // we don't want to test not visible elements
+      return domUtils.DOMWalker.FILTER_REJECT;
+    }
+  }
+}
+
+/**
+ * Callback function for testing access keys. To be used with the DOMWalker.
+ *
+ * It packs a submitted node and its access key into a double array
+ *
+ * @param {node} node Node containing the access key
+ * @returns lower-cased access key and its node in a nested array
+ * @type {array of array}
+ */
+function prepareAccessKey(node) {
+  return [[node.accessKey.toLowerCase(), node]];
+}
+
+/**
+ * Callback function for parsing the results of testing for cropped elements.
+ *
+ * This function calls the screenshot.create method if there is at least one
+ * box.
+ *
+ * @param {array of array of int} boxes
+ * @param {MozmillController} controller
+ */
+function processDimensionsResults(controller, boxes) {
+  if (boxes && boxes.length > 0) {
+    screenshot.create(controller, boxes);
+  }
+}
+
+/**
+ * Tries to return a useful string identificator of the given node
+ *
+ * @param {node} node
+ * @returns Identificator of the node
+ * @type {String}
+ */
+function _reportNode(node) {
+  if (node.id) {
+    return "id: " + node.id;
+  } else if (node.label) {
+    return "label: " + node.label;
+  } else if (node.value) {
+    return "value: " + node.value;
+  } else if (node.hasAttributes()) {
+    var attrs = "node attributes: ";
+    for (var i = node.attributes.length - 1; i >= 0; i--) {
+      attrs += node.attributes[i].name + "->" + node.attributes[i].value + ";";
+    }
+    return attrs;
+  } else {
+    return "anonymous node";
+  }
+}
+
+// Export of functions
+exports.checkAccessKeysResults = checkAccessKeysResults;
+exports.checkDimensions = checkDimensions;
+exports.filterAccessKeys = filterAccessKeys;
+exports.filterCroppedNodes = filterCroppedNodes;
+exports.prepareAccessKey = prepareAccessKey;
+exports.processDimensionsResults = processDimensionsResults;
diff --git a/common/tests/functional/shared-modules/modal-dialog.js b/common/tests/functional/shared-modules/modal-dialog.js
new file mode 100644 (file)
index 0000000..5ba5a9d
--- /dev/null
@@ -0,0 +1,236 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Clint Talbert <ctalbert@mozilla.com>
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+// Include required modules
+var domUtils = require("dom-utils");
+
+const TIMEOUT_MODAL_DIALOG = 5000;
+const DELAY_CHECK = 100;
+
+/**
+ * Observer object to find the modal dialog spawned by a controller
+ *
+ * @constructor
+ * @class Observer used to find a modal dialog
+ *
+ * @param {object} aOpener
+ *        Window which is the opener of the modal dialog
+ * @param {function} aCallback
+ *        The callback handler to use to interact with the modal dialog
+ */
+function mdObserver(aOpener, aCallback) {
+  this._opener = aOpener;
+  this._callback = aCallback;
+  this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+}
+
+mdObserver.prototype = {
+
+  /**
+   * Set our default values for our internal properties
+   */
+  _opener : null,
+  _callback: null,
+  _timer: null,
+  exception: null,
+  finished: false,
+
+  /**
+   * Check if the modal dialog has been opened
+   *
+   * @returns {object} The modal dialog window found, or null.
+   */
+  findWindow : function mdObserver_findWindow() {
+    // If a window has been opened from content, it has to be unwrapped.
+    var window = domUtils.unwrapNode(mozmill.wm.getMostRecentWindow(''));
+
+    // Get the WebBrowserChrome and check if it's a modal window
+    var chrome = window.QueryInterface(Ci.nsIInterfaceRequestor).
+                 getInterface(Ci.nsIWebNavigation).
+                 QueryInterface(Ci.nsIDocShellTreeItem).
+                 treeOwner.
+                 QueryInterface(Ci.nsIInterfaceRequestor).
+                 getInterface(Ci.nsIWebBrowserChrome);
+    if (!chrome.isWindowModal()) {
+      return null;
+    }
+
+    // Opening a modal dialog from a modal dialog would fail, if we wouldn't
+    // check for the opener of the modal dialog
+    var found = false;
+    if (window.opener) {
+      // XXX Bug 614757 - an already unwrapped node returns a wrapped node
+      var opener = domUtils.unwrapNode(window.opener);
+      found = (mozmill.utils.getChromeWindow(opener) == this._opener);
+    }
+    else {
+      // Also note that it could happen that dialogs don't have an opener
+      // (i.e. clear recent history). In such a case make sure that the most
+      // recent window is not the passed in reference opener
+      found = (window != this._opener);
+    }
+
+    return (found ? window : null);
+  },
+
+  /**
+   * Called by the timer in the given interval to check if the modal dialog has
+   * been opened. Once it has been found the callback gets executed
+   *
+   * @param {object} aSubject Not used.
+   * @param {string} aTopic Not used.
+   * @param {string} aData Not used.
+   */
+  observe : function mdObserver_observe(aSubject, aTopic, aData) {
+    // Once the window has been found and loaded we can execute the callback
+    var window = this.findWindow();
+    if (window && ("documentLoaded" in window)) {
+      try {
+        this._callback(new mozmill.controller.MozMillController(window));
+      }
+      catch (ex) {
+        // Store the exception, so it can be forwarded if a modal dialog has
+        // been opened by another modal dialog
+        this.exception = ex;
+      }
+
+      if (window) {
+        window.close();
+      }
+
+      this.finished = true;
+      this.stop();
+    }
+    else {
+      // otherwise try again in a bit
+      this._timer.init(this, DELAY_CHECK, Ci.nsITimer.TYPE_ONE_SHOT);
+    }
+  },
+
+  /**
+   * Stop the timer which checks for new modal dialogs
+   */
+  stop : function mdObserver_stop() {
+    delete this._timer;
+  }
+};
+
+
+/**
+ * Creates a new instance of modalDialog.
+ *
+ * @constructor
+ * @class Handler for modal dialogs
+ *
+ * @param {object} aWindow [optional - default: null]
+ *        Window which is the opener of the modal dialog
+ */
+function modalDialog(aWindow) {
+  this._window = aWindow || null;
+}
+
+modalDialog.prototype = {
+
+  /**
+   * Simply checks if the modal dialog has been processed
+   *
+   * @returns {boolean} True, if the dialog has been processed
+   */
+  get finished() {
+    return (!this._observer || this._observer.finished);
+  },
+
+  /**
+   * Start timer to wait for the modal dialog.
+   *
+   * @param {function} aCallback
+   *        The callback handler to use to interact with the modal dialog
+   */
+  start : function modalDialog_start(aCallback) {
+    if (!aCallback)
+      throw new Error(arguments.callee.name + ": Callback not specified.");
+
+    this._observer = new mdObserver(this._window, aCallback);
+
+    this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+    this._timer.init(this._observer, DELAY_CHECK, Ci.nsITimer.TYPE_ONE_SHOT);
+  },
+
+  /**
+   * Stop the timer which checks for new modal dialogs
+   */
+  stop : function modalDialog_stop() {
+    delete this._timer;
+
+    if (this._observer) {
+      this._observer.stop();
+      this._observer = null;
+    }
+  },
+
+  /**
+   * Wait until the modal dialog has been processed.
+   *
+   * @param {Number} aTimeout (optional - default 5s)
+   *        Duration to wait
+   */
+  waitForDialog : function modalDialog_waitForDialog(aTimeout) {
+    var timeout = aTimeout || TIMEOUT_MODAL_DIALOG;
+
+    if (!this._observer) {
+      return;
+    }
+
+    try {
+      mozmill.utils.waitFor(function () {
+        return this.finished;
+      }, "Modal dialog has been found and processed", timeout, undefined, this);
+
+      // Forward the raised exception so we can detect failures in modal dialogs
+      if (this._observer.exception) {
+        throw this._observer.exception;
+      }
+    }
+    finally {
+      this.stop();
+    }
+  }
+}
+
+
+// Export of classes
+exports.modalDialog = modalDialog;
diff --git a/common/tests/functional/shared-modules/performance.js b/common/tests/functional/shared-modules/performance.js
new file mode 100644 (file)
index 0000000..a1b08ed
--- /dev/null
@@ -0,0 +1,208 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Geo Mealer <gmealer@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+// Paths for mapped memory and allocated memory, respectively.  Use as
+// keys to access the appropriate memory reporters.
+const PATH_MAPPED = "malloc/mapped";
+const PATH_ALLOCATED = "malloc/allocated";
+
+// Returning this as a numeric constant to simplify memory calculations
+// Neither allocated nor mapped should be 0 in real life.
+const MEMORY_UNAVAILABLE = "0";
+
+// INITIALIZE MEMORY REPORTERS
+
+// gMemReporters will be a dictionary, key=path and val=reporter
+// See initMemReporters() for how it's used.
+var gMemReporters = {};
+
+/**
+ * Initialize the static memory reporters
+ *
+ * Called during module initialization, below.
+ * See also aboutMemory.js in Firefox code
+ */
+function initMemReporters() {
+  var memMgr = Cc["@mozilla.org/memory-reporter-manager;1"].
+               getService(Ci.nsIMemoryReporterManager);
+
+  // Grab all the memory reporters, load into gMemReporters as a dictionary
+  var e = memMgr.enumerateReporters();
+  while (e.hasMoreElements()) {
+    var memReporter = e.getNext().QueryInterface(Ci.nsIMemoryReporter);
+    gMemReporters[memReporter.path] = memReporter;
+  }
+}
+
+initMemReporters();
+
+/**
+ * PERFORMANCE TRACER
+ *
+ * Keeps a trace log of both actions and performance statistics
+ * throughout a test run.
+ *
+ * Performance stats currently include mapped and allocated memory.
+ * More stats will be added as methods to read them are discovered.
+ *
+ * Usage:
+ *   Before test, create a new PerfTracer named after the test.
+ *     Ex: var perf = new performance.PerfTracer("MyTestFunc");
+ *
+ *   During test, after notable actions call PerfTracer.addCheckpoint(label)
+ *     Ex: perf.addCheckpoint("Opened preferences dialog");
+ *
+ *   After test, call PerfTracer.finish()
+ *     Ex: perf.finish();
+ */
+
+/**
+ * PerfTracer constructor
+ *
+ * @param {string} name
+ *        Name of the tracer, currently used in the output title
+ */
+function PerfTracer(name) {
+  if (!name) {
+    throw new Error(arguments.callee.name + ": name not supplied.");
+  }
+
+  this.clearLog();
+  this._name = name;
+}
+
+PerfTracer.prototype = {
+  // UTILITY METHODS
+
+  /**
+   * Format a single result for printing
+   *
+   * @param {object} result
+   *        Result as created by addCheckpoint()
+   *        Elements: timestamp {Date}   - date/time
+   *                  allocated {number} - allocated memory
+   *                  mapped {number}    - mapped memory
+   *                  label {string}     - label for result
+   *
+   * @returns Result string formatted for output
+   * @type {string}
+   */
+  _formatResult : function PerfTracer_formatResult(result) {
+    var resultString = result.timestamp.toUTCString() + " | " +
+                       result.allocated + " | " +
+                       result.mapped + " | " +
+                       result.label + "\n";
+
+    return resultString;
+  },
+
+  // PUBLIC INTERFACE
+
+  /**
+   * Get a memory value from a reporter
+   *
+   * @param {string} path
+   *        Path of memory reporter (e.g. PATH_MAPPED)
+   * @returns Memory value from requested reporter, MEMORY_UNAVAILABLE if
+   *          not found
+   * @type {number}
+   */
+  getMemory : function PerfTracer_getMemory(path) {
+    var val = MEMORY_UNAVAILABLE;
+    if (path in gMemReporters) {
+      val = gMemReporters[path].memoryUsed;
+    }
+
+    return val;
+  },
+
+  /**
+   * Clears the tracker log and starts over
+   */
+  clearLog : function PerfTracer_clearLog() {
+    this._log = new Array();
+  },
+
+  /**
+   * Adds a checkpoint to the tracker log, with time and performance info
+   *
+   * @param {string} aLabel
+   *        Label attached to performance results. Typically should be
+   *        whatever the test just did.
+   */
+  addCheckpoint : function PerfTracer_addCheckpoint(aLabel) {
+    var result = {
+      label:     aLabel,
+      timestamp: new Date(),
+      mapped:    this.getMemory(PATH_MAPPED),
+      allocated: this.getMemory(PATH_ALLOCATED)
+    };
+
+    this._log.push(result);
+  },
+
+  /**
+   * Prints all results to console.
+   * XXX: make this work with output files
+   */
+  finish : function PerfTracer_finish() {
+    // Title
+    var title = "Performance Trace (" + this._name + ")";
+
+    // Separator
+    var sep = "";
+    for(var i = 0; i < title.length; i++) {
+      sep += "=";
+    }
+
+    dump(sep + "\n");
+    dump(title + "\n");
+    dump(sep + "\n");
+
+    // Log
+    for(i = 0; i < this._log.length; i++) {
+      dump(this._formatResult(this._log[i]));
+    }
+  }
+}
+
+// Exported constants
+exports.PATH_MAPPED = PATH_MAPPED;
+exports.PATH_ALLOCATED = PATH_ALLOCATED;
+exports.MEMORY_UNAVAILABLE = MEMORY_UNAVAILABLE;
+
+// Exported class
+exports.PerfTracer = PerfTracer;
diff --git a/common/tests/functional/shared-modules/places.js b/common/tests/functional/shared-modules/places.js
new file mode 100644 (file)
index 0000000..fa5eda8
--- /dev/null
@@ -0,0 +1,192 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Geo Mealer <gmealer@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The ModalDialogAPI adds support for handling modal dialogs. It
+ * has to be used e.g. for alert boxes and other commonDialog instances.
+ *
+ * @version 1.0.2
+ */
+
+// Include required modules
+var utils = require("utils");
+
+const gTimeout = 5000;
+
+// Default bookmarks.html file lives in omni.jar, get via resource URI
+const BOOKMARKS_RESOURCE = "resource:///defaults/profile/bookmarks.html";
+
+// Bookmarks can take up to ten seconds to restore
+const BOOKMARKS_TIMEOUT = 10000;
+
+// Observer topics we need to watch to know whether we're finished
+const TOPIC_BOOKMARKS_RESTORE_SUCCESS = "bookmarks-restore-success";
+
+/**
+ * Instance of the bookmark service to gain access to the bookmark API.
+ *
+ * @see http://mxr.mozilla.org/mozilla-central (nsINavBookmarksService.idl)
+ */
+var bookmarksService = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+                       getService(Ci.nsINavBookmarksService);
+
+/**
+ * Instance of the history service to gain access to the history API.
+ *
+ * @see http://mxr.mozilla.org/mozilla-central (nsINavHistoryService.idl)
+ */
+var historyService = Cc["@mozilla.org/browser/nav-history-service;1"].
+                     getService(Ci.nsINavHistoryService);
+
+/**
+ * Instance of the livemark service to gain access to the livemark API
+ *
+ * @see http://mxr.mozilla.org/mozilla-central (nsILivemarkService.idl)
+ */
+var livemarkService = Cc["@mozilla.org/browser/livemark-service;2"].
+                      getService(Ci.nsILivemarkService);
+
+/**
+ * Instance of the browser history interface to gain access to
+ * browser-specific history API
+ *
+ * @see http://mxr.mozilla.org/mozilla-central (nsIBrowserHistory.idl)
+ */
+var browserHistory = Cc["@mozilla.org/browser/nav-history-service;1"].
+                     getService(Ci.nsIBrowserHistory);
+
+/**
+ * Instance of the observer service to gain access to the observer API
+ *
+ * @see http://mxr.mozilla.org/mozilla-central (nsIObserverService.idl)
+ */
+var observerService = Cc["@mozilla.org/observer-service;1"].
+                      getService(Ci.nsIObserverService);
+
+/**
+ * Check if an URI is bookmarked within the specified folder
+ *
+ * @param (nsIURI) uri
+ *        URI of the bookmark
+ * @param {String} folderId
+ *        Folder in which the search has to take place
+ * @return Returns if the URI exists in the given folder
+ * @type Boolean
+ */
+function isBookmarkInFolder(uri, folderId)
+{
+  var ids = bookmarksService.getBookmarkIdsForURI(uri, {});
+  for (let i = 0; i < ids.length; i++) {
+    if (bookmarksService.getFolderIdForItem(ids[i]) == folderId)
+      return true;
+  }
+
+  return false;
+}
+
+/**
+ * Restore the default bookmarks for the current profile
+ */
+function restoreDefaultBookmarks() {
+  // Set up the observer -- we're only checking for success here, so we'll simply
+  // time out and throw on failure. It makes the code much clearer than handling
+  // finished state and success state separately.
+  var importSuccessful = false;
+  var importObserver = {
+    observe: function (aSubject, aTopic, aData) {
+      if (aTopic == TOPIC_BOOKMARKS_RESTORE_SUCCESS) {
+        importSuccessful = true;
+      }
+    }
+  }
+  observerService.addObserver(importObserver, TOPIC_BOOKMARKS_RESTORE_SUCCESS, false);
+
+  try {
+    // Fire off the import
+    var bookmarksURI = utils.createURI(BOOKMARKS_RESOURCE);
+    var importer = Cc["@mozilla.org/browser/places/import-export-service;1"].
+                   getService(Ci.nsIPlacesImportExportService);
+    importer.importHTMLFromURI(bookmarksURI, true);
+
+    // Wait for it to be finished--the observer above will flip this flag
+    mozmill.utils.waitFor(function () {
+      return importSuccessful;
+    }, "Default bookmarks have finished importing", BOOKMARKS_TIMEOUT);
+  }
+  finally {
+    // Whatever happens, remove the observer afterwards
+    observerService.removeObserver(importObserver, TOPIC_BOOKMARKS_RESTORE_SUCCESS);
+  }
+}
+
+/**
+ * Synchronous wrapper around browserHistory.removeAllPages()
+ * Removes history and blocks until done
+ */
+function removeAllHistory() {
+  const TOPIC_EXPIRATION_FINISHED = "places-expiration-finished";
+
+  // Create flag visible to both the eval and the observer object
+  var finishedFlag = {
+    state: false
+  }
+
+  // Set up an observer so we get notified when remove completes
+  let observer = {
+    observe: function(aSubject, aTopic, aData) {
+      observerService.removeObserver(this, TOPIC_EXPIRATION_FINISHED);
+      finishedFlag.state = true;
+    }
+  }
+  observerService.addObserver(observer, TOPIC_EXPIRATION_FINISHED, false);
+
+  // Remove the pages, then block until we're done or until timeout is reached
+  browserHistory.removeAllPages();
+  mozmill.controller.waitForEval("subject.state == true", gTimeout, 100, finishedFlag);
+}
+
+// Export of variables
+exports.bookmarksService = bookmarksService;
+exports.historyService = historyService;
+exports.livemarkService = livemarkService;
+exports.browserHistory = browserHistory;
+
+// Export of functions
+exports.isBookmarkInFolder = isBookmarkInFolder;
+exports.restoreDefaultBookmarks = restoreDefaultBookmarks;
+exports.removeAllHistory = removeAllHistory;
diff --git a/common/tests/functional/shared-modules/prefs.js b/common/tests/functional/shared-modules/prefs.js
new file mode 100644 (file)
index 0000000..553c4ec
--- /dev/null
@@ -0,0 +1,384 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Clint Talbert <ctalbert@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The PrefsAPI adds support for preferences related functions. It gives access
+ * to the preferences system and allows to handle the preferences dialog
+ *
+ * @version 1.0.1
+ */
+
+// Include required modules
+var modalDialog = require("modal-dialog");
+var utils = require("utils");
+
+
+const gTimeout = 5000;
+
+// Preferences dialog element templates
+const PREF_DIALOG_BUTTONS  = '/{"type":"prefwindow"}/anon({"anonid":"dlg-buttons"})';
+const PREF_DIALOG_DECK     = '/{"type":"prefwindow"}/anon({"class":"paneDeckContainer"})/anon({"anonid":"paneDeck"})';
+const PREF_DIALOG_SELECTOR = '/{"type":"prefwindow"}/anon({"orient":"vertical"})/anon({"anonid":"selector"})';
+
+
+/**
+ * Constructor
+ *
+ * @param {MozMillController} controller
+ *        MozMill controller of the browser window to operate on.
+ */
+function preferencesDialog(controller) {
+  this._controller = controller;
+}
+
+/**
+ * Preferences dialog object to simplify the access to this dialog
+ */
+preferencesDialog.prototype = {
+  /**
+   * Returns the MozMill controller
+   *
+   * @returns Mozmill controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Retrieve the currently selected panel
+   *
+   * @returns The panel element
+   * @type {ElemBase}
+   */
+  get selectedPane() {
+    return this.getElement({type: "deck_pane"});
+  },
+
+  /**
+   * Get the given pane id
+   */
+  get paneId() {
+    // Check if the selector and the pane are consistent
+    var selector = this.getElement({type: "selector"});
+
+    this._controller.waitForEval("subject.selector.getAttribute('pane') == subject.dlg.selectedPane.getNode().id", gTimeout, 100,
+                                 {selector: selector.getNode().selectedItem, dlg: this});
+
+    return this.selectedPane.getNode().id;
+  },
+
+  /**
+   * Set the given pane by id
+   *
+   * @param {string} id of the pane
+   */
+  set paneId(id) {
+    var button = this.getElement({type: "selector_button", value: id});
+    this._controller.waitThenClick(button, gTimeout);
+
+    // Check if the correct selector is selected
+    var selector = this.getElement({type: "selector"});
+    this._controller.waitForEval("subject.selector.getAttribute('pane') == subject.newPane", gTimeout, 100,
+                                 {selector: selector.getNode().selectedItem, newPane: id});
+    return this.paneId;
+  },
+
+  /**
+   * Close the preferences dialog
+   *
+   * @param {MozMillController} controller
+   *        MozMillController of the window to operate on
+   * @param {boolean} saveChanges
+   *        (Optional) If true the OK button is clicked on Windows which saves
+   *        the changes. On OS X and Linux changes are applied immediately
+   */
+  close : function preferencesDialog_close(saveChanges) {
+    saveChanges = (saveChanges == undefined) ? false : saveChanges;
+
+    if (mozmill.isWindows) {
+      var button = this.getElement({type: "button", subtype: (saveChanges ? "accept" : "cancel")});
+      this._controller.click(button);
+    } else {
+      this._controller.keypress(null, 'w', {accelKey: true});
+    }
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function preferencesDialog_getDtds() {
+    return null;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type {ElemBase}
+   */
+  getElement : function aboutSessionRestore_getElement(spec) {
+    var elem = null;
+
+    switch(spec.type) {
+      case "button":
+        elem = new elementslib.Lookup(this._controller.window.document, PREF_DIALOG_BUTTONS +
+                                      '/{"dlgtype":"' + spec.subtype + '"}');
+        break;
+      case "deck":
+        elem = new elementslib.Lookup(this._controller.window.document, PREF_DIALOG_DECK);
+        break;
+      case "deck_pane":
+        var deck = this.getElement({type: "deck"}).getNode();
+
+        // XXX: Bug 390724 - selectedPane is broken. So iterate through all elements
+        var panel = deck.boxObject.firstChild;
+        for (var ii = 0; ii < deck.selectedIndex; ii++)
+          panel = panel.nextSibling;
+
+        elem = new elementslib.Elem(panel);
+        break;
+      case "selector":
+        elem = new elementslib.Lookup(this._controller.window.document, PREF_DIALOG_SELECTOR);
+        break;
+      case "selector_button":
+        elem = new elementslib.Lookup(this._controller.window.document, PREF_DIALOG_SELECTOR +
+                                      '/{"pane":"' + spec.value + '"}');
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  }
+};
+
+/**
+ * Preferences object to simplify the access to the nsIPrefBranch.
+ */
+var preferences = {
+  _prefService : Cc["@mozilla.org/preferences-service;1"].
+                 getService(Ci.nsIPrefService),
+
+  /**
+   * Use branch to access low level functions of nsIPrefBranch
+   *
+   * @return Instance of the preferences branch
+   * @type nsIPrefBranch
+   */
+  get prefBranch() {
+    return this._prefService.QueryInterface(Ci.nsIPrefBranch);
+  },
+
+  /**
+   * Use defaultPrefBranch to access low level functions of the default branch
+   *
+   * @return Instance of the preferences branch
+   * @type nsIPrefBranch
+   */
+  get defaultPrefBranch() {
+    return this._prefService.getDefaultBranch("");
+  },
+
+  /**
+   * Use prefService to access low level functions of nsIPrefService
+   *
+   * @return Instance of the pref service
+   * @type nsIPrefService
+   */
+  get prefService() {
+    return this._prefService;
+  },
+
+  /**
+   * Clear a user set preference
+   *
+   * @param {string} prefName
+   *        The user-set preference to clear
+   * @return False if the preference had the default value
+   * @type boolean
+   **/
+  clearUserPref : function preferences_clearUserPref(prefName) {
+    try {
+      this.prefBranch.clearUserPref(prefName);
+      return true;
+    } catch (e) {
+      return false;
+    }
+  },
+
+  /**
+   * Retrieve the value of an individual preference.
+   *
+   * @param {string} prefName
+   *        The preference to get the value of.
+   * @param {boolean/number/string} defaultValue
+   *        The default value if preference cannot be found.
+   * @param {boolean/number/string} defaultBranch
+   *        If true the value will be read from the default branch (optional)
+   * @param {string} interfaceType
+   *        Interface to use for the complex value (optional)
+   *        (nsILocalFile, nsISupportsString, nsIPrefLocalizedString)
+   *
+   * @return The value of the requested preference
+   * @type boolean/int/string/complex
+   */
+  getPref : function preferences_getPref(prefName, defaultValue, defaultBranch,
+                                         interfaceType) {
+    try {
+      branch = defaultBranch ? this.defaultPrefBranch : this.prefBranch;
+
+      // If interfaceType has been set, handle it differently
+      if (interfaceType != undefined) {
+        return branch.getComplexValue(prefName, interfaceType);
+      }
+
+      switch (typeof defaultValue) {
+        case ('boolean'):
+          return branch.getBoolPref(prefName);
+        case ('string'):
+          return branch.getCharPref(prefName);
+        case ('number'):
+          return branch.getIntPref(prefName);
+        default:
+          return undefined;
+      }
+    } catch(e) {
+      return defaultValue;
+    }
+  },
+
+  /**
+   * Set the value of an individual preference.
+   *
+   * @param {string} prefName
+   *        The preference to set the value of.
+   * @param {boolean/number/string/complex} value
+   *        The value to set the preference to.
+   * @param {string} interfaceType
+   *        Interface to use for the complex value
+   *        (nsILocalFile, nsISupportsString, nsIPrefLocalizedString)
+   *
+   * @return Returns if the value was successfully set.
+   * @type boolean
+   */
+  setPref : function preferences_setPref(prefName, value, interfaceType) {
+    try {
+      switch (typeof value) {
+        case ('boolean'):
+          this.prefBranch.setBoolPref(prefName, value);
+          break;
+        case ('string'):
+          this.prefBranch.setCharPref(prefName, value);
+          break;
+        case ('number'):
+          this.prefBranch.setIntPref(prefName, value);
+          break;
+        default:
+          this.prefBranch.setComplexValue(prefName, interfaceType, value);
+      }
+    } catch(e) {
+      return false;
+    }
+
+    return true;
+  }
+};
+
+/**
+ * Open the preferences dialog and call the given handler
+ *
+ * @param {MozMillController} controller
+ *        MozMillController which is the opener of the preferences dialog
+ * @param {function} callback
+ *        The callback handler to use to interact with the preference dialog
+ * @param {function} launcher
+ *        (Optional) A callback handler to launch the preference dialog
+ */
+function openPreferencesDialog(controller, callback, launcher) {
+  if (!controller)
+    throw new Error("No controller given for Preferences Dialog");
+  if (typeof callback != "function")
+    throw new Error("No callback given for Preferences Dialog");
+
+  if (mozmill.isWindows) {
+    // Preference dialog is modal on windows, set up our callback
+    var prefModal = new modalDialog.modalDialog(controller.window);
+    prefModal.start(callback);
+  }
+
+  // Launch the preference dialog
+  if (launcher) {
+    launcher();
+  } else {
+    mozmill.getPreferencesController();
+  }
+
+  if (mozmill.isWindows) {
+    prefModal.waitForDialog();
+  } else {
+    // Get the window type of the preferences window depending on the application
+    var prefWindowType = null;
+    switch (mozmill.Application) {
+      case "Thunderbird":
+        prefWindowType = "Mail:Preferences";
+        break;
+      default:
+        prefWindowType = "Browser:Preferences";
+    }
+
+    utils.handleWindow("type", prefWindowType, callback);
+  }
+}
+
+// Export of variables
+exports.preferences = preferences;
+
+// Export of functions
+exports.openPreferencesDialog = openPreferencesDialog;
+
+// Export of classes
+exports.preferencesDialog = preferencesDialog;
diff --git a/common/tests/functional/shared-modules/private-browsing.js b/common/tests/functional/shared-modules/private-browsing.js
new file mode 100644 (file)
index 0000000..12344d6
--- /dev/null
@@ -0,0 +1,237 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The PrivateBrowsingAPI adds support for handling the private browsing mode.
+ *
+ * @version 1.0.0
+ */
+
+// Include required modules
+var modalDialog = require("modal-dialog");
+var prefs = require("prefs");
+var utils = require("utils");
+
+// Preference for confirmation dialog when entering Private Browsing mode
+const PB_NO_PROMPT_PREF = 'browser.privatebrowsing.dont_prompt_on_enter';
+
+const gTimeout = 5000;
+
+/**
+ * Create a new privateBrowsing instance.
+ *
+ * @class This class adds support for the Private Browsing mode
+ * @param {MozMillController} controller
+ *        MozMillController to use for the modal entry dialog
+ */
+function privateBrowsing(controller) {
+  this._controller = controller;
+  this._handler = null;
+
+  /**
+   * Menu item in the main menu to enter/leave Private Browsing mode
+   * @private
+   */
+  this._pbMenuItem = new elementslib.Elem(this._controller.menus['tools-menu'].privateBrowsingItem);
+  this._pbTransitionItem = new elementslib.ID(this._controller.window.document, "Tools:PrivateBrowsing");
+
+  this.__defineGetter__('_pbs', function() {
+    delete this._pbs;
+    return this._pbs = Cc["@mozilla.org/privatebrowsing;1"].
+                       getService(Ci.nsIPrivateBrowsingService);
+  });
+}
+
+/**
+ * Prototype definition of the privateBrowsing class
+ */
+privateBrowsing.prototype = {
+  /**
+   * Returns the controller of the current window
+   *
+   * @returns Mozmill Controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Checks the state of the Private Browsing mode
+   *
+   * @returns Enabled state
+   * @type {boolean}
+   */
+  get enabled() {
+    return this._pbs.privateBrowsingEnabled;
+  },
+
+  /**
+   * Sets the state of the Private Browsing mode
+   *
+   * @param {boolean} value
+   *        New state of the Private Browsing mode
+   */
+  set enabled(value) {
+    this._pbs.privateBrowsingEnabled = value;
+  },
+
+  /**
+   * Sets the callback handler for the confirmation dialog
+   *
+   * @param {function} callback
+   *        Callback handler for the confirmation dialog
+   */
+  set handler(callback) {
+    this._handler = callback;
+  },
+
+  /**
+   * Gets the enabled state of the confirmation dialog
+   *
+   * @returns Enabled state
+   * @type {boolean}
+   */
+  get showPrompt() {
+    return !prefs.preferences.getPref(PB_NO_PROMPT_PREF, true);
+  },
+
+  /**
+   * Sets the enabled state of the confirmation dialog
+   *
+   * @param {boolean} value
+   *        New enabled state of the confirmation dialog
+   */
+  set showPrompt(value){
+    prefs.preferences.setPref(PB_NO_PROMPT_PREF, !value);
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function downloadManager_getDtds() {
+    var dtds = ["chrome://branding/locale/brand.dtd",
+                "chrome://browser/locale/browser.dtd",
+                "chrome://browser/locale/aboutPrivateBrowsing.dtd"];
+    return dtds;
+  },
+
+  /**
+   * Turn off Private Browsing mode and reset all changes
+   */
+  reset : function privateBrowsing_reset() {
+    try {
+      this.stop(true);
+    } catch (ex) {
+      // Do a hard reset
+      this.enabled = false;
+    }
+
+    this.showPrompt = true;
+  },
+
+  /**
+   * Start the Private Browsing mode
+   *
+   * @param {boolean} useShortcut
+   *        Use the keyboard shortcut if true otherwise the menu entry is used
+   */
+  start: function privateBrowsing_start(useShortcut) {
+    var dialog = null;
+
+    if (this.enabled)
+      return;
+
+    if (this.showPrompt) {
+      dialog = new modalDialog.modalDialog(this._controller.window);
+      dialog.start(this._handler);
+    }
+
+    if (useShortcut) {
+      var cmdKey = utils.getEntity(this.getDtds(), "privateBrowsingCmd.commandkey");
+      this._controller.keypress(null, cmdKey, {accelKey: true, shiftKey: true});
+    } else {
+      this._controller.click(this._pbMenuItem);
+    }
+
+    if (dialog) {
+      dialog.waitForDialog();
+    }
+    this.waitForTransistionComplete(true);
+  },
+
+  /**
+   * Stop the Private Browsing mode
+   *
+   * @param {boolean} useShortcut
+   *        Use the keyboard shortcut if true otherwise the menu entry is used
+   */
+  stop: function privateBrowsing_stop(useShortcut)
+  {
+    if (!this.enabled)
+      return;
+
+    if (useShortcut) {
+      var privateBrowsingCmdKey = utils.getEntity(this.getDtds(), "privateBrowsingCmd.commandkey");
+      this._controller.keypress(null, privateBrowsingCmdKey, {accelKey: true, shiftKey: true});
+    } else {
+      this._controller.click(this._pbMenuItem);
+    }
+
+    this.waitForTransistionComplete(false);
+  },
+
+  /**
+   * Waits until the transistion into or out of the Private Browsing mode happened
+   *
+   * @param {boolean} state
+   *        Expected target state of the Private Browsing mode
+   */
+  waitForTransistionComplete : function privateBrowsing_waitForTransitionComplete(state) {
+    // We have to wait until the transition has been finished
+    this._controller.waitForEval("subject.hasAttribute('disabled') == false", gTimeout, 100,
+                                 this._pbTransitionItem.getNode());
+    this._controller.waitForEval("subject.privateBrowsing.enabled == subject.state", gTimeout, 100,
+                                 {privateBrowsing: this, state: state});
+  }
+}
+
+// Export of classes
+exports.privateBrowsing = privateBrowsing;
diff --git a/common/tests/functional/shared-modules/readme.txt b/common/tests/functional/shared-modules/readme.txt
new file mode 100644 (file)
index 0000000..9931d1c
--- /dev/null
@@ -0,0 +1,10 @@
+# Shared Modules #
+
+Many common elements are referenced across the Mozmill tests. To make it easier
+to work with these elements and execute common actions, shared modules have been
+implemented. These modules contain helper classes and helper functions with a
+focus on user interface. Some of these shared modules are unique to Firefox,
+while others can also be used in other applications based on the Gecko platform.
+
+For more information on Shared Modules visit:
+https://developer.mozilla.org/en/Mozmill_Tests/Shared_Modules
diff --git a/common/tests/functional/shared-modules/screenshot.js b/common/tests/functional/shared-modules/screenshot.js
new file mode 100644 (file)
index 0000000..1975189
--- /dev/null
@@ -0,0 +1,131 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Adrian Kalla <akalla@aviary.pl>
+ *   Axel Hecht <axel@pike.org>
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+// Include the required modules
+var utils = require("utils");
+
+/**
+ * This function creates a screenshot of the window provided in the given
+ * controller and highlights elements from the coordinates provided in the
+ * given boxes-array.
+ *
+ * @param {array of array of int} boxes
+ * @param {MozmillController} controller
+ */
+function create(controller, boxes) {
+  var doc = controller.window.document;
+  var maxWidth = doc.documentElement.boxObject.width;
+  var maxHeight = doc.documentElement.boxObject.height;
+  var rect = [];
+  for (var i = 0, j = boxes.length; i < j; ++i) {
+    rect = boxes[i];
+    if (rect[0] + rect[2] > maxWidth) maxWidth = rect[0] + rect[2];
+    if (rect[1] + rect[3] > maxHeight) maxHeight = rect[1] + rect[3];
+  }
+  var canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+  var width = doc.documentElement.boxObject.width;
+  var height = doc.documentElement.boxObject.height;
+  canvas.width = maxWidth;
+  canvas.height = maxHeight;
+  var ctx = canvas.getContext("2d");
+  ctx.clearRect(0,0, canvas.width, canvas.height);
+  ctx.save();
+  ctx.drawWindow(controller.window, 0, 0, width, height, "rgb(0,0,0)");
+  ctx.restore();
+  ctx.save();
+  ctx.fillStyle = "rgba(255,0,0,0.4)";
+  for (var i = 0, j = boxes.length; i < j; ++i) {
+    rect = boxes[i];
+    ctx.fillRect(rect[0], rect[1], rect[2], rect[3]);
+  }
+  ctx.restore();
+
+  _saveCanvas(canvas);
+}
+
+/**
+ * Saves a given Canvas object to a file.
+ * The path to save the file under should be given on the command line. If not,
+ * it will be saved in the temporary folder of the system.
+ *
+ * @param {canvas} canvas
+ */
+function _saveCanvas(canvas) {
+  // Use the path given on the command line and saved under
+  // persisted.screenshotPath, if available. If not, use the path to the
+  // temporary folder as a fallback.
+  var file = null;
+  if ("screenshotPath" in persisted) {
+    file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+    file.initWithPath(persisted.screenshotPath);
+  }
+  else {
+    file = Cc["@mozilla.org/file/directory_service;1"].
+           getService(Ci.nsIProperties).
+           get("TmpD", Ci.nsIFile);
+  }
+
+  var fileName = utils.appInfo.name + "-" +
+                 utils.appInfo.locale + "." +
+                 utils.appInfo.version + "." +
+                 utils.appInfo.buildID + "." +
+                 utils.appInfo.os + ".png";
+  file.append(fileName);
+
+  // if a file already exists, don't overwrite it and create a new name
+  file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0666", 8));
+
+  // create a data url from the canvas and then create URIs of the source
+  // and targets
+  var io = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService);
+  var source = io.newURI(canvas.toDataURL("image/png", ""), "UTF8", null);
+  var target = io.newFileURI(file)
+
+  // prepare to save the canvas data
+  var wbPersist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"].
+                  createInstance(Ci.nsIWebBrowserPersist);
+
+  wbPersist.persistFlags = Ci.nsIWebBrowserPersist.PERSIST_FLAGS_REPLACE_EXISTING_FILES;
+  wbPersist.persistFlags |= Ci.nsIWebBrowserPersist.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
+
+  // save the canvas data to the file
+  wbPersist.saveURI(source, null, null, null, null, file);
+}
+
+// Export of functions
+exports.create = create;
diff --git a/common/tests/functional/shared-modules/search.js b/common/tests/functional/shared-modules/search.js
new file mode 100644 (file)
index 0000000..0d40b32
--- /dev/null
@@ -0,0 +1,836 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The SearchAPI adds support for search related functions like the search bar.
+ */
+
+// Include required modules
+var modalDialog = require("modal-dialog");
+var utils = require("utils");
+var widgets = require("widgets");
+
+const TIMEOUT = 5000;
+const TIMEOUT_REQUEST_SUGGESTIONS = 750;
+
+// Helper lookup constants for the engine manager elements
+const MANAGER_BUTTONS   = '/id("engineManager")/anon({"anonid":"buttons"})';
+
+// Helper lookup constants for the search bar elements
+const NAV_BAR             = '/id("main-window")/id("tab-view-deck")/{"flex":"1"}' +
+                            '/id("navigator-toolbox")/id("nav-bar")';
+const SEARCH_BAR          = NAV_BAR + '/id("search-container")/id("searchbar")';
+const SEARCH_TEXTBOX      = SEARCH_BAR      + '/anon({"anonid":"searchbar-textbox"})';
+const SEARCH_DROPDOWN     = SEARCH_TEXTBOX  + '/[0]/anon({"anonid":"searchbar-engine-button"})';
+const SEARCH_POPUP        = SEARCH_DROPDOWN + '/anon({"anonid":"searchbar-popup"})';
+const SEARCH_INPUT        = SEARCH_TEXTBOX  + '/anon({"class":"autocomplete-textbox-container"})' +
+                                              '/anon({"anonid":"textbox-input-box"})' +
+                                              '/anon({"anonid":"input"})';
+const SEARCH_CONTEXT      = SEARCH_TEXTBOX  + '/anon({"anonid":"textbox-input-box"})' +
+                                              '/anon({"anonid":"input-box-contextmenu"})';
+const SEARCH_GO_BUTTON    = SEARCH_TEXTBOX  + '/anon({"class":"search-go-container"})' +
+                                              '/anon({"class":"search-go-button"})';
+const SEARCH_AUTOCOMPLETE =  '/id("main-window")/id("mainPopupSet")/id("PopupAutoComplete")';
+
+/**
+ * Constructor
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the engine manager
+ */
+function engineManager(controller)
+{
+  this._controller = controller;
+}
+
+/**
+ * Search Manager class
+ */
+engineManager.prototype = {
+  /**
+   * Get the controller of the associated engine manager dialog
+   *
+   * @returns Controller of the browser window
+   * @type MozMillController
+   */
+  get controller()
+  {
+    return this._controller;
+  },
+
+  /**
+   * Gets the list of search engines
+   *
+   * @returns List of engines
+   * @type object
+   */
+  get engines() {
+    var engines = [ ];
+    var tree = this.getElement({type: "engine_list"}).getNode();
+
+    for (var ii = 0; ii < tree.view.rowCount; ii ++) {
+      engines.push({name: tree.view.getCellText(ii, tree.columns.getColumnAt(0)),
+                    keyword: tree.view.getCellText(ii, tree.columns.getColumnAt(1))});
+    }
+
+    return engines;
+  },
+
+  /**
+   * Gets the name of the selected search engine
+   *
+   * @returns Name of the selected search engine
+   * @type string
+   */
+  get selectedEngine() {
+    var treeNode = this.getElement({type: "engine_list"}).getNode();
+
+    if(this.selectedIndex != -1) {
+      return treeNode.view.getCellText(this.selectedIndex,
+                                       treeNode.columns.getColumnAt(0));
+    } else {
+      return null;
+    }
+  },
+
+  /**
+   * Select the engine with the given name
+   *
+   * @param {string} name
+   *        Name of the search engine to select
+   */
+  set selectedEngine(name) {
+    var treeNode = this.getElement({type: "engine_list"}).getNode();
+
+    for (var ii = 0; ii < treeNode.view.rowCount; ii ++) {
+      if (name == treeNode.view.getCellText(ii, treeNode.columns.getColumnAt(0))) {
+        this.selectedIndex = ii;
+        break;
+      }
+    }
+  },
+
+  /**
+   * Gets the index of the selected search engine
+   *
+   * @returns Index of the selected search engine
+   * @type number
+   */
+  get selectedIndex() {
+    var tree = this.getElement({type: "engine_list"});
+    var treeNode = tree.getNode();
+
+    return treeNode.view.selection.currentIndex;
+  },
+
+  /**
+   * Select the engine with the given index
+   *
+   * @param {number} index
+   *        Index of the search engine to select
+   */
+  set selectedIndex(index) {
+    var tree = this.getElement({type: "engine_list"});
+    var treeNode = tree.getNode();
+
+    if (index < treeNode.view.rowCount) {
+      widgets.clickTreeCell(this._controller, tree, index, 0, {});
+    }
+
+    this._controller.waitForEval("subject.manager.selectedIndex == subject.newIndex", TIMEOUT, 100,
+                                 {manager: this, newIndex: index});
+  },
+
+  /**
+   * Gets the suggestions enabled state
+   */
+  get suggestionsEnabled() {
+    var checkbox = this.getElement({type: "suggest"});
+
+    return checkbox.getNode().checked;
+  },
+
+  /**
+   * Sets the suggestions enabled state
+   */
+  set suggestionsEnabled(state) {
+    var checkbox = this.getElement({type: "suggest"});
+    this._controller.check(checkbox, state);
+  },
+
+  /**
+   * Close the engine manager
+   *
+   * @param {MozMillController} controller
+   *        MozMillController of the window to operate on
+   * @param {boolean} saveChanges
+   *        (Optional) If true the OK button is clicked otherwise Cancel
+   */
+  close : function preferencesDialog_close(saveChanges) {
+    saveChanges = (saveChanges == undefined) ? false : saveChanges;
+
+    var button = this.getElement({type: "button", subtype: (saveChanges ? "accept" : "cancel")});
+    this._controller.click(button);
+  },
+
+  /**
+   * Edit the keyword associated to a search engine
+   *
+   * @param {string} name
+   *        Name of the engine to remove
+   * @param {function} handler
+   *        Callback function for Engine Manager
+   */
+  editKeyword : function engineManager_editKeyword(name, handler)
+  {
+    // Select the search engine
+    this.selectedEngine = name;
+
+    // Setup the modal dialog handler
+    md = new modalDialog.modalDialog(this._controller.window);
+    md.start(handler);
+
+    var button = this.getElement({type: "engine_button", subtype: "edit"});
+    this._controller.click(button);
+    md.waitForDialog();
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function engineManager_getDtds() {
+    var dtds = ["chrome://browser/locale/engineManager.dtd"];
+    return dtds;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type ElemBase
+   */
+  getElement : function engineManager_getElement(spec) {
+    var elem = null;
+
+    switch(spec.type) {
+      /**
+       * subtype: subtype to match
+       * value: value to match
+       */
+      case "more_engines":
+        elem = new elementslib.ID(this._controller.window.document, "addEngines");
+        break;
+      case "button":
+        elem = new elementslib.Lookup(this._controller.window.document, MANAGER_BUTTONS +
+                                      '/{"dlgtype":"' + spec.subtype + '"}');
+        break;
+      case "engine_button":
+        switch(spec.subtype) {
+          case "down":
+            elem = new elementslib.ID(this._controller.window.document, "dn");
+            break;
+          case "edit":
+            elem = new elementslib.ID(this._controller.window.document, "edit");
+            break;
+          case "remove":
+            elem = new elementslib.ID(this._controller.window.document, "remove");
+            break;
+          case "up":
+            elem = new elementslib.ID(this._controller.window.document, "up");
+            break;
+        }
+        break;
+      case "engine_list":
+        elem = new elementslib.ID(this._controller.window.document, "engineList");
+        break;
+      case "suggest":
+        elem = new elementslib.ID(this._controller.window.document, "enableSuggest");
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Clicks the "Get more search engines..." link
+   */
+  getMoreSearchEngines : function engineManager_getMoreSearchEngines() {
+    var link = this.getElement({type: "more_engines"});
+    this._controller.click(link);
+  },
+
+  /**
+   * Move down the engine with the given name
+   *
+   * @param {string} name
+   *        Name of the engine to remove
+   */
+  moveDownEngine : function engineManager_moveDownEngine(name) {
+    this.selectedEngine = name;
+    var index = this.selectedIndex;
+
+    var button = this.getElement({type: "engine_button", subtype: "down"});
+    this._controller.click(button);
+
+    this._controller.waitForEval("subject.manager.selectedIndex == subject.oldIndex + 1", TIMEOUT, 100,
+                                 {manager: this, oldIndex: index});
+  },
+
+  /**
+   * Move up the engine with the given name
+   *
+   * @param {string} name
+   *        Name of the engine to remove
+   */
+  moveUpEngine : function engineManager_moveUpEngine(name) {
+    this.selectedEngine = name;
+    var index = this.selectedIndex;
+
+    var button = this.getElement({type: "engine_button", subtype: "up"});
+    this._controller.click(button);
+
+    this._controller.waitForEval("subject.manager.selectedIndex == subject.oldIndex - 1", TIMEOUT, 100,
+                                 {manager: this, oldIndex: index});
+  },
+
+  /**
+   * Remove the engine with the given name
+   *
+   * @param {string} name
+   *        Name of the engine to remove
+   */
+  removeEngine : function engineManager_removeEngine(name) {
+    this.selectedEngine = name;
+
+    var button = this.getElement({type: "engine_button", subtype: "remove"});
+    this._controller.click(button);
+
+    this._controller.waitForEval("subject.manager.selectedEngine != subject.removedEngine", TIMEOUT, 100,
+                                 {manager: this, removedEngine: name});
+  },
+
+  /**
+   * Restores the defaults for search engines
+   */
+  restoreDefaults : function engineManager_restoreDefaults() {
+    var button = this.getElement({type: "button", subtype: "extra2"});
+    this._controller.click(button);
+  }
+};
+
+/**
+ * Constructor
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the browser window to operate on
+ */
+function searchBar(controller)
+{
+  this._controller = controller;
+  this._bss = Cc["@mozilla.org/browser/search-service;1"]
+                 .getService(Ci.nsIBrowserSearchService);
+}
+
+/**
+ * Search Manager class
+ */
+searchBar.prototype = {
+  /**
+   * Get the controller of the associated browser window
+   *
+   * @returns Controller of the browser window
+   * @type MozMillController
+   */
+  get controller()
+  {
+    return this._controller;
+  },
+
+  /**
+   * Get the names of all installed engines
+   */
+  get engines()
+  {
+    var engines = [ ];
+    var popup = this.getElement({type: "searchBar_dropDownPopup"});
+
+    for (var ii = 0; ii < popup.getNode().childNodes.length; ii++) {
+      var entry = popup.getNode().childNodes[ii];
+      if (entry.className.indexOf("searchbar-engine") != -1) {
+        engines.push({name: entry.id,
+                      selected: entry.selected,
+                      tooltipText: entry.getAttribute('tooltiptext')
+                    });
+      }
+    }
+
+    return engines;
+  },
+
+  /**
+   * Get the search engines drop down open state
+   */
+  get enginesDropDownOpen()
+  {
+    var popup = this.getElement({type: "searchBar_dropDownPopup"});
+    return popup.getNode().state != "closed";
+  },
+
+  /**
+   * Set the search engines drop down open state
+   */
+  set enginesDropDownOpen(newState)
+  {
+    if (this.enginesDropDownOpen != newState) {
+      var button = this.getElement({type: "searchBar_dropDown"});
+      this._controller.click(button);
+
+      this._controller.waitForEval("subject.searchBar.enginesDropDownOpen == subject.newState", TIMEOUT, 100,
+                                   {searchBar: this, newState: newState });
+      this._controller.sleep(0);
+    }
+  },
+
+  /**
+   * Get the names of all installable engines
+   */
+  get installableEngines()
+  {
+    var engines = [ ];
+    var popup = this.getElement({type: "searchBar_dropDownPopup"});
+
+    for (var ii = 0; ii < popup.getNode().childNodes.length; ii++) {
+      var entry = popup.getNode().childNodes[ii];
+      if (entry.className.indexOf("addengine-item") != -1) {
+        engines.push({name: entry.getAttribute('title'),
+                      selected: entry.selected,
+                      tooltipText: entry.getAttribute('tooltiptext')
+                    });
+      }
+    }
+
+    return engines;
+  },
+
+  /**
+   * Returns the currently selected search engine
+   *
+   * @return Name of the currently selected engine
+   * @type string
+   */
+  get selectedEngine()
+  {
+    // Open drop down which updates the list of search engines
+    var state = this.enginesDropDownOpen;
+    this.enginesDropDownOpen = true;
+
+    var engine = this.getElement({type: "engine", subtype: "selected", value: "true"});
+    this._controller.waitForElement(engine, TIMEOUT);
+
+    this.enginesDropDownOpen = state;
+
+    return engine.getNode().id;
+  },
+
+  /**
+   * Select the search engine with the given name
+   *
+   * @param {string} name
+   *        Name of the search engine to select
+   */
+  set selectedEngine(name) {
+    // Open drop down and click on search engine
+    this.enginesDropDownOpen = true;
+
+    var engine = this.getElement({type: "engine", subtype: "id", value: name});
+    this._controller.waitThenClick(engine, TIMEOUT);
+
+    // Wait until the drop down has been closed
+    this._controller.waitForEval("subject.searchBar.enginesDropDownOpen == false", TIMEOUT, 100,
+                                 {searchBar: this});
+
+    this._controller.waitForEval("subject.searchBar.selectedEngine == subject.newEngine", TIMEOUT, 100,
+                                 {searchBar: this, newEngine: name});
+  },
+
+  /**
+   * Returns all the visible search engines (API call)
+   */
+  get visibleEngines()
+  {
+    return this._bss.getVisibleEngines({});
+  },
+
+  /**
+   * Checks if the correct target URL has been opened for the search
+   *
+   * @param {string} searchTerm
+   *        Text which should be checked for
+   */
+  checkSearchResultPage : function searchBar_checkSearchResultPage(searchTerm) {
+    // Retrieve the URL which is used for the currently selected search engine
+    var targetUrl = this._bss.currentEngine.getSubmission(searchTerm, null).uri;
+    var currentUrl = this._controller.tabs.activeTabWindow.document.location;
+
+    var domainRegex = /[^\.]+\.([^\.]+)\..+$/gi;
+    var targetDomainName = targetUrl.host.replace(domainRegex, "$1");
+    var currentDomainName = currentUrl.host.replace(domainRegex, "$1");
+
+    this._controller.assert(function () {
+      return currentDomainName === targetDomainName;
+    }, "Current domain name matches target domain name - got '" +
+      currentDomainName + "', expected '" + targetDomainName + "'");
+
+    // Check if search term is listed in URL
+    this._controller.assert(function () {
+      return currentUrl.href.toLowerCase().indexOf(searchTerm.toLowerCase()) != -1;
+    }, "Current URL contains the search term - got '" +
+      currentUrl.href.toLowerCase() + "', expected '" + searchTerm.toLowerCase() + "'");
+
+  },
+
+  /**
+   * Clear the search field
+   */
+  clear : function searchBar_clear()
+  {
+    var activeElement = this._controller.window.document.activeElement;
+
+    var searchInput = this.getElement({type: "searchBar_input"});
+    var cmdKey = utils.getEntity(this.getDtds(), "selectAllCmd.key");
+    this._controller.keypress(searchInput, cmdKey, {accelKey: true});
+    this._controller.keypress(searchInput, 'VK_DELETE', {});
+
+    if (activeElement)
+      activeElement.focus();
+  },
+
+  /**
+   * Focus the search bar text field
+   *
+   * @param {object} event
+   *        Specifies the event which has to be used to focus the search bar
+   */
+  focus : function searchBar_focus(event)
+  {
+    var input = this.getElement({type: "searchBar_input"});
+
+    switch (event.type) {
+      case "click":
+        this._controller.click(input);
+        break;
+      case "shortcut":
+        if (mozmill.isLinux) {
+          var cmdKey = utils.getEntity(this.getDtds(), "searchFocusUnix.commandkey");
+        } else {
+          var cmdKey = utils.getEntity(this.getDtds(), "searchFocus.commandkey");
+        }
+        this._controller.keypress(null, cmdKey, {accelKey: true});
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + event.type);
+    }
+
+    // Check if the search bar has the focus
+    var activeElement = this._controller.window.document.activeElement;
+    this._controller.assertJS("subject.isFocused == true",
+                              {isFocused: input.getNode() == activeElement});
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function searchBar_getDtds() {
+    var dtds = ["chrome://browser/locale/browser.dtd"];
+    return dtds;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type ElemBase
+   */
+  getElement : function searchBar_getElement(spec) {
+    var elem = null;
+
+    switch(spec.type) {
+      /**
+       * subtype: subtype to match
+       * value: value to match
+       */
+      case "engine":
+        // XXX: bug 555938 - Mozmill can't fetch the element via a lookup here.
+        // That means we have to grab it temporarily by iterating through all childs.
+        var popup = this.getElement({type: "searchBar_dropDownPopup"}).getNode();
+        for (var ii = 0; ii < popup.childNodes.length; ii++) {
+          var entry = popup.childNodes[ii];
+          if (entry.getAttribute(spec.subtype) == spec.value) {
+            elem = new elementslib.Elem(entry);
+            break;
+          }
+        }
+        //elem = new elementslib.Lookup(this._controller.window.document, SEARCH_POPUP +
+        //                              '/anon({"' + spec.subtype + '":"' + spec.value + '"})');
+        break;
+      case "engine_manager":
+        // XXX: bug 555938 - Mozmill can't fetch the element via a lookup here.
+        // That means we have to grab it temporarily by iterating through all childs.
+        var popup = this.getElement({type: "searchBar_dropDownPopup"}).getNode();
+        for (var ii = popup.childNodes.length - 1; ii >= 0; ii--) {
+          var entry = popup.childNodes[ii];
+          if (entry.className == "open-engine-manager") {
+            elem = new elementslib.Elem(entry);
+            break;
+          }
+        }
+        //elem = new elementslib.Lookup(this._controller.window.document, SEARCH_POPUP +
+        //                              '/anon({"anonid":"open-engine-manager"})');
+        break;
+      case "searchBar":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_BAR);
+        break;
+      case "searchBar_autoCompletePopup":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_AUTOCOMPLETE);
+        break;
+      case "searchBar_contextMenu":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_CONTEXT);
+        break;
+      case "searchBar_dropDown":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_DROPDOWN);
+        break;
+      case "searchBar_dropDownPopup":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_POPUP);
+        break;
+      case "searchBar_goButton":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_GO_BUTTON);
+        break;
+      case "searchBar_input":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_INPUT);
+        break;
+      case "searchBar_suggestions":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_AUTOCOMPLETE +
+                                      '/anon({"anonid":"tree"})');
+         break;
+      case "searchBar_textBox":
+        elem = new elementslib.Lookup(this._controller.window.document, SEARCH_TEXTBOX);
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Returns the search suggestions for the search term
+   */
+  getSuggestions : function(searchTerm) {
+    var suggestions = [ ];
+    var popup = this.getElement({type: "searchBar_autoCompletePopup"});
+    var treeElem = this.getElement({type: "searchBar_suggestions"});
+
+    // XXX Bug 542990, Bug 392633
+    // Typing too fast can cause several issue like the suggestions not to appear.
+    // Lets type the letters one by one and wait for the popup or the timeout
+    for (var i = 0; i < searchTerm.length; i++) {
+      try {
+        this.type(searchTerm[i]);
+        this._controller.waitFor(function () {
+          return popup.getNode().state === 'open';
+        }, "", TIMEOUT_REQUEST_SUGGESTIONS);
+      }
+      catch (e) {
+        // We are not interested in handling the timeout for now
+      }
+    }
+
+    // After entering the search term the suggestions have to be visible
+    this._controller.assert(function () {
+      return popup.getNode().state === 'open';
+    }, "Search suggestions are visible");
+    this._controller.waitForElement(treeElem, TIMEOUT);
+
+    // Get all suggestions
+    var tree = treeElem.getNode();
+    this._controller.waitForEval("subject.tree.view != null", TIMEOUT, 100,
+                                 {tree: tree});
+    for (var i = 0; i < tree.view.rowCount; i ++) {
+      suggestions.push(tree.view.getCellText(i, tree.columns.getColumnAt(0)));
+    }
+
+    // Close auto-complete popup
+    this._controller.keypress(popup, "VK_ESCAPE", {});
+    this._controller.waitForEval("subject.popup.state == 'closed'", TIMEOUT, 100,
+                                 {popup: popup.getNode()});
+
+    return suggestions;
+  },
+
+  /**
+   * Check if a search engine is installed (API call)
+   *
+   * @param {string} name
+   *        Name of the search engine to check
+   */
+  isEngineInstalled : function searchBar_isEngineInstalled(name)
+  {
+    var engine = this._bss.getEngineByName(name);
+    return (engine != null);
+  },
+
+  /**
+   * Open the Engine Manager
+   *
+   * @param {function} handler
+   *        Callback function for Engine Manager
+   */
+  openEngineManager : function searchBar_openEngineManager(handler)
+  {
+    this.enginesDropDownOpen = true;
+    var engineManager = this.getElement({type: "engine_manager"});
+
+    // Setup the modal dialog handler
+    md = new modalDialog.modalDialog(this._controller.window);
+    md.start(handler);
+
+    // XXX: Bug 555347 - Process any outstanding events before clicking the entry
+    this._controller.sleep(0);
+    this._controller.click(engineManager);
+    md.waitForDialog();
+
+    this._controller.assert(function () {
+      return this.enginesDropDownOpen == false;
+    }, "The search engine drop down menu has been closed", this);
+  },
+
+  /**
+   * Remove the search engine with the given name (API call)
+   *
+   * @param {string} name
+   *        Name of the search engine to remove
+   */
+  removeEngine : function searchBar_removeEngine(name)
+  {
+    if (this.isEngineInstalled(name)) {
+      var engine = this._bss.getEngineByName(name);
+      this._bss.removeEngine(engine);
+    }
+  },
+
+  /**
+   * Restore the default set of search engines (API call)
+   */
+  restoreDefaultEngines : function searchBar_restoreDefaults()
+  {
+    // XXX: Bug 556477 - Restore default sorting
+    this.openEngineManager(function(controller) {
+      var manager = new engineManager(controller);
+
+      // We have to do any action so the restore button gets enabled
+      manager.moveDownEngine(manager.engines[0].name);
+      manager.restoreDefaults();
+      manager.close(true);
+    });
+
+    // Update the visibility status for each engine and reset the default engine
+    this._bss.restoreDefaultEngines();
+    this._bss.currentEngine = this._bss.defaultEngine;
+
+    // Clear any entered search term
+    this.clear();
+  },
+
+  /**
+   * Start a search with the given search term and check if the resulting URL
+   * contains the search term.
+   *
+   * @param {object} data
+   *        Object which contains the search term and the action type
+   */
+  search : function searchBar_search(data)
+  {
+    var searchBar = this.getElement({type: "searchBar"});
+    this.type(data.text);
+
+    switch (data.action) {
+      case "returnKey":
+        this._controller.keypress(searchBar, 'VK_RETURN', {});
+        break;
+      case "goButton":
+      default:
+        this._controller.click(this.getElement({type: "searchBar_goButton"}));
+        break;
+    }
+
+    this._controller.waitForPageLoad();
+    this.checkSearchResultPage(data.text);
+  },
+
+  /**
+   * Enter a search term into the search bar
+   *
+   * @param {string} searchTerm
+   *        Text which should be searched for
+   */
+  type : function searchBar_type(searchTerm) {
+    var searchBar = this.getElement({type: "searchBar"});
+    this._controller.type(searchBar, searchTerm);
+  }
+};
+
+// Export of classes
+exports.engineManager = engineManager;
+exports.searchBar = searchBar;
diff --git a/common/tests/functional/shared-modules/sessionstore.js b/common/tests/functional/shared-modules/sessionstore.js
new file mode 100644 (file)
index 0000000..d54491c
--- /dev/null
@@ -0,0 +1,318 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Aaron Train <aaron.train@gmail.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The SessionStoreAPI adds support for accessing session related elements and features
+ *
+ * @version 1.0.0
+ */
+
+// Include required modules
+var prefs = require("prefs");
+var utils = require("utils");
+var widgets = require("widgets");
+
+// Session Store service
+var sessionStoreService = Cc["@mozilla.org/browser/sessionstore;1"]
+                             .getService(Ci.nsISessionStore);
+
+// Preference for indicating the amount of restorable tabs
+const SESSIONSTORE_MAXTABS_PREF = 'browser.sessionstore.max_tabs_undo';
+
+const gTimeout = 5000;
+
+/**
+ * Constructor
+ *
+ * @param {MozMillController} controller
+ *        MozMill controller of the browser window to operate on.
+ */
+function aboutSessionRestore(controller)
+{
+  this._controller = controller;
+}
+
+/**
+ * This class handles the about:sessionrestore page.
+ */
+aboutSessionRestore.prototype = {
+  /**
+   * Returns the MozMill controller
+   *
+   * @returns Mozmill controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Returns the tree which contains the windows and tabs
+   *
+   * @returns Tree with windows and tabs to restore
+   * @type {ElemBase}
+   */
+  get tabList() {
+    return this.getElement({type: "tabList"});
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function aboutSessionRestore_getDtds() {
+    var dtds = ["chrome://browser/locale/browser.dtd",
+                "chrome://browser/locale/aboutSessionRestore.dtd"];
+    return dtds;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type {ElemBase}
+   */
+  getElement : function aboutSessionRestore_getElement(spec) {
+    var elem = null;
+
+    switch(spec.type) {
+      case "button_restoreSession":
+        elem = new elementslib.ID(this._controller.tabs.activeTab, "errorTryAgain");
+        break;
+      case "error_longDesc":
+        elem = new elementslib.ID(this._controller.tabs.activeTab, "errorLongDesc");
+        break;
+      case "error_pageContainer":
+        elem = new elementslib.ID(this._controller.tabs.activeTab, "errorPageContainer");
+        break;
+      case "error_shortDesc":
+        elem = new elementslib.ID(this._controller.tabs.activeTab, "errorShortDescText");
+        break;
+      case "error_title":
+        elem = new elementslib.ID(this._controller.tabs.activeTab, "errorTitleText");
+        break;
+      case "tabList":
+        elem = new elementslib.ID(this._controller.window.document, "tabList");
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Returns the current restore state of the given element
+   *
+   * @param {object} element
+   *        Element which restore state should be retrieved
+   * @returns True if the element should be restored
+   * @type {boolean}
+   *
+   */
+  getRestoreState : function aboutSessionRestore_getRestoreState(element) {
+    var tree = this.tabList.getNode();
+
+    return tree.view.getCellValue(element.listIndex, tree.columns.getColumnAt(0));
+  },
+
+  /**
+   * Get restorable tabs under the given window
+   *
+   * @param {object} window
+   *        Window inside the tree
+   * @returns List of tabs
+   * @type {array of object}
+   */
+  getTabs : function aboutSessionRestore_getTabs(window) {
+    var tabs = [ ];
+    var tree = this.tabList.getNode();
+
+    // Add entries when they are tabs (no container)
+    var ii = window.listIndex + 1;
+    while (ii < tree.view.rowCount && !tree.view.isContainer(ii)) {
+      tabs.push({
+                 index: tabs.length,
+                 listIndex : ii,
+                 restore: tree.view.getCellValue(ii, tree.columns.getColumnAt(0)),
+                 title: tree.view.getCellText(ii, tree.columns.getColumnAt(2))
+                });
+      ii++;
+    }
+
+    return tabs;
+  },
+
+  /**
+   * Get restorable windows
+   *
+   * @returns List of windows
+   * @type {array of object}
+   */
+  getWindows : function aboutSessionRestore_getWindows() {
+    var windows = [ ];
+    var tree = this.tabList.getNode();
+
+    for (var ii = 0; ii < tree.view.rowCount; ii++) {
+      if (tree.view.isContainer(ii)) {
+        windows.push({
+                      index: windows.length,
+                      listIndex : ii,
+                      open: tree.view.isContainerOpen(ii),
+                      restore: tree.view.getCellValue(ii, tree.columns.getColumnAt(0)),
+                      title: tree.view.getCellText(ii, tree.columns.getColumnAt(2))
+                     });
+      }
+    }
+
+    return windows;
+  },
+
+  /**
+   * Toggles the restore state for the element
+   *
+   * @param {object} element
+   *        Specifies the element which restore state should be toggled
+   */
+  toggleRestoreState : function aboutSessionRestore_toggleRestoreState(element) {
+    var state = this.getRestoreState(element);
+
+    widgets.clickTreeCell(this._controller, this.tabList, element.listIndex, 0, {});
+    this._controller.sleep(0);
+
+    this._controller.assertJS("subject.newState != subject.oldState",
+                              {newState : this.getRestoreState(element), oldState : state});
+  }
+}
+
+/**
+ * Resets the list of recently closed tabs by setting and clearing the user preference
+ */
+function resetRecentlyClosedTabs()
+{
+  prefs.preferences.setPref(SESSIONSTORE_MAXTABS_PREF, 0);
+  prefs.preferences.clearUserPref(SESSIONSTORE_MAXTABS_PREF);
+}
+
+/**
+ * Returns the number of restorable tabs for a given window
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the window to operate on
+ * @returns The number of restorable tabs in the window
+ */
+function getClosedTabCount(controller)
+{
+  return sessionStoreService.getClosedTabCount(controller.window);
+}
+
+/**
+ * Restores the tab which has been recently closed
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the window to operate on
+ * @param {object} event
+ *        Specifies the event to use to execute the command
+ */
+function undoClosedTab(controller, event)
+{
+  var count = sessionStoreService.getClosedTabCount(controller.window);
+
+  switch (event.type) {
+    case "menu":
+      throw new Error("Menu gets build dynamically and cannot be accessed.");
+      break;
+    case "shortcut":
+      var cmdKey = utils.getEntity(this.getDtds(), "tabCmd.commandkey");
+      controller.keypress(null, cmdKey, {accelKey: true, shiftKey: true});
+      break;
+  }
+
+  if (count > 0)
+    controller.assertJS("subject.newTabCount < subject.oldTabCount",
+                        {
+                         newTabCount : sessionStoreService.getClosedTabCount(controller.window),
+                         oldTabCount : count
+                        });
+}
+
+/**
+ * Restores the window which has been recently closed
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the window to operate on
+ * @param {object} event
+ *        Specifies the event to use to execute the command
+ */
+function undoClosedWindow(controller, event)
+{
+  var count = sessionStoreService.getClosedWindowCount(controller.window);
+
+  switch (event.type) {
+    case "menu":
+      throw new Error("Menu gets build dynamically and cannot be accessed.");
+      break;
+    case "shortcut":
+      var cmdKey = utils.getEntity(this.getDtds(), "newNavigatorCmd.key");
+      controller.keypress(null, cmdKey, {accelKey: true, shiftKey: true});
+      break;
+  }
+
+  if (count > 0)
+    controller.assertJS("subject.newWindowCount < subject.oldWindowCount",
+                        {
+                         newWindowCount : sessionStoreService.getClosedWindowCount(controller.window),
+                         oldWindowCount : count
+                        });
+}
+
+// Export of functions
+exports.getClosedTabCount = getClosedTabCount;
+exports.resetRecentlyClosedTabs = resetRecentlyClosedTabs;
+exports.undoClosedTab = undoClosedTab;
+exports.undoClosedWindow = undoClosedWindow;
+
+// Export of classes
+exports.aboutSessionRestore = aboutSessionRestore;
diff --git a/common/tests/functional/shared-modules/software-update.js b/common/tests/functional/shared-modules/software-update.js
new file mode 100644 (file)
index 0000000..6123958
--- /dev/null
@@ -0,0 +1,530 @@
+/* * ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * **** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The SoftwareUpdateAPI adds support for an easy access to the update process.
+ */
+
+// Include required modules
+var prefs = require("prefs");
+var utils = require("utils");
+
+const gTimeoutUpdateCheck     = 10000;
+const gTimeoutUpdateDownload  = 360000;
+
+const PREF_DISABLED_ADDONS = "extensions.disabledAddons";
+
+// Helper lookup constants for elements of the software update dialog
+const WIZARD = '/id("updates")';
+const WIZARD_BUTTONS = WIZARD + '/anon({"anonid":"Buttons"})';
+const WIZARD_DECK = WIZARD  + '/anon({"anonid":"Deck"})';
+
+const WIZARD_PAGES = {
+  dummy: 'dummy',
+  checking: 'checking',
+  pluginUpdatesFound: 'pluginupdatesfound',
+  noUpdatesFound: 'noupdatesfound',
+  manualUpdate: 'manualUpdate',
+  incompatibleCheck: 'incompatibleCheck',
+  updatesFoundBasic: 'updatesfoundbasic',
+  updatesFoundBillboard: 'updatesfoundbillboard',
+  license: 'license',
+  incompatibleList: 'incompatibleList',
+  downloading: 'downloading',
+  errors: 'errors',
+  errorPatching: 'errorpatching',
+  finished: 'finished',
+  finishedBackground: 'finishedBackground',
+  installed: 'installed'
+}
+
+// On Mac there is another DOM structure used as on Windows and Linux
+if (mozmill.isMac) {
+  var WIZARD_BUTTONS_BOX = WIZARD_BUTTONS +
+                             '/anon({"flex":"1"})/{"class":"wizard-buttons-btm"}/';
+  var WIZARD_BUTTON = {
+          back: '{"dlgtype":"back"}',
+          next: '{"dlgtype":"next"}',
+          cancel: '{"dlgtype":"cancel"}',
+          finish: '{"dlgtype":"finish"}',
+          extra1: '{"dlgtype":"extra1"}',
+          extra2: '{"dlgtype":"extra2"}'
+        }
+} else {
+  var WIZARD_BUTTONS_BOX = WIZARD_BUTTONS +
+                       '/anon({"flex":"1"})/{"class":"wizard-buttons-box-2"}/';
+  var WIZARD_BUTTON = {
+    back: '{"dlgtype":"back"}',
+    next: 'anon({"anonid":"WizardButtonDeck"})/[1]/{"dlgtype":"next"}',
+    cancel: '{"dlgtype":"cancel"}',
+    finish: 'anon({"anonid":"WizardButtonDeck"})/[0]/{"dlgtype":"finish"}',
+    extra1: '{"dlgtype":"extra1"}',
+    extra2: '{"dlgtype":"extra2"}'
+  }
+}
+
+/**
+ * Constructor for software update class
+ */
+function softwareUpdate() {
+  this._controller = null;
+  this._wizard = null;
+  this._downloadDuration = -1;
+
+  this._aus = Cc["@mozilla.org/updates/update-service;1"].
+              getService(Ci.nsIApplicationUpdateService);
+  this._ums = Cc["@mozilla.org/updates/update-manager;1"].
+              getService(Ci.nsIUpdateManager);
+  this._vc = Cc["@mozilla.org/xpcom/version-comparator;1"].
+             getService(Ci.nsIVersionComparator);
+}
+
+/**
+ * Class for software updates
+ */
+softwareUpdate.prototype = {
+  /**
+   * Returns the active update
+   *
+   * @returns The currently selected update
+   * @type nsIUpdate
+   */
+  get activeUpdate() {
+    return this._ums.activeUpdate;
+  },
+
+  /**
+   * Check if the user has permissions to run the software update
+   *
+   * @returns Status if the user has the permissions.
+   * @type {boolean}
+   */
+  get allowed() {
+    return this._aus.canCheckForUpdates && this._aus.canApplyUpdates;
+  },
+
+  /**
+   * Returns information of the current build version
+   */
+  get buildInfo() {
+    return {
+      buildid : utils.appInfo.buildID,
+      disabled_addons : prefs.preferences.getPref(PREF_DISABLED_ADDONS, ''),
+      locale : utils.appInfo.locale,
+      user_agent : utils.appInfo.userAgent,
+      version : utils.appInfo.version
+    };
+  },
+
+  /**
+   * Returns the current update channel
+   */
+  get channel() {
+    return prefs.preferences.getPref('app.update.channel', '');
+  },
+
+  /**
+   * Get the controller of the associated engine manager dialog
+   *
+   * @returns Controller of the browser window
+   * @type MozMillController
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Returns the current step of the software update dialog wizard
+   */
+  get currentPage() {
+    return this._wizard.getNode().getAttribute('currentpageid');
+  },
+
+  /**
+   * Returns true if the offered update is a complete update
+   */
+  get isCompleteUpdate() {
+    // Throw when isCompleteUpdate is called without an update. This should
+    // never happen except if the test is incorrectly written.
+    if (!this.activeUpdate)
+      throw new Error(arguments.callee.name + ": isCompleteUpdate called " +
+                      "when activeUpdate is null!");
+
+    var patchCount = this.activeUpdate.patchCount;
+    if ((patchCount < 1) || (patchCount > 2)) {
+      throw new Error("An update must have one or two patches included.");
+    }
+
+    // XXX: After Firefox 4 has been released and we do not have to test any
+    // beta release anymore uncomment out the following code
+//    if (this.activeUpdate.patchCount == 2) {
+//      var patch0URL = this.activeUpdate.getPatchAt(0).URL;
+//      var patch1URL = this.activeUpdate.getPatchAt(1).URL;
+       // Test that the update snippet created by releng doesn't have the same
+       // url for both patches (bug 514040).
+//      controller.assertJS("subject.patch0URL != subject.patch1URL",
+//                          {patch0URL: patch0URL, patch1URL: patch1URL});
+//    }
+
+    return (this.activeUpdate.selectedPatch.type  == "complete");
+  },
+
+   /**
+   * Returns information of the active update in the queue.
+   */
+  get patchInfo() {
+    this._controller.assert(function() {
+      return !!this.activeUpdate;
+    }, "An active update is in the queue.", this);
+
+    return {
+      buildid : this.activeUpdate.buildID,
+      channel : this.channel,
+      is_complete : this.isCompleteUpdate,
+      size : this.activeUpdate.selectedPatch.size,
+      type : this.activeUpdate.type,
+      url : this.activeUpdate.selectedPatch.finalURL || "n/a",
+      download_duration : this._downloadDuration,
+      version : this.activeUpdate.version
+    };
+  },
+
+  /**
+   * Returns the update type (minor or major)
+   *
+   * @returns The update type
+   */
+  get updateType() {
+    return this.activeUpdate.type;
+  },
+
+  /**
+   * Check if updates have been found
+   */
+  get updatesFound() {
+    return this.currentPage.indexOf("updatesfound") == 0;
+  },
+
+  /**
+   * Checks if an update has been applied correctly
+   *
+   * @param {object} updateData
+   *        All the data collected during the update process
+   */
+  assertUpdateApplied : function softwareUpdate_assertUpdateApplied(updateData) {
+    // Get the information from the last update
+    var info = updateData.updates[updateData.updateIndex];
+
+    // The upgraded version should be identical with the version given by
+    // the update and we shouldn't have run a downgrade
+    var check = this._vc.compare(info.build_post.version, info.build_pre.version);
+    this._controller.assert(function () {
+      return check >= 0;
+    }, "The version number of the upgraded build is higher or equal.");
+
+    // The build id should be identical with the one from the update
+    this._controller.assert(function () {
+      return info.build_post.buildid === info.patch.buildid;
+    }, "The build id is equal to the build id of the update.");
+
+    // If a target build id has been given, check if it matches the updated build
+    info.target_buildid = updateData.targetBuildID;
+    if (info.target_buildid) {
+      this._controller.assert(function () {
+        return info.build_post.buildid === info.target_buildid;
+      }, "Target build id matches id of updated build.");
+    }
+
+    // An upgrade should not change the builds locale
+    this._controller.assert(function () {
+      return info.build_post.locale === info.build_pre.locale;
+    }, "The locale of the updated build is identical to the original locale.");
+
+    // Check that no application-wide add-ons have been disabled
+    this._controller.assert(function () {
+      return info.build_post.disabled_addons === info.build_pre.disabled_addons;
+    }, "No application-wide add-ons have been disabled by the update.");
+  },
+
+  /**
+   * Close the software update dialog
+   */
+  closeDialog: function softwareUpdate_closeDialog() {
+    if (this._controller) {
+      this._controller.keypress(null, "VK_ESCAPE", {});
+      this._controller.sleep(500);
+      this._controller = null;
+      this._wizard = null;
+    }
+  },
+
+  /**
+   * Download the update of the given channel and type
+   * @param {string} channel
+   *        Update channel to use
+   * @param {boolean} waitForFinish
+   *        Sets if the function should wait until the download has been finished
+   * @param {number} timeout
+   *        Timeout the download has to stop
+   */
+  download : function softwareUpdate_download(channel, waitForFinish, timeout) {
+    waitForFinish = waitForFinish ? waitForFinish : true;
+
+    // Check that the correct channel has been set
+    this._controller.assert(function() {
+      return channel == this.channel;
+    }, "The current update channel is identical to the specified one.", this);
+
+    // Retrieve the timestamp, so we can measure the duration of the download
+    var startTime = Date.now();
+
+    // Click the next button
+    var next = this.getElement({type: "button", subtype: "next"});
+    this._controller.click(next);
+
+    // Wait for the download page - if it fails the update was already cached
+    try {
+      this.waitForWizardPage(WIZARD_PAGES.downloading);
+
+      if (waitForFinish)
+        this.waitforDownloadFinished(timeout);
+    } catch (ex) {
+      this.waitForWizardPage(WIZARD_PAGES.finished);
+    }
+
+    // Calculate the duration in ms
+    this._downloadDuration = Date.now() - startTime;
+  },
+
+  /**
+   * Update the update.status file and set the status to 'failed:6'
+   */
+  forceFallback : function softwareUpdate_forceFallback() {
+    var dirService = Cc["@mozilla.org/file/directory_service;1"].
+                     getService(Ci.nsIProperties);
+
+    var updateDir;
+    var updateStatus;
+
+    // Check the global update folder first
+    try {
+      updateDir = dirService.get("UpdRootD", Ci.nsIFile);
+      updateDir.append("updates");
+      updateDir.append("0");
+
+      updateStatus = updateDir.clone();
+      updateStatus.append("update.status");
+    } catch (ex) {
+    }
+
+    if (updateStatus == undefined || !updateStatus.exists()) {
+      updateDir = dirService.get("XCurProcD", Ci.nsIFile);
+      updateDir.append("updates");
+      updateDir.append("0");
+
+      updateStatus = updateDir.clone();
+      updateStatus.append("update.status");
+    }
+
+    var foStream = Cc["@mozilla.org/network/file-output-stream;1"].
+                   createInstance(Ci.nsIFileOutputStream);
+    var status = "failed: 6\n";
+    foStream.init(updateStatus, 0x02 | 0x08 | 0x20, -1, 0);
+    foStream.write(status, status.length);
+    foStream.close();
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function softwareUpdate_getDtds() {
+    var dtds = ["chrome://mozapps/locale/update/history.dtd",
+                "chrome://mozapps/locale/update/updates.dtd"]
+    return dtds;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type {ElemBase}
+   */
+  getElement : function softwareUpdate_getElement(spec) {
+    var elem = null;
+
+    switch(spec.type) {
+      /**
+       * subtype: subtype to match
+       * value: value to match
+       */
+      case "button":
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      WIZARD_BUTTONS_BOX + WIZARD_BUTTON[spec.subtype]);
+        break;
+      case "wizard":
+        elem = new elementslib.Lookup(this._controller.window.document, WIZARD);
+        break;
+      case "wizard_page":
+        elem = new elementslib.Lookup(this._controller.window.document, WIZARD_DECK +
+                                      '/id(' + spec.subtype + ')');
+        break;
+      case "download_progress":
+        elem = new elementslib.ID(this._controller.window.document, "downloadProgress");
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Open software update dialog
+   *
+   * @param {MozMillController} browserController
+   *        Mozmill controller of the browser window
+   */
+  openDialog: function softwareUpdate_openDialog(browserController) {
+    // XXX: After Firefox 4 has been released and we do not have to test any
+    // beta release anymore uncomment out the following code
+
+    // With version >= 4.0b7pre the update dialog is reachable from within the
+    // about window now.
+    var appVersion = utils.appInfo.version;
+
+    if (this._vc.compare(appVersion, "4.0b7pre") >= 0) {
+      // XXX: We can't open the about window, otherwise a parallel download of
+      // the update will let us fallback to a complete one all the time
+
+      // Open the about window and check the update button
+      //var aboutItem = new elementslib.Elem(browserController.menus.helpMenu.aboutName);
+      //browserController.click(aboutItem);
+      //
+      //utils.handleWindow("type", "Browser:About", function(controller) {
+      //  // XXX: Bug 599290 - Check for updates has been completely relocated
+      //  // into the about window. We can't check the in-about ui yet.
+      //  var updateButton = new elementslib.ID(controller.window.document,
+      //                                        "checkForUpdatesButton");
+      //  //controller.click(updateButton);
+      //  controller.waitForElement(updateButton, gTimeout);
+      //});
+
+      // For now just call the old ui until we have support for the about window.
+      var updatePrompt = Cc["@mozilla.org/updates/update-prompt;1"].
+                         createInstance(Ci.nsIUpdatePrompt);
+      updatePrompt.checkForUpdates();
+    } else {
+      // For builds <4.0b7pre
+      updateItem = new elementslib.Elem(browserController.menus.helpMenu.checkForUpdates);
+      browserController.click(updateItem);
+    }
+
+    this.waitForDialogOpen(browserController);
+  },
+
+  /**
+   * Wait that check for updates has been finished
+   * @param {number} timeout
+   */
+  waitForCheckFinished : function softwareUpdate_waitForCheckFinished(timeout) {
+    timeout = timeout ? timeout : gTimeoutUpdateCheck;
+
+    this._controller.waitFor(function() {
+      return this.currentPage != WIZARD_PAGES.checking;
+    }, "Check for updates has been completed.", timeout, null, this);
+  },
+
+  /**
+   * Wait for the software update dialog
+   *
+   * @param {MozMillController} browserController
+   *        Mozmill controller of the browser window
+   */
+  waitForDialogOpen : function softwareUpdate_waitForDialogOpen(browserController) {
+    this._controller = utils.handleWindow("type", "Update:Wizard",
+                                          undefined, false);
+    this._wizard = this.getElement({type: "wizard"});
+
+    this._controller.waitFor(function () {
+      return this.currentPage !== WIZARD_PAGES.dummy;
+    }, "Dummy wizard page has been made invisible - got " + this.currentPage,
+      undefined, undefined, this);
+
+    this._controller.window.focus();
+  },
+
+  /**
+   * Wait until the download has been finished
+   *
+   * @param {number} timeout
+   *        Timeout the download has to stop
+   */
+  waitforDownloadFinished: function softwareUpdate_waitForDownloadFinished(timeout) {
+    timeout = timeout ? timeout : gTimeoutUpdateDownload;
+
+    var progress =  this.getElement({type: "download_progress"});
+    this._controller.waitFor(function () {
+      return progress.getNode().value === '100';
+    }, "Update has been finished downloading.", timeout);
+
+    this.waitForWizardPage(WIZARD_PAGES.finished);
+  },
+
+  /**
+   * Waits for the given page of the update dialog wizard
+   */
+  waitForWizardPage : function softwareUpdate_waitForWizardPage(step) {
+    this._controller.waitFor(function () {
+      return this.currentPage === step;
+    }, "New wizard page has been selected - got " + this.currentPage +
+      ", expected " + step, undefined, undefined, this);
+  }
+}
+
+// Export of variables
+exports.WIZARD_PAGES = WIZARD_PAGES;
+
+// Export of classes
+exports.softwareUpdate = softwareUpdate;
diff --git a/common/tests/functional/shared-modules/tabs.js b/common/tests/functional/shared-modules/tabs.js
new file mode 100644 (file)
index 0000000..b6e7568
--- /dev/null
@@ -0,0 +1,503 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Anthony Hughes <ahughes@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The TabbedBrowsingAPI adds support for accessing and interacting with tab elements
+ *
+ * @version 1.0.0
+ */
+
+// Include required modules
+var utils = require("utils");
+var prefs = require("prefs");
+
+const TIMEOUT = 5000;
+
+const PREF_TABS_ANIMATE = "browser.tabs.animate";
+
+const TABS_VIEW = '/id("main-window")/id("tab-view-deck")/{"flex":"1"}';
+const TABS_BROWSER = TABS_VIEW + '/id("browser")/id("appcontent")/id("content")';
+const TABS_TOOLBAR = TABS_VIEW + '/id("navigator-toolbox")/id("TabsToolbar")';
+const TABS_TABS = TABS_TOOLBAR + '/id("tabbrowser-tabs")';
+const TABS_ARROW_SCROLLBOX = TABS_TABS + '/anon({"anonid":"arrowscrollbox"})';
+const TABS_STRIP = TABS_ARROW_SCROLLBOX + '/anon({"anonid":"scrollbox"})/anon({"flex":"1"})';
+
+/**
+ * Close all tabs and open about:blank
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the window to operate on
+ */
+function closeAllTabs(controller)
+{
+  var browser = new tabBrowser(controller);
+  browser.closeAllTabs();
+}
+
+/**
+ * Check and return all open tabs with the specified URL
+ *
+ * @param {string} aUrl
+ *        URL to check for
+ *
+ * @returns Array of tabs
+ */
+function getTabsWithURL(aUrl) {
+  var tabs = [ ];
+
+  var uri = utils.createURI(aUrl, null, null);
+
+  var wm = Cc["@mozilla.org/appshell/window-mediator;1"].
+           getService(Ci.nsIWindowMediator);
+  var winEnum = wm.getEnumerator("navigator:browser");
+
+  // Iterate through all windows
+  while (winEnum.hasMoreElements()) {
+    var window = winEnum.getNext();
+
+    // Don't check windows which are about to close or don't have gBrowser set
+    if (window.closed || !("gBrowser" in window))
+      continue;
+
+    // Iterate through all tabs in the current window
+    var browsers = window.gBrowser.browsers;
+    for (var i = 0; i < browsers.length; i++) {
+      var browser = browsers[i];
+      if (browser.currentURI.equals(uri)) {
+        tabs.push({
+          controller : new mozmill.controller.MozMillController(window),
+          index : i
+        });
+      }
+    }
+  }
+
+  return tabs;
+}
+
+/**
+ * Constructor
+ *
+ * @param {MozMillController} controller
+ *        MozMill controller of the window to operate on
+ */
+function tabBrowser(controller)
+{
+  this._controller = controller;
+  this._tabs = this.getElement({type: "tabs"});
+}
+
+/**
+ * Tabbed Browser class
+ */
+tabBrowser.prototype = {
+  /**
+   * Returns the MozMill controller
+   *
+   * @returns Mozmill controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Get the amount of open tabs
+   *
+   * @returns Number of tabs
+   * @type {number}
+   */
+  get length() {
+    return this._tabs.getNode().itemCount;
+  },
+
+  /**
+   * Get the currently selected tab index
+   *
+   * @returns Index of currently selected tab
+   * @type {number}
+   */
+  get selectedIndex() {
+    return this._tabs.getNode().selectedIndex;
+  },
+
+  /**
+   * Select the tab with the given index
+   *
+   * @param {number} index
+   *        Index of the tab which should be selected
+   */
+  set selectedIndex(index) {
+    this._controller.click(this.getTab(index));
+  },
+
+  /**
+   * Close all tabs of the window except the last one and open a blank page.
+   */
+  closeAllTabs : function tabBrowser_closeAllTabs()
+  {
+    while (this._controller.tabs.length > 1) {
+      this.closeTab({type: "menu"});
+    }
+
+    this._controller.open("about:blank");
+    this._controller.waitForPageLoad();
+  },
+
+  /**
+   * Close an open tab
+   *
+   * @param {object} aEvent
+   *        The event specifies how to close a tab
+   *        Elements: type - Type of event (closeButton, menu, middleClick, shortcut)
+   *                         [optional - default: menu]
+   */
+  closeTab : function tabBrowser_closeTab(aEvent) {
+    var event = aEvent || { };
+    var type = (event.type == undefined) ? "menu" : event.type;
+
+    // Disable tab closing animation for default behavior
+    prefs.preferences.setPref(PREF_TABS_ANIMATE, false);
+
+    // Add event listener to wait until the tab has been closed
+    var self = { closed: false };
+    function checkTabClosed() { self.closed = true; }
+    this._controller.window.addEventListener("TabClose", checkTabClosed, false);
+
+    switch (type) {
+      case "closeButton":
+        var button = this.getElement({type: "tabs_tabCloseButton",
+                                     subtype: "tab", value: this.getTab()});
+        this._controller.click(button);
+        break;
+      case "menu":
+        var menuitem = new elementslib.Elem(this._controller.menus['file-menu'].menu_close);
+        this._controller.click(menuitem);
+        break;
+      case "middleClick":
+        var tab = this.getTab(event.index);
+        this._controller.middleClick(tab);
+        break;
+      case "shortcut":
+        var cmdKey = utils.getEntity(this.getDtds(), "closeCmd.key");
+        this._controller.keypress(null, cmdKey, {accelKey: true});
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown event type - " + type);
+    }
+
+    try {
+      this._controller.waitForEval("subject.tab.closed == true", TIMEOUT, 100,
+                                   {tab: self});
+    } finally {
+      this._controller.window.removeEventListener("TabClose", checkTabClosed, false);
+      prefs.preferences.clearUserPref(PREF_TABS_ANIMATE);
+    }
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function tabBrowser_getDtds() {
+    var dtds = ["chrome://browser/locale/browser.dtd",
+                "chrome://browser/locale/tabbrowser.dtd",
+                "chrome://global/locale/global.dtd"];
+    return dtds;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type {ElemBase}
+   */
+  getElement : function tabBrowser_getElement(spec) {
+    var document = this._controller.window.document;
+    var elem = null;
+
+    switch(spec.type) {
+      /**
+       * subtype: subtype to match
+       * value: value to match
+       */
+      case "tabs":
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      TABS_TABS);
+        break;
+      case "tabs_allTabsButton":
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      TABS_TOOLBAR + '/id("alltabs-button")');
+        break;
+      case "tabs_allTabsPopup":
+        elem = new elementslib.Lookup(this._controller.window.document, TABS_TOOLBAR +
+                                      '/id("alltabs-button")/id("alltabs-popup")');
+        break;
+      case "tabs_newTabButton":
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      TABS_ARROW_SCROLLBOX + '/anon({"class":"tabs-newtab-button"})');
+        break;
+      case "tabs_scrollButton":
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      TABS_ARROW_SCROLLBOX +
+                                      '/anon({"anonid":"scrollbutton-' + spec.subtype + '"})');
+        break;
+      case "tabs_strip":
+        elem = new elementslib.Lookup(this._controller.window.document, TABS_STRIP);
+        break;
+      case "tabs_tab":
+        switch (spec.subtype) {
+          case "index":
+            elem = new elementslib.Elem(this._tabs.getNode().getItemAtIndex(spec.value));
+            break;
+        }
+        break;
+      case "tabs_tabCloseButton":
+        var node = document.getAnonymousElementByAttribute(
+                     spec.value.getNode(),
+                     "anonid",
+                     "close-button"
+                   );
+        elem = new elementslib.Elem(node);
+        break;
+      case "tabs_tabFavicon":
+        var node = document.getAnonymousElementByAttribute(
+                     spec.value.getNode(),
+                     "class",
+                     "tab-icon-image"
+                   );
+
+        elem = new elementslib.Elem(node);
+        break;
+      case "tabs_tabPanel":
+        var panelId = spec.value.getNode().getAttribute("linkedpanel");
+        elem = new elementslib.Lookup(this._controller.window.document, TABS_BROWSER +
+                                      '/anon({"anonid":"tabbox"})/anon({"anonid":"panelcontainer"})' +
+                                      '/{"id":"' + panelId + '"}');
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Get the tab at the specified index
+   *
+   * @param {number} index
+   *        Index of the tab
+   * @returns The requested tab
+   * @type {ElemBase}
+   */
+  getTab : function tabBrowser_getTab(index) {
+    if (index === undefined)
+      index = this.selectedIndex;
+
+    return this.getElement({type: "tabs_tab", subtype: "index", value: index});
+  },
+
+  /**
+   * Creates the child element of the tab's notification bar
+   *
+   * @param {number} tabIndex
+   *        (Optional) Index of the tab to check
+   * @param {string} elemString
+   *        (Optional) Lookup string of the notification bar's child element
+   * @return The created child element
+   * @type {ElemBase}
+   */
+  getTabPanelElement : function tabBrowser_getTabPanelElement(tabIndex, elemString)
+  {
+    var index = tabIndex ? tabIndex : this.selectedIndex;
+    var elemStr = elemString ? elemString : "";
+
+    // Get the tab panel and check if an element has to be fetched
+    var panel = this.getElement({type: "tabs_tabPanel", subtype: "tab", value: this.getTab(index)});
+    var elem = new elementslib.Lookup(this._controller.window.document, panel.expression + elemStr);
+
+    return elem;
+  },
+
+  /**
+   * Open element (link) in a new tab
+   *
+   * @param {object} aEvent
+   *        The event specifies how to open the element in a new tab
+   *        Elements: type   - Type of event (contextMenu, middleClick)
+   *                           [optional - default: middleClick]
+   *                  target - Element to click on
+   *                           [optional - default: -]
+   */
+  openInNewTab : function tabBrowser_openInNewTab(aEvent) {
+    var event = aEvent || { };
+    var type = (event.type == undefined) ? "middleClick" : event.type;
+    var target = event.target;
+
+    // Disable tab closing animation for default behavior
+    prefs.preferences.setPref(PREF_TABS_ANIMATE, false);
+
+    // Add event listener to wait until the tab has been opened
+    var self = { opened: false };
+    function checkTabOpened() { self.opened = true; }
+    this._controller.window.addEventListener("TabOpen", checkTabOpened, false);
+
+    switch (type) {
+      case "contextMenu":
+        var contextMenuItem = new elementslib.ID(this._controller.window.document,
+                                                 "context-openlinkintab");
+        this._controller.rightClick(target);
+        this._controller.click(contextMenuItem);
+        utils.closeContentAreaContextMenu(this._controller);
+        break;
+      case "middleClick":
+        this._controller.middleClick(target);
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown event type - " + type);
+    }
+
+    try {
+      this._controller.waitForEval("subject.tab.opened == true", TIMEOUT, 100,
+                                   {tab: self});
+    } finally {
+      this._controller.window.removeEventListener("TabOpen", checkTabOpened, false);
+      prefs.preferences.clearUserPref(PREF_TABS_ANIMATE);
+    }
+  },
+
+  /**
+   * Open a new tab
+   *
+   * @param {object} aEvent
+   *        The event specifies how to open a new tab (menu, shortcut,
+   *        Elements: type - Type of event (menu, newTabButton, shortcut, tabStrip)
+   *                         [optional - default: menu]
+   */
+  openTab : function tabBrowser_openTab(aEvent) {
+    var event = aEvent || { };
+    var type = (event.type == undefined) ? "menu" : event.type;
+
+    // Disable tab closing animation for default behavior
+    prefs.preferences.setPref(PREF_TABS_ANIMATE, false);
+
+    // Add event listener to wait until the tab has been opened
+    var self = { opened: false };
+    function checkTabOpened() { self.opened = true; }
+    this._controller.window.addEventListener("TabOpen", checkTabOpened, false);
+
+    switch (type) {
+      case "menu":
+        var menuitem = new elementslib.Elem(this._controller.menus['file-menu'].menu_newNavigatorTab);
+        this._controller.click(menuitem);
+        break;
+      case "shortcut":
+        var cmdKey = utils.getEntity(this.getDtds(), "tabCmd.commandkey");
+        this._controller.keypress(null, cmdKey, {accelKey: true});
+        break;
+      case "newTabButton":
+        var newTabButton = this.getElement({type: "tabs_newTabButton"});
+        this._controller.click(newTabButton);
+        break;
+      case "tabStrip":
+        var tabStrip = this.getElement({type: "tabs_strip"});
+
+        // RTL-locales need to be treated separately
+        if (utils.getEntity(this.getDtds(), "locale.dir") == "rtl") {
+          // XXX: Workaround until bug 537968 has been fixed
+          this._controller.click(tabStrip, 100, 3);
+          // Todo: Calculate the correct x position
+          this._controller.doubleClick(tabStrip, 100, 3);
+        } else {
+          // XXX: Workaround until bug 537968 has been fixed
+          this._controller.click(tabStrip, tabStrip.getNode().clientWidth - 100, 3);
+          // Todo: Calculate the correct x position
+          this._controller.doubleClick(tabStrip, tabStrip.getNode().clientWidth - 100, 3);
+        }
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown event type - " + type);
+    }
+
+    try {
+      this._controller.waitForEval("subject.tab.opened == true", TIMEOUT, 100,
+                                   {tab: self});
+    } finally {
+      this._controller.window.removeEventListener("TabOpen", checkTabOpened, false);
+      prefs.preferences.clearUserPref(PREF_TABS_ANIMATE);
+    }
+  },
+
+  /**
+   * Waits for a particular tab panel element to display and stop animating
+   *
+   * @param {number} tabIndex
+   *        Index of the tab to check
+   * @param {string} elemString
+   *        Lookup string of the tab panel element
+   */
+  waitForTabPanel: function tabBrowser_waitForTabPanel(tabIndex, elemString) {
+    // Get the specified tab panel element
+    var tabPanel = this.getTabPanelElement(tabIndex, elemString);
+
+    // Get the style information for the tab panel element
+    var style = this._controller.window.getComputedStyle(tabPanel.getNode(), null);
+
+    // Wait for the top margin to be 0px - ie. has stopped animating
+    // XXX: A notification bar starts at a negative pixel margin and drops down
+    //      to 0px.  This creates a race condition where a test may click
+    //      before the notification bar appears at it's anticipated screen location
+    this._controller.waitFor(function () {
+      return style.marginTop == '0px';
+    }, "Expected notification bar to be visible: '" + elemString + "' ");
+  }
+}
+
+// Export of functions
+exports.closeAllTabs = closeAllTabs;
+exports.getTabsWithURL = getTabsWithURL;
+
+// Export of classes
+exports.tabBrowser = tabBrowser;
diff --git a/common/tests/functional/shared-modules/tabview.js b/common/tests/functional/shared-modules/tabview.js
new file mode 100644 (file)
index 0000000..fbbf08a
--- /dev/null
@@ -0,0 +1,629 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+// Include required modules
+var domUtils = require("dom-utils");
+var tabs = require("tabs");
+var utils = require("utils");
+
+const TIMEOUT = 5000;
+
+/**
+ * Constructor
+ */
+function tabView(aController) {
+  this._controller = aController;
+  this._tabView = null;
+  this._tabViewDoc = this._controller.window.document;
+  this._tabViewObject = this._controller.window.TabView;
+}
+
+/**
+ * Tab View class
+ */
+tabView.prototype = {
+
+  ///////////////////////////////
+  // Global section
+  ///////////////////////////////
+
+  /**
+   * Returns the MozMill controller
+   *
+   * @returns Mozmill controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Check if the Tab View is open
+   *
+   * @returns True if the Tab View is open
+   * @type {boolean}
+   */
+  get isOpen() {
+    var deck = this.getElement({type: "deck"});
+    return deck.getNode().getAttribute("selectedIndex") == "1";
+  },
+
+  /**
+   * Open the Tab View
+   */
+  open : function tabView_open() {
+    var menuitem = new elementslib.Elem(this._controller.menus['view-menu'].menu_tabview);
+    this._controller.click(menuitem);
+    this.waitForOpened();
+
+    this._tabView = this.getElement({type: "tabView"});
+    this._tabViewDoc = this._tabView.getNode().webNavigation.document;
+  },
+
+  /**
+   * Reset the Tab View settings for the current window
+   */
+  reset : function tabView_reset() {
+    // Make sure to close TabView before resetting its ui
+    if (this.isOpen) {
+      this.close();
+    }
+
+    var self = this;
+    this._tabViewObject._initFrame(function () {
+      var contentWindow = self._tabViewObject._window;
+      contentWindow.UI.reset();
+    });
+
+    // Make sure all tabs will be shown
+    Array.forEach(this._controller.window.gBrowser.tabs, function (tab) {
+      this._controller.window.gBrowser.showTab(tab);
+    }, this);
+  },
+
+  /**
+   * Wait until the Tab View has been opened
+   */
+  waitForOpened : function tabView_waitForOpened() {
+    // Add event listener to wait until the tabview has been opened
+    var self = { opened: false };
+    function checkOpened() { self.opened = true; }
+    this._controller.window.addEventListener("tabviewshown", checkOpened, false);
+
+    try {
+      mozmill.utils.waitFor(function () {
+        return self.opened == true;
+      }, "TabView is not open.");
+
+      this._groupItemsObject = this._tabViewObject._window.GroupItems;
+      this._tabItemsObject = this._tabViewObject._window.TabItems;
+    }
+    finally {
+      this._controller.window.removeEventListener("tabviewshown", checkOpened, false);
+    }
+  },
+
+  /**
+   * Close the Tab View
+   */
+  close : function tabView_close() {
+    var menuitem = new elementslib.Elem(this._controller.menus['view-menu'].menu_tabview);
+    this._controller.click(menuitem);
+    this.waitForClosed();
+
+    this._tabView = null;
+    this._tabViewDoc = this._controller.window.document;
+  },
+
+  /**
+   * Wait until the Tab View has been closed
+   */
+  waitForClosed : function tabView_waitForClosed() {
+    // Add event listener to wait until the tabview has been closed
+    var self = { closed: false };
+    function checkClosed() { self.closed = true; }
+    this._controller.window.addEventListener("tabviewhidden", checkClosed, false);
+
+    try {
+      mozmill.utils.waitFor(function () {
+        return self.closed == true;
+      }, "TabView is still open.");
+    } finally {
+      this._controller.window.removeEventListener("tabviewhidden", checkClosed, false);
+    }
+
+    this._groupItemsObject = null;
+    this._tabItemsObject = null;
+  },
+
+
+  ///////////////////////////////
+  // Groups section
+  ///////////////////////////////
+
+  /**
+   * Returns the tab groups which match the filter criteria
+   *
+   * @param {object} aSpec
+   *        Information about the filter to apply
+   *        Elements: filter - Type of filter to apply
+   *                           (active, title)
+   *                           [optional - default: ""]
+   *                  value  - Value of the element
+   *                           [optional - default: ""]
+   *
+   * @returns List of groups
+   * @type {array of ElemBase}
+   */
+  getGroups : function tabView_getGroups(aSpec) {
+    var spec = aSpec || {};
+
+    return this.getElements({
+      type: "groups",
+      subtype: spec.filter,
+      value: spec.value
+    });
+  },
+
+  /**
+   * Retrieve the group's title box
+   *
+   * @param {object} aSpec
+   *        Information on which group to operate on
+   *        Elements: group - Group element
+   *
+   * @returns Group title box
+   * @type {ElemBase}
+   */
+  getGroupTitleBox : function tabView_getGroupTitleBox(aSpec) {
+    var spec = aSpec || {};
+    var group = spec.group;
+
+    if (!group) {
+      throw new Error(arguments.callee.name + ": Group not specified.");
+    }
+
+    return this.getElement({
+      type: "group_titleBox",
+      parent: group
+    });
+  },
+
+  /**
+   * Close the specified tab group
+   *
+   * @param {object} aSpec
+   *        Information on which group to operate on
+   *        Elements: group - Group
+   */
+  closeGroup : function tabView_closeGroup(aSpec) {
+    var spec = aSpec || {};
+    var group = spec.group;
+
+    if (!group) {
+      throw new Error(arguments.callee.name + ": Group not specified.");
+    }
+
+    var button = this.getElement({
+      type: "group_closeButton",
+      parent: group
+    });
+    this._controller.click(button);
+
+    this.waitForGroupClosed({group: group});
+  },
+
+  /**
+   * Wait until the specified tab group has been closed
+   *
+   * @param {object} aSpec
+   *        Information on which group to operate on
+   *        Elements: group - Group
+   */
+  waitForGroupClosed : function tabView_waitForGroupClosed(aSpec) {
+    var spec = aSpec || {};
+    var group = spec.group;
+    var groupObj = null;
+
+    var self = { closed: false };
+    function checkClosed() { self.closed = true; }
+
+    if (!group) {
+      throw new Error(arguments.callee.name + ": Group not specified.");
+    }
+
+    this._groupItemsObject.groupItems.forEach(function (node) {
+      if (node.container == group.getNode()) {
+        groupObj = node;
+      }
+    });
+
+    if (!groupObj) {
+      throw new Error(arguments.callee.name + ": Group not found.");
+    }
+
+    try {
+      groupObj.addSubscriber(groupObj, "groupHidden", checkClosed);
+      mozmill.utils.waitFor(function () {
+        return self.closed;
+      }, "Tab Group has not been closed.");
+    }
+    finally {
+      groupObj.removeSubscriber(groupObj, "groupHidden");
+    }
+  },
+
+  /**
+   * Undo the closing of the specified tab group
+   *
+   * @param {object} aSpec
+   *        Information on which group to operate on
+   *        Elements: group - Group
+   */
+  undoCloseGroup : function tabView_undoCloseGroup(aSpec) {
+    var spec = aSpec || {};
+    var group = spec.group;
+
+    if (!group) {
+      throw new Error(arguments.callee.name + ": Group not specified.");
+    }
+
+    var undo = this.getElement({
+      type: "group_undoButton",
+      parent: group
+    });
+    this._controller.waitThenClick(undo);
+
+    this.waitForGroupUndo({group: group});
+  },
+
+  /**
+   * Wait until the specified tab group has been reopened
+   *
+   * @param {object} aSpec
+   *        Information on which group to operate on
+   *        Elements: group - Group
+   */
+  waitForGroupUndo : function tabView_waitForGroupUndo(aSpec) {
+    var spec = aSpec || {};
+    var group = spec.group;
+    var groupObj = null;
+
+    var self = { reopened: false };
+    function checkClosed() { self.reopened = true; }
+
+    if (!group) {
+      throw new Error(arguments.callee.name + ": Group not specified.");
+    }
+
+    var groupObj = null;
+    this._groupItemsObject.groupItems.forEach(function(node) {
+      if (node.container == group.getNode()) {
+        groupObj = node;
+      }
+    });
+
+    if (!groupObj) {
+      throw new Error(arguments.callee.name + ": Group not found.");
+    }
+
+    try {
+      groupObj.addSubscriber(groupObj, "groupShown", checkClosed);
+      mozmill.utils.waitFor(function () {
+        return self.reopened;
+      }, "Tab Group has not been reopened.");
+    }
+    finally {
+      groupObj.removeSubscriber(groupObj, "groupShown");
+    }
+  },
+
+
+  ///////////////////////////////
+  // Tabs section
+  ///////////////////////////////
+
+  /**
+   * Returns the tabs which match the filter criteria
+   *
+   * @param {object} aSpec
+   *        Information about the filter to apply
+   *        Elements: filter - Type of filter to apply
+   *                           (active, title)
+   *                           [optional - default: ""]
+   *                  value  - Value of the element
+   *                           [optional - default: ""]
+   *
+   * @returns List of tabs
+   * @type {array of ElemBase}
+   */
+  getTabs : function tabView_getTabs(aSpec) {
+    var spec = aSpec || {};
+
+    return this.getElements({
+      type: "tabs",
+      subtype: spec.filter,
+      value: spec.value
+    });
+  },
+
+  /**
+   * Close a tab
+   *
+   * @param {object} aSpec
+   *        Information about the element to operate on
+   *        Elements: tab - Tab to close
+   */
+  closeTab : function tabView_closeTab(aSpec) {
+    var spec = aSpec || {};
+    var tab = spec.tab;
+
+    if (!tab) {
+      throw new Error(arguments.callee.name + ": Tab not specified.");
+    }
+
+    var button = this.getElement({
+      type: "tab_closeButton",
+      value: tab}
+    );
+    this._controller.click(button);
+  },
+
+  /**
+   * Retrieve the tab's title box
+   *
+   * @param {object} aSpec
+   *        Information on which tab to operate on
+   *        Elements: tab - Tab
+   *
+   * @returns Tab title box
+   * @type {ElemBase}
+   */
+  getTabTitleBox : function tabView_getTabTitleBox(aSpec) {
+    var spec = aSpec || {};
+    var tab = spec.tab;
+
+    if (!tab) {
+      throw new Error(arguments.callee.name + ": Tab not specified.");
+    }
+
+    return this.getElement({
+      type: "tab_titleBox",
+      parent: spec.tab
+    });
+  },
+
+  /**
+   * Open a new tab in the specified group
+   *
+   * @param {object} aSpec
+   *        Information about the element to operate on
+   *        Elements: group - Group to create a new tab in
+   */
+  openTab : function tabView_openTab(aSpec) {
+    var spec = aSpec || {};
+    var group = spec.group;
+
+    if (!group) {
+      throw new Error(arguments.callee.name + ": Group not specified.");
+    }
+
+    var button = this.getElement({
+      type: "group_newTabButton",
+      parent: group
+    });
+
+    this._controller.click(button);
+    this.waitForClosed();
+  },
+
+
+  ///////////////////////////////
+  // UI Elements section
+  ///////////////////////////////
+
+  /**
+   * Retrieve an UI element based on the given specification
+   *
+   * @param {object} aSpec
+   *        Information of the UI elements which should be retrieved
+   *        Elements: type     - Identifier of the element
+   *                  subtype  - Attribute of the element to filter
+   *                             [optional - default: ""]
+   *                  value    - Value of the attribute to filter
+   *                             [optional - default: ""]
+   *                  parent   - Parent of the to find element
+   *                             [optional - default: document]
+   *
+   * @returns Element which has been found
+   * @type {ElemBase}
+   */
+  getElement : function tabView_getElement(aSpec) {
+    var elements = this.getElements(aSpec);
+
+    return (elements.length > 0) ? elements[0] : undefined;
+  },
+
+  /**
+   * Retrieve list of UI elements based on the given specification
+   *
+   * @param {object} aSpec
+   *        Information of the UI elements which should be retrieved
+   *        Elements: type     - Identifier of the element
+   *                  subtype  - Attribute of the element to filter
+   *                             [optional - default: ""]
+   *                  value    - Value of the attribute to filter
+   *                             [optional - default: ""]
+   *                  parent   - Parent of the to find element
+   *                             [optional - default: document]
+   *
+   * @returns Elements which have been found
+   * @type {array of ElemBase}
+   */
+  getElements : function tabView_getElement(aSpec) {
+    var spec = aSpec || { };
+    var type = spec.type;
+    var subtype = spec.subtype;
+    var value = spec.value;
+    var parent = spec.parent;
+
+    var root = parent ? parent.getNode() : this._tabViewDoc;
+    var nodeCollector = new domUtils.nodeCollector(root);
+
+    switch(type) {
+      // Top level elements
+      case "tabView":
+        nodeCollector.root = this._controller.window.document;
+        nodeCollector.queryNodes("#tab-view");
+        break;
+      case "contentArea":
+        nodeCollector.queryNodes("#content");
+        break;
+      case "deck":
+        nodeCollector.root = this._controller.window.document;
+        nodeCollector.queryNodes("#tab-view-deck");
+        break;
+      case "exitButton":
+        nodeCollector.queryNodes("#exit-button");
+        break;
+
+      // Group elements
+      case "group_appTabs":
+        nodeCollector.queryNodes(".appTabIcon");
+        break;
+      case "group_closeButton":
+        nodeCollector.queryNodes(".close");
+        break;
+      case "group_newTabButton":
+        nodeCollector.queryNodes(".newTabButton");
+        break;
+      case "group_resizer":
+        nodeCollector.queryNodes(".iq-resizable-handle");
+        break;
+      case "group_stackExpander":
+        nodeCollector.queryNodes(".stackExpander");
+        break;
+      case "group_titleBox":
+        nodeCollector.queryNodes(".name");
+        break;
+      case "group_undoButton":
+        // Bug 596504 - No reference to the undo button
+        nodeCollector.root = this._tabViewDoc;
+        nodeCollector.queryNodes(".undo").filter(function (node) {
+          var groups = this._groupItemsObject.groupItems;
+          for (var i = 0; i < groups.length; i++) {
+            var group = groups[i];
+            if (group.container == parent.getNode() &&
+                group.$undoContainer.length == 1) {
+              return true;
+            }
+          }
+          return false;
+        }, this);
+        break;
+      case "groups":
+        nodeCollector.queryNodes(".groupItem").filter(function (node) {
+          switch(subtype) {
+            case "active":
+              return node.className.indexOf("activeGroup") != -1;
+            case "title":
+              // If no title is given the default name is used
+              if (!value) {
+                value = utils.getProperty("chrome://browser/locale/tabview.properties",
+                                          "tabview.groupItem.defaultName");
+              }
+              var title = node.querySelector(".name");
+              return (value == title.value);
+            default:
+              return true;
+          }
+        }, this);
+        break;
+
+      // Search elements
+      case "search_box":
+        nodeCollector.queryNodes("#searchbox");
+        break;
+      case "search_button":
+        nodeCollector.queryNodes("#searchbutton");
+        break;
+
+      // Tab elements
+      case "tab_closeButton":
+        nodeCollector.queryNodes(".tab .close");
+        break;
+      case "tab_favicon":
+        nodeCollector.queryNodes(".tab .favicon");
+        break;
+      case "tab_titleBox":
+        nodeCollector.queryNodes(".tab .tab-title");
+        break;
+      case "tabs":
+        nodeCollector.queryNodes(".tab").filter(function (node) {
+          switch (subtype) {
+            case "active":
+              return (node.className.indexOf("focus") != -1);
+            case "group":
+              var group = value ? value.getNode() : null;
+              if (group) {
+                var tabs = this._tabItemsObject.getItems();
+                for (var i = 0; i < tabs.length; i++) {
+                  var tab = tabs[i];
+                  if (tab.parent && tab.parent.container == group) {
+                    return true;
+                  }
+                }
+                return false;
+              }
+              else {
+                return (node.className.indexOf("tabInGroupItem") == -1);
+              }
+            default:
+              return true;
+          }
+        }, this);
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " +
+                        aSpec.type);
+    }
+
+    return nodeCollector.elements;
+  }
+}
+
+// Export of classes
+exports.tabView = tabView;
diff --git a/common/tests/functional/shared-modules/toolbars.js b/common/tests/functional/shared-modules/toolbars.js
new file mode 100644 (file)
index 0000000..acfa890
--- /dev/null
@@ -0,0 +1,509 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Aaron Train <atrain@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The ToolbarAPI adds support for accessing and interacting with toolbar elements
+ *
+ * @version 1.0.0
+ */
+
+// Include required modules
+var utils = require("utils");
+
+const TIMEOUT = 5000;
+
+const AUTOCOMPLETE_POPUP = '/id("main-window")/id("mainPopupSet")/id("PopupAutoCompleteRichResult")';
+const NOTIFICATION_POPUP = '/id("main-window")/id("mainPopupSet")/id("notification-popup")';
+const URLBAR_CONTAINER = '/id("main-window")/id("tab-view-deck")/{"flex":"1"}' +
+                         '/id("navigator-toolbox")/id("nav-bar")/id("urlbar-container")';
+const URLBAR_INPUTBOX = URLBAR_CONTAINER + '/id("urlbar")/anon({"anonid":"stack"})' + 
+                                           '/anon({"anonid":"textbox-container"})' + 
+                                           '/anon({"anonid":"textbox-input-box"})';
+const CONTEXT_MENU = URLBAR_INPUTBOX + '/anon({"anonid":"input-box-contextmenu"})';
+
+/**
+ * Constructor
+ * 
+ * @param {MozmillController} controller
+ *        MozMillController of the window to operate on
+ */
+function autoCompleteResults(controller) {
+  this._controller = controller;
+  this._popup = this.getElement({type: "popup"});
+  this._results = this.getElement({type: "results"});
+}
+
+/**
+ * AutoComplete Result class
+ */
+autoCompleteResults.prototype = {
+  /**
+   * Returns all autocomplete results
+   *
+   * @returns Autocomplete results
+   * @type {Array of ElemBase}
+   */
+  get allResults() {
+    var results = [];
+    for (ii = 0; ii < this.length; ii++) {
+      results.push(this.getResult(ii));
+    }
+    return results;
+  },
+
+  /**
+   * Returns the controller of the current window
+   *
+   * @returns Mozmill Controller
+   * @type MozMillController
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Check if the autocomplete popup is open
+   *
+   * @returns True if the panel is open
+   * @type {boolean}
+   */
+  get isOpened() {
+    return (this._popup.getNode().state == 'open');
+  },
+
+  /**
+   * Return the amount of autocomplete entries
+   *
+   * @returns Number of all entries
+   * @type {number}
+   */
+  get length() {
+    return this._results.getNode().itemCount;
+  },
+
+  /**
+   * Returns the currently selected index
+   *
+   * @returns Selected index
+   * @type {number}
+   */
+  get selectedIndex() {
+    return this._results.getNode().selectedIndex;
+  },
+
+  /**
+   * Returns the visible autocomplete results
+   *
+   * @returns Results
+   * @type {Array of ElemBase}
+   */
+  get visibleResults() {
+    var results = [];
+    for (ii = 0; ii < this.length; ii++) {
+      var result = this.getResult(ii);
+      if (!result.getNode().hasAttribute("collapsed"))
+        results.push(result);
+    }
+    return results;
+  },
+
+  /**
+   * Returns the underlined text of all results from the text or URL
+   *
+   * @param {ElemBase} result
+   *        Autocomplete result which has to be checked
+   * @param {string} type
+   *        Type of element to check (text or url)
+   *
+   * @returns An array of substrings which are underlined
+   * @type {Array of string}
+   */
+  getUnderlinedText : function autoCompleteResults_getUnderlinedText(result, type) {
+    this._controller.assertJS("subject.resultNode != null",
+                              {resultNode: result.getNode()});
+
+    // Get the description element of the given title or url
+    var description = null;
+    switch (type) {
+      case "title":
+        description = result.getNode().boxObject.firstChild.childNodes[1].childNodes[0];
+        break;
+      case "url":
+        description = result.getNode().boxObject.lastChild.childNodes[2].childNodes[0];
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Type unknown - " + type);
+    }
+
+    let values = [ ];
+    for each (node in description.childNodes) {
+      if (node.nodeName == 'span') {
+        // Only add underlined text to the results
+        values.push(node.innerHTML);
+      }
+    }
+
+    return values;
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function autoCompleteResults_getDtds() {
+    return null;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type {ElemBase}
+   */
+  getElement : function autoCompleteResults_getElement(spec) {
+    var elem = null;
+
+    switch (spec.type) {
+      /**
+       * subtype: subtype to match
+       * value: value to match
+       */
+      case "popup":
+        elem = new elementslib.Lookup(this._controller.window.document, AUTOCOMPLETE_POPUP);
+        break;
+      case "results":
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      AUTOCOMPLETE_POPUP + '/anon({"anonid":"richlistbox"})');
+        break;
+      case "result":
+        elem = new elementslib.Elem(this._results.getNode().getItemAtIndex(spec.value));
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Returns the autocomplete result element of the given index
+   *
+   * @param {number} index
+   *        Index of the result to return
+   * @returns Autocomplete result element
+   * @type {ElemBase}
+   */
+  getResult : function autoCompleteResults_getResult(index) {
+    return this.getElement({type: "result", value: index});
+  },
+  
+  /**
+   * Close the autocomplete popup
+   * 
+   * @param {boolean} force
+   *        Force the closing of the autocomplete popup
+   */
+  close : function autoCompleteResults_close(force) {
+    if (this.isOpened) {
+      if (force) {
+        this._popup.getNode().hidePopup();
+      } else {
+        this._controller.keypress(locationBar.urlbar, "VK_ESCAPE", {});
+      }
+      this._controller.waitFor(function () {
+          return !this.isOpened;
+      }, "Autocomplete list should not be open.");
+    }
+  }
+}
+
+/**
+ * Constructor
+ * 
+ * @param {MozmillController} controller
+ *        MozMillController of the window to operate on
+ */
+function locationBar(controller)
+{
+  this._controller = controller;
+  this._autoCompleteResults = new autoCompleteResults(controller);
+}
+
+/**
+ * Location Bar class
+ */
+locationBar.prototype = {
+  /**
+   * Returns the autocomplete object
+   *
+   * @returns Autocomplete object
+   * @type {object}
+   */
+  get autoCompleteResults() {
+    return this._autoCompleteResults;
+  },
+
+  /**
+   * Returns the controller of the current window
+   *
+   * @returns Mozmill controller
+   * @type {MozMillController}
+   */
+  get controller() {
+    return this._controller;
+  },
+
+  /**
+   * Returns the urlbar element
+   *
+   * @returns URL bar
+   * @type {ElemBase}
+   */
+  get urlbar() {
+    return this.getElement({type: "urlbar"});
+  },
+
+  /**
+   * Returns the currently shown URL
+   *
+   * @returns Text inside the location bar
+   * @type {string}
+   */
+  get value() {
+    return this.urlbar.getNode().value;
+  },
+
+  /**
+   * Clear the location bar
+   */
+  clear : function locationBar_clear() {
+    this.focus({type: "shortcut"});
+    this._controller.keypress(this.urlbar, "VK_DELETE", {});
+    this._controller.waitForEval("subject.value == ''",
+                                 TIMEOUT, 100, this.urlbar.getNode());
+  },
+
+  /**
+   * Close the context menu of the urlbar input field
+   */
+  closeContextMenu : function locationBar_closeContextMenu() {
+    var menu = this.getElement({type: "contextMenu"});
+    this._controller.keypress(menu, "VK_ESCAPE", {});
+  },
+
+  /**
+   * Check if the location bar contains the given text
+   *
+   * @param {string} text
+   *        Text which should be checked against
+   */
+  contains : function locationBar_contains(text) {
+    return this.urlbar.getNode().value.indexOf(text) != -1;
+  },
+
+  /**
+   * Focus the location bar
+   *
+   * @param {object} event
+   *        Focus the location bar with the given event (click or shortcut)
+   */
+  focus : function locationBar_focus(event) {
+    switch (event.type) {
+      case "click":
+        this._controller.click(this.urlbar);
+        break;
+      case "shortcut":
+        var cmdKey = utils.getEntity(this.getDtds(), "openCmd.commandkey");
+        this._controller.keypress(null, cmdKey, {accelKey: true});
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unkown event type - " + event.type);
+    }
+
+    // Wait until the location bar has been focused
+    this._controller.waitForEval("subject.getAttribute('focused') == 'true'",
+                                 TIMEOUT, 100, this.urlbar.getNode());
+  },
+
+  /**
+   * Gets all the needed external DTD urls as an array
+   *
+   * @returns Array of external DTD urls
+   * @type [string]
+   */
+  getDtds : function locationBar_getDtds() {
+    var dtds = ["chrome://branding/locale/brand.dtd",
+                "chrome://browser/locale/browser.dtd"];
+    return dtds;
+  },
+
+  /**
+   * Retrieve an UI element based on the given spec
+   *
+   * @param {object} spec
+   *        Information of the UI element which should be retrieved
+   *        type: General type information
+   *        subtype: Specific element or property
+   *        value: Value of the element or property
+   * @returns Element which has been created
+   * @type ElemBase
+   */
+  getElement : function locationBar_getElement(spec) {
+    var elem = null;
+
+    switch(spec.type) {
+      /**
+       * subtype: subtype to match
+       * value: value to match
+       */
+      case "contextMenu":
+        elem = new elementslib.Lookup(this._controller.window.document, CONTEXT_MENU);
+        break;
+      case "contextMenu_entry":
+        elem = new elementslib.Lookup(this._controller.window.document, CONTEXT_MENU +
+                                      '/{"cmd":"cmd_' + spec.subtype + '"}');
+        break;
+      case "favicon":
+        elem = new elementslib.ID(this._controller.window.document, "page-proxy-favicon");
+        break;
+      case "feedButton":
+        elem = new elementslib.ID(this._controller.window.document, "feed-button");
+        break;
+      case "goButton":
+        elem = new elementslib.ID(this._controller.window.document, "urlbar-go-button");
+        break;
+      case "historyDropMarker":
+        elem = new elementslib.Lookup(this._controller.window.document,
+                                      URLBAR_CONTAINER + '/id("urlbar")/anon({"anonid":"historydropmarker"})');
+        break;
+      case "identityBox":
+        elem = new elementslib.ID(this._controller.window.document, "identity-box");
+        break;
+      case "notification_element":
+        elem = new elementslib.Lookup(this._controller.window.document, NOTIFICATION_POPUP +
+                                      spec.subtype);
+        break;
+      case "notification_popup":
+        elem = new elementslib.Lookup(this._controller.window.document, NOTIFICATION_POPUP);
+        break;
+      case "starButton":
+        elem = new elementslib.ID(this._controller.window.document, "star-button");
+        break;
+      case "stopButton":
+        elem = new elementslib.ID(this._controller.window.document, "urlbar-stop-button");
+        break;
+      case "urlbar":
+        elem = new elementslib.ID(this._controller.window.document, "urlbar");
+        break;
+      case "urlbar_input":
+        elem = new elementslib.Lookup(this._controller.window.document, URLBAR_INPUTBOX +
+                                      '/anon({"anonid":"input"})');
+        break;
+      default:
+        throw new Error(arguments.callee.name + ": Unknown element type - " + spec.type);
+    }
+
+    return elem;
+  },
+
+  /**
+   * Retrieves the notification popup
+   * 
+   * @return The notification popup element
+   * @type {ElemBase}
+   */
+  getNotification : function locationBar_getNotification() { 
+    return this.getElement({type: "notification_popup"});
+  },
+
+  /**
+   * Retrieves the specified element of the door hanger notification bar
+   *
+   * @param {string} aType
+   *        Type of the notification bar to look for
+   * @param {string} aLookupString
+   *        Lookup string of the notification bar's child element
+   *        [optional - default: ""]
+   *
+   * @return The created element
+   * @type {ElemBase}
+   */
+  getNotificationElement : function locationBar_getNotificationElement(aType, aLookupString)
+  {
+    var lookup = '/id("' + aType + '")';
+    lookup = aLookupString ? lookup + aLookupString : lookup;
+
+    // Get the notification and fetch the child element if wanted
+    return this.getElement({type: "notification_element", subtype: lookup});
+  },
+
+  /**
+   * Load the given URL
+   *
+   * @param {string} url
+   *        URL of web page to load
+   */
+  loadURL : function locationBar_loadURL(url) {
+    this.focus({type: "shortcut"});
+    this.type(url);
+    this._controller.keypress(this.urlbar, "VK_RETURN", {});
+  },
+
+  /**
+   * Type the given text into the location bar
+   *
+   * @param {string} text
+   *        Text to enter into the location bar
+   */
+  type : function locationBar_type(text) {
+    this._controller.type(this.urlbar, text);
+    this.contains(text);
+  }
+}
+
+// Export of classes
+exports.locationBar = locationBar;
+exports.autoCompleteResults = autoCompleteResults;
+
diff --git a/common/tests/functional/shared-modules/utils.js b/common/tests/functional/shared-modules/utils.js
new file mode 100644 (file)
index 0000000..4e1c730
--- /dev/null
@@ -0,0 +1,445 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *   Anthony Hughes <ahughes@mozilla.com>
+ *   M.-A. Darche <mozdev@cynode.org>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The UtilsAPI offers various helper functions for any other API which is
+ * not already covered by another shared module.
+ *
+ * @version 1.0.3
+ */
+
+// Include required modules
+var prefs = require("prefs");
+
+const gTimeout = 5000;
+
+/**
+ * Get application specific informations
+ * @see http://mxr.mozilla.org/mozilla-central/source/xpcom/system/nsIXULAppInfo.idl
+ */
+var appInfo = {
+  _service: null,
+
+  /**
+   * Get the application info service
+   * @returns XUL runtime object
+   * @type nsiXULRuntime
+   */
+  get appInfo() {
+    if (!this._appInfo) {
+      this._service = Cc["@mozilla.org/xre/app-info;1"]
+                        .getService(Ci.nsIXULAppInfo)
+                        .QueryInterface(Ci.nsIXULRuntime);
+    }
+    return this._service;
+  },
+
+  /**
+   * Get the build id
+   * @returns Build id
+   * @type string
+   */
+  get buildID() this.appInfo.appBuildID,
+
+  /**
+   * Get the application id
+   * @returns Application id
+   * @type string
+   */
+  get ID() this.appInfo.ID,
+
+  /**
+   * Get the application name
+   * @returns Application name
+   * @type string
+   */
+  get name() this.appInfo.name,
+
+  /**
+   * Get the operation system
+   * @returns Operation system name
+   * @type string
+   */
+  get os() this.appInfo.OS,
+
+  /**
+   * Get the product vendor
+   * @returns Vendor name
+   * @type string
+   */
+  get vendor() this.appInfo.vendor,
+
+  /**
+   * Get the application version
+   * @returns Application version
+   * @type string
+   */
+  get version() this.appInfo.version,
+
+  /**
+   * Get the build id of the Gecko platform
+   * @returns Platform build id
+   * @type string
+   */
+  get platformBuildID() this.appInfo.platformBuildID,
+
+  /**
+   * Get the version of the Gecko platform
+   * @returns Platform version
+   * @type string
+   */
+  get platformVersion() this.appInfo.platformVersion,
+
+  /**
+   * Get the currently used locale
+   * @returns Current locale
+   * @type string
+   */
+  get locale() {
+    var registry = Cc["@mozilla.org/chrome/chrome-registry;1"]
+                     .getService(Ci.nsIXULChromeRegistry);
+    return registry.getSelectedLocale("global");
+  },
+
+  /**
+   * Get the user agent string
+   * @returns User agent
+   * @type string
+   */
+  get userAgent() {
+    var window = mozmill.wm.getMostRecentWindow("navigator:browser");
+    if (window)
+      return window.navigator.userAgent;
+    return "";
+  }
+};
+
+/**
+ * Checks the visibility of an element.
+ * XXX: Mozmill doesn't check if an element is visible and also operates on
+ * elements which are invisible. (Bug 490548)
+ *
+ * @param {MozmillController} controller
+ *        MozMillController of the window to operate on
+ * @param {ElemBase} elem
+ *        Element to check its visibility
+ * @param {boolean} expectedVisibility
+ *        Expected visibility state of the element
+ */
+function assertElementVisible(controller, elem, expectedVisibility) {
+  var element = elem.getNode();
+  var visible;
+
+  switch (element.nodeName) {
+    case 'panel':
+      visible = (element.state == 'open');
+      break;
+    default:
+      var style = controller.window.getComputedStyle(element, '');
+      var state = style.getPropertyValue('visibility');
+      visible = (state == 'visible');
+  }
+
+  controller.assertJS('subject.visible == subject.expectedVisibility', {
+    visible: visible,
+    expectedVisibility: expectedVisibility
+  });
+}
+
+/**
+ * Assert if the current URL is identical to the target URL.
+ * With this function also redirects can be tested.
+ *
+ * @param {MozmillController} controller
+ *        MozMillController of the window to operate on
+ * @param {string} targetURL
+ *        URL to check
+ */
+function assertLoadedUrlEqual(controller, targetUrl) {
+  var locationBar = new elementslib.ID(controller.window.document, "urlbar");
+  var currentURL = locationBar.getNode().value;
+
+  // Load the target URL
+  controller.open(targetUrl);
+  controller.waitForPageLoad();
+
+  // Check the same web page has been opened
+  controller.waitFor(function () { 
+    return locationBar.getNode().value === currentURL;
+  }, "Current URL should be identical to the target URL - got " +
+     locationBar.getNode().value + ", expected " + currentURL);
+}
+
+/**
+ * Close the context menu inside the content area of the currently open tab
+ *
+ * @param {MozmillController} controller
+ *        MozMillController of the window to operate on
+ */
+function closeContentAreaContextMenu(controller) {
+  var contextMenu = new elementslib.ID(controller.window.document, "contentAreaContextMenu");
+  controller.keypress(contextMenu, "VK_ESCAPE", {});
+}
+
+/**
+ * Run tests against a given search form
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the window to operate on
+ * @param {ElemBase} searchField
+ *        The HTML input form element to test
+ * @param {string} searchTerm
+ *        The search term for the test
+ * @param {ElemBase} submitButton
+ *        (Optional) The forms submit button
+ * @param {number} timeout
+ *        The timeout value for the single tests
+ */
+function checkSearchField(controller, searchField,
+                                                     searchTerm, submitButton,
+                                                     timeout) {
+  controller.waitThenClick(searchField, timeout);
+  controller.type(searchField, searchTerm);
+
+  if (submitButton != undefined) {
+    controller.waitThenClick(submitButton, timeout);
+  }
+}
+
+/**
+ * Create a new URI
+ *
+ * @param {string} spec
+ *        The URI string in UTF-8 encoding.
+ * @param {string} originCharset
+ *        The charset of the document from which this URI string originated.
+ * @param {string} baseURI
+ *        If null, spec must specify an absolute URI. Otherwise, spec may be
+ *        resolved relative to baseURI, depending on the protocol.
+ * @return A URI object
+ * @type nsIURI
+ */
+function createURI(spec, originCharset, baseURI)
+{
+  let iosvc = Cc["@mozilla.org/network/io-service;1"].
+              getService(Ci.nsIIOService);
+
+  return iosvc.newURI(spec, originCharset, baseURI);
+}
+
+/**
+ * Empty the clipboard by assigning an empty string
+ */
+function emptyClipboard() {
+  var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].
+                  getService(Ci.nsIClipboardHelper);
+  clipboard.copyString("");
+}
+
+/**
+ * Format a URL by replacing all placeholders
+ *
+ * @param {string} prefName
+ *        The preference name which contains the URL
+ * @return The formatted URL
+ * @type string
+ */
+function formatUrlPref(prefName) {
+  var formatter = Cc["@mozilla.org/toolkit/URLFormatterService;1"]
+                     .getService(Ci.nsIURLFormatter);
+
+  return formatter.formatURLPref(prefName);
+}
+
+/**
+ * Returns the default home page
+ *
+ * @return The URL of the default homepage
+ * @type string
+ */
+function getDefaultHomepage() {
+  var preferences = prefs.preferences;
+
+  var prefValue = preferences.getPref("browser.startup.homepage", "",
+                                      true, Ci.nsIPrefLocalizedString);
+  return prefValue.data;
+}
+
+/**
+ * Returns the value of an individual entity in a DTD file.
+ *
+ * @param [string] urls
+ *        Array of DTD urls.
+ * @param {string} entityId
+ *        The ID of the entity to get the value of.
+ *
+ * @return The value of the requested entity
+ * @type string
+ */
+function getEntity(urls, entityId) {
+  // Add xhtml11.dtd to prevent missing entity errors with XHTML files
+  urls.push("resource:///res/dtd/xhtml11.dtd");
+
+  // Build a string of external entities
+  var extEntities = "";
+  for (i = 0; i < urls.length; i++) {
+    extEntities += '<!ENTITY % dtd' + i + ' SYSTEM "' +
+                   urls[i] + '">%dtd' + i + ';';
+  }
+
+  var parser = Cc["@mozilla.org/xmlextras/domparser;1"]
+                  .createInstance(Ci.nsIDOMParser);
+  var header = '<?xml version="1.0"?><!DOCTYPE elem [' + extEntities + ']>';
+  var elem = '<elem id="elementID">&' + entityId + ';</elem>';
+  var doc = parser.parseFromString(header + elem, 'text/xml');
+  var elemNode = doc.querySelector('elem[id="elementID"]');
+
+  if (elemNode == null)
+    throw new Error(arguments.callee.name + ": Unknown entity - " + entityId);
+
+  return elemNode.textContent;
+}
+
+/**
+ * Returns the value of an individual property.
+ *
+ * @param {string} url
+ *        URL of the string bundle.
+ * @param {string} prefName
+ *        The property to get the value of.
+ *
+ * @return The value of the requested property
+ * @type string
+ */
+function getProperty(url, prefName) {
+  var sbs = Cc["@mozilla.org/intl/stringbundle;1"]
+            .getService(Ci.nsIStringBundleService);
+  var bundle = sbs.createBundle(url);
+
+  try {
+    return bundle.GetStringFromName(prefName);
+  } catch (ex) {
+    throw new Error(arguments.callee.name + ": Unknown property - " + prefName);
+  }
+}
+
+/**
+ * Function to handle non-modal windows
+ *
+ * @param {string} type
+ *        Specifies how to check for the new window (possible values: type or title)
+ * @param {string} text
+ *        The window type of title string to search for
+ * @param {function} callback (optional)
+ *        Callback function to call for window specific tests
+ * @param {boolean} close (optional - default: true)
+ *        Make sure the window is closed after the return from the callback handler
+ * @returns The MozMillController of the window (if the window hasn't been closed)
+ */
+function handleWindow(type, text, callback, close) {
+  // Set the window opener function to use depending on the type
+  var func_ptr = null;
+  switch (type) {
+    case "type":
+      func_ptr = mozmill.utils.getWindowByType;
+      break;
+    case "title":
+      func_ptr = mozmill.utils.getWindowByTitle;
+      break;
+    default:
+      throw new Error(arguments.callee.name + ": Unknown opener type - " + type);
+  }
+
+  var window = null;
+  var controller = null;
+
+  try {
+    // Wait until the window has been opened
+    mozmill.utils.waitFor(function () {
+      window = func_ptr(text);
+      return window != null;
+    }, "Window has been found.");
+
+    // XXX: We still have to find a reliable way to wait until the new window
+    // content has been finished loading. Let's wait for now.
+    controller = new mozmill.controller.MozMillController(window);
+    controller.sleep(200);
+
+    if (callback) {
+      callback(controller);
+    }
+
+    // Check if we have to close the window
+    if (close === undefined)
+      close = true;
+
+    if (close && window) {
+      controller.window.close();
+      mozmill.utils.waitFor(function () {
+        return func_ptr(text) != window;
+      }, "Window has been closed.");
+
+      window = null;
+      controller = null;
+    }
+
+    return controller;
+  } catch (ex) {
+    if (window)
+      window.close();
+
+    throw ex;
+  }
+}
+
+// Export of variables
+exports.appInfo = appInfo;
+
+// Export of functions
+exports.assertElementVisible = assertElementVisible;
+exports.assertLoadedUrlEqual = assertLoadedUrlEqual;
+exports.closeContentAreaContextMenu = closeContentAreaContextMenu;
+exports.checkSearchField = checkSearchField;
+exports.createURI = createURI;
+exports.formatUrlPref = formatUrlPref;
+exports.emptyClipboard = emptyClipboard;
+exports.getDefaultHomepage = getDefaultHomepage;
+exports.getEntity = getEntity;
+exports.getProperty = getProperty;
+exports.handleWindow = handleWindow;
diff --git a/common/tests/functional/shared-modules/widgets.js b/common/tests/functional/shared-modules/widgets.js
new file mode 100644 (file)
index 0000000..6b796ee
--- /dev/null
@@ -0,0 +1,82 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is MozMill Test code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Henrik Skupin <hskupin@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * @fileoverview
+ * The WidgetsAPI adds support for handling objects like trees.
+ */
+
+var EventUtils = {};
+Components.utils.import('resource://mozmill/stdlib/EventUtils.js', EventUtils);
+
+const gTimeout = 5000;
+
+/**
+ * Click the specified tree cell
+ *
+ * @param {MozMillController} controller
+ *        MozMillController of the browser window to operate on
+ * @param {tree} tree
+ *        Tree to operate on
+ * @param {number } rowIndex
+ *        Index of the row
+ * @param {number} columnIndex
+ *        Index of the column
+ * @param {object} eventDetails
+ *        Details about the mouse event
+ */
+function clickTreeCell(controller, tree, rowIndex, columnIndex, eventDetails)
+{
+  tree = tree.getNode();
+
+  var selection = tree.view.selection;
+  selection.select(rowIndex);
+  tree.treeBoxObject.ensureRowIsVisible(rowIndex);
+
+  // get cell coordinates
+  var x = {}, y = {}, width = {}, height = {};
+  var column = tree.columns[columnIndex];
+  tree.treeBoxObject.getCoordsForCellItem(rowIndex, column, "text",
+                                           x, y, width, height);
+
+  controller.sleep(0);
+  EventUtils.synthesizeMouse(tree.body, x.value + 4, y.value + 4,
+                             eventDetails, tree.ownerDocument.defaultView);
+  controller.sleep(0);
+}
+
+// Export of functions
+exports.clickTreeCell = clickTreeCell;
diff --git a/common/tests/functional/testAboutPage.js b/common/tests/functional/testAboutPage.js
new file mode 100644 (file)
index 0000000..2dd5cfc
--- /dev/null
@@ -0,0 +1,18 @@
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+};
+
+var testAboutPage_WhenOpened_PageIsLoadedWithExpectedTitle = function () {
+    const ABOUT_PAGE_URL = "about:pentadactyl";
+    const EXPECTED_TITLE = "About Pentadactyl";
+    const BLANK_PAGE_URL = "about:blank";
+
+    controller.open(BLANK_PAGE_URL);
+    controller.waitForPageLoad(controller.tabs.activeTab);
+    controller.open(ABOUT_PAGE_URL);
+    controller.waitForPageLoad(controller.tabs.activeTab);
+
+    controller.assert(function () controller.tabs.activeTab.title === EXPECTED_TITLE);
+};
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/testCommands.js b/common/tests/functional/testCommands.js
new file mode 100644 (file)
index 0000000..59724b5
--- /dev/null
@@ -0,0 +1,904 @@
+// Runs a slew of generic command tests
+
+var utils = require("utils");
+const { module } = utils;
+var dactyllib = module("dactyl");
+var jumlib = module("resource://mozmill/modules/jum.js");
+
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+    dactyl = new dactyllib.Controller(controller);
+
+    dactyl.modules.options["autocomplete"] = [];
+    dactyl.modules.options["wildmode"] = ["list"];
+
+    dactyl.modules.prefs.set("browser.tabs.closeWindowWithLastTab", false);
+    dactyl.elements.multilineContainer.setAttribute("moz-collapsed", "true");
+};
+var teardownModule = function (module) {
+    dactyl.elements.multilineContainer.removeAttribute("moz-collapsed");
+    dactyl.teardown();
+}
+
+function $(selector) controller.window.document.querySelector(selector);
+
+function hasNItems(nItems)
+    function hasNItems(context) {
+        utils.assertEqual("testCommand.hasNItems", nItems, context.allItems.items.length);
+    };
+
+function hasItems(context) context.allItems.items.length;
+
+function hasntNullItems(context) hasItems(context) &&
+    !context.allItems.items.some(function ({ text, description }) [text, description].some(function (text) /^\[object/.test(text)));
+
+function sidebarState(state)
+    function sidebarState() {
+        utils.assertEqual("testCommand.sidebarState", state,
+                          typeof state == "string" ? $("#sidebar-title").value
+                                                   : !$("#sidebar-box").hidden);
+    };
+function toolbarState(selector, state)
+    function toolbarState() {
+        utils.assertEqual("testCommand.toolbarState", state, !$(selector).collapsed)
+    };
+
+var tests = {
+    "!": {
+        multiOutput: ["echo foo"]
+    },
+    abbreviate: {
+        someOutput: ["", "abc"],
+        noOutput: ["abc def", "-js abc def"],
+        completions: ["", "abc ", "-js abc "]
+    },
+    addons: {
+        multiOutput: ["", "dactyl", "-type=extension", "-type=extension dactyl"],
+        completions: [
+            "",
+            ["-types=", hasItems]
+        ]
+    },
+    autocmd: {
+        multiOutput: ["", "DOMLoad", "DOMLoad foo"],
+        noOutput: ["DOMLoad foo bar", "-js DOMLoad foo bar"],
+        completions: [
+            ["", hasntNullItems],
+            "DOMLoad foo ",
+            "-js DOMLoad foo "
+        ]
+    },
+    back: { noOutput: [""] },
+    bdelete: {
+        init: ["tabopen about:pentadactyl", "tabopen about:pentadactyl"],
+        noOutput: [""],
+        anyOutput: ["about:pentadactyl"],
+        completions: [["", hasItems]]
+    },
+    bmark: {
+        singleOutput: ["", "-tags=foo -title=bar -keyword=baz -charset=UTF-8 -post=quux about:pentadactyl"],
+        error: ["-tags=foo -title=bar -keyword=baz -charset=nonExistentCharset -post=quux about:pentadactyl"],
+        completions: [
+            "-max=1 -keyword=",
+            "-max=1 -keyword=foo -tags=",
+            "-max=1 -keyword=foo -tags=bar -title=",
+            ["-max=1 -keyword=foo -tags=bar -title=baz -charset=", hasItems],
+            "-max=1 -keyword=foo -tags=bar -title=baz -charset= about:"
+        ]
+    },
+    bmarks: {
+        multiOutput: ["-max=1", "-max=1 -keyword=foo -tags=bar -title=baz about:pentadactyl"],
+        completions: [
+            "-max=1 -keyword=",
+            "-max=1 -keyword=foo -tags=",
+            "-max=1 -keyword=foo -tags=bar -title=",
+            "-max=1 -keyword=foo -tags=bar -title=baz about:"
+        ]
+    },
+    buffer: {
+        anyOutput: ["", "1"],
+        noOutput: ["!", "! 1"],
+        completions: [
+            ["", hasItems],
+            ["1", hasItems]
+        ]
+    },
+    buffers: {
+        multiOutput: ["", "1"],
+        completions: ["", "1"]
+    },
+    cd: {
+        singleOutput: ["", "~/"],
+        completions: ["", "~/"]
+    },
+    colorscheme: {
+        error: ["", "some-nonexistent-scheme"]
+    },
+    command: {
+        init: ["delc!"],
+        singleOutput: ["", "foobar"],
+        noOutput: ["foo bar", "-js bar baz"],
+        multiOutput: [""],
+        error: [
+            "foo bar",
+            "-js bar baz",
+            "-group=builtin baz quux",
+            "! -group=builtin baz quux",
+        ],
+        completions: [
+            ["", hasItems],
+            ["-group=", hasItems],
+            ["-group=user ", hasItems]
+        ]
+    },
+    contexts: {}, // Not testable in this manner
+    cookies: {
+        anyOutput: ["dactyl.sf.net", "dactyl.sf.net list"],
+        error: [""],
+        completions: [
+            "",
+            ["dactyl.sf.net ", hasItems]
+        ]
+    },
+    delbmarks: { anyOutput: ["", "about:pentadactyl"] },
+    delcommand: [
+        {
+            init: ["delcommand!", "command foo bar"],
+            error: [""],
+            completions: [
+                ["", hasItems],
+                ["-group=", hasItems],
+                ["-group=user ", hasItems]
+            ],
+            noOutput: ["foo", "! "]
+        },
+        {
+            init: ["delcommand!"],
+            error: ["foo"]
+        }
+    ],
+    delmacros: {
+        error: [""],
+        noOutput: ["x"],
+        completions: ["", "x"]
+    },
+    get delmarks() this.delmacros,
+    get delqmarks() this.delmacros,
+    delstyle: {
+        completions: ["", "-name=", "-name=foo ", "-index=", "-index="]
+    },
+    dialog: {
+        // Skip implementation for now
+        completions: [
+            ["", hasntNullItems]
+        ]
+    },
+    doautoall: {}, // Skip for now
+    doautocmd: {}, // Skip for now
+    downloads: {
+        multiOutput: ["", "dactyl", "dactyl"]
+    },
+    echo: {
+        singleOutput: [
+            ["' - '", " - "]
+        ],
+        multiOutput: [
+            ["'\\n'", /\n/],
+            ["window", /\[object\sChromeWindow\]/]
+        ],
+        completions: [
+            "",
+            "window",
+            "window.",
+            "window['",
+            "commands.get('"
+        ]
+    },
+    get echoerr() ({
+        errorsOk: true,
+        __proto__: this.echo,
+    }),
+    get echomsg() this.echo,
+    else: {}, // Skip for now
+    elseif: {}, // Skip for now
+    emenu: {
+        noOutput: ["View.Zoom.Zoom In", "View.Zoom.Zoom Out"],
+        error: [""],
+        completions: [
+            ["", hasItems],
+            ["View.", hasItems]
+        ]
+    },
+    endif: {}, // Skip for now
+    execute: {
+        noOutput: ["", "'js " + "".quote() + "'"],
+        someOutput: ["'ls'"],
+        completions: [["", hasItems]]
+    },
+    extadd: {
+        completions: [["", hasItems]],
+        error: [""]
+    },
+    extdelete: {
+        completions: [["", hasItems]],
+        error: [""]
+    },
+    get extdisable() this.extdelete,
+    extenable: {
+        completions: [""],
+        error: [""]
+    },
+    extoptions: {
+        completions: [""],
+        error: [""]
+    },
+    get extrehash() this.extdelete,
+    get exttoggle() this.extdelete,
+    get extupdate() this.extdelete,
+    feedkeys: {
+        noOutput: ["<Esc>"],
+        error: [""]
+    },
+    finish: { noOutput: [""] },
+    forward: { noOutput: [""] },
+    frameonly: { noOutput: [""] },
+    delgroup: {
+        error: ["builtin"],
+        completions: [""]
+    },
+    group: {
+        multiOutput: [""],
+        noOutput: [
+            "foo -d='foo group' -nopersist -l 'bar.com','http://bar/*','http://bar','^http:'",
+            "! foo -d='foo group' -nopersist -l 'bar.com','http://bar/*','http://bar','^http:'",
+            "foo",
+            "user"
+        ],
+        error: ["builtin"],
+        completions: [
+            "",
+            "foo "
+        ],
+        cleanup: ["delmapgroup foo"]
+    },
+    hardcopy: {}, // Skip for now
+    help: {
+        noOutput: ["", "intro"],
+        cleanup: ["tabdelete", "tabdelete"],
+        completions: [
+            ["", hasItems],
+            ["'wild", hasItems]
+        ]
+    },
+    get helpall() this.help,
+    highlight: {
+        multiOutput: ["", "Help"],
+        noOutput: [
+            "Help foo: bar;",
+            "Help -group=FontCode",
+            "Help -group=FontCode foo: bar;"
+        ],
+        completions: [
+            ["", hasItems],
+            ["Help", hasItems],
+            ["Help ", hasItems],
+            ["Help -group=", hasItems],
+            ["Help -group=FontCode ", hasItems],
+            ["Help foo: bar; -moz", hasItems]
+        ]
+    },
+    history: {
+        init: ["open about:pentadactyl"],
+        anyOutput: ["-max=1", "-max=1 -sort=+date", "-max=1 dactyl"],
+        completions: [
+            ["", hasItems],
+            "about:",
+            ["-sort=+", hasItems],
+            ["-sort=-", hasItems],
+            ["-sort=+date ", hasItems],
+            "-sort=+date about:"
+        ]
+    },
+    if: {}, // Skip for now
+    javascript: {
+        noOutput: ["''", "'\\n'", "<pre>foo bar</pre>", "window"],
+        completions: [
+            ["", hasItems],
+            ["window", hasItems],
+            ["window.", hasItems],
+            ["window['", hasItems],
+            ["File('", hasItems],
+            ["File.expandPath('", hasItems],
+            "autocommands.user.get('",
+            ["commands.get('", hasItems],
+            ["commands.builtin.get('", hasItems],
+            ["highlight.get('", hasItems],
+            ["highlight.highlightNode(null, '", hasItems],
+            ["mappings.get(modes.NORMAL, '", hasItems],
+            // ["mappings.builtin.get(modes.NORMAL, '", hasItems],
+            ["options.get('", hasItems],
+            ["prefs.get('", hasItems],
+            ["prefs.defaults.get('", hasItems],
+            ["localPrefs.get('", hasItems],
+            ["localPrefs.defaults.get('", hasItems],
+            ["styles.system.get('", hasItems],
+        ]
+    },
+    jumps: {
+        multiOutput: [""]
+    },
+    keepalt: {
+        error: [""],
+        noOutput: ["js ''"],
+        anyOutput: ["echo 'foo'"]
+    },
+    let: {}, // Deprecated. Fuck it.
+    listcommands: {
+        anyOutput: ["", "in"],
+        completions: [
+            ["", hasItems],
+            "in "
+        ]
+    },
+    get listkeys() this.listcommands,
+    get listoptions() this.listcommands,
+    loadplugins: {},
+    macros: {
+        multiOutput: [""],
+        completions: [""]
+    },
+    map: {
+        init: ["unmap!"],
+        anyOutput: [""],
+        singleOutput: ["i"],
+        noOutput: [
+            "i j",
+            "-builtin i j",
+            "-group=user -b i j",
+            "-js i j()",
+            "-ex i :j",
+            "-silent i :j",
+            "-mode=ex -b <C-a> <C-a>"
+        ],
+        multiOutput: ["", "i"],
+        error: [
+            "-mode=some-nonexistent-mode <C-a> <C-a>",
+            "-group=some-nonexistent-group <C-a> <C-a>",
+            "-group=builtin <C-a> <C-a>"
+        ],
+        completions: [
+            ["", hasItems],
+            ["-", hasItems],
+            ["-mode=ex ", hasItems],
+            ["-mode=", hasItems],
+            ["-group=", hasItems],
+            ["-builtin i ", hasItems],
+            ["-ex i ", hasItems],
+            ["-javascript i ", hasItems]
+        ]
+    },
+    mark: {
+        error: ["", "#", "xy"],
+        noOutput: ["y"],
+        completions: [""]
+    },
+    marks: {
+        init: ["delmarks q"],
+        multiOutput: ["", "y"],
+        error: ["q", "#"],
+        completions: [""]
+    },
+    messages: {
+        anyOutput: ["messages"]
+    },
+    messclear: {
+        error: ["q"],
+        noOutput: [""]
+    },
+    mkpentadactylrc: {
+        noOutput: [
+            "some-nonexistent-rc.penta",
+            "! some-nonexistent-rc.penta"
+        ],
+        error: ["some-nonexistent-rc.penta"],
+        completions: [""],
+        cleanup: ["silent !rm some-nonexistent-rc.penta"]
+    },
+    mksyntax: {
+        noOutput: [
+            "some-nonexistent-pentadactyl-dir/",
+            "! some-nonexistent-pentadactyl-dir/",
+            "some-nonexistent-pentadactyl-dir/foo.vim",
+            "! some-nonexistent-pentadactyl-dir/foo.vim",
+        ],
+        error: [
+            "some-nonexistent-pentadactyl-dir/",
+            "some-nonexistent-pentadactyl-dir/foo.vim"
+        ],
+        completions: [
+            ["", hasItems]
+        ],
+        cleanup: ["silent !rm -r some-nonexistent-pentadactyl-dir/"]
+    },
+    normal: {
+        noOutput: ["<Nop>"],
+        singleOutput: ["<C-g>"],
+        multiOutput: ["g<C-g>"]
+    },
+    open: {
+        noOutput: ["about:blank | about:home"],
+        completions: [
+            ["", hasItems],
+            ["./", hasItems],
+            ["./ | ", hasItems], // FIXME: broken feature
+            ["chrome://", hasItems],
+            ["chrome://browser/", hasItems],
+            ["chrome://browser/content/", hasItems],
+            ["about:", hasItems],
+            ["resource://", hasItems],
+            ["resource://dactyl/", hasItems]
+        ]
+    },
+    pageinfo: {
+        multiOutput: ["", "fgm"],
+        completions: [["", hasItems]],
+        error: ["abcdefghijklmnopqrstuvwxyz", "f g m"]
+    },
+    pagestyle: {
+        completions: [""]
+    },
+    preferences: {}, // Skip for now
+    pwd: {
+        singleOutput: [""]
+    },
+    qmark: {
+        singleOutput: [
+            "m",
+            "m foo bar"
+        ],
+        error: ["", "#"],
+        completions: [
+            ["", hasItems],
+            ["m ", hasItems]
+        ]
+    },
+    qmarks: [
+        {
+            init: ["delqmarks a-zA-Z0-9"],
+            error: ["", "x"],
+        },
+        {
+            init: ["qmark x"],
+            multiOutput: ["", "m", "x"],
+            completions: [["", hasItems]]
+        }
+    ],
+    quit: {}, // Skip for now
+    quitall: {}, // Skip for now
+    redraw: {
+        noOutput: [""]
+    },
+    rehash: {}, // Skip for now
+    reload: {
+        noOutput: [""]
+    },
+    reloadall: {
+        noOutput: [""]
+    },
+    restart: {}, // Skip
+    runtime: {
+        init: [
+            "js File('~/.pentadactyl/some-nonexistent/good.css').write('')",
+            "js File('~/.pentadactyl/some-nonexistent/good.js').write('')",
+            "js File('~/.pentadactyl/some-nonexistent/bad.js').write('dactyl.echoerr(\"error\")')",
+            "js File('~/.pentadactyl/some-nonexistent/good.penta').write('')",
+            "js File('~/.pentadactyl/some-nonexistent/bad.penta').write('echoerr \"error\"')",
+        ],
+        cleanup: ["js File('~/.pentadactyl/some-nonexistent').remove(true)"],
+        noOutput: [
+            "some-nonexistent/good.css",
+            "some-nonexistent/good.js",
+            "some-nonexistent/good.penta"
+        ],
+        error: [
+            "some-nonexistent/bad.js",
+            "some-nonexistent/bad.penta"
+        ],
+        singleOutput: ["some-nonexistent-file.js"],
+        completions: [
+            ["", hasItems],
+            ["some-nonexistent/", hasItems],
+            ["info/", hasItems]
+        ]
+    },
+    sanitize: {
+        // Skip details for now.
+        completions: [
+            ["", function (context) ["all",
+                                     "cache",
+                                     "downloads",
+                                     "formdata",
+                                     "offlineapps",
+                                     "passwords",
+                                     "sessions",
+                                     "cookies",
+                                     "history",
+                                     "host",
+                                     "sitesettings",
+                                     "commandline",
+                                     "messages",
+                                     "macros",
+                                     "marks",
+                                     "options"
+                ].every(function (item) context.allItems.items.some(function ({ text }) item == text))
+            ],
+            "-",
+            "-host=",
+            "-timespan="
+        ]
+    },
+    saveas: {},
+    sbclose: {
+        noOutput: [""]
+    },
+    scriptnames: {},
+    set: {
+        multiOutput: [
+            "vb?", "cpt?", "messages?", "titlestring?", "au?", "eht?",
+            "cpt", "messages", "titlestring", "au", "eht"
+        ],
+        noOutput: ["vb", "novb"],
+        completions: [
+            ["", hasItems],
+            ["c", hasItems],
+            ["cpt=", hasItems],
+            ["cpt=l", hasItems],
+            ["cpt+=", hasItems],
+            ["cpt+=f", hasItems],
+            ["activate=", hasItems],
+            ["activate=links,", hasItems],
+            ["activate+=", hasItems],
+            ["activate+=links,", hasItems],
+            ["activate^=", hasItems],
+            ["activate^=links,", hasItems],
+            ["activate-=", hasItems],
+            ["activate-=links,", hasItems],
+            ["activate!=", hasItems],
+            ["activate!=links,", hasItems]
+        ]
+    },
+    get setglobal() this.set,
+    get setlocal() this.set,
+    sidebar: {
+        error: ["!", ""],
+        test: function (name) [
+            ["! " + name, sidebarState(name)],
+            [name, sidebarState(name)],
+            ["! " + name, sidebarState(false)]
+        ],
+        get noOutput()
+            Array.concat.apply([],
+                ["Add-ons", // Final "! Add-ons" currently failing
+                 "Bookmarks",
+                 "Downloads",
+                 "Console",
+                 "History",
+                 "Preferences"]
+            .map(this.test))
+            .concat([
+                ["Preferences", sidebarState("Preferences")],
+                ["!", sidebarState(false)]
+            ]),
+        completions: [
+            ["", hasntNullItems],
+            "! "
+        ]
+    },
+    silent: {
+        noOutput: [
+            "echo 'foo'",
+            "echo " + "foo\nbar".quote(),
+            "echoerr 'foo'",
+            "echoerr " + "foo\nbar".quote()
+        ],
+        completions: [["", hasItems]]
+    },
+    get source() ({
+        init: this.runtime.init,
+        cleanup: this.runtime.cleanup,
+        noOutput: [
+            "! .pentadactyl/some-nonexistent/really-nonexistent.js",
+            ".pentadactyl/some-nonexistent/good.css",
+            ".pentadactyl/some-nonexistent/good.js",
+            ".pentadactyl/some-nonexistent/good.penta"
+        ],
+        error: [
+            ".pentadactyl/some-nonexistent/really-nonexistent.js",
+            "~/.pentadactyl/some-nonexistent/bad.js",
+            "~/.pentadactyl/some-nonexistent/bad.penta",
+            "./.pentadactyl/some-nonexistent/bad.js",
+            "./.pentadactyl/some-nonexistent/bad.penta",
+            ".pentadactyl/some-nonexistent/bad.js",
+            ".pentadactyl/some-nonexistent/bad.penta",
+            ".pentadactyl/some-nonexistent-file.js"
+        ],
+        completions: [
+            ["", hasItems],
+            [".pentadactyl/some-nonexistent/", hasItems],
+            ["chrome://browser/content/", hasItems],
+            ["resource://dactyl/", hasItems]
+        ]
+    }),
+    stop: { noOutput: [""] },
+    stopall: { noOutput: [""] },
+    style: {
+        cleanup: ["delstyle -n foo"],
+        noOutput: [
+            "-name=foo http://does.not.exist/* div { display: inline; }",
+            "-name=foo -append http://does.not.exist/* span { display: block; }"
+        ],
+        multiOutput: [
+            "",
+            "-name=foo"
+        ],
+        completions: [
+            ["", hasItems],
+            ["-name=", hasItems],
+            ["http:* div { -moz", hasItems],
+            ["http:* div { foo: bar; -moz", hasItems],
+            ["http:* div { foo: bar; } span { -moz", hasItems],
+            ["http:* div { foo: bar; } span { foo: bar; -moz", hasItems]
+        ]
+    },
+    styledisable: {
+        init: ["style -n foo http:* div {}", "style -n bar ftp:* div", "styledisable -n bar"],
+        cleanup: ["delstyle -n foo", "delstyle -n bar"],
+        completions: [
+            ["", hasItems],
+            ["-name=", hasNItems(1)],
+            ["-index=", hasNItems(1)]
+        ],
+        noOutput: ["-name=foo", "-name=bar"]
+    },
+    get styleenable() this.styledisable,
+    styletoggle: {
+        init: ["style -n foo http:* div {}", "style -n bar ftp:* div", "styledisable -n bar"],
+        cleanup: ["delstyle -n foo", "delstyle -n bar"],
+        noOutput: ["-name=foo"],
+        completions: [
+            ["", hasItems],
+            ["-name=", hasNItems(2)],
+            ["-index=", hasNItems(2)]
+        ]
+    },
+    tab: {},
+    tabattach: {},
+    tabdetach: {},
+    tabdo: {},
+    tabduplicate: {},
+    tablast: {},
+    tabmove: {},
+    tabnext: {},
+    tabonly: {},
+    tabopen: {},
+    tabprevious: {},
+    tabrewind: {},
+    time: {},
+    toolbarhide: {
+        init: [
+            ["tbs Navigation Toolbar", toolbarState("#nav-bar", true)],
+            ["tbs Bookmarks Toolbar", toolbarState("#PersonalToolbar", true)]
+        ],
+        completions: [["", hasItems]],
+        noOutput: [
+            ["Navigation Toolbar", toolbarState("#nav-bar", false)],
+            ["Bookmarks Toolbar", toolbarState("#PersonalToolbar", false)]
+        ],
+        error: ["", "foo"]
+    },
+    toolbarshow: {
+        completions: [["", hasItems]],
+        noOutput: [
+            ["Navigation Toolbar", toolbarState("#nav-bar", true)],
+            ["Bookmarks Toolbar", toolbarState("#PersonalToolbar", true)]
+        ],
+        error: ["", "foo"]
+    },
+    toolbartoggle: {
+        completions: [["", hasItems]],
+        noOutput: [
+            ["Navigation Toolbar", toolbarState("#nav-bar", false)],
+            ["Bookmarks Toolbar", toolbarState("#PersonalToolbar", false)],
+            ["Navigation Toolbar", toolbarState("#nav-bar", true)],
+            ["Bookmarks Toolbar", toolbarState("#PersonalToolbar", true)],
+            ["Navigation Toolbar", toolbarState("#nav-bar", false)],
+            ["Bookmarks Toolbar", toolbarState("#PersonalToolbar", false)]
+        ],
+        error: ["", "foo"]
+    },
+    unabbreviate: {
+        noOutput: ["abc", "! "],
+        error: [""]
+    },
+    undo: {},
+    undoall: {},
+    unlet: {},
+    unmap: {
+        noOutput: [
+            "i",
+            "! "
+        ],
+        error: [
+            "i",
+            "-group=builtin k",
+            "! -group=builtin"
+        ],
+        completions: [
+            "",
+            "-group="
+        ]
+    },
+    verbose: {},
+    version: {
+        multiOutput: [
+            ["", function (msg) {
+                var res = /(\w+dactyl) (\S+) \(([\^)]+)\) running on:\nMozilla/;
+                return res && res[2] != "null" && res[3] != "null";
+            }]
+        ]
+    },
+    viewsource: {},
+    winclose: {},
+    window: {},
+    winonly: {},
+    winopen: {},
+    wqall: {},
+    yank: {
+        multiOutput: [
+            ["foo".quote(), /foo/],
+            [":echo " + "bar".quote(), /bar/],
+            [":addons", /Pentadactyl/]
+        ],
+        error: [
+            ":echoerr " + "foo".quote()
+        ],
+        completions: [
+            ["", hasItems],
+            [":", hasItems]
+        ]
+    },
+    zoom: {}
+};
+
+var global = this;
+function addTest(cmdName, testName, func) {
+    global["testCommand_" + cmdName + "_" + testName] = func;
+}
+
+function runCommands(cmdName, testName, commands, test, forbidErrors) {
+    addTest(cmdName, testName, function () {
+        commands.forEach(function (val) {
+            var [cmd, testVal] = Array.concat(val);
+
+            dump("CMD: " + testName + " " + cmdName + " " + cmd + "\n");
+            dactyl.clearMessage();
+            dactyl.closeMessageWindow();
+
+            cmd = cmdName + cmd.replace(/^(!?) ?/, "$1 ");
+            if (forbidErrors)
+                dactyl.assertNoErrorMessages(function () { dactyl.runExCommand(cmd) },
+                                             null, [], cmd);
+            else
+                dactyl.runExCommand(cmd);
+            controller.waitForPageLoad(controller.tabs.activeTab);
+
+            test(cmd, testVal);
+        });
+    });
+}
+function _runCommands(cmdName, testName, commands) {
+    addTest(cmdName, testName, function () {
+        commands.forEach(function (value) {
+            var [cmd, test] = Array.concat(value);
+
+            dump("CMD: " + testName + " " + cmdName + " " + cmd + "\n");
+            var res = dactyl.runExCommand(cmd);
+            controller.waitForPageLoad(controller.tabs.activeTab);
+            runTest("Initializing for " + cmdName + " tests failed: " + cmd.quote() + " " + test,
+                    test);
+        });
+    });
+}
+
+function runTest(message, test) {
+    if (test)
+        var res = test.apply(null, Array.slice(arguments, runTest.length));
+    if (res !== undefined)
+        jumlib.assert(res, message);
+}
+
+for (var val in Iterator(tests)) (function ([command, paramsList]) {
+    Array.concat(paramsList).forEach(function (params, i) {
+        if (params.init)
+            _runCommands(command, "init" + (i || ""), params.init);
+
+        // Goddamn stupid fucking MozMill and its stupid fucking sandboxes with their ancient fucking JS versions.
+        for (var val in Iterator(params)) (function ([test, commands]) {
+            var testName = test + (i || "");
+
+            switch (test) {
+            case "noOutput":
+                runCommands(command, testName, commands, function (cmd, test) {
+                    var res = dactyl.assertMessage(function (msg) !msg, "Unexpected command output: " + cmd);
+                    if (res && test)
+                        dactyl.assertMessage(test, "Running " + testName + " tests failed: " + cmd.quote() + " " + test.toSource());
+                });
+                break;
+            case "anyOutput":
+                runCommands(command, testName, commands, function (cmd, test) {
+                    if (test)
+                        dactyl.assertMessage(test, "Running " + testName + " tests failed: " + cmd.quote() + " " + test.toSource());
+                });
+                break;
+            case "someOutput":
+                runCommands(command, testName, commands, function (cmd, test) {
+                    var res = dactyl.assertMessage(/./, "Expected command output: " + cmd);
+                    if (res && test != null)
+                        dactyl.assertMessage(test, "Running " + testName + " tests failed: " + cmd.quote() + " " + test.toSource());
+                });
+                break;
+            case "singleOutput":
+                runCommands(command, testName, commands, function (cmd, test) {
+                    var res = dactyl.assertMessageLine(/./, "Expected command output: " + cmd);
+                    if (res && test != null)
+                        dactyl.assertMessageLine(test, "Running " + testName + " tests failed: " + cmd.quote() + " " + test.toSource());
+                }, !params.errorsOk);
+                break;
+            case "multiOutput":
+                runCommands(command, testName, commands, function (cmd, test) {
+                    var res = dactyl.assertMessageWindowOpen(true, "Expected command output: " + cmd);
+                    if (res && test != null)
+                        dactyl.assertMessageWindow(test, "Running " + testName + " tests failed: " + cmd.quote() + " " + test.toSource());
+                }, !params.errorsOk);
+                break;
+            case "error":
+                addTest(command, testName, function () {
+                    commands.forEach(function (val) {
+                        var [cmd, test] = Array.concat(val);
+                        cmd = command + cmd.replace(/^(!?) ?/, "$1 ");
+
+                        var res = dactyl.assertMessageError(function () {
+                            dactyl.runExCommand(cmd);
+                            controller.waitForPageLoad(controller.tabs.activeTab);
+                        }, null, [], cmd);
+
+                        if (res && test != null)
+                            dactyl.assertMessage(test, "Running " + testName + " tests failed: " + cmd.quote() + " " + test.toSource());
+                    });
+                });
+                break;
+            case "completions":
+                addTest(command, testName, function () {
+                    commands.forEach(function (val) {
+                        var [cmd, test] = Array.concat(val);
+                        cmd = command + cmd.replace(/^(!?) ?/, "$1 ");
+
+                        dactyl.assertNoErrorMessages(function () {
+                            dump("COMPL: " + cmd + "\n");
+                            var context = dactyl.runExCompletion(cmd);
+                            if (context)
+                                runTest("Completion tests failed: " + cmd.quote() + " " + test,
+                                        test, context);
+                        });
+                    });
+                });
+                break;
+            }
+        })(val);
+
+        if (params.cleanup)
+            _runCommands(command, "cleanup" + (i || ""), params.cleanup);
+    });
+})(val);
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/testEchoCommands.js b/common/tests/functional/testEchoCommands.js
new file mode 100644 (file)
index 0000000..39d4f08
--- /dev/null
@@ -0,0 +1,78 @@
+var dactyllib = require("utils").module("dactyl");
+
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+    dactyl = new dactyllib.Controller(controller);
+};
+
+var teardownModule = function (module) {
+    dactyl.teardown();
+}
+
+var teardownTest = function (test) {
+    dactyl.closeMessageWindow();
+};
+
+var testEchoCommand_SingleLineMessageAndClosedMOW_MessageDisplayedInMessageLine = function () {
+    const output = "foobar";
+
+    assertEchoGeneratesLineOutput({
+        ECHO_COMMAND: "echo " + output.quote(),
+        EXPECTED_OUTPUT: output
+    });
+};
+
+var testEchoCommand_SingleLineMessageAndOpenMOW_MessageAppendedToMOW = function () {
+    const output = "foobar";
+
+    dactyl.openMessageWindow();
+
+    assertEchoGeneratesWindowOutput({
+        ECHO_COMMAND: "echo " + output.quote(),
+        EXPECTED_OUTPUT: RegExp(output)
+    });
+};
+
+var testEchoCommand_MultilineMessageAndClosedMOW_MessageDisplayedInMOW = function () {
+    const output = "foo\nbar";
+
+    assertEchoGeneratesWindowOutput({
+        ECHO_COMMAND: "echo " + output.quote(),
+        EXPECTED_OUTPUT: output
+    });
+};
+
+var testEchoCommand_MultilineMessageAndOpenMOW_MessageAppendedToMOW = function () {
+    const output = "foo\nbar";
+
+    dactyl.openMessageWindow();
+
+    assertEchoGeneratesWindowOutput({
+        ECHO_COMMAND: "echo " + output.quote(),
+        EXPECTED_OUTPUT: RegExp(output)
+    });
+};
+
+var testEchoCommand_ObjectArgumentAndClosedMOW_MessageDisplayedInMOW = function () {
+    assertEchoGeneratesWindowOutput({
+        ECHO_COMMAND: "echo var obj = { x: 1, y: 2 }; obj;",
+        EXPECTED_OUTPUT: "[object\u00A0Object]::\nx: 1\ny: 2\n"
+    });
+};
+
+function executeCommand(command) {
+    dactyl.runViCommand(":" + command);
+    dactyl.runViCommand([["VK_RETURN"]]);
+}
+
+function assertEchoGeneratesWindowOutput({ ECHO_COMMAND, EXPECTED_OUTPUT }) {
+    executeCommand(ECHO_COMMAND);
+    dactyl.assertMessageWindow(EXPECTED_OUTPUT);
+}
+
+function assertEchoGeneratesLineOutput({ ECHO_COMMAND, EXPECTED_OUTPUT }) {
+    executeCommand(ECHO_COMMAND);
+    dactyl.assertMessageLine(EXPECTED_OUTPUT);
+}
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/testFindCommands.js b/common/tests/functional/testFindCommands.js
new file mode 100644 (file)
index 0000000..b1ac821
--- /dev/null
@@ -0,0 +1,49 @@
+var dactyllib = require("utils").module("dactyl");
+
+const FIND_TEST_PAGE = collector.addHttpResource("./data/") + "find.html";
+
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+    dactyl = new dactyllib.Controller(controller);
+};
+
+var teardownModule = function (module) {
+    dactyl.teardown();
+}
+
+var setupTest = function (test) {
+    controller.open(FIND_TEST_PAGE);
+    controller.waitForPageLoad(controller.tabs.activeTab);
+    controller.sleep(1000);
+};
+
+var testFindCommand_PresentAlphabeticText_TextSelected = function () {
+    assertTextFoundInPage("letter")
+};
+
+var testFindCommand_PresentNumericText_TextSelected = function () {
+    assertTextFoundInPage("3.141")
+};
+
+var testFindCommand_MissingText_ErrorMessageDisplayed = function () {
+    const MISSING_TEXT = "8c307545a017f60add90ef08955e148e";
+    const PATTERN_NOT_FOUND_ERROR = "E486: Pattern not found: " + MISSING_TEXT;
+
+    runTextSearchCommand(MISSING_TEXT);
+
+    dactyl.assertErrorMessage(PATTERN_NOT_FOUND_ERROR);
+};
+
+function runTextSearchCommand(str) {
+    dactyl.runViCommand("/" + str);
+    dactyl.runViCommand([["VK_RETURN"]]);
+
+    controller.sleep(0);
+}
+
+function assertTextFoundInPage(text) {
+    runTextSearchCommand(text);
+    dactyl.assertSelection(text);
+}
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/testHelpCommands.js b/common/tests/functional/testHelpCommands.js
new file mode 100644 (file)
index 0000000..dd0afe9
--- /dev/null
@@ -0,0 +1,87 @@
+const utils = require("utils");
+const { module } = utils;
+
+var jumlib = module("resource://mozmill/modules/jum.js");
+var dactyllib = module("dactyl");
+
+const { Services } = module("resource://gre/modules/Services.jsm");
+
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+    dactyl = new dactyllib.Controller(controller);
+};
+
+var teardownModule = function (module) {
+    dactyl.teardown();
+}
+
+var setupTest = function (test) {
+    dactyl.runViCommand([["VK_ESCAPE"]]);
+};
+
+function urlTarget(url) Services.io.newChannel(url, null, null).name;
+
+__defineGetter__("doesNotExist", function () {
+    delete this.doesNotExist;
+    return this.doesNotExist = urlTarget("dactyl://help-tag/non-existent-help-tag-url-thingy");
+});
+
+const HELP_FILES = ["all", "tutorial", "intro", "starting", "browsing",
+    "buffer", "cmdline", "editing", "options", "pattern", "tabs", "hints",
+    "map", "eval", "marks", "repeat", "autocommands", "print", "gui",
+    "styling", "message", "privacy", "developer", "various", "plugins", "faq",
+    "versions", "index"];
+
+var testViHelpCommand_OpensIntroHelpPage = function () {
+    assertHelpOpensPageWithTag({
+        HELP_COMMAND: function () { dactyl.runViCommand([["VK_F1"]]); },
+        EXPECTED_HELP_TAG: "intro.xml"
+    });
+};
+
+var testViHelpAllCommand_OpensAllHelpPage = function () {
+    assertHelpOpensPageWithTag({
+        HELP_COMMAND: function () { dactyl.runViCommand([["VK_F1", { altKey: true }]]); },
+        EXPECTED_HELP_TAG: "all.xml"
+    });
+};
+
+var testExHelpCommand_NoArgs_OpensIntroHelpPage = function () {
+    assertHelpOpensPageWithTag({
+        HELP_COMMAND: function () { dactyl.runExCommand("help"); },
+        EXPECTED_HELP_TAG: "intro.xml"
+    });
+};
+
+var testExHelpAllCommand_NoArgs_OpensAllHelpPage = function () {
+    assertHelpOpensPageWithTag({
+        HELP_COMMAND: function () { dactyl.runExCommand("helpall"); },
+        EXPECTED_HELP_TAG: "all.xml"
+    });
+};
+
+var testExHelpCommand_PageTagArg_OpensHelpPageContainingTag = function () {
+    for (let [, file] in Iterator(HELP_FILES)) {
+        let tag = file + ".xml";
+        assertHelpOpensPageWithTag({
+            HELP_COMMAND: function () { dactyl.runExCommand("help " + tag); },
+            EXPECTED_HELP_TAG: tag
+        });
+
+        let links = controller.tabs.activeTab.querySelectorAll("a[href^='dactyl:']");
+
+        let missing = Array.filter(links, function (link) urlTarget(link.href) === doesNotExist)
+                           .map(function (link) link.textContent + " -> " + link.href);
+
+        utils.assertEqual("testHelpCommands.assertNoDeadLinks", 0, missing.length,
+                          "Found dead links in " + tag + ": " + missing.join(", "));
+    }
+};
+
+function assertHelpOpensPageWithTag({ HELP_COMMAND, EXPECTED_HELP_TAG }) {
+    HELP_COMMAND();
+    controller.waitForPageLoad(controller.tabs.activeTab);
+    controller.assertNode(new elementslib.ID(controller.tabs.activeTab, EXPECTED_HELP_TAG));
+}
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/testOptions.js b/common/tests/functional/testOptions.js
new file mode 100644 (file)
index 0000000..c268743
--- /dev/null
@@ -0,0 +1,40 @@
+// Runs a slew of generic option tests
+
+var utils = require("utils");
+const { module } = utils;
+
+var dactyllib = module("dactyl");
+var jumlib = module("resource://mozmill/modules/jum.js");
+
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+    dactyl = new dactyllib.Controller(controller);
+};
+var teardownModule = function (module) {
+    dactyl.teardown();
+}
+
+function $(selector) controller.window.document.querySelector(selector);
+
+function testDefaultValidators() {
+    for (var option in dactyl.modules.options)
+        dactyl.assertNoErrors(function () {
+            dactyl.assertNoErrorMessages(function () {
+                dump("OPT VAL " + option.name + "\n");
+                utils.assert("testOptions.testValidators", option.validator(option.value),
+                             "Option '" + option.name + "' validator failed");
+            });
+        });
+}
+
+var options = {};
+
+function testCompleters() {
+    for (var option in dactyl.modules.options)
+        for (var [,value] in Iterator([""].concat(options[option.name] || []))) {
+            dump("OPT COMP " + option.name + " " + value + "\n");
+            dactyl.testCompleter(option, "completer", value, "Option '" + option.name + "' completer failed");
+        }
+}
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/testShellCommands.js b/common/tests/functional/testShellCommands.js
new file mode 100644 (file)
index 0000000..de4eb2b
--- /dev/null
@@ -0,0 +1,37 @@
+var dactyllib = require("utils").module("dactyl");
+
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+    dactyl = new dactyllib.Controller(controller);
+};
+
+var teardownModule = function (module) {
+    dactyl.teardown();
+}
+
+var teardownTest = function (test) {
+    dactyl.closeMessageWindow();
+};
+
+var testRunCommand_ExecutingOutputCommand_OutputDisplayed = function () {
+    const EXPECTED_OUTPUT = "foobar";
+    const COMMAND = "run echo " + EXPECTED_OUTPUT;
+
+    dactyl.runExCommand(COMMAND);
+
+    dactyl.assertMessageWindow(RegExp(EXPECTED_OUTPUT));
+};
+
+var testRunCommand_RepeatArg_LastCommandRepeated = function () {
+    const EXPECTED_OUTPUT = /foobar$/; // XXX
+    const COMMAND = "run echo 'foobar'";
+    const REPEAT_COMMAND = "run!";
+
+    dactyl.runExCommand(COMMAND);
+    dactyl.closeMessageWindow();
+    dactyl.runExCommand(REPEAT_COMMAND);
+
+    dactyl.assertMessageWindow(EXPECTED_OUTPUT);
+};
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/testVersionCommand.js b/common/tests/functional/testVersionCommand.js
new file mode 100644 (file)
index 0000000..78a2f02
--- /dev/null
@@ -0,0 +1,38 @@
+var dactyllib = require("utils").module("dactyl");
+
+var setupModule = function (module) {
+    controller = mozmill.getBrowserController();
+    dactyl = new dactyllib.Controller(controller);
+};
+
+var teardownModule = function (module) {
+    dactyl.teardown();
+}
+
+var setupTest = function (test) {
+    dactyl.closeMessageWindow();
+};
+
+var testVersionCommand_NoArg_VersionStringDisplayed = function () {
+    const EXPECTED_OUTPUT = RegExp(dactyl.applicationName + ".+ (.+) running on:.+"); // XXX
+
+    dactyl.runExCommand("version");
+
+    dactyl.assertMessageWindow(EXPECTED_OUTPUT);
+};
+
+var testVersionCommand_BangArg_HostAppVersionPageDisplayed = function () {
+    const EXPECTED_URL = "about:";
+    const EXPECTED_TITLE = "About:";
+    const BLANK_PAGE_URL = "about:blank";
+
+    controller.open(BLANK_PAGE_URL);
+    controller.waitForPageLoad(controller.tabs.activeTab);
+    dactyl.runExCommand("version!");
+    controller.waitForPageLoad(controller.tabs.activeTab);
+
+    controller.assert(function () controller.tabs.activeTab.location.href === EXPECTED_URL);
+    controller.assert(function () controller.tabs.activeTab.title === EXPECTED_TITLE);
+};
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/utils.js b/common/tests/functional/utils.js
new file mode 100644 (file)
index 0000000..0354553
--- /dev/null
@@ -0,0 +1,5 @@
+
+// Work around these horrendous Sandbox issues.
+Components.utils.import(/([^ ]+\/)[^\/]+$/.exec(Components.stack.filename)[1] + "utils.jsm", exports);
+
+// vim: sw=4 ts=8 et:
diff --git a/common/tests/functional/utils.jsm b/common/tests/functional/utils.jsm
new file mode 100644 (file)
index 0000000..126359c
--- /dev/null
@@ -0,0 +1,48 @@
+
+var EXPORTED_SYMBOLS = ["NS", "assert", "assertEqual", "module", "test", "toJSON"];
+
+const Ci = Components.interfaces;
+
+function module(uri) {
+    if (!/^[a-z-]+:/.exec(uri))
+        uri = /([^ ]+\/)[^\/]+$/.exec(Components.stack.caller.filename)[1] + uri + ".jsm";
+
+    let obj = {};
+    Components.utils.import(uri, obj);
+    return obj;
+}
+
+var elementslib = module("resource://mozmill/modules/elementslib.js");
+var frame = module("resource://mozmill/modules/frame.js");
+var jumlib = module("resource://mozmill/modules/jum.js");
+
+function toJSON(val) {
+    if (typeof val == "function")
+        return val.toSource();
+    if (val instanceof Ci.nsIDOMNode || val instanceof Ci.nsIDOMWindow)
+        return { DOMNode: String(val) };
+    return val;
+}
+
+function test(val, params) {
+    frame.events[val ? "pass" : "fail"](params);
+    return val;
+}
+
+var NS = Namespace("dactyl", "http://vimperator.org/namespaces/liberator");
+
+function assert(funcName, value, comment)
+    test(value, {
+        function: funcName,
+        value: toJSON(value),
+        comment: toJSON(comment)
+    });
+
+function assertEqual(funcName, want, got, comment)
+    test(want == got, {
+        function: funcName,
+        want: toJSON(want), got: toJSON(got),
+        comment: toJSON(comment)
+    });
+
+// vim: sw=4 ts=8 et ft=javascript:
diff --git a/melodactyl/AUTHORS b/melodactyl/AUTHORS
new file mode 100644 (file)
index 0000000..4c5be98
--- /dev/null
@@ -0,0 +1,6 @@
+Main developer/Project founder:
+  * Prathyush Thota <prathyushthota@gmail.com>
+
+Developers:
+  * Martin Stubenschrott (stubenschrott@vimperator.org)
+  * Doug Kearns (dougkearns@gmail.com)
diff --git a/melodactyl/Makefile b/melodactyl/Makefile
new file mode 100644 (file)
index 0000000..ed90a96
--- /dev/null
@@ -0,0 +1,11 @@
+#### configuration
+
+NAME          = melodactyl
+
+SONGBIRD     ?= songbird
+HOSTAPP      ?= $(SONGBIRD)
+PROFILEPATHS ?= "$$HOME/.songbird2" \
+               "$$HOME/Library/Songbird2" \
+               "$$APPDATA/Songbird2"
+
+include ../common/Makefile
diff --git a/melodactyl/NEWS b/melodactyl/NEWS
new file mode 100755 (executable)
index 0000000..2bbbde9
--- /dev/null
@@ -0,0 +1,18 @@
+0.1a1pre:
+    * :playerprev and :playernext and equivalent mappings now accept a count.
+    * :displaypane arguments changed to "leftservice", "bottomservice",
+      "bottomcontent" or "rightsidebar"
+    * asciidoc is no longer required to build Melodactyl
+    * The help system is newly modularized
+    * add :silent
+    * add $MY_MELODACTYLRC
+    * add ' and " local marks
+    * add w and W Normal mode mappings for symmetry with o/O and t/T
+    * add :messclear
+    * add 'maxitems'
+    * :dialog {subscribe|newsmartplaylist}
+    * add :displaypane and :dpclose
+    * rename :filter to :queue and :Filter to :filter
+    * add 'repeat' and 'shuffle'
+    * add 'jsdebugger' option - switch on/off javascript debugger service
+    * add "addons", "downloads", "extoptions" and "help" to the 'activate' option.
diff --git a/melodactyl/TODO b/melodactyl/TODO
new file mode 100644 (file)
index 0000000..af6f4ff
--- /dev/null
@@ -0,0 +1,25 @@
+Priority list:
+1-9 as in Vim (9 = required for next release, 5 = would be nice, 1 = probably not)
+
+BUGS:
+- broken commands:
+  - :tabduplicate
+  - :open songbird-internal-search searchString (broken because SB intercepts queries to this engine)
+  - <C-^> only works between browser tabs not the media tab
+  - numbered tabs
+
+FEATURES:
+9 Clean up NEWS
+9 Bookmarks are now only supported in SB via an addon.
+9 '?' - Reusing '/'.
+8 Merge with SongBird's sanitizer implementation.
+8 Playlist/SmartPlaylist operations & meta-data operations.
+8 Sort-View commands.
+7 Queue view.
+7 j/k - for browsing through the visible view.
+7 extended hint mode for opening links in FF.
+6 :tqueue, :lqueue etc.
+5 make Player mode commands work in the Mini-Player.
+5 Check for default extensions and add commands for them. Ex. Last.fm, Seeqpod e.t.c
+    Wouldn't these be provided as Melodactyl plugins like Pentadactyl does? --djk
+5 tagging tracks for manipulation by commands
diff --git a/melodactyl/chrome.manifest b/melodactyl/chrome.manifest
new file mode 120000 (symlink)
index 0000000..4032143
--- /dev/null
@@ -0,0 +1 @@
+../common/chrome.manifest
\ No newline at end of file
diff --git a/melodactyl/components/commandline-handler.js b/melodactyl/components/commandline-handler.js
new file mode 120000 (symlink)
index 0000000..aa8427b
--- /dev/null
@@ -0,0 +1 @@
+../../common/components/commandline-handler.js
\ No newline at end of file
diff --git a/melodactyl/components/protocols.js b/melodactyl/components/protocols.js
new file mode 120000 (symlink)
index 0000000..7c25b74
--- /dev/null
@@ -0,0 +1 @@
+../../common/components/protocols.js
\ No newline at end of file
diff --git a/melodactyl/content/config.js b/melodactyl/content/config.js
new file mode 100644 (file)
index 0000000..0270203
--- /dev/null
@@ -0,0 +1,329 @@
+// Copyright (c) 2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2009 by Prathyush Thota <prathyushthota@gmail.com>
+// Copyright (c) 2009-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2009-2011 by Kris Maglione <maglione.k@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+Components.utils.import("resource://gre/modules/utils.js"); // XXX: PlacesUtils
+
+const Config = Module("config", ConfigBase, {
+    name: "melodactyl",
+    appName: "Melodactyl",
+    idName: "MELODACTYL",
+    host: "Songbird",
+    hostbin: "songbird",
+
+    commandContainer: "mainplayer",
+
+    Local: function Local(dactyl, modules, window) let ({ config } = modules, { document } = window) {
+        init: function init() {
+            init.supercall(this);
+
+            // TODO: mention this to SB devs, they seem keen to provide these
+            // functions to make porting from FF as simple as possible.
+            window.toJavaScriptConsole = function () {
+                toOpenWindowByType("global:console", "chrome://global/content/console.xul");
+            };
+        },
+
+        // FIXME: unless I'm seeing double in in the wee small hours gBrowser is
+        // first set from getBrowser which they've deprecated in FF.
+        get browser() window.getBrowser(),
+        get tabbrowser() window.getBrowser(),
+
+        dialogs: {
+            about: ["About Songbird",
+                function () { window.openDialog("chrome://songbird/content/xul/about.xul", "_blank", "chrome,dialog,modal,centerscreen"); }],
+            addons: ["Manage Add-ons",
+                function () { window.SBOpenPreferences("paneAddons"); }],
+            checkupdates: ["Check for updates",
+                function () { window.checkForUpdates(); }],
+            cookies: ["List your cookies",
+                function () { window.toOpenWindowByType("Browser:Cookies", "chrome://browser/content/preferences/cookies.xul", "chrome,dialog=no,resizable"); }],
+            console: ["JavaScript console",
+                function () { window.toJavaScriptConsole(); }],
+            dominspector: ["DOM Inspector",
+                function () { window.inspectDOMDocument(window.content.document); },
+                function () "inspectDOMDocument" in window],
+            downloads: ["Manage Downloads",
+                function () { window.toOpenWindowByType("Download:Manager", "chrome://mozapps/content/downloads/downloads.xul", "chrome,dialog=no,resizable"); }],
+            newsmartplaylist: ["Open the file selector dialog",
+                function () { window.SBNewSmartPlaylist(); }],
+            openfile: ["Open the file selector dialog",
+                function () { window.SBFileOpen(); }],
+            pagesource: ["View page source",
+                function () { window.BrowserViewSourceOfDocument(window.content.document); }],
+            preferences: ["Show Songbird preferences dialog",
+                function () { window.openPreferences(); }],
+            printsetup: ["Setup the page size and orientation before printing",
+                function () { window.PrintUtils.showPageSetup(); }],
+            print: ["Show print dialog",
+                function () { window.PrintUtils.print(); }],
+            saveframe: ["Save frame to disk",
+                function () { window.saveFrameDocument(); }],
+            savepage: ["Save page to disk",
+                function () { window.saveDocument(window.content.document); }],
+            searchengines: ["Manage installed search engines",
+                function () { window.openDialog("chrome://browser/content/search/engineManager.xul", "_blank", "chrome,dialog,modal,centerscreen"); }],
+            selectionsource: ["View selection source",
+                function () { modules.buffer.viewSelectionSource(); }],
+            subscribe: ["Add a new subscription",
+                function () { window.SBSubscribe(); }]
+        },
+
+        // TODO: clean this up
+        focusChange: function (win) {
+            const { modes } = modules;
+
+            // Switch to -- PLAYER -- mode for Songbird Media Player.
+            if (this.isPlayerWindow)
+                modes.set(modes.PLAYER);
+            else
+                if (modes.main == modes.PLAYER)
+                    modes.pop();
+        },
+
+        get isPlayerWindow() window.SBGetBrowser().mCurrentTab == window.SBGetBrowser().mediaTab,
+
+        /**
+         * Shows or hides the main service pane.
+         *
+         * @param {boolean} value Show the service pane if true, hide it if false.
+         */
+        showServicePane: function (value) {
+            const key = "splitter.servicepane_splitter.was_collapsed";
+            window.gServicePane.open = value;
+            window.SBDataSetBoolValue(key, window.gServicePane.open);
+        },
+
+        /**
+         * Opens the display panel with the specified *id*.
+         *
+         * @param {string} id The ID of the display pane.
+         */
+        openDisplayPane: function (id) {
+            if (id == "servicepane")
+                this.showServicePane(true);
+            else {
+                let pane = document.getElementById(id);
+                let manager = services.displayPaneManager;
+                let paneinfo = manager.getPaneInfo(pane._lastURL.stringValue);
+
+                if (!paneinfo)
+                    paneinfo = manager.defaultPaneInfo;
+
+                pane.loadContent(paneinfo);
+            }
+        },
+
+        /**
+         * Closes the display panel with the specified *id*
+         *
+         * @param {string} id The ID of the display pane.
+         */
+        closeDisplayPane: function (id) {
+            if (id == "servicepane")
+                this.showServicePane(false);
+            else
+                document.getElementById(id).hide();
+        }
+    },
+
+    /*** optional options, there are checked for existence and a fallback provided  ***/
+    features: set(["bookmarks", "hints", "marks", "history", "quickmarks", "session", "tabs", "player"]),
+
+    defaults: {
+        guioptions: "bCmprs",
+        showtabline: 2,
+        get titlestring() config.name
+    },
+
+    guioptions: {
+        m: ["Menubar",         ["main-menubar"]],
+        T: ["Toolbar",         ["nav-bar"]],
+        p: ["Player controls", ["player_wrapper"]]
+    },
+
+    overlayChrome: ["chrome://purplerain/content/xul/mainplayer.xul"],
+
+    styleableChrome: ["chrome://purplerain/content/xul/mainplayer.xul"],
+
+    autocommands: {
+        BookmarkAdd: "Triggered after a page is bookmarked",
+        ColorScheme: "Triggered after a color scheme has been loaded",
+        DOMLoad: "Triggered when a page's DOM content has fully loaded",
+        DownloadPost: "Triggered when a download has completed",
+        Fullscreen: "Triggered when the browser's fullscreen state changes",
+        LocationChange: "Triggered when changing tabs or when navigation to a new location",
+        PageLoadPre: "Triggered after a page load is initiated",
+        PageLoad: "Triggered when a page gets (re)loaded/opened",
+        ShellCmdPost: "Triggered after executing a shell command with :!cmd",
+        TrackChangePre: "Triggered before a playing track is changed",
+        TrackChange: "Triggered after a playing track has changed",
+        ViewChangePre: "Triggered before a sequencer view is changed",
+        ViewChange: "Triggered after a sequencer view is changed",
+        StreamStart: "Triggered after a stream has started",
+        StreamPause: "Triggered after a stream has paused",
+        StreamEnd: "Triggered after a stream has ended",
+        StreamStop: "Triggered after a stream has stopped",
+        Enter: "Triggered after Songbird starts",
+        LeavePre: "Triggered before exiting Songbird, just before destroying each module",
+        Leave: "Triggered before exiting Songbird"
+    },
+
+    completers: Class.memoize(function () update({
+        displaypane: "displayPane",
+        playlist: "playlist",
+        mediaview: "mediaView",
+        mediasort: "mediaListSort",
+        song: "song"
+    }, this.__proto__.completers)),
+
+    hasTabbrowser: true,
+
+    removeTab: function (tab) {
+        if (config.tabbrowser.mTabs.length > 1)
+            config.tabbrowser.removeTab(tab);
+        else {
+            if (buffer.URL != "about:blank" || window.getWebNavigation().sessionHistory.count > 0) {
+                dactyl.open("about:blank", dactyl.NEW_BACKGROUND_TAB);
+                config.tabbrowser.removeTab(tab);
+            }
+            else
+                dactyl.beep();
+        }
+    },
+
+    scripts: [
+        "browser",
+        "bookmarks",
+        "history",
+        "quickmarks",
+        "tabs",
+        "player",
+        "library"
+    ],
+
+    sidebars: {
+        viewAddons:      ["Add-ons",     "A", "chrome://mozapps/content/extensions/extensions.xul"],
+        viewConsole:     ["Console",     "C", "chrome://global/content/console.xul"],
+        viewDownloads:   ["Downloads",   "D", "chrome://mozapps/content/downloads/downloads.xul"],
+        viewPreferences: ["Preferences", "P", "about:config"]
+    },
+
+    // FIXME: tab arg and media tab exception?
+    stop: function (tab) {
+        window.SBGetBrowser().mCurrentBrowser.stop();
+    }
+}, {
+    /**
+     * @property {object} A map of display pane command argument strings to
+     *     panel element IDs.
+     */
+    displayPanes: {
+        "leftservice"  : "servicepane",
+        "bottomcontent": "displaypane_contentpane_bottom",
+        "bottomservice": "displaypane_servicepane_bottom",
+        "rightsidebar" : "displaypane_right_sidebar"
+    }
+}, {
+    commands: function initCommands(dactyl, modules, window) {
+        const { commands, completion, options } = modules;
+
+        commands.add(["dpcl[ose]"],
+            "Close a display pane",
+            function (args) {
+                let arg = args.literalArg;
+                dactyl.assert(arg in Config.displayPanes, _("error.invalidArgument", arg));
+                config.closeDisplayPane(Config.displayPanes[arg]);
+            },
+            {
+                argCount: "1",
+                completer: function (context) completion.displayPane(context),
+                literal: 0
+            });
+
+        // TODO: this should accept a second arg to specify content
+        commands.add(["displayp[ane]", "dp[ane]", "dpope[n]"],
+            "Open a display pane",
+            function (args) {
+                let arg = args.literalArg;
+                dactyl.assert(arg in Config.displayPanes, _("error.invalidArgument", arg));
+                // TODO: focus when we have better key handling of these extended modes
+                config.openDisplayPane(Config.displayPanes[arg]);
+            },
+            {
+                argCount: "1",
+                completer: function (context) completion.displayPane(context),
+                literal: 0
+            });
+
+        commands.add(["pref[erences]", "prefs"],
+            "Show " + config.host + " preferences",
+            function (args) {
+                if (args.bang) { // open Songbird settings GUI dialog
+                    dactyl.open("about:config",
+                        (options["newtab"] && options.get("newtab").has("all", "prefs"))
+                                ? dactyl.NEW_TAB : dactyl.CURRENT_TAB);
+                }
+                else
+                    window.openPreferences();
+            },
+            {
+                argCount: "0",
+                bang: true
+            });
+    },
+    completion: function initCompletion(dactyl, modules, window) {
+        const completion = require("completion");
+
+        completion.displayPane = function (context) {
+            context.title = ["Display Pane"];
+            context.completions = Config.displayPanes; // FIXME: useful description etc
+        };
+    },
+    modes: function initModes(dactyl, modules, window) {
+        const { modes } = modules;
+
+        this.ignoreKeys = {
+            "<Return>": modes.NORMAL | modes.INSERT,
+            "<Space>": modes.NORMAL | modes.INSERT,
+            "<Up>": modes.NORMAL | modes.INSERT,
+            "<Down>": modes.NORMAL | modes.INSERT
+        };
+
+        modes.addMode("PLAYER", {
+            char: "p"
+        });
+    },
+    options: function initOptions(dactyl, modules, window) {
+        const { options } = modules;
+
+        // TODO: SB doesn't explicitly support an offline mode. Should we? --djk
+        options.add(["online"],
+            "Set the 'work offline' option",
+            "boolean", true,
+            {
+                setter: function (value) {
+                    const ioService = services.io;
+                    ioService.offline = !value;
+                    prefs.set("browser.offline", ioService.offline);
+                    return value;
+                },
+                getter: function () !services.io.offline
+            });
+    },
+    services: function initServices(dactyl, modules, window) {
+        services.add("displayPaneManager",      "@songbirdnest.com/Songbird/DisplayPane/Manager;1",         Ci.sbIDisplayPaneManager);
+        services.add("mediaPageManager",        "@songbirdnest.com/Songbird/MediaPageManager;1",            Ci.sbIMediaPageManager);
+        services.add("propertyManager",         "@songbirdnest.com/Songbird/Properties/PropertyManager;1",  Ci.sbIPropertyManager);
+
+        services.addClass("mutablePropertyArray", "@songbirdnest.com/Songbird/Properties/MutablePropertyArray;1",
+                          Ci.sbIMutablePropertyArray);
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/melodactyl/content/library.js b/melodactyl/content/library.js
new file mode 100644 (file)
index 0000000..be88474
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (c) 2009 by Prathyush Thota <prathyushthota@gmail.com>
+// Copyright (c) 2009 by Doug Kearns <dougkearns@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+// TODO: flesh this out
+const Library = Module("library", {
+    init: function () {
+        this.MAIN_LIBRARY = LibraryUtils.mainLibrary;
+    },
+
+    /**
+     * Converts an XPCOM enumerator to a JavaScript array.
+     *
+     * @param {nsISimpleEnumerator|nsIStringEnumerator|nsIArray} enum The enumerator to convert.
+     * @returns {Array}
+     */
+    _toJSArray: function _toJSArray(enum) ArrayConverter.JSArray(enum),
+
+    /**
+     * Returns an array of all the artist names in the main library.
+     *
+     * @returns {string[]}
+     */
+    getArtists: function getArtists() this._toJSArray(this.MAIN_LIBRARY.getDistinctValuesForProperty(SBProperties.artistName)),
+
+    // FIXME: Prathyush do we really want to remove duplicates? If so, why not tracks too? --djk
+    /**
+     * Returns an array of all the album names for *artist* in the main
+     * library.
+     *
+     * @param {string} artist The artist's name.
+     * @returns {string[]}
+     */
+    getAlbums: function getAlbums(artist) {
+        let albums = this._toJSArray(this.MAIN_LIBRARY.getItemsByProperty(SBProperties.artistName, artist))
+                         .map(function (track) track.getProperty(SBProperties.albumName));
+        return array.uniq(albums);
+    },
+
+    /**
+     * Returns an array of all the track names for *artist* and *album* in the
+     * main library.
+     *
+     * @param {string} artist The artist's name.
+     * @param {string} album The album's name.
+     * @returns {string[]}
+     */
+    getTracks: function getTracks(artist, album) {
+        let properties = services.MutablePropertyArray();
+
+        properties.appendProperty(SBProperties.artistName, artist);
+        properties.appendProperty(SBProperties.albumName, album);
+
+        return this._toJSArray(this.MAIN_LIBRARY.getItemsByProperties(properties))
+                   .map(function (track) track.getProperty(SBProperties.trackName));
+    }
+}, {
+}, {
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/melodactyl/content/logo.png b/melodactyl/content/logo.png
new file mode 100644 (file)
index 0000000..e907ad0
Binary files /dev/null and b/melodactyl/content/logo.png differ
diff --git a/melodactyl/content/player.js b/melodactyl/content/player.js
new file mode 100644 (file)
index 0000000..8eb8f11
--- /dev/null
@@ -0,0 +1,832 @@
+// Copyright (c) 2009 by Prathyush Thota <prathyushthota@gmail.com>
+// Copyright (c) 2009 by Doug Kearns <dougkearns@gmail.com>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+const Player = Module("player", {
+    init: function init() {
+        this._lastSearchString = "";
+        this._lastSearchIndex = 0;
+        this._lastSearchView = this._currentView; //XXX
+
+        // Get the focus to the visible playlist first
+        //window._SBShowMainLibrary();
+
+        gMM.addListener(this._mediaCoreListener);
+    },
+
+    destroy: function destroy() {
+        gMM.removeListener(this._mediaCoreListener);
+    },
+
+    /**
+     * Moves the track position *interval* milliseconds forwards or backwards.
+     *
+     * @param {number} interval The time interval (ms) to move the track
+     *     position.
+     * @param {boolean} direction The direction in which to move the track
+     *     position, forward if true otherwise backwards.
+     * @private
+     */
+    _seek: function _seek(interval, direction) {
+        let position = gMM.playbackControl ? gMM.playbackControl.position : 0;
+        player.seekTo(position + (direction ? interval : -interval));
+    },
+
+    /**
+     * Listens for media core events and in response dispatches the appropriate
+     * autocommand events.
+     * @private
+     */
+    _mediaCoreListener: {
+        onMediacoreEvent: function (event) {
+            switch (event.type) {
+                case Ci.sbIMediacoreEvent.BEFORE_TRACK_CHANGE:
+                    dactyl.log("Before track changed: " + event.data);
+                    autocommands.trigger("TrackChangePre", { track: event.data });
+                    break;
+                case Ci.sbIMediacoreEvent.TRACK_CHANGE:
+                    autocommands.trigger("TrackChange", { track: event.data });
+                    break;
+                case Ci.sbIMediacoreEvent.BEFORE_VIEW_CHANGE:
+                    dactyl.log("Before view changed: " + event.data);
+                    autocommands.trigger("ViewChangePre", { view: event.data });
+                    break;
+                case Ci.sbIMediacoreEvent.VIEW_CHANGE:
+                    dactyl.log("View changed: " + event.data);
+                    autocommands.trigger("ViewChange", { view: event.data });
+                    break;
+                case Ci.sbIMediacoreEvent.STREAM_START:
+                    dactyl.log("Track started: " + gMM.sequencer.currentItem);
+                    autocommands.trigger("StreamStart", { track: gMM.sequencer.currentItem });
+                    break;
+                case Ci.sbIMediacoreEvent.STREAM_PAUSE:
+                    dactyl.log("Track paused: " + gMM.sequencer.currentItem);
+                    autocommands.trigger("StreamPause", { track: gMM.sequencer.currentItem });
+                    break;
+                case Ci.sbIMediacoreEvent.STREAM_END:
+                    dactyl.log("Track ended: " + gMM.sequencer.currentItem);
+                    autocommands.trigger("StreamEnd", { track: gMM.sequencer.currentItem });
+                    break;
+                case Ci.sbIMediacoreEvent.STREAM_STOP:
+                    dactyl.log("Track stopped: " + gMM.sequencer.currentItem);
+                    autocommands.trigger("StreamStop", { track: gMM.sequencer.currentItem });
+                    break;
+            }
+        }
+    },
+
+    /** @property {sbIMediaListView} The current media list view. @private */
+    get _currentView() SBGetBrowser().currentMediaListView,
+
+    /**
+     * @property {number} The player volume in the range 0.0-1.0.
+     */
+    get volume() gMM.volumeControl.volume,
+    set volume(value) {
+        gMM.volumeControl.volume = value;
+    },
+
+    /**
+     * Focuses the specified media item in the current media list view.
+     *
+     * @param {sbIMediaItem} mediaItem The media item to focus.
+     */
+    focusTrack: function focusTrack(mediaItem) {
+        SBGetBrowser().mediaTab.mediaPage.highlightItem(this._currentView.getIndexForItem(mediaItem));
+    },
+
+    /**
+     * Plays the currently selected media item. If no item is selected the
+     * first item in the current media view is played.
+     */
+    play: function play() {
+        // Check if there is any selection in place, else play first item of the visible view.
+        // TODO: this approach, or similar, should be generalised for all commands, PT? --djk
+        if (this._currentView.selection.count != 0)
+            gMM.sequencer.playView(this._currentView,
+                    this._currentView.getIndexForItem(this._currentView.selection.currentMediaItem));
+        else
+            gMM.sequencer.playView(SBGetBrowser().currentMediaListView, 0);
+
+        this.focusTrack(gMM.sequencer.currentItem);
+    },
+
+    /**
+     * Stops playback of the currently playing media item.
+     */
+    stop: function stop() {
+        gMM.sequencer.stop();
+    },
+
+    /**
+     * Plays the *count*th next media item in the current media view.
+     *
+     * @param {number} count
+     */
+    next: function next(count) {
+        for (let i = 0; i < count; i++)
+            gSongbirdWindowController.doCommand("cmd_control_next");
+        gSongbirdWindowController.doCommand("cmd_find_current_track");
+    },
+
+    /**
+     * Plays the *count*th previous media item in the current media view.
+     *
+     * @param {number} count
+     */
+    previous: function previous(count) {
+        for (let i = 0; i < count; i++)
+            gSongbirdWindowController.doCommand("cmd_control_previous");
+        gSongbirdWindowController.doCommand("cmd_find_current_track");
+    },
+
+    /**
+     * Toggles the play/pause status of the current media item.
+     */
+    togglePlayPause: function togglePlayPause() {
+        ["cmd_control_playpause", "cmd_find_current_track"].forEach(gSongbirdWindowController.doCommand);
+    },
+
+    /**
+     * Toggles the shuffle status of the sequencer.
+     */
+    toggleShuffle: function toggleShuffle() {
+        if (gMM.sequencer.mode != gMM.sequencer.MODE_SHUFFLE)
+            gMM.sequencer.mode = gMM.sequencer.MODE_SHUFFLE;
+        else
+            gMM.sequencer.mode = gMM.sequencer.MODE_FORWARD;
+    },
+
+    // FIXME: not really toggling (depending on your definition) - good enough for now.
+    /**
+     * Toggles between the sequencer's three repeat modes: Repeat-One,
+     * Repeat-All and Repeat-None.
+     */
+    toggleRepeat: function toggleRepeat() {
+        switch (gMM.sequencer.repeatMode) {
+            case gMM.sequencer.MODE_REPEAT_NONE:
+                gMM.sequencer.repeatMode = gMM.sequencer.MODE_REPEAT_ONE;
+                break;
+            case gMM.sequencer.MODE_REPEAT_ONE:
+                gMM.sequencer.repeatMode = gMM.sequencer.MODE_REPEAT_ALL;
+                break;
+            case gMM.sequencer.MODE_REPEAT_ALL:
+                gMM.sequencer.repeatMode = gMM.sequencer.MODE_REPEAT_NONE;
+                break;
+            default:
+                gMM.sequencer.repeatMode = gMM.sequencer.MODE_REPEAT_NONE;
+                break;
+        }
+    },
+
+    /**
+     * Seeks forward *interval* milliseconds in the currently playing track.
+     *
+     * @param {number} interval The time interval (ms) to advance the
+     *     current track.
+     */
+    seekForward: function seekForward(interval) {
+        this._seek(interval, true);
+    },
+
+    /**
+     * Seeks backwards *interval* milliseconds in the currently playing track.
+     *
+     * @param {number} interval The time interval (ms) to rewind the
+     *     current track.
+     */
+    seekBackward: function seekBackward(interval) {
+        this._seek(interval, false);
+    },
+
+    /**
+     * Seeks to a specific position in the currently playing track.
+     *
+     * @param {number} The new position (ms) in the track.
+     */
+    seekTo: function seekTo(position) {
+        // FIXME: if not playing
+        if (!gMM.playbackControl)
+            this.play();
+
+        let min = 0;
+        let max = gMM.playbackControl.duration - 5000; // TODO: 5s buffer like cmus desirable?
+
+        gMM.playbackControl.position = Math.constrain(position, min, max);
+    },
+
+    /**
+     * Increases the volume by 5% of the maximum volume.
+     */
+    increaseVolume: function increaseVolume() {
+        this.volume = Math.constrain(this.volume + 0.05, 0, 1);
+    },
+
+    /**
+     * Decreases the volume by 5% of the maximum volume.
+     */
+    decreaseVolume: function decreaseVolume() {
+        this.volume = Math.constrain(this.volume - 0.05, 0, 1);
+    },
+
+    // TODO: Document what this buys us over and above cmd_find_current_track
+    /**
+     * Focuses the currently playing track.
+     */
+    focusPlayingTrack: function focusPlayingTrack() {
+        this.focusTrack(gMM.sequencer.currentItem);
+    },
+
+    /**
+     * Searches the current media view for *str*
+     *
+     * @param {string} str The search string.
+     */
+    searchView: function searchView(str) {
+        let search = _getSearchString(this._currentView);
+        let searchString = "";
+
+        if (search != "") // XXX
+            searchString = str + " " + search;
+        else
+            searchString = str;
+
+        this._lastSearchString = searchString;
+
+        let searchView = LibraryUtils.createStandardMediaListView(this._currentView.mediaList, searchString);
+
+        if (searchView.length) {
+            this._lastSearchView = searchView;
+            this._lastSearchIndex = 0;
+            this.focusTrack(searchView.getItemByIndex(this._lastSearchIndex));
+        }
+        else
+            dactyl.echoerr(_("finder.notFound", searchString), commandline.FORCE_SINGLELINE);
+    },
+
+    /**
+     * Repeats the previous view search.
+     *
+     * @param {boolean} reverse Search in the reverse direction to the previous
+     *     search.
+     */
+    searchViewAgain: function searchViewAgain(reverse) {
+        function echo(str) {
+            this.timeout(function () {
+                commandline.echo(str, commandline.HL_WARNINGMSG, commandline.APPEND_TO_MESSAGES | commandline.FORCE_SINGLELINE);
+            }, 0);
+        }
+
+        if (reverse) {
+            if (this._lastSearchIndex == 0) {
+                this._lastSearchIndex = this._lastSearchView.length - 1;
+                echo(_("finder.atTop"));
+            }
+            else
+                this._lastSearchIndex = this._lastSearchIndex - 1;
+        }
+        else {
+            if (this._lastSearchIndex == (this._lastSearchView.length - 1)) {
+                this._lastSearchIndex = 0;
+                echo(_("finder.atBottom"));
+            }
+            else
+                this._lastSearchIndex = this._lastSearchIndex + 1;
+        }
+
+        // TODO: Implement for "?" --ken
+        commandline.echo("/" + this._lastSearchString, null, commandline.FORCE_SINGLELINE);
+        this.focusTrack(this._lastSearchView.getItemByIndex(this._lastSearchIndex));
+
+    },
+
+    /**
+     * The search dialog keypress callback.
+     *
+     * @param {string} str The contents of the search dialog.
+     */
+    onSearchKeyPress: function onSearchKeyPress(str) {
+        if (options["incsearch"])
+            this.searchView(str);
+    },
+
+    /**
+     * The search dialog submit callback.
+     *
+     * @param {string} str The contents of the search dialog.
+     */
+    onSearchSubmit: function onSearchSubmit(str) {
+        this.searchView(str);
+    },
+
+    /**
+     * The search dialog cancel callback.
+     */
+    onSearchCancel: function onSearchCancel() {
+        // TODO: restore the view state if altered by an 'incsearch' search
+    },
+
+    /**
+     * Returns an array of all available playlists.
+     *
+     * @returns {sbIMediaList[]}
+     */
+    getPlaylists: function getPlaylists() {
+        let mainLibrary = LibraryUtils.mainLibrary;
+        let playlists = [mainLibrary];
+        let listener = {
+            onEnumerationBegin: function () { },
+            onEnumerationEnd: function () { },
+            onEnumeratedItem: function (list, item) {
+                // FIXME: why are there null items and duplicates?
+                if (!playlists.some(function (list) list.name == item.name) && item.name != null)
+                    playlists.push(item);
+                return Ci.sbIMediaListEnumerationListener.CONTINUE;
+            }
+        };
+
+        mainLibrary.enumerateItemsByProperty("http://songbirdnest.com/data/1.0#isList", "1", listener);
+
+        return playlists;
+    },
+
+    /**
+     * Plays the media item at *index* in *playlist*.
+     *
+     * @param {sbIMediaList} playlist
+     * @param {number} index
+     */
+    playPlaylist: function playPlaylist(playlist, index) {
+        gMM.sequencer.playView(playlist.createView(), index);
+    },
+
+    /**
+     * Returns an array of all available media pages.
+     *
+     * @returns {sbIMediaPageInfo[]}
+     */
+    getMediaPages: function getMediaPages() {
+        let list = SBGetBrowser().currentMediaPage.mediaListView.mediaList;
+        let pages = services.mediaPageManager.getAvailablePages(list);
+        return ArrayConverter.JSArray(pages).map(function (page) page.QueryInterface(Ci.sbIMediaPageInfo));
+    },
+
+    /**
+     * Loads the the specified media page into *view* with the given *list* of
+     * media items.
+     *
+     * @param {sbIMediaPage} page
+     * @param {sbIMediaList} list
+     * @param {sbIMediaView} view
+     */
+    loadMediaPage: function loadMediaPage(page, list, view) {
+        services.mediaPageManager.setPage(list, page);
+        SBGetBrowser().loadMediaList(list, null, null, view, null);
+    },
+
+    /**
+     * Applys the specified *rating* to *mediaItem*.
+     *
+     * @param {sbIMediaItem} mediaItem The media item to rate.
+     * @param {number} rating The star rating (1-5).
+     */
+    rateMediaItem: function rateMediaItem(mediaItem, rating) {
+        mediaItem.setProperty(SBProperties.rating, rating);
+    },
+
+    // TODO: add more fields, and generate the list dynamically. PT should the
+    // available fields reflect only the visible view fields or offer others? --djk
+    /**
+     * Sorts the current media view by *field* in the order specified by
+     * *ascending*.
+     *
+     * @param {string} field The sort field.
+     * @param {boolean} ascending If true sort in ascending order, otherwise in
+     *     descending order.
+     */
+    sortBy: function sortBy(field, ascending) {
+        let order = ascending ? "a" : "d";
+        let properties = services.MutablePropertyArray();
+        properties.strict = false;
+
+        switch (field) {
+            case "title":
+                properties.appendProperty(SBProperties.trackName, order);
+                break;
+            case "time":
+                properties.appendProperty(SBProperties.duration, order);
+                break;
+            case "artist":
+                properties.appendProperty(SBProperties.artistName, order);
+                break;
+            case "album":
+                properties.appendProperty(SBProperties.albumName, order);
+                break;
+            case "genre":
+                properties.appendProperty(SBProperties.genre, order);
+                break;
+            case "rating":
+                properties.appendProperty(SBProperties.rating, order);
+                break;
+            default:
+                properties.appendProperty(SBProperties.trackName, order);
+                break;
+        }
+
+        this._currentView.setSort(properties);
+    }
+}, {
+}, {
+    modes: function initModes(dactyl, modules, window) {
+        modes.addMode("SEARCH_VIEW", {
+            description: "Search View mode",
+            bases: [modes.COMMAND_LINE],
+        });
+        modes.addMode("SEARCH_VIEW_FORWARD", {
+            description: "Forward Search View mode",
+            bases: [modes.SEARCH_VIEW]
+        });
+        modes.addMode("SEARCH_VIEW_BACKWARD", {
+            description: "Backward Search View mode",
+            bases: [modes.SEARCH_VIEW]
+        });
+
+    },
+    commandline: function () {
+        player.CommandMode = Class("CommandSearchViewMode", modules.CommandMode, {
+            init: function init(mode) {
+                this.mode = mode;
+                init.supercall(this);
+            },
+
+            historyKey: "search-view",
+
+            get prompt() this.mode === modules.modes.SEARCH_VIEW_BACKWARD ? "?" : "/",
+
+            get onCancel() player.closure.onSearchCancel,
+            get onChange() player.closure.onSearchKeyPress,
+            get onSubmit() player.closure.onSearchSubmit
+        });
+    },
+    commands: function () {
+        commands.add(["f[ilter]"],
+                "Filter tracks based on keywords {genre/artist/album/track}",
+                function (args) {
+                    let view = LibraryUtils.createStandardMediaListView(LibraryUtils.mainLibrary, args.literalArg);
+
+                    dactyl.assert(view.length, "No matching tracks");
+
+                    SBGetBrowser().loadMediaList(LibraryUtils.mainLibrary, null, null, view,
+                                                     "chrome://songbird/content/mediapages/filtersPage.xul");
+                    // TODO: make this player.focusTrack work ?
+                    player.focusTrack(view.getItemByIndex(0));
+                },
+                {
+                    argCount: "1",
+                    literal: 0
+                    //completer: function (context, args) completion.tracks(context, args);
+                });
+
+        commands.add(["load"],
+            "Load a playlist",
+            function (args) {
+                let arg = args.literalArg;
+
+                if (arg) {
+                    // load the selected playlist/smart playlist
+                    for ([, playlist] in Iterator(player.getPlaylists())) {
+                        if (util.compareIgnoreCase(arg, playlist.name) == 0) {
+                            SBGetBrowser().loadMediaList(playlist);
+                            player.focusTrack(player._currentView.getItemByIndex(0));
+                            return;
+                        }
+                    }
+                    dactyl.echoerr(_("error.invalidArgument", arg));
+                }
+                else {
+                    // load main library if there are no args
+                    _SBShowMainLibrary();
+                }
+            },
+            {
+                argCount: "?",
+                completer: function (context, args) completion.playlist(context),
+                literal: 0
+            });
+
+        // TODO: better off as a single command (:player play) or cmus compatible (:player-play)? --djk
+        commands.add(["playerp[lay]"],
+            "Play track",
+            function () { player.play(); });
+
+        commands.add(["playerpa[use]"],
+            "Pause/unpause track",
+            function () { player.togglePlayPause(); });
+
+        commands.add(["playern[ext]"],
+            "Play next track",
+            function (args) { player.next(Math.max(args.count, 1)); },
+            { count: true });
+
+        commands.add(["playerpr[ev]"],
+            "Play previous track",
+            function (args) { player.previous(Math.max(args.count, 1)); },
+            { count: true });
+
+        commands.add(["players[top]"],
+            "Stop track",
+            function () { player.stop(); });
+
+        commands.add(["see[k]"],
+            "Seek to a track position",
+            function (args) {
+                let arg = args[0];
+
+                // intentionally supports 999:99:99
+                dactyl.assert(/^[+-]?(\d+[smh]?|(\d+:\d\d:|\d+:)?\d{2})$/.test(arg),
+                    _("error.invalidArgument", arg));
+
+                function ms(t, m) Math.abs(parseInt(t, 10) * { s: 1000, m: 60000, h: 3600000 }[m])
+
+                if (/:/.test(arg)) {
+                    let [seconds, minutes, hours] = arg.split(":").reverse();
+                    hours = hours || 0;
+                    var value = ms(seconds, "s") + ms(minutes, "m") + ms(hours, "h");
+                }
+                else {
+                    if (!/[smh]/.test(arg.substr(-1)))
+                        arg += "s"; // default to seconds
+
+                    value = ms(arg.substring(arg, arg.length - 1), arg.substr(-1));
+                }
+
+                if (/^[-+]/.test(arg))
+                    arg[0] == "-" ? player.seekBackward(value) : player.seekForward(value);
+                else
+                    player.seekTo(value);
+
+            },
+            { argCount: "1" });
+
+        commands.add(["mediav[iew]"],
+            "Change the current media view",
+            function (args) {
+                // FIXME: is this a SB restriction? --djk
+                dactyl.assert(SBGetBrowser().currentMediaPage,
+                    "Exxx: Can only set the media view from the media tab"); // XXX
+
+                let arg = args[0];
+
+                if (arg) {
+                    for ([, page] in Iterator(player.getMediaPages())) {
+                        if (util.compareIgnoreCase(arg, page.contentTitle) == 0) {
+                            player.loadMediaPage(page, SBGetBrowser().currentMediaListView.mediaList,
+                                    SBGetBrowser().currentMediaListView);
+                            return;
+                        }
+                    }
+                    dactyl.echoerr(_("error.invalidArgument", arg));
+                }
+            },
+            {
+                argCount: "1",
+                completer: function (context) completion.mediaView(context),
+                literal: 0
+            });
+
+        commands.add(["sort[view]"],
+                "Sort the current media view",
+                function (args) {
+                    player.sortBy(args[0], args["-order"] == "up");
+                },
+                {
+                    argCount: "1",
+                    completer: function (context) completion.mediaListSort(context),
+                    options: [
+                        {
+                            names: ["-order", "-o"], type: CommandOption.STRING,
+                            default: "up",
+                            description: "Specify the sorting order of the given field",
+                            validator: function (arg) /^(up|down)$/.test(arg),
+                            completer: function () [["up", "Sort in ascending order"], ["down", "Sort in descending order"]]
+                        }
+                    ]
+                });
+
+        // FIXME: use :add -q like cmus? (not very vim-like are it's multi-option commands) --djk
+        commands.add(["qu[eue]"],
+            "Queue tracks by artist/album/track",
+            function (args) {
+                let properties = services.MutablePropertyArray();
+
+                // args
+                switch (args.length) {
+                    case 3:
+                        properties.appendProperty(SBProperties.trackName, args[2]);
+                    case 2:
+                        properties.appendProperty(SBProperties.albumName, args[1]);
+                    case 1:
+                        properties.appendProperty(SBProperties.artistName, args[0]);
+                        break;
+                    default:
+                        break;
+                }
+
+                let library = LibraryUtils.mainLibrary;
+                let mainView = library.createView();
+                gMM.sequencer.playView(mainView,
+                        mainView.getIndexForItem(library.getItemsByProperties(properties).queryElementAt(0, Ci.sbIMediaItem)));
+                player.focusPlayingTrack();
+            },
+            {
+                argCount: "+",
+                completer: function (context, args) {
+                    if (args.completeArg == 0)
+                        completion.artist(context);
+                    else if (args.completeArg == 1)
+                        completion.album(context, args[0]);
+                    else if (args.completeArg == 2)
+                        completion.song(context, args[0], args[1]);
+                }
+            });
+
+        // TODO: maybe :vol! could toggle mute on/off? --djk
+        commands.add(["vol[ume]"],
+            "Set the volume",
+            function (args) {
+                let arg = args[0];
+
+                dactyl.assert(arg, _("error.argumentRequired"));
+                dactyl.assert(/^[+-]?\d+$/.test(arg), _("error.trailing"));
+
+                let level = parseInt(arg, 10) / 100;
+
+                if (/^[+-]/.test(arg))
+                    level = player.volume + level;
+
+                player.volume = Math.constrain(level, 0, 1);
+            },
+            { argCount: "1" });
+    },
+    completion: function () {
+        completion.album = function album(context, artist) {
+            context.title = ["Album"];
+            context.completions = [[v, ""] for ([, v] in Iterator(library.getAlbums(artist)))];
+        };
+
+        completion.artist = function artist(context) {
+            context.title = ["Artist"];
+            context.completions = [[v, ""] for ([, v] in Iterator(library.getArtists()))];
+        };
+
+        completion.playlist = function playlist(context) {
+            context.title = ["Playlist", "Type"];
+            context.keys = { text: "name", description: "type" };
+            context.completions = player.getPlaylists();
+        };
+
+        completion.mediaView = function mediaView(context) {
+            context.title = ["Media View", "URL"];
+            context.anchored = false;
+            context.keys = { text: "contentTitle", description: "contentUrl" };
+            context.completions = player.getMediaPages();
+        };
+
+        completion.mediaListSort = function mediaListSort(context) {
+            context.title = ["Media List Sort Field", "Description"];
+            context.anchored = false;
+            context.completions = [["title", "Track name"], ["time", "Duration"], ["artist", "Artist name"],
+                ["album", "Album name"], ["genre", "Genre"], ["rating", "Rating"]]; // FIXME: generate this list dynamically - see #sortBy
+        };
+
+        completion.song = function album(context, artist, album) {
+            context.title = ["Song"];
+            context.completions = [[v, ""] for ([, v] in Iterator(library.getTracks(artist, album)))];
+        };
+    },
+    mappings: function () {
+        mappings.add([modes.PLAYER],
+            ["x"], "Play track",
+            function () { ex.playerplay(); });
+
+        mappings.add([modes.PLAYER],
+            ["z"], "Previous track",
+            function (args) { ex.playerprev({ "#": args.count }); },
+            { count: true });
+
+        mappings.add([modes.PLAYER],
+            ["c"], "Pause/unpause track",
+            function () { ex.playerpause(); });
+
+        mappings.add([modes.PLAYER],
+            ["b"], "Next track",
+            function (args) { ex.playernext({ "#": args.count }); },
+            { count: true });
+
+        mappings.add([modes.PLAYER],
+            ["v"], "Stop track",
+            function () { ex.playerstop(); });
+
+        mappings.add([modes.PLAYER],
+            ["Q"], "Queue tracks by artist/album/track",
+            function () { commandline.open(":", "queue ", modes.EX); });
+
+        mappings.add([modes.PLAYER],
+            ["f"], "Loads current view filtered by the keywords",
+            function () { commandline.open(":", "filter ", modes.EX); });
+
+        mappings.add([modes.PLAYER],
+            ["i"], "Select current track",
+            function () { gSongbirdWindowController.doCommand("cmd_find_current_track"); });
+
+        mappings.add([modes.PLAYER],
+            ["s"], "Toggle shuffle",
+            function () { player.toggleShuffle(); });
+
+        mappings.add([modes.PLAYER],
+            ["r"], "Toggle repeat",
+            function () { player.toggleRepeat(); });
+
+        mappings.add([modes.PLAYER],
+            ["h", "<Left>"], "Seek -10s",
+            function (args) { player.seekBackward(Math.max(1, args.count) * 10000); },
+            { count: true });
+
+        mappings.add([modes.PLAYER],
+            ["l", "<Right>"], "Seek +10s",
+            function (args) { player.seekForward(Math.max(1, args.count) * 10000); },
+            { count: true });
+
+        mappings.add([modes.PLAYER],
+            ["H", "<S-Left>"], "Seek -1m",
+            function (args) { player.seekBackward(Math.max(1, args.count) * 60000); },
+            { count: true });
+
+        mappings.add([modes.PLAYER],
+            ["L", "<S-Right>"], "Seek +1m",
+            function (args) { player.seekForward(Math.max(1, args.count) * 60000); },
+            { count: true });
+
+        mappings.add([modes.PLAYER],
+             ["=", "+"], "Increase volume by 5% of the maximum",
+             function () { player.increaseVolume(); });
+
+        mappings.add([modes.PLAYER],
+             ["-"], "Decrease volume by 5% of the maximum",
+             function () { player.decreaseVolume(); });
+
+        mappings.add([modes.PLAYER],
+             ["/"], "Search forward for a track",
+             function () { player.CommandMode(modes.SEARCH_VIEW_FORWARD).open(); });
+
+        mappings.add([modes.PLAYER],
+             ["n"], "Find the next track",
+             function () { player.searchViewAgain(false); });
+
+        mappings.add([modes.PLAYER],
+             ["N"], "Find the previous track",
+             function () { player.searchViewAgain(true); });
+
+        for (let i in util.range(0, 6)) {
+            let (rating = i) {
+                mappings.add([modes.PLAYER],
+                     ["<C-" + rating + ">"], "Rate the current media item " + rating,
+                     function () {
+                         let item = gMM.sequencer.currentItem || this._currentView.selection.currentMediaItem; // XXX: a bit too magic
+                         if (item)
+                             player.rateMediaItem(item, rating);
+                         else
+                             dactyl.beep();
+                     }
+                );
+            };
+        }
+    },
+    options: function () {
+        options.add(["repeat"],
+            "Set the playback repeat mode",
+            "number", 0,
+            {
+                setter: function (value) gMM.sequencer.repeatMode = value,
+                getter: function () gMM.sequencer.repeatMode,
+                completer: function (context) [
+                    ["0", "Repeat none"],
+                    ["1", "Repeat one"],
+                    ["2", "Repeat all"]
+                ]
+            });
+
+        options.add(["shuffle"],
+            "Play tracks in shuffled order",
+            "boolean", false,
+            {
+                setter: function (value) gMM.sequencer.mode = value ? gMM.sequencer.MODE_SHUFFLE : gMM.sequencer.MODE_FORWARD,
+                getter: function () gMM.sequencer.mode == gMM.sequencer.MODE_SHUFFLE
+            });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/melodactyl/contrib/vim/Makefile b/melodactyl/contrib/vim/Makefile
new file mode 100644 (file)
index 0000000..29972b5
--- /dev/null
@@ -0,0 +1,9 @@
+VIMBALL = melodactyl.vba
+
+vimball: mkvimball.txt syntax/melodactyl.vim ftdetect/melodactyl.vim
+       -echo '%MkVimball! ${VIMBALL} .' | vim -u NORC -N -e -s mkvimball.txt
+
+all: vimball
+
+clean:
+       rm -f ${VIMBALL}
diff --git a/melodactyl/contrib/vim/ftdetect/melodactyl.vim b/melodactyl/contrib/vim/ftdetect/melodactyl.vim
new file mode 100644 (file)
index 0000000..e7661ca
--- /dev/null
@@ -0,0 +1 @@
+au BufNewFile,BufRead *melodactylrc*,*.melo set filetype=melodactyl
diff --git a/melodactyl/contrib/vim/mkvimball.txt b/melodactyl/contrib/vim/mkvimball.txt
new file mode 100644 (file)
index 0000000..ec820b4
--- /dev/null
@@ -0,0 +1,2 @@
+syntax/melodactyl.vim
+ftdetect/melodactyl.vim
diff --git a/melodactyl/defaults/preferences/dactyl.js b/melodactyl/defaults/preferences/dactyl.js
new file mode 100644 (file)
index 0000000..077178a
--- /dev/null
@@ -0,0 +1,6 @@
+pref("extensions.dactyl.name", "melodactyl");
+pref("extensions.dactyl.appName", "Melodactyl");
+pref("extensions.dactyl.idName", "MELODACTYL");
+pref("extensions.dactyl.fileExt", "melo");
+pref("extensions.dactyl.host", "Songbird");
+pref("extensions.dactyl.hostbin", "songbird");
diff --git a/melodactyl/install.rdf b/melodactyl/install.rdf
new file mode 100644 (file)
index 0000000..0cebb2b
--- /dev/null
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+    xmlns:em="http://www.mozilla.org/2004/em-rdf#"
+    xmlns:songbird="http://www.songbirdnest.com/2007/addon-metadata-rdf#">
+    <Description about="urn:mozilla:install-manifest"
+        em:id="melodactyl@dactyl.googlecode.com"
+        em:type="2"
+        em:name="Melodactyl"
+        em:version="0.1a1pre"
+        em:description="Songbird for Vim and CMus junkies."
+        em:creator="Prathyush Thota"
+        em:homepageURL="http://dactyl.sourceforge.net/"
+        em:iconURL="chrome://melodactyl/skin/icon.png"
+        em:optionsURL="chrome://dactyl/content/preferences.xul">
+        <em:targetApplication>
+            <Description>
+                <em:id>songbird@songbirdnest.com</em:id>
+                <em:minVersion>1.8.0</em:minVersion>
+                <em:maxVersion>1.9.*</em:maxVersion>
+            </Description>
+        </em:targetApplication>
+    </Description>
+</RDF>
+
+<!-- vim: set fdm=marker sw=4 ts=4 et: -->
diff --git a/melodactyl/locale/en-US/all.xml b/melodactyl/locale/en-US/all.xml
new file mode 100644 (file)
index 0000000..7d838b9
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<include href="player" tag="player.html" insertafter="intro.html"/>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/locale/en-US/autocommands.xml b/melodactyl/locale/en-US/autocommands.xml
new file mode 100644 (file)
index 0000000..154ca6b
--- /dev/null
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl tag="autocommand-list" replace="autocommand-list">
+    <dt>BookmarkAdd</dt>   <dd>Triggered after a page is bookmarked</dd>
+    <dt>ColorScheme</dt>   <dd>Triggered after a color scheme has been loaded</dd>
+    <dt>DOMLoad</dt>       <dd>Triggered when a page's DOM content has fully loaded</dd>
+    <dt>DownloadPost</dt>  <dd>Triggered when a download has completed</dd>
+    <dt>Fullscreen</dt>    <dd>Triggered when the player's fullscreen state changes</dd>
+    <dt>LocationChange</dt><dd>Triggered when changing tabs or when navigating to a new location</dd>
+    <dt>PageLoadPre</dt>   <dd>Triggered after a page load is initiated</dd>
+    <dt>PageLoad</dt>      <dd>Triggered when a page gets (re)loaded/opened</dd>
+    <dt>ShellCmdPost</dt>  <dd>Triggered after executing a shell command with <ex>:!</ex>#{cmd}</dd>
+    <dt>TrackChangePre</dt><dd>Triggered before a playing track is changed</dd>
+    <dt>TrackChange</dt>   <dd>Triggered after a playing track has changed</dd>
+    <dt>ViewChangePre</dt> <dd>Triggered before a sequencer view is changed</dd>
+    <dt>ViewChange</dt>    <dd>Triggered after a sequencer view is changed</dd>
+    <dt>StreamStart</dt>   <dd>Triggered after a stream has started</dd>
+    <dt>StreamPause</dt>   <dd>Triggered after a stream has paused</dd>
+    <dt>StreamEnd</dt>     <dd>Triggered after a stream has ended</dd>
+    <dt>StreamStop</dt>    <dd>Triggered after a stream has stopped</dd>
+    <dt>Enter</dt>         <dd>Triggered after Songbird starts</dd>
+    <dt>LeavePre</dt>      <dd>Triggered before exiting Songbird, just before destroying each module</dd>
+    <dt>Leave</dt>         <dd>Triggered before exiting Songbird</dd>
+</dl>
+
+<dl tag="autocommand-args" replace="autocommand-args">
+    <dt>&lt;url></dt>    <dd>The URL against which the event was selected.</dd>
+    <dt>&lt;title></dt>  <dd>The page, bookmark or download title.</dd>
+    <dt>&lt;doc></dt>    <dd>The document for which the event occurred. Only for <em>DOMLoad</em>, <em>PageLoad</em> and <em>PageLoadPre</em>.</dd>
+    <dt>&lt;tab></dt>    <dd>The tab in which the event occurred. Only for <em>DOMLoad</em>, <em>PageLoad</em> and <em>PageLoadPre</em>.</dd>
+    <dt>&lt;tags></dt>   <dd>The tags applied to <em>&lt;url></em>. Only for <em>BookmarkAdd</em>.</dd>
+    <dt>&lt;keyword></dt><dd>The keywords applied to the bookmark. Only for <em>BookmarkAdd</em>.</dd>
+    <dt>&lt;icon></dt>   <dd>The icon associated with <em>&lt;url></em>. Only for <em>BookmarkAdd</em>.</dd>
+    <dt>&lt;size></dt>   <dd>The size of a downloaded file. Only for <em>DownloadPost</em>.</dd>
+    <dt>&lt;file></dt>   <dd>The target destination of a download. Only for <em>DownloadPost</em>.</dd>
+    <dt>&lt;state></dt>  <dd>The new fullscreen state. Only for <em>Fullscreen</em>.</dd>
+    <dt>&lt;name></dt>   <dd>The color scheme name. Only for <em>ColorScheme</em>.</dd>
+    <dt>&lt;view></dt>   <dd>The new sequencer view. Only for <em>ViewChangePre</em> and <em>ViewChange</em>.</dd>
+    <dt>&lt;track></dt>  <dd>The new media track. Only for <em>TrackChangePre</em>, <em>TrackChange</em> and <em>Stream</em>.</dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/locale/en-US/browsing.xml b/melodactyl/locale/en-US/browsing.xml
new file mode 100644 (file)
index 0000000..400d475
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<span replace="w"/>
+<span replace="W"/>
+<span replace=":wc"/>
+<span replace=":winon"/>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/locale/en-US/gui.xml b/melodactyl/locale/en-US/gui.xml
new file mode 100644 (file)
index 0000000..fd788b6
--- /dev/null
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl tag="dialog-list" replace="dialog-list">
+    <dt>about</dt>           <dd>About &dactyl.host;</dd>
+    <dt>addons</dt>          <dd>Manage Add-ons</dd>
+    <dt>checkupdates</dt>    <dd>Check for updates</dd>
+    <dt>cleardata</dt>       <dd>Clear private data</dd>
+    <dt>cookies</dt>         <dd>List your cookies</dd>
+    <dt>console</dt>         <dd>JavaScript console</dd>
+    <dt>dominspector</dt>    <dd>DOM Inspector</dd>
+    <dt>downloads</dt>       <dd>Manage Downloads</dd>
+    <dt>jumpto</dt>          <dd>Jump to a media item</dd>
+    <dt>newsmartplaylist</dt><dd>Create a new smart playlist</dd>
+    <dt>openfile</dt>        <dd>Open the file selector dialog</dd>
+    <dt>pagesource</dt>      <dd>View page source</dd>
+    <dt>places</dt>          <dd>Places Organizer: Manage your bookmarks and history</dd>
+    <dt>preferences</dt>     <dd>Show &dactyl.host; preferences dialog</dd>
+    <dt>printsetup</dt>      <dd>Setup the page size and orientation before printing</dd>
+    <dt>print</dt>           <dd>Show print dialog</dd>
+    <dt>saveframe</dt>       <dd>Save frame to disk</dd>
+    <dt>savepage</dt>        <dd>Save page to disk</dd>
+    <dt>searchengines</dt>   <dd>Manage installed search engines</dd>
+    <dt>selectionsource</dt> <dd>View selection source</dd>
+    <dt>subscribe</dt>       <dd>Add a new subscription</dd>
+</dl>
+
+<item replace=":sbcl">
+    <tags>:dpcl :dpclose</tags>
+    <spec>:dpcl<oa>ose</oa> <a>pane</a></spec>
+    <description>
+        <p>Close the specified display pane.</p>
+    </description>
+</item>
+
+<item replace=":sbope">
+    <tags>:dpope :dpopen :dp :dpane :displayp :displaypane</tags>
+    <spec>:displaypane <a>pane</a></spec>
+    <description>
+        <p>
+            Open the specified display pane. <a>pane</a> is any of
+            "leftservice", "bottomservice", "bottomcontent" or "rightsidebar".
+        </p>
+    </description>
+</item>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/locale/en-US/intro.xml b/melodactyl/locale/en-US/intro.xml
new file mode 100644 (file)
index 0000000..7e49891
--- /dev/null
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<p replace="intro-text">
+    <link topic="&dactyl.apphome;">&dactyl.appName;</link>
+    is a free media player add-on for &dactyl.host;, which combines the best
+    features of the <link topic="http://cmus.sourceforge.net">CMus</link> music
+    player and the <link topic="http://www.vim.org">Vim</link> text editor.
+</p>
+
+<!-- TODO: make Melodactyl specific -->
+<ol replace="topics-list">
+    <!--
+    <li>
+        <link topic="tutorial">Quick-start tutorial</link>:
+        A quick-start tutorial for new users.
+    </li>
+    -->
+    <li>
+        <link topic="starting">Starting &dactyl.appName;</link>:
+        How &dactyl.appName; starts up, where it reads the config file, etc.
+    </li>
+    <li>
+        <link topic="player">Player mode</link>:
+        Interacting with the media player.
+    </li>
+    <li>
+        <link topic="browsing">Browsing</link>:
+        Basic key mappings and commands needed for a browsing
+        session (how to open a web page, go back in history, etc.)
+    </li>
+    <li>
+        <link topic="buffer">Buffer</link>:
+        Operations on the current document (scrolling, copying text,
+        etc.)
+    </li>
+    <li>
+        <link topic="cmdline">Command-line mode</link>:
+        Command-line editing.
+    </li>
+    <li>
+        <link topic="editing">Editing text</link>:
+        Text area and input field editing.
+    </li>
+    <li>
+        <link topic="options">Options</link>:
+        A description of all options.
+    </li>
+    <li>
+        <link topic="pattern">Text search commands</link>:
+        Searching for text in the current buffer.
+    </li>
+    <li>
+        <link topic="tabs">Tabs</link>:
+        Managing your tabbed browsing session.
+    </li>
+    <li>
+        <link topic="hints">Hints</link>:
+        Selecting hyperlinks and other page elements.
+    </li>
+    <li>
+        <link topic="map">Keyboard shortcuts and commands</link>:
+        Defining new key mappings, abbreviations and user commands.
+    </li>
+    <li>
+        <link topic="eval">Expression evaluation</link>:
+        Executing JavaScript.
+    </li>
+    <li>
+        <link topic="marks">Marks</link>:
+        Using bookmarks, QuickMarks, history and local marks.
+    </li>
+    <li>
+        <link topic="repeat">Repeating commands</link>:
+        Using macros to repeat recurring workflows.
+    </li>
+    <li>
+        <link topic="autocommands">Automatic commands</link>:
+        Automatically executing code on certain events.
+    </li>
+    <li>
+        <link topic="print">Printing</link>:
+        Printing pages.
+    </li>
+    <li>
+        <link topic="gui">&dactyl.appName;'s GUI</link>:
+        Accessing &dactyl.host; menus, dialogs and the display panels.
+    </li>
+    <li>
+        <link topic="styling">Styling the GUI and web pages</link>:
+        Changing the styling of content pages and &dactyl.appName; itself.
+    </li>
+    <li>
+        <link topic="message">Error and informational messages</link>:
+        A description of informational and error messages.
+    </li>
+    <li>
+        <link topic="developer">Developer information</link>:
+        How to write plugins and documentation.
+    </li>
+    <li>
+        <link topic="various">Various commands</link>:
+        Other help which doesn't readily fit into any other category.
+    </li>
+    <li>
+        <link topic="plugins">Plugins</link>:
+        Documentation for any plugins you have installed.
+    </li>
+    <li>
+        <link topic="index">Index</link>:
+        An index of all commands and options.
+    </li>
+</ol>
+
+<!-- TODO: make Melodactyl specific -->
+<ul replace="features-list">
+    <li>Vim-like keybindings (<k>h</k>, <k>j</k>, <k>gg</k>, <k>ZZ</k>, <k name="C-f"/>, etc.)</li>
+    <li>Ex commands (<ex>:quit</ex>, <ex>:open www.foo.com</ex>, …)</li>
+    <li>Tab completion for all commands, highly configurable via <o>wildmode</o>, <o>autocomplete</o>, ...</li>
+    <li>Hit-a-hint like navigation of links (start with <k>f</k> to follow a link)</li>
+    <li>Advanced completion of bookmark and history URLs</li>
+    <li>Vim-like status line with a Wget-like progress bar</li>
+    <li>Minimal GUI (easily hide superfluous menubar and toolbar with <se opt="guioptions"/>)</li>
+    <li>Ability to <ex>:source</ex> JavaScript, CSS, and &dactyl.appName; command files</li>
+    <li>Easy quick searches (see <ex>:open</ex>)</li>
+    <li>Count supported for many commands (<em>3</em><k name="C-o"/> will go back 3 pages)</li>
+    <li>Visual bell for errors (<o>visualbell</o>)</li>
+    <li>Marks support (<k>m</k><em>a</em> to set mark a, <k>'</k><em>a</em> to jump to it)</li>
+    <li><link topic="quickmarks">QuickMark</link> support</li>
+    <li><ex>:map</ex>, <ex>:command</ex>, <ex>:normal</ex>, and <t>macros</t></li>
+    <li><link topic="i_&lt;C-i>">Editing of text fields</link> with an <link topic="'editor'">external editor</link></li>
+    <li>AutoCommands to execute actions on certain events</li>
+    <li>A comprehensive help system, explaining all commands, mappings, options, and plugins</li>
+</ul>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/locale/en-US/map.xml b/melodactyl/locale/en-US/map.xml
new file mode 100644 (file)
index 0000000..e8b1e12
--- /dev/null
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl replace=":command-complete-arg-list">
+    <dt>abbreviation</dt> <dd>abbreviations</dd>
+    <dt>altstyle</dt>     <dd>alternate author style sheets</dd>
+    <dt>bookmark</dt>     <dd>bookmarks</dd>
+    <dt>buffer</dt>       <dd>buffers</dd>
+    <dt>charset</dt>      <dd>character sets</dd>
+    <dt>color</dt>        <dd>color schemes</dd>
+    <dt>command</dt>      <dd>Ex commands</dd>
+    <dt>dialog</dt>       <dd>&dactyl.host; dialogs</dd>
+    <dt>displaypane</dt>  <dd>display panes</dd>
+    <dt>dir</dt>          <dd>directories</dd>
+    <dt>environment</dt>  <dd>environment variables</dd>
+    <dt>event</dt>        <dd>autocommand events</dd>
+    <dt>extension</dt>    <dd>installed extensions</dd>
+    <dt>file</dt>         <dd>files</dd>
+    <dt>help</dt>         <dd>help tags</dd>
+    <dt>highlight</dt>    <dd>highlight groups</dd>
+    <dt>history</dt>      <dd>browsing history</dd>
+    <dt>javascript</dt>   <dd>JavaScript expressions</dd>
+    <dt>macro</dt>        <dd>named macros</dd>
+    <dt>mapping</dt>      <dd>user mappings</dd>
+    <dt>mark</dt>         <dd>local page marks</dd>
+    <dt>mediaview</dt>    <dd>media views</dd>
+    <dt>mediasort</dt>    <dd>media sort fields</dd>
+    <dt>menu</dt>         <dd>menu items</dd>
+    <dt>option</dt>       <dd>&dactyl.appName; options</dd>
+    <dt>playlist</dt>     <dd>play lists</dd>
+    <dt>preference</dt>   <dd>&dactyl.host; preferences</dd>
+    <dt>qmark</dt>        <dd>quick marks</dd>
+    <dt>runtime</dt>      <dd>runtime paths</dd>
+    <dt>search</dt>       <dd>search engines and keywords</dd>
+    <dt>shellcmd</dt>     <dd>shell commands</dd>
+    <dt>song</dt>         <dd>songs</dd>
+    <dt>toolbar</dt>      <dd>toolbars</dd>
+    <dt>url</dt>          <dd>URLs</dd>
+    <dt>usercommand</dt>  <dd>user commands</dd>
+    <dt>custom,<a>thing</a></dt><dd>custom completion, provided by <a>thing</a></dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/locale/en-US/player.xml b/melodactyl/locale/en-US/player.xml
new file mode 100644 (file)
index 0000000..ef95518
--- /dev/null
@@ -0,0 +1,328 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="player"
+    title="&dactyl.appName; Player mode"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<h1 tag="player-mode player">Player mode</h1>
+<toc start="2"/>
+
+<p>
+    The following features apply to Player mode which is activated when the
+    media tab has focus.
+</p>
+
+<h2 tag="playing-tracks">Playing tracks</h2>
+
+<item>
+    <tags>p_x :playerp :playerplay</tags>
+    <spec>:<oa>count</oa>playerp<oa>lay</oa></spec>
+    <spec><oa>count</oa>x</spec>
+    <description>
+        <p>
+            Play the current track.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_z :playerpr :playerprev</tags>
+    <spec>:<oa>count</oa>playerpr<oa>ev</oa></spec>
+    <spec><oa>count</oa>z</spec>
+    <description>
+        <p>
+            Play the previous track.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_b :playern :playernext</tags>
+    <spec>:playern<oa>ext</oa></spec>
+    <spec>b</spec>
+    <description>
+        <p>
+            Play the next track.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_c :playerpa :playerpause</tags>
+    <spec>:playerpa<oa>use</oa></spec>
+    <spec>c</spec>
+    <description>
+        <p>
+            Pause/unpause the current track.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_v :playerps :playerstop</tags>
+    <spec>:playerps<oa>top</oa></spec>
+    <spec>v</spec>
+    <description>
+        <p>
+            Stop playing the current track.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_s</tags>
+    <spec>s</spec>
+    <description>
+        <p>
+            Toggle shuffle mode.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_r</tags>
+    <spec>r</spec>
+    <description>
+        <p>
+            Toggle repeat mode.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_i</tags>
+    <spec>i</spec>
+    <description>
+        <p>
+            Select the currently playing track.
+        </p>
+    </description>
+</item>
+
+<h2 tag="queue queueing">Queueing tracks</h2>
+
+<item>
+    <tags>p_Q :qu :queue</tags>
+    <spec>:qu<oa>eue</oa> <a>artist</a> <oa>album</oa> <oa>track</oa></spec>
+    <spec>Q</spec>
+    <description>
+        <p>
+            Queue tracks by artist/album/track. If only <a>artist</a> is
+            specified then all tracks for that artist are played in album
+            order. If <oa>album</oa> is also specified then all tracks for that
+            album are played. A specific track can be specified with
+            <oa>track</oa>.
+        </p>
+    </description>
+</item>
+
+<h2 tag="filter filtering">Filtering the library</h2>
+
+<item>
+    <tags>p_f :f :filter</tags>
+    <spec>:f<oa>ilter</oa> <a>keywords</a></spec>
+    <spec>f</spec>
+    <description>
+        <p>
+            Filter and show the tracks as a view. The tracks are filtered by
+            the <a>keywords</a> provided as arguments. This text search applies
+            over the default filter properties, namely: Genre, Artist, Album
+            and Track.
+        </p>
+    </description>
+</item>
+
+<h2 tag="seeking">Seeking to a track position</h2>
+
+<item>
+    <tags><![CDATA[p_<Left> p_h]]></tags>
+    <spec><oa>count</oa>h</spec>
+    <description>
+        <p>
+            Seek +10s.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[p_<Right> p_l]]></tags>
+    <spec><oa>count</oa>l</spec>
+    <description>
+        <p>
+            Seek -10s.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[p_<S-Left> p_H]]></tags>
+    <spec><oa>count</oa>H</spec>
+    <description>
+        <p>
+            Seek +1m.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags><![CDATA[p_<S-Right> p_L]]></tags>
+    <spec><oa>count</oa>L</spec>
+    <description>
+        <p>
+            Seek -1m.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:see :seek</tags>
+    <spec>:see<oa>k</oa> <a>[HH:]MM:SS]</a></spec>
+    <spec>:see<oa>k</oa> +<a>time[hms]</a> | -<a>time[hms]</a></spec>
+    <description>
+        <p>
+            Seek to an absolute or relative position in a track. The position
+            can be given in seconds (s), minutes (m), or hours (h). If the unit
+            is not specified then seconds is assumed. The position is absolute
+            unless the value is prefixed with "-" or "+".
+        </p>
+        <p>
+            Positions may also be specified in <a>[HH:]MM:SS</a> format.
+        </p>
+    </description>
+</item>
+
+<h2 tag="volume">Adjusting the volume</h2>
+
+<item>
+    <tags>p_+ p_=</tags>
+    <spec>+</spec>
+    <spec>=</spec>
+    <description>
+        <p>
+            Increase volume by 5% of the maximum.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_-</tags>
+    <spec>-</spec>
+    <description>
+        <p>
+            Decrease volume by 5% of the maximum.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>:vol :volume</tags>
+    <spec>vol<oa>[ume]</oa> <a>value</a></spec>
+    <spec>vol<oa>[ume]</oa> +<a>value</a> | -<a>value</a></spec>
+    <description>
+        <p>
+            Set the player volume. <a>value</a> can be an absolute value
+            between 0 and 100% or a relative value if prefixed with "-" or "+".
+        </p>
+    </description>
+</item>
+
+<h2 tag="playlists">Managing playlists</h2>
+
+<item>
+    <tags>:load</tags>
+    <spec>:load <oa>playlist</oa></spec>
+    <description>
+        <p>
+            Load <a>playlist</a>. If no playlist is specified then the main
+            library view is loaded.
+        </p>
+    </description>
+</item>
+
+<h2 tag="media-view view">Changing media views</h2>
+
+<item>
+    <tags>:mediav :mediaview</tags>
+    <spec>:mediav<oa>ew</oa> <a>view</a></spec>
+    <description>
+        <p>
+            Change the media view to <a>view</a>. This can only be run when the
+            media tab is the current tab.
+        </p>
+    </description>
+</item>
+
+<h2 tag="search">Search commands</h2>
+
+<item>
+    <tags>p_/</tags>
+    <spec>/<a>pattern</a></spec>
+    <description>
+        <p>
+            Search forward for a track matching <a>pattern</a> in the visible
+            media view.
+        </p>
+    </description>
+</item>
+
+<!--
+<item>
+    <tags>p_?</tags>
+    <spec>?<a>pattern</a></spec>
+    <description>
+        <p>
+            Search backwards for a track matching <a>pattern</a> in the visible
+            media view.
+        </p>
+    </description>
+</item>
+-->
+
+<item>
+    <tags>p_n</tags>
+    <spec>n</spec>
+    <description>
+        <p>
+            Find the next track. Repeats the last search. If the search hits
+            BOTTOM of the view, it continues from TOP.
+        </p>
+    </description>
+</item>
+
+<item>
+    <tags>p_N</tags>
+    <spec>N</spec>
+    <description>
+        <p>
+            Find the previous track. Repeats the last search in the opposite
+            direction. If the search hits TOP of the view, it continues from
+            BOTTTOM.
+        </p>
+    </description>
+</item>
+
+<h2 tag="rating">Rating tracks</h2>
+
+<item>
+    <tags><![CDATA[p_<C-5> p_<C-4> p_<C-3> p_<C-2> p_<C-1> p_<C-0>]]></tags>
+    <spec><![CDATA[<C-0>]]></spec>
+    <spec><![CDATA[<C-1>]]></spec>
+    <spec><![CDATA[<C-2>]]></spec>
+    <spec><![CDATA[<C-3>]]></spec>
+    <spec><![CDATA[<C-4>]]></spec>
+    <spec><![CDATA[<C-5>]]></spec>
+    <description>
+        <p>
+            Rate the current track with N stars.
+        </p>
+    </description>
+</item>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/locale/en-US/tabs.xml b/melodactyl/locale/en-US/tabs.xml
new file mode 100644 (file)
index 0000000..1514253
--- /dev/null
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<span replace=":window"/>
+<span replace=":u"/>
+<span replace=":undoa"/>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/melodactyl/skin/icon.png b/melodactyl/skin/icon.png
new file mode 100644 (file)
index 0000000..3d16d3f
Binary files /dev/null and b/melodactyl/skin/icon.png differ
diff --git a/pentadactyl/AUTHORS b/pentadactyl/AUTHORS
new file mode 100644 (file)
index 0000000..6ebc13d
--- /dev/null
@@ -0,0 +1,43 @@
+Developers:
+  * Kris Maglione
+  * Doug Kearns <dougkearns@gmail.com>
+  * anekos <anekos@snca.net>
+  * teramako <teramako@gmail.com>
+  * Štěpán Němec
+
+Inactive/former developers:
+  * Viktor Kojouharov (Виктор Кожухаров)
+  * Marco Candrian <mac@calmar.ws>
+  * Daniel Bainton <dpb .AT. driftaway .DOT. org>
+  * Konstantin Stepanov <milezv@yandex.ru>
+  * Tim Hammerquist <penryu@gmail.com>
+  * Ted Pavlic <ted@tedpavlic.com>
+  * janus_wel <janus.wel.3@gmail.com>
+  * Martin Stubenschrott <stubenschrott@vimperator.org>
+  * Conrad Irwin
+
+Patches (in no special order):
+  * Ruud Grosmann ('followhints' option)
+  * Xie&Tian (multibyte support for hints, doc fixes)
+  * Juergen Descher
+  * Kazuo (count support for ctrl-^)
+  * Daniel Schaffrath (;b support)
+  * Dominik Meister (:b# support and other buffer related commands)
+  * Lukas Mai
+  * Guido Van Hoecke
+  * Daniel Trstenjak (various things with hints)
+  * M.Terada (suggest engines)
+  * Muthu Kannan (ctrl-v support)
+  * Lars Kindler (:buffer(s) functionality)
+  * Lee Hinman (:open ./.. support)
+  * Bart Trojanowski (Makefile)
+  * Hannes Rist (:set titlestring support)
+  * Nikolai Weibull ($PENTADACTYL_HOME)
+  * Joseph Xu (supporting multiple top level windows better)
+  * Raimon Grau Cuscó (document relationship navigation - ]], [[)
+  * Ryan Zheng (ctrl-x/a support)
+  * Dan Boger (:set online support)
+  * Štěpán Němec (help copy-editing and favicon support)
+  * Jarkko Oranen ('maxitems' option)
+  * Gary Katsevman ('private' option)
+  * Srinath Krishna (disable password field editing)
diff --git a/pentadactyl/Donors b/pentadactyl/Donors
new file mode 100644 (file)
index 0000000..ecef674
--- /dev/null
@@ -0,0 +1,139 @@
+2009:
+* Francois-Xavier Gsell
+* Ken Takano
+* Daniel Bainton (web hosting for about a year - thanks a lot!)
+* 渡邉 丈洋 (takehiro78)
+* Tony Rein
+* Daniel Bentsur
+* Ganesh Hegde
+* Elbert Hannah
+* Oliver Schaefer (2nd donation this year, and largest one in 2009! - Thanks!)
+* James Davis
+* Gregg Archer
+* James Henderson
+* Hiroshi Okada
+* Imron Alston
+* Luke Wagner
+* Deepan Gandhi
+* Guilherme Freitas
+* Robert Johnston
+* Jim Aragon
+* Norio Sugimoto
+* Catatonic Recording
+* Wataru Inoue (first one donating via addons.mozilla.org)
+* Krishnakumar Subramanian
+* Natan Zohar
+* Frederic Hebert
+* kursad karatas
+* Thomas Svensen (the first donor who donated 3 times! (once every year))
+* Máté Nagy
+* Jessica Pavlin
+* David Wolfe
+* Sigurdur Finnsson
+* Pixel G
+* Joel Rosario
+* Alvaro Novo
+* Robert Heckel
+* Daniel Collin
+* Lucien Grondin
+* Christoph Petzold
+* Bjoern Steinbrink
+* Erlend Hamberg
+* Fabien Benetou
+* Arvin Moezzi
+* Calogero Lo Leggio
+* Sapan Bhatia
+* Gavin Sinclair
+* Stephen Borchert
+* Convolution
+* Brian Hall
+* Daniel Hahler
+* Per-Henrik Lundblom
+* David C Foor
+* Oliver Schaefer
+* Paul Moss
+* Yongji Zhang
+* Brian Peiris
+* Peleg Michaeli ("Every hand revealed" from my amazon.de wishlist)
+* InspireFocus
+* Michael Fremont
+* Kamil Dworakowski
+* Jonathan Austin
+* Steven Romanow
+
+2008:
+* John Baber
+* Mark Orr
+* George Lowell (biggest donor so far!)
+* Keith Waclena
+* Christopher Glazner
+* Mats Vestin
+* Berni Joss
+* Peter Rufer
+* Yann Le Du
+* Wilson Bilkovich
+* Galen Taylor
+* Ben Hoffstein
+* Luc St-Louis
+* Robert Meerman
+* Silvio Di Stefano
+* Lance Feagan
+* Alexander Klink
+* Chaz McGarvey
+* Jesse Hathaway
+* Takayuki Tsukitani
+* Victor Nemkov
+* John Lusth
+* Thomas Svensen
+* Ryan McBride
+* Brian Clark
+* Gavin Gilmour
+* Sivaraj Doddannan
+* Michael Hrabanek
+* Nigel McNie
+* Ben Damm
+* Anton Kovalenko
+* Paulo Tanimoto
+* Paul Sobey
+* Olivier Guéry
+* Dotan Cohen
+* Daniel Schaffrath
+* Sam Griffin
+* Ivan Pantuyev
+* Spike Spiegal
+* Mark Hashimoto
+* Anirudh Sanjeev
+* Ted Pavlic
+* Jacqueline Wegscheid
+* Kashyap Paidimarri
+* Gabriel Gellner
+* Marco Candrian
+* Ovidiu Curcan
+* Ivo-Jose Jimenez-Ramos (2x)
+* Andrew Pantyukhin
+* Kurtis Rader
+
+2007:
+* Richard Terrell
+* Benjamin Sergeant
+* Frank Schiebel
+* Cillian de Roiste (2x)
+* David Thompson
+* Christian Walther
+* Ivo-Jose Jimenez-Ramos
+* Robert Heckel
+* Ramana Kumar
+* Thomas Svensen
+* Ian Taylor
+* Albert Menkveld
+* Nathan Saper
+* Paulo Tanimoto
+* Nigel McNie
+* Richard Dooling
+* Giuseppe Guida
+* Stefan Krauth
+* Robert Heckel
+* Miron Tewfik
+* Sjoerd Siebinga
+* Ben Klemens
+* Andrew Pantyukhin
diff --git a/pentadactyl/Makefile b/pentadactyl/Makefile
new file mode 100644 (file)
index 0000000..74afcb9
--- /dev/null
@@ -0,0 +1,12 @@
+#### configuration
+
+NAME          = pentadactyl
+
+FIREFOX      ?= firefox
+HOSTAPP      ?= $(FIREFOX)
+PROFILEPATHS ?= "$$HOME/.mozilla/firefox" \
+               "$$HOME/Library/Mozilla/Firefox" \
+               "$$APPDATA/Mozilla/Firefox" \
+               "$$AppData/Mozilla/Firefox"
+
+include ../common/Makefile
diff --git a/pentadactyl/NEWS b/pentadactyl/NEWS
new file mode 100644 (file)
index 0000000..aece912
--- /dev/null
@@ -0,0 +1,206 @@
+1.0b6:
+    • Extensive Firefox 4 support, including:
+      - Fully restartless. Can now be installed, uninstalled,
+        enabled, disabled, and upgraded without restarting Firefox.
+        [b4]
+      - Tabs in :buffer completions and listings are grouped
+        by panorama groups. [b1]
+      - Only visible tabs are considered in tab numbering,
+        n_gt, n_gn, etc. [b1]
+    • Performance improvements:
+      - Improved startup time by a factor of 7. [b1]
+      - Further improved startup time. [b6]
+      - Significant completion speed improvements, especially for
+        JavaScript. [b1]
+    • Added site-local and script-local groups: [b6]
+      - Added the :group command to define and select groups.
+      - Added the -group flag to :abbreviate, :autocmd, :command,
+        :map, :style, and friends.
+      - Mappings and commands can now be bound to groups which
+        execute only for certain websites.
+      - Autocommands, commands, mappings, and styles are now
+        automatically added to per-script groups so that most traces
+        of a script can be easily purged.
+    • Greatly improved private mode support and :sanitize command.
+      - Full integration with Firefox data clearing dialogs. [b3]
+      - Support for sanitizing items at shutdown. [b3]
+      - Fine-grained control over what data is sanitized and for
+        what timespan. [b1]
+      - Support for sanitizing reference to particular hosts,
+        everywhere from command-line and message history to option
+        values and cookies. [b1]
+    • New and much more powerful incremental search implementation.
+      Improvements over the standard Firefox find include: [b1]
+      - Starts at the cursor position in the currently selected
+        frame, unlike Firefox, which always starts at the start of
+        the first frame.
+      - Returns the cursor and viewport to their original position
+        on cancel.
+      - Backtracks to the first successful match after pressing
+        backspace.
+      - Supports reverse incremental search.
+      - Input boxes are not focused when matches are highlighted.
+    • Ex command parsing improvements, including:
+      - Multiple Ex commands may now be separated by | [b1]
+      - Commands can continue over multiple lines in RC files by
+        prefixing the continuation lines with a \ [b3]
+      - The \ character is no longer treated specially within single
+        quotes, i.e., 'fo\o''bar' ⇒ fo\o'bar [b1]
+    • The command line is now hidden by default. Added c, C, and M to
+      'guioptions'. [b4]
+    • Hint mode improvements, including:
+      - Added g; continued extended hint mode, which allows
+        selecting multiple hints. Removed ;F. [b1]
+      - Hints are now updated after scrolling and window resizing. [b3]
+      - ;s now prompts for a filename on the command-line rather
+        than in a dialog. [b5]
+      - Added ;a and ;S modes for creating bookmarks and search keywords. [b4][b3]
+      - Added 'hintkeys' option. [b2]
+      - Added "transliterated" option to 'hintmatching'. [b1]
+    • General completion improvements
+      - JavaScript completion improvements, including: [b2]
+        + The prototype of the function whose arguments are currently
+          being typed is displayed during completion.
+        + Non-enumerable global properties are now completed for the
+          global object, including XMLHttpRequest and encodeURI.
+      - The concept of completion contexts is now exposed to the user
+        (see :contexts), allowing for powerful and fine-grained
+        completion system customization. [b1]
+    • The external editor can now be configured to open to a given
+      line number and column, used for opening source links and
+      editing input fields with i_<C-i>. See :h 'editor'. [b4]
+    • Mapping changes:
+      - It's now possible to map keys in many more modes, including
+        Hint, Multi-line Output, and Menu. [b4]
+      - Added site-specific mapping groups and related command
+        changes. [b6]
+      - Added 'timeout' and 'timeoutlen' options. [b6]
+      - Added <A-b> to execute a builtin mapping. [b6]
+      - Added <A-m>l and <A-m>s to aid in the construction of
+        macros. [b6]
+      - Added <Pass> pseudo-key. [b6]
+      - Removed the implicit page load delays during macro playback. [b6]
+      - Added the base modes Base, Main, and Command which other
+        modes inherit key bindings from. [b6]
+    • Command changes:
+      - :viusage, :optionusage and :exusage were replaced with :listkeys,
+        :listoptions and :listcommands, providing more powerful and
+        consistent interactive help facility (improvements include
+        listing keys for modes other than Normal, filtering the output,
+        and linking to source code locations). [b4]
+      - :downloads now opens a download list in the multi-line output
+        buffer. [b6]
+      - :extensions has been replaced with a more powerful :addons. [b6]
+      - :javascript! now opens a Read Eval Print Loop. [b6]
+      - Added -arg flag to :map. [b6]
+      - Added -literal flag to :command. [b6]
+      - Added :completions command. [b6]
+      - Added :cookies command. [b3]
+      - :extadd now supports remote URLs as well as local files on Firefox 4. [b2]
+      - Added :if/:elseif/:else/:endif conditionals. [b3]
+      - Added -charset and -post to :bmark. [b5]
+      - Added -keyword, -tags, -title to :delbmarks. [b2]
+      - Added :extrehash, :exttoggle, :extupdate, and :rehash commands. [b5]
+      - :abclear, :comclear and :mapclear have been substituted by a bang now
+        accepted by :unabbreviate, :delcommand and :unmap, respectively. [b6]
+      - Added :feedkeys command. [b4]
+      - Added -sort option to :history. [b4]
+      - Added several new options, including -javascript, to :abbreviate and
+        :map. [b2]
+      - Added :mksyntax command to auto-generate Vim syntax files. [b4]
+      - :open now only opens files beginning with /, ./, ../, or ~/ [b1]
+      - :saveas now provides completions for default file names, and
+        automatically chooses a filename when the save target is a
+        directory. [b4]
+      - :sidebar now accepts a ! flag to toggle the sidebar rather
+        than open it unconditionally. [b6]
+      - Added :write !cmd and :write >>file. [b3]
+      - Added :yank command. [b3]
+      - :delmarks, :marks and :qmarks now also accept ranges, same as
+        :delqmarks. [b4]
+      - :command now accepts comma-separated alternative command names. [b4]
+      - :command -complete custom now also accepts a completions array, see
+        :h :command-completion-custom. [b4]
+    • Improvements to :style and :highlight:
+      - Added -link flag to :highlight. [b4]
+      - Added -agent flag to :style. [b2]
+      - The -append flag now updates existing properties rather than
+        simply appending its arguments to the previous value. [b4]
+      - Active filters are now highlighted in :style listings. [b4]
+      - :style-related commands now divide their completions between
+        those active and inactive for the current site. [b4]
+      - CSS property name completion is now available. [b4]
+      - :delstyle, :styleenable, :styledisable and :styletoggle accept a !
+        to operate on all styles. [b6]
+    • IMPORTANT option changes:
+      - Option value quoting has changed. List options will
+        no longer be split at quoted commas and the option name,
+        operators, and = sign may no longer be quoted. This will
+        break certain automatically-generated configuration files.
+        See :help
+        [stringlist]. [b2]
+      - Boolean options no longer accept an argument. [b4]
+      - 'cdpath' and 'runtimepath' no longer treat ",,"
+        specially. Use "." instead. [b2]
+      - 'extendedhinttags' is now a [regexpmap] rather than a
+        string. [b2]
+      - 'guioptions' default value has changed. [b4]
+      - 'hinttags' and 'extendedhinttags' now treat their values as
+        CSS selectors by default. [b6]
+      - 'incsearch', 'hlsearch', 'ignorecase', and 'smartcase' have
+        been replaced with 'incfind', 'hlfind', and 'findcase'. [b4]
+      - 'laststatus' has been replaced with the "s" flag in
+        'guioptions'. [b4]
+      - 'linksearch' has been removed.  The \l search modifier can
+        still be used for this purpose. [b4]
+      - 'loadplugins' is now a regexplist option rather than
+        a boolean. [b2]
+      - 'mapleader' is now an option rather than a :let
+        variable. [b4]
+      - 'passkeys' is now a sitemap with key chain support rather
+        than a regexpmap. [b6]
+      - 'showmode' is now a regexplist. [b6]
+      - 'showstatuslinks' and 'showtabline' are now string options. [b4]
+    • IMPORTANT: Command script files now use the *.penta file extension. [b2]
+    • IMPORTANT: Plugins are now loaded from the 'plugins/'
+      directory in 'runtimepath' rather than 'plugin/'. [b1]
+    • Option changes:
+      - Added [stringmap], [regexplist], and [regexpmap] option
+        types. [b1]
+      - Added [sitelist] and [sitemap] option types. [b6]
+      - Added "bookmarks", "diverted", and "links" to 'activate'
+        option. [b2]
+      - Added 'altwildmode' and c_<A-Tab> command-line key binding. [b2]
+      - Added 'autocomplete' option for specifying which completion
+        groups should be auto-completed. [b2]
+      - Added 'banghist' option. [b1]
+      - Replaced 'focuscontent' with 'strictfocus'. [b1]
+      - 'complete' now defaults to "slf" but file completion only
+        triggers when the URL begins as above. [b1]
+      - Added 'passkeys' option. [b3]
+      - Changed 'urlseparator' default value to "|". [b3]
+      - Added "passwords" and "venkman" dialogs to :dialog. [b2]
+      - Added 'wildanchor' option. [b2]
+      - Added 'cookies', 'cookieaccept', and 'cookielifetime' options. [b3]
+    • Added BookmarkChange, BookmarkRemove autocommands. [b2]
+    • Removed the :source line at the end of files generated by
+      :mkpentadactylrc. [b2]
+    • gf now toggles between source and content view.
+      The | key binding has been removed. [b1]
+    • Page zoom information is now shown in the status bar, and
+      change in zoom status no longer appears in :messages. [b1]
+    • Added n_ZO, n_ZI, n_ZM, and n_ZR as aliases for n_zO, n_zI,
+      n_zM, and n_zR. [b1]
+    • Completion list now behaves better when the multi-line output
+      window is displayed. [b1]
+    • Major help system improvements:
+      - Plugins may now provide full-fledged :help documentation. [b1]
+      - Add basic plugin authorship documentation. [b1]
+      - The help system is newly modularized and features significant
+        updates, rewrites, and formatting improvements. [b1]
+      - Added <A-F1> to open the single, consolidated help page. [b5]
+    • Removed :beep. [b2]
+    • Removed :edit, :tabedit, and :winedit aliases. [b2]
+    • Removed :play. [b2]
+
+# vim:set ft=conf sts=2 sw=2 et fo=tcn2 tw=68:
diff --git a/pentadactyl/TODO b/pentadactyl/TODO
new file mode 100644 (file)
index 0000000..9e11e72
--- /dev/null
@@ -0,0 +1,70 @@
+Priority list:
+1-9 as in Vim (9 = required for next release, 5 = would be nice, 1 = probably not)
+
+ARCHITECTURE:
+- decide on how to document Arrays in our jsdoc(ish) documentation. Host docs
+  on the website - is there even a documentation tool that can parse our source
+  sensibly?
+
+- unify the disgusting hodge podge of contract specification styles
+
+REFACTORING:
+- remove unnecessary usage of "self"
+
+BUGS:
+- insert abbreviations broken on <space>
+- :sidebar improvements (:sidebar! Downloads while downloads is open should refocus the sidebar)
+- ;s saves the page rather than the image
+- RC file is sourced once per window
+
+FEATURES:
+8 registers
+8 Document Caret and Visual modes.
+8 replace global variables with plugin scoped user options
+8 fix local options
+8 adaptive timeout for auto-completions, :set completions can be updated more often than
+  :open foo
+8 use the storage module for autocommands
+8 add support for filename special characters such as %
+8 :redir and 'verbosefile'
+8 middleclick in content == p, and if command line is open, paste there the clipboard buffer
+8 all search commands should start searching from the top of the visible viewport
+8 <C-o>/<C-i> should work as in Vim (i.e., save page positions as well as
+  locations in the history list).
+8 jump to the next heading with ]h, next image ]i, previous textbox [t and so on
+7 clean up error message codes and document
+7 add search capability to MOW
+7 describe-key command (prompt for a key and display its binding with documentation)
+7 Specify all INSERT mode mappings rather than falling through to the host apps
+  mystery meat mappings.
+7 use ctrl-n/p in insert mode for word completion
+7 implement QuickFix window based on ItemList
+7 wherever possible: get rid of dialogs and ask console-like dialog questions
+  or write error prompts directly on the webpage or with :echo()
+7 [d could go to the last domain in the history stack. So if I browse from
+  google to another page and click 10 links there, [d would take me back to the google page
+  opera's fast forward does something like this
+7 make an option to disable session saving by default when you close Firefox
+7 The output of the pageinfo-command should contain the security-information of ssl-encrypted sites
+7 :grep support (needs location list)
+7 map <A-n/p> in command-line mode to something useful (such as Down/Up) and
+  possibly <C-n/p> to Vim-like behaviour
+6 :mksession
+6 add [count] support to :b* and :tab* commands where missing
+6 check/correct spellings in insert mode with some mappings
+6 add more autocommands (TabClose, TabOpen, TabChanged etc)
+6 Use ctrl-w+j/k/w to switch between sidebar, content, preview window
+6 Command :tags for getting a list of used tags
+6 ;?<hint> should show more information
+6 Add information to dactyl/HACKING file about testing and optimization
+5 when looking at a zoomed out image (because it's large), zi should zoom in
+  maybe with this? : http://mxr.mozilla.org/seamonkey/source/content/html/document/public/nsIImageDocument.idl
+5 make a command to search within google search results
+  (http://gadelkareem.com/2007/01/28/using-google-ajax-api-as-an-array/)
+  maybe impossible, needs a per-site key from google
+4 } { should jump to the next paragraph of the page
+3 A format for 'guitablabel' and 'statusline'
+3 add a command-line window (:help cmdline-window in Vim).
+3 Splitting Windows with [:sp :vsp ctrl-w,s ctrl-w,v] and closing with [ctrl-w,q], moving with [ctrl-w,w or tab]
+  have a look into the split browser extension
+1 Reformat dactyl/HACKING so that git diff can find sections and report changes @ somewhere
diff --git a/pentadactyl/bootstrap.js b/pentadactyl/bootstrap.js
new file mode 120000 (symlink)
index 0000000..ff1024d
--- /dev/null
@@ -0,0 +1 @@
+../common/bootstrap.js
\ No newline at end of file
diff --git a/pentadactyl/chrome.manifest b/pentadactyl/chrome.manifest
new file mode 120000 (symlink)
index 0000000..4032143
--- /dev/null
@@ -0,0 +1 @@
+../common/chrome.manifest
\ No newline at end of file
diff --git a/pentadactyl/components/commandline-handler.js b/pentadactyl/components/commandline-handler.js
new file mode 120000 (symlink)
index 0000000..aa8427b
--- /dev/null
@@ -0,0 +1 @@
+../../common/components/commandline-handler.js
\ No newline at end of file
diff --git a/pentadactyl/components/protocols.js b/pentadactyl/components/protocols.js
new file mode 120000 (symlink)
index 0000000..7c25b74
--- /dev/null
@@ -0,0 +1 @@
+../../common/components/protocols.js
\ No newline at end of file
diff --git a/pentadactyl/content/config.js b/pentadactyl/content/config.js
new file mode 100644 (file)
index 0000000..2dd3b53
--- /dev/null
@@ -0,0 +1,371 @@
+// Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
+// Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+var Config = Module("config", ConfigBase, {
+    name: "pentadactyl",
+    appName: "Pentadactyl",
+    idName: "PENTADACTYL",
+    host: "Firefox",
+    hostbin: "firefox",
+
+    commandContainer: "browser-bottombox",
+
+    Local: function Local(dactyl, modules, window)
+        let ({ config } = modules) ({
+
+        completers: Class.memoize(function () update({ sidebar: "sidebar", window: "window" }, this.__proto__.completers)),
+
+        dialogs: {
+            about: ["About Firefox",
+                function () { window.openDialog("chrome://browser/content/aboutDialog.xul", "_blank", "chrome,dialog,modal,centerscreen"); }],
+            addbookmark: ["Add bookmark for the current page",
+                function () { window.PlacesCommandHook.bookmarkCurrentPage(true, window.PlacesUtils.bookmarksRootId); }],
+            addons: ["Manage Add-ons",
+                function () { window.BrowserOpenAddonsMgr(); }],
+            bookmarks: ["List your bookmarks",
+                function () { window.openDialog("chrome://browser/content/bookmarks/bookmarksPanel.xul", "Bookmarks", "dialog,centerscreen,width=600,height=600"); }],
+            checkupdates: ["Check for updates",
+                function () { window.checkForUpdates(); },
+                function () "checkForUpdates" in window],
+            cookies: ["List your cookies",
+                function () { window.toOpenWindowByType("Browser:Cookies", "chrome://browser/content/preferences/cookies.xul", "chrome,dialog=no,resizable"); }],
+            console: ["JavaScript console",
+                function () { window.toJavaScriptConsole(); }],
+            customizetoolbar: ["Customize the Toolbar",
+                function () { window.BrowserCustomizeToolbar(); }],
+            dominspector: ["DOM Inspector",
+                function () { window.inspectDOMDocument(window.content.document); },
+                function () "inspectDOMDocument" in window],
+            downloads: ["Manage Downloads",
+                function () { window.toOpenWindowByType("Download:Manager", "chrome://mozapps/content/downloads/downloads.xul", "chrome,dialog=no,resizable"); }],
+            history: ["List your history",
+                function () { window.openDialog("chrome://browser/content/history/history-panel.xul", "History", "dialog,centerscreen,width=600,height=600"); }],
+            import: ["Import Preferences, Bookmarks, History, etc. from other browsers",
+                function () { window.BrowserImport(); }],
+            openfile: ["Open the file selector dialog",
+                function () { window.BrowserOpenFileWindow(); }],
+            pageinfo: ["Show information about the current page",
+                function () { window.BrowserPageInfo(); }],
+            pagesource: ["View page source",
+                function () { window.BrowserViewSourceOfDocument(window.content.document); }],
+            passwords: ["Passwords dialog",
+                function () { window.openDialog("chrome://passwordmgr/content/passwordManager.xul"); }],
+            places: ["Places Organizer: Manage your bookmarks and history",
+                function () { window.PlacesCommandHook.showPlacesOrganizer(window.ORGANIZER_ROOT_BOOKMARKS); }],
+            preferences: ["Show Firefox preferences dialog",
+                function () { window.openPreferences(); }],
+            printpreview: ["Preview the page before printing",
+                function () { window.PrintUtils.printPreview(window.PrintPreviewListener || window.onEnterPrintPreview, window.onExitPrintPreview); }],
+            printsetup: ["Setup the page size and orientation before printing",
+                function () { window.PrintUtils.showPageSetup(); }],
+            print: ["Show print dialog",
+                function () { window.PrintUtils.print(); }],
+            saveframe: ["Save frame to disk",
+                function () { window.saveFrameDocument(); }],
+            savepage: ["Save page to disk",
+                function () { window.saveDocument(window.content.document); }],
+            searchengines: ["Manage installed search engines",
+                function () { window.openDialog("chrome://browser/content/search/engineManager.xul", "_blank", "chrome,dialog,modal,centerscreen"); }],
+            selectionsource: ["View selection source",
+                function () { modules.buffer.viewSelectionSource(); }],
+            venkman: ["The JavaScript debugger",
+                function () { dactyl.assert("start_venkman" in window, "Venkman is not installed"); window.start_venkman() },
+                function () "start_venkman" in window]
+        },
+
+        removeTab: function removeTab(tab) {
+            if (this.tabbrowser.mTabs.length > 1)
+                this.tabbrowser.removeTab(tab);
+            else {
+                if (modules.buffer.uri.spec !== "about:blank" || window.getWebNavigation().sessionHistory.count > 0) {
+                    dactyl.open("about:blank", dactyl.NEW_BACKGROUND_TAB);
+                    this.tabbrowser.removeTab(tab);
+                }
+                else
+                    dactyl.beep();
+            }
+        },
+
+        get tempFile() {
+            let prefix = this.name;
+            try {
+                prefix += "-" + window.content.document.location.hostname;
+            }
+            catch (e) {}
+
+            return prefix + ".tmp";
+        }
+    }),
+
+    overlayChrome: ["chrome://browser/content/browser.xul"],
+
+    styleableChrome: ["chrome://browser/content/browser.xul"],
+
+    autocommands: {
+        BookmarkAdd: "Triggered after a page is bookmarked",
+        BookmarkChange: "Triggered after a page's bookmark is changed",
+        BookmarkRemove: "Triggered after a page's bookmark is removed",
+        ColorScheme: "Triggered after a color scheme has been loaded",
+        DOMLoad: "Triggered when a page's DOM content has fully loaded",
+        DownloadPost: "Triggered when a download has completed",
+        Fullscreen: "Triggered when the browser's fullscreen state changes",
+        LocationChange: "Triggered when changing tabs or when navigation to a new location",
+        PageLoadPre: "Triggered after a page load is initiated",
+        PageLoad: "Triggered when a page gets (re)loaded/opened",
+        PrivateMode: "Triggered when private mode is activated or deactivated",
+        Sanitize: "Triggered when a sanitizeable item is cleared",
+        ShellCmdPost: "Triggered after executing a shell command with :!cmd",
+        Enter: "Triggered after Firefox starts",
+        LeavePre: "Triggered before exiting Firefox, just before destroying each module",
+        Leave: "Triggered before exiting Firefox"
+    },
+
+    defaults: {
+        complete: "slf",
+        guioptions: "bCrs",
+        showtabline: "always",
+        titlestring: "Pentadactyl"
+    },
+
+    features: set([
+        "bookmarks", "hints", "history", "marks", "quickmarks", "sanitizer",
+        "session", "tabs", "tabs_undo", "windows"
+    ]),
+
+    guioptions: {
+        m: ["Menubar",      ["toolbar-menubar"]],
+        T: ["Toolbar",      ["nav-bar"]],
+        B: ["Bookmark bar", ["PersonalToolbar"]]
+    },
+
+    hasTabbrowser: true,
+
+    scripts: [
+        "browser",
+        "bookmarkcache",
+        "bookmarks",
+        "history",
+        "quickmarks",
+        "sanitizer",
+        "tabs"
+    ],
+
+    sidebars: {
+        viewAddons:      ["Add-ons",     "A", "chrome://mozapps/content/extensions/extensions.xul"],
+        viewConsole:     ["Console",     "C", "chrome://global/content/console.xul"],
+        viewDownloads:   ["Downloads",   "D", "chrome://mozapps/content/downloads/downloads.xul"],
+        viewPreferences: ["Preferences", "P", "about:config"]
+    }
+}, {
+}, {
+    commands: function (dactyl, modules, window) {
+        const { commands, completion, config } = modules;
+        const { document } = window;
+
+        commands.add(["winon[ly]"],
+            "Close all other windows",
+            function () {
+                dactyl.windows.forEach(function (win) {
+                    if (win != window)
+                        win.close();
+                });
+            },
+            { argCount: "0" });
+
+        commands.add(["pref[erences]", "prefs"],
+            "Show " + config.host + " preferences",
+            function (args) {
+                if (args.bang) // open Firefox settings GUI dialog
+                    dactyl.open("about:config", { from: "prefs" });
+                else
+                    window.openPreferences();
+            },
+            {
+                argCount: "0",
+                bang: true
+            });
+
+        commands.add(["sbcl[ose]"],
+            "Close the sidebar window",
+            function () {
+                if (!document.getElementById("sidebar-box").hidden)
+                    window.toggleSidebar();
+            },
+            { argCount: "0" });
+
+        commands.add(["sideb[ar]", "sb[ar]", "sbop[en]"],
+            "Open the sidebar window",
+            function (args) {
+                function compare(a, b) util.compareIgnoreCase(a, b) == 0
+                let title = document.getElementById("sidebar-title");
+
+                dactyl.assert(args.length || title.value || args.bang && config.lastSidebar,
+                              "Argument required");
+
+                if (!args.length)
+                    return window.toggleSidebar(title.value ? null : config.lastSidebar);
+
+                // focus if the requested sidebar is already open
+                if (compare(title.value, args[0])) {
+                    if (args.bang)
+                        return window.toggleSidebar();
+                    return dactyl.focus(document.getElementById("sidebar-box"));
+                }
+
+                let menu = document.getElementById("viewSidebarMenu");
+
+                for (let [, panel] in Iterator(menu.childNodes))
+                    if (compare(panel.getAttribute("label"), args[0])) {
+                        let elem = document.getElementById(panel.observes);
+                        if (elem)
+                            elem.doCommand();
+                        return;
+                    }
+
+                return dactyl.echoerr("No sidebar " + args[0] + " found");
+            },
+            {
+                argCount: "?",
+                bang: true,
+                completer: function (context) {
+                    context.ignoreCase = true;
+                    return completion.sidebar(context);
+                },
+                literal: 0
+            });
+
+        commands.add(["wind[ow]"],
+            "Execute a command and tell it to output in a new window",
+            function (args) {
+                dactyl.withSavedValues(["forceNewWindow"], function () {
+                    this.forceNewWindow = true;
+                    this.execute(args[0], null, true);
+                });
+            },
+            {
+                argCount: "+",
+                completer: function (context) completion.ex(context),
+                literal: 0,
+                subCommand: 0
+            });
+
+        commands.add(["winc[lose]", "wc[lose]"],
+            "Close window",
+            function () { window.close(); },
+            { argCount: "0" });
+
+        commands.add(["wino[pen]", "wo[pen]"],
+            "Open one or more URLs in a new window",
+            function (args) {
+                if (args[0])
+                    dactyl.open(args[0], dactyl.NEW_WINDOW);
+                else
+                    dactyl.open("about:blank", dactyl.NEW_WINDOW);
+            },
+            {
+                completer: function (context) completion.url(context),
+                domains: function (args) commands.get("open").domains(args),
+                literal: 0,
+                privateData: true
+            });
+    },
+    completion: function (dactyl, modules, window) {
+        const { CompletionContext, bookmarkcache, completion } = modules;
+        const { document } = window;
+
+        var searchRunning = null; // only until Firefox fixes https://bugzilla.mozilla.org/show_bug.cgi?id=510589
+        completion.location = function location(context) {
+            if (!services.autoCompleteSearch)
+                return;
+
+            if (searchRunning) {
+                searchRunning.completions = searchRunning.completions;
+                searchRunning.cancel();
+            }
+
+            context.anchored = false;
+            context.compare = CompletionContext.Sort.unsorted;
+            context.filterFunc = null;
+
+            let words = context.filter.toLowerCase().split(/\s+/g);
+            context.hasItems = true;
+            context.completions = context.completions.filter(function ({ url, title })
+                words.every(function (w) (url + " " + title).toLowerCase().indexOf(w) >= 0))
+            context.incomplete = true;
+
+            context.format = modules.bookmarks.format;
+            context.keys.extra = function (item) (bookmarkcache.get(item.url) || {}).extra;
+            context.title = ["Smart Completions"];
+
+            context.cancel = function () {
+                this.incomplete = false;
+                if (searchRunning === this) {
+                    services.autoCompleteSearch.stopSearch();
+                    searchRunning = null;
+                }
+            };
+
+            services.autoCompleteSearch.startSearch(context.filter, "", context.result, {
+                onSearchResult: function onSearchResult(search, result) {
+                    if (result.searchResult <= result.RESULT_SUCCESS)
+                        searchRunning = null;
+
+                    context.incomplete = result.searchResult >= result.RESULT_NOMATCH_ONGOING;
+                    context.completions = [
+                        { url: result.getValueAt(i), title: result.getCommentAt(i), icon: result.getImageAt(i) }
+                        for (i in util.range(0, result.matchCount))
+                    ];
+                },
+                get onUpdateSearchResult() this.onSearchResult
+            });
+            searchRunning = context;
+        };
+
+        completion.addUrlCompleter("l",
+            "Firefox location bar entries (bookmarks and history sorted in an intelligent way)",
+            completion.location);
+
+        completion.sidebar = function sidebar(context) {
+            let menu = document.getElementById("viewSidebarMenu");
+            context.title = ["Sidebar Panel"];
+            context.completions = Array.map(menu.childNodes, function (n) [n.getAttribute("label"), ""]);
+        };
+    },
+    events: function (dactyl, modules, window) {
+        modules.events.listen(window, "SidebarFocused", function (event) {
+            modules.config.lastSidebar = window.document.getElementById("sidebar-box")
+                                               .getAttribute("sidebarcommand");
+        }, false);
+    },
+    mappings: function initMappings(dactyl, modules, window) {
+        const { Events, mappings, modes } = modules;
+        mappings.add([modes.NORMAL],
+                     ["<Return>", "<Space>", "<Up>", "<Down>"],
+                     "Handled by " + config.host,
+                     function () Events.PASS_THROUGH);
+    },
+    modes: function (dactyl, modules, window) {
+        const { modes } = modules;
+        config.modes.forEach(function (mode) { modes.addMode.apply(this, mode); });
+    },
+    options: function (dactyl, modules, window) {
+        modules.options.add(["online"],
+            "Set the 'work offline' option",
+            "boolean", true,
+            {
+                setter: function (value) {
+                    if (services.io.offline == value)
+                        window.BrowserOffline.toggleOfflineStatus();
+                    return value;
+                },
+                getter: function () !services.io.offline
+            });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/pentadactyl/content/logo.png b/pentadactyl/content/logo.png
new file mode 100644 (file)
index 0000000..f023912
Binary files /dev/null and b/pentadactyl/content/logo.png differ
diff --git a/pentadactyl/contrib/vim/Makefile b/pentadactyl/contrib/vim/Makefile
new file mode 100644 (file)
index 0000000..5bb780d
--- /dev/null
@@ -0,0 +1,9 @@
+VIMBALL = pentadactyl.vba
+
+vimball: mkvimball.txt syntax/pentadactyl.vim ftdetect/pentadactyl.vim
+       -echo '%MkVimball! ${VIMBALL} .' | vim -u NORC -N -e -s mkvimball.txt
+
+all: vimball
+
+clean:
+       rm -f ${VIMBALL}
diff --git a/pentadactyl/contrib/vim/ftdetect/pentadactyl.vim b/pentadactyl/contrib/vim/ftdetect/pentadactyl.vim
new file mode 100644 (file)
index 0000000..19624df
--- /dev/null
@@ -0,0 +1 @@
+au BufNewFile,BufRead *pentadactylrc*,*.penta set filetype=pentadactyl
diff --git a/pentadactyl/contrib/vim/mkvimball.txt b/pentadactyl/contrib/vim/mkvimball.txt
new file mode 100644 (file)
index 0000000..0350633
--- /dev/null
@@ -0,0 +1,2 @@
+syntax/pentadactyl.vim
+ftdetect/pentadactyl.vim
diff --git a/pentadactyl/install.rdf b/pentadactyl/install.rdf
new file mode 100644 (file)
index 0000000..8b811cd
--- /dev/null
@@ -0,0 +1,40 @@
+<?xml version="1.0"?>
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+    <Description about="urn:mozilla:install-manifest"
+        em:id="pentadactyl@dactyl.googlecode.com"
+        em:type="2"
+        em:name="Pentadactyl"
+        em:version="1.0b6"
+        em:description="Firefox for Vim and Links addicts"
+        em:homepageURL="http://dactyl.sourceforge.net/pentadactyl"
+        em:iconURL="resource://dactyl-local-skin/icon.png"
+        em:bootstrap="true">
+
+        <em:creator>Kris Maglione, Doug Kearns</em:creator>
+        <em:developer>Kris Maglione</em:developer>
+        <em:developer>Doug Kearns</em:developer>
+        <em:developer>Štěpán Němec</em:developer>
+
+        <em:contributor>anekos</em:contributor>
+        <em:contributor>teramako</em:contributor>
+        <em:contributor>Viktor Kojouharov (Виктор Кожухаров)</em:contributor>
+        <em:contributor>Marco Candrian</em:contributor>
+        <em:contributor>Daniel Bainton</em:contributor>
+        <em:contributor>Konstantin Stepanov</em:contributor>
+        <em:contributor>Tim Hammerquist</em:contributor>
+        <em:contributor>Ted Pavlic</em:contributor>
+        <em:contributor>janus_wel</em:contributor>
+        <em:contributor>Martin Stubenschrott</em:contributor>
+        <em:contributor>Conrad Irwin</em:contributor>
+
+        <em:targetApplication>
+            <Description
+                em:id="{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"
+                em:minVersion="3.5"
+                em:maxVersion="4.0.*"/>
+        </em:targetApplication>
+    </Description>
+</RDF>
+
+<!-- vim: set fdm=marker sw=4 ts=4 et: -->
diff --git a/pentadactyl/locale/en-US/all.xml b/pentadactyl/locale/en-US/all.xml
new file mode 100644 (file)
index 0000000..859eef4
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<include href="tutorial" tag="tutorial.xml" insertafter="intro.xml" />
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/pentadactyl/locale/en-US/autocommands.xml b/pentadactyl/locale/en-US/autocommands.xml
new file mode 100644 (file)
index 0000000..3726d8b
--- /dev/null
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl tag="autocommand-list" replace="autocommand-list">
+    <dt>BookmarkAdd</dt>       <dd>Triggered after a page is bookmarked</dd>
+    <dt>BookmarkChange</dt>    <dd>Triggered after a page's bookmark is changed</dd>
+    <dt>BookmarkRemove</dt>    <dd>Triggered after a page's bookmark is removed</dd>
+    <dt>ColorScheme</dt>       <dd>Triggered after a color scheme has been loaded</dd>
+    <dt>DOMLoad</dt>           <dd>Triggered when a page's DOM content has fully loaded</dd>
+    <dt>DownloadPost</dt>      <dd>Triggered when a download has completed</dd>
+    <dt>Fullscreen</dt>        <dd>Triggered when the browser's fullscreen state changes</dd>
+    <dt>LocationChange</dt>    <dd>Triggered when changing tabs or when navigating to a new location</dd>
+    <dt>PageLoadPre</dt>       <dd>Triggered after a page load is initiated</dd>
+    <dt>PageLoad</dt>          <dd>Triggered when a page gets (re)loaded/opened</dd>
+    <dt>PrivateMode</dt>       <dd>Triggered when private mode is activated or deactivated</dd>
+    <dt>Sanitize</dt>          <dd>Triggered when private data are sanitized</dd>
+    <dt>ShellCmdPost</dt>      <dd>Triggered after executing a shell command with <ex>:!</ex><a>cmd</a></dd>
+    <dt>Enter</dt>             <dd>Triggered after &dactyl.host; starts</dd>
+    <dt>LeavePre</dt>          <dd>Triggered before exiting &dactyl.host;, just before destroying each module</dd>
+    <dt>Leave</dt>             <dd>Triggered before exiting &dactyl.host;</dd>
+</dl>
+
+<dl tag="autocommand-args" replace="autocommand-args">
+    <dt>&lt;bookmark></dt>  <dd>The JavaScript bookmark object. Only for <em>Bookmark*</em>.</dd>
+    <dt>&lt;changed></dt>   <dd>The name of the property that has changed. Only for <em>BookmarkChange</em>.</dd>
+    <dt>&lt;doc></dt>       <dd>The document for which the event occurred. Only for <em>DOMLoad</em>, <em>PageLoad</em> and <em>PageLoadPre</em>.</dd>
+    <dt>&lt;file></dt>      <dd>The target destination of a download. Only for <em>DownloadPost</em>.</dd>
+    <dt>&lt;icon></dt>      <dd>The icon associated with <em>&lt;url></em>. Only for <em>Bookmark*</em>.</dd>
+    <dt>&lt;keyword></dt>   <dd>The keywords applied to the bookmark. Only for <em>BookmarkChange</em>, <em>BookmarkRemove</em>.</dd>
+    <dt>&lt;name></dt>      <dd>The name of the item. Only for <em>ColorScheme</em> and <em>Sanitize</em>.</dd>
+    <dt>&lt;size></dt>      <dd>The size of a downloaded file. Only for <em>DownloadPost</em>.</dd>
+    <dt>&lt;state></dt>     <dd>The new state. Only for <em>Fullscreen</em> and <em>PrivateMode</em>.</dd>
+    <dt>&lt;tab></dt>       <dd>The tab in which the event occurred. Only for <em>DOMLoad</em>, <em>PageLoad</em> and <em>PageLoadPre</em>.</dd>
+    <dt>&lt;tags></dt>      <dd>The tags applied to <em>&lt;url></em>. Only for <em>Bookmark*</em>.</dd>
+    <dt>&lt;title></dt>     <dd>The page, bookmark or download title.</dd>
+    <dt>&lt;url></dt>       <dd>The URL against which the event was selected.</dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/pentadactyl/locale/en-US/gui.xml b/pentadactyl/locale/en-US/gui.xml
new file mode 100644 (file)
index 0000000..1c4e9fb
--- /dev/null
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl tag="dialog-list" replace="dialog-list">
+    <dt>about</dt>           <dd>About Mozilla &dactyl.host;</dd>
+    <dt>addbookmark</dt>     <dd>Add bookmark for the current page</dd>
+    <dt>addons</dt>          <dd>Manage Add-ons</dd>
+    <dt>bookmarks</dt>       <dd>List your bookmarks</dd>
+    <dt>checkupdates</dt>    <dd>Check for updates</dd>
+    <dt>cookies</dt>         <dd>List your cookies</dd>
+    <dt>console</dt>         <dd>JavaScript console</dd>
+    <dt>customizetoolbar</dt><dd>Customize the Toolbar</dd>
+    <dt>dominspector</dt>    <dd>DOM Inspector</dd>
+    <dt>downloads</dt>       <dd>Manage Downloads</dd>
+    <dt>history</dt>         <dd>List your history</dd>
+    <dt>import</dt>          <dd>Import Preferences, Bookmarks, History, etc. from other browsers</dd>
+    <dt>openfile</dt>        <dd>Open the file selector dialog</dd>
+    <dt>pageinfo</dt>        <dd>Show information about the current page</dd>
+    <dt>pagesource</dt>      <dd>View page source</dd>
+    <dt>passwords</dt>       <dd>Passwords dialog</dd>
+    <dt>places</dt>          <dd>Places Organizer: Manage your bookmarks and history</dd>
+    <dt>preferences</dt>     <dd>Show &dactyl.host; preferences dialog</dd>
+    <dt>printpreview</dt>    <dd>Preview the page before printing</dd>
+    <dt>printsetup</dt>      <dd>Setup the page size and orientation before printing</dd>
+    <dt>print</dt>           <dd>Show print dialog</dd>
+    <dt>saveframe</dt>       <dd>Save frame to disk</dd>
+    <dt>savepage</dt>        <dd>Save page to disk</dd>
+    <dt>searchengines</dt>   <dd>Manage installed search engines</dd>
+    <dt>selectionsource</dt> <dd>View selection source</dd>
+    <dt>venkman</dt>         <dd>The JavaScript debugger</dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/pentadactyl/locale/en-US/intro.xml b/pentadactyl/locale/en-US/intro.xml
new file mode 100644 (file)
index 0000000..bc8bd7f
--- /dev/null
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<p replace="intro-text">
+    <link topic="&dactyl.apphome;">&dactyl.appName;</link> is a
+    free browser add-on for &dactyl.host;, designed to make browsing more
+    efficient and especially more keyboard accessible. Largely inspired by the
+    <link topic="http://www.vim.org">Vim</link> text editor, the appearance
+    and finger feel should be familiar to Vim users.
+</p>
+
+<ol tag="topics-list" replace="topics-list">
+    <li>
+        <link topic="tutorial">Quick-start tutorial</link>:
+        A quick-start tutorial for new users.
+    </li>
+    <li>
+        <link topic="starting">Starting &dactyl.appName;</link>:
+        How &dactyl.appName; starts up, where it reads the config file, etc.
+    </li>
+    <li>
+        <link topic="browsing">Browsing</link>:
+        Basic key mappings and commands needed for a browsing
+        session (how to open a web page, go back in history, etc.)
+    </li>
+    <li>
+        <link topic="buffer">Buffer</link>:
+        Operations on the current document (scrolling, copying text,
+        etc.)
+    </li>
+    <li>
+        <link topic="cmdline">Command-line mode</link>:
+        Command-line editing.
+    </li>
+    <li>
+        <link topic="editing">Editing text</link>:
+        Text area and input field editing.
+    </li>
+    <li>
+        <link topic="options">Options</link>:
+        A description of all options.
+    </li>
+    <li>
+        <link topic="pattern">Text search commands</link>:
+        Searching for text in the current buffer.
+    </li>
+    <li>
+        <link topic="tabs">Tabs</link>:
+        Managing your tabbed browsing session.
+    </li>
+    <li>
+        <link topic="hints">Hints</link>:
+        Selecting hyperlinks and other page elements.
+    </li>
+    <li>
+        <link topic="map">Keyboard shortcuts and commands</link>:
+        Defining new key mappings, abbreviations and user commands.
+    </li>
+    <li>
+        <link topic="eval">Expression evaluation</link>:
+        Executing JavaScript.
+    </li>
+    <li>
+        <link topic="marks">Marks</link>:
+        Using bookmarks, QuickMarks, history and local marks.
+    </li>
+    <li>
+        <link topic="repeat">Repeating commands</link>:
+        Using macros to repeat recurring workflows.
+    </li>
+    <li>
+        <link topic="autocommands">Automatic commands</link>:
+        Automatically executing code on certain events.
+    </li>
+    <li>
+        <link topic="print">Printing</link>:
+        Printing pages.
+    </li>
+    <li>
+        <link topic="gui">&dactyl.appName;'s GUI</link>:
+        Accessing &dactyl.host; menus, dialogs and the sidebar.
+    </li>
+    <li>
+        <link topic="styling">Styling the GUI and web pages</link>:
+        Changing the styling of content pages and &dactyl.appName; itself.
+    </li>
+    <li>
+        <link topic="message">Error and informational messages</link>:
+        A description of informational and error messages.
+    </li>
+    <li>
+        <link topic="privacy">Privacy and sensitive information</link>:
+        How to manage your private data while browsing.
+    </li>
+    <li>
+        <link topic="developer">Developer information</link>:
+        How to write plugins and documentation.
+    </li>
+    <li>
+        <link topic="various">Various commands</link>:
+        Other help which doesn't readily fit into any other category.
+    </li>
+    <li>
+        <link topic="plugins">Plugins</link>:
+        Documentation for any plugins you have installed.
+    </li>
+    <li>
+        <link topic="faq">FAQ</link>:
+        Frequently asked questions.
+    </li>
+    <li>
+        <link topic="versions">Version information</link>:
+        A detailed list of differences between each of the released versions of
+        &dactyl.appName;.
+    </li>
+    <li>
+        <link topic="index">Index</link>:
+        An index of all commands and options.
+    </li>
+</ol>
+
+<ul tag="features-list" replace="features-list">
+    <li>Vim-like key bindings (<k>h</k>, <k>j</k>, <k>gg</k>, <k>ZZ</k>, <k name="C-f"/>, etc.)</li>
+    <li><link topic="cmdline.xml">Ex commands</link> (<ex>:quit</ex>, <ex>:open www.foo.com</ex>, …)</li>
+    <li>Powerful tab completion for all commands and options</li>
+    <li>Powerful <t>privacy</t> features</li>
+    <li><link topic="pattern.xml">Page search</link> with many fixes for long-standing Firefox annoyances</li>
+    <li>Quick and powerful <link topic="hints">keyboard navigation</link> for links, input fields, etc.</li>
+    <li>Vim-like <link topic="status-line">status line</link></li>
+    <li>Keyboard <t>macros</t>, along with custom key mappings and commands</li>
+    <li>Minimal GUI, along with commands to <link href=":toolbarhide">hide</link> and <link href=":toolbartoggle">toggle</link> toolbars and menus</li>
+    <li>Ability to <ex>:source</ex> JavaScript, CSS, and &dactyl.appName; command files</li>
+    <li>Ability to <link topic="marks">mark</link> the current page position and return to it</li>
+    <li>Count support for many commands (<tt>3</tt><k name="C-o"/> will go back <tt>3</tt> pages)</li>
+    <li>Visual bell for errors (<o>visualbell</o>)</li>
+    <li><link topic="marks">Marks</link> support (<k>m</k><tt>M</tt> to set mark <tt>M</tt>, <k>'</k><tt>M</tt> to jump to it)</li>
+    <li><link topic="quickmarks">QuickMark</link> support</li>
+    <li><link topic="I_&lt;C-i>">Editing of text fields</link> with an <link topic="'editor'">external editor</link></li>
+    <li><link topic="autocommands">AutoCommands</link> to execute actions on certain events</li>
+    <li>A comprehensive help system, explaining all <link topic="cmdline.xml">commands</link>, <link topic="mapping">mappings</link>, <t>options</t>, and <t>plugins</t></li>
+</ul>
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/pentadactyl/locale/en-US/map.xml b/pentadactyl/locale/en-US/map.xml
new file mode 100644 (file)
index 0000000..9effed2
--- /dev/null
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl tag=":command-complete-arg-list" replace=":command-complete-arg-list">
+    <dt>abbreviation</dt> <dd>abbreviations</dd>
+    <dt>altstyle</dt>     <dd>alternate author style sheets</dd>
+    <dt>bookmark</dt>     <dd>bookmarks</dd>
+    <dt>buffer</dt>       <dd>buffers</dd>
+    <dt>charset</dt>      <dd>character sets</dd>
+    <dt>color</dt>        <dd>color schemes</dd>
+    <dt>command</dt>      <dd>Ex commands</dd>
+    <dt>dialog</dt>       <dd>&dactyl.host; dialogs</dd>
+    <dt>dir</dt>          <dd>directories</dd>
+    <dt>environment</dt>  <dd>environment variables</dd>
+    <dt>event</dt>        <dd>autocommand events</dd>
+    <dt>extension</dt>    <dd>installed extensions</dd>
+    <dt>file</dt>         <dd>files</dd>
+    <dt>help</dt>         <dd>help tags</dd>
+    <dt>highlight</dt>    <dd>highlight groups</dd>
+    <dt>history</dt>      <dd>browsing history</dd>
+    <dt>javascript</dt>   <dd>JavaScript expressions</dd>
+    <dt>macro</dt>        <dd>named macros</dd>
+    <dt>mapping</dt>      <dd>user mappings</dd>
+    <dt>mark</dt>         <dd>local page marks</dd>
+    <dt>menu</dt>         <dd>menu items</dd>
+    <dt>option</dt>       <dd>&dactyl.appName; options</dd>
+    <dt>preference</dt>   <dd>&dactyl.host; preferences</dd>
+    <dt>qmark</dt>        <dd>quick marks</dd>
+    <dt>runtime</dt>      <dd>runtime paths</dd>
+    <dt>search</dt>       <dd>search engines and keywords</dd>
+    <dt>shellcmd</dt>     <dd>shell commands</dd>
+    <dt>sidebar</dt>      <dd>sidebar panels</dd>
+    <dt>toolbar</dt>      <dd>toolbars</dd>
+    <dt>url</dt>          <dd>URLs</dd>
+    <dt>usercommand</dt>  <dd>user commands</dd>
+    <dt>window</dt>       <dd>windows</dd>
+    <dt>custom,<a>thing</a></dt><dd>custom completion, provided by <a>thing</a></dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/pentadactyl/locale/en-US/tutorial.xml b/pentadactyl/locale/en-US/tutorial.xml
new file mode 100644 (file)
index 0000000..4ab27d6
--- /dev/null
@@ -0,0 +1,389 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE document SYSTEM "dactyl://content/dtd">
+
+<document
+    name="tutorial"
+    title="&dactyl.appName; Tutorial"
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<!-- Initial revision: Sun Jun  8 10:07:05 UTC 2008 (penryu) -->
+<h1 tag="tutorial">Quick-start tutorial</h1>
+
+<note>
+    This is a quickstart tutorial to help new users get up and running
+    in &dactyl.appName;. It is not intended as a full reference explaining all
+    features.
+</note>
+
+<p>
+    If you've started using &dactyl.appName; from scratch (i.e., without any
+    customization), you should be looking at this help page in a relatively
+    bare-looking window. The menubar, navigation bar, and bookmark bars are hidden.
+    In case you missed the notice in the <t>intro</t>, you can
+    regain these by issuing the command
+</p>
+
+<set opt="go" op="+="><str delim="">mTB</str><k name="CR"/></set>
+
+<p>
+    where <k name="CR"/> represents pressing the <k name="Enter" link="false"/>
+    or <k name="Return" link="false"/> key. If you're a veteran Vim user, this
+    may look familiar. It should.
+</p>
+
+<p>
+    However, in this author's opinion, the best way to get familiar with
+    &dactyl.appName; is to leave these disabled for now. (The above action can be
+    reversed with <se opt="go" op="&amp;"/><k name="CR"/>) You can look at the entry
+    for <o>guioptions</o> in <t>options</t> for more information on this.
+</p>
+
+<h2 tag="modal">&dactyl.appName;'s modal interface</h2>
+
+<p>
+    &dactyl.appName;'s power, like Vim's, comes from its modal interface. Keys have
+    different meanings depending on which mode the browser is in. &dactyl.appName; has
+    several modes, but the 2 most important are <em>Normal</em> mode and
+    <em>Command-line</em> mode.
+</p>
+
+<p>
+    When &dactyl.appName; starts, it is in Normal mode by default. This is probably where
+    you will spend the majority of your time.
+</p>
+
+<p>
+    The other core mode of &dactyl.appName;, Command-line mode, can be entered from
+    Normal mode by typing a <k>:</k> (colon). You will frequently see &dactyl.appName;
+    commands start with a <k>:</k>, indicating that what follows is a command.
+</p>
+
+<p>
+    To return to Normal mode from Command-line mode, type <k name="Esc"/>. Pressing
+    <k name="Esc"/> will also return you to Normal mode from most other modes in
+    &dactyl.appName;.
+</p>
+
+<h2 tag="getting-help">Getting help</h2>
+
+<p>
+    Vim is a great editor but it's not much of a web browser. So even seasoned Vim
+    users will probably have to look at &dactyl.appName;'s documentation sooner or later.
+    Most of the documentation for &dactyl.appName;'s features are easily found using the
+    <ex>:help</ex> command. For example, you can find help on the <ex>:help</ex> command
+    by typing
+</p>
+
+<code><ex>:help :help<k name="CR"/></ex></code>
+
+<p>
+    Similarly, help on configurable options is available with
+    <ex>:help '<a>option_name</a>'</ex>. (Note the single quotes
+    around the option name as in Vim.) Information on all available
+    options is, predictably, <ex>:help options</ex>.
+</p>
+
+<p>
+    And you can find out about the <k>gt</k> and <k>gT</k> mapping with
+</p>
+
+<code>
+<ex>:help gt<k name="CR"/></ex>
+<ex>:help gT<k name="CR"/></ex>
+</code>
+
+<p>
+    Finally, in addition to the help system itself, <ex>:listcommands</ex>,
+    <ex>:listkeys</ex> and <ex>:listoptions</ex> are useful quick-reference
+    commands.
+</p>
+
+<h2 tag="living-mouseless">Mouseless</h2>
+
+<em>– or how I learned to stop worrying and love the 80+ buttons I already have.</em>
+
+<p>
+    The efficiency of &dactyl.appName;, as with the legendary editor it was inspired by,
+    relies on the user being able to keep his fingers on the keyboard where they
+    can do the most good. While there are some areas where the mouse is clearly
+    superior at, such as GUI design or some games, &dactyl.appName; acts on the
+    assumption that a web browser doesn't have to be one of those.
+</p>
+
+<p>
+    Here are some areas where the mouse is typically considered indisposable, and
+    how &dactyl.appName; challenges this preconception.
+</p>
+
+<h2 tag="keyboard-scrolling">Scrolling</h2>
+
+<p>
+    Scrolling the browser window is done with simple keystrokes:
+</p>
+
+<dl>
+    <dt><k>j</k>/<k>k</k></dt>
+    <dd>
+        scroll window down/up by one line, respectively
+    </dd>
+    <dt><k>h</k>/<k>l</k></dt>
+    <dd>
+        scroll window left/right
+    </dd>
+    <dt><k name="Space"/>/<k name="C-b"/></dt>
+    <dd>
+        scroll down/up by one page
+    </dd>
+    <dt><k name="C-d"/>/<k name="C-u"/></dt>
+    <dd>
+        scroll down/up by 1/2 page
+    </dd>
+</dl>
+
+<p>
+    Your standard buttons (<k name="Up"/>/<k name="Down"/>/<k name="PageUp"/>/<k name="PageDown"/>) will
+    also work as expected.
+</p>
+
+<h2 tag="history-navigation tab-navigation">History and tabs</h2>
+
+<p>
+    History navigation (e.g., <em>Back</em>, <em>Forward</em>) are done similarly to
+    scrolling.
+</p>
+
+<dl>
+    <dt><k name="C-o"/>/<k name="C-i"/></dt>
+    <dd>
+        move Back/Forward in the current window/tab's history, respectively
+    </dd>
+</dl>
+
+<p>
+    Move between tabs using these keystrokes which may also be familiar to tabbing
+    Vimmers.
+</p>
+
+<dl>
+    <dt><k>gt</k>/<k name="C-n"/></dt>
+    <dd>
+        go to the next tab
+    </dd>
+    <dt><k>gT</k>/<k name="C-p"/></dt>
+    <dd>
+        go to the previous tab
+    </dd>
+    <dt><k>g0</k>/<k>g$</k></dt>
+    <dd>
+        go to the first/last tab
+    </dd>
+    <dt><k>d</k></dt>
+    <dd>
+        close the active tab (delete the buffer)
+    </dd>
+</dl>
+
+<p>
+    To open a web page in a new tab, use the <ex>:tabopen <a>url</a></ex>. To open a URL in
+    the current tab, use <ex>:open</ex>. The Normal mode mappings <k>t</k> and <k>o</k>,
+    respectively, map to these commands, so the following pairs of sequences are
+    equivalent:
+</p>
+
+<code>
+<ex>:open my.webmail.com<k name="CR"/></ex>
+<k>o</k>my.webmail.com<k name="CR"/>
+
+<ex>:tabopen google.com<k name="CR"/></ex>
+<k>t</k>google.com<k name="CR"/>
+</code>
+
+<h2 tag="hints-tutorial">Some hints about surfing…</h2>
+
+<p>
+    So now you can navigate around in &dactyl.appName;. But wait… how do you <em>open</em> a
+    page or tab linked in a web page? How do you <em>click</em> on all those links
+    without your tailed friend?
+</p>
+
+<p>
+    The answer is <em>hints</em>. Activating hints displays a number next to every link
+    &dactyl.appName; can find. To follow the link, simply type the number corresponding
+    to the hint.
+</p>
+
+<p>
+    For text links, there's an additional shortcut; you can type some text
+    contained in the link and &dactyl.appName; will search all the links it can find and
+    only hint the matching links, further narrowing down the list. If the text you
+    type uniquely identifies any given link, &dactyl.appName; will follow that link
+    immediately without any further user input.
+</p>
+
+<p>
+    Whichever way you choose to indicate your target link, once &dactyl.appName; has
+    highlighted the link you want, simply hit <k name="CR"/> to open it.
+</p>
+
+<p>
+    The most common hint mode is called <t>quick-hints</t>.
+    To activate QuickHint mode, press either <k>f</k> or <k>F</k>. The lower-case
+    <k>f</k> will open the resulting link in the current tab, while the upper-case
+    <k>F</k> will open it in a new tab.
+</p>
+
+<p>
+    To test it, try this link: <link topic="&dactyl.apphome;">&dactyl.appName; Homepage</link>.
+    Activate QuickHint mode with <k>f</k> or <k>F</k> to highlight all currently
+    visible links. Then start typing the text of the link. The link should be
+    uniquely identified soon, and &dactyl.appName; will open it. Once you're done,
+    remember to use <k name="C-o"/> (<em>History Back</em>) or <k>d</k> (<em>Delete Buffer</em>)
+    to return here, depending on which key you used to activate QuickHint mode.
+</p>
+
+<h2 tag="common-issues">Common issues</h2>
+
+<p>
+    Say you get half-way done typing in a new URL, only to remember that you've
+    already got that page open in the previous tab. Your command line might look
+    something like this:
+</p>
+
+<code><ex>:open my.partial.url/fooba</ex></code>
+
+<p>
+    You can exit the command line and access the already loaded page with the
+    following:
+</p>
+
+<code><k name="Esc"/></code>
+
+<h2 tag="pentadactylrc">Saving for posterity—<tt>pentadactylrc</tt></h2>
+
+<p>
+    Once you get &dactyl.appName; set up with your desired options, maps, and commands,
+    you'll probably want them to be available the next time you open &dactyl.appName;.
+    Continuing the Vim theme, this is done with a <tt>pentadactylrc</tt> file.
+</p>
+
+<p>
+    To save your current settings and allow them to be loaded automatically
+    next time you start &dactyl.appName;, issue the <ex>:mkp</ex> command.
+</p>
+
+<p>
+    This will create the file <em>$HOME/.pentadactylrc</em> containing your settings.
+    It is a simple text file, just like a vimrc file and can be easily
+    edited to suit your preferences.
+</p>
+
+<h2 tag="quitting-without-menus">Find the exit nearest you</h2>
+
+<p>
+    &dactyl.appName; supports all of Vim's classic methods of exiting.
+</p>
+
+<dl>
+    <dt><ex>:xall</ex></dt>
+    <dd>
+        command to quit and save the current browsing session for next time; the default.
+    </dd>
+    <dt><ex>:qall</ex></dt>
+    <dd>
+        command to quit <em>without</em> saving the session
+    </dd>
+    <dt><k>ZZ</k></dt>
+    <dd>
+        Normal mode mapping equivalent to <ex>:xall</ex>
+    </dd>
+    <dt><k>ZQ</k></dt>
+    <dd>
+        Normal mode mapping equivalent to <ex>:qall</ex>
+    </dd>
+</dl>
+
+<h2 tag="whither-&dactyl.host;">Where did &dactyl.host; go?</h2>
+
+<p>
+    You might feel pretty disoriented now. Don't worry. This is still &dactyl.host;
+    underneath. Here are some ways &dactyl.appName; allows &dactyl.host; to shine through. See
+    the <ex>:help</ex> for these commands and mappings for more information on how to
+    make the best use of them.
+</p>
+
+<dl>
+    <dt><ex>:dialog</ex></dt>
+    <dd>
+        To access some of &dactyl.host;'s many dialog windows, you can use the
+        <ex>:dialog</ex> command. See <ex>:help :dialog</ex>.
+    </dd>
+    <dt><ex>:bmarks</ex></dt>
+    <dd>
+        &dactyl.appName; provides a new interface to bookmarks, but they're still your
+        standard &dactyl.host; bookmarks under the hood. <ex>:bmark</ex> will add a new
+        bookmark, while <ex>:bmarks</ex> will list the bookmarks currently defined.
+    </dd>
+    <dt><ex>:history</ex></dt>
+    <dd>
+        It's exactly what it sounds like. This command will display a colorized,
+        scrollable and clickable list of the locations in &dactyl.appName;'s history.
+    </dd>
+    <dt><ex>:emenu</ex></dt>
+    <dd>
+        Access the &dactyl.host; menus through the &dactyl.appName; command line.
+    </dd>
+</dl>
+
+<p>
+    Feel free to explore at this point. If you use the <ex>:tabopen</ex> command,
+    remember to use the <k>gt</k>/<k>gT</k> mappings to get back to this page. If
+    using the <ex>:open</ex> command, use the history keys (e.g., <k>H</k>) to return.
+    If you get hopelessly lost, just type <ex>:help<k name="CR"/></ex> and click the
+    <em>Tutorial</em> link to return.
+</p>
+
+<!-- TODO: other sections? -->
+
+<h2 tag="removal">Get me out of here!</h2>
+
+<p>
+    If you've given it a fair shot and determined … TODO
+</p>
+
+<p>
+    The &dactyl.appName; way to do this is with the command <ex>:addons</ex>. Issuing this
+    command brings up the &dactyl.host; Add-ons dialog window; you can then remove it as
+    normal, selecting &dactyl.appName; from the list and clicking (yes, clicking)
+    <em>Uninstall</em>.
+</p>
+
+<p>
+    Alternatively, you can do this the old-fashioned way: re-enable the menubar,
+    as above, with <se opt="go" op="+=">m</se>, and select <em>Add-ons</em> from the <em>Tools</em> menu.
+</p>
+
+<h2 tag="support">I'm interested… but lost!</h2>
+
+<p>
+    &dactyl.appName; has an energetic and growing user base. If you've run into a problem
+    that you can't seem to solve with &dactyl.appName;, or if you think you might have
+    found a bug, please let us know! There is support available on the
+    <link topic="http://code.google.com/p/dactyl/w/list?q=label%3Aproject-&dactyl.name;">wiki</link>
+    or in the <link topic="irc://irc.oftc.net/pentadactyl">#pentadactyl</link> IRC
+    channel on <link topic="http://oftc.net/">OFTC</link>.
+</p>
+
+<p>
+    If you have any feature requests or (even better) offers to help, we'd love to
+    hear from you as well. Developers work on &dactyl.appName; whenever possible, but we
+    are neither infinite nor omnipotent; please bear with us. If you can't wait for
+    us to get around to it, rest assured patches are welcome! See the
+    <link topic="developer">developer</link> page for more information.
+</p>
+
+</document>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/pentadactyl/skin/about.css b/pentadactyl/skin/about.css
new file mode 100644 (file)
index 0000000..cd1dd70
--- /dev/null
@@ -0,0 +1,37 @@
+/* Based on vimperator.org home page layout. */
+
+#main-container {
+    width: 775px;
+    margin: 55px auto;
+}
+
+#img-container {
+    display: table-cell;
+    vertical-align: middle;
+    width: 775px;
+    height: 440px;
+    border: 5px solid black;
+    background-repeat: no-repeat;
+    background-image: url(chrome://pentadactyl/content/about_background.png);
+    background-color: green;
+    border-spacing: 0px 10px;
+    -moz-border-radius: 10px;
+}
+
+#text-container {
+    float: right;
+    border: solid black;
+    white-space: pre-wrap;
+    font-family: monospace;
+    font-size: 13px;
+    font-weight: bold;
+    width: 500px;
+    height: 275px;
+    color: black;
+    background-color: rgb(176, 196, 222);
+    opacity: 0.75;
+}
+
+.key { color: blue; }
+
+/* vim: set fdm=marker sw=4 ts=4 et: */
diff --git a/pentadactyl/skin/icon.png b/pentadactyl/skin/icon.png
new file mode 100644 (file)
index 0000000..bdd74b7
Binary files /dev/null and b/pentadactyl/skin/icon.png differ
diff --git a/teledactyl/AUTHORS b/teledactyl/AUTHORS
new file mode 100644 (file)
index 0000000..f3cc6bb
--- /dev/null
@@ -0,0 +1,11 @@
+Main developer/Project founder:
+  * Martin Stubenschrott (stubenschrott@vimperator.org)
+
+Inactive/former developers:
+  * Daniel Bainton (dpb .AT. driftaway .DOT. org)
+
+Patches:
+  * Christian Dietrich (too many to list)
+
+A lot of people contributed to Pentadactyl, which is the basis of Teledactyl, so please refer
+to that AUTHOR file for more contributors.
diff --git a/teledactyl/Makefile b/teledactyl/Makefile
new file mode 100644 (file)
index 0000000..46abb52
--- /dev/null
@@ -0,0 +1,12 @@
+#### configuration
+
+NAME          = teledactyl
+
+THUNDERBIRD  ?= thunderbird
+HOSTAPP      ?= $(THUNDERBIRD)
+PROFILEPATHS ?= "$$HOME/.thunderbird" \
+               "$$HOME/Library/Thunderbird" \
+               "$$APPDATA/Thunderbird" \
+               "$$AppData/Thunderbird"
+
+include ../common/Makefile
diff --git a/teledactyl/NEWS b/teledactyl/NEWS
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/teledactyl/TODO b/teledactyl/TODO
new file mode 100644 (file)
index 0000000..3c8c498
--- /dev/null
@@ -0,0 +1,17 @@
+Priority list:
+1-9 as in vim (9=required for next release, 5=would be nice, 1=probably not)
+
+BUGS:
+- several tab related commands like :tab are enabled but don't work
+- stal=1 doesn't work
+
+(recent CVS regressions):
+- 'autoexternal' doesn't work
+
+FEATURES:
+9 check autocmd Events - which are applicable etc.
+9 edit messages with vim
+8 the archives need to be mailbox specific
+  - currently it archives mail to the first Archive folder found
+7 :set! mailnews.wraplength=140 or similar
+6 add a feature to show emails from threads under each other like in gmail
diff --git a/teledactyl/chrome.manifest b/teledactyl/chrome.manifest
new file mode 120000 (symlink)
index 0000000..4032143
--- /dev/null
@@ -0,0 +1 @@
+../common/chrome.manifest
\ No newline at end of file
diff --git a/teledactyl/components/commandline-handler.js b/teledactyl/components/commandline-handler.js
new file mode 120000 (symlink)
index 0000000..aa8427b
--- /dev/null
@@ -0,0 +1 @@
+../../common/components/commandline-handler.js
\ No newline at end of file
diff --git a/teledactyl/components/protocols.js b/teledactyl/components/protocols.js
new file mode 120000 (symlink)
index 0000000..7c25b74
--- /dev/null
@@ -0,0 +1 @@
+../../common/components/protocols.js
\ No newline at end of file
diff --git a/teledactyl/content/addressbook.js b/teledactyl/content/addressbook.js
new file mode 100644 (file)
index 0000000..2a7dd1e
--- /dev/null
@@ -0,0 +1,155 @@
+// Copyright (c) 2008 by Christian Dietrich <stettberger@dokucode.de>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+const Addressbook = Module("addressbook", {
+    init: function () {
+    },
+
+    // TODO: add option for a format specifier, like:
+    // :set displayname=%l, %f
+    generateDisplayName: function (firstName, lastName) {
+        if (firstName && lastName)
+            return lastName + ", " + firstName;
+        else if (firstName)
+            return firstName;
+        else if (lastName)
+            return lastName;
+        else
+            return "";
+    },
+
+    getDirectoryFromURI: function (uri) services.rdf.GetResource(uri).QueryInterface(Ci.nsIAbDirectory),
+
+    add: function (address, firstName, lastName, displayName) {
+        const personalAddressbookURI = "moz-abmdbdirectory://abook.mab";
+        let directory = this.getDirectoryFromURI(personalAddressbookURI);
+        let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(Ci.nsIAbCard);
+
+        if (!address || !directory || !card)
+            return false;
+
+        card.primaryEmail = address;
+        card.firstName = firstName;
+        card.lastName = lastName;
+        card.displayName = displayName;
+
+        return directory.addCard(card);
+    },
+
+    // TODO: add telephone number support
+    list: function (filter, newMail) {
+        let addresses = [];
+        let dirs = services.abManager.directories;
+        let lowerFilter = filter.toLowerCase();
+
+        while (dirs.hasMoreElements()) {
+            let addrbook = dirs.getNext().QueryInterface(Ci.nsIAbDirectory);
+            let cards = addrbook.childCards;
+            while (cards.hasMoreElements()) {
+                let card = cards.getNext().QueryInterface(Ci.nsIAbCard);
+                //var mail = card.primaryEmail || ""; //XXX
+                let displayName = card.displayName;
+                if (!displayName)
+                    displayName = this.generateDisplayName(card.firstName, card.lastName);
+
+                if (displayName.toLowerCase().indexOf(lowerFilter) > -1
+                    || card.primaryEmail.toLowerCase().indexOf(lowerFilter) > -1)
+                        addresses.push([displayName, card.primaryEmail]);
+            }
+        }
+
+        if (addresses.length < 1) {
+            if (!filter)
+                dactyl.echoerr("Exxx: No contacts", commandline.FORCE_SINGLELINE);
+            else
+                dactyl.echoerr("Exxx: No contacts matching string '" + filter + "'", commandline.FORCE_SINGLELINE);
+            return false;
+        }
+
+        if (newMail) {
+            // Now we have to create a new message
+            let args = {};
+            args.to = addresses.map(
+                function (address) "\"" + address[0].replace(/"/g, "") + " <" + address[1] + ">\""
+            ).join(", ");
+
+            mail.composeNewMail(args);
+        }
+        else {
+            let list = template.tabular(["Name", "Address"], [],
+                [[util.clip(address[0], 50), address[1]] for ([, address] in Iterator(addresses))]
+            );
+            commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE);
+        }
+        return true;
+    }
+}, {
+}, {
+    commands: function () {
+        commands.add(["con[tact]"],
+            "Add an address book entry",
+            function (args) {
+                let mailAddr    = args[0]; // TODO: support more than one email address
+                let firstName   = args["-firstname"] || null;
+                let lastName    = args["-lastname"] || null;
+                let displayName = args["-name"] || null;
+                if (!displayName)
+                    displayName = this.generateDisplayName(firstName, lastName);
+
+                if (addressbook.add(mailAddr, firstName, lastName, displayName))
+                    dactyl.echomsg("Added address: " + displayName + " <" + mailAddr + ">", 1, commandline.FORCE_SINGLELINE);
+                else
+                    dactyl.echoerr("Exxx: Could not add contact `" + mailAddr + "'", commandline.FORCE_SINGLELINE);
+
+            },
+            {
+                argCount: "+",
+                options: [{ names: ["-firstname", "-f"], type: CommandOption.STRING, description: "The first name of the contact"   },
+                          { names: ["-lastname", "-l"],  type: CommandOption.STRING, description: "The last name of the contact"    },
+                          { names: ["-name", "-n"],      type: CommandOption.STRING, description: "The display name of the contact" }]
+            });
+
+        commands.add(["contacts", "addr[essbook]"],
+            "List or open multiple addresses",
+            function (args) { addressbook.list(args.string, args.bang); },
+            { bang: true });
+    },
+    mappings: function () {
+        var myModes = config.mailModes;
+
+        mappings.add(myModes, ["a"],
+            "Open a prompt to save a new addressbook entry for the sender of the selected message",
+            function () {
+                try {
+                    var to = gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor;
+                }
+                catch (e) {
+                    dactyl.beep();
+                }
+
+                if (!to)
+                    return;
+
+                let address = to.substring(to.indexOf("<") + 1, to.indexOf(">"));
+
+                let displayName = to.substr(0, to.indexOf("<") - 1);
+                if (/^\S+\s+\S+\s*$/.test(displayName)) {
+                    let names = displayName.split(/\s+/);
+                    displayName = "-firstname=" + names[0].replace(/"/g, "")
+                                + " -lastname=" + names[1].replace(/"/g, "");
+                }
+                else
+                    displayName = "-name=\"" + displayName.replace(/"/g, "") + "\"";
+
+                CommandExMode().open("contact " + address + " " + displayName);
+            });
+    },
+    services: function initServices(dactyl, modules, window) {
+        services.add("abManager", "@mozilla.org/abmanager;1", Ci.nsIAbManager);
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/teledactyl/content/compose/compose.js b/teledactyl/content/compose/compose.js
new file mode 100644 (file)
index 0000000..33c5f23
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+var Compose = Module("compose", {
+    init: function init() {
+        var stateListener = {
+            QueryInterface: function (id) {
+                if (id.equals(Ci.nsIDocumentStateListener))
+                    return this;
+                throw Cr.NS_NOINTERFACE;
+            },
+
+            // this is (also) fired once the new compose window loaded the message for the first time
+            NotifyDocumentStateChanged: function (nowDirty) {
+                // only edit with external editor if this window was not cached!
+                if (options["autoexternal"] && !window.messageWasEditedExternally/* && !gMsgCompose.recycledWindow*/) {
+                    window.messageWasEditedExternally = true;
+                    editor.editFieldExternally();
+                }
+
+            },
+            NotifyDocumentCreated: function () {},
+            NotifyDocumentWillBeDestroyed: function () {}
+        };
+
+        events.listen(window.document, "load", function () {
+                if (window.messageWasEditedExternally === undefined) {
+                    window.messageWasEditedExternally = false;
+                    GetCurrentEditor().addDocumentStateListener(stateListener);
+                }
+            }, true);
+
+        events.listen(window, "compose-window-close", function () {
+            window.messageWasEditedExternally = false;
+        }, true);
+    }
+}, {
+}, {
+    mappings: function initMappings(dactyl, modules, window) {
+        mappings.add([modes.COMPOSE],
+            ["e"], "Edit message",
+            function () { editor.editFieldExternally(); });
+
+        mappings.add([modes.COMPOSE],
+            ["y"], "Send message now",
+            function () { window.goDoCommand("cmd_sendNow"); });
+
+        mappings.add([modes.COMPOSE],
+            ["Y"], "Send message later",
+            function () { window.goDoCommand("cmd_sendLater"); });
+
+        // FIXME: does not really work reliably
+        mappings.add([modes.COMPOSE],
+            ["t"], "Select To: field",
+            function () { awSetFocus(0, awGetInputElement(1)); });
+
+        mappings.add([modes.COMPOSE],
+            ["s"], "Select Subject: field",
+            function () { GetMsgSubjectElement().focus(); });
+
+        mappings.add([modes.COMPOSE],
+            ["i"], "Select message body",
+            function () { SetMsgBodyFrameFocus(); });
+
+        mappings.add([modes.COMPOSE],
+            ["q"], "Close composer, ask when for unsaved changes",
+            function () { DoCommandClose(); });
+
+        mappings.add([modes.COMPOSE],
+            ["Q", "ZQ"], "Force closing composer",
+            function () { MsgComposeCloseWindow(true); /* cache window for better performance*/ });
+    },
+    modes: function initModes(dactyl, modules, window) {
+        modes.addMode("COMPOSE", {
+            insert: true
+        });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/teledactyl/content/compose/compose.xul b/teledactyl/content/compose/compose.xul
new file mode 100644 (file)
index 0000000..b9af4c1
--- /dev/null
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+
+<!-- ***** BEGIN LICENSE BLOCK ***** {{{
+ Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+
+ This work is licensed for reuse under an MIT license. Details are
+ given in the LICENSE.txt file included with this file.
+}}} ***** END LICENSE BLOCK ***** -->
+
+<!--?xml-stylesheet href="chrome://browser/skin/" type="text/css"?-->
+
+<overlay id="teledactyl"
+    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+    xmlns:nc="http://home.netscape.com/NC-rdf#"
+    xmlns:html="http://www.w3.org/1999/xhtml"
+    xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+</overlay>
+
+<!-- vim: set fdm=marker sw=4 ts=4 et: -->
diff --git a/teledactyl/content/compose/dactyl.xul b/teledactyl/content/compose/dactyl.xul
new file mode 100644 (file)
index 0000000..bcdb613
--- /dev/null
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!-- ***** BEGIN LICENSE BLOCK ***** {{{
+// Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+}}} ***** END LICENSE BLOCK ***** -->
+
+<?xml-stylesheet href="chrome://dactyl/skin/dactyl.css" type="text/css"?>
+<!DOCTYPE overlay SYSTEM "dactyl.dtd" [
+    <!ENTITY dactyl.content "chrome://dactyl/content/">
+]>
+
+<overlay id="dactyl"
+    xmlns:dactyl="http://vimperator.org/namespaces/liberator"
+    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+    xmlns:nc="http://home.netscape.com/NC-rdf#"
+    xmlns:html="http://www.w3.org/1999/xhtml"
+    xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+    <script type="application/x-javascript;version=1.8" src="&dactyl.content;dactyl-overlay.js"/>
+
+    <window id="&dactyl.mainWindow;">
+
+        <keyset id="mainKeyset">
+            <key id="key_open_vimbar" key=":" oncommand="dactyl.modules.commandline.open(':', '', dactyl.modules.modes.EX);" modifiers=""/>
+            <key id="key_stop" keycode="VK_ESCAPE" oncommand="dactyl.modules.events.onEscape();"/>
+            <!-- other keys are handled inside the event loop in events.js -->
+        </keyset>
+
+        <popupset>
+            <panel id="dactyl-visualbell" dactyl:highlight="Bell"/>
+        </popupset>
+
+        <!--this notifies us also of focus events in the XUL
+            from: http://developer.mozilla.org/en/docs/XUL_Tutorial:Updating_Commands !-->
+        <commandset id="onPentadactylFocus"
+            commandupdater="true"
+            events="focus"
+            oncommandupdate="if (dactyl.modules.events != undefined) dactyl.modules.events.onFocusChange(event);"/>
+        <commandset id="onPentadactylSelect"
+            commandupdater="true"
+            events="select"
+            oncommandupdate="if (dactyl.modules.events != undefined) dactyl.modules.events.onSelectionChange(event);"/>
+
+        <!-- As of Firefox 3.1pre, <iframe>.height changes do not seem to have immediate effect,
+             therefore we need to put them into a <vbox> for which that works just fine -->
+        <vbox class="dactyl-container" hidden="false" collapsed="true">
+            <iframe id="dactyl-multiline-output" src="chrome://dactyl/content/buffer.xhtml"
+                flex="1" hidden="false" collapsed="false"
+                onclick="dactyl.modules.commandline.onMultilineOutputEvent(event)"/>
+        </vbox>
+
+        <vbox class="dactyl-container" hidden="false" collapsed="true">
+            <iframe id="dactyl-completions" src="chrome://dactyl/content/buffer.xhtml"
+                flex="1" hidden="false" collapsed="false"
+                onclick="dactyl.modules.commandline.onMultilineOutputEvent(event)"/>
+        </vbox>
+
+        <stack orient="horizontal" align="stretch" class="dactyl-container" dactyl:highlight="CmdLine">
+            <textbox class="plain" id="dactyl-message" flex="1" readonly="true" dactyl:highlight="Normal"/>
+            <hbox id="dactyl-commandline" hidden="false" collapsed="true" class="dactyl-container" dactyl:highlight="Normal">
+                <label class="plain" id="dactyl-commandline-prompt"  flex="0" crop="end" value="" collapsed="true"/>
+                <textbox class="plain" id="dactyl-commandline-command" flex="1" type="timed" timeout="100"
+                    oninput="dactyl.modules.commandline.onEvent(event);"
+                    onkeyup="dactyl.modules.commandline.onEvent(event);"
+                    onfocus="dactyl.modules.commandline.onEvent(event);"
+                    onblur="dactyl.modules.commandline.onEvent(event);"/>
+            </hbox>
+        </stack>
+
+        <vbox class="dactyl-container" hidden="false" collapsed="false">
+            <textbox id="dactyl-multiline-input" class="plain" flex="1" rows="1" hidden="false" collapsed="true" multiline="true"
+                onkeypress="dactyl.modules.commandline.onMultilineInputEvent(event);"
+                oninput="dactyl.modules.commandline.onMultilineInputEvent(event);"
+                onblur="dactyl.modules.commandline.onMultilineInputEvent(event);"/>
+        </vbox>
+
+    </window>
+
+    <statusbar id="status-bar" dactyl:highlight="StatusLine">
+        <hbox insertbefore="&dactyl.statusBefore;" insertafter="&dactyl.statusAfter;"
+              id="dactyl-statusline" flex="1" hidden="false" align="center">
+            <textbox class="plain" id="dactyl-statusline-field-url" readonly="false" flex="1" crop="end"/>
+            <label class="plain" id="dactyl-statusline-field-inputbuffer"    flex="0"/>
+            <label class="plain" id="dactyl-statusline-field-progress"       flex="0"/>
+            <label class="plain" id="dactyl-statusline-field-tabcount"       flex="0"/>
+            <label class="plain" id="dactyl-statusline-field-bufferposition" flex="0"/>
+        </hbox>
+        <!-- just hide them since other elements expect them -->
+        <statusbarpanel id="statusbar-display" hidden="true"/>
+        <statusbarpanel id="statusbar-progresspanel" hidden="true"/>
+    </statusbar>
+
+</overlay>
+
+<!-- vim: set fdm=marker sw=4 ts=4 et: -->
diff --git a/teledactyl/content/config.js b/teledactyl/content/config.js
new file mode 100644 (file)
index 0000000..67586e8
--- /dev/null
@@ -0,0 +1,217 @@
+// Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+const Config = Module("config", ConfigBase, {
+    name: "teledactyl",
+    appName: "Teledactyl",
+    idName: "TELEDACTYL",
+    host: "Thunderbird",
+    hostbin: "thunderbird",
+
+    Local: function Local(dactyl, modules, window)
+        let ({ config } = modules, { document } = window) {
+        init: function init() {
+            init.superapply(this, arguments);
+
+            modules.__defineGetter__("content", function () window.content);
+
+            util.overlayWindow(window, { append: <><hbox id="statusTextBox" flex=""/></> });
+        },
+
+        get browser() window.getBrowser(),
+
+        get commandContainer() document.documentElement.id,
+
+        tabbrowser: {
+            __proto__: Class.makeClosure.call(window.document.getElementById("tabmail")),
+            get mTabContainer() this.tabContainer,
+            get mTabs() this.tabContainer.childNodes,
+            get mCurrentTab() this.tabContainer.selectedItem,
+            get mStrip() this.tabStrip,
+            get browsers() [browser for (browser in Iterator(this.mTabs))],
+
+            loadOneTab: function loadOneTab(uri) {
+                return this.openTab("contentTab", { contentPage: uri });
+            },
+            loadURIWithFlags: function loadURIWithFlags() {
+                return this.mCurrentTab.loadURIWithFlags.apply(this.mCurrentTab, arguments);
+            }
+        },
+
+        get hasTabbrowser() !this.isComposeWindow,
+
+        get tabStip() this.tabbrowser.tabContainer,
+
+        get isComposeWindow() window.wintype == "msgcompose",
+
+        get mainWidget() this.isComposeWindow ? document.getElementById("content-frame") : window.GetThreadTree(),
+
+        get mainWindowId() this.isComposeWindow ? "msgcomposeWindow" : "messengerWindow",
+        get browserModes() [modules.modes.MESSAGE],
+        get mailModes() [modules.modes.NORMAL],
+
+        // NOTE: as I don't use TB I have no idea how robust this is. --djk
+        get outputHeight() {
+            if (!this.isComposeWindow) {
+                let container = document.getElementById("tabpanelcontainer").boxObject;
+                let deck      = document.getElementById("displayDeck");
+                let box       = document.getElementById("messagepanebox");
+                let splitter  = document.getElementById("threadpane-splitter").boxObject;
+
+                if (splitter.width > splitter.height)
+                    return container.height - deck.minHeight - box.minHeight- splitter.height;
+                else
+                    return container.height - Math.max(deck.minHeight, box.minHeight);
+            }
+            else
+                return document.getElementById("appcontent").boxObject.height;
+        },
+
+        removeTab: function removeTab(tab) {
+            if (this.tabbrowser.mTabs.length > 1)
+                this.tabbrowser.removeTab(tab);
+            else
+                dactyl.beep();
+        },
+
+        completers: Class.memoize(function () update({ mailfolder: "mailFolder" }, this.__proto__.completers)),
+
+        dialogs: {
+            about: ["About Thunderbird",
+                function () { window.openAboutDialog(); }],
+            addons: ["Manage Add-ons",
+                function () { window.openAddonsMgr(); }],
+            addressbook: ["Address book",
+                function () { window.toAddressBook(); }],
+            checkupdates: ["Check for updates",
+                function () { window.checkForUpdates(); }],
+            console: ["JavaScript console",
+                function () { window.toJavaScriptConsole(); }],
+            dominspector: ["DOM Inspector",
+                function () { window.inspectDOMDocument(content.document); }],
+            downloads: ["Manage Downloads",
+                function () { window.toOpenWindowByType('Download:Manager', 'chrome://mozapps/content/downloads/downloads.xul', 'chrome,dialog=no,resizable'); }],
+            preferences: ["Show Thunderbird preferences dialog",
+                function () { window.openOptionsDialog(); }],
+            printsetup: ["Setup the page size and orientation before printing",
+                function () { window.PrintUtils.showPageSetup(); }],
+            print: ["Show print dialog",
+                function () { window.PrintUtils.print(); }],
+            saveframe: ["Save frame to disk",
+                function () { window.saveFrameDocument(); }],
+            savepage: ["Save page to disk",
+                function () { window.saveDocument(window.content.document); }],
+        },
+
+        focusChange: function focusChange(win) {
+            const { modes } = modules;
+
+            // we switch to -- MESSAGE -- mode for Teledactyl when the main HTML widget gets focus
+            if (win && win.document instanceof Ci.nsIHTMLDocument || dactyl.focus instanceof Ci.nsIHTMLAnchorElement) {
+                if (this.isComposeWindow)
+                    modes.set(modes.INSERT, modes.TEXT_EDIT);
+                else if (dactyl.mode != modes.MESSAGE)
+                    modes.main = modes.MESSAGE;
+            }
+        }
+    },
+
+    autocommands: {
+        DOMLoad: "Triggered when a page's DOM content has fully loaded",
+        FolderLoad: "Triggered after switching folders in Thunderbird",
+        PageLoadPre: "Triggered after a page load is initiated",
+        PageLoad: "Triggered when a page gets (re)loaded/opened",
+        Enter: "Triggered after Thunderbird starts",
+        Leave: "Triggered before exiting Thunderbird",
+        LeavePre: "Triggered before exiting Thunderbird"
+    },
+
+    defaults: {
+        guioptions: "bCfrs",
+        complete: "f",
+        showtabline: 1,
+        titlestring: "Teledactyl"
+    },
+
+    /*** optional options, there are checked for existence and a fallback provided  ***/
+    features: Class.memoize(function () set(
+        this.isComposeWindow ? ["addressbook"]
+                             : ["hints", "mail", "marks", "addressbook", "tabs"])),
+
+    guioptions: {
+        m: ["MenuBar",            ["mail-toolbar-menubar2"]],
+        T: ["Toolbar" ,           ["mail-bar2"]],
+        f: ["Folder list",        ["folderPaneBox", "folderpane_splitter"]],
+        F: ["Folder list header", ["folderPaneHeader"]]
+    },
+
+    // they are sorted by relevance, not alphabetically
+    helpFiles: ["intro.html", "version.html"],
+
+    modes: [
+        ["MESSAGE", { char: "m" }],
+        ["COMPOSE"]
+    ],
+
+    get scripts() this.isComposeWindow ? ["compose/compose"] : [
+        "addressbook",
+        "mail",
+        "tabs",
+    ],
+
+    overlayChrome: ["chrome://messenger/content/messenger.xul",
+                      "chrome://messenger/content/messengercompose/messengercompose.xul"],
+    styleableChrome: ["chrome://messenger/content/messenger.xul",
+                      "chrome://messenger/content/messengercompose/messengercompose.xul"],
+
+    // to allow Vim to :set ft=mail automatically
+    tempFile: "teledactyl.eml"
+}, {
+}, {
+    commands: function initCommands(dactyl, modules, window) {
+        const { commands } = modules;
+
+        commands.add(["pref[erences]", "prefs"],
+            "Show " + config.host + " preferences",
+            function () { window.openOptionsDialog(); },
+            { argCount: "0" });
+    },
+    modes: function initModes(dactyl, modules, window) {
+        const { modes } = modules;
+
+        this.ignoreKeys = {
+            "<Return>": modes.NORMAL | modes.INSERT,
+            "<Space>": modes.NORMAL | modes.INSERT,
+            "<Up>": modes.NORMAL | modes.INSERT,
+            "<Down>": modes.NORMAL | modes.INSERT
+        };
+    },
+    options: function initOptions(dactyl, modules, window) {
+        const { options } = modules;
+
+        // FIXME: comment obviously incorrect
+        // 0: never automatically edit externally
+        // 1: automatically edit externally when message window is shown the first time
+        // 2: automatically edit externally, once the message text gets focus (not working currently)
+        options.add(["autoexternal", "ae"],
+            "Edit message with external editor by default",
+            "boolean", false);
+
+        options.add(["online"],
+            "Set the 'work offline' option",
+            "boolean", true,
+            {
+                setter: function (value) {
+                    if (window.MailOfflineMgr.isOnline() != value)
+                        window.MailOfflineMgr.toggleOfflineStatus();
+                    return value;
+                },
+                getter: function () window.MailOfflineMgr.isOnline()
+            });
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/teledactyl/content/logo.png b/teledactyl/content/logo.png
new file mode 100644 (file)
index 0000000..a393436
Binary files /dev/null and b/teledactyl/content/logo.png differ
diff --git a/teledactyl/content/mail.js b/teledactyl/content/mail.js
new file mode 100644 (file)
index 0000000..ff01820
--- /dev/null
@@ -0,0 +1,935 @@
+// Copyright (c) 2006-2009 by Martin Stubenschrott <stubenschrott@vimperator.org>
+//
+// This work is licensed for reuse under an MIT license. Details are
+// given in the LICENSE.txt file included with this file.
+"use strict";
+
+const Mail = Module("mail", {
+    init: function init() {
+        // used for asynchronously selecting messages after wrapping folders
+        this._selectMessageKeys = [];
+        this._selectMessageCount = 1;
+        this._selectMessageReverse = false;
+
+        this._mailSession = Cc["@mozilla.org/messenger/services/session;1"].getService(Ci.nsIMsgMailSession);
+        this._notifyFlags = Ci.nsIFolderListener.intPropertyChanged | Ci.nsIFolderListener.event;
+        this._mailSession.AddFolderListener(this._folderListener, this._notifyFlags);
+    },
+
+    _folderListener: {
+        OnItemAdded: function (parentItem, item) {},
+        OnItemRemoved: function (parentItem, item) {},
+        OnItemPropertyChanged: function (item, property, oldValue, newValue) {},
+        OnItemIntPropertyChanged: function (item, property, oldValue, newValue) {},
+        OnItemBoolPropertyChanged: function (item, property, oldValue, newValue) {},
+        OnItemUnicharPropertyChanged: function (item, property, oldValue, newValue) {},
+        OnItemPropertyFlagChanged: function (item, property, oldFlag, newFlag) {},
+
+        OnItemEvent: function (folder, event) {
+            let eventType = event.toString();
+            if (eventType == "FolderLoaded") {
+                if (folder) {
+                    let msgFolder = folder.QueryInterface(Ci.nsIMsgFolder);
+                    autocommands.trigger("FolderLoaded", { url: msgFolder });
+
+                    // Jump to a message when requested
+                    let indices = [];
+                    if (mail._selectMessageKeys.length > 0) {
+                        for (let j = 0; j < mail._selectMessageKeys.length; j++)
+                            indices.push([gDBView.findIndexFromKey(mail._selectMessageKeys[j], true), mail._selectMessageKeys[j]]);
+
+                        indices.sort();
+                        let index = mail._selectMessageCount - 1;
+                        if (mail._selectMessageReverse)
+                            index = mail._selectMessageKeys.length - 1 - index;
+
+                        gDBView.selectMsgByKey(indices[index][1]);
+                        mail._selectMessageKeys = [];
+                    }
+                }
+            }
+            /*else if (eventType == "ImapHdrDownloaded") {}
+            else if (eventType == "DeleteOrMoveMsgCompleted") {}
+            else if (eventType == "DeleteOrMoveMsgFailed") {}
+            else if (eventType == "AboutToCompact") {}
+            else if (eventType == "CompactCompleted") {}
+            else if (eventType == "RenameCompleted") {}
+            else if (eventType == "JunkStatusChanged") {}*/
+        }
+    },
+
+    _getCurrentFolderIndex: function () {
+        // for some reason, the index is interpreted as a string, therefore the parseInt
+        return parseInt(gFolderTreeView.getIndexOfFolder(gFolderTreeView.getSelectedFolders()[0]));
+    },
+
+    _getRSSUrl: function () {
+        return gDBView.hdrForFirstSelectedMessage.messageId.replace(/(#.*)?@.*$/, "");
+    },
+
+    _moveOrCopy: function (copy, destinationFolder, operateOnThread) {
+        let folders = mail.getFolders(destinationFolder);
+        if (folders.length == 0)
+            return void dactyl.echoerr("Exxx: No matching folder for " + destinationFolder);
+        else if (folders.length > 1)
+            return dactyl.echoerr("Exxx: More than one match for " + destinationFolder);
+
+        let count = gDBView.selection.count;
+        if (!count)
+            return void dactyl.beep();
+
+        (copy ? MsgCopyMessage : MsgMoveMessage)(folders[0]);
+        util.timeout(function () {
+            dactyl.echomsg(count + " message(s) " + (copy ? "copied" : "moved") + " to " + folders[0].prettyName, 1);
+        }, 100);
+    },
+
+    _parentIndex: function (index) {
+        let parent = index;
+        let tree = GetThreadTree();
+
+        while (true) {
+            let tmp = tree.view.getParentIndex(parent);
+            if (tmp >= 0)
+                parent = tmp;
+            else
+                break;
+        }
+        return parent;
+    },
+
+    // does not wrap yet, intentional?
+    _selectUnreadFolder: function (backwards, count) {
+        count = Math.max(1, count);
+        let direction = backwards ? -1 : 1;
+        let c = this._getCurrentFolderIndex();
+        let i = direction;
+        let folder;
+        while (count > 0 && (c + i) < gFolderTreeView.rowCount && (c + i) >= 0) {
+            let resource = gFolderTreeView._rowMap[c + i]._folder;
+            if (!resource.isServer && resource.getNumUnread(false)) {
+                count -= 1;
+                folder = i;
+            }
+            i += direction;
+        }
+        if (!folder || count > 0)
+            dactyl.beep();
+        else
+            gFolderTreeView.selection.timedSelect(c + folder, 500);
+    },
+
+    _escapeRecipient: function (recipient) {
+        // strip all ":
+        recipient = recipient.replace(/"/g, "");
+        return "\"" + recipient + "\"";
+    },
+
+    get currentAccount() this.currentFolder.rootFolder,
+
+    get currentFolder() gFolderTreeView.getSelectedFolders()[0],
+
+    /** @property {nsISmtpServer[]} The list of configured SMTP servers. */
+    get smtpServers() {
+        let servers = services.smtp.smtpServers;
+        let res = [];
+
+        while (servers.hasMoreElements()) {
+            let server = servers.getNext();
+            if (server instanceof Ci.nsISmtpServer)
+                res.push(server);
+        }
+
+        return res;
+    },
+
+    composeNewMail: function (args) {
+        let params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(Ci.nsIMsgComposeParams);
+        params.composeFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(Ci.nsIMsgCompFields);
+
+        if (args) {
+            if (args.originalMsg)
+                params.originalMsgURI = args.originalMsg;
+            if (args.to)
+                params.composeFields.to = args.to;
+            if (args.cc)
+                params.composeFields.cc = args.cc;
+            if (args.bcc)
+                params.composeFields.bcc = args.bcc;
+            if (args.newsgroups)
+                params.composeFields.newsgroups = args.newsgroups;
+            if (args.subject)
+                params.composeFields.subject = args.subject;
+            if (args.body)
+                params.composeFields.body = args.body;
+
+            if (args.attachments) {
+                while (args.attachments.length > 0) {
+                    let url = args.attachments.pop();
+                    let file = io.getFile(url);
+                    if (!file.exists())
+                        return void dactyl.echoerr("Exxx: Could not attach file `" + url + "'", commandline.FORCE_SINGLELINE);
+
+                    attachment = Cc["@mozilla.org/messengercompose/attachment;1"].createInstance(Ci.nsIMsgAttachment);
+                    attachment.url = "file://" + file.path;
+                    params.composeFields.addAttachment(attachment);
+                }
+            }
+        }
+
+        params.type = Ci.nsIMsgCompType.New;
+
+        const msgComposeService = Cc["@mozilla.org/messengercompose;1"].getService();
+        msgComposeService = msgComposeService.QueryInterface(Ci.nsIMsgComposeService);
+        msgComposeService.OpenComposeWindowWithParams(null, params);
+    },
+
+    // returns an array of nsIMsgFolder objects
+    getFolders: function (filter, includeServers, includeMsgFolders) {
+        let folders = [];
+        if (!filter)
+            filter = "";
+        else
+            filter = filter.toLowerCase();
+
+        if (includeServers === undefined)
+            includeServers = false;
+        if (includeMsgFolders === undefined)
+            includeMsgFolders = true;
+
+        for (let i = 0; i < gFolderTreeView.rowCount; i++) {
+            let resource = gFolderTreeView._rowMap[i]._folder;
+            if ((resource.isServer && !includeServers) || (!resource.isServer && !includeMsgFolders))
+                continue;
+
+            let folderString = resource.server.prettyName + ": " + resource.name;
+
+            if (resource.prettiestName.toLowerCase().indexOf(filter) >= 0)
+                folders.push(resource);
+            else if (folderString.toLowerCase().indexOf(filter) >= 0)
+                folders.push(resource);
+        }
+        return folders;
+    },
+
+    getNewMessages: function (currentAccountOnly) {
+        if (currentAccountOnly)
+            MsgGetMessagesForAccount();
+        else
+            GetMessagesForAllAuthenticatedAccounts();
+    },
+
+    getStatistics: function (currentAccountOnly) {
+        let accounts = currentAccountOnly ? [this.currentAccount]
+                                          : this.getFolders("", true, false);
+
+        let unreadCount = 0, totalCount = 0, newCount = 0;
+        for (let i = 0; i < accounts.length; i++) {
+            let account = accounts[i];
+            unreadCount += account.getNumUnread(true); // true == deep (includes subfolders)
+            totalCount  += account.getTotalMessages(true);
+            newCount    += account.getNumUnread(true);
+        }
+
+        return { numUnread: unreadCount, numTotal: totalCount, numNew: newCount };
+    },
+
+    collapseThread: function () {
+        let tree = GetThreadTree();
+        if (tree) {
+            let parent = this._parentIndex(tree.currentIndex);
+            if (tree.changeOpenState(parent, false)) {
+                tree.view.selection.select(parent);
+                tree.treeBoxObject.ensureRowIsVisible(parent);
+                return true;
+            }
+        }
+        return false;
+    },
+
+    expandThread: function () {
+        let tree = GetThreadTree();
+        if (tree) {
+            let row = tree.currentIndex;
+            if (row >= 0 && tree.changeOpenState(row, true))
+               return true;
+        }
+        return false;
+    },
+
+    /**
+     * General-purpose method to find messages.
+     *
+     * @param {function(nsIMsgDBHdr):boolean} validatorFunc Return
+     *     true/false whether msg should be selected or not.
+     * @param {boolean} canWrap When true, wraps around folders.
+     * @param {boolean} openThreads Should we open closed threads?
+     * @param {boolean} reverse Change direction of searching.
+     */
+    selectMessage: function (validatorFunc, canWrap, openThreads, reverse, count) {
+        function currentIndex() {
+            let index = gDBView.selection.currentIndex;
+            if (index < 0)
+                index = 0;
+            return index;
+        }
+
+        function closedThread(index) {
+            if (!(gDBView.viewFlags & nsMsgViewFlagsType.kThreadedDisplay))
+                return false;
+
+            index = (typeof index == "number") ? index : currentIndex();
+            return !gDBView.isContainerOpen(index) && !gDBView.isContainerEmpty(index);
+        }
+
+        if (typeof validatorFunc != "function")
+            return;
+
+        if (typeof count != "number" || count < 1)
+            count = 1;
+
+        // first try to find in current folder
+        if (gDBView) {
+            for (let i = currentIndex() + (reverse ? -1 : (openThreads && closedThread() ? 0 : 1));
+                    reverse ? (i >= 0) : (i < gDBView.rowCount);
+                    reverse ? i-- : i++) {
+                let key = gDBView.getKeyAt(i);
+                let msg = gDBView.db.GetMsgHdrForKey(key);
+
+                // a closed thread
+                if (openThreads && closedThread(i)) {
+                    let thread = gDBView.db.GetThreadContainingMsgHdr(msg);
+                    let originalCount = count;
+
+                    for (let j = (i == currentIndex() && !reverse) ? 1 : (reverse ? thread.numChildren - 1 : 0);
+                             reverse ? (j >= 0) : (j < thread.numChildren);
+                             reverse ? j-- : j++) {
+                        msg = thread.getChildAt(j);
+                        if (validatorFunc(msg) && --count == 0) {
+                            // this hack is needed to get the correct message, because getChildAt() does not
+                            // necessarily return the messages in the order they are displayed
+                            gDBView.selection.timedSelect(i, GetThreadTree()._selectDelay || 500);
+                            GetThreadTree().treeBoxObject.ensureRowIsVisible(i);
+                            if (j > 0) {
+                                GetThreadTree().changeOpenState(i, true);
+                                this.selectMessage(validatorFunc, false, false, false, originalCount);
+                            }
+                            return;
+                        }
+                    }
+                }
+                else { // simple non-threaded message
+                    if (validatorFunc(msg) && --count == 0) {
+                        gDBView.selection.timedSelect(i, GetThreadTree()._selectDelay || 500);
+                        GetThreadTree().treeBoxObject.ensureRowIsVisible(i);
+                        return;
+                    }
+                }
+            }
+        }
+
+        // then in other folders
+        if (canWrap) {
+            this._selectMessageReverse = reverse;
+
+            let folders = this.getFolders("", true, true);
+            let ci = this._getCurrentFolderIndex();
+            for (let i = 1; i < folders.length; i++) {
+                let index = (i + ci) % folders.length;
+                if (reverse)
+                    index = folders.length - 1 - index;
+
+                let folder = folders[index];
+                if (folder.isServer)
+                    continue;
+
+                this._selectMessageCount = count;
+                this._selectMessageKeys = [];
+
+                // sometimes folder.getMessages can fail with an exception
+                // TODO: find out why, and solve the problem
+                try {
+                    var msgs = folder.messages;
+                }
+                catch (e) {
+                    msgs = folder.getMessages(msgWindow); // for older thunderbirds
+                    dactyl.dump("WARNING: " + folder.prettyName + " failed to getMessages, trying old API");
+                    //continue;
+                }
+
+                while (msgs.hasMoreElements()) {
+                    let msg = msgs.getNext().QueryInterface(Ci.nsIMsgDBHdr);
+                    if (validatorFunc(msg)) {
+                        count--;
+                        this._selectMessageKeys.push(msg.messageKey);
+                    }
+                }
+
+                if (count <= 0) {
+                    // SelectFolder is asynchronous, message is selected in this._folderListener
+                    SelectFolder(folder.URI);
+                    return;
+                }
+            }
+        }
+
+        // TODO: finally for the "rest" of the current folder
+
+        dactyl.beep();
+    },
+
+    setHTML: function (value) {
+        let values = [[true,  1, gDisallow_classes_no_html],  // plaintext
+                      [false, 0, 0],                          // HTML
+                      [false, 3, gDisallow_classes_no_html]]; // sanitized/simple HTML
+
+        if (typeof value != "number" || value < 0 || value > 2)
+            value = 1;
+
+        gPrefBranch.setBoolPref("mailnews.display.prefer_plaintext", values[value][0]);
+        gPrefBranch.setIntPref("mailnews.display.html_as", values[value][1]);
+        gPrefBranch.setIntPref("mailnews.display.disallow_mime_handlers", values[value][2]);
+        ReloadMessage();
+    }
+}, {
+}, {
+    commands: function initCommands(dactyl, modules, window) {
+        commands.add(["go[to]"],
+            "Select a folder",
+            function (args) {
+                let count = Math.max(0, args.count - 1);
+                let arg = args.literalArg || "Inbox";
+
+                let folder = mail.getFolders(arg, true, true)[count];
+                if (!folder)
+                    dactyl.echoerr("Exxx: Folder \"" + arg + "\" does not exist");
+                else if (dactyl.forceNewTab)
+                    MsgOpenNewTabForFolder(folder.URI);
+                else
+                    SelectFolder(folder.URI);
+            },
+            {
+                argCount: "?",
+                completer: function (context) completion.mailFolder(context),
+                count: true,
+                literal: 0
+            });
+
+        commands.add(["m[ail]"],
+            "Write a new message",
+            function (args) {
+                let mailargs = {};
+                mailargs.to =          args.join(", ");
+                mailargs.subject =     args["-subject"];
+                mailargs.bcc =         args["-bcc"] || [];
+                mailargs.cc =          args["-cc"] || [];
+                mailargs.body =        args["-text"];
+                mailargs.attachments = args["-attachment"] || [];
+
+                let addresses = args;
+                if (mailargs.bcc)
+                    addresses = addresses.concat(mailargs.bcc);
+                if (mailargs.cc)
+                    addresses = addresses.concat(mailargs.cc);
+
+                // TODO: is there a better way to check for validity?
+                if (addresses.some(function (recipient) !(/\S@\S+\.\S/.test(recipient))))
+                    return void dactyl.echoerr("Exxx: Invalid e-mail address");
+
+                mail.composeNewMail(mailargs);
+            },
+            {
+                // TODO: completers, validators - whole shebang. Do people actually use this? --djk
+                options: [
+                    { names: ["-subject", "-s"],     type: CommandOption.STRING, description: "Subject line"},
+                    { names: ["-attachment", "-a"],  type: CommandOption.LIST,   description: "List of attachments"},
+                    { names: ["-bcc", "-b"],         type: CommandOption.LIST,   description: "Blind Carbon Copy addresses"},
+                    { names: ["-cc", "-c"],          type: CommandOption.LIST,   description: "Carbon Copy addresses"},
+                    { names: ["-text", "-t"],        type: CommandOption.STRING, description: "Message body"}
+                ]
+            });
+
+        commands.add(["copy[to]"],
+            "Copy selected messages",
+            function (args) { mail._moveOrCopy(true, args.literalArg); },
+            {
+                argCount: 1,
+                completer: function (context) completion.mailFolder(context),
+                literal: 0
+            });
+
+        commands.add(["move[to]"],
+            "Move selected messages",
+            function (args) { mail._moveOrCopy(false, args.literalArg); },
+            {
+                argCount: 1,
+                completer: function (context) completion.mailFolder(context),
+                literal: 0
+            });
+
+        commands.add(["empty[trash]"],
+            "Empty trash of the current account",
+            function () { window.goDoCommand("cmd_emptyTrash"); },
+            { argCount: "0" });
+
+        commands.add(["get[messages]"],
+            "Check for new messages",
+            function (args) mail.getNewMessages(!args.bang),
+            {
+                argCount: "0",
+                bang: true,
+            });
+    },
+    completion: function initCompletion(dactyl, modules, window) {
+        completion.mailFolder = function mailFolder(context) {
+            let folders = mail.getFolders(context.filter);
+            context.anchored = false;
+            context.quote = false;
+            context.completions = folders.map(function (folder)
+                    [folder.server.prettyName + ": " + folder.name,
+                     "Unread: " + folder.getNumUnread(false)]);
+        };
+    },
+    mappings: function initMappings(dactyl, modules, window) {
+        var myModes = config.mailModes;
+
+        mappings.add(myModes, ["<Return>", "i"],
+            "Inspect (focus) message",
+            function () { content.focus(); });
+
+        mappings.add(myModes, ["I"],
+            "Open the message in new tab",
+            function () {
+                if (gDBView && gDBView.selection.count < 1)
+                    return void dactyl.beep();
+
+                MsgOpenNewTabForMessage();
+            });
+
+        mappings.add(myModes, ["<Space>"],
+            "Scroll message or select next unread one",
+            function () Events.PASS);
+
+        mappings.add(myModes, ["t"],
+            "Select thread",
+            function () { gDBView.ExpandAndSelectThreadByIndex(GetThreadTree().currentIndex, false); });
+
+        mappings.add(myModes, ["d", "<Del>"],
+            "Move mail to Trash folder",
+            function () { window.goDoCommand("cmd_delete"); });
+
+        mappings.add(myModes, ["j", "<Right>"],
+            "Select next message",
+            function (args) { mail.selectMessage(function (msg) true, false, false, false, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["gj"],
+            "Select next message, including closed threads",
+            function (args) { mail.selectMessage(function (msg) true, false, true, false, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["J", "<Tab>"],
+            "Select next unread message",
+            function (args) { mail.selectMessage(function (msg) !msg.isRead, true, true, false, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["k", "<Left>"],
+            "Select previous message",
+            function (args) { mail.selectMessage(function (msg) true, false, false, true, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["gk"],
+            "Select previous message",
+            function (args) { mail.selectMessage(function (msg) true, false, true, true, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["K"],
+            "Select previous unread message",
+            function (args) { mail.selectMessage(function (msg) !msg.isRead, true, true, true, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["*"],
+            "Select next message from the same sender",
+            function (args) {
+                let author = gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor.toLowerCase();
+                mail.selectMessage(function (msg) msg.mime2DecodedAuthor.toLowerCase().indexOf(author) == 0, true, true, false, args.count);
+            },
+            { count: true });
+
+        mappings.add(myModes, ["#"],
+            "Select previous message from the same sender",
+            function (args) {
+                let author = gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor.toLowerCase();
+                mail.selectMessage(function (msg) msg.mime2DecodedAuthor.toLowerCase().indexOf(author) == 0, true, true, true, args.count);
+            },
+            { count: true });
+
+        // SENDING MESSAGES
+        mappings.add(myModes, ["m"],
+            "Compose a new message",
+            function () { CommandExMode().open("mail -subject="); });
+
+        mappings.add(myModes, ["M"],
+            "Compose a new message to the sender of selected mail",
+            function () {
+                let to = mail._escapeRecipient(gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor);
+                CommandExMode().open("mail " + to + " -subject=");
+            });
+
+        mappings.add(myModes, ["r"],
+            "Reply to sender",
+            function () { window.goDoCommand("cmd_reply"); });
+
+        mappings.add(myModes, ["R"],
+            "Reply to all",
+            function () { window.goDoCommand("cmd_replyall"); });
+
+        mappings.add(myModes, ["f"],
+            "Forward message",
+            function () { window.goDoCommand("cmd_forward"); });
+
+        mappings.add(myModes, ["F"],
+            "Forward message inline",
+            function () { window.goDoCommand("cmd_forwardInline"); });
+
+        // SCROLLING
+        mappings.add(myModes, ["<Down>"],
+            "Scroll message down",
+            function (args) { buffer.scrollLines(Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add(myModes, ["<Up>"],
+            "Scroll message up",
+            function (args) { buffer.scrollLines(-Math.max(args.count, 1)); },
+            { count: true });
+
+        mappings.add([modes.MESSAGE], ["<Left>"],
+            "Select previous message",
+            function (args) { mail.selectMessage(function (msg) true, false, false, true, args.count); },
+            { count: true });
+
+        mappings.add([modes.MESSAGE], ["<Right>"],
+            "Select next message",
+            function (args) { mail.selectMessage(function (msg) true, false, false, false, args.count); },
+            { count: true });
+
+        // UNDO/REDO
+        mappings.add(myModes, ["u"],
+            "Undo",
+            function () {
+                if (messenger.canUndo())
+                    messenger.undo(msgWindow);
+                else
+                    dactyl.beep();
+            });
+        mappings.add(myModes, ["<C-r>"],
+            "Redo",
+            function () {
+                if (messenger.canRedo())
+                    messenger.redo(msgWindow);
+                else
+                    dactyl.beep();
+            });
+
+        // GETTING MAIL
+        mappings.add(myModes, ["gm"],
+            "Get new messages",
+            function () { mail.getNewMessages(); });
+
+        mappings.add(myModes, ["gM"],
+            "Get new messages for current account only",
+            function () { mail.getNewMessages(true); });
+
+        // MOVING MAIL
+        mappings.add(myModes, ["c"],
+            "Change folders",
+            function () { CommandExMode().open("goto "); });
+
+        mappings.add(myModes, ["s"],
+            "Move selected messages",
+            function () { CommandExMode().open("moveto "); });
+
+        mappings.add(myModes, ["S"],
+            "Copy selected messages",
+            function () { CommandExMode().open("copyto "); });
+
+        mappings.add(myModes, ["<C-s>"],
+            "Archive message",
+            function () { mail._moveOrCopy(false, options["archivefolder"]); });
+
+        mappings.add(myModes, ["]s"],
+            "Select next starred message",
+            function (args) { mail.selectMessage(function (msg) msg.isFlagged, true, true, false, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["[s"],
+            "Select previous starred message",
+            function (args) { mail.selectMessage(function (msg) msg.isFlagged, true, true, true, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["]a"],
+            "Select next message with an attachment",
+            function (args) { mail.selectMessage(function (msg) gDBView.db.HasAttachments(msg.messageKey), true, true, false, args.count); },
+            { count: true });
+
+        mappings.add(myModes, ["[a"],
+            "Select previous message with an attachment",
+            function (args) { mail.selectMessage(function (msg) gDBView.db.HasAttachments(msg.messageKey), true, true, true, args.count); },
+            { count: true });
+
+        // FOLDER SWITCHING
+        mappings.add(myModes, ["gi"],
+            "Go to inbox",
+            function (args) {
+                let folder = mail.getFolders("Inbox", false, true)[(args.count > 0) ? (args.count - 1) : 0];
+                if (folder)
+                    SelectFolder(folder.URI);
+                else
+                    dactyl.beep();
+            },
+            { count: true });
+
+        mappings.add(myModes, ["<C-n>"],
+            "Select next folder",
+            function (args) {
+                let newPos = mail._getCurrentFolderIndex() + Math.max(1, args.count);
+                if (newPos >= gFolderTreeView.rowCount) {
+                    newPos = newPos % gFolderTreeView.rowCount;
+                    commandline.echo(_("finder.atBottom"), commandline.HL_WARNINGMSG, commandline.APPEND_TO_MESSAGES);
+                }
+                gFolderTreeView.selection.timedSelect(newPos, 500);
+            },
+            { count: true });
+
+        mappings.add(myModes, ["<C-N>"],
+            "Go to next mailbox with unread messages",
+            function (args) {
+                mail._selectUnreadFolder(false, args.count);
+            },
+            { count: true });
+
+        mappings.add(myModes, ["<C-p>"],
+            "Select previous folder",
+            function (args) {
+                let newPos = mail._getCurrentFolderIndex() - Math.max(1, args.count);
+                if (newPos < 0) {
+                    newPos = (newPos % gFolderTreeView.rowCount) + gFolderTreeView.rowCount;
+                    commandline.echo(_("finder.atTop"), commandline.HL_WARNINGMSG, commandline.APPEND_TO_MESSAGES);
+                }
+                gFolderTreeView.selection.timedSelect(newPos, 500);
+            },
+            { count: true });
+
+        mappings.add(myModes, ["<C-P>"],
+            "Go to previous mailbox with unread messages",
+            function (args) {
+                mail._selectUnreadFolder(true, args.count);
+            },
+            { count: true });
+
+        // THREADING
+        mappings.add(myModes, ["za"],
+            "Toggle thread collapsed/expanded",
+            function () { if (!mail.expandThread()) mail.collapseThread(); });
+
+        mappings.add(myModes, ["zc"],
+            "Collapse thread",
+            function () { mail.collapseThread(); });
+
+        mappings.add(myModes, ["zo"],
+            "Open thread",
+            function () { mail.expandThread(); });
+
+        mappings.add(myModes, ["zr", "zR"],
+            "Expand all threads",
+            function () { window.goDoCommand("cmd_expandAllThreads"); });
+
+        mappings.add(myModes, ["zm", "zM"],
+            "Collapse all threads",
+            function () { window.goDoCommand("cmd_collapseAllThreads"); });
+
+        mappings.add(myModes, ["<C-i>"],
+            "Go forward",
+            function ({ count }) { if (count < 1) count = 1; while (count--) GoNextMessage(nsMsgNavigationType.forward, true); },
+            { count: true });
+
+        mappings.add(myModes, ["<C-o>"],
+            "Go back",
+            function ({ count }) { if (count < 1) count = 1; while (count--) GoNextMessage(nsMsgNavigationType.back, true); },
+            { count: true });
+
+        mappings.add(myModes, ["gg"],
+            "Select first message",
+            function ({ count }) { if (count < 1) count = 1; while (count--) GoNextMessage(nsMsgNavigationType.firstMessage, true); },
+            { count: true });
+
+        mappings.add(myModes, ["G"],
+            "Select last message",
+            function ({ count }) { if (count < 1) count = 1; while (count--) GoNextMessage(nsMsgNavigationType.lastMessage, false); },
+            { count: true });
+
+        // tagging messages
+        mappings.add(myModes, ["l"],
+            "Label message",
+            function (arg) {
+                if (!GetSelectedMessages())
+                    return void dactyl.beep();
+
+                switch (arg) {
+                    case "r": MsgMarkMsgAsRead(); break;
+                    case "s": MsgMarkAsFlagged(); break;
+                    case "i": ToggleMessageTagKey(1); break; // Important
+                    case "w": ToggleMessageTagKey(2); break; // Work
+                    case "p": ToggleMessageTagKey(3); break; // Personal
+                    case "t": ToggleMessageTagKey(4); break; // TODO
+                    case "l": ToggleMessageTagKey(5); break; // Later
+                    default:  dactyl.beep();
+                }
+            },
+            {
+                arg: true
+            });
+
+        // TODO: change binding?
+        mappings.add(myModes, ["T"],
+            "Mark current folder as read",
+            function () {
+                if (mail.currentFolder.isServer)
+                    return dactyl.beep();
+
+                mail.currentFolder.markAllMessagesRead(msgWindow);
+            });
+
+        mappings.add(myModes, ["<C-t>"],
+            "Mark all messages as read",
+            function () {
+                mail.getFolders("", false).forEach(function (folder) { folder.markAllMessagesRead(msgWindow); });
+            });
+
+        // DISPLAY OPTIONS
+        mappings.add(myModes, ["h"],
+            "Toggle displayed headers",
+            function () {
+                let value = gPrefBranch.getIntPref("mail.show_headers", 2);
+                gPrefBranch.setIntPref("mail.show_headers", value == 2 ? 1 : 2);
+                ReloadMessage();
+            });
+
+        mappings.add(myModes, ["x"],
+            "Toggle HTML message display",
+            function () {
+                let wantHtml = (gPrefBranch.getIntPref("mailnews.display.html_as", 1) == 1);
+                mail.setHTML(wantHtml ? 1 : 0);
+            });
+
+        // YANKING TEXT
+        mappings.add(myModes, ["Y"],
+            "Yank subject",
+            function () {
+                try {
+                    let subject = gDBView.hdrForFirstSelectedMessage.mime2DecodedSubject;
+                    dactyl.clipboardWrite(subject, true);
+                }
+                catch (e) { dactyl.beep(); }
+            });
+
+        mappings.add(myModes, ["y"],
+            "Yank sender or feed URL",
+            function () {
+                try {
+                    if (mail.currentAccount.server.type == "rss")
+                        dactyl.clipboardWrite(mail._getRSSUrl(), true);
+                    else
+                        dactyl.clipboardWrite(gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor, true);
+                }
+                catch (e) { dactyl.beep(); }
+            });
+
+        // RSS specific mappings
+        mappings.add(myModes, ["p"],
+            "Open RSS message in browser",
+            function () {
+                try {
+                    if (mail.currentAccount.server.type == "rss")
+                        messenger.launchExternalURL(mail._getRSSUrl());
+                    // TODO: what to do for non-rss message?
+                }
+                catch (e) {
+                    dactyl.beep();
+                }
+            });
+    },
+    services: function initServices(dactyl, modules, window) {
+        services.add("smtp", "@mozilla.org/messengercompose/smtp;1", Ci.nsISmtpService);
+    },
+
+    modes: function initModes(dactyl, modules, window) {
+        modes.addMode("MESSAGE", {
+            char: "m",
+            description: "Active the message is focused",
+            bases: [modes.COMMAND]
+        });
+    },
+    options: function initOptions(dactyl, modules, window) {
+        // FIXME: why does this default to "Archive", I don't have one? The default
+        // value won't validate now. mst please fix. --djk
+        options.add(["archivefolder"],
+            "Set the archive folder",
+            "string", "Archive",
+            {
+                completer: function (context) completion.mailFolder(context)
+            });
+
+        // TODO: generate the possible values dynamically from the menu
+        options.add(["layout"],
+            "Set the layout of the mail window",
+            "string", "inherit",
+            {
+                setter: function (value) {
+                    switch (value) {
+                        case "classic":  ChangeMailLayout(0); break;
+                        case "wide":     ChangeMailLayout(1); break;
+                        case "vertical": ChangeMailLayout(2); break;
+                        // case "inherit" just does nothing
+                    }
+
+                    return value;
+                },
+                completer: function (context) [
+                    ["inherit",  "Default View"], // FIXME: correct description?
+                    ["classic",  "Classic View"],
+                    ["wide",     "Wide View"],
+                    ["vertical", "Vertical View"]
+                ]
+            });
+
+        options.add(["smtpserver", "smtp"],
+            "Set the default SMTP server",
+            "string", services.smtp.defaultServer.key, // TODO: how should we handle these persistent external defaults - "inherit" or null?
+            {
+                getter: function () services.smtp.defaultServer.key,
+                setter: function (value) {
+                    let server = mail.smtpServers.filter(function (s) s.key == value)[0];
+                    services.smtp.defaultServer = server;
+                    return value;
+                },
+                completer: function (context) [[s.key, s.serverURI] for ([, s] in Iterator(mail.smtpServers))]
+            });
+
+        /*options.add(["threads"],
+            "Use threading to group messages",
+            "boolean", true,
+            {
+                setter: function (value) {
+                    if (value)
+                        MsgSortThreaded();
+                    else
+                        MsgSortUnthreaded();
+
+                    return value;
+                }
+            });*/
+    }
+});
+
+// vim: set fdm=marker sw=4 ts=4 et:
diff --git a/teledactyl/contrib/vim/Makefile b/teledactyl/contrib/vim/Makefile
new file mode 100644 (file)
index 0000000..c3a65aa
--- /dev/null
@@ -0,0 +1,9 @@
+VIMBALL = teledactyl.vba
+
+vimball: mkvimball.txt syntax/teledactyl.vim ftdetect/teledactyl.vim
+       -echo '%MkVimball! ${VIMBALL} .' | vim -u NORC -N -e -s mkvimball.txt
+
+all: vimball
+
+clean:
+       rm -f ${VIMBALL}
diff --git a/teledactyl/contrib/vim/ftdetect/teledactyl.vim b/teledactyl/contrib/vim/ftdetect/teledactyl.vim
new file mode 100644 (file)
index 0000000..f4a303b
--- /dev/null
@@ -0,0 +1 @@
+au BufNewFile,BufRead *teledactylrc*,*.tele set filetype=teledactyl
diff --git a/teledactyl/contrib/vim/mkvimball.txt b/teledactyl/contrib/vim/mkvimball.txt
new file mode 100644 (file)
index 0000000..20148df
--- /dev/null
@@ -0,0 +1,2 @@
+syntax/teledactyl.vim
+ftdetect/teledactyl.vim
diff --git a/teledactyl/defaults/preferences/dactyl.js b/teledactyl/defaults/preferences/dactyl.js
new file mode 100644 (file)
index 0000000..bf78924
--- /dev/null
@@ -0,0 +1,6 @@
+pref("extensions.dactyl.name", "teledactyl");
+pref("extensions.dactyl.appName", "Teledactyl");
+pref("extensions.dactyl.idName", "TELEDACTYL");
+pref("extensions.dactyl.fileExt", "tele");
+pref("extensions.dactyl.host", "Thunderbird");
+pref("extensions.dactyl.hostbin", "thunderbird");
diff --git a/teledactyl/install.rdf b/teledactyl/install.rdf
new file mode 100644 (file)
index 0000000..d68eb62
--- /dev/null
@@ -0,0 +1,24 @@
+<?xml version="1.0"?>
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+    xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+    <Description about="urn:mozilla:install-manifest"
+        em:id="teledactyl@dactyl.googlecode.com"
+        em:type="2"
+        em:name="Teledactyl"
+        em:version="0.5b1pre"
+        em:description="Thunderbird for Mutt and Vim addicts"
+        em:creator="Kris Maglione"
+        em:homepageURL="http://dactyl.sf.net/Teledactyl"
+        em:iconURL="chrome://teledactyl/skin/icon.png"
+        em:optionsURL="chrome://dactyl/content/preferences.xul">
+        <em:targetApplication>
+            <Description>
+                <em:id>{3550f703-e582-4d05-9a08-453d09bdfdc6}</em:id>
+                <em:minVersion>3.0b3</em:minVersion>
+                <em:maxVersion>3.2</em:maxVersion>
+            </Description>
+        </em:targetApplication>
+    </Description>
+</RDF>
+
+<!-- vim: set fdm=marker sw=4 ts=4 et: -->
diff --git a/teledactyl/locale/en-US/Makefile b/teledactyl/locale/en-US/Makefile
new file mode 100644 (file)
index 0000000..9bb4fb4
--- /dev/null
@@ -0,0 +1,3 @@
+NAME = teledactyl
+BASE = ../../../common
+include $(BASE)/Makefile.doc
diff --git a/teledactyl/locale/en-US/all.xml b/teledactyl/locale/en-US/all.xml
new file mode 100644 (file)
index 0000000..59d250a
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/teledactyl/locale/en-US/autocommands.xml b/teledactyl/locale/en-US/autocommands.xml
new file mode 100644 (file)
index 0000000..edcaf54
--- /dev/null
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl tag="autocommand-list" replace="autocommand-list">
+    <dt>ColorScheme</dt>       <dd>Triggered after a color scheme has been loaded</dd>
+    <dt>DOMLoad</dt>           <dd>Triggered when a page's DOM content has fully loaded</dd>
+    <dt>DownloadPost</dt>      <dd>Triggered when a download has completed</dd>
+    <dt>Fullscreen</dt>        <dd>Triggered when the browser's fullscreen state changes</dd>
+    <dt>LocationChange</dt>    <dd>Triggered when changing tabs or when navigating to a new location</dd>
+    <dt>PageLoadPre</dt>       <dd>Triggered after a page load is initiated</dd>
+    <dt>PageLoad</dt>          <dd>Triggered when a page gets (re)loaded/opened</dd>
+    <dt>ShellCmdPost</dt>      <dd>Triggered after executing a shell command with <ex>:!</ex><a>cmd</a></dd>
+    <dt>Enter</dt>             <dd>Triggered after &dactyl.host; starts</dd>
+    <dt>LeavePre</dt>          <dd>Triggered before exiting &dactyl.host;, just before destroying each module</dd>
+    <dt>Leave</dt>             <dd>Triggered before exiting &dactyl.host;</dd>
+    <dt>FolderLoad</dt>        <dd>Triggered after switching folders in &dactyl.host;</dd>
+</dl>
+
+<dl tag="autocommand-args" replace="autocommand-args">
+    <dt>&lt;url></dt>       <dd>The URL against which the event was selected.</dd>
+    <dt>&lt;title></dt>     <dd>The page, bookmark or download title.</dd>
+    <dt>&lt;doc></dt>       <dd>The document for which the event occurred. Only for <em>DOMLoad</em>, <em>PageLoad</em> and <em>PageLoadPre</em>.</dd>
+    <dt>&lt;tab></dt>       <dd>The tab in which the event occurred. Only for <em>DOMLoad</em>, <em>PageLoad</em> and <em>PageLoadPre</em>.</dd>
+    <dt>&lt;size></dt>      <dd>The size of a downloaded file. Only for <em>DownloadPost</em>.</dd>
+    <dt>&lt;file></dt>      <dd>The target destination of a download. Only for <em>DownloadPost</em>.</dd>
+    <dt>&lt;name></dt>      <dd>The name of the item. Only for <em>ColorScheme</em> and <em>Sanitize</em>.</dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/teledactyl/locale/en-US/gui.xml b/teledactyl/locale/en-US/gui.xml
new file mode 100644 (file)
index 0000000..4f8b449
--- /dev/null
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl tag="dialog-list" replace="dialog-list">
+    <dt>about</dt>           <dd>About &dactyl.host;</dd>
+    <dt>addons</dt>          <dd>Manage Add-ons</dd>
+    <dt>addressbook</dt>     <dd>Address book</dd>
+    <dt>checkupdates</dt>    <dd>Check for updates</dd>
+    <dt>console</dt>         <dd>JavaScript console</dd>
+    <dt>dominspector</dt>    <dd>DOM Inspector</dd>
+    <dt>downloads</dt>       <dd>Manage Downloads</dd>
+    <dt>openfile</dt>        <dd>Open the file selector dialog</dd>
+    <dt>pageinfo</dt>        <dd>Show information about the current page</dd>
+    <dt>pagesource</dt>      <dd>View page source</dd>
+    <dt>preferences</dt>     <dd>Show &dactyl.host; preferences dialog</dd>
+    <dt>printsetup</dt>      <dd>Setup the page size and orientation before printing</dd>
+    <dt>print</dt>           <dd>Show print dialog</dd>
+    <dt>saveframe</dt>       <dd>Save frame to disk</dd>
+    <dt>savepage</dt>        <dd>Save page to disk</dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/teledactyl/locale/en-US/intro.xml b/teledactyl/locale/en-US/intro.xml
new file mode 100644 (file)
index 0000000..4f111d5
--- /dev/null
@@ -0,0 +1,138 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<p replace="intro-text">
+    <link topic="&dactyl.apphome;">&dactyl.appName;</link> is a free mailer
+    add-on for &dactyl.host;, which combines the best features of the
+    <link topic="http://www.mutt.org">Mutt</link> mail client and the
+    <link topic="http://www.vim.org">Vim</link> text editor.
+</p>
+
+<!-- TODO: make Teledactyl specific -->
+<ol replace="topics-list">
+    <li>
+        <link topic="tutorial">Quick-start tutorial</link>:
+        A quick-start tutorial for new users.
+    </li>
+    <li>
+        <link topic="starting">Starting &dactyl.appName;</link>:
+        How &dactyl.appName; starts up, where it reads the config file, etc.
+    </li>
+    <li>
+        <link topic="browsing">Browsing</link>:
+        Basic key mappings and commands needed for a browsing
+        session (how to open a web page, go back in history, etc.)
+    </li>
+    <li>
+        <link topic="buffer">Buffer</link>:
+        Operations on the current document (scrolling, copying text,
+        etc.)
+    </li>
+    <li>
+        <link topic="cmdline">Command-line mode</link>:
+        Command-line editing.
+    </li>
+    <li>
+        <link topic="editing">Editing text</link>:
+        Text area and input field editing.
+    </li>
+    <li>
+        <link topic="options">Options</link>:
+        A description of all options.
+    </li>
+    <li>
+        <link topic="pattern">Text search commands</link>:
+        Searching for text in the current buffer.
+    </li>
+    <li>
+        <link topic="tabs">Tabs</link>:
+        Managing your tabbed browsing session.
+    </li>
+    <li>
+        <link topic="hints">Hints</link>:
+        Selecting hyperlinks and other page elements.
+    </li>
+    <li>
+        <link topic="map">Keyboard shortcuts and commands</link>:
+        Defining new key mappings, abbreviations and user commands.
+    </li>
+    <li>
+        <link topic="eval">Expression evaluation</link>:
+        Executing JavaScript.
+    </li>
+    <li>
+        <link topic="marks">Marks</link>:
+        Using bookmarks, QuickMarks, history and local marks.
+    </li>
+    <li>
+        <link topic="repeat">Repeating commands</link>:
+        Using macros to repeat recurring workflows.
+    </li>
+    <li>
+        <link topic="autocommands">Automatic commands</link>:
+        Automatically executing code on certain events.
+    </li>
+    <li>
+        <link topic="print">Printing</link>:
+        Printing pages.
+    </li>
+    <li>
+        <link topic="gui">&dactyl.appName;'s GUI</link>:
+        Accessing &dactyl.host; menus, dialogs and the sidebar.
+    </li>
+    <li>
+        <link topic="styling">Styling the GUI and web pages</link>:
+        Changing the styling of content pages and &dactyl.appName; itself.
+    </li>
+    <li>
+        <link topic="message">Error and informational messages</link>:
+        A description of informational and error messages.
+    </li>
+    <li>
+        <link topic="developer">Developer information</link>:
+        How to write plugins and documentation.
+    </li>
+    <li>
+        <link topic="various">Various commands</link>:
+        Other help which doesn't readily fit into any other category.
+    </li>
+    <li>
+        <link topic="plugins">Plugins</link>:
+        Documentation for any plugins you have installed.
+    </li>
+    <li>
+        <link topic="index">Index</link>:
+        An index of all commands and options.
+    </li>
+</ol>
+
+<!-- TODO: make Teledactyl specific -->
+<ul replace="features-list">
+    <li>Vim-like keybindings (<k>h</k>, <k>j</k>, <k>gg</k>, <k>ZZ</k>, <k name="C-f"/>, etc.)</li>
+    <li>Ex commands (<ex>:quit</ex>, <ex>:open www.foo.com</ex>, …)</li>
+    <li>Tab completion for all commands, highly configurable via <o>wildmode</o>, <o>autocomplete</o>, ...</li>
+    <li>Hit-a-hint like navigation of links (start with <k>f</k> to follow a link)</li>
+    <li>Advanced completion of bookmark and history URLs</li>
+    <li>Vim-like status line with a Wget-like progress bar</li>
+    <li>Minimal GUI (easily hide superfluous menubar and toolbar with <se opt="guioptions"/>)</li>
+    <li>Ability to <ex>:source</ex> JavaScript, CSS, and &dactyl.appName; command files</li>
+    <li>Easy quick searches (see <ex>:open</ex>)</li>
+    <li>Count supported for many commands (<em>3</em><k name="C-o"/> will go back 3 pages)</li>
+    <li>Visual bell for errors (<o>visualbell</o>)</li>
+    <li>Marks support (<k>m</k><em>a</em> to set mark a, <k>'</k><em>a</em> to jump to it)</li>
+    <li><link topic="quickmarks">QuickMark</link> support</li>
+    <li><ex>:map</ex>, <ex>:command</ex>, <ex>:normal</ex>, and <t>macros</t></li>
+    <li><link topic="i_&lt;C-i>">Editing of text fields</link> with an <link topic="'editor'">external editor</link></li>
+    <li>AutoCommands to execute actions on certain events</li>
+    <li>A comprehensive help system, explaining all commands, mappings, options, and plugins</li>
+</ul>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/teledactyl/locale/en-US/map.xml b/teledactyl/locale/en-US/map.xml
new file mode 100644 (file)
index 0000000..4038530
--- /dev/null
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" href="dactyl://content/help.xsl"?>
+
+<!DOCTYPE overlay SYSTEM "dactyl://content/dtd">
+
+<overlay
+    xmlns="&xmlns.dactyl;"
+    xmlns:html="&xmlns.html;">
+
+<dl replace=":command-complete-arg-list">
+    <dt>abbreviation</dt> <dd>abbreviations</dd>
+    <dt>altstyle</dt>     <dd>alternate author style sheets</dd>
+    <dt>bookmark</dt>     <dd>bookmarks</dd>
+    <dt>buffer</dt>       <dd>buffers</dd>
+    <dt>charset</dt>      <dd>character sets</dd>
+    <dt>color</dt>        <dd>color schemes</dd>
+    <dt>command</dt>      <dd>Ex commands</dd>
+    <dt>dialog</dt>       <dd>&dactyl.host; dialogs</dd>
+    <dt>dir</dt>          <dd>directories</dd>
+    <dt>environment</dt>  <dd>environment variables</dd>
+    <dt>event</dt>        <dd>autocommand events</dd>
+    <dt>extension</dt>    <dd>installed extensions</dd>
+    <dt>file</dt>         <dd>files</dd>
+    <dt>help</dt>         <dd>help tags</dd>
+    <dt>highlight</dt>    <dd>highlight groups</dd>
+    <dt>history</dt>      <dd>browsing history</dd>
+    <dt>javascript</dt>   <dd>JavaScript expressions</dd>
+    <dt>macro</dt>        <dd>named macros</dd>
+    <dt>mailfolder</dt>   <dd>mail folders</dd>
+    <dt>mapping</dt>      <dd>user mappings</dd>
+    <dt>mark</dt>         <dd>local page marks</dd>
+    <dt>menu</dt>         <dd>menu items</dd>
+    <dt>option</dt>       <dd>&dactyl.appName; options</dd>
+    <dt>preference</dt>   <dd>&dactyl.host; preferences</dd>
+    <dt>qmark</dt>        <dd>quick marks</dd>
+    <dt>runtime</dt>      <dd>runtime paths</dd>
+    <dt>search</dt>       <dd>search engines and keywords</dd>
+    <dt>shellcmd</dt>     <dd>shell commands</dd>
+    <dt>toolbar</dt>      <dd>toolbars</dd>
+    <dt>url</dt>          <dd>URLs</dd>
+    <dt>usercommand</dt>  <dd>user commands</dd>
+    <dt>custom,<a>thing</a></dt><dd>custom completion, provided by <a>thing</a></dd>
+</dl>
+
+</overlay>
+
+<!-- vim:se sts=4 sw=4 et: -->
diff --git a/teledactyl/skin/icon.png b/teledactyl/skin/icon.png
new file mode 100644 (file)
index 0000000..d07edc6
Binary files /dev/null and b/teledactyl/skin/icon.png differ