diff options
Diffstat (limited to 'common/content')
33 files changed, 15711 insertions, 17646 deletions
diff --git a/common/content/autocommands.js b/common/content/autocommands.js new file mode 100644 index 00000000..5ce1ab49 --- /dev/null +++ b/common/content/autocommands.js @@ -0,0 +1,275 @@ +// 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. + + +/** @scope modules */ + +const AutoCommand = new Struct("event", "pattern", "command"); + +/** + * @instance autocommands + */ +const AutoCommands = Module("autocommands", { + init: function () { + this._store = []; + }, + + __iterator__: function () util.Array.itervalues(this._store), + + /** + * Adds a new autocommand. <b>cmd</b> will be executed when one of the + * specified <b>events</b> occurs and the URL of the applicable buffer + * matches <b>regex</b>. + * + * @param {Array} events The array of event names for which this + * autocommand should be executed. + * @param {string} regex The URL pattern to match against the buffer URL. + * @param {string} cmd The Ex command to run. + */ + add: function (events, regex, cmd) { + if (typeof events == "string") { + events = events.split(","); + liberator.log("DEPRECATED: the events list arg to autocommands.add() should be an array of event names"); + } + events.forEach(function (event) { + this._store.push(new AutoCommand(event, RegExp(regex), cmd)); + }); + }, + + /** + * Returns all autocommands with a matching <b>event</b> and + * <b>regex</b>. + * + * @param {string} event The event name filter. + * @param {string} regex The URL pattern filter. + * @returns {AutoCommand[]} + */ + get: function (event, regex) { + return this._store.filter(function (autoCmd) matchAutoCmd(autoCmd, event, regex)); + }, + + /** + * Deletes all autocommands with a matching <b>event</b> and + * <b>regex</b>. + * + * @param {string} event The event name filter. + * @param {string} regex The URL pattern filter. + */ + remove: function (event, regex) { + this._store = this._store.filter(function (autoCmd) !matchAutoCmd(autoCmd, event, regex)); + }, + + /** + * Lists all autocommands with a matching <b>event</b> and + * <b>regex</b>. + * + * @param {string} event The event name filter. + * @param {string} regex The URL pattern filter. + */ + list: function (event, regex) { + let cmds = {}; + + // XXX + this._store.forEach(function (autoCmd) { + if (matchAutoCmd(autoCmd, event, regex)) { + cmds[autoCmd.event] = cmds[autoCmd.event] || []; + cmds[autoCmd.event].push(autoCmd); + } + }); + + let list = template.commandOutput( + <table> + <tr highlight="Title"> + <td colspan="2">----- Auto Commands -----</td> + </tr> + { + template.map(cmds, function ([event, items]) + <tr highlight="Title"> + <td colspan="2">{event}</td> + </tr> + + + template.map(items, function (item) + <tr> + <td> {item.pattern.source}</td> + <td>{item.command}</td> + </tr>)) + } + </table>); + + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + /** + * Triggers the execution of all autocommands registered for + * <b>event</b>. A map of <b>args</b> 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("all", event)) + return; + + let autoCmds = this._store.filter(function (autoCmd) autoCmd.event == event); + + liberator.echomsg("Executing " + event + " Auto commands for \"*\"", 8); + + let lastPattern = null; + let url = args.url || ""; + + for (let [, autoCmd] in Iterator(autoCmds)) { + if (autoCmd.pattern.test(url)) { + if (!lastPattern || lastPattern.source != autoCmd.pattern.source) + liberator.echomsg("Executing " + event + " Auto commands for \"" + autoCmd.pattern.source + "\"", 8); + + lastPattern = autoCmd.pattern; + liberator.echomsg("autocommand " + autoCmd.command, 9); + + if (typeof autoCmd.command == "function") { + try { + autoCmd.command.call(autoCmd, args); + } + catch (e) { + liberator.reportError(e); + liberator.echoerr(e); + } + } + else + liberator.execute(commands.replaceTokens(autoCmd.command, args), null, true); + } + } + } +}, { + matchAutoCmd: function (autoCmd, event, regex) { + return (!event || autoCmd.event == event) && (!regex || autoCmd.pattern.source == regex); + }, +}, { + commands: function () { + commands.add(["au[tocmd]"], + "Execute commands automatically on events", + function (args) { + let [event, regex, cmd] = args; + let events = []; + + try { + RegExp(regex); + } + catch (e) { + liberator.assert(false, "E475: Invalid argument: " + regex); + } + + if (event) { + // NOTE: event can only be a comma separated list for |:au {event} {pat} {cmd}| + let validEvents = config.autocommands.map(function (event) event[0]); + validEvents.push("*"); + + events = event.split(","); + liberator.assert(events.every(function (event) validEvents.indexOf(event) >= 0), + "E216: No such group or event: " + event); + } + + if (cmd) { // add new command, possibly removing all others with the same event/pattern + if (args.bang) + autocommands.remove(event, regex); + if (args["-javascript"]) + cmd = eval("(function (args) { with(args) {" + cmd + "} })"); + autocommands.add(events, regex, 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] != "*" || regex) + autocommands.remove(event, regex); // remove all + } + else + autocommands.list(event, regex); // list all + } + }, { + bang: true, + completer: function (context) completion.autocmdEvent(context), + literal: 2, + options: [[["-javascript", "-js"], commands.OPTION_NOARG]] + }); + + [ + { + 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 liberator.echomsg("No matching autocommands"); + + let [event, url] = args; + let defaultURL = url || buffer.URL; + let validEvents = config.autocommands.map(function (e) e[0]); + + // TODO: add command validators + liberator.assert(event != "*", + "E217: Can't execute autocommands for ALL events"); + liberator.assert(validEvents.indexOf(event) >= 0, + "E216: No such group or event: " + args); + liberator.assert(autocommands.get(event).some(function (c) c.pattern.test(defaultURL)), + "No matching autocommands"); + + if (this.name == "doautoall" && liberator.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.URL }); + } + + 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) + }); + }); + }, + completion: function () { + completion.setFunctionCompleter(autocommands.get, [function () config.autocommands]); + + completion.autocmdEvent = function autocmdEvent(context) { + context.completions = config.autocommands; + }; + + completion.macro = function macro(context) { + context.title = ["Macro", "Keys"]; + context.completions = [item for (item in events.getMacros())]; + }; + }, + options: function () { + options.add(["eventignore", "ei"], + "List of autocommand event names which should be ignored", + "stringlist", "", + { + completer: function () config.autocommands.concat([["all", "All events"]]), + validator: Option.validateCompleter + }); + + options.add(["focuscontent", "fc"], + "Try to stay in normal mode after loading a web page", + "boolean", false); + }, +}); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/base.js b/common/content/base.js new file mode 100644 index 00000000..119810c6 --- /dev/null +++ b/common/content/base.js @@ -0,0 +1,293 @@ +function array(obj) { + if (isgenerator(obj)) + obj = [k for (k in obj)]; + else if (obj.length) + obj = Array.slice(obj); + return util.Array(obj); +} + +function keys(obj) { + if ('__iterator__' in obj) { + var iter = obj.__iterator__; + yield '__iterator__'; + // This is dangerous, but necessary. + delete obj.__iterator__; + } + for (var k in obj) + if (obj.hasOwnProperty(k)) + yield k; + if (iter !== undefined) + obj.__iterator__ = iter; +} +function values(obj) { + for (var k in obj) + if (obj.hasOwnProperty(k)) + yield obj[k]; +} +function foreach(iter, fn, self) { + for (let val in iter) + fn.call(self, val); +} + +function dict(ary) { + var obj = {}; + for (var i=0; i < ary.length; i++) { + var val = ary[i]; + obj[val[0]] = val[1]; + } + return obj; +} + +function set(ary) { + var obj = {} + if (ary) + for (var i=0; i < ary.length; i++) + obj[ary[i]] = true; + return obj; +} +set.add = function(set, key) { set[key] = true } +set.remove = function(set, key) { delete set[key] } + +function iter(obj) { + if (obj instanceof Ci.nsISimpleEnumerator) + return (function () { + while (obj.hasMoreElements()) + yield obj.getNext(); + })() + if (isinstance(obj, [Ci.nsIStringEnumerator, Ci.nsIUTF8StringEnumerator])) + return (function () { + while (obj.hasMore()) + yield obj.getNext(); + })(); + if (isinstance(obj, Ci.nsIDOMNodeIterator)) + return (function () { + try { + while (true) + yield obj.nextNode() + } + catch (e) {} + })(); + if (isinstance(obj, [HTMLCollection, NodeList])) + return util.Array.iteritems(obj); + if (obj instanceof NamedNodeMap) + return (function () { + for (let i=0; i < obj.length; i++) + yield [obj.name, obj] + })(); + return Iterator(obj); +} + +function issubclass(targ, src) { + return src === targ || + targ && typeof targ === "function" && targ.prototype instanceof src; +} + +function isinstance(targ, src) { + const types = { + boolean: Boolean, + string: String, + function: Function, + number: Number, + } + src = Array.concat(src); + for (var i=0; i < src.length; i++) { + if (targ instanceof src[i]) + return true; + var type = types[typeof targ]; + if (type && issubclass(src[i], type)) + return true; + } + return false; +} + +function isobject(obj) { + return typeof obj === "object" && obj != null; +} + +function isarray(obj) { + return Object.prototype.toString(obj) == "[object Array]"; +} + +function isgenerator(val) { + return Object.prototype.toString(obj) == "[object Generator]"; +} + +function isstring(val) { + return typeof val === "string" || val instanceof String; +} + +function callable(val) { + return typeof val === "function"; +} + +function call(fn) { + fn.apply(arguments[1], Array.slice(arguments, 2)); + return fn; +} + +function curry(fn, length, 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)); + + let first = (arguments.length < 3); + if (acc == null) + acc = []; + + return function() { + let args = acc.concat(Array.slice(arguments)); + + /* The curried result should preserve 'this' */ + if (arguments.length == 0) + return close(this, arguments.callee); + + if (args.length >= length) + return fn.apply(this, args); + + if (first) + fn = close(this, fn); + return curry(fn, length, args); + } +} + +function update(targ) { + for (let i=1; i < arguments.length; i++) { + let src = arguments[i]; + foreach(keys(src || {}), function(k) { + var get = src.__lookupGetter__(k), + set = src.__lookupSetter__(k); + if (!get && !set) { + var v = src[k]; + targ[k] = v; + if (targ.__proto__ && callable(v)) { + v.superapply = function(self, args) { + return targ.__proto__[k].apply(self, args); + } + v.supercall = function(self) { + return v.superapply(self, Array.slice(arguments, 1)); + } + } + } + if (get) + targ.__defineGetter__(k, get); + if (set) + targ.__defineSetter__(k, set); + }); + } + return targ; +} + +function extend(subc, superc, overrides) { + subc.prototype = { __proto__: superc.prototype }; + update(subc.prototype, overrides); + + subc.superclass = superc.prototype; + subc.prototype.constructor = subc; + subc.prototype.__class__ = subc; + + if (superc.prototype.constructor === Object.prototype.constructor) + superc.prototype.constructor = superc; +} + +function Class() { + function constructor() { + let self = { + __proto__: Constructor.prototype, + constructor: Constructor, + get closure() { + delete this.closure; + const self = this; + return this.closure = dict([k for (k in this) if (!self.__lookupGetter__(k) && callable(self[k]))].map( + function (k) [k, function () self[k].apply(self, arguments)])); + } + }; + var res = self.init.apply(self, arguments); + return res !== undefined ? res : self + } + + 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("(function " + (name || superclass.name) + + String.substr(constructor, 20) + ")"); + + if (!('init' in superclass.prototype)) { + var superc = superclass; + superclass = function Shim() {} + extend(superclass, superc, { + init: superc, + }); + } + + extend(Constructor, superclass, args[0]); + update(Constructor, args[1]); + args = args.slice(2); + Array.forEach(args, function(obj) { + if (callable(obj)) + obj = obj.prototype; + update(Constructor.prototype, obj); + }); + return Constructor; +} +Class.toString = function () "[class " + this.constructor.name + "]", +Class.prototype = { + init: function() {}, + toString: function () "[instance " + this.constructor.name + "]", +}; + +const Struct = Class("Struct", { + init: function () { + let args = Array.slice(arguments); + this.__defineGetter__("length", function () args.length); + this.__defineGetter__("members", function () args.slice()); + for (let arg in Iterator(args)) { + let [i, name] = arg; + this.__defineGetter__(name, function () this[i]); + this.__defineSetter__(name, function (val) { this[i] = val; }); + } + function Struct() { + let self = this instanceof arguments.callee ? this : new arguments.callee(); + //for (let [k, v] in Iterator(Array.slice(arguments))) // That is makes using struct twice as slow as the following code: + for (let i = 0; i < arguments.length; i++) { + if (arguments[i] != undefined) + self[i] = arguments[i]; + } + return self; + } + Struct.prototype = this; + Struct.defaultValue = function (key, val) { + let i = args.indexOf(key); + Struct.prototype.__defineGetter__(i, function () (this[i] = val.call(this), this[i])); // Kludge for FF 3.0 + Struct.prototype.__defineSetter__(i, function (val) { + let value = val; + this.__defineGetter__(i, function () value); + this.__defineSetter__(i, function (val) { value = val }); + }); + }; + return this.constructor = Struct; + }, + + clone: function clone() { + return this.constructor.apply(null, this.slice()); + }, + // Iterator over our named members + __iterator__: function () { + let self = this; + return ([v, self[v]] for ([k, v] in Iterator(self.members))) + } +}); +// Add no-sideeffect array methods. Can't set new Array() as the prototype or +// get length() won't work. +for (let [, k] in Iterator(["concat", "every", "filter", "forEach", "indexOf", "join", "lastIndexOf", + "map", "reduce", "reduceRight", "reverse", "slice", "some", "sort"])) + Struct.prototype[k] = Array.prototype[k]; + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/bookmarks.js b/common/content/bookmarks.js index d2ccda20..14e6bc61 100644 --- a/common/content/bookmarks.js +++ b/common/content/bookmarks.js @@ -6,1263 +6,686 @@ const DEFAULT_FAVICON = "chrome://mozapps/skin/places/defaultFavicon.png"; -// Try to import older command line history, quick marks, etc. -liberator.registerObserver("load", function () { - let branch = "extensions." + config.name.toLowerCase(); - if (!options.getPref(branch + ".commandline_cmd_history")) - return; - - let store = storage["history-command"]; - let pref = options.getPref(branch + ".commandline_cmd_history"); - for (let [k, v] in Iterator(pref.split("\n"))) - store.push(v); - - store = storage["quickmarks"]; - pref = options.getPref(branch + ".quickmarks") - .split("\n"); - while (pref.length > 0) - store.set(pref.shift(), pref.shift()); - - options.resetPref(branch + ".commandline_cmd_history"); - options.resetPref(branch + ".commandline_search_history"); - options.resetPref(branch + ".quickmarks"); -}); - // also includes methods for dealing with keywords and search engines -function Bookmarks() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const historyService = PlacesUtils.history; - const bookmarksService = PlacesUtils.bookmarks; - const taggingService = PlacesUtils.tagging; - const faviconService = services.get("favicon"); - const livemarkService = services.get("livemark"); - - // XXX for strange Firefox bug :( - // Error: [Exception... "Component returned failure code: 0x8000ffff (NS_ERROR_UNEXPECTED) [nsIObserverService.addObserver]" - // nsresult: "0x8000ffff (NS_ERROR_UNEXPECTED)" - // location: "JS frame :: file://~firefox/components/nsTaggingService.js :: anonymous :: line 89" - // data: no] - // Source file: file://~firefox/components/nsTaggingService.js - taggingService.getTagsForURI(window.makeURI("http://mysterious.bug"), {}); - - const Bookmark = new Struct("url", "title", "icon", "keyword", "tags", "id"); - const Keyword = new Struct("keyword", "title", "icon", "url"); - Bookmark.defaultValue("icon", function () getFavicon(this.url)); - Bookmark.prototype.__defineGetter__("extra", function () [ - ["keyword", this.keyword, "Keyword"], - ["tags", this.tags.join(", "), "Tag"] - ].filter(function (item) item[1])); - - const storage = modules.storage; - function Cache(name, store) - { - const rootFolders = [bookmarksService.toolbarFolder, bookmarksService.bookmarksMenuFolder, bookmarksService.unfiledBookmarksFolder]; - const sleep = liberator.sleep; // Storage objects are global to all windows, 'liberator' isn't. - - let bookmarks = []; - let self = this; - - this.__defineGetter__("name", function () name); - this.__defineGetter__("store", function () store); - this.__defineGetter__("bookmarks", function () this.load()); - - this.__defineGetter__("keywords", - function () [new Keyword(k.keyword, k.title, k.icon, k.url) for ([, k] in Iterator(self.bookmarks)) if (k.keyword)]); - - this.__iterator__ = function () (val for ([, val] in Iterator(self.bookmarks))); - - function loadBookmark(node) - { - try - { - let uri = util.newURI(node.uri); - let keyword = bookmarksService.getKeywordForBookmark(node.itemId); - let tags = taggingService.getTagsForURI(uri, {}) || []; - let bmark = new Bookmark(node.uri, node.title, node.icon && node.icon.spec, keyword, tags, node.itemId); - - bookmarks.push(bmark); - return bmark; +const Bookmarks = Module("bookmarks", { + requires: ["autocommands", "liberator", "storage", "services"], + + init: function () { + const faviconService = services.get("favicon"); + const bookmarksService = services.get("bookmarks"); + const historyService = services.get("history"); + const tagging = PlacesUtils.tagging; + + this.getFavicon = getFavicon; + function getFavicon(uri) { + try { + return faviconService.getFaviconImageForPage(util.newURI(uri)).spec; } - catch (e) - { - liberator.dump("Failed to create bookmark for URI: " + node.uri); - liberator.reportError(e); - return null; + catch (e) { + return ""; } } - function readBookmark(id) - { - return { - itemId: id, - uri: bookmarksService.getBookmarkURI(id).spec, - title: bookmarksService.getItemTitle(id) - }; - } + // Fix for strange Firefox bug: + // Error: [Exception... "Component returned failure code: 0x8000ffff (NS_ERROR_UNEXPECTED) [nsIObserverService.addObserver]" + // nsresult: "0x8000ffff (NS_ERROR_UNEXPECTED)" + // location: "JS frame :: file://~firefox/components/nsTaggingService.js :: anonymous :: line 89" + // data: no] + // Source file: file://~firefox/components/nsTaggingService.js + tagging.getTagsForURI(window.makeURI("http://mysterious.bug"), {}); + + const Bookmark = new Struct("url", "title", "icon", "keyword", "tags", "id"); + const Keyword = new Struct("keyword", "title", "icon", "url"); + Bookmark.defaultValue("icon", function () getFavicon(this.url)); + Bookmark.prototype.__defineGetter__("extra", function () [ + ["keyword", this.keyword, "Keyword"], + ["tags", this.tags.join(", "), "Tag"] + ].filter(function (item) item[1])); + + const storage = modules.storage; + function Cache(name, store) { + const rootFolders = [bookmarksService.toolbarFolder, bookmarksService.bookmarksMenuFolder, bookmarksService.unfiledBookmarksFolder]; + const sleep = liberator.sleep; // Storage objects are global to all windows, 'liberator' isn't. + + let bookmarks = []; + let self = this; + + this.__defineGetter__("name", function () name); + this.__defineGetter__("store", function () store); + this.__defineGetter__("bookmarks", function () this.load()); + + this.__defineGetter__("keywords", + function () [new Keyword(k.keyword, k.title, k.icon, k.url) for ([, k] in Iterator(self.bookmarks)) if (k.keyword)]); + + this.__iterator__ = function () (val for ([, val] in Iterator(self.bookmarks))); + + function loadBookmark(node) { + try { + let uri = util.newURI(node.uri); + let keyword = bookmarksService.getKeywordForBookmark(node.itemId); + let tags = tagging.getTagsForURI(uri, {}) || []; + let bmark = new Bookmark(node.uri, node.title, node.icon && node.icon.spec, keyword, tags, node.itemId); + + bookmarks.push(bmark); + return bmark; + } + catch (e) { + liberator.dump("Failed to create bookmark for URI: " + node.uri); + liberator.reportError(e); + return null; + } + } - function deleteBookmark(id) - { - let length = bookmarks.length; - bookmarks = bookmarks.filter(function (item) item.id != id); - return bookmarks.length < length; - } + function readBookmark(id) { + return { + itemId: id, + uri: bookmarksService.getBookmarkURI(id).spec, + title: bookmarksService.getItemTitle(id) + }; + } - this.findRoot = function findRoot(id) - { - do - { - var root = id; - id = bookmarksService.getFolderIdForItem(id); - } while (id != bookmarksService.placesRoot && id != root); - return root; - } + function deleteBookmark(id) { + let length = bookmarks.length; + bookmarks = bookmarks.filter(function (item) item.id != id); + return bookmarks.length < length; + } - this.isBookmark = function (id) rootFolders.indexOf(self.findRoot(id)) >= 0; + this.findRoot = function findRoot(id) { + do { + var root = id; + id = bookmarksService.getFolderIdForItem(id); + } while (id != bookmarksService.placesRoot && id != root); + return root; + } - this.isRegularBookmark = function findRoot(id) - { - do - { - var root = id; - if (livemarkService && livemarkService.isLivemark(id)) - return false; - id = bookmarksService.getFolderIdForItem(id); - } while (id != bookmarksService.placesRoot && id != root); - return rootFolders.indexOf(root) >= 0; - } + this.isBookmark = function (id) rootFolders.indexOf(self.findRoot(id)) >= 0; - // since we don't use a threaded bookmark loading (by set preload) - // anymore, is this loading synchronization still needed? --mst - let loading = false; - this.load = function load() - { - if (loading) - { - while (loading) - sleep(10); - return bookmarks; + this.isRegularBookmark = function findRoot(id) { + do { + var root = id; + if (services.get("livemark") && services.get("livemark").isLivemark(id)) + return false; + id = bookmarksService.getFolderIdForItem(id); + } while (id != bookmarksService.placesRoot && id != root); + return rootFolders.indexOf(root) >= 0; } - // update our bookmark cache - bookmarks = []; - loading = true; + // since we don't use a threaded bookmark loading (by set preload) + // anymore, is this loading synchronization still needed? --mst + let loading = false; + this.load = function load() { + if (loading) { + while (loading) + sleep(10); + return bookmarks; + } - let folders = rootFolders.slice(); - let query = historyService.getNewQuery(); - let options = historyService.getNewQueryOptions(); - while (folders.length > 0) - { - query.setFolders(folders, 1); - folders.shift(); - let result = historyService.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 - loadBookmark(node); + // update our bookmark cache + bookmarks = []; + loading = true; + + let folders = rootFolders.slice(); + let query = historyService.getNewQuery(); + let options = historyService.getNewQueryOptions(); + while (folders.length > 0) { + query.setFolders(folders, 1); + folders.shift(); + let result = historyService.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 + loadBookmark(node); + } + + // close a container after using it! + folder.containerOpen = false; } + this.__defineGetter__("bookmarks", function () bookmarks); + loading = false; + return bookmarks; + }; - // close a container after using it! - folder.containerOpen = false; - } - this.__defineGetter__("bookmarks", function () bookmarks); - loading = false; - return bookmarks; + var observer = { + onBeginUpdateBatch: function onBeginUpdateBatch() {}, + onEndUpdateBatch: function onEndUpdateBatch() {}, + onItemVisited: function onItemVisited() {}, + onItemMoved: function onItemMoved() {}, + onItemAdded: function onItemAdded(itemId, folder, index) { + // liberator.dump("onItemAdded(" + itemId + ", " + folder + ", " + index + ")\n"); + if (bookmarksService.getItemType(itemId) == bookmarksService.TYPE_BOOKMARK) { + if (self.isBookmark(itemId)) { + let bmark = loadBookmark(readBookmark(itemId)); + storage.fireEvent(name, "add", bmark); + } + } + }, + onItemRemoved: function onItemRemoved(itemId, folder, index) { + // liberator.dump("onItemRemoved(" + itemId + ", " + folder + ", " + index + ")\n"); + if (deleteBookmark(itemId)) + storage.fireEvent(name, "remove", itemId); + }, + onItemChanged: function onItemChanged(itemId, property, isAnnotation, value) { + if (isAnnotation) + return; + // liberator.dump("onItemChanged(" + itemId + ", " + property + ", " + value + ")\n"); + let bookmark = bookmarks.filter(function (item) item.id == itemId)[0]; + if (bookmark) { + if (property == "tags") + value = tagging.getTagsForURI(util.newURI(bookmark.url), {}); + if (property in bookmark) + bookmark[property] = value; + storage.fireEvent(name, "change", itemId); + } + }, + QueryInterface: function QueryInterface(iid) { + if (iid.equals(Ci.nsINavBookmarkObserver) || iid.equals(Ci.nsISupports)) + return this; + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + bookmarksService.addObserver(observer, false); + } + + let bookmarkObserver = function (key, event, arg) { + if (event == "add") + autocommands.trigger("BookmarkAdd", arg); + statusline.updateUrl(); }; - var observer = { - onBeginUpdateBatch: function onBeginUpdateBatch() {}, - onEndUpdateBatch: function onEndUpdateBatch() {}, - onItemVisited: function onItemVisited() {}, - onItemMoved: function onItemMoved() {}, - onItemAdded: function onItemAdded(itemId, folder, index) - { - // liberator.dump("onItemAdded(" + itemId + ", " + folder + ", " + index + ")\n"); - if (bookmarksService.getItemType(itemId) == bookmarksService.TYPE_BOOKMARK) - { - if (self.isBookmark(itemId)) - { - let bmark = loadBookmark(readBookmark(itemId)); - storage.fireEvent(name, "add", bmark); + this._cache = storage.newObject("bookmark-cache", Cache, { store: false }); + storage.addObserver("bookmark-cache", bookmarkObserver, 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); + }, + + // if starOnly = true it is saved in the unfiledBookmarksFolder, otherwise in the bookmarksMenuFolder + add: function add(starOnly, title, url, keyword, tags, force) { + try { + let uri = util.createURI(url); + if (!force) { + for (let bmark in this._cache) { + if (bmark[0] == uri.spec) { + var id = bmark[5]; + if (title) + services.get("bookmarks").setItemTitle(id, title); + break; } } - }, - onItemRemoved: function onItemRemoved(itemId, folder, index) - { - // liberator.dump("onItemRemoved(" + itemId + ", " + folder + ", " + index + ")\n"); - if (deleteBookmark(itemId)) - storage.fireEvent(name, "remove", itemId); - }, - onItemChanged: function onItemChanged(itemId, property, isAnnotation, value) - { - if (isAnnotation) - return; - // liberator.dump("onItemChanged(" + itemId + ", " + property + ", " + value + ")\n"); - let bookmark = bookmarks.filter(function (item) item.id == itemId)[0]; - if (bookmark) - { - if (property == "tags") - value = taggingService.getTagsForURI(util.newURI(bookmark.url), {}); - if (property in bookmark) - bookmark[property] = value; - storage.fireEvent(name, "change", itemId); - } - }, - QueryInterface: function QueryInterface(iid) - { - if (iid.equals(Ci.nsINavBookmarkObserver) || iid.equals(Ci.nsISupports)) - return this; - throw Cr.NS_ERROR_NO_INTERFACE; } - }; - - bookmarksService.addObserver(observer, false); - } + if (id == undefined) + id = services.get("bookmarks").insertBookmark( + services.get("bookmarks")[starOnly ? "unfiledBookmarksFolder" : "bookmarksMenuFolder"], + uri, -1, title || url); + if (!id) + return false; - function getFavicon(uri) - { - try - { - return faviconService.getFaviconImageForPage(util.newURI(uri)).spec; + if (keyword) + services.get("bookmarks").setKeywordForBookmark(id, keyword); + if (tags) { + PlacesUtils.tagging.untagURI(uri, null); + PlacesUtils.tagging.tagURI(uri, tags); + } } - catch (e) - { - return ""; + catch (e) { + liberator.log(e, 0); + return false; } - } - - let bookmarkObserver = function (key, event, arg) - { - if (event == "add") - autocommands.trigger("BookmarkAdd", arg); - statusline.updateUrl(); - }; - - var cache = storage.newObject("bookmark-cache", Cache, { store: false }); - storage.addObserver("bookmark-cache", bookmarkObserver, window); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["defsearch", "ds"], - "Set the default search engine", - "string", "google", - { - completer: function completer(context) - { - completion.search(context, true); - context.completions = [["", "Don't perform searches by default"]].concat(context.completions); - }, - validator: Option.validateCompleter - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - var myModes = config.browserModes; + return true; + }, - mappings.add(myModes, ["a"], - "Open a prompt to bookmark the current URL", - function () - { - let options = {}; - - let bmarks = bookmarks.get(buffer.URL).filter(function (bmark) bmark.url == buffer.URL); + toggle: function toggle(url) { + if (!url) + return; - if (bmarks.length == 1) - { - let bmark = bmarks[0]; + let count = this.remove(url); + if (count > 0) + commandline.echo("Removed bookmark: " + url, commandline.HL_NORMAL, commandline.FORCE_SINGLELINE); + else { + let title = buffer.title || url; + let extra = ""; + if (title != url) + extra = " (" + title + ")"; + this.add(true, title, url); + commandline.echo("Added bookmark: " + url + extra, commandline.HL_NORMAL, commandline.FORCE_SINGLELINE); + } + }, - options["-title"] = bmark.title; - if (bmark.keyword) - options["-keyword"] = bmark.keyword; - if (bmark.tags.length > 0) - options["-tags"] = bmark.tags.join(", "); - } - else - { - if (buffer.title != buffer.URL) - options["-title"] = buffer.title; + isBookmarked: function isBookmarked(url) { + try { + return services.get("bookmarks").getBookmarkIdsForURI(makeURI(url), {}) + .some(this._cache.isRegularBookmark); + } + catch (e) { + return false; + } + }, + + // returns number of deleted bookmarks + remove: function remove(url) { + try { + let uri = util.newURI(url); + let bmarks = services.get("bookmarks").getBookmarkIdsForURI(uri, {}) + .filter(this._cache.isRegularBookmark); + bmarks.forEach(services.get("bookmarks").removeItem); + return bmarks.length; + } + catch (e) { + liberator.log(e, 0); + return 0; + } + }, + + // TODO: add filtering + // also ensures that each search engine has a Liberator-friendly alias + getSearchEngines: function getSearchEngines() { + let searchEngines = []; + for (let [, engine] in Iterator(services.get("browserSearch").getVisibleEngines({}))) { + let alias = engine.alias; + if (!alias || !/^[a-z0-9_-]+$/.test(alias)) + alias = engine.name.replace(/^\W*([a-zA-Z_-]+).*/, "$1").toLowerCase(); + if (!alias) + alias = "search"; // for search engines which we can't find a suitable alias + + // make sure we can use search engines which would have the same alias (add numbers at the end) + let newAlias = alias; + for (let j = 1; j <= 10; j++) { // <=10 is intentional + if (!searchEngines.some(function (item) item[0] == newAlias)) + break; + + newAlias = alias + j; } + // only write when it changed, writes are really slow + if (engine.alias != newAlias) + engine.alias = newAlias; - commandline.open(":", - commands.commandToString({ command: "bmark", options: options, arguments: [buffer.URL], bang: bmarks.length == 1 }), - modes.EX); - }); - - mappings.add(myModes, ["A"], - "Toggle bookmarked state of current URL", - function () { bookmarks.toggle(buffer.URL); }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["ju[mps]"], - "Show jumplist", - function () - { - let sh = history.session; - let list = template.jumps(sh.index, sh); - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - }, - { argCount: "0" }); - - // TODO: Clean this up. - function tags(context, args) - { - let filter = context.filter; - let have = filter.split(","); - - args.completeFilter = have.pop(); - - let prefix = filter.substr(0, filter.length - args.completeFilter.length); - let tags = util.Array.uniq(util.Array.flatten([b.tags for ([k, b] in Iterator(cache.bookmarks))])); - - return [[prefix + tag, tag] for ([i, tag] in Iterator(tags)) if (have.indexOf(tag) < 0)]; - } + searchEngines.push([engine.alias, engine.description, engine.iconURI && engine.iconURI.spec]); + } - function title(context, args) - { - if (!args.bang) - return [[content.document.title, "Current Page Title"]]; - context.keys.text = "title"; - context.keys.description = "url"; - return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: args["-keyword"], title: context.filter }); - } + return searchEngines; + }, - function keyword(context, args) - { - if (!args.bang) - return []; - context.keys.text = "keyword"; - return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: context.filter, title: args["-title"] }); - } + getSuggestions: function getSuggestions(engineName, query, callback) { + const responseType = "application/x-suggestions+json"; - commands.add(["bma[rk]"], - "Add a bookmark", - function (args) - { - let url = args.length == 0 ? buffer.URL : args[0]; - let title = args["-title"] || (args.length == 0 ? buffer.title : null); - let keyword = args["-keyword"] || null; - let tags = args["-tags"] || []; + let engine = services.get("browserSearch").getEngineByAlias(engineName); + if (engine && engine.supportsResponseType(responseType)) + var queryURI = engine.getSubmission(query, responseType).uri.spec; + if (!queryURI) + return []; - if (bookmarks.add(false, title, url, keyword, tags, args.bang)) - { - let extra = (title == url) ? "" : " (" + title + ")"; - liberator.echomsg("Added bookmark: " + url + extra, 1, commandline.FORCE_SINGLELINE); + function process(resp) { + let results = []; + try { + results = services.get("json").decode(resp.responseText)[1]; + results = [[item, ""] for ([k, item] in Iterator(results)) if (typeof item == "string")]; } - else - liberator.echoerr("Exxx: Could not add bookmark `" + title + "'", commandline.FORCE_SINGLELINE); - }, - { - argCount: "?", - bang: true, - completer: function (context, args) - { - if (!args.bang) - { - context.completions = [[content.document.documentURI, "Current Location"]]; - return; - } - completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] }); - }, - options: [[["-title", "-t"], commands.OPTION_STRING, null, title], - [["-tags", "-T"], commands.OPTION_LIST, null, tags], - [["-keyword", "-k"], commands.OPTION_STRING, function (arg) /\w/.test(arg)]] - }); - - commands.add(["bmarks"], - "List or open multiple bookmarks", - function (args) - { - bookmarks.list(args.join(" "), args["-tags"] || [], args.bang, args["-max"]); - }, - { - bang: true, - completer: function completer(context, args) - { - context.quote = null; - context.filter = args.join(" "); - completion.bookmark(context, args["-tags"]); - }, - options: [[["-tags", "-T"], commands.OPTION_LIST, null, tags], - [["-max", "-m"], commands.OPTION_INT]] - }); - - 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)) - { - cache.bookmarks.forEach(function (bmark) { bookmarksService.removeItem(bmark.id); }); - liberator.echomsg("All bookmarks deleted", 1, commandline.FORCE_SINGLELINE); - } - }); + catch (e) {} + if (!callback) + return results; + callback(results); + } + + let resp = util.httpGet(queryURI, callback && process); + if (!callback) + return process(resp); + }, + + // TODO: add filtering + // format of returned array: + // [keyword, helptext, url] + getKeywords: function getKeywords() { + return this._cache.keywords; + }, + + // full search string including engine name as first word in @param text + // if @param useDefSearch is true, it uses the default search engine + // @returns the url for the search string + // if the search also requires a postData, [url, postData] is returned + getSearchURL: function getSearchURL(text, useDefsearch) { + let searchString = (useDefsearch ? options["defsearch"] + " " : "") + text; + + // we need to make sure our custom alias have been set, even if the user + // did not :open <tab> once before + this.getSearchEngines(); + + // ripped from Firefox + function getShortcutOrURI(url) { + var shortcutURL = null; + var keyword = url; + var param = ""; + var offset = url.indexOf(" "); + if (offset > 0) { + keyword = url.substr(0, offset); + param = url.substr(offset + 1); } - else - { - let url = args.string || buffer.URL; - let deletedCount = bookmarks.remove(url); - liberator.echomsg(deletedCount + " bookmark(s) with url " + url.quote() + " deleted", 1, commandline.FORCE_SINGLELINE); + var engine = services.get("browserSearch").getEngineByAlias(keyword); + if (engine) { + var submission = engine.getSubmission(param, null); + return [submission.uri.spec, submission.postData]; } - }, - { - argCount: "?", - bang: true, - completer: function completer(context) completion.bookmark(context), - literal: 0 - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - completion.bookmark = function bookmark(context, tags, extra) { - context.title = ["Bookmark", "Title"]; - context.format = bookmarks.format; - for (let val in Iterator(extra || [])) - { - let [k, v] = val; // Need block scope here for the closure - if (v) - context.filters.push(function (item) this._match(v, item[k])); + [shortcutURL, postData] = PlacesUtils.getURLAndPostDataForKeyword(keyword); + if (!shortcutURL) + return [url, null]; + + let data = window.unescape(postData || ""); + if (/%s/i.test(shortcutURL) || /%s/i.test(data)) { + var charset = ""; + var matches = shortcutURL.match(/^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/); + if (matches) + [, shortcutURL, charset] = matches; + else { + try { + charset = services.get("history").getCharsetForURI(window.makeURI(shortcutURL)); + } + catch (e) {} + } + var encodedParam; + if (charset) + encodedParam = escape(window.convertFromUnicode(charset, param)); + else + encodedParam = encodeURIComponent(param); + shortcutURL = shortcutURL.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) + return [shortcutURL, null]; + return [shortcutURL, postData]; } - // Need to make a copy because set completions() checks instanceof Array, - // and this may be an Array from another window. - context.completions = Array.slice(storage["bookmark-cache"].bookmarks); - completion.urls(context, tags); - }; - - completion.search = function search(context, noSuggest) { - let [, keyword, space, args] = context.filter.match(/^\s*(\S*)(\s*)(.*)$/); - let keywords = bookmarks.getKeywords(); - let engines = bookmarks.getSearchEngines(); - - context.title = ["Search Keywords"]; - context.completions = keywords.concat(engines); - context.keys = { text: 0, description: 1, icon: 2 }; - - if (!space || noSuggest) - return; - context.fork("suggest", keyword.length + space.length, this, "searchEngineSuggest", - keyword, true); - - let item = keywords.filter(function (k) k.keyword == keyword)[0]; - 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: window.makeURI(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) - { - item.url = decodeURIComponent(query.replace(/#.*/, "")); - return item; - } - }).filter(util.identity); - }; - }); - }; + let [url, postData] = getShortcutOrURI(searchString); + + if (url == searchString) + return null; + if (postData) + return [url, postData]; + return url; // can be null + }, + + // if openItems is true, open the matching bookmarks items in tabs rather than display + list: function list(filter, tags, openItems, maxItems) { + // 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); + let items = completion.runCompleter("bookmark", filter, maxItems, tags); + + if (items.length) + return liberator.open(items.map(function (i) i.url), liberator.NEW_TAB); + + if (filter.length > 0 && tags.length > 0) + liberator.echoerr("E283: No bookmarks matching tags: \"" + tags + "\" and string: \"" + filter + "\""); + else if (filter.length > 0) + liberator.echoerr("E283: No bookmarks matching string: \"" + filter + "\""); + else if (tags.length > 0) + liberator.echoerr("E283: No bookmarks matching tags: \"" + tags + "\""); + else + liberator.echoerr("No bookmarks set"); + } +}, { +}, { + commands: function () { + commands.add(["ju[mps]"], + "Show jumplist", + function () { + let sh = history.session; + let list = template.jumps(sh.index, sh); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + { argCount: "0" }); - completion.searchEngineSuggest = function searchEngineSuggest(context, engineAliases, kludge) { - if (!context.filter) - return; + // TODO: Clean this up. + function tags(context, args) { + let filter = context.filter; + let have = filter.split(","); - let engineList = (engineAliases || options["suggestengines"] || "google").split(","); + args.completeFilter = have.pop(); - let completions = []; - engineList.forEach(function (name) { - let engine = services.get("browserSearch").getEngineByAlias(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.compare = CompletionContext.Sort.unsorted; - ctxt.incomplete = true; - bookmarks.getSuggestions(name, ctxt.filter, function (compl) { - ctxt.incomplete = false; - ctxt.completions = compl; - }); - }); - }; - - completion.addUrlCompleter("S", "Suggest engines", completion.searchEngineSuggest); - completion.addUrlCompleter("b", "Bookmarks", completion.bookmark); - completion.addUrlCompleter("s", "Search engines and keyword URLs", completion.search); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - 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); - }, - - // if starOnly = true it is saved in the unfiledBookmarksFolder, otherwise in the bookmarksMenuFolder - add: function add(starOnly, title, url, keyword, tags, force) - { - try - { - let uri = util.createURI(url); - if (!force) - { - for (let bmark in cache) - { - if (bmark[0] == uri.spec) - { - var id = bmark[5]; - if (title) - bookmarksService.setItemTitle(id, title); - break; - } - } - } - if (id == undefined) - id = bookmarksService.insertBookmark( - bookmarksService[starOnly ? "unfiledBookmarksFolder" : "bookmarksMenuFolder"], - uri, -1, title || url); - if (!id) - return false; - - if (keyword) - bookmarksService.setKeywordForBookmark(id, keyword); - if (tags) - { - taggingService.untagURI(uri, null); - taggingService.tagURI(uri, tags); - } - } - catch (e) - { - liberator.log(e, 0); - return false; - } + let prefix = filter.substr(0, filter.length - args.completeFilter.length); + let tags = util.Array.uniq(util.Array.flatten([b.tags for ([k, b] in Iterator(this._cache.bookmarks))])); - return true; - }, + return [[prefix + tag, tag] for ([i, tag] in Iterator(tags)) if (have.indexOf(tag) < 0)]; + } - toggle: function toggle(url) - { - if (!url) - return; + function title(context, args) { + if (!args.bang) + return [[content.document.title, "Current Page Title"]]; + context.keys.text = "title"; + context.keys.description = "url"; + return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: args["-keyword"], title: context.filter }); + } - let count = this.remove(url); - if (count > 0) - commandline.echo("Removed bookmark: " + url, commandline.HL_NORMAL, commandline.FORCE_SINGLELINE); - else - { - let title = buffer.title || url; - let extra = ""; - if (title != url) - extra = " (" + title + ")"; - this.add(true, title, url); - commandline.echo("Added bookmark: " + url + extra, commandline.HL_NORMAL, commandline.FORCE_SINGLELINE); - } - }, + function keyword(context, args) { + if (!args.bang) + return []; + context.keys.text = "keyword"; + return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: context.filter, title: args["-title"] }); + } - isBookmarked: function isBookmarked(url) - { - try - { - return bookmarksService.getBookmarkIdsForURI(makeURI(url), {}) - .some(cache.isRegularBookmark); - } - catch (e) - { - return false; - } - }, + commands.add(["bma[rk]"], + "Add a bookmark", + function (args) { + let url = args.length == 0 ? buffer.URL : args[0]; + let title = args["-title"] || (args.length == 0 ? buffer.title : null); + let keyword = args["-keyword"] || null; + let tags = args["-tags"] || []; + + if (bookmarks.add(false, title, url, keyword, tags, args.bang)) { + let extra = (title == url) ? "" : " (" + title + ")"; + liberator.echomsg("Added bookmark: " + url + extra, 1, commandline.FORCE_SINGLELINE); + } + else + liberator.echoerr("Exxx: Could not add bookmark `" + title + "'", commandline.FORCE_SINGLELINE); + }, { + argCount: "?", + bang: true, + completer: function (context, args) { + if (!args.bang) { + context.completions = [[content.document.documentURI, "Current Location"]]; + return; + } + completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] }); + }, + options: [[["-title", "-t"], commands.OPTION_STRING, null, title], + [["-tags", "-T"], commands.OPTION_LIST, null, tags], + [["-keyword", "-k"], commands.OPTION_STRING, function (arg) /\w/.test(arg)]] + }); - // returns number of deleted bookmarks - remove: function remove(url) - { - try - { - let uri = util.newURI(url); - let bmarks = bookmarksService.getBookmarkIdsForURI(uri, {}) - .filter(cache.isRegularBookmark); - bmarks.forEach(bookmarksService.removeItem); - return bmarks.length; - } - catch (e) + commands.add(["bmarks"], + "List or open multiple bookmarks", + function (args) { + bookmarks.list(args.join(" "), args["-tags"] || [], args.bang, args["-max"]); + }, { - liberator.log(e, 0); - return 0; - } - }, - - getFavicon: function (url) getFavicon(url), + bang: true, + completer: function completer(context, args) { + context.quote = null; + context.filter = args.join(" "); + completion.bookmark(context, args["-tags"]); + }, + options: [[["-tags", "-T"], commands.OPTION_LIST, null, tags], + [["-max", "-m"], commands.OPTION_INT]] + }); - // TODO: add filtering - // also ensures that each search engine has a Liberator-friendly alias - getSearchEngines: function getSearchEngines() - { - let searchEngines = []; - for (let [, engine] in Iterator(services.get("browserSearch").getVisibleEngines({}))) - { - let alias = engine.alias; - if (!alias || !/^[a-z0-9_-]+$/.test(alias)) - alias = engine.name.replace(/^\W*([a-zA-Z_-]+).*/, "$1").toLowerCase(); - if (!alias) - alias = "search"; // for search engines which we can't find a suitable alias - - // make sure we can use search engines which would have the same alias (add numbers at the end) - let newAlias = alias; - for (let j = 1; j <= 10; j++) // <=10 is intentional - { - if (!searchEngines.some(function (item) item[0] == newAlias)) - break; + 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._cache.bookmarks.forEach(function (bmark) { services.get("bookmarks").removeItem(bmark.id); }); + liberator.echomsg("All bookmarks deleted", 1, commandline.FORCE_SINGLELINE); + } + }); + } + else { + let url = args.string || buffer.URL; + let deletedCount = bookmarks.remove(url); - newAlias = alias + j; + liberator.echomsg(deletedCount + " bookmark(s) with url " + url.quote() + " deleted", 1, commandline.FORCE_SINGLELINE); } - // only write when it changed, writes are really slow - if (engine.alias != newAlias) - engine.alias = newAlias; - searchEngines.push([engine.alias, engine.description, engine.iconURI && engine.iconURI.spec]); - } + }, + { + argCount: "?", + bang: true, + completer: function completer(context) completion.bookmark(context), + literal: 0 + }); + }, + mappings: function () { + var myModes = config.browserModes; - return searchEngines; - }, + mappings.add(myModes, ["a"], + "Open a prompt to bookmark the current URL", + function () { + let options = {}; - getSuggestions: function getSuggestions(engineName, query, callback) - { - const responseType = "application/x-suggestions+json"; + let bmarks = bookmarks.get(buffer.URL).filter(function (bmark) bmark.url == buffer.URL); - let engine = services.get("browserSearch").getEngineByAlias(engineName); - if (engine && engine.supportsResponseType(responseType)) - var queryURI = engine.getSubmission(query, responseType).uri.spec; - if (!queryURI) - return []; + if (bmarks.length == 1) { + let bmark = bmarks[0]; - function process(resp) - { - let results = []; - try - { - results = services.get("json").decode(resp.responseText)[1]; - results = [[item, ""] for ([k, item] in Iterator(results)) if (typeof item == "string")]; + options["-title"] = bmark.title; + if (bmark.keyword) + options["-keyword"] = bmark.keyword; + if (bmark.tags.length > 0) + options["-tags"] = bmark.tags.join(", "); } - catch (e) {} - if (!callback) - return results; - callback(results); - } - - let resp = util.httpGet(queryURI, callback && process); - if (!callback) - return process(resp); - }, - - // TODO: add filtering - // format of returned array: - // [keyword, helptext, url] - getKeywords: function getKeywords() - { - return cache.keywords; - }, - - // full search string including engine name as first word in @param text - // if @param useDefSearch is true, it uses the default search engine - // @returns the url for the search string - // if the search also requires a postData, [url, postData] is returned - getSearchURL: function getSearchURL(text, useDefsearch) - { - let searchString = (useDefsearch ? options["defsearch"] + " " : "") + text; - - // we need to make sure our custom alias have been set, even if the user - // did not :open <tab> once before - this.getSearchEngines(); - - // ripped from Firefox - function getShortcutOrURI(url) { - var shortcutURL = null; - var keyword = url; - var param = ""; - var offset = url.indexOf(" "); - if (offset > 0) - { - keyword = url.substr(0, offset); - param = url.substr(offset + 1); + else { + if (buffer.title != buffer.URL) + options["-title"] = buffer.title; } - var engine = services.get("browserSearch").getEngineByAlias(keyword); - if (engine) - { - var submission = engine.getSubmission(param, null); - return [submission.uri.spec, submission.postData]; - } + commandline.open(":", + commands.commandToString({ command: "bmark", options: options, arguments: [buffer.URL], bang: bmarks.length == 1 }), + modes.EX); + }); - [shortcutURL, postData] = PlacesUtils.getURLAndPostDataForKeyword(keyword); - if (!shortcutURL) - return [url, null]; - - let data = window.unescape(postData || ""); - if (/%s/i.test(shortcutURL) || /%s/i.test(data)) - { - var charset = ""; - var matches = shortcutURL.match(/^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/); - if (matches) - [, shortcutURL, charset] = matches; - else - { - try - { - charset = PlacesUtils.history.getCharsetForURI(window.makeURI(shortcutURL)); - } - catch (e) {} - } - var encodedParam; - if (charset) - encodedParam = escape(window.convertFromUnicode(charset, param)); - else - encodedParam = encodeURIComponent(param); - shortcutURL = shortcutURL.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) - return [shortcutURL, null]; - return [shortcutURL, postData]; + mappings.add(myModes, ["A"], + "Toggle bookmarked state of current URL", + function () { bookmarks.toggle(buffer.URL); }); + }, + options: function () { + options.add(["defsearch", "ds"], + "Set the default search engine", + "string", "google", + { + completer: function completer(context) { + completion.search(context, true); + context.completions = [["", "Don't perform searches by default"]].concat(context.completions); + }, + validator: Option.validateCompleter + }); + }, + completion: function () { + completion.bookmark = function bookmark(context, tags, extra) { + context.title = ["Bookmark", "Title"]; + context.format = bookmarks.format; + for (let val in Iterator(extra || [])) { + let [k, v] = val; // Need block scope here for the closure + if (v) + context.filters.push(function (item) this._match(v, item[k])); } + // Need to make a copy because set completions() checks instanceof Array, + // and this may be an Array from another window. + context.completions = Array.slice(storage["bookmark-cache"].bookmarks); + completion.urls(context, tags); + }; - let [url, postData] = getShortcutOrURI(searchString); - - if (url == searchString) - return null; - if (postData) - return [url, postData]; - return url; // can be null - }, - - // if openItems is true, open the matching bookmarks items in tabs rather than display - list: function list(filter, tags, openItems, maxItems) - { - // 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); - let items = completion.runCompleter("bookmark", filter, maxItems, tags); - - if (items.length) - return liberator.open(items.map(function (i) i.url), liberator.NEW_TAB); - - if (filter.length > 0 && tags.length > 0) - liberator.echoerr("E283: No bookmarks matching tags: \"" + tags + "\" and string: \"" + filter + "\""); - else if (filter.length > 0) - liberator.echoerr("E283: No bookmarks matching string: \"" + filter + "\""); - else if (tags.length > 0) - liberator.echoerr("E283: No bookmarks matching tags: \"" + tags + "\""); - else - liberator.echoerr("No bookmarks set"); - } - }; - //}}} -} //}}} - -function History() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const historyService = PlacesUtils.history; - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var myModes = config.browserModes; - - mappings.add(myModes, - ["<C-o>"], "Go to an older position in the jump list", - function (count) { history.stepTo(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, - ["<C-i>"], "Go to a newer position in the jump list", - function (count) { history.stepTo(Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, - ["H", "<A-Left>", "<M-Left>"], "Go back in the browser history", - function (count) { history.stepTo(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, - ["L", "<A-Right>", "<M-Right>"], "Go forward in the browser history", - function (count) { history.stepTo(Math.max(count, 1)); }, - { count: true }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["ba[ck]"], - "Go back in the browser history", - function (args) - { - let url = args.literalArg; - - 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); - liberator.echoerr("Exxx: URL not found in history"); - } - else - history.stepTo(-Math.max(args.count, 1)); - } - }, - { - argCount: "?", - bang: true, - completer: function completer(context) - { - let sh = history.session; + completion.search = function search(context, noSuggest) { + let [, keyword, space, args] = context.filter.match(/^\s*(\S*)(\s*)(.*)$/); + let keywords = bookmarks.getKeywords(); + let engines = bookmarks.getSearchEngines(); - 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 - }); - - 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); - liberator.echoerr("Exxx: URL not found in history"); - } - else - history.stepTo(Math.max(args.count, 1)); - } - }, - { - argCount: "?", - bang: true, - completer: function completer(context) - { - let sh = history.session; + context.title = ["Search Keywords"]; + context.completions = keywords.concat(engines); + context.keys = { text: 0, description: 1, icon: 2 }; - 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 - }); - - commands.add(["hist[ory]", "hs"], - "Show recently visited URLs", - function (args) { history.list(args.join(" "), args.bang, args["-max"] || 1000); }, - { - bang: true, - completer: function (context) { context.quote = null; completion.history(context); }, - // completer: function (filter) completion.history(filter) - options: [[["-max", "-m"], commands.OPTION_INT]] - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - completion.history = function _history(context, maxItems) { - context.format = history.format; - context.title = ["History"]; - context.compare = CompletionContext.Sort.unsorted; - //context.background = true; - if (context.maxItems == null) - context.maxItems = 100; - context.regenerate = true; - context.generate = function () history.get(context.filter, this.maxItems); - }; - - completion.addUrlCompleter("h", "History", completion.history); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - get format() bookmarks.format, - - get service() historyService, - - get: function get(filter, maxItems) - { - // no query parameters will get all history - let query = historyService.getNewQuery(); - let options = historyService.getNewQueryOptions(); - - if (typeof filter == "string") - filter = { searchTerms: filter }; - for (let [k, v] in Iterator(filter)) - query[k] = v; - options.sortingMode = options.SORT_BY_DATE_DESCENDING; - options.resultType = options.RESULTS_AS_URI; - if (maxItems > 0) - options.maxResults = maxItems; - - // execute the query - let root = historyService.executeQuery(query, options).root; - root.containerOpen = true; - let items = util.map(util.range(0, root.childCount), function (i) { - let node = root.getChild(i); - return { - url: node.uri, - title: node.title, - icon: node.icon ? node.icon.spec : DEFAULT_FAVICON - }; + if (!space || noSuggest) + return; + + context.fork("suggest", keyword.length + space.length, this, "searchEngineSuggest", + keyword, true); + + let item = keywords.filter(function (k) k.keyword == keyword)[0]; + 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: window.makeURI(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) { + item.url = decodeURIComponent(query.replace(/#.*/, "")); + return item; + } + }).filter(util.identity); + }; + }); + }; + + completion.searchEngineSuggest = function searchEngineSuggest(context, engineAliases, kludge) { + if (!context.filter) + return; + + let engineList = (engineAliases || options["suggestengines"] || "google").split(","); + + let completions = []; + engineList.forEach(function (name) { + let engine = services.get("browserSearch").getEngineByAlias(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.compare = CompletionContext.Sort.unsorted; + ctxt.incomplete = true; + bookmarks.getSuggestions(name, ctxt.filter, function (compl) { + ctxt.incomplete = false; + ctxt.completions = compl; + }); }); - 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 () util.Array.iteritems(this); - for (let i in util.range(0, sh.count)) - { - obj[i] = { index: i, __proto__: sh.getEntryAtIndex(i, false) }; - util.memoize(obj[i], "icon", - function (obj) services.get("favicon").getFaviconImageForPage(obj.URI).spec); - } - return obj; - }, - - // TODO: better names - 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) - liberator.beep(); - else - { - let index = util.Math.constrain(current + steps, start, end); - window.getWebNavigation().gotoIndex(index); - } - }, - - goToStart: function goToStart() - { - let index = window.getWebNavigation().sessionHistory.index; - - if (index > 0) - window.getWebNavigation().gotoIndex(0); - else - liberator.beep(); - - }, - - goToEnd: function goToEnd() - { - let sh = window.getWebNavigation().sessionHistory; - let max = sh.count - 1; - - if (sh.index < max) - window.getWebNavigation().gotoIndex(max); - else - liberator.beep(); - - }, - - // if openItems is true, open the matching history items in tabs rather than display - list: function list(filter, openItems, maxItems) - { - // 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); - let items = completion.runCompleter("history", filter, maxItems); - - if (items.length) - return liberator.open(items.map(function (i) i.url), liberator.NEW_TAB); - - if (filter.length > 0) - liberator.echoerr("E283: No history matching \"" + filter + "\""); - else - liberator.echoerr("No history set"); - } - }; - //}}} -} //}}} - -/** @scope modules */ - -/** - * @instance quickmarks - */ -function QuickMarks() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var qmarks = storage.newMap("quickmarks", { store: true }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var myModes = config.browserModes; - - mappings.add(myModes, - ["go"], "Jump to a QuickMark", - function (arg) { quickmarks.jumpTo(arg, liberator.CURRENT_TAB); }, - { arg: true }); - - mappings.add(myModes, - ["gn"], "Jump to a QuickMark in a new tab", - function (arg) - { - quickmarks.jumpTo(arg, - /\bquickmark\b/.test(options["activate"]) ? - liberator.NEW_TAB : liberator.NEW_BACKGROUND_TAB); - }, - { arg: true }); - - mappings.add(myModes, - ["M"], "Add new QuickMark for current URL", - function (arg) - { - if (/[^a-zA-Z0-9]/.test(arg)) - return void liberator.beep(); - - quickmarks.add(arg, buffer.URL); - }, - { arg: true }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - 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.string) - liberator.assert( args.bang || args.string, "E471: Argument required"); - liberator.assert(!args.bang || !args.string, "E474: Invalid argument"); - - if (args.bang) - quickmarks.removeAll(); - else - quickmarks.remove(args.string); - }, - { - bang: true, - completer: function (context) - { - context.title = ["QuickMark", "URL"]; - context.completions = qmarks; - } - }); - - commands.add(["qma[rk]"], - "Mark a URL with a letter for quick access", - function (args) - { - let matches = args.string.match(/^([a-zA-Z0-9])(?:\s+(.+))?$/); - if (!matches) - liberator.echoerr("E488: Trailing characters"); - else if (!matches[2]) - quickmarks.add(matches[1], buffer.URL); - else - quickmarks.add(matches[1], matches[2]); - }, - { argCount: "+" }); - - commands.add(["qmarks"], - "Show all QuickMarks", - function (args) - { - args = args.string; - - // ignore invalid qmark characters unless there are no valid qmark chars - liberator.assert(!args || /[a-zA-Z0-9]/.test(args), "E283: No QuickMarks matching \"" + args + "\""); - - let filter = args.replace(/[^a-zA-Z0-9]/g, ""); - quickmarks.list(filter); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - /** - * Adds a new quickmark with name <b>qmark</b> referencing - * the URL <b>location</b>. 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) - { - qmarks.set(qmark, location); - liberator.echomsg("Added Quick Mark '" + qmark + "': " + location, 1); - }, - - /** - * Deletes the specified quickmarks. The <b>filter</b> 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 = RegExp("[" + filter.replace(/\s+/g, "") + "]"); - - for (let [qmark, ] in qmarks) - { - if (pattern.test(qmark)) - qmarks.remove(qmark); - } - }, - - /** - * Removes all quickmarks. - */ - removeAll: function removeAll() - { - qmarks.clear(); - }, - - /** - * Opens the URL referenced by the specified <b>qmark</b>. - * - * @param {string} qmark The quickmark to open. - * @param {number} where A constant describing where to open the page. - * See {@link Liberator#open}. - */ - jumpTo: function jumpTo(qmark, where) - { - let url = qmarks.get(qmark); - - if (url) - liberator.open(url, where); - else - liberator.echoerr("E20: QuickMark not set"); - }, - - /** - * Lists all quickmarks matching <b>filter</b> in the message window. - * - * @param {string} filter The list of quickmarks to display. Eg. "abc" - * Ranges are not supported. - */ - // FIXME: filter should match that of quickmarks.remove or vice versa - list: function list(filter) - { - let marks = [k for ([k, v] in 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); - - liberator.assert(marks.length > 0, "No QuickMarks set"); - - if (filter.length > 0) - { - marks = marks.filter(function (qmark) filter.indexOf(qmark) >= 0); - liberator.assert(marks.length >= 0, "E283: No QuickMarks matching \"" + filter + "\""); - } + }; - let items = [[mark, qmarks.get(mark)] for ([k, mark] in Iterator(marks))]; - template.genericTable(items, { title: ["QuickMark", "URL"] }); - } - }; - //}}} -} //}}} + 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 index 842aa185..00d580db 100644 --- a/common/content/browser.js +++ b/common/content/browser.js @@ -9,15 +9,10 @@ /** * @instance browser */ -function Browser() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - +const Browser = Module("browser", { +}, { // TODO: support 'nrformats'? -> probably not worth it --mst - function incrementURL(count) - { + incrementURL: function (count) { let matches = buffer.URL.match(/(.*?)(\d+)(\D*)$/); if (!matches) return void liberator.beep(); @@ -25,253 +20,219 @@ function Browser() //{{{ let [, pre, number, post] = matches; let newNumber = parseInt(number, 10) + count; let newNumberStr = String(newNumber > 0 ? newNumber : 0); - if (number.match(/^0/)) // add 0009<C-a> should become 0010 - { + if (number.match(/^0/)) { // add 0009<C-a> should become 0010 while (newNumberStr.length < number.length) newNumberStr = "0" + newNumberStr; } liberator.open(pre + newNumberStr + post); } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["encoding", "enc"], - "Sets the current buffer's character encoding", - "string", "UTF-8", - { - scope: options.OPTION_SCOPE_LOCAL, - getter: function () getBrowser().docShell.QueryInterface(Ci.nsIDocCharset).charset, - setter: function (val) +}, { + options: function () { + options.add(["encoding", "enc"], + "Sets the current buffer's character encoding", + "string", "UTF-8", { - if (options["encoding"] == val) - return val; + scope: options.OPTION_SCOPE_LOCAL, + getter: function () getBrowser().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 { + getBrowser().docShell.QueryInterface(Ci.nsIDocCharset).charset = val; + PlacesUtils.history.setCharsetForURI(getWebNavigation().currentURI, val); + getWebNavigation().reload(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE); + } + catch (e) { liberator.reportError(e); } + }, + completer: function (context) completion.charset(context), + validator: Option.validateCompleter + }); - // Stolen from browser.jar/content/browser/browser.js, more or less. - try + // only available in FF 3.5 + services.add("privateBrowsing", "@mozilla.org/privatebrowsing;1", Ci.nsIPrivateBrowsingService); + if (services.get("privateBrowsing")) { + options.add(["private", "pornmode"], + "Set the 'private browsing' option", + "boolean", false, { - getBrowser().docShell.QueryInterface(Ci.nsIDocCharset).charset = val; - PlacesUtils.history.setCharsetForURI(getWebNavigation().currentURI, val); - getWebNavigation().reload(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE); - } - catch (e) { liberator.reportError(e); } - }, - completer: function (context) completion.charset(context), - validator: Option.validateCompleter - }); + setter: function (value) services.get("privateBrowsing").privateBrowsingEnabled = value, + getter: function () services.get("privateBrowsing").privateBrowsingEnabled + }); + let services = modules.services; // Storage objects are global to all windows, 'modules' isn't. + storage.newObject("private-mode", function () { + ({ + init: function () { + services.get("observer").addObserver(this, "private-browsing", false); + services.get("observer").addObserver(this, "quit-application", false); + this.private = services.get("privateBrowsing").privateBrowsingEnabled; + }, + observe: function (subject, topic, data) { + if (topic == "private-browsing") { + if (data == "enter") + storage.privateMode = true; + else if (data == "exit") + storage.privateMode = false; + storage.fireEvent("private-mode", "change", storage.privateMode); + } + else if (topic == "quit-application") { + services.get("observer").removeObserver(this, "quit-application"); + services.get("observer").removeObserver(this, "private-browsing"); + } + } + }).init(); + }, { store: false }); + storage.addObserver("private-mode", + function (key, event, value) { + autocommands.trigger("PrivateMode", { state: value }); + }, window); + } - // only available in FF 3.5 - services.add("privateBrowsing", "@mozilla.org/privatebrowsing;1", Ci.nsIPrivateBrowsingService); - if (services.get("privateBrowsing")) - { - options.add(["private", "pornmode"], - "Set the 'private browsing' option", - "boolean", false, - { - setter: function (value) services.get("privateBrowsing").privateBrowsingEnabled = value, - getter: function () services.get("privateBrowsing").privateBrowsingEnabled + options.add(["urlseparator"], + "Set the separator regex used to separate multiple URL args", + "string", ",\\s"); + }, + + mappings: function () { + mappings.add([modes.NORMAL], + ["y"], "Yank current location to the clipboard", + function () { util.copyToClipboard(buffer.URL, true); }); + + // opening websites + mappings.add([modes.NORMAL], + ["o"], "Open one or more URLs", + function () { commandline.open(":", "open ", modes.EX); }); + + mappings.add([modes.NORMAL], ["O"], + "Open one or more URLs, based on current location", + function () { commandline.open(":", "open " + buffer.URL, modes.EX); }); + + mappings.add([modes.NORMAL], ["t"], + "Open one or more URLs in a new tab", + function () { commandline.open(":", "tabopen ", modes.EX); }); + + mappings.add([modes.NORMAL], ["T"], + "Open one or more URLs in a new tab, based on current location", + function () { commandline.open(":", "tabopen " + buffer.URL, modes.EX); }); + + mappings.add([modes.NORMAL], ["w"], + "Open one or more URLs in a new window", + function () { commandline.open(":", "winopen ", modes.EX); }); + + mappings.add([modes.NORMAL], ["W"], + "Open one or more URLs in a new window, based on current location", + function () { commandline.open(":", "winopen " + buffer.URL, modes.EX); }); + + mappings.add([modes.NORMAL], + ["<C-a>"], "Increment last number in URL", + function (count) { Browser.incrementURL(Math.max(count, 1)); }, + { count: true }); + + mappings.add([modes.NORMAL], + ["<C-x>"], "Decrement last number in URL", + function (count) { Browser.incrementURL(-Math.max(count, 1)); }, + { count: true }); + + mappings.add([modes.NORMAL], ["~"], + "Open home directory", + function () { liberator.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(); + liberator.open(homepages, { from: "homepage", where: liberator.NEW_TAB }); }); - let services = modules.services; // Storage objects are global to all windows, 'modules' isn't. - storage.newObject("private-mode", function () { - ({ - init: function () - { - services.get("observer").addObserver(this, "private-browsing", false); - services.get("observer").addObserver(this, "quit-application", false); - this.private = services.get("privateBrowsing").privateBrowsingEnabled; - }, - observe: function (subject, topic, data) - { - if (topic == "private-browsing") - { - if (data == "enter") - storage.privateMode = true; - else if (data == "exit") - storage.privateMode = false; - storage.fireEvent("private-mode", "change", storage.privateMode); + + mappings.add([modes.NORMAL], ["gu"], + "Go to parent directory", + function (count) { + function isDirectory(url) { + if (/^file:\/|^\//.test(url)) { + let file = io.File(url); + return file.exists() && file.isDirectory(); } - else if (topic == "quit-application") - { - services.get("observer").removeObserver(this, "quit-application"); - services.get("observer").removeObserver(this, "private-browsing"); + else { + // for all other locations just check if the URL ends with / + return /\/$/.test(url); } } - }).init(); - }, { store: false }); - storage.addObserver("private-mode", - function (key, event, value) { - autocommands.trigger("PrivateMode", { state: value }); - }, window); - } - - options.add(["urlseparator"], - "Set the separator regex used to separate multiple URL args", - "string", ",\\s"); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - mappings.add([modes.NORMAL], - ["y"], "Yank current location to the clipboard", - function () { util.copyToClipboard(buffer.URL, true); }); - - // opening websites - mappings.add([modes.NORMAL], - ["o"], "Open one or more URLs", - function () { commandline.open(":", "open ", modes.EX); }); - - mappings.add([modes.NORMAL], ["O"], - "Open one or more URLs, based on current location", - function () { commandline.open(":", "open " + buffer.URL, modes.EX); }); - - mappings.add([modes.NORMAL], ["t"], - "Open one or more URLs in a new tab", - function () { commandline.open(":", "tabopen ", modes.EX); }); - - mappings.add([modes.NORMAL], ["T"], - "Open one or more URLs in a new tab, based on current location", - function () { commandline.open(":", "tabopen " + buffer.URL, modes.EX); }); - - mappings.add([modes.NORMAL], ["w"], - "Open one or more URLs in a new window", - function () { commandline.open(":", "winopen ", modes.EX); }); - - mappings.add([modes.NORMAL], ["W"], - "Open one or more URLs in a new window, based on current location", - function () { commandline.open(":", "winopen " + buffer.URL, modes.EX); }); - mappings.add([modes.NORMAL], - ["<C-a>"], "Increment last number in URL", - function (count) { incrementURL(Math.max(count, 1)); }, - { count: true }); + if (count < 1) + count = 1; - mappings.add([modes.NORMAL], - ["<C-x>"], "Decrement last number in URL", - function (count) { incrementURL(-Math.max(count, 1)); }, - { count: true }); - - mappings.add([modes.NORMAL], ["~"], - "Open home directory", - function () { liberator.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(); - liberator.open(homepages, { from: "homepage", where: liberator.NEW_TAB }); - }); - - mappings.add([modes.NORMAL], ["gu"], - "Go to parent directory", - function (count) - { - function isDirectory(url) - { - if (/^file:\/|^\//.test(url)) - { - let file = io.File(url); - return file.exists() && file.isDirectory(); + // XXX + let url = buffer.URL; + for (let i = 0; i < count; i++) { + if (isDirectory(url)) + url = url.replace(/^(.*?:)(.*?)([^\/]+\/*)$/, "$1$2/"); + else + url = url.replace(/^(.*?:)(.*?)(\/+[^\/]+)$/, "$1$2/"); } - else - { - // for all other locations just check if the URL ends with / - return /\/$/.test(url); - } - } + url = url.replace(/^(.*:\/+.*?)\/+$/, "$1/"); // get rid of more than 1 / at the end - if (count < 1) - count = 1; - - // XXX - let url = buffer.URL; - for (let i = 0; i < count; i++) - { - if (isDirectory(url)) - url = url.replace(/^(.*?:)(.*?)([^\/]+\/*)$/, "$1$2/"); + if (url == buffer.URL) + liberator.beep(); else - url = url.replace(/^(.*?:)(.*?)(\/+[^\/]+)$/, "$1$2/"); - } - url = url.replace(/^(.*:\/+.*?)\/+$/, "$1/"); // get rid of more than 1 / at the end - - if (url == buffer.URL) - liberator.beep(); - else - liberator.open(url); - }, - { count: true }); - - mappings.add([modes.NORMAL], ["gU"], - "Go to the root of the website", - function () - { - let uri = content.document.location; - if (/(about|mailto):/.test(uri.protocol)) // exclude these special protocols for now - return void liberator.beep(); - liberator.open(uri.protocol + "//" + (uri.host || "") + "/"); - }); - - mappings.add([modes.NORMAL], ["<C-l>"], - "Redraw the screen", - function () { commands.get("redraw").execute("", false); }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["downl[oads]", "dl"], - "Show progress of current downloads", - function () - { - liberator.open("chrome://mozapps/content/downloads/downloads.xul", - options.get("newtab").has("all", "downloads") - ? liberator.NEW_TAB : liberator.CURRENT_TAB); - }, - { argCount: "0" }); - - commands.add(["o[pen]", "e[dit]"], - "Open one or more URLs in the current tab", - function (args) - { - args = args.string; + liberator.open(url); + }, + { count: true }); + + mappings.add([modes.NORMAL], ["gU"], + "Go to the root of the website", + function () { + let uri = content.document.location; + if (/(about|mailto):/.test(uri.protocol)) // exclude these special protocols for now + return void liberator.beep(); + liberator.open(uri.protocol + "//" + (uri.host || "") + "/"); + }); - if (args) - liberator.open(args); - else - liberator.open("about:blank"); - }, - { - completer: function (context) completion.url(context), - literal: 0, - privateData: true, - }); + mappings.add([modes.NORMAL], ["<C-l>"], + "Redraw the screen", + function () { commands.get("redraw").execute("", false); }); + }, + + commands: function () { + commands.add(["downl[oads]", "dl"], + "Show progress of current downloads", + function () { + liberator.open("chrome://mozapps/content/downloads/downloads.xul", + options.get("newtab").has("all", "downloads") + ? liberator.NEW_TAB : liberator.CURRENT_TAB); + }, + { argCount: "0" }); - commands.add(["redr[aw]"], - "Redraw the screen", - function () - { - let wu = window.QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIDOMWindowUtils); - wu.redraw(); - modes.show(); - }, - { argCount: "0" }); + commands.add(["o[pen]", "e[dit]"], + "Open one or more URLs in the current tab", + function (args) { + args = args.string; - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + if (args) + liberator.open(args); + else + liberator.open("about:blank"); + }, { + completer: function (context) completion.url(context), + literal: 0, + privateData: true, + }); - return { - // TODO: extract browser-specific functionality from liberator - }; - //}}} -} //}}} + commands.add(["redr[aw]"], + "Redraw the screen", + function () { + let wu = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + wu.redraw(); + modes.show(); + }, + { argCount: "0" }); + } +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/buffer.js b/common/content/buffer.js index 51623b24..30dbdfcf 100644 --- a/common/content/buffer.js +++ b/common/content/buffer.js @@ -14,32 +14,779 @@ const Point = new Struct("x", "y"); * files. * @instance buffer */ -function Buffer() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - if ("ZoomManager" in window) - { - const ZOOM_MIN = Math.round(ZoomManager.MIN * 100); - const ZOOM_MAX = Math.round(ZoomManager.MAX * 100); +const Buffer = Module("buffer", { + init: function () { + + this.pageInfo = {}; + + this.addPageInfoSection("f", "Feeds", function (verbose) { + let doc = window.content.document; + + 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 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"> ({type})</span>]; + } + } + + if (!verbose && nFeed) + yield nFeed + " feed" + (nFeed > 1 ? "s" : ""); + }); + + this.addPageInfoSection("g", "General Info", function (verbose) { + let doc = window.content.document; + + // get file size + const ACCESS_READ = Ci.nsICache.ACCESS_READ; + let cacheKey = doc.location.toString().replace(/#.*$/, ""); + + for (let proto in util.Array.itervalues(["HTTP", "FTP"])) { + try { + var cacheEntryDescriptor = services.get("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.toString(), 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 = window.content.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])); + }); + }, + + /** + * @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(window.content); + + return stylesheets.filter( + function (stylesheet) /^(screen|all|)$/i.test(stylesheet.media.mediaText) && !/^\s*$/.test(stylesheet.title) + ); + }, + + /** + * @property {Object} A map of page info sections to their + * content generating functions. + */ + pageInfo: null, + + /** + * @property {number} A value indicating whether the buffer is loaded. + * Values may be: + * 0 - Loading. + * 1 - Fully loaded. + * 2 - Load failed. + */ + get loaded() { + if (window.content.document.pageIsFullyLoaded !== undefined) + return window.content.document.pageIsFullyLoaded; + return 0; // in doubt return "loading" + }, + set loaded(value) { + window.content.document.pageIsFullyLoaded = value; + }, + + /** + * @property {Node} The last focused input field in the buffer. Used + * by the "gi" key binding. + */ + get lastInputField() window.content.document.lastInputField || null, + set lastInputField(value) { window.content.document.lastInputField = value; }, + + /** + * @property {string} The current top-level document's URL. + */ + get URL() window.content.location.href, + + /** + * @property {string} The current top-level document's URL, sans any + * fragment identifier. + */ + get URI() { + let loc = window.content.location; + return loc.href.substr(0, loc.href.length - loc.hash.length); + }, + + /** + * @property {number} The buffer's height in pixels. + */ + get pageHeight() window.content.innerHeight, + + /** + * @property {number} The current browser's text zoom level, as a + * percentage with 100 as 'normal'. Only affects text size. + */ + get textZoom() getBrowser().markupDocumentViewer.textZoom * 100, + set textZoom(value) { Buffer.setZoom(value, false); }, + + /** + * @property {number} The current browser's text zoom level, as a + * percentage with 100 as 'normal'. Affects text size, as well as + * image size and block size. + */ + get fullZoom() getBrowser().markupDocumentViewer.fullZoom * 100, + set fullZoom(value) { Buffer.setZoom(value, true); }, + + /** + * @property {string} The current document's title. + */ + get title() window.content.document.title, + + /** + * @property {number} The buffer's horizontal scroll percentile. + */ + get scrollXPercent() { + let win = Buffer.findScrollableWindow(); + if (win.scrollMaxX > 0) + return Math.round(win.scrollX / win.scrollMaxX * 100); + else + return 0; + }, + + /** + * @property {number} The buffer's vertical scroll percentile. + */ + get scrollYPercent() { + let win = Buffer.findScrollableWindow(); + if (win.scrollMaxY > 0) + return Math.round(win.scrollY / win.scrollMaxY * 100); + else + return 0; + }, + + /** + * 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] = [func, title]; + }, + + /** + * Returns the currently selected word. If the selection is + * null, it tries to guess the word that the caret is + * positioned in. + * + * NOTE: might change the selection + * + * @returns {string} + */ + // FIXME: getSelection() doesn't always preserve line endings, see: + // https://www.mozdev.org/bugs/show_bug.cgi?id=19303 + getCurrentWord: function () { + let selection = window.content.getSelection(); + let range = selection.getRangeAt(0); + if (selection.isCollapsed) { + let selController = this.selectionController; + let caretmode = selController.getCaretEnabled(); + selController.setCaretEnabled(true); + // Only move backwards if the previous character is not a space. + if (range.startOffset > 0 && !/\s/.test(range.startContainer.textContent[range.startOffset - 1])) + selController.wordMove(false, false); + + selController.wordMove(true, true); + selController.setCaretEnabled(caretmode); + return String.match(selection, /\w*/)[0]; + } + if (util.computedStyle(range.startContainer).whiteSpace == "pre" + && util.computedStyle(range.endContainer).whiteSpace == "pre") + return String(range); + return String(selection); + }, + + /** + * 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 (elem) { + let doc = window.content.document; + if (elem instanceof HTMLFrameElement || elem instanceof HTMLIFrameElement) + return void elem.contentWindow.focus(); + else if (elem instanceof HTMLInputElement && elem.type == "file") { + Buffer.openUploadPrompt(elem); + buffer.lastInputField = elem; + return; + } + + elem.focus(); + + // for imagemap + if (elem instanceof HTMLAreaElement) { + try { + let [x, y] = elem.getAttribute("coords").split(",").map(parseFloat); + + elem.dispatchEvent(events.create(doc, "mouseover", { screenX: x, screenY: y })); + } + catch (e) {} + } + }, + + /** + * Tries to guess links the like of "next" and "prev". Though it has a + * singularly horrendous name, it turns out to be quite useful. + * + * @param {string} rel The relationship to look for. Looks for + * links with matching @rel or @rev attributes, and, + * failing that, looks for an option named rel + + * "pattern", and finds the last link matching that + * RegExp. + */ + followDocumentRelationship: function (rel) { + let regexes = options.get(rel + "pattern").values + .map(function (re) RegExp(re, "i")); + + 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]; + } + + // <link>s have higher priority than normal <a> hrefs + let elems = frame.document.getElementsByTagName("link"); + for (let elem in iter(elems)) { + liberator.open(elem.href); + return true; + } + + // no links? ok, look for hrefs + elems = frame.document.getElementsByTagName("a"); + for (let elem in iter(elems)) { + buffer.followLink(elem, liberator.CURRENT_TAB); + return true; + } + + let res = util.evaluateXPath(options.get("hinttags").defaultValue, frame.document); + for (let [, regex] in Iterator(regexes)) { + for (let i in util.range(res.snapshotLength, 0, -1)) { + let elem = res.snapshotItem(i); + if (regex.test(elem.textContent) || + regex.test(elem.title) || + Array.some(elem.childNodes, function (child) regex.test(child.alt))) + { + buffer.followLink(elem, liberator.CURRENT_TAB); + return true; + } + } + } + return false; + } + + let ret = followFrame(window.content); + if (!ret) + // only loop through frames if the main content didn't match + ret = Array.some(window.content.frames, followFrame); + + if (!ret) + liberator.beep(); + }, + + /** + * Fakes a click on a link. + * + * @param {Node} elem The element to click. + * @param {number} where Where to open the link. See + * {@link liberator.open}. + */ + followLink: function (elem, where) { + let doc = elem.ownerDocument; + let view = doc.defaultView; + let offsetX = 1; + let offsetY = 1; + + if (elem instanceof HTMLFrameElement || elem instanceof HTMLIFrameElement) { + elem.contentWindow.focus(); + return; + } + else 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 liberator.NEW_TAB: + case liberator.NEW_BACKGROUND_TAB: + ctrlKey = true; + shiftKey = (where != liberator.NEW_BACKGROUND_TAB); + break; + case liberator.NEW_WINDOW: + shiftKey = true; + break; + case liberator.CURRENT_TAB: + break; + default: + liberator.log("Invalid where argument for followLink()", 0); + } + + elem.focus(); + + options.withContext(function () { + options.setPref("browser.tabs.loadInBackground", true); + ["mousedown", "mouseup", "click"].forEach(function (event) { + elem.dispatchEvent(events.create(doc, event, { + screenX: offsetX, screenY: offsetY, + ctrlKey: ctrlKey, shiftKey: shiftKey, metaKey: ctrlKey + })); + }); + }); + }, + + /** + * @property {nsISelectionController} The current document's selection + * controller. + */ + get selectionController() getBrowser().docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController), + + /** + * Opens the appropriate context menu for <b>elem</b>. + * + * @param {Node} elem The context element. + */ + openContextMenu: function (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. + * @param {boolean} skipPrompt Whether to open the "Save Link As..." + * dialog. + */ + saveLink: function (elem, skipPrompt) { + let doc = elem.ownerDocument; + let url = window.makeURLAbsolute(elem.baseURI, elem.href); + let text = elem.textContent; + + try { + window.urlSecurityCheck(url, doc.nodePrincipal); + // we always want to save that link relative to the current working directory + options.setPref("browser.download.lastDir", io.getCurrentDirectory().path); + window.saveURL(url, text, null, true, skipPrompt, makeURI(url, doc.characterSet)); + } + catch (e) { + liberator.echoerr(e); + } + }, + + /** + * Scrolls to the bottom of the current buffer. + */ + scrollBottom: function () { + Buffer.scrollToPercent(null, 100); + }, + + /** + * Scrolls the buffer laterally <b>cols</b> columns. + * + * @param {number} cols The number of columns to scroll. A positive + * value scrolls right and a negative value left. + */ + scrollColumns: function (cols) { + Buffer.scrollHorizontal(null, "columns", cols); + }, + + /** + * Scrolls to the top of the current buffer. + */ + scrollEnd: function () { + Buffer.scrollToPercent(100, null); + }, + + /** + * Scrolls the buffer vertically <b>lines</b> rows. + * + * @param {number} lines The number of lines to scroll. A positive + * value scrolls down and a negative value up. + */ + scrollLines: function (lines) { + Buffer.scrollVertical(null, "lines", lines); + }, + + /** + * Scrolls the buffer vertically <b>pages</b> pages. + * + * @param {number} pages The number of pages to scroll. A positive + * value scrolls down and a negative value up. + */ + scrollPages: function (pages) { + Buffer.scrollVertical(null, "pages", pages); + }, + + /** + * 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 (direction, count) { + direction = direction ? 1 : -1; + count = count || 1; + let win = Buffer.findScrollableWindow(); + + Buffer.checkScrollYBounds(win, direction); + + if (options["scroll"] > 0) + this.scrollLines(options["scroll"] * direction); + else // scroll half a page down in pixels + win.scrollBy(0, win.innerHeight / 2 * direction); + }, + + _scrollByScrollSize: function _scrollByScrollSize(count, direction) { + if (count > 0) + options["scroll"] = count; + buffer.scrollByScrollSize(direction); + }, + + /** + * Scrolls the buffer to the specified screen percentiles. + * + * @param {number} x The horizontal page percentile. + * @param {number} y The vertical page percentile. + */ + scrollToPercent: function (x, y) { + Buffer.scrollToPercent(x, y); + }, + + /** + * Scrolls the buffer to the specified screen pixels. + * + * @param {number} x The horizontal pixel. + * @param {number} y The vertical pixel. + */ + scrollTo: function (x, y) { + marks.add("'", true); + content.scrollTo(x, y); + }, + + /** + * Scrolls the current buffer laterally to its leftmost. + */ + scrollStart: function () { + Buffer.scrollToPercent(0, null); + }, + + /** + * Scrolls the current buffer vertically to the top. + */ + scrollTop: function () { + Buffer.scrollToPercent(null, 0); + }, + + // 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. + * @param {boolean} forward The direction of motion. + */ + shiftFrameFocus: function (count, forward) { + if (!(window.content.document instanceof HTMLDocument)) + return; + + count = Math.max(count, 1); + let frames = []; + + // find all frames - depth-first search + (function (frame) { + if (frame.document.body instanceof HTMLBodyElement) + frames.push(frame); + Array.forEach(frame.frames, arguments.callee); + })(window.content); + + if (frames.length == 0) // currently top is always included + return; + + // remove all unfocusable frames + // TODO: find a better way to do this - walking the tree is too slow + let start = document.commandDispatcher.focusedWindow; + frames = frames.filter(function (frame) { + frame.focus(); + return document.commandDispatcher.focusedWindow == frame; + }); + start.focus(); + + // find the currently focused frame index + // TODO: If the window is a frameset then the first _frame_ should be + // focused. Since this is not the current FF behaviour, + // we initalize current to -1 so the first call takes us to the + // first frame. + let current = frames.indexOf(document.commandDispatcher.focusedWindow); + + // calculate the next frame to focus + let next = current; + if (forward) { + next = current + count; + + if (next > frames.length - 1) { + if (current == frames.length - 1) + liberator.beep(); + next = frames.length - 1; // still allow the frame indicator to be activated + } + } + else { + next = current - count; + + if (next < 0) { + if (current == 0) + liberator.beep(); + next = 0; // still allow the frame indicator to be activated + } + } + + // focus next frame and scroll into view + frames[next].focus(); + if (frames[next] != window.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.appendChild(indicator); + + setTimeout(function () { doc.body.removeChild(indicator); }, 500); + + // Doesn't unattach + //doc.body.setAttributeNS(NS.uri, "activeframe", "true"); + //setTimeout(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 (elem) { + liberator.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 (verbose, sections) { + // Ctrl-g single line output + if (!verbose) { + let file = content.document.location.pathname.split("/").pop() || "[No Name]"; + let title = content.document.title || "[No Title]"; + + let info = template.map("gf", function (opt) + template.map(this.pageInfo[opt][0](), util.identity, ", "), + ", "); + + if (bookmarks.isBookmarked(this.URL)) + info += ", bookmarked"; + + let pageInfoText = <>{file.quote()} [{info}] {title}</>; + liberator.echo(pageInfoText, commandline.FORCE_SINGLELINE); + return; + } + + let option = sections || options["pageinfo"]; + let list = template.map(option, function (option) { + let opt = this.pageInfo[option]; + if (opt) + return template.table(opt[1], opt[0](true)); + }, <br/>); + liberator.echo(list, commandline.FORCE_MULTILINE); + }, + + /** + * Opens a viewer to inspect the source of the currently selected + * range. + */ + viewSelectionSource: function () { + // copied (and tuned somebit) from browser.jar -> nsContextMenu.js + let focusedWindow = document.commandDispatcher.focusedWindow; + if (focusedWindow == window) + focusedWindow = content; + + let docCharset = null; + if (focusedWindow) + docCharset = "charset=" + focusedWindow.document.characterSet; + + let reference = null; + reference = focusedWindow.getSelection(); + + let docUrl = null; + window.openDialog("chrome://global/content/viewPartialSource.xul", + "_blank", "scrollbars,resizable,chrome,dialog=no", + docUrl, docCharset, reference, "selection"); + }, + + /** + * Opens a viewer to inspect the source of the current buffer or the + * specified <b>url</b>. 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 (url, useExternalEditor) { + url = url || buffer.URI; + + if (useExternalEditor) + editor.editFileExternally(url); + else { + const PREFIX = "view-source:"; + if (url.indexOf(PREFIX) == 0) + url = url.substr(PREFIX.length); + else + url = PREFIX + url; + liberator.open(url, { hide: true }); + } + }, + + /** + * 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 (steps, fullZoom) { + Buffer.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 (steps, fullZoom) { + Buffer.bumpZoomLevel(-steps, fullZoom); } +}, { + ZOOM_MIN: "ZoomManager" in window && Math.round(ZoomManager.MIN * 100), + ZOOM_MAX: "ZoomManager" in window && Math.round(ZoomManager.MAX * 100), - function setZoom(value, fullZoom) - { - liberator.assert(value >= ZOOM_MIN && value <= ZOOM_MAX, - "Zoom value out of range (" + ZOOM_MIN + " - " + ZOOM_MAX + "%)"); + setZoom: function setZoom(value, fullZoom) { + liberator.assert(value >= Buffer.ZOOM_MIN || value <= Buffer.ZOOM_MAX, + "Zoom value out of range (" + Buffer.ZOOM_MIN + " - " + Buffer.ZOOM_MAX + "%)"); ZoomManager.useFullZoom = fullZoom; ZoomManager.zoom = value / 100; if ("FullZoom" in window) FullZoom._applySettingToPref(); liberator.echomsg((fullZoom ? "Full" : "Text") + " zoom: " + value + "%"); - } + }, - function bumpZoomLevel(steps, fullZoom) - { + bumpZoomLevel: function bumpZoomLevel(steps, fullZoom) { let values = ZoomManager.zoomValues; let cur = values.indexOf(ZoomManager.snap(ZoomManager.zoom)); let i = util.Math.constrain(cur + steps, 0, values.length - 1); @@ -47,18 +794,16 @@ function Buffer() //{{{ if (i == cur && fullZoom == ZoomManager.useFullZoom) liberator.beep(); - setZoom(Math.round(values[i] * 100), fullZoom); - } + Buffer.setZoom(Math.round(values[i] * 100), fullZoom); + }, - function checkScrollYBounds(win, direction) - { + checkScrollYBounds: function checkScrollYBounds(win, direction) { // NOTE: it's possible to have scrollY > scrollMaxY - FF bug? if (direction > 0 && win.scrollY >= win.scrollMaxY || direction < 0 && win.scrollY == 0) liberator.beep(); - } + }, - function findScrollableWindow() - { + findScrollableWindow: function findScrollableWindow() { let win = window.document.commandDispatcher.focusedWindow; if (win && (win.scrollMaxX > 0 || win.scrollMaxY > 0)) return win; @@ -72,20 +817,17 @@ function Buffer() //{{{ return frame; return win; - } + }, - function findScrollable(dir, horizontal) - { + findScrollable: function findScrollable(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"; - function find(elem) - { - for (; elem && elem.parentNode instanceof Element; elem = elem.parentNode) - { + function find(elem) { + for (; elem && elem.parentNode instanceof Element; elem = elem.parentNode) { let style = util.computedStyle(elem); let borderSize = parseInt(style[border1]) + parseInt(style[border2]); let realSize = elem[size]; @@ -102,18 +844,16 @@ function Buffer() //{{{ if (content.getSelection().rangeCount) var elem = find(content.getSelection().getRangeAt(0).startContainer); - if (!(elem instanceof Element)) - { - let doc = findScrollableWindow().document; + if (!(elem instanceof Element)) { + let doc = Buffer.findScrollableWindow().document; elem = find(doc.body || doc.getElementsByTagName("body")[0] || doc.documentElement); } return elem; - } + }, - function scrollVertical(elem, increment, number) - { - elem = elem || findScrollable(number, false); + scrollVertical: function scrollVertical(elem, increment, number) { + elem = elem || Buffer.findScrollable(number, false); let fontSize = parseInt(util.computedStyle(elem).fontSize); let increment; if (increment == "lines") @@ -124,10 +864,9 @@ function Buffer() //{{{ throw Error() elem.scrollTop += number * increment; - } - function scrollHorizontal(elem, increment, number) - { - elem = elem || findScrollable(number, true); + }, + scrollHorizontal: function scrollHorizontal(elem, increment, number) { + elem = elem || Buffer.findScrollable(number, true); let fontSize = parseInt(util.computedStyle(elem).fontSize); let increment; if (increment == "columns") @@ -138,11 +877,10 @@ function Buffer() //{{{ throw Error() elem.scrollLeft += number * increment; - } + }, - function scrollElemToPercent(elem, horizontal, vertical) - { - elem = elem || findScrollable(); + scrollElemToPercent: function scrollElemToPercent(elem, horizontal, vertical) { + elem = elem || Buffer.findScrollable(); marks.add("'", true); if (horizontal != null) @@ -150,11 +888,10 @@ function Buffer() //{{{ if (vertical != null) elem.scrollTop = (elem.scrollHeight - elem.clientHeight) * (vertical / 100); - } + }, - function scrollToPercent(horizontal, vertical) - { - let win = findScrollableWindow(); + scrollToPercent: function scrollToPercent(horizontal, vertical) { + let win = Buffer.findScrollableWindow(); let h, v; if (horizontal == null) @@ -169,539 +906,191 @@ function Buffer() //{{{ marks.add("'", true); win.scrollTo(h, v); - } + }, - // Holds option: [function, title] to generate :pageinfo sections - var pageInfo = {}; - function addPageInfoSection(option, title, func) - { - pageInfo[option] = [func, title]; - } - - function openUploadPrompt(elem) - { + openUploadPrompt: function openUploadPrompt(elem) { commandline.input("Upload file: ", function (path) { let file = io.File(path); if (!file.exists()) return void liberator.beep(); elem.value = file.path; - }, - { + }, { completer: completion.file, default: elem.value }); - } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["nextpattern"], // \u00BB is » (>> in a single char) - "Patterns to use when guessing the 'next' page in a document sequence", - "stringlist", "\\bnext\\b,^>$,^(>>|\u00BB)$,^(>|\u00BB),(>|\u00BB)$,\\bmore\\b"); - - options.add(["previouspattern"], // \u00AB is « (<< in a single char) - "Patterns to use when guessing the 'previous' page in a document sequence", - "stringlist", "\\bprev|previous\\b,^<$,^(<<|\u00AB)$,^(<|\u00AB),(<|\u00AB)$"); - - options.add(["pageinfo", "pa"], - "Desired info in the :pageinfo output", - "charlist", "gfm", - { - completer: function (context) [[k, v[1]] for ([k, v] in Iterator(pageInfo))], - validator: Option.validateCompleter - }); - - 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"], - "Show the destination of the link under the cursor in the status bar", - "number", 1, - { - completer: function (context) [ - ["0", "Don't show link destination"], - ["1", "Show the link in the status line"], - ["2", "Show the link in the command line"] - ], - validator: Option.validateCompleter - }); - - options.add(["usermode", "um"], - "Show current website with a minimal style sheet to make it easily accessible", - "boolean", false, - { - setter: function (value) getBrowser().markupDocumentViewer.authorStyleDisabled = value, - getter: function () getBrowser().markupDocumentViewer.authorStyleDisabled - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var myModes = config.browserModes; - - mappings.add(myModes, ["."], - "Repeat the last key event", - function (count) - { - if (mappings.repeat) - { - for (let i in util.interruptibleRange(0, Math.max(count, 1), 100)) - mappings.repeat(); - } - }, - { count: true }); - - mappings.add(myModes, ["i", "<Insert>"], - "Start caret mode", - function () - { - // setting this option notifies an observer which takes care of the - // mode setting - options.setPref("accessibility.browsewithcaret", true); - }); - - mappings.add(myModes, ["<C-c>"], - "Stop loading the current web page", - function () { tabs.stop(getBrowser().mCurrentTab); }); - - // scrolling - mappings.add(myModes, ["j", "<Down>", "<C-e>"], - "Scroll document down", - function (count) { buffer.scrollLines(Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, ["k", "<Up>", "<C-y>"], - "Scroll document up", - function (count) { buffer.scrollLines(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, liberator.has("mail") ? ["h"] : ["h", "<Left>"], - "Scroll document to the left", - function (count) { buffer.scrollColumns(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, liberator.has("mail") ? ["l"] : ["l", "<Right>"], - "Scroll document to the right", - function (count) { buffer.scrollColumns(Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, ["0", "^"], - "Scroll to the absolute left of the document", - function () { buffer.scrollStart(); }); - - mappings.add(myModes, ["$"], - "Scroll to the absolute right of the document", - function () { buffer.scrollEnd(); }); - - mappings.add(myModes, ["gg", "<Home>"], - "Go to the top of the document", - function (count) { buffer.scrollToPercent(buffer.scrollXPercent, Math.max(count, 0)); }, - { count: true }); - - mappings.add(myModes, ["G", "<End>"], - "Go to the end of the document", - function (count) { buffer.scrollToPercent(buffer.scrollXPercent, count >= 0 ? count : 100); }, - { count: true }); - - mappings.add(myModes, ["%"], - "Scroll to {count} percent of the document", - function (count) - { - if (count > 0 && count <= 100) - buffer.scrollToPercent(buffer.scrollXPercent, count); - else - liberator.beep(); - }, - { count: true }); - - function scrollByScrollSize(count, direction) - { - if (count > 0) - options["scroll"] = count; - buffer.scrollByScrollSize(direction); - } + }, +}, { + commands: function () { + commands.add(["frameo[nly]"], + "Show only the current frame's page", + function (args) { + liberator.open(tabs.localStore.focusedFrame.document.documentURI); + }, + { 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 + liberator.assert(!arg || arg[0] == ">" && !liberator.has("Win32"), + "E488: Trailing characters"); + + options.withContext(function () { + if (arg) { + options.setPref("print.print_to_file", "true"); + options.setPref("print.print_to_filename", io.File(arg.substr(1)).path); + liberator.echomsg("Printing to file: " + arg.substr(1)); + } + else + liberator.echomsg("Sending to printer..."); - mappings.add(myModes, ["<C-d>"], - "Scroll window downwards in the buffer", - function (count) { scrollByScrollSize(count, true); }, - { count: true }); - - mappings.add(myModes, ["<C-u>"], - "Scroll window upwards in the buffer", - function (count) { scrollByScrollSize(count, false); }, - { count: true }); - - mappings.add(myModes, ["<C-b>", "<PageUp>", "<S-Space>"], - "Scroll up a full page", - function (count) { buffer.scrollPages(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, ["<C-f>", "<PageDown>", "<Space>"], - "Scroll down a full page", - function (count) { buffer.scrollPages(Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, ["]f"], - "Focus next frame", - function (count) { buffer.shiftFrameFocus(Math.max(count, 1), true); }, - { count: true }); - - mappings.add(myModes, ["[f"], - "Focus previous frame", - function (count) { buffer.shiftFrameFocus(Math.max(count, 1), false); }, - { count: true }); - - mappings.add(myModes, ["]]"], - "Follow the link labeled 'next' or '>' if it exists", - function (count) { buffer.followDocumentRelationship("next"); }, - { count: true }); - - mappings.add(myModes, ["[["], - "Follow the link labeled 'prev', 'previous' or '<' if it exists", - function (count) { buffer.followDocumentRelationship("previous"); }, - { count: true }); - - mappings.add(myModes, ["gf"], - "View source", - function () { buffer.viewSource(null, false); }); - - mappings.add(myModes, ["gF"], - "View source with an external editor", - function () { buffer.viewSource(null, true); }); - - mappings.add(myModes, ["|"], - "Toggle between rendered and source view", - function () { buffer.viewSource(null, false); }); - - mappings.add(myModes, ["gi"], - "Focus last used input field", - function (count) - { - if (count < 1 && buffer.lastInputField) - buffer.focusElement(buffer.lastInputField); - else - { - let xpath = ["input[not(@type) or @type='text' or @type='password' or @type='file']", - "textarea[not(@disabled) and not(@readonly)]"]; + options.setPref("print.always_print_silent", args.bang); + options.setPref("print.show_print_progress", !args.bang); - let elements = [m for (m in util.evaluateXPath(xpath))].filter(function (match) { - let computedStyle = util.computedStyle(match); - return computedStyle.visibility != "hidden" && computedStyle.display != "none"; + getBrowser().contentWindow.print(); }); - if (elements.length > 0) - buffer.focusElement(elements[util.Math.constrain(count, 1, elements.length) - 1]); + if (arg) + liberator.echomsg("Printed: " + arg.substr(1)); else - liberator.beep(); - } - }, - { count: true }); - - mappings.add(myModes, ["gP"], - "Open (put) a URL based on the current clipboard contents in a new buffer", - function () - { - liberator.open(util.readFromClipboard(), - liberator[options.get("activate").has("paste") ? "NEW_BACKGROUND_TAB" : "NEW_TAB"]); - }); - - mappings.add(myModes, ["p", "<MiddleMouse>"], - "Open (put) a URL based on the current clipboard contents in the current buffer", - function () - { - let url = util.readFromClipboard(); - if (url) - liberator.open(url); - else - liberator.beep(); - }); - - mappings.add(myModes, ["P"], - "Open (put) a URL based on the current clipboard contents in a new buffer", - function () - { - let url = util.readFromClipboard(); - if (url) - liberator.open(url, { from: "activate", where: liberator.NEW_TAB }); - else - liberator.beep(); - }); - - // reloading - mappings.add(myModes, ["r"], - "Reload the current web page", - function () { tabs.reload(getBrowser().mCurrentTab, false); }); + liberator.echomsg("Print job sent."); + }, + { + argCount: "?", + literal: 0, + bang: true + }); - mappings.add(myModes, ["R"], - "Reload while skipping the cache", - function () { tabs.reload(getBrowser().mCurrentTab, true); }); + commands.add(["pa[geinfo]"], + "Show various page information", + function (args) { buffer.showPageInfo(true, args[0]); }, + { + argCount: "?", + completer: function (context) { + completion.optionValue(context, "pageinfo", "+", ""); + context.title = ["Page Info"]; + } + }); - // yanking - mappings.add(myModes, ["Y"], - "Copy selected text or current word", - function () - { - let sel = buffer.getCurrentWord(); + commands.add(["pagest[yle]", "pas"], + "Select the author style sheet to apply", + function (args) { + let arg = args.literalArg; - if (sel) - util.copyToClipboard(sel, true); - else - liberator.beep(); - }); + let titles = buffer.alternateStyleSheets.map(function (stylesheet) stylesheet.title); - // zooming - mappings.add(myModes, ["zi", "+"], - "Enlarge text zoom of current web page", - function (count) { buffer.zoomIn(Math.max(count, 1), false); }, - { count: true }); - - mappings.add(myModes, ["zm"], - "Enlarge text zoom of current web page by a larger amount", - function (count) { buffer.zoomIn(Math.max(count, 1) * 3, false); }, - { count: true }); - - mappings.add(myModes, ["zo", "-"], - "Reduce text zoom of current web page", - function (count) { buffer.zoomOut(Math.max(count, 1), false); }, - { count: true }); - - mappings.add(myModes, ["zr"], - "Reduce text zoom of current web page by a larger amount", - function (count) { buffer.zoomOut(Math.max(count, 1) * 3, false); }, - { count: true }); - - mappings.add(myModes, ["zz"], - "Set text zoom value of current web page", - function (count) { buffer.textZoom = count > 1 ? count : 100; }, - { count: true }); - - mappings.add(myModes, ["zI"], - "Enlarge full zoom of current web page", - function (count) { buffer.zoomIn(Math.max(count, 1), true); }, - { count: true }); - - mappings.add(myModes, ["zM"], - "Enlarge full zoom of current web page by a larger amount", - function (count) { buffer.zoomIn(Math.max(count, 1) * 3, true); }, - { count: true }); - - mappings.add(myModes, ["zO"], - "Reduce full zoom of current web page", - function (count) { buffer.zoomOut(Math.max(count, 1), true); }, - { count: true }); - - mappings.add(myModes, ["zR"], - "Reduce full zoom of current web page by a larger amount", - function (count) { buffer.zoomOut(Math.max(count, 1) * 3, true); }, - { count: true }); - - mappings.add(myModes, ["zZ"], - "Set full zoom value of current web page", - function (count) { buffer.fullZoom = count > 1 ? count : 100; }, - { count: true }); - - // page info - mappings.add(myModes, ["<C-g>"], - "Print the current file name", - function (count) { buffer.showPageInfo(false); }, - { count: true }); - - mappings.add(myModes, ["g<C-g>"], - "Print file information", - function () { buffer.showPageInfo(true); }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["frameo[nly]"], - "Show only the current frame's page", - function (args) - { - liberator.open(tabs.localStore.focusedFrame.document.documentURI); - }, - { 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 - liberator.assert(!arg || arg[0] == ">" && !liberator.has("Win32"), - "E488: Trailing characters"); - - options.withContext(function () { - if (arg) - { - options.setPref("print.print_to_file", "true"); - options.setPref("print.print_to_filename", io.File(arg.substr(1)).path); - liberator.echomsg("Printing to file: " + arg.substr(1)); - } - else - liberator.echomsg("Sending to printer..."); + liberator.assert(!arg || titles.indexOf(arg) >= 0, + "E475: Invalid argument: " + arg); - options.setPref("print.always_print_silent", args.bang); - options.setPref("print.show_print_progress", !args.bang); + if (options["usermode"]) + options["usermode"] = false; - getBrowser().contentWindow.print(); + window.stylesheetSwitchAll(window.content, arg); + }, + { + argCount: "?", + completer: function (context) completion.alternateStyleSheet(context), + literal: 0 }); - if (arg) - liberator.echomsg("Printed: " + arg.substr(1)); - else - liberator.echomsg("Print job sent."); - }, - { - argCount: "?", - literal: 0, - bang: true - }); - - commands.add(["pa[geinfo]"], - "Show various page information", - function (args) { buffer.showPageInfo(true, args[0]); }, - { - argCount: "?", - completer: function (context) + commands.add(["re[load]"], + "Reload the current web page", + function (args) { tabs.reload(getBrowser().mCurrentTab, args.bang); }, { - 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.literalArg; + bang: true, + argCount: "0" + }); - let titles = buffer.alternateStyleSheets.map(function (stylesheet) stylesheet.title); + // 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 = window.content.document; + let chosenData = null; + let filename = args[0]; - liberator.assert(!arg || titles.indexOf(arg) >= 0, - "E475: Invalid argument: " + arg); + if (filename) { + let file = io.File(filename); - if (options["usermode"]) - options["usermode"] = false; + liberator.assert(!file.exists() || args.bang, + "E13: File exists (add ! to override)"); - window.stylesheetSwitchAll(window.content, arg); - }, - { - argCount: "?", - completer: function (context) completion.alternateStyleSheet(context), - literal: 0 - }); + chosenData = { file: file, uri: window.makeURI(doc.location.href, doc.characterSet) }; + } - commands.add(["re[load]"], - "Reload the current web page", - function (args) { tabs.reload(getBrowser().mCurrentTab, args.bang); }, - { - bang: true, - argCount: "0" - }); + // 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? + options.setPref("browser.download.lastDir", io.getCurrentDirectory().path); - // 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 = window.content.document; - let chosenData = null; - let filename = args[0]; + try { + var contentDisposition = window.content + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .getDocumentMetadata("content-disposition"); + } + catch (e) {} - if (filename) + window.internalSave(doc.location.href, doc, null, contentDisposition, + doc.contentType, false, null, chosenData, doc.referrer ? + window.makeURI(doc.referrer) : null, true); + }, { - let file = io.File(filename); - - liberator.assert(!file.exists() || args.bang, - "E13: File exists (add ! to override)"); + argCount: "?", + bang: true, + completer: function (context) completion.file(context) + }); - chosenData = { file: file, uri: window.makeURI(doc.location.href, doc.characterSet) }; - } + commands.add(["st[op]"], + "Stop loading the current web page", + function () { tabs.stop(getBrowser().mCurrentTab); }, + { argCount: "0" }); - // 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? - options.setPref("browser.download.lastDir", io.getCurrentDirectory().path); - - try + commands.add(["vie[wsource]"], + "View source code of current document", + function (args) { buffer.viewSource(args[0], args.bang); }, { - var contentDisposition = window.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) completion.file(context) - }); + argCount: "?", + bang: true, + completer: function (context) completion.url(context, "bhf") + }); - commands.add(["st[op]"], - "Stop loading the current web page", - function () { tabs.stop(getBrowser().mCurrentTab); }, - { 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)) { + if (args.bang) + level = buffer.fullZoom + parseInt(arg, 10); + else + level = buffer.textZoom + parseInt(arg, 10); + + // relative args shouldn't take us out of range + level = util.Math.constrain(level, Buffer.ZOOM_MIN, Buffer.ZOOM_MAX); + } + else + liberator.assert(false, "E488: Trailing characters"); - 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)) - { if (args.bang) - level = buffer.fullZoom + parseInt(arg, 10); + buffer.fullZoom = level; else - level = buffer.textZoom + parseInt(arg, 10); - - // relative args shouldn't take us out of range - level = util.Math.constrain(level, ZOOM_MIN, ZOOM_MAX); - } - else - liberator.assert(false, "E488: Trailing characters"); - - if (args.bang) - buffer.fullZoom = level; - else - buffer.textZoom = level; - }, - { - argCount: "?", - bang: true - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - liberator.registerObserver("load_completion", function () { + buffer.textZoom = level; + }, + { + argCount: "?", + bang: true + }); + }, + completion: function () { completion.alternateStyleSheet = function alternateStylesheet(context) { context.title = ["Stylesheet", "Location"]; @@ -750,1220 +1139,306 @@ function Buffer() //{{{ }; }); }; - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PAGE INFO /////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - addPageInfoSection("f", "Feeds", function (verbose) { - let doc = window.content.document; - - 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 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"> ({type})</span>]; - } - } - - if (!verbose && nFeed) - yield nFeed + " feed" + (nFeed > 1 ? "s" : ""); - }); - - addPageInfoSection("g", "General Info", function (verbose) { - let doc = window.content.document; - - // get file size - const ACCESS_READ = Ci.nsICache.ACCESS_READ; - let cacheKey = doc.location.toString().replace(/#.*$/, ""); - - for (let proto in util.Array.itervalues(["HTTP", "FTP"])) - { - try - { - var cacheEntryDescriptor = services.get("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.toString(), 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]; - }); - - addPageInfoSection("m", "Meta Tags", function (verbose) { - // get meta tag data, sort and put into pageMeta[] - let metaNodes = window.content.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])); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - get evaluateXPath() util.evaluateXPath, - - /** - * @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(window.content); - - return stylesheets.filter( - function (stylesheet) /^(screen|all|)$/i.test(stylesheet.media.mediaText) && !/^\s*$/.test(stylesheet.title) - ); - }, - - /** - * @property {Object} A map of page info sections to their - * content generating functions. - */ - get pageInfo() pageInfo, - - /** - * @property {number} A value indicating whether the buffer is loaded. - * Values may be: - * 0 - Loading. - * 1 - Fully loaded. - * 2 - Load failed. - */ - get loaded() - { - if (window.content.document.pageIsFullyLoaded !== undefined) - return window.content.document.pageIsFullyLoaded; - else - return 0; // in doubt return "loading" - }, - set loaded(value) - { - window.content.document.pageIsFullyLoaded = value; - }, - - /** - * @property {Node} The last focused input field in the buffer. Used - * by the "gi" key binding. - */ - get lastInputField() window.content.document.lastInputField || null, - set lastInputField(value) { window.content.document.lastInputField = value; }, - - /** - * @property {string} The current top-level document's URL. - */ - get URL() window.content.location.href, - - /** - * @property {string} The current top-level document's URL, sans any - * fragment identifier. - */ - get URI() - { - let loc = window.content.location; - return loc.href.substr(0, loc.href.length - loc.hash.length); - }, - - /** - * @property {number} The buffer's height in pixels. - */ - get pageHeight() window.content.innerHeight, - - /** - * @property {number} The current browser's text zoom level, as a - * percentage with 100 as 'normal'. Only affects text size. - */ - get textZoom() getBrowser().markupDocumentViewer.textZoom * 100, - set textZoom(value) { setZoom(value, false); }, - - /** - * @property {number} The current browser's text zoom level, as a - * percentage with 100 as 'normal'. Affects text size, as well as - * image size and block size. - */ - get fullZoom() getBrowser().markupDocumentViewer.fullZoom * 100, - set fullZoom(value) { setZoom(value, true); }, - - /** - * @property {string} The current document's title. - */ - get title() window.content.document.title, - - /** - * @property {number} The buffer's horizontal scroll percentile. - */ - get scrollXPercent() - { - let win = findScrollableWindow(); - if (win.scrollMaxX > 0) - return Math.round(win.scrollX / win.scrollMaxX * 100); - else - return 0; - }, - - /** - * @property {number} The buffer's vertical scroll percentile. - */ - get scrollYPercent() - { - let win = findScrollableWindow(); - if (win.scrollMaxY > 0) - return Math.round(win.scrollY / win.scrollMaxY * 100); - else - return 0; - }, - - /** - * 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: addPageInfoSection, - - /** - * Returns the currently selected word. If the selection is - * null, it tries to guess the word that the caret is - * positioned in. - * - * NOTE: might change the selection - * - * @returns {string} - */ - // FIXME: getSelection() doesn't always preserve line endings, see: - // https://www.mozdev.org/bugs/show_bug.cgi?id=19303 - getCurrentWord: function () - { - let selection = window.content.getSelection(); - let range = selection.getRangeAt(0); - if (selection.isCollapsed) - { - let selController = this.selectionController; - let caretmode = selController.getCaretEnabled(); - selController.setCaretEnabled(true); - // Only move backwards if the previous character is not a space. - if (range.startOffset > 0 && !/\s/.test(range.startContainer.textContent[range.startOffset - 1])) - selController.wordMove(false, false); - - selController.wordMove(true, true); - selController.setCaretEnabled(caretmode); - return String.match(selection, /\w*/)[0]; - } - if (util.computedStyle(range.startContainer).whiteSpace == "pre" - && util.computedStyle(range.endContainer).whiteSpace == "pre") - return String(range); - return String(selection); - }, - - /** - * 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 (elem) - { - let doc = window.content.document; - if (elem instanceof HTMLFrameElement || elem instanceof HTMLIFrameElement) - return void elem.contentWindow.focus(); - else if (elem instanceof HTMLInputElement && elem.type == "file") - { - openUploadPrompt(elem); - buffer.lastInputField = elem; - return; - } - - elem.focus(); - - // for imagemap - if (elem instanceof HTMLAreaElement) - { - try - { - let [x, y] = elem.getAttribute("coords").split(",").map(parseFloat); - - elem.dispatchEvent(events.create(doc, "mouseover", { screenX: x, screenY: y })); - } - catch (e) {} - } - }, - - /** - * Tries to guess links the like of "next" and "prev". Though it has a - * singularly horrendous name, it turns out to be quite useful. - * - * @param {string} rel The relationship to look for. Looks for - * links with matching @rel or @rev attributes, and, - * failing that, looks for an option named rel + - * "pattern", and finds the last link matching that - * RegExp. - */ - followDocumentRelationship: function (rel) - { - let regexes = options.get(rel + "pattern").values - .map(function (re) RegExp(re, "i")); - - 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]; + }, + mappings: function () { + var myModes = config.browserModes; + + mappings.add(myModes, ["."], + "Repeat the last key event", + function (count) { + if (mappings.repeat) { + for (let i in util.interruptibleRange(0, Math.max(count, 1), 100)) + mappings.repeat(); } - - // <link>s have higher priority than normal <a> hrefs - let elems = frame.document.getElementsByTagName("link"); - for (let elem in iter(elems)) - { - liberator.open(elem.href); - return true; - } - - // no links? ok, look for hrefs - elems = frame.document.getElementsByTagName("a"); - for (let elem in iter(elems)) - { - buffer.followLink(elem, liberator.CURRENT_TAB); - return true; - } - - let res = util.evaluateXPath(options.get("hinttags").defaultValue, frame.document); - for (let [, regex] in Iterator(regexes)) - { - for (let i in util.range(res.snapshotLength, 0, -1)) - { - let elem = res.snapshotItem(i); - if (regex.test(elem.textContent) || - regex.test(elem.title) || - Array.some(elem.childNodes, function (child) regex.test(child.alt))) - { - buffer.followLink(elem, liberator.CURRENT_TAB); - return true; - } - } - } - return false; - } - - let ret = followFrame(window.content); - if (!ret) - // only loop through frames if the main content didn't match - ret = Array.some(window.content.frames, followFrame); - - if (!ret) - liberator.beep(); - }, - - /** - * Fakes a click on a link. - * - * @param {Node} elem The element to click. - * @param {number} where Where to open the link. See - * {@link liberator.open}. - */ - followLink: function (elem, where) - { - let doc = elem.ownerDocument; - let view = doc.defaultView; - let offsetX = 1; - let offsetY = 1; - - if (elem instanceof HTMLFrameElement || elem instanceof HTMLIFrameElement) - { - elem.contentWindow.focus(); - return; - } - else 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") - { - openUploadPrompt(elem); - return; - } - - let ctrlKey = false, shiftKey = false; - switch (where) - { - case liberator.NEW_TAB: - case liberator.NEW_BACKGROUND_TAB: - ctrlKey = true; - shiftKey = (where != liberator.NEW_BACKGROUND_TAB); - break; - case liberator.NEW_WINDOW: - shiftKey = true; - break; - case liberator.CURRENT_TAB: - break; - default: - liberator.log("Invalid where argument for followLink()", 0); - } - - elem.focus(); - - options.withContext(function () { - options.setPref("browser.tabs.loadInBackground", true); - ["mousedown", "mouseup", "click"].forEach(function (event) { - elem.dispatchEvent(events.create(doc, event, { - screenX: offsetX, screenY: offsetY, - ctrlKey: ctrlKey, shiftKey: shiftKey, metaKey: ctrlKey - })); - }); + }, + { count: true }); + + mappings.add(myModes, ["i", "<Insert>"], + "Start caret mode", + function () { + // setting this option notifies an observer which takes care of the + // mode setting + options.setPref("accessibility.browsewithcaret", true); }); - }, - - /** - * @property {nsISelectionController} The current document's selection - * controller. - */ - get selectionController() getBrowser().docShell - .QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsISelectionDisplay) - .QueryInterface(Ci.nsISelectionController), - - /** - * Opens the appropriate context menu for <b>elem</b>. - * - * @param {Node} elem The context element. - */ - openContextMenu: function (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. - * @param {boolean} skipPrompt Whether to open the "Save Link As..." - * dialog. - */ - saveLink: function (elem, skipPrompt) - { - let doc = elem.ownerDocument; - let url = window.makeURLAbsolute(elem.baseURI, elem.href); - let text = elem.textContent; - - try - { - window.urlSecurityCheck(url, doc.nodePrincipal); - // we always want to save that link relative to the current working directory - options.setPref("browser.download.lastDir", io.getCurrentDirectory().path); - window.saveURL(url, text, null, true, skipPrompt, makeURI(url, doc.characterSet)); - } - catch (e) - { - liberator.echoerr(e); - } - }, - - /** - * Scrolls to the bottom of the current buffer. - */ - scrollBottom: function () - { - scrollToPercent(null, 100); - }, - - /** - * Scrolls the buffer laterally <b>cols</b> columns. - * - * @param {number} cols The number of columns to scroll. A positive - * value scrolls right and a negative value left. - */ - scrollColumns: function (cols) - { - scrollHorizontal(null, "columns", cols); - }, - - /** - * Scrolls to the top of the current buffer. - */ - scrollEnd: function () - { - scrollToPercent(100, null); - }, - - /** - * Scrolls the buffer vertically <b>lines</b> rows. - * - * @param {number} lines The number of lines to scroll. A positive - * value scrolls down and a negative value up. - */ - scrollLines: function (lines) - { - scrollVertical(null, "lines", lines); - }, - - /** - * Scrolls the buffer vertically <b>pages</b> pages. - * - * @param {number} pages The number of pages to scroll. A positive - * value scrolls down and a negative value up. - */ - scrollPages: function (pages) - { - scrollVertical(null, "pages", pages); - }, - - /** - * 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 (direction, count) - { - direction = direction ? 1 : -1; - count = count || 1; - let win = findScrollableWindow(); - - checkScrollYBounds(win, direction); - - if (options["scroll"] > 0) - this.scrollLines(options["scroll"] * direction); - else // scroll half a page down in pixels - win.scrollBy(0, win.innerHeight / 2 * direction); - }, - - /** - * Scrolls the buffer to the specified screen percentiles. - * - * @param {number} x The horizontal page percentile. - * @param {number} y The vertical page percentile. - */ - scrollToPercent: function (x, y) - { - scrollToPercent(x, y); - }, - - /** - * Scrolls the buffer to the specified screen pixels. - * - * @param {number} x The horizontal pixel. - * @param {number} y The vertical pixel. - */ - scrollTo: function (x, y) - { - marks.add("'", true); - content.scrollTo(x, y); - }, - - /** - * Scrolls the current buffer laterally to its leftmost. - */ - scrollStart: function () - { - scrollToPercent(0, null); - }, - - /** - * Scrolls the current buffer vertically to the top. - */ - scrollTop: function () - { - scrollToPercent(null, 0); - }, - - // 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. - * @param {boolean} forward The direction of motion. - */ - shiftFrameFocus: function (count, forward) - { - if (!(window.content.document instanceof HTMLDocument)) - return; - count = Math.max(count, 1); - let frames = []; - - // find all frames - depth-first search - (function (frame) { - if (frame.document.body instanceof HTMLBodyElement) - frames.push(frame); - Array.forEach(frame.frames, arguments.callee); - })(window.content); - - if (frames.length == 0) // currently top is always included - return; - - // remove all unfocusable frames - // TODO: find a better way to do this - walking the tree is too slow - let start = document.commandDispatcher.focusedWindow; - frames = frames.filter(function (frame) { - frame.focus(); - return document.commandDispatcher.focusedWindow == frame; - }); - start.focus(); - - // find the currently focused frame index - // TODO: If the window is a frameset then the first _frame_ should be - // focused. Since this is not the current FF behaviour, - // we initalize current to -1 so the first call takes us to the - // first frame. - let current = frames.indexOf(document.commandDispatcher.focusedWindow); - - // calculate the next frame to focus - let next = current; - if (forward) - { - next = current + count; - - if (next > frames.length - 1) - { - if (current == frames.length - 1) - liberator.beep(); - next = frames.length - 1; // still allow the frame indicator to be activated - } - } - else - { - next = current - count; - - if (next < 0) - { - if (current == 0) + mappings.add(myModes, ["<C-c>"], + "Stop loading the current web page", + function () { tabs.stop(getBrowser().mCurrentTab); }); + + // scrolling + mappings.add(myModes, ["j", "<Down>", "<C-e>"], + "Scroll document down", + function (count) { buffer.scrollLines(Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, ["k", "<Up>", "<C-y>"], + "Scroll document up", + function (count) { buffer.scrollLines(-Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, liberator.has("mail") ? ["h"] : ["h", "<Left>"], + "Scroll document to the left", + function (count) { buffer.scrollColumns(-Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, liberator.has("mail") ? ["l"] : ["l", "<Right>"], + "Scroll document to the right", + function (count) { buffer.scrollColumns(Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, ["0", "^"], + "Scroll to the absolute left of the document", + function () { buffer.scrollStart(); }); + + mappings.add(myModes, ["$"], + "Scroll to the absolute right of the document", + function () { buffer.scrollEnd(); }); + + mappings.add(myModes, ["gg", "<Home>"], + "Go to the top of the document", + function (count) { buffer.scrollToPercent(buffer.scrollXPercent, Math.max(count, 0)); }, + { count: true }); + + mappings.add(myModes, ["G", "<End>"], + "Go to the end of the document", + function (count) { buffer.scrollToPercent(buffer.scrollXPercent, count >= 0 ? count : 100); }, + { count: true }); + + mappings.add(myModes, ["%"], + "Scroll to {count} percent of the document", + function (count) { + if (count > 0 && count <= 100) + buffer.scrollToPercent(buffer.scrollXPercent, count); + else + liberator.beep(); + }, + { count: true }); + + mappings.add(myModes, ["<C-d>"], + "Scroll window downwards in the buffer", + function (count) { buffer._scrollByScrollSize(count, true); }, + { count: true }); + + mappings.add(myModes, ["<C-u>"], + "Scroll window upwards in the buffer", + function (count) { buffer._scrollByScrollSize(count, false); }, + { count: true }); + + mappings.add(myModes, ["<C-b>", "<PageUp>", "<S-Space>"], + "Scroll up a full page", + function (count) { buffer.scrollPages(-Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, ["<C-f>", "<PageDown>", "<Space>"], + "Scroll down a full page", + function (count) { buffer.scrollPages(Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, ["]f"], + "Focus next frame", + function (count) { buffer.shiftFrameFocus(Math.max(count, 1), true); }, + { count: true }); + + mappings.add(myModes, ["[f"], + "Focus previous frame", + function (count) { buffer.shiftFrameFocus(Math.max(count, 1), false); }, + { count: true }); + + mappings.add(myModes, ["]]"], + "Follow the link labeled 'next' or '>' if it exists", + function (count) { buffer.followDocumentRelationship("next"); }, + { count: true }); + + mappings.add(myModes, ["[["], + "Follow the link labeled 'prev', 'previous' or '<' if it exists", + function (count) { buffer.followDocumentRelationship("previous"); }, + { count: true }); + + mappings.add(myModes, ["gf"], + "View source", + function () { buffer.viewSource(null, false); }); + + mappings.add(myModes, ["gF"], + "View source with an external editor", + function () { buffer.viewSource(null, true); }); + + mappings.add(myModes, ["|"], + "Toggle between rendered and source view", + function () { buffer.viewSource(null, false); }); + + mappings.add(myModes, ["gi"], + "Focus last used input field", + function (count) { + if (count < 1 && buffer.lastInputField) + buffer.focusElement(buffer.lastInputField); + else { + let xpath = ["input[not(@type) or @type='text' or @type='password' or @type='file']", + "textarea[not(@disabled) and not(@readonly)]"]; + + let elements = [m for (m in util.evaluateXPath(xpath))].filter(function (match) { + let computedStyle = util.computedStyle(match); + return computedStyle.visibility != "hidden" && computedStyle.display != "none"; + }); + + if (elements.length > 0) + buffer.focusElement(elements[util.Math.constrain(count, 1, elements.length) - 1]); + else liberator.beep(); - next = 0; // still allow the frame indicator to be activated } - } - - // focus next frame and scroll into view - frames[next].focus(); - if (frames[next] != window.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.appendChild(indicator); - - setTimeout(function () { doc.body.removeChild(indicator); }, 500); - - // Doesn't unattach - //doc.body.setAttributeNS(NS.uri, "activeframe", "true"); - //setTimeout(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 (elem) - { - liberator.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 (verbose, sections) - { - // Ctrl-g single line output - if (!verbose) - { - let file = content.document.location.pathname.split("/").pop() || "[No Name]"; - let title = content.document.title || "[No Title]"; - - let info = template.map("gf", function (opt) - template.map(pageInfo[opt][0](), util.identity, ", "), - ", "); - - if (bookmarks.isBookmarked(this.URL)) - info += ", bookmarked"; - - let pageInfoText = <>{file.quote()} [{info}] {title}</>; - liberator.echo(pageInfoText, commandline.FORCE_SINGLELINE); - return; - } + }, + { count: true }); + + mappings.add(myModes, ["gP"], + "Open (put) a URL based on the current clipboard contents in a new buffer", + function () { + liberator.open(util.readFromClipboard(), + liberator[options.get("activate").has("paste") ? "NEW_BACKGROUND_TAB" : "NEW_TAB"]); + }); - let option = sections || options["pageinfo"]; - let list = template.map(option, function (option) { - let opt = pageInfo[option]; - if (opt) - return template.table(opt[1], opt[0](true)); - }, <br/>); - liberator.echo(list, commandline.FORCE_MULTILINE); - }, - - /** - * Opens a viewer to inspect the source of the currently selected - * range. - */ - viewSelectionSource: function () - { - // copied (and tuned somebit) from browser.jar -> nsContextMenu.js - let focusedWindow = document.commandDispatcher.focusedWindow; - if (focusedWindow == window) - focusedWindow = content; - - let docCharset = null; - if (focusedWindow) - docCharset = "charset=" + focusedWindow.document.characterSet; - - let reference = null; - reference = focusedWindow.getSelection(); - - let docUrl = null; - window.openDialog("chrome://global/content/viewPartialSource.xul", - "_blank", "scrollbars,resizable,chrome,dialog=no", - docUrl, docCharset, reference, "selection"); - }, - - /** - * Opens a viewer to inspect the source of the current buffer or the - * specified <b>url</b>. 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 (url, useExternalEditor) - { - url = url || buffer.URI; - - if (useExternalEditor) - editor.editFileExternally(url); - else - { - const PREFIX = "view-source:"; - if (url.indexOf(PREFIX) == 0) - url = url.substr(PREFIX.length); + mappings.add(myModes, ["p", "<MiddleMouse>"], + "Open (put) a URL based on the current clipboard contents in the current buffer", + function () { + let url = util.readFromClipboard(); + if (url) + liberator.open(url); else - url = PREFIX + url; - liberator.open(url, { hide: true }); - } - }, - - /** - * 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 (steps, fullZoom) - { - 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 (steps, fullZoom) - { - bumpZoomLevel(-steps, fullZoom); - } - }; - //}}} -} //}}} - -/** - * @instance marks - */ -function Marks() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var localMarks = storage.newMap("local-marks", { store: true, privateData: true }); - var urlMarks = storage.newMap("url-marks", { store: true, privateData: true }); - - var pendingJumps = []; - var appContent = document.getElementById("appcontent"); - - if (appContent) - appContent.addEventListener("load", onPageLoad, true); - - function onPageLoad(event) - { - let win = event.originalTarget.defaultView; - for (let [i, mark] in Iterator(pendingJumps)) - { - if (win && win.location.href == mark.location) - { - buffer.scrollToPercent(mark.position.x * 100, mark.position.y * 100); - pendingJumps.splice(i, 1); - return; - } - } - } - - function markToString(name, mark) - { - return name + ", " + mark.location + - ", (" + Math.round(mark.position.x * 100) + - "%, " + Math.round(mark.position.y * 100) + "%)" + - (("tab" in mark) ? ", tab: " + tabs.index(mark.tab) : ""); - } - - function removeLocalMark(mark) - { - let localmark = localMarks.get(mark); - if (localmark) - { - let win = window.content; - for (let [i, ] in Iterator(localmark)) - { - if (localmark[i].location == win.location.href) - { - liberator.log("Deleting local mark: " + markToString(mark, localmark[i]), 5); - localmark.splice(i, 1); - if (localmark.length == 0) - localMarks.remove(mark); - break; - } - } - } - } - - function removeURLMark(mark) - { - let urlmark = urlMarks.get(mark); - if (urlmark) - { - liberator.log("Deleting URL mark: " + markToString(mark, urlmark), 5); - urlMarks.remove(mark); - } - } - - function isLocalMark(mark) /^['`a-z]$/.test(mark); - function isURLMark(mark) /^[A-Z0-9]$/.test(mark); - - function localMarkIter() - { - for (let [mark, value] in localMarks) - for (let [, val] in Iterator(value)) - yield [mark, val]; - } - - function getSortedMarks() - { - // local marks - let location = window.content.location.href; - let lmarks = [i for (i in localMarkIter()) if (i[1].location == location)]; - lmarks.sort(); - - // URL marks - // FIXME: why does umarks.sort() cause a "Component is not available = - // NS_ERROR_NOT_AVAILABLE" exception when used here? - let umarks = [i for (i in urlMarks)]; - umarks.sort(function (a, b) a[0].localeCompare(b[0])); - - return lmarks.concat(umarks); - } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var myModes = config.browserModes; - - mappings.add(myModes, - ["m"], "Set mark at the cursor position", - function (arg) - { - if (/[^a-zA-Z]/.test(arg)) - return void liberator.beep(); - - marks.add(arg); - }, - { arg: true }); - - mappings.add(myModes, - ["'", "`"], "Jump to the mark in the current buffer", - function (arg) { marks.jumpTo(arg); }, - { arg: true }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["delm[arks]"], - "Delete the specified marks", - function (args) - { - let special = args.bang; - args = args.string; - - // assert(special ^ args) - liberator.assert( special || args, "E471: Argument required"); - liberator.assert(!special || !args, "E474: Invalid argument"); - - let matches; - if (matches = args.match(/(?:(?:^|[^a-zA-Z0-9])-|-(?:$|[^a-zA-Z0-9])|[^a-zA-Z0-9 -]).*/)) - { - // NOTE: this currently differs from Vim's behavior which - // deletes any valid marks in the arg list, up to the first - // invalid arg, as well as giving the error message. - liberator.echoerr("E475: Invalid argument: " + matches[0]); - return; - } - // check for illegal ranges - only allow a-z A-Z 0-9 - if (matches = args.match(/[a-zA-Z0-9]-[a-zA-Z0-9]/g)) - { - for (let i = 0; i < matches.length; i++) - { - let start = matches[i][0]; - let end = matches[i][2]; - if (/[a-z]/.test(start) != /[a-z]/.test(end) || - /[A-Z]/.test(start) != /[A-Z]/.test(end) || - /[0-9]/.test(start) != /[0-9]/.test(end) || - start > end) - { - liberator.echoerr("E475: Invalid argument: " + args.match(matches[i] + ".*")[0]); - return; - } - } - } - - marks.remove(args, special); - }, - { - bang: true, - completer: function (context) completion.mark(context) - }); - - commands.add(["ma[rk]"], - "Mark current location within the web page", - function (args) - { - let mark = args[0]; + liberator.beep(); + }); - liberator.assert(mark.length <= 1, "E488: Trailing characters"); - liberator.assert(/[a-zA-Z]/.test(mark), - "E191: Argument must be a letter or forward/backward quote"); + mappings.add(myModes, ["P"], + "Open (put) a URL based on the current clipboard contents in a new buffer", + function () { + let url = util.readFromClipboard(); + if (url) + liberator.open(url, { from: "activate", where: liberator.NEW_TAB }); + else + liberator.beep(); + }); - marks.add(mark); - }, - { argCount: "1" }); + // reloading + mappings.add(myModes, ["r"], + "Reload the current web page", + function () { tabs.reload(getBrowser().mCurrentTab, false); }); - commands.add(["marks"], - "Show all location marks of current web page", - function (args) - { - args = args.string; + mappings.add(myModes, ["R"], + "Reload while skipping the cache", + function () { tabs.reload(getBrowser().mCurrentTab, true); }); - // ignore invalid mark characters unless there are no valid mark chars - liberator.assert(!args || /[a-zA-Z]/.test(args), - "E283: No marks matching " + args.quote()); + // yanking + mappings.add(myModes, ["Y"], + "Copy selected text or current word", + function () { + let sel = buffer.getCurrentWord(); - let filter = args.replace(/[^a-zA-Z]/g, ""); - marks.list(filter); - }); + if (sel) + util.copyToClipboard(sel, true); + else + liberator.beep(); + }); - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - completion.mark = function mark(context) { - function percent(i) Math.round(i * 100); - - // FIXME: Line/Column doesn't make sense with % - context.title = ["Mark", "Line Column File"]; - context.keys.description = function ([, m]) percent(m.position.y) + "% " + percent(m.position.x) + "% " + m.location; - context.completions = marks.all; - }; - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - /** - * @property {Array} Returns all marks, both local and URL, in a sorted - * array. - */ - get all() getSortedMarks(), - - /** - * 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. - */ - // TODO: add support for frameset pages - add: function (mark, silent) - { - let win = window.content; - let doc = win.document; - - if (!doc.body) - return; - if (doc.body instanceof HTMLFrameSetElement) + // zooming + mappings.add(myModes, ["zi", "+"], + "Enlarge text zoom of current web page", + function (count) { buffer.zoomIn(Math.max(count, 1), false); }, + { count: true }); + + mappings.add(myModes, ["zm"], + "Enlarge text zoom of current web page by a larger amount", + function (count) { buffer.zoomIn(Math.max(count, 1) * 3, false); }, + { count: true }); + + mappings.add(myModes, ["zo", "-"], + "Reduce text zoom of current web page", + function (count) { buffer.zoomOut(Math.max(count, 1), false); }, + { count: true }); + + mappings.add(myModes, ["zr"], + "Reduce text zoom of current web page by a larger amount", + function (count) { buffer.zoomOut(Math.max(count, 1) * 3, false); }, + { count: true }); + + mappings.add(myModes, ["zz"], + "Set text zoom value of current web page", + function (count) { buffer.textZoom = count > 1 ? count : 100; }, + { count: true }); + + mappings.add(myModes, ["zI"], + "Enlarge full zoom of current web page", + function (count) { buffer.zoomIn(Math.max(count, 1), true); }, + { count: true }); + + mappings.add(myModes, ["zM"], + "Enlarge full zoom of current web page by a larger amount", + function (count) { buffer.zoomIn(Math.max(count, 1) * 3, true); }, + { count: true }); + + mappings.add(myModes, ["zO"], + "Reduce full zoom of current web page", + function (count) { buffer.zoomOut(Math.max(count, 1), true); }, + { count: true }); + + mappings.add(myModes, ["zR"], + "Reduce full zoom of current web page by a larger amount", + function (count) { buffer.zoomOut(Math.max(count, 1) * 3, true); }, + { count: true }); + + mappings.add(myModes, ["zZ"], + "Set full zoom value of current web page", + function (count) { buffer.fullZoom = count > 1 ? count : 100; }, + { count: true }); + + // page info + mappings.add(myModes, ["<C-g>"], + "Print the current file name", + function (count) { buffer.showPageInfo(false); }, + { count: true }); + + mappings.add(myModes, ["g<C-g>"], + "Print file information", + function () { buffer.showPageInfo(true); }); + }, + options: function () { + options.add(["nextpattern"], // \u00BB is » (>> in a single char) + "Patterns to use when guessing the 'next' page in a document sequence", + "stringlist", "\\bnext\\b,^>$,^(>>|\u00BB)$,^(>|\u00BB),(>|\u00BB)$,\\bmore\\b"); + + options.add(["previouspattern"], // \u00AB is « (<< in a single char) + "Patterns to use when guessing the 'previous' page in a document sequence", + "stringlist", "\\bprev|previous\\b,^<$,^(<<|\u00AB)$,^(<|\u00AB),(<|\u00AB)$"); + + options.add(["pageinfo", "pa"], + "Desired info in the :pageinfo output", + "charlist", "gfm", { - if (!silent) - liberator.echoerr("Marks support for frameset pages not implemented yet"); - return; - } + completer: function (context) [[k, v[1]] for ([k, v] in Iterator(this.pageInfo))], + validator: Option.validateCompleter + }); - let x = win.scrollMaxX ? win.pageXOffset / win.scrollMaxX : 0; - let y = win.scrollMaxY ? win.pageYOffset / win.scrollMaxY : 0; - let position = { x: x, y: y }; + options.add(["scroll", "scr"], + "Number of lines to scroll with <C-u> and <C-d> commands", + "number", 0, + { validator: function (value) value >= 0 }); - if (isURLMark(mark)) - { - urlMarks.set(mark, { location: win.location.href, position: position, tab: tabs.getTab() }); - if (!silent) - liberator.log("Adding URL mark: " + markToString(mark, urlMarks.get(mark)), 5); - } - else if (isLocalMark(mark)) - { - // remove any previous mark of the same name for this location - removeLocalMark(mark); - if (!localMarks.get(mark)) - localMarks.set(mark, []); - let vals = { location: win.location.href, position: position }; - localMarks.get(mark).push(vals); - if (!silent) - liberator.log("Adding local mark: " + markToString(mark, vals), 5); - } - }, - - /** - * Remove all marks matching <b>filter</b>. If <b>special</b> is - * given, removes all local marks. - * - * @param {string} filter A string containing one character for each - * mark to be removed. - * @param {boolean} special Whether to delete all local marks. - */ - // FIXME: Shouldn't special be replaced with a null filter? - remove: function (filter, special) - { - if (special) - { - // :delmarks! only deletes a-z marks - for (let [mark, ] in localMarks) - removeLocalMark(mark); - } - else + options.add(["showstatuslinks", "ssli"], + "Show the destination of the link under the cursor in the status bar", + "number", 1, { - for (let [mark, ] in urlMarks) - { - if (filter.indexOf(mark) >= 0) - removeURLMark(mark); - } - for (let [mark, ] in localMarks) - { - if (filter.indexOf(mark) >= 0) - removeLocalMark(mark); - } - } - }, - - /** - * Jumps to the named mark. See {@link #add} - * - * @param {string} mark The mark to jump to. - */ - jumpTo: function (mark) - { - let ok = false; - - if (isURLMark(mark)) - { - let slice = urlMarks.get(mark); - if (slice && slice.tab && slice.tab.linkedBrowser) - { - if (slice.tab.parentNode != getBrowser().tabContainer) - { - pendingJumps.push(slice); - // NOTE: this obviously won't work on generated pages using - // non-unique URLs :( - liberator.open(slice.location, liberator.NEW_TAB); - return; - } - let index = tabs.index(slice.tab); - if (index != -1) - { - tabs.select(index); - let win = slice.tab.linkedBrowser.contentWindow; - if (win.location.href != slice.location) - { - pendingJumps.push(slice); - win.location.href = slice.location; - return; - } - liberator.log("Jumping to URL mark: " + markToString(mark, slice), 5); - buffer.scrollToPercent(slice.position.x * 100, slice.position.y * 100); - ok = true; - } - } - } - else if (isLocalMark(mark)) - { - let win = window.content; - let slice = localMarks.get(mark) || []; - - for (let [, lmark] in Iterator(slice)) - { - if (win.location.href == lmark.location) - { - liberator.log("Jumping to local mark: " + markToString(mark, lmark), 5); - buffer.scrollToPercent(lmark.position.x * 100, lmark.position.y * 100); - ok = true; - break; - } - } - } - - if (!ok) - liberator.echoerr("E20: Mark not set"); - }, - - /** - * List all marks matching <b>filter</b>. - * - * @param {string} filter - */ - list: function (filter) - { - let marks = getSortedMarks(); - - liberator.assert(marks.length > 0, "No marks set"); + completer: function (context) [ + ["0", "Don't show link destination"], + ["1", "Show the link in the status line"], + ["2", "Show the link in the command line"] + ], + validator: Option.validateCompleter + }); - if (filter.length > 0) + options.add(["usermode", "um"], + "Show current website with a minimal style sheet to make it easily accessible", + "boolean", false, { - marks = marks.filter(function (mark) filter.indexOf(mark[0]) >= 0); - liberator.assert(marks.length > 0, "E283: No marks matching " + filter.quote()); - } - - let list = template.tabular( - ["Mark", "Line", "Column", "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)))); - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - } - - }; - //}}} -} //}}} + setter: function (value) getBrowser().markupDocumentViewer.authorStyleDisabled = value, + getter: function () getBrowser().markupDocumentViewer.authorStyleDisabled + }); + }, +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/commandline.js b/common/content/commandline.js new file mode 100644 index 00000000..6a66f63d --- /dev/null +++ b/common/content/commandline.js @@ -0,0 +1,1867 @@ +// 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. + +/** @scope modules */ + +/** + * 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. + */ +const CommandLine = Module("commandline", { + requires: ["liberator", "modes", "services", "storage", "template", "util"], + + init: function () { + const self = this; + + this._callbacks = {}; + + storage.newArray("history-search", { store: true, privateData: true }); + storage.newArray("history-command", { store: true, privateData: true }); + + // Really inideal. + let services = modules.services; // Storage objects are global to all windows, 'modules' isn't. + storage.newObject("sanitize", function () { + ({ + CLEAR: "browser:purge-session-history", + QUIT: "quit-application", + init: function () { + services.get("observer").addObserver(this, this.CLEAR, false); + services.get("observer").addObserver(this, this.QUIT, false); + }, + observe: function (subject, topic, data) { + switch (topic) { + case this.CLEAR: + ["search", "command"].forEach(function (mode) { + CommandLine.History(null, mode).sanitize(); + }); + break; + case this.QUIT: + services.get("observer").removeObserver(this, this.CLEAR); + services.get("observer").removeObserver(this, this.QUIT); + break; + } + } + }).init(); + }, { store: false }); + storage.addObserver("sanitize", + function (key, event, value) { + autocommands.trigger("Sanitize", {}); + }, window); + + 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 = []; + }, + + add: function add(message) { + if (!message) + return; + + if (this._messages.length >= options["messages"]) + this._messages.shift(); + + this._messages.push(message); + } + }; //}}} + + this._lastMowOutput = null; + + this._silent = false; + this._quiet = false; + this._keepCommand = false; + this._lastEcho = null; + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// TIMERS ////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + this._statusTimer = new Timer(5, 100, function statusTell() { + if (self._completions == null) + return; + if (self._completions.selected == null) + statusline.updateProgress(""); + else + statusline.updateProgress("match " + (self._completions.selected + 1) + " of " + self._completions.items.length); + }); + + this._autocompleteTimer = new Timer(200, 500, function autocompleteTell(tabPressed) { + if (!events.feedingKeys && self._completions && options.get("wildoptions").has("auto")) { + self._completions.complete(true, false); + self._completions.itemList.show(); + } + }); + + // This timer just prevents <Tab>s from queueing up when the + // system is under load (and, thus, giving us several minutes of + // the completion list scrolling). Multiple <Tab> presses are + // still processed normally, as the time is flushed on "keyup". + this._tabTimer = new Timer(0, 0, function tabTell(event) { + if (self._completions) + self._completions.tab(event.shiftKey); + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// VARIABLES /////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + this._completionList = new ItemList("liberator-completions"); + this._completions = null; + this._history = null; + + this._startHints = false; // whether we're waiting to start hints mode + this._lastSubstring = ""; + + // the containing box for the this._promptWidget and this._commandWidget + this._commandlineWidget = document.getElementById("liberator-commandline"); + // the prompt for the current command, for example : or /. Can be blank + this._promptWidget = document.getElementById("liberator-commandline-prompt"); + // the command bar which contains the current command + this._commandWidget = document.getElementById("liberator-commandline-command"); + + this._messageBox = document.getElementById("liberator-message"); + + this._commandWidget.inputField.QueryInterface(Ci.nsIDOMNSEditableElement); + this._messageBox.inputField.QueryInterface(Ci.nsIDOMNSEditableElement); + + // the widget used for multiline output + this._multilineOutputWidget = document.getElementById("liberator-multiline-output"); + this._outputContainer = this._multilineOutputWidget.parentNode; + + this._multilineOutputWidget.contentDocument.body.id = "liberator-multiline-output-content"; + + // the widget used for multiline intput + this._multilineInputWidget = document.getElementById("liberator-multiline-input"); + + // we need to save the mode which were in before opening the command line + // this is then used if we focus the command line again without the "official" + // way of calling "open" + this._currentExtendedMode = null; // the extended mode which we last openend the command line for + this._currentPrompt = null; + this._currentCommand = null; + + // save the arguments for the inputMultiline method which are needed in the event handler + this._multilineRegexp = null; + this._multilineCallback = null; + + + this._input = {}; + + this.registerCallback("submit", modes.EX, function (command) { + commands.repeat = command; + liberator.execute(command); + }); + this.registerCallback("complete", modes.EX, function (context) { + context.fork("ex", 0, completion, "ex"); + }); + this.registerCallback("change", modes.EX, function (command) { + self._autocompleteTimer.tell(false); + }); + + this.registerCallback("cancel", modes.PROMPT, cancelPrompt); + this.registerCallback("submit", modes.PROMPT, closePrompt); + this.registerCallback("change", modes.PROMPT, function (str) { + if (self._input.complete) + self._autocompleteTimer.tell(false); + if (self._input.change) + return self._input.change.call(commandline, str); + }); + this.registerCallback("complete", modes.PROMPT, function (context) { + if (self._input.complete) + context.fork("input", 0, commandline, input.complete); + }); + + function cancelPrompt(value) { + let callback = self._input.cancel; + self._input = {}; + if (callback) + callback.call(self, value != null ? value : commandline.command); + } + + function closePrompt(value) { + let callback = self._input.submit; + self._input = {}; + if (callback) + callback.call(self, value != null ? value : commandline.command); + } + }, + + /** + * Highlight the messageBox according to <b>group</b>. + */ + _setHighlightGroup: function (group) { + this._messageBox.setAttributeNS(NS.uri, "highlight", group); + }, + + /** + * Determines whether the command line should be visible. + * + * @returns {boolean} + */ + _commandShown: function () modes.main == modes.COMMAND_LINE && + !(modes.extended & (modes.INPUT_MULTILINE | modes.OUTPUT_MULTILINE)), + + /** + * Set the command-line prompt. + * + * @param {string} val + * @param {string} highlightGroup + */ + _setPrompt: function (val, highlightGroup) { + this._promptWidget.value = val; + this._promptWidget.size = val.length; + this._promptWidget.collapsed = (val == ""); + this._promptWidget.setAttributeNS(NS.uri, "highlight", highlightGroup || commandline.HL_NORMAL); + }, + + /** + * Set the command-line input value. The caret is reset to the + * end of the line. + * + * @param {string} cmd + */ + _setCommand: function (cmd) { + this._commandWidget.value = cmd; + this._commandWidget.selectionStart = cmd.length; + this._commandWidget.selectionEnd = cmd.length; + }, + + /** + * 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 (str, highlightGroup, forceSingle) { + this._setHighlightGroup(highlightGroup); + this._messageBox.value = str; + + liberator.triggerObserver("echoLine", str, highlightGroup, forceSingle); + + if (!this._commandShown()) + commandline.hide(); + + let field = this._messageBox.inputField; + if (!forceSingle && field.editor.rootElement.scrollWidth > field.scrollWidth) + this._echoMultiline(<span highlight="Message">{str}</span>, highlightGroup); + }, + + /** + * Display a multiline message. + * + * @param {string} str + * @param {string} highlightGroup + */ + // TODO: resize upon a window resize + _echoMultiline: function (str, highlightGroup) { + let doc = this._multilineOutputWidget.contentDocument; + let win = this._multilineOutputWidget.contentWindow; + + liberator.triggerObserver("echoMultiline", str, highlightGroup); + + // 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 = typeof str != "xml"; + this._lastMowOutput = <div class="ex-command-output" style="white-space: nowrap" highlight={highlightGroup}>{template.maybeXML(str)}</div>; + let output = util.xmlToDom(this._lastMowOutput, doc); + XML.ignoreWhitespace = true; + + // FIXME: need to make sure an open MOW is closed when commands + // that don't generate output are executed + if (this._outputContainer.collapsed) + doc.body.innerHTML = ""; + + doc.body.appendChild(output); + + commandline.updateOutputHeight(true); + + if (options["more"] && win.scrollMaxY > 0) { + // start the last executed command's output at the top of the screen + let elements = doc.getElementsByClassName("ex-command-output"); + elements[elements.length - 1].scrollIntoView(true); + } + else + win.scrollTo(0, doc.height); + + win.focus(); + + this._startHints = false; + modes.set(modes.COMMAND_LINE, modes.OUTPUT_MULTILINE); + commandline.updateMorePrompt(); + }, + + /** + * Ensure that the multiline input widget is the correct size. + */ + _autosizeMultilineInputWidget: function () { + let lines = this._multilineInputWidget.value.split("\n").length - 1; + + this._multilineInputWidget.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 this._history + + get completionContext() this._completions.context, + + get mode() (modes.extended == modes.EX) ? "cmd" : "search", + + get silent() this._silent, + set silent(val) { + this._silent = val; + this._quiet = this._quiet; + }, + get quiet() this._quiet, + set quiet(val) { + this._quiet = val; + Array.forEach(document.getElementById("liberator-commandline").childNodes, function (node) { + node.style.opacity = this._quiet || this._silent ? "0" : ""; + }); + }, + + // @param type can be: + // "submit": when the user pressed enter in the command line + // "change" + // "cancel" + // "complete" + registerCallback: function (type, mode, func) { + if (!(type in this._callbacks)) + this._callbacks[type] = {}; + this._callbacks[type][mode] = func; + }, + + triggerCallback: function (type, mode, data) { + if (this._callbacks[type] && this._callbacks[type][mode]) + this._callbacks[type][mode].call(this, data); + }, + + runSilently: function (func, self) { + let wasSilent = this._silent; + this._silent = true; + try { + func.call(self); + } + finally { + this._silent = wasSilent; + } + }, + + get command() { + try { + // The long path is because of complications with the + // completion preview. + return this._commandWidget.inputField.editor.rootElement.firstChild.textContent; + } + catch (e) { + return this._commandWidget.value; + } + }, + set command(cmd) this._commandWidget.value = cmd, + + get message() this._messageBox.value, + + /** + * Open the command line. The main mode is set to + * COMMAND_LINE, the extended mode to <b>extendedMode</b>. + * Further, callbacks defined for <b>extendedMode</b> are + * triggered as appropriate (see {@link #registerCallback}). + * + * @param {string} prompt + * @param {string} cmd + * @param {number} extendedMode + */ + open: function open(prompt, cmd, extendedMode) { + // save the current prompts, we need it later if the command widget + // receives focus without calling the this.open() method + this._currentPrompt = prompt || ""; + this._currentCommand = cmd || ""; + this._currentExtendedMode = extendedMode || null; + this._keepCommand = false; + + this._setPrompt(this._currentPrompt); + this._setCommand(this._currentCommand); + this._commandlineWidget.collapsed = false; + + modes.set(modes.COMMAND_LINE, this._currentExtendedMode); + + this._commandWidget.focus(); + + this._history = CommandLine.History(this._commandWidget.inputField, (modes.extended == modes.EX) ? "command" : "search"); + this._completions = CommandLine.Completions(this._commandWidget.inputField); + + // open the completion list automatically if wanted + if (cmd.length) + commandline.triggerCallback("change", this._currentExtendedMode, cmd); + }, + + /** + * Closes the command line. This is ordinarily triggered automatically + * by a mode change. Will not hide the command line immediately if + * called directly after a successful command, otherwise it will. + */ + close: function close() { + let mode = this._currentExtendedMode; + this._currentExtendedMode = null; + commandline.triggerCallback("cancel", mode); + + if (this._history) + this._history.save(); + + this.resetCompletions(); // cancels any asynchronous completion still going on, must be before we set completions = null + this._completions = null; + this._history = null; + + statusline.updateProgress(""); // we may have a "match x of y" visible + liberator.focusContent(false); + + this._multilineInputWidget.collapsed = true; + this._completionList.hide(); + + if (!this._keepCommand || this._silent || this._quiet) { + this._outputContainer.collapsed = true; + commandline.updateMorePrompt(); + this.hide(); + } + if (!this._outputContainer.collapsed) { + modes.set(modes.COMMAND_LINE, modes.OUTPUT_MULTILINE); + commandline.updateMorePrompt(); + } + this._keepCommand = false; + }, + + /** + * Hides the command line, and shows any status messages that + * are under it. + */ + hide: function hide() { + this._commandlineWidget.collapsed = true; + }, + + /** + * 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 this._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. + */ + echo: function echo(str, highlightGroup, flags) { + // liberator.echo uses different order of flags as it omits the highlight group, change commandline.echo argument order? --mst + if (this._silent) + return; + + highlightGroup = highlightGroup || this.HL_NORMAL; + + if (flags & this.APPEND_TO_MESSAGES) + this._messageHistory.add({ str: str, highlight: highlightGroup }); + + // The DOM isn't threadsafe. It must only be accessed from the main thread. + liberator.callInMainThread(function () { + if ((flags & this.DISALLOW_MULTILINE) && !this._outputContainer.collapsed) + return; + + let single = flags & (this.FORCE_SINGLELINE | this.DISALLOW_MULTILINE); + let action = this._echoLine; + + // TODO: this is all a bit convoluted - clean up. + // assume that FORCE_MULTILINE output is fully styled + if (!(flags & this.FORCE_MULTILINE) && !single && (!this._outputContainer.collapsed || this._messageBox.value == this._lastEcho)) { + highlightGroup += " Message"; + action = this._echoMultiline; + } + + if ((flags & this.FORCE_MULTILINE) || (/\n/.test(str) || typeof str == "xml") && !(flags & this.FORCE_SINGLELINE)) + action = this._echoMultiline; + + if (single) + this._lastEcho = null; + else { + if (this._messageBox.value == this._lastEcho) + this._echoMultiline(<span highlight="Message">{this._lastEcho}</span>, + this._messageBox.getAttributeNS(NS.uri, "highlight")); + this._lastEcho = (action == this._echoLine) && str; + } + + if (action) + action.call(this, str, highlightGroup, single); + }, this); + }, + + /** + * 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 || {}; + + this._input = { + submit: callback, + change: extra.onChange, + complete: extra.completer, + cancel: extra.onCancel + }; + + modes.push(modes.COMMAND_LINE, modes.PROMPT); + this._currentExtendedMode = modes.PROMPT; + + this._setPrompt(prompt, extra.promptHighlight || this.HL_QUESTION); + this._setCommand(extra.default || ""); + this._commandlineWidget.collapsed = false; + this._commandWidget.focus(); + + this._completions = CommandLine.Completions(this._commandWidget.inputField); + }, + + /** + * Get a multiline 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 {RegExp} untilRegexp + * @param {function(string)} callbackFunc + */ + // FIXME: Buggy, especially when pasting. Shouldn't use a RegExp. + inputMultiline: function inputMultiline(untilRegexp, callbackFunc) { + // Kludge. + let cmd = !this._commandWidget.collapsed && this.command; + modes.push(modes.COMMAND_LINE, modes.INPUT_MULTILINE); + if (cmd != false) + this._echoLine(cmd, this.HL_NORMAL); + + // save the arguments, they are needed in the event handler onEvent + this._multilineRegexp = untilRegexp; + this._multilineCallback = callbackFunc; + + this._multilineInputWidget.collapsed = false; + this._multilineInputWidget.value = ""; + this._autosizeMultilineInputWidget(); + + setTimeout(function () { this._multilineInputWidget.focus(); }, 10); + }, + + /** + * Handles all command-line events. All key events are passed here when + * COMMAND_LINE mode is active, as well as all input, keyup, focus, and + * blur events sent to the command-line XUL element. + * + * @param {Event} event + * @private + */ + onEvent: function onEvent(event) { + const self = this; + let command = this.command; + + if (event.type == "blur") { + // prevent losing focus, there should be a better way, but it just didn't work otherwise + setTimeout(function () { + if (self._commandShown() && event.originalTarget == self._commandWidget.inputField) + self._commandWidget.inputField.focus(); + }, 0); + } + else if (event.type == "focus") { + if (!self._commandShown() && event.target == self._commandWidget.inputField) { + event.target.blur(); + liberator.beep(); + } + } + else if (event.type == "input") { + this.resetCompletions(); + commandline.triggerCallback("change", this._currentExtendedMode, command); + } + else if (event.type == "keypress") { + let key = events.toString(event); + if (this._completions) + this._completions.previewClear(); + if (!this._currentExtendedMode) + return; + + // user pressed <Enter> to carry out a command + // user pressing <Esc> is handled in the global onEscape + // FIXME: <Esc> should trigger "cancel" event + if (events.isAcceptKey(key)) { + let mode = this._currentExtendedMode; // save it here, as modes.pop() resets it + this._keepCommand = !userContext.hidden_option_no_command_afterimage; + this._currentExtendedMode = null; // Don't let modes.pop trigger "cancel" + modes.pop(!this._silent); + commandline.triggerCallback("submit", mode, command); + } + // user pressed <Up> or <Down> arrow to cycle this._history completion + else if (/^(<Up>|<Down>|<S-Up>|<S-Down>|<PageUp>|<PageDown>)$/.test(key)) { + // prevent tab from moving to the next field + event.preventDefault(); + event.stopPropagation(); + + if (this._history) + this._history.select(/Up/.test(key), !/(Page|S-)/.test(key)); + else + liberator.beep(); + } + // user pressed <Tab> to get completions of a command + else if (key == "<Tab>" || key == "<S-Tab>") { + // prevent tab from moving to the next field + event.preventDefault(); + event.stopPropagation(); + + this._tabTimer.tell(event); + } + else if (key == "<BS>") { + // reset the tab completion + //this.resetCompletions(); + + // and blur the command line if there is no text left + if (command.length == 0) { + commandline.triggerCallback("cancel", this._currentExtendedMode); + modes.pop(); + } + } + else { // any other key + //this.resetCompletions(); + } + // allow this event to be handled by the host app + } + else if (event.type == "keyup") { + let key = events.toString(event); + if (key == "<Tab>" || key == "<S-Tab>") + this._tabTimer.flush(); + } + }, + + /** + * Multiline input events, they will come straight from + * #liberator-multiline-input in the XUL. + * + * @param {Event} event + */ + onMultilineInputEvent: function onMultilineInputEvent(event) { + if (event.type == "keypress") { + let key = events.toString(event); + if (events.isAcceptKey(key)) { + let text = this._multilineInputWidget.value.substr(0, this._multilineInputWidget.selectionStart); + if (text.match(this._multilineRegexp)) { + text = text.replace(this._multilineRegexp, ""); + modes.pop(); + this._multilineInputWidget.collapsed = true; + this._multilineCallback.call(this, text); + } + } + else if (events.isCancelKey(key)) { + modes.pop(); + this._multilineInputWidget.collapsed = true; + } + } + else if (event.type == "blur") { + if (modes.extended & modes.INPUT_MULTILINE) + setTimeout(function () { this._multilineInputWidget.inputField.focus(); }, 0); + } + else if (event.type == "input") + this._autosizeMultilineInputWidget(); + return true; + }, + + /** + * Handle events when we are in multiline output mode, these come from + * liberator when modes.extended & modes.MULTILINE_OUTPUT and also from + * #liberator-multiline-output in the XUL. + * + * @param {Event} event + */ + // FIXME: if 'more' is set and the MOW is not scrollable we should still + // allow a down motion after an up rather than closing + onMultilineOutputEvent: function onMultilineOutputEvent(event) { + let win = this._multilineOutputWidget.contentWindow; + + let showMoreHelpPrompt = false; + let showMorePrompt = false; + let closeWindow = false; + let passEvent = false; + + let key = events.toString(event); + + // TODO: Wouldn't multiple handlers be cleaner? --djk + if (event.type == "click" && event.target instanceof HTMLAnchorElement) { + function openLink(where) { + event.preventDefault(); + // FIXME: Why is this needed? --djk + if (event.target.getAttribute("href") == "#") + liberator.open(event.target.textContent, where); + else + liberator.open(event.target.href, where); + } + + switch (key) { + case "<LeftMouse>": + if (event.originalTarget.getAttributeNS(NS.uri, "highlight") == "URL buffer-list") { + event.preventDefault(); + tabs.select(parseInt(event.originalTarget.parentNode.parentNode.firstChild.textContent, 10) - 1); + } + else + openLink(liberator.CURRENT_TAB); + break; + case "<MiddleMouse>": + case "<C-LeftMouse>": + case "<C-M-LeftMouse>": + openLink(liberator.NEW_BACKGROUND_TAB); + break; + case "<S-MiddleMouse>": + case "<C-S-LeftMouse>": + case "<C-M-S-LeftMouse>": + openLink(liberator.NEW_TAB); + break; + case "<S-LeftMouse>": + openLink(liberator.NEW_WINDOW); + break; + } + + return; + } + + if (this._startHints) { + statusline.updateInputBuffer(""); + this._startHints = false; + hints.show(key, undefined, win); + return; + } + + function isScrollable() !win.scrollMaxY == 0; + function atEnd() win.scrollY / win.scrollMaxY >= 1; + + switch (key) { + case "<Esc>": + closeWindow = true; + break; // handled globally in events.js:onEscape() + + case ":": + commandline.open(":", "", modes.EX); + return; + + // down a line + case "j": + case "<Down>": + if (options["more"] && isScrollable()) + win.scrollByLines(1); + else + passEvent = true; + break; + + case "<C-j>": + case "<C-m>": + case "<Return>": + if (options["more"] && isScrollable() && !atEnd()) + win.scrollByLines(1); + else + closeWindow = true; // don't propagate the event for accept keys + break; + + // up a line + case "k": + case "<Up>": + case "<BS>": + if (options["more"] && isScrollable()) + win.scrollByLines(-1); + else if (options["more"] && !isScrollable()) + showMorePrompt = true; + else + passEvent = true; + break; + + // half page down + case "d": + if (options["more"] && isScrollable()) + win.scrollBy(0, win.innerHeight / 2); + else + passEvent = true; + break; + + // TODO: <LeftMouse> on the prompt line should scroll one page + // page down + case "f": + if (options["more"] && isScrollable()) + win.scrollByPages(1); + else + passEvent = true; + break; + + case "<Space>": + case "<PageDown>": + if (options["more"] && isScrollable() && !atEnd()) + win.scrollByPages(1); + else + passEvent = true; + break; + + // half page up + case "u": + // if (more and scrollable) + if (options["more"] && isScrollable()) + win.scrollBy(0, -(win.innerHeight / 2)); + else + passEvent = true; + break; + + // page up + case "b": + if (options["more"] && isScrollable()) + win.scrollByPages(-1); + else if (options["more"] && !isScrollable()) + showMorePrompt = true; + else + passEvent = true; + break; + + case "<PageUp>": + if (options["more"] && isScrollable()) + win.scrollByPages(-1); + else + passEvent = true; + break; + + // top of page + case "g": + if (options["more"] && isScrollable()) + win.scrollTo(0, 0); + else if (options["more"] && !isScrollable()) + showMorePrompt = true; + else + passEvent = true; + break; + + // bottom of page + case "G": + if (options["more"] && isScrollable() && !atEnd()) + win.scrollTo(0, win.scrollMaxY); + else + passEvent = true; + break; + + // copy text to clipboard + case "<C-y>": + util.copyToClipboard(win.getSelection()); + break; + + // close the window + case "q": + closeWindow = true; + break; + + case ";": + statusline.updateInputBuffer(";"); + this._startHints = true; + break; + + // unmapped key + default: + if (!options["more"] || !isScrollable() || atEnd() || events.isCancelKey(key)) + passEvent = true; + else + showMoreHelpPrompt = true; + } + + if (passEvent || closeWindow) { + modes.pop(); + + if (passEvent) + events.onKeyPress(event); + } + else + commandline.updateMorePrompt(showMorePrompt, showMoreHelpPrompt); + }, + + getSpaceNeeded: function getSpaceNeeded() { + let rect = this._commandlineWidget.getBoundingClientRect(); + let offset = rect.bottom - window.innerHeight; + return Math.max(0, offset); + }, + + /** + * Update or remove the multiline 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._outputContainer.collapsed) + return this._echoLine("", this.HL_NORMAL); + + let win = this._multilineOutputWidget.contentWindow; + function isScrollable() !win.scrollMaxY == 0; + function atEnd() win.scrollY / win.scrollMaxY >= 1; + + if (showHelp) + this._echoLine("-- More -- SPACE/d/j: screen/page/line down, b/u/k: up, q: quit", this.HL_MOREMSG, true); + else if (force || (options["more"] && isScrollable() && !atEnd())) + this._echoLine("-- More --", this.HL_MOREMSG, true); + else + this._echoLine("Press ENTER or type command to continue", this.HL_QUESTION, true); + }, + + /** + * Changes the height of the this._multilineOutputWidget to fit in the + * available space. + * + * @param {boolean} open If true, the widget will be opened if it's not + * already so. + */ + updateOutputHeight: function updateOutputHeight(open) { + if (!open && this._outputContainer.collapsed) + return; + + let doc = this._multilineOutputWidget.contentDocument; + + availableHeight = config.outputHeight; + if (!this._outputContainer.collapsed) + availableHeight += parseFloat(this._outputContainer.height); + doc.body.style.minWidth = this._commandlineWidget.scrollWidth + "px"; + this._outputContainer.height = Math.min(doc.height, availableHeight) + "px"; + doc.body.style.minWidth = ""; + this._outputContainer.collapsed = false; + }, + + resetCompletions: function resetCompletions() { + if (this._completions) { + this._completions.context.cancelAll(); + this._completions.wildIndex = -1; + this._completions.previewClear(); + } + if (this._history) + this._history.reset(); + } +}, { + /** + * A class for managing the this._history of an inputField. + * + * @param {HTMLInputElement} inputField + * @param {string} mode The mode for which we need this._history. + */ + History: Class("History", { + init: function (inputField, mode) { + this.mode = mode; + this.input = inputField; + this.store = storage["history-" + mode]; + this.reset(); + }, + /** + * Reset the this._history index to the first entry. + */ + reset: function () { + 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 () { + if (events.feedingKeys) + return; + let str = this.input.value; + if (/^\s*$/.test(str)) + return; + this.store.mutate("filter", function (line) (line.value || line) != str); + this.store.push({ value: str, timestamp: Date.now(), privateData: this.checkPrivate(str) }); + this.store.truncate(options["history"], true); + }, + /** + * @property {function} Returns whether a data item should be + * considered private. + */ + checkPrivate: function (str) { + // Not really the ideal place for this check. + if (this.mode == "command") + return (commands.get(commands.parseCommand(str)[1]) || {}).privateData; + return false; + }, + /** + * Removes any private data from this this._history. + */ + sanitize: function (timespan) { + let range = [0, Number.MAX_VALUE]; + if (liberator.has("sanitizer") && (timespan || options["sanitizetimespan"])) + range = sanitizer.getClearRange(timespan || options["sanitizetimespan"]); + + this.store.mutate("filter", function (item) { + let timestamp = (item.timestamp || Date.now()/1000) * 1000; + return !line.privateData || timestamp < self.range[0] || timestamp > self.range[1]; + }); + }, + /** + * Replace the current input field value. + * + * @param {string} val The new value. + */ + replace: function (val) { + this.input.value = val; + commandline.triggerCallback("change", this._currentExtendedMode, val); + }, + + /** + * Move forward or backward in this._history. + * + * @param {boolean} backward Direction to move. + * @param {boolean} matchCurrent Search for matches starting + * with the current input value. + */ + select: function (backward, matchCurrent) { + // always reset the tab completion if we use up/down keys + commandline._completions.reset(); + + let diff = backward ? -1 : 1; + + if (this.index == null) { + this.original = this.input.value; + this.index = this.store.length; + } + + // search the this._history for the first item matching the current + // commandline string + while (true) { + this.index += diff; + if (this.index < 0 || this.index > this.store.length) { + this.index = util.Math.constrain(this.index, 0, this.store.length); + liberator.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.get(this.index); + // user pressed DOWN when there is no newer this._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 this._completions on an input field. + * + * @param {Object} input + */ + Completions: Class("Completions", { + init: function (input) { + this.context = CompletionContext(input.editor); + this.context.onUpdate = this.closure._reset; + this.editor = input.editor; + this.selected = null; + this.wildmode = options.get("wildmode"); + this.itemList = commandline._completionList; + this.itemList.setItems(this.context); + this.reset(); + }, + + UP: {}, + DOWN: {}, + PAGE_UP: {}, + PAGE_DOWN: {}, + RESET: null, + + get completion() { + let str = commandline.command; + return str.substring(this.prefix.length, str.length - this.suffix.length); + }, + set completion set_completion(completion) { + this.previewClear(); + + // Change the completion text. + // The second line is a hack to deal with some substring + // preview corner cases. + commandline._commandWidget.value = this.prefix + completion + this.suffix; + this.editor.selection.focusNode.textContent = commandline._commandWidget.value; + + // Reset the caret to one position after the completion. + this.caret = this.prefix.length + completion.length; + }, + + get caret() this.editor.selection.focusOffset, + set caret(offset) { + commandline._commandWidget.selectionStart = offset; + commandline._commandWidget.selectionEnd = offset; + }, + + get start() this.context.allItems.start, + + get items() this.context.allItems.items, + + get substring() this.context.longestAllSubstring, + + get wildtype() this.wildtypes[this.wildIndex] || "", + + get type() ({ + list: this.wildmode.checkHas(this.wildtype, "list"), + longest: this.wildmode.checkHas(this.wildtype, "longest"), + first: this.wildmode.checkHas(this.wildtype, ""), + full: this.wildmode.checkHas(this.wildtype, "full") + }), + + complete: function complete(show, tabPressed) { + this.context.reset(); + this.context.tabPressed = tabPressed; + commandline.triggerCallback("complete", commandline._currentExtendedMode, this.context); + this.context.updateAsync = true; + this.reset(show, tabPressed); + this.wildIndex = 0; + }, + + 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].text; + 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.text; + break; + } + + // Don't show 1-character substrings unless we've just hit backspace + if (substring.length < 2 && (!this._lastSubstring || 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._commandWidget.value; + if (cmd.substr(cmd.length - str.length) == str) + commandline._commandWidget.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(); + this.selected = null; + this.wildIndex = 0; + } + + this.wildtypes = this.wildmode.values; + this.preview(); + }, + + _reset: function _reset() { + 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); + + 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 = util.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)) { + function done() !(idx >= n + context.items.length || idx == -2 && !context.items.length); + while (context.incomplete && !done()) + liberator.threadYield(false, true); + + 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].text; + } + + this.itemList.selectItem(idx); + }, + + tabs: [], + + tab: function tab(reverse) { + commandline._autocompleteTimer.flush(); + // Check if we need to run the completer. + if (this.context.waitingForTab || this.wildIndex == -1) + this.complete(true, true); + + this.tabs.push(reverse); + if (this.waiting) + return; + + while (this.tabs.length) { + reverse = this.tabs.shift(); + switch (this.wildtype.replace(/.*:/, "")) { + case "": + this.select(0); + break; + case "longest": + if (this.items.length > 1) { + if (this.substring && this.substring != this.completion) + this.completion = this.substring; + break; + } + // Fallthrough + case "full": + this.select(reverse ? this.UP : this.DOWN); + break; + } + + if (this.type.list) + this.itemList.show(); + + this.wildIndex = util.Math.constrain(this.wildIndex + 1, 0, this.wildtypes.length - 1); + this.preview(); + + this._statusTimer.tell(); + } + + if (this.items.length == 0) + liberator.beep(); + } + }), + + /** + * eval() 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 ""; + + try { + arg = liberator.eval(arg); + } + catch (e) { + liberator.echoerr(e); + return null; + } + + if (typeof arg === "object") + arg = util.objectToString(arg, useColor); + else if (typeof arg == "string" && /\n/.test(arg)) + arg = <span highlight="CmdOutput">{arg}</span>; + else + arg = String(arg); + + return arg; + }, +}, { + commands: function () { + [ + { + name: "ec[ho]", + description: "Echo the expression", + action: liberator.echo + }, + { + name: "echoe[rr]", + description: "Echo the expression as an error message", + action: liberator.echoerr + }, + { + name: "echom[sg]", + description: "Echo the expression as an informational message", + action: liberator.echomsg + } + ].forEach(function (command) { + commands.add([command.name], + command.description, + function (args) { + let str = CommandLine.echoArgumentToString(args.string, true); + if (str != null) + command.action(str); + }, { + completer: function (context) completion.javascript(context), + literal: 0 + }); + }); + + commands.add(["mes[sages]"], + "Display previously given messages", + function () { + // TODO: are all messages single line? Some display an aggregation + // of single line messages at least. E.g. :source + if (this._messageHistory.length == 1) { + let message = this._messageHistory.messages[0]; + commandline.echo(message.str, message.highlight, commandline.FORCE_SINGLELINE); + } + else if (this._messageHistory.length > 1) { + XML.ignoreWhitespace = false; + let list = template.map(this._messageHistory.messages, function (message) + <div highlight={message.highlight + " Message"}>{message.str}</div>); + liberator.echo(list, commandline.FORCE_MULTILINE); + } + }, + { argCount: "0" }); + + commands.add(["messc[lear]"], + "Clear the message this._history", + function () { this._messageHistory.clear(); }, + { argCount: "0" }); + + commands.add(["sil[ent]"], + "Run a command silently", + function (args) { + commandline.runSilently(function () liberator.execute(args[0], null, true)); + }, { + completer: function (context) completion.ex(context), + literal: 0 + }); + }, + mappings: function () { + var myModes = [modes.COMMAND_LINE]; + + // TODO: move "<Esc>", "<C-[>" here from mappings + mappings.add(myModes, + ["<C-c>"], "Focus content", + function () { events.onEscape(); }); + + // 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 regex in + // mappings.js) + mappings.add(myModes, + ["<Space>", '"', "'"], "Expand command line abbreviation", + function () { + commandline.resetCompletions(); + return editor.expandAbbreviation("c"); + }, + { route: true }); + + mappings.add(myModes, + ["<C-]>", "<C-5>"], "Expand command line abbreviation", + function () { editor.expandAbbreviation("c"); }); + + mappings.add([modes.NORMAL], + ["g<"], "Redisplay the last command output", + function () { + if (this._lastMowOutput) + this._echoMultiline(this._lastMowOutput, commandline.HL_NORMAL); + else + liberator.beep(); + }); + }, + options: function () { + options.add(["history", "hi"], + "Number of Ex commands and search patterns to store in the command-line this._history", + "number", 500, + { validator: function (value) value >= 0 }); + + options.add(["maxitems"], + "Maximum number of items to display at once", + "number", 20, + { validator: function (value) value >= 1 }); + + options.add(["messages", "msgs"], + "Number of messages to store in the message this._history", + "number", 100, + { validator: function (value) value >= 0 }); + + options.add(["more"], + "Pause the message list window when more than one screen of listings is displayed", + "boolean", true); + + options.add(["showmode", "smd"], + "Show the current mode in the command line", + "boolean", true); + + options.add(["suggestengines"], + "Engine Alias which has a feature of suggest", + "stringlist", "google", + { + completer: function completer(value) { + let engines = services.get("browserSearch").getEngines({}) + .filter(function (engine) engine.supportsResponseType("application/x-suggestions+json")); + + return engines.map(function (engine) [engine.alias, engine.description]); + }, + validator: Option.validateCompleter + }); + + options.add(["complete", "cpt"], + "Items which are completed at the :open prompts", + "charlist", typeof(config.defaults["complete"]) == "string" ? config.defaults["complete"] : "slf", + { + completer: function (context) array(keys(completion.urlCompleters)), + validator: Option.validateCompleter + }); + + options.add(["wildcase", "wic"], + "Completion case matching mode", + "string", "smart", + { + completer: function () [ + ["smart", "Case is significant when capital letters are typed"], + ["match", "Case is always significant"], + ["ignore", "Case is never significant"] + ], + validator: Option.validateCompleter + }); + + options.add(["wildignore", "wig"], + "List of file patterns to ignore when completing files", + "stringlist", "", + { + validator: function validator(values) { + // TODO: allow for escaping the "," + try { + RegExp("^(" + values.join("|") + ")$"); + return true; + } + catch (e) { + return false; + } + } + }); + + options.add(["wildmode", "wim"], + "Define how command line completion works", + "stringlist", "list:full", + { + completer: function (context) [ + // Why do we need ""? + ["", "Complete only the first match"], + ["full", "Complete the next full match"], + ["longest", "Complete to 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 common string"] + ], + validator: Option.validateCompleter, + checkHas: function (value, val) { + let [first, second] = value.split(":", 2); + return first == val || second == val; + } + }); + + options.add(["wildoptions", "wop"], + "Change how command line completion is done", + "stringlist", "", + { + completer: function completer(value) { + return [ + ["", "Default completion that won't show or sort the results"], + ["auto", "Automatically show this._completions while you are typing"], + ["sort", "Always sort the completion list"] + ]; + }, + validator: Option.validateCompleter + }); + }, + styles: function () { + let fontSize = util.computedStyle(document.getElementById(config.mainWindowId)).fontSize; + styles.registerSheet("chrome://liberator/skin/liberator.css"); + let error = styles.addSheet(true, "font-size", "chrome://liberator/content/buffer.xhtml", + "body { font-size: " + fontSize + "; }"); + }, +}); + + +/** + * 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. + */ +const ItemList = Class("ItemList", { + init: function (id) { + this._completionElements = []; + + var iframe = document.getElementById(id); + if (!iframe) { + liberator.log("No iframe with id: " + id + " found, strange things may happen!"); // "The truth is out there..." -- djk + return; // XXX + } + + this._doc = iframe.contentDocument; + this._container = iframe.parentNode; + + this._doc.body.id = id + "-content"; + this._doc.body.appendChild(this._doc.createTextNode("")); + this._doc.body.style.borderTop = "1px solid black"; // FIXME: For cases where this._completions/MOW are shown at once, or ls=0. Should use :highlight. + + this._gradient = template.gradient("GradientLeft", "GradientRight"); + + 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 (xml, map) util.xmlToDom(xml, this._doc, map), + + _autoSize: function () { + const self = this; + if (this._container.collapsed) + this._div.style.minWidth = document.getElementById("liberator-commandline").scrollWidth + "px"; + this._minHeight = Math.max(this._minHeight, this._divNodes.completions.getBoundingClientRect().bottom); + this._container.height = this._minHeight; + if (this._container.collapsed) + this._div.style.minWidth = ""; + // FIXME: Belongs elsewhere. + commandline.updateOutputHeight(false); + setTimeout(function () { self._container.height -= commandline.getSpaceNeeded() }, 0); + }, + + _getCompletion: function (index) this._completionElements.snapshotItem(index - this._startIndex), + + _init: function () { + 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) + <span highlight="CompItem"> + <li highlight="NonText">~</li> + </span>) + } + </div> + </div>, this._divNodes); + this._doc.body.replaceChild(this._div, this._doc.body.firstChild); + //div.scrollIntoView(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> + { this._gradient } + <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); + + setTimeout(this.closure._autoSize, 0); + }, + + /** + * Uses the entries in "items" to this._fill the listbox and does incremental + * filling to speed up things. + * + * @param {number} offset Start at this index and show options["maxitems"]. + */ + _fill: function (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) util.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 = 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 util.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"; + }, this); + + this._divNodes.noCompletions.style.display = haveCompletions ? "none" : "block"; + + this._completionElements = util.evaluateXPath("//xhtml:div[@liberator:highlight='CompItem']", this._doc); + + return true; + }, + + clear: function clear() { this.setItems(); this._doc.body.innerHTML = ""; }, + hide: function hide() { this._container.collapsed = true; }, + show: function show() { this._container.collapsed = false; }, + visible: function visible() !this._container.collapsed, + + reset: function () { + this._startIndex = this._endIndex = this._selIndex = -1; + this._div = null; + this.selectItem(-1); + }, + + // if @param selectedItem is given, show the list and select that item + setItems: function setItems(newItems, selectedItem) { + if (this._container.collapsed) + this._minHeight = 0; + this._startIndex = this._endIndex = this._selIndex = -1; + this._items = newItems; + this.reset(); + if (typeof selectedItem == "number") { + this.selectItem(selectedItem); + this.show(); + } + }, + + // select index, refill list if necessary + selectItem: function selectItem(index) { + //if (this._container.collapsed) // FIXME + // return; + + //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"); + //this._getCompletion(index).scrollIntoView(false); + } + + //if (index == 0) + // this.start = now; + //if (index == Math.min(len - 1, 100)) + // liberator.dump({ time: Date.now() - this.start }); + }, + + onEvent: function onEvent(event) false +}, { + WAITING_MESSAGE: "Generating results...", +}); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/commands.js b/common/content/commands.js index 402e8275..9b4cdd34 100644 --- a/common/content/commands.js +++ b/common/content/commands.js @@ -8,6 +8,7 @@ // Do NOT create instances of this class yourself, use the helper method // commands.add() instead + /** * A class representing Ex commands. Instances are created by * the {@link Commands} class. @@ -31,135 +32,98 @@ * @optional * @private */ -function Command(specs, description, action, extraInfo) //{{{ -{ - specs = Array.concat(specs); - - if (!extraInfo) - extraInfo = {}; - - // convert command name abbreviation specs of the form - // 'shortname[optional-tail]' to short and long versions Eg. 'abc[def]' -> - // 'abc', 'abcdef' - function parseSpecs(specs) - { - // Whoever wrote the following should be ashamed. :( - // Good grief! I have no words... -- djk ;-) - // let shortNames = longNames = names = []; - let names = []; - let longNames = []; - let shortNames = []; +const Command = Class("Command", { + init: function (specs, description, action, extraInfo) { + specs = Array.concat(specs); - for (let [, spec] in Iterator(specs)) - { - let matches = spec.match(/(\w+)\[(\w+)\](\w*)/); + if (!extraInfo) + extraInfo = {}; - if (matches) - { - shortNames.push(matches[1] + matches[3]); - longNames.push(matches[1] + matches[2] + matches[3]); - // order as long1, short1, long2, short2 - names.push(matches[1] + matches[2]); - names.push(matches[1]); - } - else - { - longNames.push(spec); - names.push(spec); - } - } - - return { names: names, longNames: longNames, shortNames: shortNames }; - }; - - let expandedSpecs = parseSpecs(specs); - /** - * @property {string[]} All of this command's name specs. e.g., "com[mand]" - */ - this.specs = specs; - /** @property {string[]} All of this command's short names, e.g., "com" */ - this.shortNames = expandedSpecs.shortNames; - /** - * @property {string[]} All of this command's long names, e.g., "command" - */ - this.longNames = expandedSpecs.longNames; - - /** @property {string} The command's canonical name. */ - this.name = this.longNames[0]; - /** @property {string[]} All of this command's long and short names. */ - this.names = expandedSpecs.names; // return all command name aliases - - /** @property {string} This command's description, as shown in :exusage */ - this.description = description || ""; - /** - * @property {function (Args)} The function called to execute this command. - */ - this.action = action; - /** - * @property {string} This command's argument count spec. - * @see Commands#parseArguments - */ - this.argCount = extraInfo.argCount || 0; - /** - * @property {function (CompletionContext, Args)} This command's completer. - * @see CompletionContext - */ - this.completer = extraInfo.completer || null; - /** @property {boolean} Whether this command accepts a here document. */ - this.hereDoc = extraInfo.hereDoc || false; - /** - * @property {Array} The options this command takes. - * @see Commands@parseArguments - */ - this.options = extraInfo.options || []; - /** - * @property {boolean} Whether this command may be called with a bang, - * e.g., :com! - */ - this.bang = extraInfo.bang || false; - /** - * @property {boolean} Whether this command may be called with a count, - * e.g., :12bdel - */ - this.count = extraInfo.count || false; - /** - * @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. - */ - this.literal = extraInfo.literal == null ? null : extraInfo.literal; - /** - * @property {function} Should return an array of <b>Object</b>s suitable - * to be passed to {@link Commands#commandToString}, one for each past - * invocation which should be restored on subsequent @liberator - * startups. - */ - this.serial = extraInfo.serial; - /** - * @property {boolean} When true, invocations of this command - * may contain private data which should be purged from - * saved histories when clearing private data. - */ - this.privateData = Boolean(extraInfo.privateData); + let expandedSpecs = Command.parseSpecs(specs); + /** + * @property {string[]} All of this command's name specs. e.g., "com[mand]" + */ + this.specs = specs; + /** @property {string[]} All of this command's short names, e.g., "com" */ + this.shortNames = expandedSpecs.shortNames; + /** + * @property {string[]} All of this command's long names, e.g., "command" + */ + this.longNames = expandedSpecs.longNames; - /** - * @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. - */ - this.user = extraInfo.user || false; - /** - * @property {string} For commands defined via :command, contains the Ex - * command line to be executed upon invocation. - */ - this.replacementText = extraInfo.replacementText || null; -} + /** @property {string} The command's canonical name. */ + this.name = this.longNames[0]; + /** @property {string[]} All of this command's long and short names. */ + this.names = expandedSpecs.names; // return all command name aliases -Command.prototype = { + /** @property {string} This command's description, as shown in :exusage */ + this.description = description || ""; + /** + * @property {function (Args)} The function called to execute this command. + */ + this.action = action; + /** + * @property {string} This command's argument count spec. + * @see Commands#parseArguments + */ + this.argCount = extraInfo.argCount || 0; + /** + * @property {function (CompletionContext, Args)} This command's completer. + * @see CompletionContext + */ + this.completer = extraInfo.completer || null; + /** @property {boolean} Whether this command accepts a here document. */ + this.hereDoc = extraInfo.hereDoc || false; + /** + * @property {Array} The options this command takes. + * @see Commands@parseArguments + */ + this.options = extraInfo.options || []; + /** + * @property {boolean} Whether this command may be called with a bang, + * e.g., :com! + */ + this.bang = extraInfo.bang || false; + /** + * @property {boolean} Whether this command may be called with a count, + * e.g., :12bdel + */ + this.count = extraInfo.count || false; + /** + * @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. + */ + this.literal = extraInfo.literal == null ? null : extraInfo.literal; + /** + * @property {function} Should return an array of <b>Object</b>s suitable + * to be passed to {@link Commands#commandToString}, one for each past + * invocation which should be restored on subsequent @liberator + * startups. + */ + this.serial = extraInfo.serial; + /** + * @property {boolean} When true, invocations of this command + * may contain private data which should be purged from + * saved histories when clearing private data. + */ + this.privateData = Boolean(extraInfo.privateData); + /** + * @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. + */ + this.user = extraInfo.user || false; + /** + * @property {string} For commands defined via :command, contains the Ex + * command line to be executed upon invocation. + */ + this.replacementText = extraInfo.replacementText || null; + }, /** * Execute this command. * @@ -173,16 +137,14 @@ Command.prototype = { * @deprecated * @param {Object} modifiers Any modifiers to be passed to {@link #action}. */ - execute: function (args, bang, count, modifiers) - { + execute: function (args, bang, count, modifiers) { // XXX bang = !!bang; count = (count === undefined) ? -1 : count; modifiers = modifiers || {}; let self = this; - function exec(args) - { + function exec(args) { // FIXME: Move to parseCommand? args = self.parseArgs(args); if (!args) @@ -192,11 +154,9 @@ Command.prototype = { liberator.trapErrors(self.action, self, args, modifiers) } - if (this.hereDoc) - { + if (this.hereDoc) { let matches = args.match(/(.*)<<\s*(\S+)$/); - if (matches && matches[2]) - { + if (matches && matches[2]) { commandline.inputMultiline(RegExp("^" + matches[2] + "$", "m"), function (args) { exec(matches[1] + "\n" + args); }); return; @@ -212,10 +172,8 @@ Command.prototype = { * @param {string} name The candidate name. * @returns {boolean} */ - hasName: function (name) - { - for (let [, spec] in Iterator(this.specs)) - { + hasName: function (name) { + for (let [, spec] in Iterator(this.specs)) { let fullName = spec.replace(/\[(\w+)]$/, "$1"); let index = spec.indexOf("["); let min = index == -1 ? fullName.length : index; @@ -241,735 +199,667 @@ Command.prototype = { */ parseArgs: function (args, complete, extra) commands.parseArgs(args, this.options, this.argCount, false, this.literal, complete, extra) -}; //}}} +}, { -/** - * @instance commands - */ -function Commands() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + /** + * convert command name abbreviation specs of the form + * 'shortname[optional-tail]' to short and long versions Eg. 'abc[def]' -> + * 'abc', 'abcdef' + */ + parseSpecs: function (specs) { + // Whoever wrote the following should be ashamed. :( + // Good grief! I have no words... -- djk ;-) + // let shortNames = longNames = names = []; + let names = []; + let longNames = []; + let shortNames = []; - var exCommands = []; + for (let [, spec] in Iterator(specs)) { + let matches = spec.match(/(\w+)\[(\w+)\](\w*)/); - const QUOTE_STYLE = "vimperator"; + if (matches) { + shortNames.push(matches[1] + matches[3]); + longNames.push(matches[1] + matches[2] + matches[3]); + // order as long1, short1, long2, short2 + names.push(matches[1] + matches[2]); + names.push(matches[1]); + } + else { + longNames.push(spec); + names.push(spec); + } + } - const quoteMap = { - "\n": "n", - "\t": "t" - }; - function quote(q, list) - { - let re = RegExp("[" + list + "]", "g"); - return function (str) q + String.replace(str, re, function ($0) $0 in quoteMap ? quoteMap[$0] : ("\\" + $0)) + q; - } - const complQuote = { // FIXME - '"': ['"', quote("", '\n\t"\\\\'), '"'], - "'": ["'", quote("", "\\\\'"), "'"], - "": ["", quote("", "\\\\ "), ""] - }; - const quoteArg = { - '"': quote('"', '\n\t"\\\\'), - "'": quote("'", "\\\\'"), - "": quote("", "\\\\ ") - }; + return { names: names, longNames: longNames, shortNames: shortNames }; + }, +}); - function parseBool(arg) - { - if (/^(true|1|on)$/i.test(arg)) - return true; - if (/^(false|0|off)$/i.test(arg)) - return false; - return NaN; - } - const ArgType = new Struct("description", "parse"); - const argTypes = [ - null, - ArgType("no arg", function (arg) !arg || null), - ArgType("boolean", parseBool), - ArgType("string", function (val) val), - ArgType("int", parseInt), - ArgType("float", parseFloat), - ArgType("list", function (arg) arg && arg.split(/\s*,\s*/)) - ]; +/** + * @instance commands + */ +const ArgType = new Struct("description", "parse"); +const Commands = Module("commands", { + init: function () { + this._exCommands = []; + }, - // returns [count, parsed_argument] - function parseArg(str) - { - let arg = ""; - let quote = null; - let len = str.length; + // FIXME: remove later, when our option handler is better + /** + * @property {number} The option argument is unspecified. Any argument + * is accepted and caller is responsible for parsing the return + * value. + * @final + */ + OPTION_ANY: 0, - while (str.length && !/^\s/.test(str)) - { - let res; + /** + * @property {number} The option doesn't accept an argument. + * @final + */ + OPTION_NOARG: 1, + /** + * @property {number} The option accepts a boolean argument. + * @final + */ + OPTION_BOOL: 2, + /** + * @property {number} The option accepts a string argument. + * @final + */ + OPTION_STRING: 3, + /** + * @property {number} The option accepts an integer argument. + * @final + */ + OPTION_INT: 4, + /** + * @property {number} The option accepts a float argument. + * @final + */ + OPTION_FLOAT: 5, + /** + * @property {number} The option accepts a string list argument. + * E.g. "foo,bar" + * @final + */ + OPTION_LIST: 6, - switch (QUOTE_STYLE) - { - case "vim-sucks": - if (res = str.match = str.match(/^()((?:[^\\\s]|\\.)+)((?:\\$)?)/)) - arg += res[2].replace(/\\(.)/g, "$1"); - break; - - case "vimperator": - if (res = str.match(/^()((?:[^\\\s"']|\\.)+)((?:\\$)?)/)) - arg += res[2].replace(/\\(.)/g, "$1"); - else if (res = str.match(/^(")((?:[^\\"]|\\.)*)("?)/)) - arg += eval(res[0] + (res[3] ? "" : '"')); - else if (res = str.match(/^(')((?:[^\\']|\\.)*)('?)/)) - arg += res[2].replace(/\\(.)/g, function (n0, n1) /[\\']/.test(n1) ? n1 : n0); - break; - - case "rc-ish": - if (res = str.match = str.match(/^()((?:[^\\\s"']|\\.)+)((?:\\$)?)/)) - arg += res[2].replace(/\\(.)/g, "$1"); - else if (res = str.match(/^(")((?:[^\\"]|\\.)*)("?)/)) - arg += eval(res[0] + (res[3] ? "" : '"')); - else if (res = str.match(/^(')((?:[^']|'')*)('?)/)) - arg += res[2].replace("''", "'", "g"); - break; - - case "pythonesque": - if (res = str.match = str.match(/^()((?:[^\\\s"']|\\.)+)((?:\\$)?)/)) - arg += res[2].replace(/\\(.)/g, "$1"); - else if (res = str.match(/^(""")((?:.?.?[^"])*)((?:""")?)/)) - arg += res[2]; - else if (res = str.match(/^(")((?:[^\\"]|\\.)*)("?)/)) - arg += eval(res[0] + (res[3] ? "" : '"')); - else if (res = str.match(/^(')((?:[^\\']|\\.)*)('?)/)) - arg += res[2].replace(/\\(.)/g, function (n0, n1) /[\\']/.test(n1) ? n1 : n0); - break; - } + /** + * @property {number} Indicates that no count was specified for this + * command invocation. + * @final + */ + COUNT_NONE: -1, + /** + * @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, // :%... - if (!res) - break; - if (!res[3]) - quote = res[1]; - if (!res[1]) - quote = res[3]; - str = str.substr(res[0].length); - } + /** @property {Iterator(Command)} @private */ + __iterator__: function () { + let sorted = this._exCommands.sort(function (a, b) a.name > b.name); + return util.Array.itervalues(sorted); + }, - return [len - str.length, arg, quote]; - } + /** @property {string} The last executed Ex command line. */ + repeat: null, - function addCommand(command, replace) - { - if (exCommands.some(function (c) c.hasName(command.name))) - { + _addCommand: function (command, replace) { + if (this._exCommands.some(function (c) c.hasName(command.name))) { if (command.user && replace) commands.removeUserCommand(command.name); - else - { + else { liberator.log("Warning: :" + command.name + " already exists, NOT replacing existing command.", 1); return false; } } - exCommands.push(command); + this._exCommands.push(command); return true; - } + }, - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + /** + * Adds a new default command. + * + * @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 (names, description, action, extra) { + return this._addCommand(new Command(names, description, action, extra), false); + }, - const self = { + /** + * Adds a new user-defined command. + * + * @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. + * @param {boolean} replace Overwrite an existing command with the same + * canonical name. + */ + addUserCommand: function (names, description, action, extra, replace) { + extra = extra || {}; + extra.user = true; + description = description || "User defined command"; - // FIXME: remove later, when our option handler is better - /** - * @property {number} The option argument is unspecified. Any argument - * is accepted and caller is responsible for parsing the return - * value. - * @final - */ - OPTION_ANY: 0, + return this._addCommand(new Command(names, description, action, extra), replace); + }, - /** - * @property {number} The option doesn't accept an argument. - * @final - */ - OPTION_NOARG: 1, - /** - * @property {number} The option accepts a boolean argument. - * @final - */ - OPTION_BOOL: 2, - /** - * @property {number} The option accepts a string argument. - * @final - */ - OPTION_STRING: 3, - /** - * @property {number} The option accepts an integer argument. - * @final - */ - OPTION_INT: 4, - /** - * @property {number} The option accepts a float argument. - * @final - */ - OPTION_FLOAT: 5, - /** - * @property {number} The option accepts a string list argument. - * E.g. "foo,bar" - * @final - */ - OPTION_LIST: 6, + /** + * Returns the specified command invocation object serialized to + * an executable Ex command string. + * + * @param {Object} args The command invocation object. + * @returns {string} + */ + commandToString: function (args) { + let res = [args.command + (args.bang ? "!" : "")]; + function quote(str) Commands.quoteArg[/[\s"'\\]|^$/.test(str) ? '"' : ""](str); + + for (let [opt, val] in Iterator(args.options || {})) { + let char = /^-.$/.test(opt) ? " " : "="; + if (val != null) + opt += char + quote(val) + res.push(opt); + } + for (let [, arg] in Iterator(args.arguments || [])) + res.push(quote(arg)); - /** - * @property {number} Indicates that no count was specified for this - * command invocation. - * @final - */ - COUNT_NONE: -1, - /** - * @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, // :%... + let str = args.literalArg; + if (str) + res.push(/\n/.test(str) ? "<<EOF\n" + str.replace(/\n$/, "") + "\nEOF" : str); + return res.join(" "); + }, - /** @property {Iterator(Command)} @private */ - __iterator__: function () - { - let sorted = exCommands.sort(function (a, b) a.name > b.name); - return util.Array.itervalues(sorted); - }, + /** + * Returns the command with matching <b>name</b>. + * + * @param {string} name The name of the command to return. This can be + * any of the command's names. + * @returns {Command} + */ + get: function (name) { + return this._exCommands.filter(function (cmd) cmd.hasName(name))[0] || null; + }, - /** @property {string} The last executed Ex command line. */ - repeat: null, + /** + * Returns the user-defined command with matching <b>name</b>. + * + * @param {string} name The name of the command to return. This can be + * any of the command's names. + * @returns {Command} + */ + getUserCommand: function (name) { + return this._exCommands.filter(function (cmd) cmd.user && cmd.hasName(name))[0] || null; + }, - /** - * Adds a new default command. - * - * @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 (names, description, action, extra) - { - return addCommand(new Command(names, description, action, extra), false); - }, + /** + * Returns all user-defined commands. + * + * @returns {Command[]} + */ + getUserCommands: function () { + return this._exCommands.filter(function (cmd) cmd.user); + }, - /** - * Adds a new user-defined command. - * - * @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. - * @param {boolean} replace Overwrite an existing command with the same - * canonical name. - */ - addUserCommand: function (names, description, action, extra, replace) - { - extra = extra || {}; - extra.user = true; - description = description || "User defined command"; + // 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 <b>str</b> for options and plain arguments. + * + * The returned <b>Args</b> object is an augmented array of arguments. + * Any key/value pairs of <b>extra</b> will be available and the + * following additional properties: + * -opt - the value of the option -opt if specified + * string - the original argument string <b>str</b> + * 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 {Array} options The options accepted. These are specified as + * an array [names, type, validator, completions, multiple]. + * names - an array of option names. The first name is the + * canonical option name. + * type - the option's value type. This is one of: + * (@link Commands#OPTION_NOARG), + * (@link Commands#OPTION_STRING), + * (@link Commands#OPTION_BOOL), + * (@link Commands#OPTION_INT), + * (@link Commands#OPTION_FLOAT), + * (@link Commands#OPTION_LIST), + * (@link Commands#OPTION_ANY) + * validator - a validator function + * completer - a list of completions, or a completion function + * multiple - whether this option can be specified multiple times + * E.g. + * options = [[["-force"], OPTION_NOARG], + * [["-fullscreen", "-f"], OPTION_BOOL], + * [["-language"], OPTION_STRING, validateFunc, ["perl", "ruby"]], + * [["-speed"], OPTION_INT], + * [["-acceleration"], OPTION_FLOAT], + * [["-accessories"], OPTION_LIST, null, ["foo", "bar"], true], + * [["-other"], OPTION_ANY]]; + * @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 (str, options, argCount, allowUnknownOptions, literal, complete, extra) { + function getNextArg(str) { + let [count, arg, quote] = Commands.parseArg(str); + if (quote == "\\" && !complete) + return [,,,"Trailing \\"]; + if (quote && !complete) + return [,,,"E114: Missing quote: " + quote]; + return [count, arg, quote]; + } - return addCommand(new Command(names, description, action, extra), replace); - }, + if (!options) + options = []; - /** - * Returns the specified command invocation object serialized to - * an executable Ex command string. - * - * @param {Object} args The command invocation object. - * @returns {string} - */ - commandToString: function (args) - { - let res = [args.command + (args.bang ? "!" : "")]; - function quote(str) quoteArg[/[\s"'\\]|^$/.test(str) ? '"' : ""](str); - - for (let [opt, val] in Iterator(args.options || {})) - { - let char = /^-.$/.test(opt) ? " " : "="; - if (val != null) - opt += char + quote(val) - res.push(opt); - } - for (let [, arg] in Iterator(args.arguments || [])) - res.push(quote(arg)); + if (!argCount) + argCount = "*"; - let str = args.literalArg; - if (str) - res.push(/\n/.test(str) ? "<<EOF\n" + str.replace(/\n$/, "") + "\nEOF" : str); - return res.join(" "); - }, + var args = []; // parsed options + args.__iterator__ = function () util.Array.iteritems(this); + args.string = str; // for access to the unparsed string + args.literalArg = ""; - /** - * Returns the command with matching <b>name</b>. - * - * @param {string} name The name of the command to return. This can be - * any of the command's names. - * @returns {Command} - */ - get: function (name) - { - return exCommands.filter(function (cmd) cmd.hasName(name))[0] || null; - }, + // FIXME! + for (let [k, v] in Iterator(extra || [])) + args[k] = v; - /** - * Returns the user-defined command with matching <b>name</b>. - * - * @param {string} name The name of the command to return. This can be - * any of the command's names. - * @returns {Command} - */ - getUserCommand: function (name) - { - return exCommands.filter(function (cmd) cmd.user && cmd.hasName(name))[0] || null; - }, + var invalid = false; + // FIXME: best way to specify these requirements? + var onlyArgumentsRemaining = allowUnknownOptions || options.length == 0 || false; // after a -- has been found + var arg = null; + var count = 0; // the length of the argument + var i = 0; + var completeOpts; - /** - * Returns all user-defined commands. - * - * @returns {Command[]} - */ - getUserCommands: function () - { - return exCommands.filter(function (cmd) cmd.user); - }, - - // 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 <b>str</b> for options and plain arguments. - * - * The returned <b>Args</b> object is an augmented array of arguments. - * Any key/value pairs of <b>extra</b> will be available and the - * following additional properties: - * -opt - the value of the option -opt if specified - * string - the original argument string <b>str</b> - * 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 {Array} options The options accepted. These are specified as - * an array [names, type, validator, completions, multiple]. - * names - an array of option names. The first name is the - * canonical option name. - * type - the option's value type. This is one of: - * (@link Commands#OPTION_NOARG), - * (@link Commands#OPTION_STRING), - * (@link Commands#OPTION_BOOL), - * (@link Commands#OPTION_INT), - * (@link Commands#OPTION_FLOAT), - * (@link Commands#OPTION_LIST), - * (@link Commands#OPTION_ANY) - * validator - a validator function - * completer - a list of completions, or a completion function - * multiple - whether this option can be specified multiple times - * E.g. - * options = [[["-force"], OPTION_NOARG], - * [["-fullscreen", "-f"], OPTION_BOOL], - * [["-language"], OPTION_STRING, validateFunc, ["perl", "ruby"]], - * [["-speed"], OPTION_INT], - * [["-acceleration"], OPTION_FLOAT], - * [["-accessories"], OPTION_LIST, null, ["foo", "bar"], true], - * [["-other"], OPTION_ANY]]; - * @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 (str, options, argCount, allowUnknownOptions, literal, complete, extra) - { - function getNextArg(str) - { - let [count, arg, quote] = parseArg(str); - if (quote == "\\" && !complete) - return [,,,"Trailing \\"]; - if (quote && !complete) - return [,,,"E114: Missing quote: " + quote]; - return [count, arg, quote]; - } + // XXX + function matchOpts(arg) { + // Push possible option matches into completions + if (complete && !onlyArgumentsRemaining) + completeOpts = [[opt[0], opt[0][0]] for ([i, opt] in Iterator(options)) if (!(opt[0][0] in args))]; + } + 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; + } + + function echoerr(error) { + if (complete) + complete.message = error; + else + liberator.echoerr(error); + } + + outer: + while (i < str.length || complete) { + // skip whitespace + while (/\s/.test(str[i]) && i < str.length) + i++; + if (i == str.length && !complete) + break; - if (!options) - options = []; - - if (!argCount) - argCount = "*"; - - var args = []; // parsed options - args.__iterator__ = function () util.Array.iteritems(this); - args.string = str; // for access to the unparsed string - args.literalArg = ""; - - // FIXME! - for (let [k, v] in Iterator(extra || [])) - args[k] = v; - - var invalid = false; - // FIXME: best way to specify these requirements? - var onlyArgumentsRemaining = allowUnknownOptions || options.length == 0 || false; // after a -- has been found - var arg = null; - var count = 0; // the length of the argument - var i = 0; - var completeOpts; - - // XXX - function matchOpts(arg) - { - // Push possible option matches into completions - if (complete && !onlyArgumentsRemaining) - completeOpts = [[opt[0], opt[0][0]] for ([i, opt] in Iterator(options)) if (!(opt[0][0] in args))]; - } - function resetCompletions() - { - completeOpts = null; - args.completeArg = null; - args.completeOpt = null; - args.completeFilter = null; - args.completeStart = i; - args.quote = complQuote[""]; - } if (complete) - { resetCompletions(); - matchOpts(""); - args.completeArg = 0; - } - function echoerr(error) - { - if (complete) - complete.message = error; - else - liberator.echoerr(error); + var sub = str.substr(i); + if ((!onlyArgumentsRemaining) && /^--(\s|$)/.test(sub)) { + onlyArgumentsRemaining = true; + i += 2; + continue; } - outer: - while (i < str.length || complete) - { - // skip whitespace - while (/\s/.test(str[i]) && i < str.length) - i++; - if (i == str.length && !complete) - break; + var optname = ""; + if (!onlyArgumentsRemaining) { + for (let [, opt] in Iterator(options)) { + for (let [, optname] in Iterator(opt[0])) { + if (sub.indexOf(optname) == 0) { + invalid = false; + arg = null; + quote = null; + count = 0; + let sep = sub[optname.length]; + if (sep == "=" || /\s/.test(sep) && opt[1] != this.OPTION_NOARG) { + [count, arg, quote, error] = getNextArg(sub.substr(optname.length + 1)); + liberator.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; - if (complete) - resetCompletions(); - - var sub = str.substr(i); - if ((!onlyArgumentsRemaining) && /^--(\s|$)/.test(sub)) - { - onlyArgumentsRemaining = true; - i += 2; - continue; - } + let context = null; + if (!complete && quote) { + liberator.echoerr("Invalid argument for option " + optname); + return null; + } - var optname = ""; - if (!onlyArgumentsRemaining) - { - for (let [, opt] in Iterator(options)) - { - for (let [, optname] in Iterator(opt[0])) - { - if (sub.indexOf(optname) == 0) - { - invalid = false; - arg = null; - quote = null; - count = 0; - let sep = sub[optname.length]; - if (sep == "=" || /\s/.test(sep) && opt[1] != this.OPTION_NOARG) - { - [count, arg, quote, error] = getNextArg(sub.substr(optname.length + 1)); - liberator.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) - { - liberator.echoerr("Invalid argument for option " + optname); - return null; + if (!invalid) { + if (complete && count > 0) { + args.completeStart += optname.length + 1; + args.completeOpt = opt; + args.completeFilter = arg; + args.quote = Commands.complQuote[quote] || Commands.complQuote[""]; } - - if (!invalid) - { - if (complete && count > 0) - { - args.completeStart += optname.length + 1; - args.completeOpt = opt; - args.completeFilter = arg; - args.quote = complQuote[quote] || complQuote[""]; - } - let type = argTypes[opt[1]]; - if (type && (!complete || arg != null)) - { - let orig = arg; - arg = type.parse(arg); - if (arg == null || (typeof arg == "number" && isNaN(arg))) - { - if (!complete || orig != "" || args.completeStart != str.length) - echoerr("Invalid argument for " + type.description + " option: " + optname); - if (complete) - complete.highlight(args.completeStart, count - 1, "SPELLCHECK"); - else - return null; - } + let type = Commands.argTypes[opt[1]]; + if (type && (!complete || arg != null)) { + let orig = arg; + arg = type.parse(arg); + if (arg == null || (typeof arg == "number" && isNaN(arg))) { + if (!complete || orig != "" || args.completeStart != str.length) + echoerr("Invalid argument for " + type.description + " option: " + optname); + if (complete) + complete.highlight(args.completeStart, count - 1, "SPELLCHECK"); + else + return null; } + } - // we have a validator function - if (typeof opt[2] == "function") - { - if (opt[2].call(this, arg) == false) - { - echoerr("Invalid argument for option: " + optname); - if (complete) - complete.highlight(args.completeStart, count - 1, "SPELLCHECK"); - else - return null; - } + // we have a validator function + if (typeof opt[2] == "function") { + if (opt[2].call(this, arg) == false) { + echoerr("Invalid argument for option: " + optname); + if (complete) + complete.highlight(args.completeStart, count - 1, "SPELLCHECK"); + else + return null; } + } - // option allowed multiple times - if (!!opt[4]) - args[opt[0][0]] = (args[opt[0][0]] || []).concat(arg); - else - args[opt[0][0]] = opt[1] == this.OPTION_NOARG || arg; + // option allowed multiple times + if (!!opt[4]) + args[opt[0][0]] = (args[opt[0][0]] || []).concat(arg); + else + args[opt[0][0]] = opt[1] == this.OPTION_NOARG || arg; - i += optname.length + count; - if (i == str.length) - break outer; - continue outer; - } - // if it is invalid, just fall through and try the next argument + 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; - args.literalArg = sub; - args.push(sub); - args.quote = null; - break; - } + matchOpts(sub); - // if not an option, treat this token as an argument - let [count, arg, quote, error] = getNextArg(sub); - liberator.assert(!error, error); + if (complete) { + if (argCount == "0" || args.length > 0 && (/[1?]/.test(argCount))) + complete.highlight(i, sub.length, "SPELLCHECK"); + } + if (args.length == literal) { if (complete) - { - args.quote = complQuote[quote] || complQuote[""]; - args.completeFilter = arg || ""; - } - else if (count == -1) - { - liberator.echoerr("Error parsing arguments: " + arg); - return null; - } - else if (!onlyArgumentsRemaining && /^-/.test(arg)) - { - liberator.echoerr("Invalid option: " + arg); - return null; - } + args.completeArg = args.length; + args.literalArg = sub; + args.push(sub); + args.quote = null; + break; + } - if (arg != null) - args.push(arg); - if (complete) - args.completeArg = args.length - 1; + // if not an option, treat this token as an argument + let [count, arg, quote, error] = getNextArg(sub); + liberator.assert(!error, error); - i += count; - if (count <= 0 || i == str.length) - break; + if (complete) { + args.quote = Commands.complQuote[quote] || Commands.complQuote[""]; + args.completeFilter = arg || ""; + } + else if (count == -1) { + liberator.echoerr("Error parsing arguments: " + arg); + return null; + } + else if (!onlyArgumentsRemaining && /^-/.test(arg)) { + liberator.echoerr("Invalid option: " + arg); + return null; } + if (arg != null) + args.push(arg); if (complete) - { - if (args.completeOpt) - { - let opt = args.completeOpt; - let context = complete.fork(opt[0][0], args.completeStart); - context.filter = args.completeFilter; - if (typeof opt[3] == "function") - var compl = opt[3](context, args); - else - compl = opt[3] || []; - context.title = [opt[0][0]]; - context.quote = args.quote; - context.completions = compl; - } - complete.advance(args.completeStart); - complete.title = ["Options"]; - if (completeOpts) - complete.completions = completeOpts; - } + args.completeArg = args.length - 1; - // check for correct number of arguments - if (args.length == 0 && /^[1+]$/.test(argCount) || - literal != null && /[1+]/.test(argCount) && !/\S/.test(args.literalArg || "")) - { - if (!complete) - { - liberator.echoerr("E471: Argument required"); - return null; - } + i += count; + if (count <= 0 || i == str.length) + break; + } + + if (complete) { + if (args.completeOpt) { + let opt = args.completeOpt; + let context = complete.fork(opt[0][0], args.completeStart); + context.filter = args.completeFilter; + if (typeof opt[3] == "function") + var compl = opt[3](context, args); + else + compl = opt[3] || []; + context.title = [opt[0][0]]; + context.quote = args.quote; + context.completions = compl; } - else if (args.length == 1 && (argCount == "0") || - args.length > 1 && /^[01?]$/.test(argCount)) - { - echoerr("E488: Trailing characters"); + complete.advance(args.completeStart); + complete.title = ["Options"]; + if (completeOpts) + complete.completions = completeOpts; + } + + // check for correct number of arguments + if (args.length == 0 && /^[1+]$/.test(argCount) || + literal != null && /[1+]/.test(argCount) && !/\S/.test(args.literalArg || "")) { + if (!complete) { + liberator.echoerr("E471: Argument required"); return null; } + } + else if (args.length == 1 && (argCount == "0") || + args.length > 1 && /^[01?]$/.test(argCount)) { + echoerr("E488: Trailing characters"); + return null; + } - return args; - }, + return args; + }, - /** - * 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 (str) - { - // remove comments - str.replace(/\s*".*$/, ""); - - // 0 - count, 1 - cmd, 2 - special, 3 - args - let matches = str.match(/^[:\s]*(\d+|%)?([a-zA-Z]+|!)(!)?(?:\s*(.*?))?$/); - //var matches = str.match(/^:*(\d+|%)?([a-zA-Z]+|!)(!)?(?:\s*(.*?)\s*)?$/); - if (!matches) - return [null, null, null, null]; - - let [, count, cmd, special, args] = matches; - - // parse count - if (count) - count = count == "%" ? this.COUNT_ALL: parseInt(count, 10); - else - count = this.COUNT_NONE; + /** + * 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 (str) { + // remove comments + str.replace(/\s*".*$/, ""); + + // 0 - count, 1 - cmd, 2 - special, 3 - args + let matches = str.match(/^[:\s]*(\d+|%)?([a-zA-Z]+|!)(!)?(?:\s*(.*?))?$/); + //var matches = str.match(/^:*(\d+|%)?([a-zA-Z]+|!)(!)?(?:\s*(.*?)\s*)?$/); + if (!matches) + return [null, null, null, null]; + + let [, count, cmd, special, args] = matches; + + // parse count + if (count) + count = count == "%" ? this.COUNT_ALL: parseInt(count, 10); + else + count = this.COUNT_NONE; + + return [count, cmd, !!special, args || ""]; + }, - return [count, cmd, !!special, args || ""]; - }, + /** @property */ + get complQuote() Commands.complQuote, - /** @property */ - get complQuote() complQuote, + /** @property */ + get quoteArg() Commands.quoteArg, // XXX: better somewhere else? - /** @property */ - get quoteArg() quoteArg, // XXX: better somewhere else? + /** + * Remove the user-defined command with matching <b>name</b>. + * + * @param {string} name The name of the command to remove. This can be + * any of the command's names. + */ + removeUserCommand: function (name) { + this._exCommands = this._exCommands.filter(function (cmd) !(cmd.user && cmd.hasName(name))); + }, - /** - * Remove the user-defined command with matching <b>name</b>. - * - * @param {string} name The name of the command to remove. This can be - * any of the command's names. - */ - removeUserCommand: function (name) - { - exCommands = exCommands.filter(function (cmd) !(cmd.user && cmd.hasName(name))); - }, + // FIXME: still belong here? Also used for autocommand parameters. + /** + * Returns a string with all tokens in <b>string</b> matching "<key>" + * replaced with "value". Where "key" is a property of the specified + * <b>tokens</b> object and "value" is the corresponding value. The + * <lt> token can be used to include a literal "<" in the returned + * string. Any tokens prefixed with "q-" will be quoted except for + * <q-lt> which is treated like <lt>. + * + * @param {string} str The string with tokens to replace. + * @param {Object} tokens A map object whose keys are replaced with its + * values. + * @returns {string} + */ + replaceTokens: function replaceTokens(str, tokens) { + return str.replace(/<((?:q-)?)([a-zA-Z]+)?>/g, function (match, quote, token) { + if (token == "lt") // Don't quote, as in Vim (but, why so in Vim? You'd think people wouldn't say <q-lt> if they didn't want it) + return "<"; + let res = tokens[token]; + if (res == undefined) // Ignore anything undefined + res = "<" + token + ">"; + if (quote && typeof res != "number") + return Commands.quoteArg['"'](res); + return res; + }); + } +}, { + QUOTE_STYLE: "rc-ish", - // FIXME: still belong here? Also used for autocommand parameters. - /** - * Returns a string with all tokens in <b>string</b> matching "<key>" - * replaced with "value". Where "key" is a property of the specified - * <b>tokens</b> object and "value" is the corresponding value. The - * <lt> token can be used to include a literal "<" in the returned - * string. Any tokens prefixed with "q-" will be quoted except for - * <q-lt> which is treated like <lt>. - * - * @param {string} str The string with tokens to replace. - * @param {Object} tokens A map object whose keys are replaced with its - * values. - * @returns {string} - */ - replaceTokens: function replaceTokens(str, tokens) - { - return str.replace(/<((?:q-)?)([a-zA-Z]+)?>/g, function (match, quote, token) { - if (token == "lt") // Don't quote, as in Vim (but, why so in Vim? You'd think people wouldn't say <q-lt> if they didn't want it) - return "<"; - let res = tokens[token]; - if (res == undefined) // Ignore anything undefined - res = "<" + token + ">"; - if (quote && typeof res != "number") - return quoteArg['"'](res); - return res; - }); - } - }; + // returns [count, parsed_argument] + parseArg: function (str) { + let arg = ""; + let quote = null; + let len = str.length; + + while (str.length && !/^\s/.test(str)) { + let res; + + switch (Commands.QUOTE_STYLE) { + case "vim-sucks": + if (res = str.match = str.match(/^()((?:[^\\\s]|\\.)+)((?:\\$)?)/)) + arg += res[2].replace(/\\(.)/g, "$1"); + break; + + case "vimperator": + if (res = str.match(/^()((?:[^\\\s"']|\\.)+)((?:\\$)?)/)) + arg += res[2].replace(/\\(.)/g, "$1"); + else if (res = str.match(/^(")((?:[^\\"]|\\.)*)("?)/)) + arg += eval(res[0] + (res[3] ? "" : '"')); + else if (res = str.match(/^(')((?:[^\\']|\\.)*)('?)/)) + arg += res[2].replace(/\\(.)/g, function (n0, n1) /[\\']/.test(n1) ? n1 : n0); + break; - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + case "rc-ish": + if (res = str.match = str.match(/^()((?:[^\\\s"']|\\.)+)((?:\\$)?)/)) + arg += res[2].replace(/\\(.)/g, "$1"); + else if (res = str.match(/^(")((?:[^\\"]|\\.)*)("?)/)) + arg += eval(res[0] + (res[3] ? "" : '"')); + else if (res = str.match(/^(')((?:[^']|'')*)('?)/)) + arg += res[2].replace("''", "'", "g"); + break; + + case "pythonesque": + if (res = str.match = str.match(/^()((?:[^\\\s"']|\\.)+)((?:\\$)?)/)) + arg += res[2].replace(/\\(.)/g, "$1"); + else if (res = str.match(/^(""")((?:.?.?[^"])*)((?:""")?)/)) + arg += res[2]; + else if (res = str.match(/^(")((?:[^\\"]|\\.)*)("?)/)) + arg += eval(res[0] + (res[3] ? "" : '"')); + else if (res = str.match(/^(')((?:[^\\']|\\.)*)('?)/)) + arg += res[2].replace(/\\(.)/g, function (n0, n1) /[\\']/.test(n1) ? n1 : n0); + break; + } - liberator.registerObserver("load_mappings", function () { + if (!res) + 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]; + }, +}, { + mappings: function () { mappings.add(config.browserModes, ["@:"], "Repeat the last Ex command", - function (count) - { - if (commands.repeat) - { + function (count) { + if (commands.repeat) { for (let i in util.interruptibleRange(0, Math.max(count, 1), 100)) liberator.execute(commands.repeat); } @@ -977,203 +867,9 @@ function Commands() //{{{ liberator.echoerr("E30: No previous command line"); }, { count: true }); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - function userCommand(args, modifiers) - { - let tokens = { - args: this.argCount && args.string, - bang: this.bang && args.bang ? "!" : "", - count: this.count && args.count - }; - - liberator.execute(commands.replaceTokens(this.replacementText, tokens)); - } - - // TODO: offer completion.ex? - // : make this config specific - var completeOptionMap = { - abbreviation: "abbreviation", altstyle: "alternateStyleSheet", - bookmark: "bookmark", buffer: "buffer", color: "colorScheme", - command: "command", dialog: "dialog", dir: "directory", - environment: "environment", event: "autocmdEvent", file: "file", - help: "help", highlight: "highlightGroup", javascript: "javascript", - macro: "macro", mapping: "userMapping", menu: "menuItem", - option: "option", preference: "preference", search: "search", - shellcmd: "shellCommand", sidebar: "sidebar", url: "url", - usercommand: "userCommand" - }; - - // TODO: Vim allows commands to be defined without {rep} if there are {attr}s - // specified - useful? - self.add(["com[mand]"], - "List and define commands", - function (args) - { - let cmd = args[0]; - - liberator.assert(!/\W/.test(cmd || ''), "E182: Invalid command name"); - - if (args.literalArg) - { - let nargsOpt = args["-nargs"] || "0"; - let bangOpt = "-bang" in args; - let countOpt = "-count" in args; - let descriptionOpt = args["-description"] || "User-defined command"; - let completeOpt = args["-complete"]; - - let completeFunc = null; // default to no completion for user commands - - if (completeOpt) - { - if (/^custom,/.test(completeOpt)) - { - completeOpt = completeOpt.substr(7); - completeFunc = function () - { - try - { - var completer = liberator.eval(completeOpt); - - if (!(completer instanceof Function)) - throw new TypeError("User-defined custom completer '" + completeOpt + "' is not a function"); - } - catch (e) - { - liberator.echo(":" + this.name + " ..."); - liberator.echoerr("E117: Unknown function: " + completeOpt); - liberator.log(e); - return undefined; - } - return completer.apply(this, Array.slice(arguments)); - }; - } - else - completeFunc = completion[completeOptionMap[completeOpt]]; - } - - let added = commands.addUserCommand([cmd], - descriptionOpt, - userCommand, - { - argCount: nargsOpt, - bang: bangOpt, - count: countOpt, - completer: completeFunc, - replacementText: args.literalArg - }, args.bang); - - if (!added) - liberator.echoerr("E174: Command already exists: add ! to replace it"); - } - else - { - function completerToString(completer) - { - if (completer) - return [k for ([k, v] in Iterator(completeOptionMap)) if (completer == completion[v])][0] || "custom"; - else - return ""; - } - - // TODO: using an array comprehension here generates flakey results across repeated calls - // : perhaps we shouldn't allow options in a list call but just ignore them for now - let cmds = exCommands.filter(function (c) c.user && (!cmd || c.name.match("^" + cmd))); - - if (cmds.length > 0) - { - let str = template.tabular(["", "Name", "Args", "Range", "Complete", "Definition"], ["padding-right: 2em;"], - ([cmd.bang ? "!" : " ", - cmd.name, - cmd.argCount, - cmd.count ? "0c" : "", - completerToString(cmd.completer), - cmd.replacementText || "function () { ... }"] - for ([, cmd] in Iterator(cmds)))); - - commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - } - else - liberator.echomsg("No user-defined commands found"); - } - }, - { - bang: true, - completer: function (context, args) - { - if (args.completeArg == 0) - completion.userCommand(context); - else - completion.ex(context); - }, - options: [ - [["-nargs"], self.OPTION_STRING, - function (arg) /^[01*?+]$/.test(arg), - [["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 is allowed"]]], - [["-bang"], self.OPTION_NOARG], - [["-count"], self.OPTION_NOARG], - [["-description"], self.OPTION_STRING], - // TODO: "E180: invalid complete value: " + arg - [["-complete"], self.OPTION_STRING, - function (arg) arg in completeOptionMap || /custom,\w+/.test(arg), - function (context) [[k, ""] for ([k, v] in Iterator(completeOptionMap))]] - ], - literal: 1, - serial: function () [ - { - command: this.name, - bang: true, - options: util.Array.toObject( - [[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" })) - // FIXME: add support for default values to parseArgs - if (k in cmd && cmd[k] != "0" && cmd[k] != "User-defined command")]), - arguments: [cmd.name], - literalArg: cmd.replacementText - } - for ([k, cmd] in Iterator(exCommands)) - if (cmd.user && cmd.replacementText) - ] - }); - - self.add(["comc[lear]"], - "Delete all user-defined commands", - function () - { - commands.getUserCommands().forEach(function (cmd) { commands.removeUserCommand(cmd.name); }); - }, - { argCount: "0" }); - - self.add(["delc[ommand]"], - "Delete the specified user-defined command", - function (args) - { - let name = args[0]; - - if (commands.get(name)) - commands.removeUserCommand(name); - else - liberator.echoerr("E184: No such user-defined command: " + name); - }, - { - argCount: "1", - completer: function (context) completion.userCommand(context) - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + }, - liberator.registerObserver("load_completion", function () { + completion: function () { completion.setFunctionCompleter(commands.get, [function () ([c.name, c.description] for (c in commands))]); completion.command = function command(context) { @@ -1194,8 +890,7 @@ function Commands() //{{{ // dynamically get completions as specified with the command's completer function let command = commands.get(cmd); - if (!command) - { + if (!command) { context.highlight(0, cmd.length, "SPELLCHECK"); return; } @@ -1204,30 +899,25 @@ function Commands() //{{{ let cmdContext = context.fork(cmd, prefix.length); let argContext = context.fork("args", prefix.length); args = command.parseArgs(cmdContext.filter, argContext, { count: count, bang: bang }); - if (args) - { + if (args) { // FIXME: Move to parseCommand args.count = count; args.bang = bang; - if (!args.completeOpt && command.completer) - { + if (!args.completeOpt && command.completer) { cmdContext.advance(args.completeStart); cmdContext.quote = args.quote; cmdContext.filter = args.completeFilter; - try - { + try { let compObject = command.completer.call(command, cmdContext, args); if (compObject instanceof Array) // for now at least, let completion functions return arrays instead of objects compObject = { start: compObject[0], items: compObject[1] }; - if (compObject != null) - { + if (compObject != null) { cmdContext.advance(compObject.start); cmdContext.filterFunc = null; cmdContext.completions = compObject.items; } } - catch (e) - { + catch (e) { liberator.reportError(e); } } @@ -1239,11 +929,216 @@ function Commands() //{{{ context.keys = { text: "name", description: "replacementText" }; context.completions = commands.getUserCommands(); }; - }); - //}}} + }, + + commands: function () { + function userCommand(args, modifiers) { + let tokens = { + args: this.argCount && args.string, + bang: this.bang && args.bang ? "!" : "", + count: this.count && args.count + }; + + liberator.execute(commands.replaceTokens(this.replacementText, tokens)); + } + + // TODO: offer completion.ex? + // : make this config specific + var completeOptionMap = { + abbreviation: "abbreviation", altstyle: "alternateStyleSheet", + bookmark: "bookmark", buffer: "buffer", color: "colorScheme", + command: "command", dialog: "dialog", dir: "directory", + environment: "environment", event: "autocmdEvent", file: "file", + help: "help", highlight: "highlightGroup", javascript: "javascript", + macro: "macro", mapping: "userMapping", menu: "menuItem", + option: "option", preference: "preference", search: "search", + shellcmd: "shellCommand", sidebar: "sidebar", url: "url", + usercommand: "userCommand" + }; + + // TODO: Vim allows commands to be defined without {rep} if there are {attr}s + // specified - useful? + commands.add(["com[mand]"], + "List and define commands", + function (args) { + let cmd = args[0]; + + liberator.assert(!/\W/.test(cmd || ''), "E182: Invalid command name"); + + if (args.literalArg) { + let nargsOpt = args["-nargs"] || "0"; + let bangOpt = "-bang" in args; + let countOpt = "-count" in args; + let descriptionOpt = args["-description"] || "User-defined command"; + let completeOpt = args["-complete"]; + + let completeFunc = null; // default to no completion for user commands + + if (completeOpt) { + if (/^custom,/.test(completeOpt)) { + completeOpt = completeOpt.substr(7); + completeFunc = function () { + try { + var completer = liberator.eval(completeOpt); + + if (!(completer instanceof Function)) + throw new TypeError("User-defined custom completer '" + completeOpt + "' is not a function"); + } + catch (e) { + liberator.echo(":" + this.name + " ..."); + liberator.echoerr("E117: Unknown function: " + completeOpt); + liberator.log(e); + return undefined; + } + return completer.apply(this, Array.slice(arguments)); + }; + } + else + completeFunc = completion[completeOptionMap[completeOpt]]; + } - return self; + let added = commands.addUserCommand([cmd], + descriptionOpt, + userCommand, { + argCount: nargsOpt, + bang: bangOpt, + count: countOpt, + completer: completeFunc, + replacementText: args.literalArg + }, args.bang); + + if (!added) + liberator.echoerr("E174: Command already exists: add ! to replace it"); + } + else { + function completerToString(completer) { + if (completer) + return [k for ([k, v] in Iterator(completeOptionMap)) if (completer == completion[v])][0] || "custom"; + else + return ""; + } -} //}}} + // TODO: using an array comprehension here generates flakey results across repeated calls + // : perhaps we shouldn't allow options in a list call but just ignore them for now + let cmds = this._exCommands.filter(function (c) c.user && (!cmd || c.name.match("^" + cmd))); + + if (cmds.length > 0) { + let str = template.tabular(["", "Name", "Args", "Range", "Complete", "Definition"], ["padding-right: 2em;"], + ([cmd.bang ? "!" : " ", + cmd.name, + cmd.argCount, + cmd.count ? "0c" : "", + completerToString(cmd.completer), + cmd.replacementText || "function () { ... }"] + for ([, cmd] in Iterator(cmds)))); + + commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + else + liberator.echomsg("No user-defined commands found"); + } + }, { + bang: true, + completer: function (context, args) { + if (args.completeArg == 0) + completion.userCommand(context); + else + completion.ex(context); + }, + options: [ + [["-nargs"], commands.OPTION_STRING, + function (arg) /^[01*?+]$/.test(arg), + [["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 is allowed"]]], + [["-bang"], commands.OPTION_NOARG], + [["-count"], commands.OPTION_NOARG], + [["-description"], commands.OPTION_STRING], + // TODO: "E180: invalid complete value: " + arg + [["-complete"], commands.OPTION_STRING, + function (arg) arg in completeOptionMap || /custom,\w+/.test(arg), + function (context) [[k, ""] for ([k, v] in Iterator(completeOptionMap))]] + ], + literal: 1, + serial: function () [ { + command: this.name, + bang: true, + options: util.Array.toObject( + [[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" })) + // FIXME: add support for default values to parseArgs + if (k in cmd && cmd[k] != "0" && cmd[k] != "User-defined command")]), + arguments: [cmd.name], + literalArg: cmd.replacementText + } + for ([k, cmd] in Iterator(this._exCommands)) + if (cmd.user && cmd.replacementText) + ] + }); + + commands.add(["comc[lear]"], + "Delete all user-defined commands", + function () { + commands.getUserCommands().forEach(function (cmd) { commands.removeUserCommand(cmd.name); }); + }, + { argCount: "0" }); + + commands.add(["delc[ommand]"], + "Delete the specified user-defined command", + function (args) { + let name = args[0]; + + if (commands.get(name)) + commands.removeUserCommand(name); + else + liberator.echoerr("E184: No such user-defined command: " + name); + }, { + argCount: "1", + completer: function (context) completion.userCommand(context) + }); + } +}); + +(function () { + + Commands.quoteMap = { + "\n": "n", + "\t": "t" + }; + function quote(q, list) { + let re = RegExp("[" + list + "]", "g"); + return function (str) q + String.replace(str, re, function ($0) $0 in Commands.quoteMap ? Commands.quoteMap[$0] : ("\\" + $0)) + q; + }; + Commands.complQuote = { // FIXME + '"': ['"', quote("", '\n\t"\\\\'), '"'], + "'": ["'", quote("", "\\\\'"), "'"], + "": ["", quote("", "\\\\ "), ""] + }; + Commands.quoteArg = { + '"': quote('"', '\n\t"\\\\'), + "'": quote("'", "\\\\'"), + "": quote("", "\\\\ ") + }; + + Commands.parseBool = function (arg) { + if (/^(true|1|on)$/i.test(arg)) + return true; + if (/^(false|0|off)$/i.test(arg)) + return false; + return NaN; + }; + Commands.argTypes = [ + null, + ArgType("no arg", function (arg) !arg || null), + ArgType("boolean", Commands.parseBool), + ArgType("string", function (val) val), + ArgType("int", parseInt), + ArgType("float", parseFloat), + ArgType("list", function (arg) arg && arg.split(/\s*,\s*/)) + ]; +})(); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/completion.js b/common/content/completion.js index 5414583f..7734f4d1 100644 --- a/common/content/completion.js +++ b/common/content/completion.js @@ -25,194 +25,162 @@ * @author Kris Maglione <maglione.k@gmail.com> * @constructor */ -function CompletionContext(editor, name, offset) //{{{ -{ - if (!(this instanceof arguments.callee)) - return new arguments.callee(editor, name, offset); - if (!name) - name = ""; - - let self = this; - if (editor instanceof arguments.callee) - { - let parent = editor; - name = parent.name + "/" + name; - this.contexts = parent.contexts; - if (name in this.contexts) - self = this.contexts[name]; - else - self.contexts[name] = this; +const 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; + 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", "title", "quote"].forEach(function (key) + self[key] = parent[key] && util.cloneObject(parent[key])); + ["anchored", "compare", "editor", "_filter", "filterFunc", "keys", "_process", "top"].forEach(function (key) + self[key] = parent[key]); + + self.__defineGetter__("value", function () this.top.value); + + self.offset = parent.offset; + self.advance(offset); + + /** + * @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; + + 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; + 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 {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 {boolean} Specifies whether this context results must + * match the filter at the beginning of the string. + * @default true + */ + this.anchored = true; + /** + * @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 {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; + /** + * @property {CompletionContext} The top-level completion context. + */ + this.top = this; + this.__defineGetter__("incomplete", function () this.contextList.some(function (c) c.parent && c.incomplete)); + this.__defineGetter__("waitingForTab", function () this.contextList.some(function (c) c.parent && c.waitingForTab)); + this.reset(); + } /** - * @property {CompletionContext} This context's parent. {null} when - * this is a top-level context. - */ - self.parent = parent; - - ["filters", "keys", "title", "quote"].forEach(function (key) - self[key] = parent[key] && util.cloneObject(parent[key])); - ["anchored", "compare", "editor", "_filter", "filterFunc", "keys", "_process", "top"].forEach(function (key) - self[key] = parent[key]); - - self.__defineGetter__("value", function () this.top.value); - - self.offset = parent.offset; - self.advance(offset); - - /** - * @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; - - 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; - 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 {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 {boolean} Specifies whether this context results must - * match the filter at the beginning of the string. - * @default true - */ - this.anchored = true; - /** - * @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}. + * @property {Object} A general-purpose store for functions which need to + * cache data between calls. */ - this.contexts = { "": this }; + this.cache = {}; /** - * @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). + * @private + * @property {Object} A cache for return values of {@link #generate}. */ - this.keys = { text: 0, description: 1, icon: "icon" }; + this.itemCache = {}; /** - * @property {number} This context's offset from the beginning of - * {@link #editor}'s value. + * @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.offset = offset || 0; + this.key = ""; /** - * @property {function} A function which is called when any subcontext - * changes its completion list. Only called when - * {@link #updateAsync} is true. + * @property {string} A message to be shown before any results. */ - this.onUpdate = function () true; + this.message = null; + this.name = name || ""; + /** @private */ + this._completions = []; // FIXME /** - * @property {CompletionContext} The top-level completion context. + * Returns a key, as detailed in {@link #keys}. + * @function */ - this.top = this; - this.__defineGetter__("incomplete", function () this.contextList.some(function (c) c.parent && c.incomplete)); - this.__defineGetter__("waitingForTab", function () this.contextList.some(function (c) c.parent && c.waitingForTab)); - this.reset(); - } - /** - * @property {Object} A general-purpose store for functions which need to - * cache data between calls. - */ - 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; -} - -CompletionContext.Sort = { - number: function (a, b) parseInt(b) - parseInt(a) || String.localeCompare(a, b), - - unsorted: null -}; - -CompletionContext.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; + 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]; }, - textDescription: function (item) { - return CompletionContext.Filter.text.call(this, item) || this.match(item.description); - } -}; - -CompletionContext.prototype = { // Temporary /** * @property {Object} @@ -222,10 +190,8 @@ CompletionContext.prototype = { * * @deprecated */ - get allItems() - { - try - { + get allItems() { + try { let self = this; let minStart = Math.min.apply(Math, [context.offset for ([k, context] in Iterator(this.contexts)) if (context.items.length && context.hasItems)]); if (minStart == Infinity) @@ -241,15 +207,13 @@ CompletionContext.prototype = { }); return { start: minStart, items: util.Array.flatten(items), longestSubstring: this.longestAllSubstring }; } - catch (e) - { + catch (e) { liberator.reportError(e); return { start: 0, items: [], longestAllSubstring: "" }; } }, // Temporary - get allSubstrings() - { + 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) { @@ -265,8 +229,7 @@ CompletionContext.prototype = { return util.Array.uniq(Array.slice(substrings)); }, // Temporary - get longestAllSubstring() - { + get longestAllSubstring() { return this.allSubstrings.reduce(function (a, b) a.length > b.length ? a : b, ""); }, @@ -277,8 +240,7 @@ CompletionContext.prototype = { set compare(val) this._compare = val, get completions() this._completions || [], - set completions(items) - { + set completions(items) { // Accept a generator if ({}.toString.call(items) != '[object Array]') items = [x for (x in Iterator(items))]; @@ -299,8 +261,7 @@ CompletionContext.prototype = { set filterFunc(val) this._filterFunc = val, get filter() this._filter != null ? this._filter : this.value.substr(this.offset, this.caret), - set filter(val) - { + set filter(val) { delete this._ignoreCase; return this._filter = val; }, @@ -311,8 +272,7 @@ CompletionContext.prototype = { keys: this.keys, process: this.process }), - set format(format) - { + set format(format) { this.anchored = format.anchored, this.title = format.title || this.title; this.keys = format.keys || this.keys; @@ -322,11 +282,9 @@ CompletionContext.prototype = { get message() this._message || (this.waitingForTab ? "Waiting for <Tab>" : null), set message(val) this._message = val, - get proto() - { + get proto() { let res = {}; - for (let i in Iterator(this.keys)) - { + for (let i in Iterator(this.keys)) { let [k, v] = i; let _k = "_" + k; if (typeof v == "string" && /^[.[]/.test(v)) @@ -343,8 +301,7 @@ CompletionContext.prototype = { 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]; }, - get generate() !this._generate ? null : function () - { + get generate() !this._generate ? null : function () { if (this.offset != this.cache.offset) this.itemCache = {}; this.cache.offset = this.offset; @@ -352,12 +309,10 @@ CompletionContext.prototype = { this.itemCache[this.key] = this._generate.call(this) || []; return this.itemCache[this.key]; }, - set generate(arg) - { + set generate(arg) { this.hasItems = true; this._generate = arg; - if (this.background && this.regenerate) - { + if (this.background && this.regenerate) { let lock = {}; this.cache.backgroundLock = lock; this.incomplete = true; @@ -374,8 +329,7 @@ CompletionContext.prototype = { } }, - get ignoreCase() - { + get ignoreCase() { if ("_ignoreCase" in this) return this._ignoreCase; let mode = options["wildcase"]; @@ -387,16 +341,14 @@ CompletionContext.prototype = { }, set ignoreCase(val) this._ignoreCase = val, - get items() - { + get items() { if (!this.hasItems || this.backgroundLock) return []; if (this.cache.filtered && this.cache.filter == this.filter) return this.cache.filtered; this.cache.rows = []; let items = this.completions; - if (this.generate && !this.background) - { + if (this.generate && !this.background) { // XXX this.noUpdate = true; this.completions = items = this.generate(); @@ -425,8 +377,7 @@ CompletionContext.prototype = { return this.cache.filtered = filtered; }, - get process() // FIXME - { + get process() { // FIXME let self = this; let process = this._process; process = [process[0] || template.icon, process[1] || function (item, k) k]; @@ -436,13 +387,11 @@ CompletionContext.prototype = { process[0] = function (item, text) first.call(self, item, template.highlightFilter(item.text, filter)); return process; }, - set process(process) - { + set process(process) { this._process = process; }, - get substrings() - { + get substrings() { let items = this.items; if (items.length == 0 || !this.hasItems) return []; @@ -452,21 +401,18 @@ CompletionContext.prototype = { let fixCase = this.ignoreCase ? String.toLowerCase : util.identity; let text = fixCase(items[0].unquoted || items[0].text); let filter = fixCase(this.filter); - if (this.anchored) - { + if (this.anchored) { var compare = function compare(text, s) text.substr(0, s.length) == s; substrings = util.map(util.range(filter.length, text.length + 1), function (end) text.substring(0, end)); } - else - { + else { var compare = function compare(text, s) text.indexOf(s) >= 0; substrings = []; let start = 0; let idx; let length = filter.length; - while ((idx = text.indexOf(filter, start)) > -1 && idx < text.length) - { + while ((idx = text.indexOf(filter, start)) > -1 && idx < text.length) { for (let end in util.range(idx + length, text.length + 1)) substrings.push(text.substring(idx, end)); start = idx + 1; @@ -494,11 +440,9 @@ CompletionContext.prototype = { * * @param {number} count The number of characters to advance the context. */ - advance: function advance(count) - { + advance: function advance(count) { delete this._ignoreCase; - if (this.quote) - { + if (this.quote) { count = this.quote[0].length + this.quote[1](this.filter.substr(0, count)).length; this.quote[0] = ""; this.quote[2] = ""; @@ -508,10 +452,8 @@ CompletionContext.prototype = { this._filter = this._filter.substr(count); }, - cancelAll: function () - { - for (let [, context] in Iterator(this.contextList)) - { + cancelAll: function () { + for (let [, context] in Iterator(this.contextList)) { if (context.cancel) context.cancel(); } @@ -524,15 +466,13 @@ CompletionContext.prototype = { * @param {string} key * @param defVal */ - getCache: function (key, defVal) - { + getCache: function (key, defVal) { if (!(key in this.cache)) this.cache[key] = defVal(); return this.cache[key]; }, - getItems: function getItems(start, end) - { + getItems: function getItems(start, end) { let self = this; let items = this.items; let step = start > end ? -1 : 1; @@ -541,8 +481,7 @@ CompletionContext.prototype = { return util.map(util.range(start, end, step), function (i) items[i]); }, - getRows: function getRows(start, end, doc) - { + getRows: function getRows(start, end, doc) { let self = this; let items = this.items; let cache = this.cache.rows; @@ -553,8 +492,7 @@ CompletionContext.prototype = { yield [i, cache[i] = cache[i] || util.xmlToDom(self.createRow(items[i]), doc)]; }, - fork: function fork(name, offset, self, completer) - { + fork: function fork(name, offset, self, completer) { if (typeof completer == "string") completer = self[completer]; let context = new CompletionContext(this, name, offset); @@ -564,26 +502,22 @@ CompletionContext.prototype = { return context; }, - getText: function getText(item) - { + getText: function getText(item) { let text = item[self.keys["text"]]; if (self.quote) return self.quote(text); return text; }, - highlight: function highlight(start, length, type) - { - try // Gecko < 1.9.1 doesn't have repaintSelection - { + highlight: function highlight(start, length, type) { + try { // Gecko < 1.9.1 doesn't have repaintSelection this.selectionTypes[type] = null; const selType = Ci.nsISelectionController["SELECTION_" + type]; const editor = this.editor; let sel = editor.selectionController.getSelection(selType); if (length == 0) sel.removeAllRanges(); - else - { + else { let range = editor.selection.getRangeAt(0).cloneRange(); range.setStart(range.startContainer, this.offset + start); range.setEnd(range.startContainer, this.offset + start + length); @@ -595,10 +529,8 @@ CompletionContext.prototype = { }, // FIXME - _match: function _match(filter, str) - { - if (this.ignoreCase) - { + _match: function _match(filter, str) { + if (this.ignoreCase) { filter = filter.toLowerCase(); str = str.toLowerCase(); } @@ -607,13 +539,11 @@ CompletionContext.prototype = { return str.indexOf(filter) > -1; }, - match: function match(str) - { + match: function match(str) { return this._match(this.filter, str); }, - reset: function reset() - { + reset: function reset() { let self = this; if (this.parent) throw Error(); @@ -633,34 +563,23 @@ CompletionContext.prototype = { this.tabPressed = false; this.title = ["Completions"]; this.updateAsync = false; - try - { - this.waitingForTab = false; - } - catch (e) {} this.cancelAll(); - if (this.editor) - { + if (this.editor) { this.value = this.editor.selection.focusNode.textContent; this._caret = this.editor.selection.focusOffset; } - else - { + 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) - { + for each (let context in this.contexts) { context.hasItems = false; - try - { + if (context != context.top) context.incomplete = false; - } - catch (e) {} } }, @@ -672,255 +591,373 @@ CompletionContext.prototype = { * @param {number} timeout The maximum time, in milliseconds, to wait. * If 0 or null, wait indefinately. */ - wait: function wait(interruptable, timeout) - { + wait: function wait(interruptable, timeout) { let end = Date.now() + timeout; while (this.incomplete && (!timeout || Date.now() > end)) liberator.threadYield(false, interruptable); return this.incomplete; } -}; //}}} +}, { + Sort: { + number: function (a, b) parseInt(b) - parseInt(a) || String.localeCompare(a, b), + + 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 */ -function Completion() //{{{ -{ +const Completion = Module("completion", { + init: function () { + this.javascriptCompleter = Completion.Javascript(); + }, + + setFunctionCompleter: function setFunctionCompleter(funcs, completers) { + funcs = Array.concat(funcs); + for (let [, func] in Iterator(funcs)) { + func.liberatorCompleter = function liberatorCompleter(context, func, obj, args) { + let completer = completers[args.length - 1]; + if (!completer) + return []; + return completer.call(this, context, obj, args); + }; + } + }, + + // FIXME + _runCompleter: function _runCompleter(name, filter, maxItems) { + let context = CompletionContext(filter); + context.maxItems = maxItems; + let res = context.fork.apply(context, ["run", 0, this, name].concat(Array.slice(arguments, 3))); + if (res) // FIXME + return { items: res.map(function (i) ({ item: i })) }; + context.wait(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 = CompletionContext(filter || ""); + context.maxItems = maxItems; + context.fork.apply(context, ["list", 0, completion, name].concat(Array.slice(arguments, 3))); + context = context.contexts["/list"]; + context.wait(); + + let list = template.commandOutput( + <div highlight="Completions"> + { template.completionRow(context.title, "CompTitle") } + { template.map(context.items, function (item) context.createRow(item), null, 100) } + </div>); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + ////////////////////// COMPLETION TYPES //////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////{{{ - const EVAL_TMP = "__liberator_eval_tmp"; + javascript: function (context) this.javascriptCompleter.complete(context), - function Javascript() - { - const OFFSET = 0, CHAR = 1, STATEMENTS = 2, DOTS = 3, FULL_STATEMENTS = 4, COMMA = 5, FUNCTIONS = 6; - let stack = []; - let functions = []; - let top = []; // The element on the top of the stack. - let last = ""; // The last opening char pushed onto the stack. - let lastNonwhite = ""; // Last non-whitespace character we saw. - let lastChar = ""; // Last character we saw, used for \ escaping quotes. - let compl = []; - let str = ""; + // 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) { + let numLocationCompletions = 0; // how many async completions did we already return to the caller? + let start = 0; + let skip = 0; - let lastIdx = 0; + if (options["urlseparator"]) + skip = context.filter.match("^.*" + options["urlseparator"]); // start after the last 'urlseparator' - let cacheKey = null; + if (skip) + context.advance(skip[0].length); - this.completers = {}; + // Will, and should, throw an error if !(c in opts) + Array.forEach(complete || options["complete"], function (c) { + let completer = completion.urlCompleters[c]; + context.fork.apply(context, [c, 0, completion, completer.completer].concat(completer.args)); + }); + }, - // Some object members are only accessible as function calls - function getKey(obj, key) - { - try - { - return obj[key]; - } - catch (e) - { - return undefined; - } + urlCompleters: {}, + + 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; } - this.iter = function iter(obj) - { - let iterator = ([k, getKey(obj, k)] for (k in obj)); - try - { - // The point of 'for k in obj' is to get keys - // that are accessible via . or [] notation. - // Iterators quite often return values of no - // use whatsoever for this purpose, so, we try - // this rather dirty hack of getting a standard - // object iterator for any object that defines its - // own. - if ("__iterator__" in obj) - { - let oldIter = obj.__iterator__; - delete obj.__iterator__; - iterator = Iterator(obj); - obj.__iterator__ = oldIter; + 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.escapeRegex).join("|"), "g"); + function highlight(item, text, i) process[i].call(this, item, template.highlightRegexp(text, re)); + let process = [template.icon, function (item, k) k]; + context.process = [ + function (item, text) highlight.call(this, item, item.text, 0), + function (item, text) highlight.call(this, item, text, 1) + ]; + }); + } + //}}} +}, { + UrlCompleter: new Struct("name", "description", "completer"), + + Javascript: Class("Javascript", { + init: function () { + const OFFSET = 0, CHAR = 1, STATEMENTS = 2, DOTS = 3, FULL_STATEMENTS = 4, COMMA = 5, FUNCTIONS = 6; + let stack = []; + let functions = []; + let top = []; // The element on the top of the stack. + let last = ""; // The last opening char pushed onto the stack. + let lastNonwhite = ""; // Last non-whitespace character we saw. + let lastChar = ""; // Last character we saw, used for \ escaping quotes. + let compl = []; + let str = ""; + + let lastIdx = 0; + + let cacheKey = null; + + this.completers = {}; + + // Some object members are only accessible as function calls + function getKey(obj, key) { + try { + return obj[key]; + } + catch (e) { + return undefined; } } - catch (e) {} - return iterator; - }; - - // Search the object for strings starting with @key. - // If @last is defined, key is a quoted string, it's - // wrapped in @last after @offset characters are sliced - // off of it and it's quoted. - this.objectKeys = function objectKeys(obj) - { - // Things we can dereference - if (["object", "string", "function"].indexOf(typeof obj) == -1) - return []; - if (!obj) - return []; - - // XPCNativeWrappers, etc, don't show all accessible - // members until they're accessed, so, we look at - // the wrappedJSObject instead, and return any keys - // available in the object itself. - let orig = obj; - - if (modules.isPrototypeOf(obj)) - compl = [v for (v in Iterator(obj))]; - else - { - if (getKey(obj, 'wrappedJSObject')) - obj = obj.wrappedJSObject; - // v[0] in orig and orig[v[0]] catch different cases. XPCOM - // objects are problematic, to say the least. - compl = [v for (v in this.iter(obj)) - if ((typeof orig == "object" && v[0] in orig) || getKey(orig, v[0]) !== undefined)]; - } - - // And if wrappedJSObject happens to be available, - // return that, too. - if (getKey(orig, 'wrappedJSObject')) - compl.push(["wrappedJSObject", obj]); - - // Add keys for sorting later. - // Numbers are parsed to ints. - // Constants, which should be unsorted, are found and marked null. - compl.forEach(function (item) { - let key = item[0]; - if (!isNaN(key)) - key = parseInt(key); - else if (/^[A-Z_][A-Z0-9_]*$/.test(key)) - key = ""; - item.key = key; - }); - return compl; - }; + this.iter = function iter(obj) { + let iterator = ([k, getKey(obj, k)] for (k in obj)); + try { + // The point of 'for k in obj' is to get keys + // that are accessible via . or [] notation. + // Iterators quite often return values of no + // use whatsoever for this purpose, so, we try + // this rather dirty hack of getting a standard + // object iterator for any object that defines its + // own. + if ("__iterator__" in obj) { + let oldIter = obj.__iterator__; + delete obj.__iterator__; + iterator = Iterator(obj); + obj.__iterator__ = oldIter; + } + } + catch (e) {} + return iterator; + }; - this.eval = function eval(arg, key, tmp) - { - let cache = this.context.cache.eval; - let context = this.context.cache.evalContext; + // Search the object for strings starting with @key. + // If @last is defined, key is a quoted string, it's + // wrapped in @last after @offset characters are sliced + // off of it and it's quoted. + this.objectKeys = function objectKeys(obj) { + // Things we can dereference + if (["object", "string", "function"].indexOf(typeof obj) == -1) + return []; + if (!obj) + return []; - if (!key) - key = arg; - if (key in cache) - return cache[key]; + // XPCNativeWrappers, etc, don't show all accessible + // members until they're accessed, so, we look at + // the wrappedJSObject instead, and return any keys + // available in the object itself. + let orig = obj; + + if (modules.isPrototypeOf(obj)) + compl = [v for (v in Iterator(obj))]; + else { + if (getKey(obj, 'wrappedJSObject')) + obj = obj.wrappedJSObject; + // v[0] in orig and orig[v[0]] catch different cases. XPCOM + // objects are problematic, to say the least. + compl = [v for (v in this.iter(obj)) + if ((typeof orig == "object" && v[0] in orig) || getKey(orig, v[0]) !== undefined)]; + } - context[EVAL_TMP] = tmp; - try - { - return cache[key] = liberator.eval(arg, context); - } - catch (e) - { - return null; - } - finally - { - delete context[EVAL_TMP]; - } - }; - - // Get an element from the stack. If @n is negative, - // count from the top of the stack, otherwise, the bottom. - // If @m is provided, return the @mth value of element @o - // of the stack entry at @n. - let get = function get(n, m, o) - { - let a = stack[n >= 0 ? n : stack.length + n]; - if (o != null) - a = a[o]; - if (m == null) - return a; - return a[a.length - m - 1]; - }; - - function buildStack(filter) - { - let self = this; - // Push and pop the stack, maintaining references to 'top' and 'last'. - let push = function push(arg) - { - top = [i, arg, [i], [], [], [], []]; - last = top[CHAR]; - stack.push(top); + // And if wrappedJSObject happens to be available, + // return that, too. + if (getKey(orig, 'wrappedJSObject')) + compl.push(["wrappedJSObject", obj]); + + // Add keys for sorting later. + // Numbers are parsed to ints. + // Constants, which should be unsorted, are found and marked null. + compl.forEach(function (item) { + let key = item[0]; + if (!isNaN(key)) + key = parseInt(key); + else if (/^[A-Z_][A-Z0-9_]*$/.test(key)) + key = ""; + item.key = key; + }); + + return compl; }; - let pop = function pop(arg) - { - if (top[CHAR] != arg) - { - self.context.highlight(top[OFFSET], i - top[OFFSET], "SPELLCHECK"); - self.context.highlight(top[OFFSET], 1, "FIND"); - throw new Error("Invalid JS"); + + this.eval = function eval(arg, key, tmp) { + let cache = this.context.cache.eval; + let context = this.context.cache.evalContext; + + if (!key) + key = arg; + if (key in cache) + return cache[key]; + + context[Completion.Javascript.EVAL_TMP] = tmp; + try { + return cache[key] = liberator.eval(arg, context); + } + catch (e) { + return null; + } + finally { + delete context[Completion.Javascript.EVAL_TMP]; } - if (i == self.context.caret - 1) - self.context.highlight(top[OFFSET], 1, "FIND"); - // 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 (top[STATEMENTS][top[STATEMENTS].length - 1] == i) - top[STATEMENTS].pop(); - top = get(-2); - last = top[CHAR]; - let ret = stack.pop(); - return ret; }; - let i = 0, c = ""; // Current index and character, respectively. - - // Reuse the old stack. - if (str && filter.substr(0, str.length) == str) - { - i = str.length; - if (this.popStatement) - top[STATEMENTS].pop(); - } - else - { - stack = []; - functions = []; - push("#root"); - } + // Get an element from the stack. If @n is negative, + // count from the top of the stack, otherwise, the bottom. + // If @m is provided, return the @mth value of element @o + // of the stack entry at @n. + let get = function get(n, m, o) { + let a = stack[n >= 0 ? n : stack.length + n]; + if (o != null) + a = a[o]; + if (m == null) + return a; + return a[a.length - m - 1]; + }; - // 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. - str = filter; - let length = str.length; - for (; i < length; lastChar = c, i++) - { - c = str[i]; - if (last == '"' || last == "'" || last == "/") - { - if (lastChar == "\\") // Escape. Skip the next char, whatever it may be. - { - c = ""; - i++; + function buildStack(filter) { + let self = this; + // Push and pop the stack, maintaining references to 'top' and 'last'. + let push = function push(arg) { + top = [i, arg, [i], [], [], [], []]; + last = top[CHAR]; + stack.push(top); + }; + let pop = function pop(arg) { + if (top[CHAR] != arg) { + self.context.highlight(top[OFFSET], i - top[OFFSET], "SPELLCHECK"); + self.context.highlight(top[OFFSET], 1, "FIND"); + throw new Error("Invalid JS"); } - else if (c == last) - pop(c); + if (i == self.context.caret - 1) + self.context.highlight(top[OFFSET], 1, "FIND"); + // 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 (top[STATEMENTS][top[STATEMENTS].length - 1] == i) + top[STATEMENTS].pop(); + top = get(-2); + last = top[CHAR]; + let ret = stack.pop(); + return ret; + }; + + let i = 0, c = ""; // Current index and character, respectively. + + // Reuse the old stack. + if (str && filter.substr(0, str.length) == str) { + i = str.length; + if (this.popStatement) + top[STATEMENTS].pop(); + } + else { + stack = []; + functions = []; + push("#root"); } - else - { - // A word character following a non-word character, or simply a non-word - // character. Start a new statement. - if (/[a-zA-Z_$]/.test(c) && !/[\w$]/.test(lastChar) || !/[\w\s$]/.test(c)) - top[STATEMENTS].push(i); - - // A "." or a "[" dereferences the last "statement" and effectively - // joins it to this logical statement. - if ((c == "." || c == "[") && /[\w$\])"']/.test(lastNonwhite) - || lastNonwhite == "." && /[a-zA-Z_$]/.test(c)) - top[STATEMENTS].pop(); - - switch (c) - { + + // 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. + str = filter; + let length = str.length; + for (; i < length; lastChar = c, i++) { + c = str[i]; + if (last == '"' || last == "'" || last == "/") { + if (lastChar == "\\") { // Escape. Skip the next char, whatever it may be. + c = ""; + i++; + } + else if (c == last) + pop(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(c) && !/[\w$]/.test(lastChar) || !/[\w\s$]/.test(c)) + top[STATEMENTS].push(i); + + // A "." or a "[" dereferences the last "statement" and effectively + // joins it to this logical statement. + if ((c == "." || c == "[") && /[\w$\])"']/.test(lastNonwhite) + || lastNonwhite == "." && /[a-zA-Z_$]/.test(c)) + top[STATEMENTS].pop(); + + switch (c) { case "(": // Function call, or if/while/for/... - if (/[\w$]/.test(lastNonwhite)) - { + if (/[\w$]/.test(lastNonwhite)) { functions.push(i); top[FUNCTIONS].push(i); top[STATEMENTS].pop(); @@ -946,464 +983,294 @@ function Completion() //{{{ case ",": top[COMMA].push(i); break; - } + } - if (/\S/.test(c)) - lastNonwhite = c; + if (/\S/.test(c)) + lastNonwhite = c; + } } - } - this.popStatement = false; - if (!/[\w$]/.test(lastChar) && lastNonwhite != ".") - { - this.popStatement = true; - top[STATEMENTS].push(i); - } - - lastIdx = i; - } - - this.complete = function _complete(context) - { - this.context = context; + this.popStatement = false; + if (!/[\w$]/.test(lastChar) && lastNonwhite != ".") { + this.popStatement = true; + top[STATEMENTS].push(i); + } - let self = this; - try - { - buildStack.call(this, context.filter); + lastIdx = i; } - catch (e) - { - if (e.message != "Invalid JS") - liberator.reportError(e); - lastIdx = 0; - return; - } - - let cache = this.context.cache; - this.context.getCache("eval", Object); - this.context.getCache("evalContext", function () ({ __proto__: userContext })); - // Okay, have parse stack. Figure out what we're completing. + this.complete = function _complete(context) { + this.context = context; - // Find any complete statements that we can eval before we eval our object. - // This allows for things like: let doc = window.content.document; let elem = doc.createElement...; elem.<Tab> - let prev = 0; - for (let [, v] in Iterator(get(0)[FULL_STATEMENTS])) - { - let key = str.substring(prev, v + 1); - if (checkFunction(prev, v, key)) + let self = this; + try { + buildStack.call(this, context.filter); + } + catch (e) { + if (e.message != "Invalid JS") + liberator.reportError(e); + lastIdx = 0; return; - this.eval(key); - prev = v + 1; - } - - // Don't eval any function calls unless the user presses tab. - function checkFunction(start, end, key) - { - let res = functions.some(function (idx) idx >= start && idx < end); - if (!res || self.context.tabPressed || key in cache.eval) - return false; - self.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. - function getObj(frame, stop) - { - let statement = get(frame, 0, STATEMENTS) || 0; // Current statement. - let prev = statement; - let obj; - let cacheKey; - for (let [i, dot] in Iterator(get(frame)[DOTS].concat(stop))) - { - if (dot < statement) - continue; - if (dot > stop || dot <= prev) - break; - let s = str.substring(prev, dot); - - if (prev != statement) - s = EVAL_TMP + "." + s; - cacheKey = str.substring(statement, dot); - - if (checkFunction(prev, dot, cacheKey)) - return []; - - prev = dot + 1; - obj = self.eval(s, cacheKey, obj); } - return [[obj, cacheKey]]; - } - - function getObjKey(frame) - { - let dot = get(frame, 0, DOTS) || -1; // Last dot in frame. - let statement = get(frame, 0, STATEMENTS) || 0; // Current statement. - let end = (frame == -1 ? lastIdx : get(frame + 1)[OFFSET]); - - cacheKey = null; - let obj = [[cache.evalContext, "Local Variables"], - [userContext, "Global Variables"], - [modules, "modules"], - [window, "window"]]; // Default objects; - // Is this an object dereference? - if (dot < statement) // No. - dot = statement - 1; - else // Yes. Set the object to the string before the dot. - obj = getObj(frame, dot); - - let [, space, key] = str.substring(dot + 1, end).match(/^(\s*)(.*)/); - return [dot + 1 + space.length, obj, key]; - } - - function fill(context, obj, name, compl, anchored, key, last, offset) - { - context.title = [name]; - context.anchored = anchored; - context.filter = key; - context.itemCache = context.parent.itemCache; - context.key = name; - if (last != null) - context.quote = [last, function (text) util.escapeString(text.substr(offset), ""), last]; - else // We're not looking for a quoted string, so filter out anything that's not a valid identifier - context.filters.push(function (item) /^[a-zA-Z_$][\w$]*$/.test(item.text)); + let cache = this.context.cache; + this.context.getCache("eval", Object); + this.context.getCache("evalContext", function () ({ __proto__: userContext })); - compl.call(self, context, obj); - } + // Okay, have parse stack. Figure out what we're completing. - function complete(objects, key, compl, string, last) - { - let orig = compl; - if (!compl) - { - compl = function (context, obj) - { - context.process = [null, function highlight(item, v) template.highlight(v, true)]; - // 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 compare = context.compare; - function isnan(item) item != '' && isNaN(item); - context.compare = function (a, b) - { - if (!isnan(a.item.key) && !isnan(b.item.key)) - return a.item.key - b.item.key; - return isnan(b.item.key) - isnan(a.item.key) || compare(a, b); - }; - if (!context.anchored) // We've already listed anchored matches, so don't list them again here. - context.filters.push(function (item) util.compareIgnoreCase(item.text.substr(0, this.filter.length), this.filter)); - if (obj == cache.evalContext) - context.regenerate = true; - context.generate = function () self.objectKeys(obj); - }; - } - // TODO: Make this a generic completion helper function. - let filter = key + (string || ""); - for (let [, obj] in Iterator(objects)) - { - this.context.fork(obj[1], top[OFFSET], this, fill, - obj[0], obj[1], compl, compl != orig, filter, last, key.length); - } - if (orig) - return; - for (let [, obj] in Iterator(objects)) - { - obj[1] += " (substrings)"; - this.context.fork(obj[1], top[OFFSET], this, fill, - obj[0], obj[1], compl, false, filter, last, key.length); + // Find any complete statements that we can eval before we eval our object. + // This allows for things like: let doc = window.content.document; let elem = doc.createElement...; elem.<Tab> + let prev = 0; + for (let [, v] in Iterator(get(0)[FULL_STATEMENTS])) { + let key = str.substring(prev, v + 1); + if (checkFunction(prev, v, key)) + return; + this.eval(key); + prev = v + 1; } - } - // In a string. Check if we're dereferencing an object. - // Otherwise, do nothing. - if (last == "'" || 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 = str.substring(get(-1)[OFFSET] + 1, lastIdx); - string = eval(last + string + last); - - function getKey() - { - if (last == "") - return ""; - // After the opening [ upto the opening ", plus '' to take care of any operators before it - let key = str.substring(get(-2, 0, STATEMENTS), get(-1, null, OFFSET)) + "''"; - // Now eval the key, to process any referenced variables. - return this.eval(key); + // Don't eval any function calls unless the user presses tab. + function checkFunction(start, end, key) { + let res = functions.some(function (idx) idx >= start && idx < end); + if (!res || self.context.tabPressed || key in cache.eval) + return false; + self.context.waitingForTab = true; + return true; } - // Is this an object accessor? - if (get(-2)[CHAR] == "[") // Are we inside of []? - { - // Stack: - // [-1]: "... - // [-2]: [... - // [-3]: base statement + // 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. + function getObj(frame, stop) { + let statement = get(frame, 0, STATEMENTS) || 0; // Current statement. + let prev = statement; + let obj; + let cacheKey; + for (let [i, dot] in Iterator(get(frame)[DOTS].concat(stop))) { + if (dot < statement) + continue; + if (dot > stop || dot <= prev) + break; + let s = str.substring(prev, dot); - // Yes. If the [ starts at the beginning of a logical - // statement, we're in an array literal, and we're done. - if (get(-3, 0, STATEMENTS) == get(-2)[OFFSET]) - return null; + if (prev != statement) + s = Completion.Javascript.EVAL_TMP + "." + s; + cacheKey = str.substring(statement, dot); - // Beginning of the statement upto the opening [ - let obj = getObj(-3, get(-2)[OFFSET]); + if (checkFunction(prev, dot, cacheKey)) + return []; - return void complete.call(this, obj, getKey(), null, string, last); + prev = dot + 1; + obj = self.eval(s, cacheKey, obj); + } + return [[obj, cacheKey]]; } - // Is this a function call? - if (get(-2)[CHAR] == "(") - { - // Stack: - // [-1]: "... - // [-2]: (... - // [-3]: base statement + function getObjKey(frame) { + let dot = get(frame, 0, DOTS) || -1; // Last dot in frame. + let statement = get(frame, 0, STATEMENTS) || 0; // Current statement. + let end = (frame == -1 ? lastIdx : get(frame + 1)[OFFSET]); + + cacheKey = null; + let obj = [[cache.evalContext, "Local Variables"], + [userContext, "Global Variables"], + [modules, "modules"], + [window, "window"]]; // Default objects; + // Is this an object dereference? + if (dot < statement) // No. + dot = statement - 1; + else // Yes. Set the object to the string before the dot. + obj = getObj(frame, dot); + + let [, space, key] = str.substring(dot + 1, end).match(/^(\s*)(.*)/); + return [dot + 1 + space.length, obj, key]; + } - // Does the opening "(" mark a function call? - if (get(-3, 0, FUNCTIONS) != get(-2)[OFFSET]) - return; // No. We're done. + function fill(context, obj, name, compl, anchored, key, last, offset) { + context.title = [name]; + context.anchored = anchored; + context.filter = key; + context.itemCache = context.parent.itemCache; + context.key = name; - let [offset, obj, func] = getObjKey(-3); - if (!obj.length) - return; - obj = obj.slice(0, 1); + if (last != null) + context.quote = [last, function (text) util.escapeString(text.substr(offset), ""), last]; + else // We're not looking for a quoted string, so filter out anything that's not a valid identifier + context.filters.push(function (item) /^[a-zA-Z_$][\w$]*$/.test(item.text)); + + compl.call(self, context, obj); + } - try - { - var completer = obj[0][0][func].liberatorCompleter; + function complete(objects, key, compl, string, last) { + let orig = compl; + if (!compl) { + compl = function (context, obj) { + context.process = [null, function highlight(item, v) template.highlight(v, true)]; + // 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 compare = context.compare; + function isnan(item) item != '' && isNaN(item); + context.compare = function (a, b) { + if (!isnan(a.item.key) && !isnan(b.item.key)) + return a.item.key - b.item.key; + return isnan(b.item.key) - isnan(a.item.key) || compare(a, b); + }; + if (!context.anchored) // We've already listed anchored matches, so don't list them again here. + context.filters.push(function (item) util.compareIgnoreCase(item.text.substr(0, this.filter.length), this.filter)); + if (obj == cache.evalContext) + context.regenerate = true; + context.generate = function () self.objectKeys(obj); + }; } - catch (e) {} - if (!completer) - completer = this.completers[func]; - if (!completer) + // TODO: Make this a generic completion helper function. + let filter = key + (string || ""); + for (let [, obj] in Iterator(objects)) { + this.context.fork(obj[1], top[OFFSET], this, fill, + obj[0], obj[1], compl, compl != orig, filter, last, key.length); + } + if (orig) return; - - // Split up the arguments - let prev = get(-2)[OFFSET]; - let args = []; - for (let [i, idx] in Iterator(get(-2)[COMMA])) - { - let arg = str.substring(prev + 1, idx); - prev = idx; - util.memoize(args, i, function () self.eval(arg)); + for (let [, obj] in Iterator(objects)) { + obj[1] += " (substrings)"; + this.context.fork(obj[1], top[OFFSET], this, fill, + obj[0], obj[1], compl, false, filter, last, key.length); } - let key = getKey(); - args.push(key + string); - - compl = function (context, obj) - { - let res = completer.call(self, context, func, obj, args); - if (res) - context.completions = res; - }; - - obj[0][1] += "." + func + "(... [" + args.length + "]"; - return void complete.call(this, obj, key, compl, string, last); } - // In a string that's not an obj key or a function arg. - // Nothing to do. - return; - } - - // - // str = "foo.bar.baz" - // obj = "foo.bar" - // key = "baz" - // - // str = "foo" - // obj = [modules, window] - // key = "foo" - // - - let [offset, obj, key] = getObjKey(-1); - - // Wait for a keypress before completing the default objects. - if (!this.context.tabPressed && key == "" && obj.length > 1) - { - this.context.waitingForTab = true; - this.context.message = "Waiting for key press"; - return; - } - - if (!/^(?:[a-zA-Z_$][\w$]*)?$/.test(key)) - return; // Not a word. Forget it. Can this even happen? - - try - { // FIXME - var o = top[OFFSET]; - top[OFFSET] = offset; - return void complete.call(this, obj, key); - } - finally - { - top[OFFSET] = o; - } - } - }; - let javascript = new Javascript(); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const self = { - - setFunctionCompleter: function setFunctionCompleter(funcs, completers) - { - funcs = Array.concat(funcs); - for (let [, func] in Iterator(funcs)) - { - func.liberatorCompleter = function liberatorCompleter(context, func, obj, args) { - let completer = completers[args.length - 1]; - if (!completer) - return []; - return completer.call(this, context, obj, args); - }; - } - }, - - // FIXME - _runCompleter: function _runCompleter(name, filter, maxItems) - { - let context = CompletionContext(filter); - context.maxItems = maxItems; - let res = context.fork.apply(context, ["run", 0, this, name].concat(Array.slice(arguments, 3))); - if (res) // FIXME - return { items: res.map(function (i) ({ item: i })) }; - context.wait(true); - return context.allItems; - }, + // In a string. Check if we're dereferencing an object. + // Otherwise, do nothing. + if (last == "'" || 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 = str.substring(get(-1)[OFFSET] + 1, lastIdx); + string = eval(last + string + last); + + function getKey() { + if (last == "") + return ""; + // After the opening [ upto the opening ", plus '' to take care of any operators before it + let key = str.substring(get(-2, 0, STATEMENTS), get(-1, null, OFFSET)) + "''"; + // Now eval the key, to process any referenced variables. + return this.eval(key); + } - runCompleter: function runCompleter(name, filter, maxItems) - { - return this._runCompleter.apply(this, Array.slice(arguments)) - .items.map(function (i) i.item); - }, + // Is this an object accessor? + if (get(-2)[CHAR] == "[") { // Are we inside of []? + // Stack: + // [-1]: "... + // [-2]: [... + // [-3]: base statement - listCompleter: function listCompleter(name, filter, maxItems) - { - let context = CompletionContext(filter || ""); - context.maxItems = maxItems; - context.fork.apply(context, ["list", 0, completion, name].concat(Array.slice(arguments, 3))); - context = context.contexts["/list"]; - context.wait(); - - let list = template.commandOutput( - <div highlight="Completions"> - { template.completionRow(context.title, "CompTitle") } - { template.map(context.items, function (item) context.createRow(item), null, 100) } - </div>); - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - }, + // Yes. If the [ starts at the beginning of a logical + // statement, we're in an array literal, and we're done. + if (get(-3, 0, STATEMENTS) == get(-2)[OFFSET]) + return null; - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// COMPLETION TYPES //////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + // Beginning of the statement upto the opening [ + let obj = getObj(-3, get(-2)[OFFSET]); - get javascriptCompleter() javascript, + return void complete.call(this, obj, getKey(), null, string, last); + } - javascript: function _javascript(context) javascript.complete(context), + // Is this a function call? + if (get(-2)[CHAR] == "(") { + // Stack: + // [-1]: "... + // [-2]: (... + // [-3]: base statement + + // Does the opening "(" mark a function call? + if (get(-3, 0, FUNCTIONS) != get(-2)[OFFSET]) + return; // No. We're done. + + let [offset, obj, func] = getObjKey(-3); + if (!obj.length) + return; + obj = obj.slice(0, 1); + + try { + var completer = obj[0][0][func].liberatorCompleter; + } + catch (e) {} + if (!completer) + completer = this.completers[func]; + if (!completer) + return; + + // Split up the arguments + let prev = get(-2)[OFFSET]; + let args = []; + for (let [i, idx] in Iterator(get(-2)[COMMA])) { + let arg = str.substring(prev + 1, idx); + prev = idx; + util.memoize(args, i, function () self.eval(arg)); + } + let key = getKey(); + args.push(key + string); + + compl = function (context, obj) { + let res = completer.call(self, context, func, obj, args); + if (res) + context.completions = res; + }; - // 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) - { - let numLocationCompletions = 0; // how many async completions did we already return to the caller? - let start = 0; - let skip = 0; + obj[0][1] += "." + func + "(... [" + args.length + "]"; + return void complete.call(this, obj, key, compl, string, last); + } - if (options["urlseparator"]) - skip = context.filter.match("^.*" + options["urlseparator"]); // start after the last 'urlseparator' + // In a string that's not an obj key or a function arg. + // Nothing to do. + return; + } - if (skip) - context.advance(skip[0].length); + // + // str = "foo.bar.baz" + // obj = "foo.bar" + // key = "baz" + // + // str = "foo" + // obj = [modules, window] + // key = "foo" + // - // Will, and should, throw an error if !(c in opts) - Array.forEach(complete || options["complete"], function (c) { - let completer = completion.urlCompleters[c]; - context.fork.apply(context, [c, 0, completion, completer.completer].concat(completer.args)); - }); - }, + let [offset, obj, key] = getObjKey(-1); - urlCompleters: {}, + // Wait for a keypress before completing the default objects. + if (!this.context.tabPressed && key == "" && obj.length > 1) { + this.context.waitingForTab = true; + this.context.message = "Waiting for key press"; + return; + } - addUrlCompleter: function addUrlCompleter(opt) - { - let completer = UrlCompleter.apply(null, Array.slice(arguments)); - completer.args = Array.slice(arguments, completer.length); - this.urlCompleters[opt] = completer; - }, + if (!/^(?:[a-zA-Z_$][\w$]*)?$/.test(key)) + return; // Not a word. Forget it. Can this even happen? - 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; + try { // FIXME + var o = top[OFFSET]; + top[OFFSET] = offset; + return void complete.call(this, obj, key); + } + finally { + top[OFFSET] = o; + } } - - 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.escapeRegex).join("|"), "g"); - function highlight(item, text, i) process[i].call(this, item, template.highlightRegexp(text, re)); - let process = [template.icon, function (item, k) k]; - context.process = [ - function (item, text) highlight.call(this, item, item.text, 0), - function (item, text) highlight.call(this, item, text, 1) - ]; - }); - } - //}}} - }; - - const UrlCompleter = new Struct("name", "description", "completer"); - - return self; - //}}} -} //}}} + }, + }, { + EVAL_TMP: "__liberator_eval_tmp", + }), +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/editor.js b/common/content/editor.js index 7b752a3f..2755d8ea 100644 --- a/common/content/editor.js +++ b/common/content/editor.js @@ -10,1187 +10,1064 @@ // http://developer.mozilla.org/en/docs/Editor_Embedding_Guide /** @instance editor */ -function Editor() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - // store our last search with f, F, t or T - var lastFindChar = null; - var lastFindCharFunc = null; - // XXX: this strikes me as a rather odd ds; everyone's a critic --djk - var abbreviations = {}; // abbreviations["lhr"][0]["{i,c,!}","rhs"] - - // (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). - // +const Editor = Module("editor", { + init: function () { + // store our last search with f, F, t or T + // + this._lastFindChar = null; + this._lastFindCharFunc = null; + // XXX: this strikes me as a rather odd ds; everyone's a critic --djk + this._abbreviations = {}; // this._abbreviations["lhr"][0]["{i,c,!}","rhs"] - let nonkw = "\\s\"'"; - let keyword = "[^" + nonkw + "]"; - let nonkeyword = "[" + nonkw + "]"; + // (summarized from Vim's ":help this._abbreviations") + // + // There are three types of this._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 this._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 fullId = keyword + "+"; - let endId = nonkeyword + "+" + keyword; - let nonId = "\\S*" + nonkeyword; + let nonkw = "\\s\"'"; + let keyword = "[^" + nonkw + "]"; + let nonkeyword = "[" + nonkw + "]"; - // Used in addAbbrevation and expandAbbreviation - var abbrevmatch = fullId + "|" + endId + "|" + nonId; + let fullId = keyword + "+"; + let endId = nonkeyword + "+" + keyword; + let nonId = "\\S*" + nonkeyword; - function getEditor() liberator.focus; + // Used in addAbbrevation and expandAbbreviation + this._abbrevmatch = fullId + "|" + endId + "|" + nonId; - function getController() - { - let ed = getEditor(); - if (!ed || !ed.controllers) - return null; + }, - return ed.controllers.getControllerForCommand("cmd_beginLine"); - } - 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"); - } + // For the record, some of this code I've just finished throwing + // away makes me want to pull someone else's hair out. --Kris + abbrevs: function () { + for (let [lhs, abbr] in Iterator(this._abbreviations)) + for (let [, rhs] in Iterator(abbr)) + yield [lhs, rhs]; + }, + + line: function () { + let line = 1; + let text = Editor.getEditor().value; + for (let i = 0; i < Editor.getEditor().selectionStart; i++) + if (text[i] == "\n") + line++; + return line; + }, + + col: function () { + let col = 1; + let text = Editor.getEditor().value; + for (let i = 0; i < Editor.getEditor().selectionStart; i++) { + col++; + if (text[i] == "\n") + col = 1; + } + return col; + }, + + unselectText: function () { + let elem = liberator.focus; + // A error occurs if the element has been removed when "elem.selectionStart" is executed. + try { + if (elem && elem.selectionEnd) + elem.selectionEnd = elem.selectionStart; + } + catch (e) {} + }, + + selectedText: function () { + let text = Editor.getEditor().value; + return text.substring(Editor.getEditor().selectionStart, Editor.getEditor().selectionEnd); + }, + + pasteClipboard: function () { + if (liberator.has("Win32")) { + this.executeCommand("cmd_paste"); + return; + } - // add mappings for commands like h,j,k,l,etc. in CARET, VISUAL and TEXTAREA mode - function addMovementMap(keys, hasCount, caretModeMethod, caretModeArg, textareaCommand, visualTextareaCommand) - { - let extraInfo = {}; - if (hasCount) - extraInfo.count = true; + // FIXME: #93 (<s-insert> in the bottom of a long textarea bounces up) + let elem = liberator.focus; + + if (elem.setSelectionRange && util.readFromClipboard()) { + // readFromClipboard would return 'undefined' if not checked + // dunno about .setSelectionRange + // This is a hacky fix - but it works. + let curTop = elem.scrollTop; + let curLeft = elem.scrollLeft; + + let rangeStart = elem.selectionStart; // caret position + let rangeEnd = elem.selectionEnd; + let tempStr1 = elem.value.substring(0, rangeStart); + let tempStr2 = util.readFromClipboard(); + let tempStr3 = elem.value.substring(rangeEnd); + elem.value = tempStr1 + tempStr2 + tempStr3; + elem.selectionStart = rangeStart + tempStr2.length; + elem.selectionEnd = elem.selectionStart; + + elem.scrollTop = curTop; + elem.scrollLeft = curLeft; + } + }, - mappings.add([modes.CARET], keys, "", - function (count) - { - if (typeof count != "number" || count < 1) - count = 1; + // count is optional, defaults to 1 + executeCommand: function (cmd, count) { + let controller = Editor.getController(); + if (!controller || !controller.supportsCommand(cmd) || !controller.isCommandEnabled(cmd)) { + liberator.beep(); + return false; + } - let controller = buffer.selectionController; - while (count--) - controller[caretModeMethod](caretModeArg, false); - }, - extraInfo); + if (typeof count != "number" || count < 1) + 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 { + controller.doCommand(cmd); + didCommand = true; + } + catch (e) { + if (!didCommand) + liberator.beep(); + return false; + } + } - mappings.add([modes.VISUAL], keys, "", - function (count) - { - if (typeof count != "number" || count < 1 || !hasCount) - count = 1; + return true; + }, - let controller = buffer.selectionController; - while (count--) - { - if (modes.extended & modes.TEXTAREA) - { - if (typeof visualTextareaCommand == "function") - visualTextareaCommand(); - else - editor.executeCommand(visualTextareaCommand); - } - else - controller[caretModeMethod](caretModeArg, true); - } - }, - extraInfo); + // cmd = y, d, c + // motion = b, 0, gg, G, etc. + executeCommandWithMotion: function (cmd, motion, count) { + if (typeof count != "number" || count < 1) + count = 1; - mappings.add([modes.TEXTAREA], keys, "", - function (count) - { - if (typeof count != "number" || count < 1) - count = 1; + if (cmd == motion) { + motion = "j"; + count--; + } - editor.executeCommand(textareaCommand, count); - }, - extraInfo); - } + modes.set(modes.VISUAL, modes.TEXTAREA); + + 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: + liberator.beep(); + return false; + } - // add mappings for commands like i,a,s,c,etc. in TEXTAREA mode - function addBeginInsertModeMap(keys, commands) - { - mappings.add([modes.TEXTAREA], keys, "", - function (count) - { - commands.forEach(function (cmd) - editor.executeCommand(cmd, 1)); - modes.set(modes.INSERT, modes.TEXTAREA); - }); - } + switch (cmd) { + case "d": + this.executeCommand("cmd_delete", 1); + // need to reset the mode as the visual selection changes it + modes.main = modes.TEXTAREA; + break; + case "c": + this.executeCommand("cmd_delete", 1); + modes.set(modes.INSERT, modes.TEXTAREA); + break; + case "y": + this.executeCommand("cmd_copy", 1); + this.unselectText(); + break; - function addMotionMap(key) - { - mappings.add([modes.TEXTAREA], [key], - "Motion command", - function (motion, count) { editor.executeCommandWithMotion(key, motion, count); }, - { count: true, motion: true }); - } + default: + liberator.beep(); + return false; + } + return true; + }, + + // 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; + } - // For the record, some of this code I've just finished throwing - // away makes me want to pull someone else's hair out. --Kris - function abbrevs() - { - for (let [lhs, abbr] in Iterator(abbreviations)) - for (let [, rhs] in Iterator(abbr)) - yield [lhs, rhs]; - } + if (forward) { + if (pos <= Editor.getEditor().selectionEnd || pos > Editor.getEditor().value.length) + return false; - // mode = "i" -> add :iabbrev, :iabclear and :iunabbrev commands - function addAbbreviationCommands(ch, modeDescription) - { - let mode = ch || "!"; - modeDescription = modeDescription ? " in " + modeDescription + " mode" : ""; - - commands.add([ch ? ch + "a[bbrev]" : "ab[breviate]"], - "Abbreviate a key sequence" + modeDescription, - function (args) - { - let matches = args.string.match(RegExp("^\\s*($|" + abbrevmatch + ")(?:\\s*$|\\s+(.*))")); - liberator.assert(matches, "E474: Invalid argument"); - - let [, lhs, rhs] = matches; - if (rhs) - editor.addAbbreviation(mode, lhs, rhs); - else - editor.listAbbreviations(mode, lhs || ""); - }, - { - completer: function (context, args) completion.abbreviation(context, args, mode), - literal: 0, - serial: function () [ - { - command: this.name, - arguments: [lhs], - literalArg: abbr[1] - } - for ([lhs, abbr] in abbrevs()) - if (abbr[0] == mode) - ] - }); + 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 false; - commands.add([ch ? ch + "una[bbrev]" : "una[bbreviate]"], - "Remove an abbreviation" + modeDescription, - function (args) { editor.removeAbbreviation(mode, args.literalArg); }, - { - argCount: "1", - completer: function (context, args) completion.abbreviation(context, args, mode), - literal: 0 - }); + do { // TODO: test code for endless loops + this.executeCommand("cmd_selectCharPrevious", 1); + } + while (Editor.getEditor().selectionStart != pos); + } + }, - commands.add([ch + "abc[lear]"], - "Remove all abbreviations" + modeDescription, - function () { editor.removeAllAbbreviations(mode); }, - { argCount: "0" }); - } + // returns the position of char + findCharForward: function (ch, count) { + if (!Editor.getEditor()) + return -1; - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["editor"], - "Set the external text editor", - "string", "gvim -f"); - - options.add(["insertmode", "im"], - "Use Insert mode as the default for text areas", - "boolean", true); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var myModes = [modes.INSERT, modes.COMMAND_LINE]; - - // KEYS COUNT CARET TEXTAREA VISUAL_TEXTAREA - addMovementMap(["k", "<Up>"], true, "lineMove", false, "cmd_linePrevious", selectPreviousLine); - addMovementMap(["j", "<Down>", "<Return>"], true, "lineMove", true, "cmd_lineNext", selectNextLine); - addMovementMap(["h", "<Left>", "<BS>"], true, "characterMove", false, "cmd_charPrevious", "cmd_selectCharPrevious"); - addMovementMap(["l", "<Right>", "<Space>"], true, "characterMove", true, "cmd_charNext", "cmd_selectCharNext"); - addMovementMap(["b", "B", "<C-Left>"], true, "wordMove", false, "cmd_wordPrevious", "cmd_selectWordPrevious"); - addMovementMap(["w", "W", "e", "<C-Right>"], true, "wordMove", true, "cmd_wordNext", "cmd_selectWordNext"); - addMovementMap(["<C-f>", "<PageDown>"], true, "pageMove", true, "cmd_movePageDown", "cmd_selectNextPage"); - addMovementMap(["<C-b>", "<PageUp>"], true, "pageMove", false, "cmd_movePageUp", "cmd_selectPreviousPage"); - addMovementMap(["gg", "<C-Home>"], false, "completeMove", false, "cmd_moveTop", "cmd_selectTop"); - addMovementMap(["G", "<C-End>"], false, "completeMove", true, "cmd_moveBottom", "cmd_selectBottom"); - addMovementMap(["0", "^", "<Home>"], false, "intraLineMove", false, "cmd_beginLine", "cmd_selectBeginLine"); - addMovementMap(["$", "<End>"], false, "intraLineMove", true, "cmd_endLine" , "cmd_selectEndLine" ); - - addBeginInsertModeMap(["i", "<Insert>"], []); - addBeginInsertModeMap(["a"], ["cmd_charNext"]); - addBeginInsertModeMap(["I", "gI"], ["cmd_beginLine"]); - addBeginInsertModeMap(["A"], ["cmd_endLine"]); - addBeginInsertModeMap(["s"], ["cmd_deleteCharForward"]); - addBeginInsertModeMap(["S"], ["cmd_deleteToEndOfLine", "cmd_deleteToBeginningOfLine"]); - addBeginInsertModeMap(["C"], ["cmd_deleteToEndOfLine"]); - - addMotionMap("d"); // delete - addMotionMap("c"); // change - addMotionMap("y"); // yank - - // insert mode mappings - mappings.add(myModes, - ["<C-w>"], "Delete previous word", - function () { editor.executeCommand("cmd_deleteWordBackward", 1); }); - - mappings.add(myModes, - ["<C-u>"], "Delete until beginning of current line", - function () - { - // broken in FF3, deletes the whole line: - // editor.executeCommand("cmd_deleteToBeginningOfLine", 1); - editor.executeCommand("cmd_selectBeginLine", 1); - if (getController().isCommandEnabled("cmd_delete")) - editor.executeCommand("cmd_delete", 1); - }); - - mappings.add(myModes, - ["<C-k>"], "Delete until end of current line", - function () { editor.executeCommand("cmd_deleteToEndOfLine", 1); }); - - mappings.add(myModes, - ["<C-a>"], "Move cursor to beginning of current line", - function () { editor.executeCommand("cmd_beginLine", 1); }); - - mappings.add(myModes, - ["<C-e>"], "Move cursor to end of current line", - function () { editor.executeCommand("cmd_endLine", 1); }); - - mappings.add(myModes, - ["<C-h>"], "Delete character to the left", - function () { editor.executeCommand("cmd_deleteCharBackward", 1); }); - - mappings.add(myModes, - ["<C-d>"], "Delete character to the right", - function () { editor.executeCommand("cmd_deleteCharForward", 1); }); - - /*mappings.add(myModes, - ["<C-Home>"], "Move cursor to beginning of text field", - function () { editor.executeCommand("cmd_moveTop", 1); }); - - mappings.add(myModes, - ["<C-End>"], "Move cursor to end of text field", - function () { editor.executeCommand("cmd_moveBottom", 1); });*/ - - mappings.add(myModes, - ["<S-Insert>"], "Insert clipboard/selection", - function () { editor.pasteClipboard(); }); - - mappings.add([modes.INSERT, modes.TEXTAREA, modes.COMPOSE], - ["<C-i>"], "Edit text field with an external editor", - function () { editor.editFieldExternally(); }); - - mappings.add([modes.INSERT], - ["<C-t>"], "Edit text field in Vi mode", - function () { liberator.mode = modes.TEXTAREA; }); - - mappings.add([modes.INSERT], - ["<Space>", "<Return>"], "Expand insert mode abbreviation", - function () { editor.expandAbbreviation("i"); }, - { route: true }); - - mappings.add([modes.INSERT], - ["<Tab>"], "Expand insert mode abbreviation", - function () { editor.expandAbbreviation("i"); document.commandDispatcher.advanceFocus(); }); - - mappings.add([modes.INSERT], - ["<C-]>", "<C-5>"], "Expand insert mode abbreviation", - function () { editor.expandAbbreviation("i"); }); - - // textarea mode - mappings.add([modes.TEXTAREA], - ["u"], "Undo", - function (count) - { - editor.executeCommand("cmd_undo", count); - liberator.mode = modes.TEXTAREA; - }, - { count: true }); - - mappings.add([modes.TEXTAREA], - ["<C-r>"], "Redo", - function (count) - { - editor.executeCommand("cmd_redo", count); - liberator.mode = modes.TEXTAREA; - }, - { count: true }); - - mappings.add([modes.TEXTAREA], - ["D"], "Delete the characters under the cursor until the end of the line", - function () { editor.executeCommand("cmd_deleteToEndOfLine"); }); - - mappings.add([modes.TEXTAREA], - ["o"], "Open line below current", - function (count) - { - editor.executeCommand("cmd_endLine", 1); - modes.set(modes.INSERT, modes.TEXTAREA); - events.feedkeys("<Return>"); - }); - - mappings.add([modes.TEXTAREA], - ["O"], "Open line above current", - function (count) - { - editor.executeCommand("cmd_beginLine", 1); - modes.set(modes.INSERT, modes.TEXTAREA); - events.feedkeys("<Return>"); - editor.executeCommand("cmd_linePrevious", 1); - }); - - mappings.add([modes.TEXTAREA], - ["X"], "Delete character to the left", - function (count) { editor.executeCommand("cmd_deleteCharBackward", count); }, - { count: true }); - - mappings.add([modes.TEXTAREA], - ["x"], "Delete character to the right", - function (count) { editor.executeCommand("cmd_deleteCharForward", count); }, - { count: true }); - - // visual mode - mappings.add([modes.CARET, modes.TEXTAREA], - ["v"], "Start visual mode", - function (count) { modes.set(modes.VISUAL, liberator.mode); }); - - mappings.add([modes.VISUAL], - ["v"], "End visual mode", - function (count) { events.onEscape(); }); - - mappings.add([modes.TEXTAREA], - ["V"], "Start visual line mode", - function (count) - { - modes.set(modes.VISUAL, modes.TEXTAREA | modes.LINE); - editor.executeCommand("cmd_beginLine", 1); - editor.executeCommand("cmd_selectLineNext", 1); - }); - - mappings.add([modes.VISUAL], - ["c", "s"], "Change selected text", - function (count) - { - if (modes.extended & modes.TEXTAREA) - { - editor.executeCommand("cmd_cut"); - modes.set(modes.INSERT, modes.TEXTAREA); - } - else - liberator.beep(); - }); - - mappings.add([modes.VISUAL], - ["d"], "Delete selected text", - function (count) - { - if (modes.extended & modes.TEXTAREA) - { - editor.executeCommand("cmd_cut"); - modes.set(modes.TEXTAREA); - } - else - liberator.beep(); - }); - - mappings.add([modes.VISUAL], - ["y"], "Yank selected text", - function (count) - { - if (modes.extended & modes.TEXTAREA) - { - editor.executeCommand("cmd_copy"); - modes.set(modes.TEXTAREA); - } - else - { - let sel = window.content.document.getSelection(); - if (sel) - util.copyToClipboard(sel, true); - else - liberator.beep(); - } - }); - - mappings.add([modes.VISUAL, modes.TEXTAREA], - ["p"], "Paste clipboard contents", - function (count) - { - if (!(modes.extended & modes.CARET)) - { - if (!count) count = 1; - while (count--) - editor.executeCommand("cmd_paste"); - liberator.mode = modes.TEXTAREA; - } - else - liberator.beep(); - }); - - // finding characters - mappings.add([modes.TEXTAREA, modes.VISUAL], - ["f"], "Move to a character on the current line after the cursor", - function (count, arg) - { - let pos = editor.findCharForward(arg, count); - if (pos >= 0) - editor.moveToPosition(pos, true, liberator.mode == modes.VISUAL); - }, - { arg: true, count: true }); - - mappings.add([modes.TEXTAREA, modes.VISUAL], - ["F"], "Move to a charater on the current line before the cursor", - function (count, arg) - { - let pos = editor.findCharBackward(arg, count); - if (pos >= 0) - editor.moveToPosition(pos, false, liberator.mode == modes.VISUAL); - }, - { arg: true, count: true }); - - mappings.add([modes.TEXTAREA, modes.VISUAL], - ["t"], "Move before a character on the current line", - function (count, arg) - { - let pos = editor.findCharForward(arg, count); - if (pos >= 0) - editor.moveToPosition(pos - 1, true, liberator.mode == modes.VISUAL); - }, - { arg: true, count: true }); - - mappings.add([modes.TEXTAREA, modes.VISUAL], - ["T"], "Move before a character on the current line, backwards", - function (count, arg) - { - let pos = editor.findCharBackward(arg, count); - if (pos >= 0) - editor.moveToPosition(pos + 1, false, liberator.mode == modes.VISUAL); - }, - { arg: true, count: true }); - - // textarea and visual mode - mappings.add([modes.TEXTAREA, 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 = getEditor().selectionEnd - getEditor().selectionStart; - if (typeof count != "number" || count < 1) - count = 1; - - while (count-- > 0) - { - let text = getEditor().value; - let pos = getEditor().selectionStart; - if (pos >= text.length) - return void liberator.beep(); - let chr = text[pos]; - getEditor().value = text.substring(0, pos) + - (chr == chr.toLocaleLowerCase() ? chr.toLocaleUpperCase() : chr.toLocaleLowerCase()) + - text.substring(pos + 1); - editor.moveToPosition(pos + 1, true, false); - } - modes.set(modes.TEXTAREA); - }, - { count: true }); + this._lastFindChar = ch; + this._lastFindCharFunc = this.findCharForward; + + let text = Editor.getEditor().value; + if (!typeof count == "number" || count < 1) + count = 1; - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + 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 + } - addAbbreviationCommands("", ""); - addAbbreviationCommands("i", "insert"); - addAbbreviationCommands("c", "command line"); + liberator.beep(); + return -1; + }, - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + // returns the position of char + findCharBackward: function (ch, count) { + if (!Editor.getEditor()) + return -1; - liberator.registerObserver("load_completion", function () { - // TODO: shouldn't all of these have a standard signature (context, args, ...)? --djk - completion.abbreviation = function abbreviation(context, args, mode) { - mode = mode || "!"; + this._lastFindChar = ch; + this._lastFindCharFunc = this.findCharBackward; - if (args.completeArg == 0) - { - let abbreviations = editor.getAbbreviations(mode); - context.completions = [[lhs, ""] for ([, [, lhs,]] in Iterator(abbreviations))]; - } - }; - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - line: function () - { - let line = 1; - let text = getEditor().value; - for (let i = 0; i < getEditor().selectionStart; i++) - if (text[i] == "\n") - line++; - return line; - }, - - col: function () - { - let col = 1; - let text = getEditor().value; - for (let i = 0; i < getEditor().selectionStart; i++) - { - col++; - if (text[i] == "\n") - col = 1; - } - return col; - }, - - unselectText: function () - { - let elem = liberator.focus; - // A error occurs if the element has been removed when "elem.selectionStart" is executed. - try - { - if (elem && elem.selectionEnd) - elem.selectionEnd = elem.selectionStart; - } - catch (e) {} - }, - - selectedText: function () - { - let text = getEditor().value; - return text.substring(getEditor().selectionStart, getEditor().selectionEnd); - }, - - pasteClipboard: function () - { - if (liberator.has("Win32")) - { - this.executeCommand("cmd_paste"); - return; - } + let text = Editor.getEditor().value; + if (!typeof count == "number" || count < 1) + count = 1; - // FIXME: #93 (<s-insert> in the bottom of a long textarea bounces up) - let elem = liberator.focus; - - if (elem.setSelectionRange && util.readFromClipboard()) - // readFromClipboard would return 'undefined' if not checked - // dunno about .setSelectionRange - { - // This is a hacky fix - but it works. - let curTop = elem.scrollTop; - let curLeft = elem.scrollLeft; - - let rangeStart = elem.selectionStart; // caret position - let rangeEnd = elem.selectionEnd; - let tempStr1 = elem.value.substring(0, rangeStart); - let tempStr2 = util.readFromClipboard(); - let tempStr3 = elem.value.substring(rangeEnd); - elem.value = tempStr1 + tempStr2 + tempStr3; - elem.selectionStart = rangeStart + tempStr2.length; - elem.selectionEnd = elem.selectionStart; + 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; + } - elem.scrollTop = curTop; - elem.scrollLeft = curLeft; - } - }, - - // count is optional, defaults to 1 - executeCommand: function (cmd, count) - { - let controller = getController(); - if (!controller || !controller.supportsCommand(cmd) || !controller.isCommandEnabled(cmd)) - { - liberator.beep(); - return false; - } + liberator.beep(); + return -1; + }, - if (typeof count != "number" || count < 1) - 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 - { - controller.doCommand(cmd); - didCommand = true; + editFileExternally: function (path) { + // TODO: save return value in v:shell_error + let args = commands.parseArgs(options["editor"], [], "*", true); + + liberator.assert(args.length >= 1, "No editor specified"); + + args.push(path); + liberator.callFunctionInThread(null, io.run, io.expandPath(args.shift()), args, true); + }, + + // TODO: clean up with 2 functions for textboxes and currentEditor? + editFieldExternally: function (forceEditing) { + if (!options["editor"]) + return false; + + let textBox = null; + if (!(config.isComposeWindow)) + textBox = liberator.focus; + + 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)) + return editor.editFieldExternally(true); + }); + return; + } + + let text = ""; // XXX + if (textBox) + text = textBox.value; + else if (typeof GetCurrentEditor == "function") // Thunderbird composer + text = GetCurrentEditor().outputToString("text/plain", 2); + else + return false; + + let oldBg, tmpBg; + try { + let res = io.withTempFiles(function (tmpfile) { + if (textBox) { + textBox.setAttribute("readonly", "true"); + oldBg = textBox.style.backgroundColor; + tmpBg = "yellow"; + textBox.style.backgroundColor = "#bbbbbb"; } - catch (e) - { - if (!didCommand) - liberator.beep(); - return false; + + if (!tmpfile.write(text)) + throw Error("Input contains characters not valid in the current " + + "file encoding"); + + this.editFileExternally(tmpfile.path); + + if (textBox) + textBox.removeAttribute("readonly"); + + let val = tmpfile.read(); + if (textBox) + textBox.value = val; + else { + let editor = GetCurrentEditor(); + let wholeDocRange = editor.document.createRange(); + let rootNode = editor.rootElement.QueryInterface(Ci.nsIDOMNode); + wholeDocRange.selectNodeContents(rootNode); + editor.selection.addRange(wholeDocRange); + editor.selection.deleteFromDocument(); + editor.insertText(val); } - } + }, this); + if (res == false) + throw "Couldn't create temporary file"; + } + catch (e) { + // Errors are unlikely, and our error messages won't + // likely be any more helpful than that given in the + // exception. + liberator.echoerr(e); + tmpBg = "red"; + } - return true; - }, - - // cmd = y, d, c - // motion = b, 0, gg, G, etc. - executeCommandWithMotion: function (cmd, motion, count) - { - if (typeof count != "number" || count < 1) - count = 1; - - if (cmd == motion) - { - motion = "j"; - count--; - } + // blink the textbox after returning + if (textBox) { + let colors = [tmpBg, oldBg, tmpBg, oldBg]; + (function () { + textBox.style.backgroundColor = colors.shift(); + if (colors.length > 0) + setTimeout(arguments.callee, 100); + })(); + } - modes.set(modes.VISUAL, modes.TEXTAREA); + return true; + }, - 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; + // Abbreviations {{{ - default: - liberator.beep(); - return false; - } + // NOTE: I think this comment block is trying to say something but no + // one is listening. In space, no one can hear you scream. --djk + // + // System for adding this._abbreviations: + // + // filter == ! delete all, and set first (END) + // + // if filter == ! remove all and add it as only END + // + // variant 1: rhs matches anywhere in loop + // + // 1 mod matches anywhere in loop + // a) simple replace and + // I) (maybe there's another rhs that matches? not possible) + // (when there's another item, it's opposite mod with different rhs) + // (so do nothing further but END) + // + // 2 mod does not match + // a) the opposite is there -> make a ! and put it as only and END + // (b) a ! is there. do nothing END) + // + // variant 2: rhs matches *no*were in loop and filter is c or i + // every kind of current combo is possible to 1 {c,i,!} or two {c and i} + // + // 1 mod is ! split into two i + c END + // 1 not !: opposite mode (first), add/change 'second' and END + // 1 not !: same mode (first), overwrite first this END + // + // TODO: I don't like these funky filters, I am a funky filter hater. --djk + // : make this a separate object + // : use Struct for individual this._abbreviations + // : rename "filter" arg "mode" + /** + * Adds a new abbreviation. Abbreviations consist of a LHS (the text + * that is replaced when the abbreviation is expanded) and a RHS (the + * replacement text). + * + * @param {string} filter The mode filter. This specifies the modes in + * which this abbreviation is available. Either: + * "c" - applies in command-line mode + * "i" - applies in insert mode + * "!" - applies in both command-line and insert modes + * @param {string} lhs The LHS of the abbreviation. + * @param {string} rhs The RHS of the abbreviation. + */ + addAbbreviation: function (filter, lhs, rhs) { + if (!this._abbreviations[lhs]) { + this._abbreviations[lhs] = []; + this._abbreviations[lhs][0] = [filter, rhs]; + return; + } - switch (cmd) - { - case "d": - this.executeCommand("cmd_delete", 1); - // need to reset the mode as the visual selection changes it - modes.main = modes.TEXTAREA; - break; - case "c": - this.executeCommand("cmd_delete", 1); - modes.set(modes.INSERT, modes.TEXTAREA); - break; - case "y": - this.executeCommand("cmd_copy", 1); - this.unselectText(); - break; + if (filter == "!") { + if (this._abbreviations[lhs][1]) + this._abbreviations[lhs][1] = ""; + this._abbreviations[lhs][0] = [filter, rhs]; + return; + } - default: - liberator.beep(); - return false; - } - return true; - }, - - // 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) - { - getEditor().setSelectionRange(pos, pos); - return; + for (let i = 0; i < this._abbreviations[lhs].length; i++) { + if (this._abbreviations[lhs][i][1] == rhs) { + if (this._abbreviations[lhs][i][0] == filter) { + this._abbreviations[lhs][i] = [filter, rhs]; + return; + } + else { + if (this._abbreviations[lhs][i][0] != "!") { + if (this._abbreviations[lhs][1]) + this._abbreviations[lhs][1] = ""; + this._abbreviations[lhs][0] = ["!", rhs]; + return; + } + else + return; + } } + } + + if (this._abbreviations[lhs][0][0] == "!") { + let tmpOpp = ("i" == filter) ? "c" : "i"; + this._abbreviations[lhs][1] = [tmpOpp, this._abbreviations[lhs][0][1]]; + this._abbreviations[lhs][0] = [filter, rhs]; + return; + } - if (forward) - { - if (pos <= getEditor().selectionEnd || pos > getEditor().value.length) - return false; + if (this._abbreviations[lhs][0][0] != filter) + this._abbreviations[lhs][1] = [filter, rhs]; + else + this._abbreviations[lhs][0] = [filter, rhs]; + }, + + /** + * Expands an abbreviation in the currently active textbox. + * + * @param {string} filter The mode filter. + * @see #addAbbreviation + */ + expandAbbreviation: function (filter) { + let textbox = Editor.getEditor(); + if (!textbox) + return; + let text = textbox.value; + let currStart = textbox.selectionStart; + let currEnd = textbox.selectionEnd; + let foundWord = text.substring(0, currStart).replace(RegExp("^(.|\\n)*?\\s*(" + this._abbrevmatch + ")$", "m"), "$2"); // get last word \b word boundary + if (!foundWord) + return true; - do // TODO: test code for endless loops - { - this.executeCommand("cmd_selectCharNext", 1); + for (let lhs in this._abbreviations) { + for (let i = 0; i < this._abbreviations[lhs].length; i++) { + if (lhs == foundWord && (this._abbreviations[lhs][i][0] == filter || this._abbreviations[lhs][i][0] == "!")) { + // if found, replace accordingly + let len = foundWord.length; + let abbrText = this._abbreviations[lhs][i][1]; + text = text.substring(0, currStart - len) + abbrText + text.substring(currStart); + textbox.value = text; + textbox.selectionStart = currStart - len + abbrText.length; + textbox.selectionEnd = currEnd - len + abbrText.length; + break; } - while (getEditor().selectionEnd != pos); } - else - { - if (pos >= getEditor().selectionStart || pos < 0) - return false; - - do // TODO: test code for endless loops - { - this.executeCommand("cmd_selectCharPrevious", 1); + } + return true; + }, + + /** + * Returns all this._abbreviations matching <b>filter</b> and <b>lhs</b>. + * + * @param {string} filter The mode filter. + * @param {string} lhs The LHS of the abbreviation. + * @returns {Array} The matching this._abbreviations [mode, lhs, rhs] + * @see #addAbbreviation + */ + getAbbreviations: function (filter, lhs) { + // ! -> list all, on c or i ! matches too + let searchFilter = (filter == "!") ? "!ci" : filter + "!"; + return list = [[mode, left, right] for ([left, [mode, right]] in this.abbrevs()) + if (searchFilter.indexOf(mode) >= 0 && left.indexOf(lhs || "") == 0)]; + }, + + /** + * Lists all this._abbreviations matching <b>filter</b> and <b>lhs</b>. + * + * @param {string} filter The mode filter. + * @param {string} lhs The LHS of the abbreviation. + * @see #addAbbreviation + */ + listAbbreviations: function (filter, lhs) { + let list = this.getAbbreviations(filter, lhs); + + if (!list.length) + liberator.echomsg("No this._abbreviations found"); + else if (list.length == 1) { + let [mode, lhs, rhs] = list[0]; + + liberator.echo(mode + " " + lhs + " " + rhs, commandline.FORCE_SINGLELINE); // 2 spaces, 3 spaces + } + else { + list = template.tabular(["", "LHS", "RHS"], [], list); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + }, + + /** + * Deletes all this._abbreviations matching <b>filter</b> and <b>lhs</b>. + * + * @param {string} filter The mode filter. + * @param {string} lhs The LHS of the abbreviation. + * @see #addAbbreviation + */ + removeAbbreviation: function (filter, lhs) { + if (!lhs) { + liberator.echoerr("E474: Invalid argument"); + return false; + } + + if (this._abbreviations[lhs]) { // this._abbreviations exists + if (filter == "!") { + this._abbreviations[lhs] = ""; + return true; + } + else { + if (!this._abbreviations[lhs][1]) { // only one exists + if (this._abbreviations[lhs][0][0] == "!") { // exists as ! -> no 'full' delete + this._abbreviations[lhs][0][0] = (filter == "i") ? "c" : "i"; // ! - i = c; ! - c = i + return true; + } + else if (this._abbreviations[lhs][0][0] == filter) { + this._abbreviations[lhs] = ""; + return true; + } + } + else { // two this._abbreviations exist ( 'i' or 'c' (filter as well)) + if (this._abbreviations[lhs][0][0] == "c" && filter == "c") + this._abbreviations[lhs][0] = this._abbreviations[lhs][1]; + + this._abbreviations[lhs][1] = ""; + + return true; } - while (getEditor().selectionStart != pos); } - }, + } - // returns the position of char - findCharForward: function (ch, count) - { - if (!getEditor()) - return -1; + liberator.echoerr("E24: No such abbreviation"); + return false; + }, + + /** + * Removes all this._abbreviations matching <b>filter</b>. + * + * @param {string} filter The mode filter. + * @see #addAbbreviation + */ + removeAllAbbreviations: function (filter) { + let searchFilter = (filter == "!") ? "!ci" : filter + "!"; + for (let [lhs, [mode, rhs]] in this.abbrevs()) + if (searchFilter.indexOf(mode) >= 0) + this.removeAbbreviation(filter, lhs); + } +}, { + getEditor: function () liberator.focus, - lastFindChar = ch; - lastFindCharFunc = this.findCharForward; + getController: function () { + let ed = Editor.getEditor(); + if (!ed || !ed.controllers) + return null; - let text = getEditor().value; - if (!typeof count == "number" || count < 1) - count = 1; + return ed.controllers.getControllerForCommand("cmd_beginLine"); + }, + +}, { + commands: function () { + // mode = "i" -> add :iabbrev, :iabclear and :iunabbrev commands + function addAbbreviationCommands(ch, modeDescription) { + let mode = ch || "!"; + modeDescription = modeDescription ? " in " + modeDescription + " mode" : ""; + + commands.add([ch ? ch + "a[bbrev]" : "ab[breviate]"], + "Abbreviate a key sequence" + modeDescription, + function (args) { + let matches = args.string.match(RegExp("^\\s*($|" + this._abbrevmatch + ")(?:\\s*$|\\s+(.*))")); + liberator.assert(matches, "E474: Invalid argument"); + + let [, lhs, rhs] = matches; + if (rhs) + editor.addAbbreviation(mode, lhs, rhs); + else + editor.listAbbreviations(mode, lhs || ""); + }, { + completer: function (context, args) completion.abbreviation(context, args, mode), + literal: 0, + serial: function () [ { + command: this.name, + arguments: [lhs], + literalArg: abbr[1] + } + for ([lhs, abbr] in editor.abbrevs()) + if (abbr[0] == mode) + ] + }); + + commands.add([ch ? ch + "una[bbrev]" : "una[bbreviate]"], + "Remove an abbreviation" + modeDescription, + function (args) { editor.removeAbbreviation(mode, args.literalArg); }, { + argCount: "1", + completer: function (context, args) completion.abbreviation(context, args, mode), + literal: 0 + }); + + commands.add([ch + "abc[lear]"], + "Remove all this._abbreviations" + modeDescription, + function () { editor.removeAllAbbreviations(mode); }, + { argCount: "0" }); + } - for (let i = 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 - } + addAbbreviationCommands("", ""); + addAbbreviationCommands("i", "insert"); + addAbbreviationCommands("c", "command line"); + }, - liberator.beep(); - return -1; - }, + completion: function () { + // TODO: shouldn't all of these have a standard signature (context, args, ...)? --djk + completion.abbreviation = function abbreviation(context, args, mode) { + mode = mode || "!"; - // returns the position of char - findCharBackward: function (ch, count) - { - if (!getEditor()) - return -1; + if (args.completeArg == 0) { + let abbreviations = editor.getAbbreviations(mode); + context.completions = [[lhs, ""] for ([, [, lhs,]] in Iterator(abbreviations))]; + } + }; + }, + + mappings: function () { + var myModes = [modes.INSERT, modes.COMMAND_LINE]; + + // add mappings for commands like h,j,k,l,etc. in CARET, VISUAL and TEXTAREA mode + function addMovementMap(keys, hasCount, caretModeMethod, caretModeArg, textareaCommand, visualTextareaCommand) { + let extraInfo = {}; + if (hasCount) + extraInfo.count = true; + + mappings.add([modes.CARET], keys, "", + function (count) { + if (typeof count != "number" || count < 1) + count = 1; + + let controller = buffer.selectionController; + while (count--) + controller[caretModeMethod](caretModeArg, false); + }, + extraInfo); + + mappings.add([modes.VISUAL], keys, "", + function (count) { + if (typeof count != "number" || count < 1 || !hasCount) + count = 1; + + let controller = buffer.selectionController; + while (count--) { + if (modes.extended & modes.TEXTAREA) { + if (typeof visualTextareaCommand == "function") + visualTextareaCommand(); + else + editor.executeCommand(visualTextareaCommand); + } + else + controller[caretModeMethod](caretModeArg, true); + } + }, + extraInfo); - lastFindChar = ch; - lastFindCharFunc = this.findCharBackward; + mappings.add([modes.TEXTAREA], keys, "", + function (count) { + if (typeof count != "number" || count < 1) + count = 1; - let text = getEditor().value; - if (!typeof count == "number" || count < 1) - count = 1; + editor.executeCommand(textareaCommand, count); + }, + extraInfo); + } - for (let i = getEditor().selectionStart - 1; i >= 0; i--) - { - if (text[i] == "\n") - break; - if (text[i] == ch) - count--; - if (count == 0) - return i; - } + // add mappings for commands like i,a,s,c,etc. in TEXTAREA mode + function addBeginInsertModeMap(keys, commands) { + mappings.add([modes.TEXTAREA], keys, "", + function (count) { + commands.forEach(function (cmd) + editor.executeCommand(cmd, 1)); + modes.set(modes.INSERT, modes.TEXTAREA); + }); + } - liberator.beep(); - return -1; - }, + function addMotionMap(key) { + mappings.add([modes.TEXTAREA], [key], + "Motion command", + function (motion, count) { editor.executeCommandWithMotion(key, motion, count); }, + { count: true, motion: true }); + } + 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"); + } - editFileExternally: function (path) - { - // TODO: save return value in v:shell_error - let args = commands.parseArgs(options["editor"], [], "*", true); + // KEYS COUNT CARET TEXTAREA VISUAL_TEXTAREA + addMovementMap(["k", "<Up>"], true, "lineMove", false, "cmd_linePrevious", selectPreviousLine); + addMovementMap(["j", "<Down>", "<Return>"], true, "lineMove", true, "cmd_lineNext", selectNextLine); + addMovementMap(["h", "<Left>", "<BS>"], true, "characterMove", false, "cmd_charPrevious", "cmd_selectCharPrevious"); + addMovementMap(["l", "<Right>", "<Space>"], true, "characterMove", true, "cmd_charNext", "cmd_selectCharNext"); + addMovementMap(["b", "B", "<C-Left>"], true, "wordMove", false, "cmd_wordPrevious", "cmd_selectWordPrevious"); + addMovementMap(["w", "W", "e", "<C-Right>"], true, "wordMove", true, "cmd_wordNext", "cmd_selectWordNext"); + addMovementMap(["<C-f>", "<PageDown>"], true, "pageMove", true, "cmd_movePageDown", "cmd_selectNextPage"); + addMovementMap(["<C-b>", "<PageUp>"], true, "pageMove", false, "cmd_movePageUp", "cmd_selectPreviousPage"); + addMovementMap(["gg", "<C-Home>"], false, "completeMove", false, "cmd_moveTop", "cmd_selectTop"); + addMovementMap(["G", "<C-End>"], false, "completeMove", true, "cmd_moveBottom", "cmd_selectBottom"); + addMovementMap(["0", "^", "<Home>"], false, "intraLineMove", false, "cmd_beginLine", "cmd_selectBeginLine"); + addMovementMap(["$", "<End>"], false, "intraLineMove", true, "cmd_endLine" , "cmd_selectEndLine" ); + + addBeginInsertModeMap(["i", "<Insert>"], []); + addBeginInsertModeMap(["a"], ["cmd_charNext"]); + addBeginInsertModeMap(["I", "gI"], ["cmd_beginLine"]); + addBeginInsertModeMap(["A"], ["cmd_endLine"]); + addBeginInsertModeMap(["s"], ["cmd_deleteCharForward"]); + addBeginInsertModeMap(["S"], ["cmd_deleteToEndOfLine", "cmd_deleteToBeginningOfLine"]); + addBeginInsertModeMap(["C"], ["cmd_deleteToEndOfLine"]); + + addMotionMap("d"); // delete + addMotionMap("c"); // change + addMotionMap("y"); // yank + + // insert mode mappings + mappings.add(myModes, + ["<C-w>"], "Delete previous word", + function () { editor.executeCommand("cmd_deleteWordBackward", 1); }); + + mappings.add(myModes, + ["<C-u>"], "Delete until beginning of current line", + function () { + // broken in FF3, deletes the whole line: + // editor.executeCommand("cmd_deleteToBeginningOfLine", 1); + editor.executeCommand("cmd_selectBeginLine", 1); + if (Editor.getController().isCommandEnabled("cmd_delete")) + editor.executeCommand("cmd_delete", 1); + }); - liberator.assert(args.length >= 1, "No editor specified"); + mappings.add(myModes, + ["<C-k>"], "Delete until end of current line", + function () { editor.executeCommand("cmd_deleteToEndOfLine", 1); }); - args.push(path); - liberator.callFunctionInThread(null, io.run, io.expandPath(args.shift()), args, true); - }, + mappings.add(myModes, + ["<C-a>"], "Move cursor to beginning of current line", + function () { editor.executeCommand("cmd_beginLine", 1); }); - // TODO: clean up with 2 functions for textboxes and currentEditor? - editFieldExternally: function (forceEditing) - { - if (!options["editor"]) - return false; + mappings.add(myModes, + ["<C-e>"], "Move cursor to end of current line", + function () { editor.executeCommand("cmd_endLine", 1); }); - let textBox = null; - if (!(config.isComposeWindow)) - textBox = liberator.focus; - - 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)) - return editor.editFieldExternally(true); - }); - return; - } + mappings.add(myModes, + ["<C-h>"], "Delete character to the left", + function () { editor.executeCommand("cmd_deleteCharBackward", 1); }); - let text = ""; // XXX - if (textBox) - text = textBox.value; - else if (typeof GetCurrentEditor == "function") // Thunderbird composer - text = GetCurrentEditor().outputToString("text/plain", 2); - else - return false; + mappings.add(myModes, + ["<C-d>"], "Delete character to the right", + function () { editor.executeCommand("cmd_deleteCharForward", 1); }); - let oldBg, tmpBg; - try - { - let res = io.withTempFiles(function (tmpfile) { - if (textBox) - { - textBox.setAttribute("readonly", "true"); - oldBg = textBox.style.backgroundColor; - tmpBg = "yellow"; - textBox.style.backgroundColor = "#bbbbbb"; - } + /*mappings.add(myModes, + ["<C-Home>"], "Move cursor to beginning of text field", + function () { editor.executeCommand("cmd_moveTop", 1); }); - if (!tmpfile.write(text)) - throw Error("Input contains characters not valid in the current " + - "file encoding"); + mappings.add(myModes, + ["<C-End>"], "Move cursor to end of text field", + function () { editor.executeCommand("cmd_moveBottom", 1); });*/ - this.editFileExternally(tmpfile.path); + mappings.add(myModes, + ["<S-Insert>"], "Insert clipboard/selection", + function () { editor.pasteClipboard(); }); - if (textBox) - textBox.removeAttribute("readonly"); + mappings.add([modes.INSERT, modes.TEXTAREA, modes.COMPOSE], + ["<C-i>"], "Edit text field with an external editor", + function () { editor.editFieldExternally(); }); - let val = tmpfile.read(); - if (textBox) - textBox.value = val; - else - { - let editor = GetCurrentEditor(); - let wholeDocRange = editor.document.createRange(); - let rootNode = editor.rootElement.QueryInterface(Ci.nsIDOMNode); - wholeDocRange.selectNodeContents(rootNode); - editor.selection.addRange(wholeDocRange); - editor.selection.deleteFromDocument(); - editor.insertText(val); - } - }, this); - if (res == false) - throw "Couldn't create temporary file"; - } - catch (e) - { - // Errors are unlikely, and our error messages won't - // likely be any more helpful than that given in the - // exception. - liberator.echoerr(e); - tmpBg = "red"; - } + mappings.add([modes.INSERT], + ["<C-t>"], "Edit text field in Vi mode", + function () { liberator.mode = modes.TEXTAREA; }); - // blink the textbox after returning - if (textBox) - { - let colors = [tmpBg, oldBg, tmpBg, oldBg]; - (function () { - textBox.style.backgroundColor = colors.shift(); - if (colors.length > 0) - setTimeout(arguments.callee, 100); - })(); - } + mappings.add([modes.INSERT], + ["<Space>", "<Return>"], "Expand insert mode abbreviation", + function () { editor.expandAbbreviation("i"); }, + { route: true }); - return true; - }, + mappings.add([modes.INSERT], + ["<Tab>"], "Expand insert mode abbreviation", + function () { editor.expandAbbreviation("i"); document.commandDispatcher.advanceFocus(); }); - // Abbreviations {{{ + mappings.add([modes.INSERT], + ["<C-]>", "<C-5>"], "Expand insert mode abbreviation", + function () { editor.expandAbbreviation("i"); }); - // NOTE: I think this comment block is trying to say something but no - // one is listening. In space, no one can hear you scream. --djk - // - // System for adding abbreviations: - // - // filter == ! delete all, and set first (END) - // - // if filter == ! remove all and add it as only END - // - // variant 1: rhs matches anywhere in loop - // - // 1 mod matches anywhere in loop - // a) simple replace and - // I) (maybe there's another rhs that matches? not possible) - // (when there's another item, it's opposite mod with different rhs) - // (so do nothing further but END) - // - // 2 mod does not match - // a) the opposite is there -> make a ! and put it as only and END - // (b) a ! is there. do nothing END) - // - // variant 2: rhs matches *no*were in loop and filter is c or i - // every kind of current combo is possible to 1 {c,i,!} or two {c and i} - // - // 1 mod is ! split into two i + c END - // 1 not !: opposite mode (first), add/change 'second' and END - // 1 not !: same mode (first), overwrite first this END - // - // TODO: I don't like these funky filters, I am a funky filter hater. --djk - // : make this a separate object - // : use Struct for individual abbreviations - // : rename "filter" arg "mode" - /** - * Adds a new abbreviation. Abbreviations consist of a LHS (the text - * that is replaced when the abbreviation is expanded) and a RHS (the - * replacement text). - * - * @param {string} filter The mode filter. This specifies the modes in - * which this abbreviation is available. Either: - * "c" - applies in command-line mode - * "i" - applies in insert mode - * "!" - applies in both command-line and insert modes - * @param {string} lhs The LHS of the abbreviation. - * @param {string} rhs The RHS of the abbreviation. - */ - addAbbreviation: function (filter, lhs, rhs) - { - if (!abbreviations[lhs]) - { - abbreviations[lhs] = []; - abbreviations[lhs][0] = [filter, rhs]; - return; - } + // textarea mode + mappings.add([modes.TEXTAREA], + ["u"], "Undo", + function (count) { + editor.executeCommand("cmd_undo", count); + liberator.mode = modes.TEXTAREA; + }, + { count: true }); - if (filter == "!") - { - if (abbreviations[lhs][1]) - abbreviations[lhs][1] = ""; - abbreviations[lhs][0] = [filter, rhs]; - return; - } + mappings.add([modes.TEXTAREA], + ["<C-r>"], "Redo", + function (count) { + editor.executeCommand("cmd_redo", count); + liberator.mode = modes.TEXTAREA; + }, + { count: true }); - for (let i = 0; i < abbreviations[lhs].length; i++) - { - if (abbreviations[lhs][i][1] == rhs) - { - if (abbreviations[lhs][i][0] == filter) - { - abbreviations[lhs][i] = [filter, rhs]; - return; - } - else - { - if (abbreviations[lhs][i][0] != "!") - { - if (abbreviations[lhs][1]) - abbreviations[lhs][1] = ""; - abbreviations[lhs][0] = ["!", rhs]; - return; - } - else - return; - } - } - } + mappings.add([modes.TEXTAREA], + ["D"], "Delete the characters under the cursor until the end of the line", + function () { editor.executeCommand("cmd_deleteToEndOfLine"); }); - if (abbreviations[lhs][0][0] == "!") - { - let tmpOpp = ("i" == filter) ? "c" : "i"; - abbreviations[lhs][1] = [tmpOpp, abbreviations[lhs][0][1]]; - abbreviations[lhs][0] = [filter, rhs]; - return; - } + mappings.add([modes.TEXTAREA], + ["o"], "Open line below current", + function (count) { + editor.executeCommand("cmd_endLine", 1); + modes.set(modes.INSERT, modes.TEXTAREA); + events.feedkeys("<Return>"); + }); - if (abbreviations[lhs][0][0] != filter) - abbreviations[lhs][1] = [filter, rhs]; - else - abbreviations[lhs][0] = [filter, rhs]; - }, - - /** - * Expands an abbreviation in the currently active textbox. - * - * @param {string} filter The mode filter. - * @see #addAbbreviation - */ - expandAbbreviation: function (filter) - { - let textbox = getEditor(); - if (!textbox) - return; - let text = textbox.value; - let currStart = textbox.selectionStart; - let currEnd = textbox.selectionEnd; - let foundWord = text.substring(0, currStart).replace(RegExp("^(.|\\n)*?\\s*(" + abbrevmatch + ")$", "m"), "$2"); // get last word \b word boundary - if (!foundWord) - return true; + mappings.add([modes.TEXTAREA], + ["O"], "Open line above current", + function (count) { + editor.executeCommand("cmd_beginLine", 1); + modes.set(modes.INSERT, modes.TEXTAREA); + events.feedkeys("<Return>"); + editor.executeCommand("cmd_linePrevious", 1); + }); - for (let lhs in abbreviations) - { - for (let i = 0; i < abbreviations[lhs].length; i++) - { - if (lhs == foundWord && (abbreviations[lhs][i][0] == filter || abbreviations[lhs][i][0] == "!")) - { - // if found, replace accordingly - let len = foundWord.length; - let abbrText = abbreviations[lhs][i][1]; - text = text.substring(0, currStart - len) + abbrText + text.substring(currStart); - textbox.value = text; - textbox.selectionStart = currStart - len + abbrText.length; - textbox.selectionEnd = currEnd - len + abbrText.length; - break; - } + mappings.add([modes.TEXTAREA], + ["X"], "Delete character to the left", + function (count) { editor.executeCommand("cmd_deleteCharBackward", count); }, + { count: true }); + + mappings.add([modes.TEXTAREA], + ["x"], "Delete character to the right", + function (count) { editor.executeCommand("cmd_deleteCharForward", count); }, + { count: true }); + + // visual mode + mappings.add([modes.CARET, modes.TEXTAREA], + ["v"], "Start visual mode", + function (count) { modes.set(modes.VISUAL, liberator.mode); }); + + mappings.add([modes.VISUAL], + ["v"], "End visual mode", + function (count) { events.onEscape(); }); + + mappings.add([modes.TEXTAREA], + ["V"], "Start visual line mode", + function (count) { + modes.set(modes.VISUAL, modes.TEXTAREA | modes.LINE); + editor.executeCommand("cmd_beginLine", 1); + editor.executeCommand("cmd_selectLineNext", 1); + }); + + mappings.add([modes.VISUAL], + ["c", "s"], "Change selected text", + function (count) { + if (modes.extended & modes.TEXTAREA) { + editor.executeCommand("cmd_cut"); + modes.set(modes.INSERT, modes.TEXTAREA); } - } - return true; - }, - - /** - * Returns all abbreviations matching <b>filter</b> and <b>lhs</b>. - * - * @param {string} filter The mode filter. - * @param {string} lhs The LHS of the abbreviation. - * @returns {Array} The matching abbreviations [mode, lhs, rhs] - * @see #addAbbreviation - */ - getAbbreviations: function (filter, lhs) - { - // ! -> list all, on c or i ! matches too - let searchFilter = (filter == "!") ? "!ci" : filter + "!"; - return list = [[mode, left, right] for ([left, [mode, right]] in abbrevs()) - if (searchFilter.indexOf(mode) >= 0 && left.indexOf(lhs || "") == 0)]; - }, - - /** - * Lists all abbreviations matching <b>filter</b> and <b>lhs</b>. - * - * @param {string} filter The mode filter. - * @param {string} lhs The LHS of the abbreviation. - * @see #addAbbreviation - */ - listAbbreviations: function (filter, lhs) - { - let list = this.getAbbreviations(filter, lhs); - - if (!list.length) - liberator.echomsg("No abbreviations found"); - else if (list.length == 1) - { - let [mode, lhs, rhs] = list[0]; - - liberator.echo(mode + " " + lhs + " " + rhs, commandline.FORCE_SINGLELINE); // 2 spaces, 3 spaces - } - else - { - list = template.tabular(["", "LHS", "RHS"], [], list); - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - } - }, - - /** - * Deletes all abbreviations matching <b>filter</b> and <b>lhs</b>. - * - * @param {string} filter The mode filter. - * @param {string} lhs The LHS of the abbreviation. - * @see #addAbbreviation - */ - removeAbbreviation: function (filter, lhs) - { - if (!lhs) - { - liberator.echoerr("E474: Invalid argument"); - return false; - } + else + liberator.beep(); + }); - if (abbreviations[lhs]) // abbreviations exists - { - if (filter == "!") - { - abbreviations[lhs] = ""; - return true; + mappings.add([modes.VISUAL], + ["d"], "Delete selected text", + function (count) { + if (modes.extended & modes.TEXTAREA) { + editor.executeCommand("cmd_cut"); + modes.set(modes.TEXTAREA); } else - { - if (!abbreviations[lhs][1]) // only one exists - { - if (abbreviations[lhs][0][0] == "!") // exists as ! -> no 'full' delete - { - abbreviations[lhs][0][0] = (filter == "i") ? "c" : "i"; // ! - i = c; ! - c = i - return true; - } - else if (abbreviations[lhs][0][0] == filter) - { - abbreviations[lhs] = ""; - return true; - } - } - else // two abbreviations exist ( 'i' or 'c' (filter as well)) - { - if (abbreviations[lhs][0][0] == "c" && filter == "c") - abbreviations[lhs][0] = abbreviations[lhs][1]; + liberator.beep(); + }); - abbreviations[lhs][1] = ""; + mappings.add([modes.VISUAL], + ["y"], "Yank selected text", + function (count) { + if (modes.extended & modes.TEXTAREA) { + editor.executeCommand("cmd_copy"); + modes.set(modes.TEXTAREA); + } + else { + let sel = window.content.document.getSelection(); + if (sel) + util.copyToClipboard(sel, true); + else + liberator.beep(); + } + }); - return true; - } + mappings.add([modes.VISUAL, modes.TEXTAREA], + ["p"], "Paste clipboard contents", + function (count) { + if (!(modes.extended & modes.CARET)) { + if (!count) count = 1; + while (count--) + editor.executeCommand("cmd_paste"); + liberator.mode = modes.TEXTAREA; } - } + else + liberator.beep(); + }); - liberator.echoerr("E24: No such abbreviation"); - return false; - }, - - /** - * Removes all abbreviations matching <b>filter</b>. - * - * @param {string} filter The mode filter. - * @see #addAbbreviation - */ - removeAllAbbreviations: function (filter) - { - let searchFilter = (filter == "!") ? "!ci" : filter + "!"; - for (let [lhs, [mode, rhs]] in abbrevs()) - if (searchFilter.indexOf(mode) >= 0) - this.removeAbbreviation(filter, lhs); - } - //}}} - }; - //}}} -} //}}} + // finding characters + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["f"], "Move to a character on the current line after the cursor", + function (count, arg) { + let pos = editor.findCharForward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos, true, liberator.mode == modes.VISUAL); + }, + { arg: true, count: true }); + + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["F"], "Move to a charater on the current line before the cursor", + function (count, arg) { + let pos = editor.findCharBackward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos, false, liberator.mode == modes.VISUAL); + }, + { arg: true, count: true }); + + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["t"], "Move before a character on the current line", + function (count, arg) { + let pos = editor.findCharForward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos - 1, true, liberator.mode == modes.VISUAL); + }, + { arg: true, count: true }); + + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["T"], "Move before a character on the current line, backwards", + function (count, arg) { + let pos = editor.findCharBackward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos + 1, false, liberator.mode == modes.VISUAL); + }, + { arg: true, count: true }); + + // textarea and visual mode + mappings.add([modes.TEXTAREA, 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; + if (typeof count != "number" || count < 1) + count = 1; + + while (count-- > 0) { + let text = Editor.getEditor().value; + let pos = Editor.getEditor().selectionStart; + if (pos >= text.length) + return void liberator.beep(); + 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.set(modes.TEXTAREA); + }, + { count: true }); + }, + + options: function () { + options.add(["editor"], + "Set the external text editor", + "string", "gvim -f"); + + options.add(["insertmode", "im"], + "Use Insert mode as the default for 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 index 2a0c9d66..c6029d01 100644 --- a/common/content/eval.js +++ b/common/content/eval.js @@ -1,7 +1,6 @@ try { __liberator_eval_result = eval(__liberator_eval_string); } -catch (e) -{ +catch (e) { __liberator_eval_error = e; } // IMPORTANT: The eval statement *must* remain on the first line diff --git a/common/content/events.js b/common/content/events.js index 89fb1a54..8dcfc550 100644 --- a/common/content/events.js +++ b/common/content/events.js @@ -7,1833 +7,1352 @@ /** @scope modules */ /** - * @instance autocommands + * @instance events */ -function AutoCommands() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const AutoCommand = new Struct("event", "pattern", "command"); - var store = []; - - function matchAutoCmd(autoCmd, event, regex) - { - return (!event || autoCmd.event == event) && (!regex || autoCmd.pattern.source == regex); - } +const Events = Module("events", { + requires: ["autocommands"], + + init: function () { + const self = this; + + this._fullscreen = window.fullScreen; + this._lastFocus = null; + this._currentMacro = ""; + this._lastMacro = ""; + + this._macros = storage.newMap("macros", true, { privateData: true }); + + // 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 liberator's source code for + // comparisons like if (key == "<Esc>") { ... } + this._keyTable = { + add: ["Plus", "Add"], + back_space: ["BS"], + delete: ["Del"], + escape: ["Esc", "Escape"], + insert: ["Insert", "Ins"], + left_shift: ["LT", "<"], + return: ["Return", "CR", "Enter"], + right_shift: [">"], + space: ["Space", " "], + subtract: ["Minus", "Subtract"] + }; - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["eventignore", "ei"], - "List of autocommand event names which should be ignored", - "stringlist", "", - { - completer: function () config.autocommands.concat([["all", "All events"]]), - validator: Option.validateCompleter - }); - - options.add(["focuscontent", "fc"], - "Try to stay in normal mode after loading a web page", - "boolean", false); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["au[tocmd]"], - "Execute commands automatically on events", - function (args) - { - let [event, regex, cmd] = args; - let events = []; - - try - { - RegExp(regex); - } - catch (e) - { - liberator.assert(false, "E475: Invalid argument: " + regex); + this._code_key = {}; + this._key_code = {}; + + for (let [k, v] in Iterator(KeyEvent)) + if (/^DOM_VK_(?![A-Z0-9]$)/.test(k)) { + k = k.substr(7).toLowerCase(); + let names = [k.replace(/(^|_)(.)/g, function (m, n1, n2) n2.toUpperCase()) + .replace(/^NUMPAD/, "k")]; + if (k in this._keyTable) + names = this._keyTable[k]; + this._code_key[v] = names[0]; + for (let [, name] in Iterator(names)) + this._key_code[name.toLowerCase()] = v; } - if (event) - { - // NOTE: event can only be a comma separated list for |:au {event} {pat} {cmd}| - let validEvents = config.autocommands.map(function (event) event[0]); - validEvents.push("*"); - - events = event.split(","); - liberator.assert(events.every(function (event) validEvents.indexOf(event) >= 0), - "E216: No such group or event: " + event); - } + // 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"; + } - if (cmd) // add new command, possibly removing all others with the same event/pattern - { - if (args.bang) - autocommands.remove(event, regex); - if (args["-javascript"]) - cmd = eval("(function (args) { with(args) {" + cmd + "} })"); - autocommands.add(events, regex, cmd); - } - else - { - if (event == "*") - event = null; + this._input = { + buffer: "", // partial command storage + pendingMotionMap: null, // e.g. "d{motion}" if we wait for a motion of the "d" command + pendingArgMap: null, // pending map storage for commands like m{a-z} + count: -1 // parsed count from the input buffer + }; - if (args.bang) - { - // TODO: "*" only appears to work in Vim when there is a {group} specified - if (args[0] != "*" || regex) - autocommands.remove(event, regex); // remove all - } - else - autocommands.list(event, regex); // list all + function onResize(event) { + if (window.fullScreen != this._fullscreen) { + this._fullscreen = window.fullScreen; + liberator.triggerObserver("fullscreen", this._fullscreen); + autocommands.trigger("Fullscreen", { state: this._fullscreen }); } - }, - { - bang: true, - completer: function (context) completion.autocmdEvent(context), - literal: 2, - options: [[["-javascript", "-js"], commands.OPTION_NOARG]] - }); - - [ - { - 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 liberator.echomsg("No matching autocommands"); - - let [event, url] = args; - let defaultURL = url || buffer.URL; - let validEvents = config.autocommands.map(function (e) e[0]); - - // TODO: add command validators - liberator.assert(event != "*", - "E217: Can't execute autocommands for ALL events"); - liberator.assert(validEvents.indexOf(event) >= 0, - "E216: No such group or event: " + args); - liberator.assert(autocommands.get(event).some(function (c) c.pattern.test(defaultURL)), - "No matching autocommands"); - - if (this.name == "doautoall" && liberator.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.URL }); - } - tabs.select(current); + ///////////////////////////////////////////////////////// + // track if a popup is open or the menubar is active + this._activeMenubar = false; + window.addEventListener("popupshown", this._enterPopupMode, true); + window.addEventListener("popuphidden", this._exitPopupMode, true); + window.addEventListener("DOMMenuBarActive", this._enterMenuMode, true); + window.addEventListener("DOMMenuBarInactive", this._exitMenuMode, true); + window.addEventListener("resize", onResize, true); + + // load all macros + // setTimeout needed since io. is loaded after events. + setTimeout(function () { + try { + let dirs = io.getRuntimeDirectories("macros"); + + if (dirs.length > 0) { + for (let [, dir] in Iterator(dirs)) { + liberator.echomsg('Searching for "macros/*" in "' + dir.path + '"', 2); + liberator.log("Sourcing macros directory: " + dir.path + "...", 3); + + let files = io.readDirectory(dir.path); + for (let file in dir.iterDirectory()) { + if (file.exists() && !file.isDirectory() && file.isReadable() && + /^[\w_-]+(\.vimp)?$/i.test(file.leafName)) { + let name = file.leafName.replace(/\.vimp$/i, ""); + this._macros.set(name, file.read().split("\n")[0]); + + liberator.log("Macro " + name + " added: " + this._macros.get(name), 5); + } + } + } } else - autocommands.trigger(event, { url: defaultURL }); - }, - { - argCount: "*", // FIXME: kludged for proper error message should be "1". - completer: function (context) completion.autocmdEvent(context) - }); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - liberator.registerObserver("load_completion", function () { - completion.setFunctionCompleter(autocommands.get, [function () config.autocommands]); - - completion.autocmdEvent = function autocmdEvent(context) { - context.completions = config.autocommands; - }; - - completion.macro = function macro(context) { - context.title = ["Macro", "Keys"]; - context.completions = [item for (item in events.getMacros())]; - }; - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - __iterator__: function () util.Array.itervalues(store), - - /** - * Adds a new autocommand. <b>cmd</b> will be executed when one of the - * specified <b>events</b> occurs and the URL of the applicable buffer - * matches <b>regex</b>. - * - * @param {Array} events The array of event names for which this - * autocommand should be executed. - * @param {string} regex The URL pattern to match against the buffer URL. - * @param {string} cmd The Ex command to run. - */ - add: function (events, regex, cmd) - { - if (typeof events == "string") - { - events = events.split(","); - liberator.log("DEPRECATED: the events list arg to autocommands.add() should be an array of event names"); + liberator.log("No user macros directory found", 3); } - events.forEach(function (event) { - store.push(new AutoCommand(event, RegExp(regex), cmd)); - }); - }, - - /** - * Returns all autocommands with a matching <b>event</b> and - * <b>regex</b>. - * - * @param {string} event The event name filter. - * @param {string} regex The URL pattern filter. - * @returns {AutoCommand[]} - */ - get: function (event, regex) - { - return store.filter(function (autoCmd) matchAutoCmd(autoCmd, event, regex)); - }, - - /** - * Deletes all autocommands with a matching <b>event</b> and - * <b>regex</b>. - * - * @param {string} event The event name filter. - * @param {string} regex The URL pattern filter. - */ - remove: function (event, regex) - { - store = store.filter(function (autoCmd) !matchAutoCmd(autoCmd, event, regex)); - }, + catch (e) { + // thrown if directory does not exist + liberator.log("Error sourcing macros directory: " + e, 9); + } + }, 100); + + window.XULBrowserWindow = this.progressListener; + window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIXULWindow) + .XULBrowserWindow = this.progressListener; + try { + getBrowser().addProgressListener(this.progressListener, Ci.nsIWebProgress.NOTIFY_ALL); + } + catch (e) {} - /** - * Lists all autocommands with a matching <b>event</b> and - * <b>regex</b>. - * - * @param {string} event The event name filter. - * @param {string} regex The URL pattern filter. - */ - list: function (event, regex) - { - let cmds = {}; - - // XXX - store.forEach(function (autoCmd) { - if (matchAutoCmd(autoCmd, event, regex)) - { - cmds[autoCmd.event] = cmds[autoCmd.event] || []; - cmds[autoCmd.event].push(autoCmd); + function wrapListener(method) { + return function (event) { + try { + self[method](event); } - }); - - let list = template.commandOutput( - <table> - <tr highlight="Title"> - <td colspan="2">----- Auto Commands -----</td> - </tr> - { - template.map(cmds, function ([event, items]) - <tr highlight="Title"> - <td colspan="2">{event}</td> - </tr> - + - template.map(items, function (item) - <tr> - <td> {item.pattern.source}</td> - <td>{item.command}</td> - </tr>)) - } - </table>); - - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - }, - - /** - * Triggers the execution of all autocommands registered for - * <b>event</b>. A map of <b>args</b> 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("all", event)) - return; - - let autoCmds = store.filter(function (autoCmd) autoCmd.event == event); - - liberator.echomsg("Executing " + event + " Auto commands for \"*\"", 8); - - let lastPattern = null; - let url = args.url || ""; - - for (let [, autoCmd] in Iterator(autoCmds)) - { - if (autoCmd.pattern.test(url)) - { - if (!lastPattern || lastPattern.source != autoCmd.pattern.source) - liberator.echomsg("Executing " + event + " Auto commands for \"" + autoCmd.pattern.source + "\"", 8); - - lastPattern = autoCmd.pattern; - liberator.echomsg("autocommand " + autoCmd.command, 9); - - if (typeof autoCmd.command == "function") - { - try - { - autoCmd.command.call(autoCmd, args); - } - catch (e) - { - liberator.reportError(e); - liberator.echoerr(e); - } - } + catch (e) { + if (e.message == "Interrupted") + liberator.echoerr("Interrupted"); else - liberator.execute(commands.replaceTokens(autoCmd.command, args), null, true); + liberator.echoerr("Processing " + event.type + " event: " + (e.echoerr || e)); + liberator.reportError(e); } - } + }; } - }; - //}}} -} //}}} - -/** - * @instance events - */ -function Events() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const input = { - buffer: "", // partial command storage - pendingMotionMap: null, // e.g. "d{motion}" if we wait for a motion of the "d" command - pendingArgMap: null, // pending map storage for commands like m{a-z} - count: -1 // parsed count from the input buffer - }; - - var fullscreen = window.fullScreen; - - var lastFocus = null; - - var macros = storage.newMap("macros", { store: true, privateData: true }); - - var currentMacro = ""; - var lastMacro = ""; - - if (liberator.has("tabs")) - { - // FIXME: most of this doesn't work for Muttator yet. - liberator.registerObserver("load_tabs", function () { - let tabContainer = tabs.getBrowser().mTabContainer; - - tabContainer.addEventListener("TabMove", function (event) { - statusline.updateTabCount(true); - }, false); - tabContainer.addEventListener("TabOpen", function (event) { - statusline.updateTabCount(true); - }, false); - tabContainer.addEventListener("TabClose", function (event) { - statusline.updateTabCount(true); - }, false); - tabContainer.addEventListener("TabSelect", function (event) { - // TODO: is all of that necessary? - // I vote no. --Kris - modes.reset(); - statusline.updateTabCount(true); - tabs.updateSelectionHistory(); - - if (options["focuscontent"]) - setTimeout(function () { liberator.focusContent(true); }, 10); // just make sure, that no widget has focus - }, false); - - tabs.getBrowser().addEventListener("DOMContentLoaded", onDOMContentLoaded, true); - - // this adds an event which is is called on each page load, even if the - // page is loaded in a background tab - tabs.getBrowser().addEventListener("load", onPageLoad, true); + window.addEventListener("keypress", wrapListener("onKeyPress"), true); + window.addEventListener("keydown", wrapListener("onKeyUpOrDown"), true); + window.addEventListener("keyup", wrapListener("onKeyUpOrDown"), true); + }, + + /** + * A destructor called when this module is destroyed. + */ + destroy: function () { + // removeEventListeners() to avoid mem leaks + liberator.dump("TODO: remove all eventlisteners"); + + try { + getBrowser().removeProgressListener(this.progressListener); + } + catch (e) {} - // called when the active document is scrolled - tabs.getBrowser().addEventListener("scroll", function (event) { - statusline.updateBufferPosition(); - modes.show(); - }, null); - }); - } + window.removeEventListener("popupshown", this._enterPopupMode, true); + window.removeEventListener("popuphidden", this._exitPopupMode, true); + window.removeEventListener("DOMMenuBarActive", this._enterMenuMode, true); + window.removeEventListener("DOMMenuBarInactive", this._exitMenuMode, true); -// getBrowser().addEventListener("submit", function (event) { -// // reset buffer loading state as early as possible, important for macros -// buffer.loaded = 0; -// }, null); + window.removeEventListener("keypress", this.onKeyPress, true); + window.removeEventListener("keydown", this.onKeyDown, true); + }, - ///////////////////////////////////////////////////////// - // track if a popup is open or the menubar is active - var activeMenubar = false; - function enterPopupMode(event) - { + _enterPopupMode: function (event) { if (event.originalTarget.localName == "tooltip" || event.originalTarget.id == "liberator-visualbell") return; modes.add(modes.MENU); - } - function exitPopupMode() - { - // gContextMenu is set to NULL, when a context menu is closed - if (typeof gContextMenu != "undefined" && gContextMenu == null && !activeMenubar) + }, + _exitPopupMode: function () { // gContextMenu is set to NULL, when a context menu is closed + if (window.gContextMenu == null && !this._activeMenubar) modes.remove(modes.MENU); - } - function enterMenuMode() - { - activeMenubar = true; + }, + _enterMenuMode: function () { + this._activeMenubar = true; modes.add(modes.MENU); - } - function exitMenuMode() - { - activeMenubar = false; + }, + _exitMenuMode: function () { + this._activeMenubar = false; modes.remove(modes.MENU); - } - window.addEventListener("popupshown", enterPopupMode, true); - window.addEventListener("popuphidden", exitPopupMode, true); - window.addEventListener("DOMMenuBarActive", enterMenuMode, true); - window.addEventListener("DOMMenuBarInactive", exitMenuMode, true); - window.addEventListener("resize", onResize, true); - - // 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 liberator's source code for - // comparisons like if (key == "<Esc>") { ... } - var keyTable = { - add: ["Plus", "Add"], - back_space: ["BS"], - delete: ["Del"], - escape: ["Esc", "Escape"], - insert: ["Insert", "Ins"], - left_shift: ["LT", "<"], - return: ["Return", "CR", "Enter"], - right_shift: [">"], - space: ["Space", " "], - subtract: ["Minus", "Subtract"] - }; - - const code_key = {}; - const key_code = {}; - - for (let [k, v] in Iterator(KeyEvent)) - if (/^DOM_VK_(?![A-Z0-9]$)/.test(k)) - { - k = k.substr(7).toLowerCase(); - let names = [k.replace(/(^|_)(.)/g, function (m, n1, n2) n2.toUpperCase()) - .replace(/^NUMPAD/, "k")]; - if (k in keyTable) - names = keyTable[k]; - code_key[v] = names[0]; - for (let [, name] in Iterator(names)) - key_code[name.toLowerCase()] = v; - } - - // HACK: as Gecko does not include an event for <, we must add this in manually. - if (!("<" in key_code)) - { - key_code["<"] = 60; - key_code["lt"] = 60; - code_key[60] = "lt"; - } - - function isInputElemFocused() - { - let elem = liberator.focus; - return ((elem instanceof HTMLInputElement && !/image/.test(elem.type)) || - elem instanceof HTMLTextAreaElement || - elem instanceof HTMLIsIndexElement || - elem instanceof HTMLObjectElement || - elem instanceof HTMLEmbedElement); - } + }, + + /** + * @property {boolean} Whether synthetic key events are currently being + * processed. + */ + feedingKeys: false, + + wantsModeReset: true, // used in onFocusChange since Firefox is so buggy here + + /** + * Initiates the recording of a key event macro. + * + * @param {string} macro The name for the macro. + */ + startRecording: function (macro) { + // TODO: ignore this like Vim? + liberator.assert(/[a-zA-Z0-9]/.test(macro), + "E354: Invalid register name: '" + macro + "'"); - function triggerLoadAutocmd(name, doc) - { - let args = { - url: doc.location.href, - title: doc.title - }; + modes.isRecording = true; - if (liberator.has("tabs")) - { - args.tab = tabs.getContentIndex(doc) + 1; - args.doc = "tabs.getTab(" + (args.tab - 1) + ").linkedBrowser.contentDocument"; + if (/[A-Z]/.test(macro)) { // uppercase (append) + this._currentMacro = macro.toLowerCase(); + if (!this._macros.get(this._currentMacro)) + this._macros.set(this._currentMacro, ""); // initialize if it does not yet exist } - - autocommands.trigger(name, args); - } - - function onResize(event) - { - if (window.fullScreen != fullscreen) - { - fullscreen = window.fullScreen; - liberator.triggerObserver("fullscreen", fullscreen); - autocommands.trigger("Fullscreen", { state: fullscreen }); + else { + this._currentMacro = macro; + this._macros.set(this._currentMacro, ""); } - } - - function onDOMContentLoaded(event) - { - let doc = event.originalTarget; - if (doc instanceof HTMLDocument && !doc.defaultView.frameElement) - triggerLoadAutocmd("DOMLoad", doc); - } - - // TODO: see what can be moved to onDOMContentLoaded() - function onPageLoad(event) - { - if (event.originalTarget instanceof HTMLDocument) - { - let doc = event.originalTarget; - // document is part of a frameset - if (doc.defaultView.frameElement) - { - // hacky way to get rid of "Transfering data from ..." on sites with frames - // when you click on a link inside a frameset, because asyncUpdateUI - // is not triggered there (Gecko bug?) - setTimeout(function () { statusline.updateUrl(); }, 10); - return; - } - - // code which should happen for all (also background) newly loaded tabs goes here: - - // mark the buffer as loaded, we can't use buffer.loaded - // since that always refers to the current buffer, while doc can be - // any buffer, even in a background tab - doc.pageIsFullyLoaded = 1; - - // code which is only relevant if the page load is the current tab goes here: - if (doc == getBrowser().contentDocument) - { - // we want to stay in command mode after a page has loaded - // TODO: move somewhere else, as focusing can already happen earlier than on "load" - if (options["focuscontent"]) - { - setTimeout(function () { - let focused = liberator.focus; - if (focused && (focused.value != null) && focused.value.length == 0) - focused.blur(); - }, 0); - } - } - else // background tab - liberator.echomsg("Background tab loaded: " + doc.title || doc.location.href, 3); - - triggerLoadAutocmd("PageLoad", doc); + }, + + /** + * Replays a macro. + * + * @param {string} The name of the macro to replay. + * @return {boolean} + */ + playMacro: function (macro) { + let res = false; + if (!/[a-zA-Z0-9@]/.test(macro) && macro.length == 1) { + liberator.echoerr("E354: Invalid register name: '" + macro + "'"); + return false; } - } - // return true when load successful, or false otherwise - function waitForPageLoad() events.waitForPageLoad(); - - // load all macros - // setTimeout needed since io. is loaded after events. - setTimeout(function () { - try - { - let dirs = io.getRuntimeDirectories("macros"); - - if (dirs.length > 0) - { - for (let [, dir] in Iterator(dirs)) - { - liberator.echomsg('Searching for "macros/*" in "' + dir.path + '"', 2); - liberator.log("Sourcing macros directory: " + dir.path + "...", 3); - - let files = io.readDirectory(dir.path); - for (let file in dir.iterDirectory()) - { - if (file.exists() && !file.isDirectory() && file.isReadable() && - /^[\w_-]+(\.vimp)?$/i.test(file.leafName)) - { - let name = file.leafName.replace(/\.vimp$/i, ""); - macros.set(name, file.read().split("\n")[0]); - - liberator.log("Macro " + name + " added: " + macros.get(name), 5); - } - } - } + if (macro == "@") { // use lastMacro if it's set + if (!this._lastMacro) { + liberator.echoerr("E748: No previously used register"); + return false; } - else - liberator.log("No user macros directory found", 3); } - catch (e) - { - // thrown if directory does not exist - liberator.log("Error sourcing macros directory: " + e, 9); + else { + if (macro.length == 1) + this._lastMacro = macro.toLowerCase(); // XXX: sets last played macro, even if it does not yet exist + else + this._lastMacro = macro; // e.g. long names are case sensitive } - }, 100); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - liberator.registerObserver("load_mappings", function () { - mappings.add(modes.all, - ["<Esc>", "<C-[>"], "Focus content", - function () { events.onEscape(); }); - - // add the ":" mapping in all but insert mode mappings - mappings.add([modes.NORMAL, modes.PLAYER, modes.VISUAL, modes.HINTS, modes.MESSAGE, modes.COMPOSE, modes.CARET, modes.TEXTAREA], - [":"], "Enter command line mode", - function () { commandline.open(":", "", modes.EX); }); - - // focus events - mappings.add([modes.NORMAL, modes.PLAYER, modes.VISUAL, modes.CARET], - ["<Tab>"], "Advance keyboard focus", - function () { document.commandDispatcher.advanceFocus(); }); - - mappings.add([modes.NORMAL, modes.PLAYER, modes.VISUAL, modes.CARET, modes.INSERT, modes.TEXTAREA], - ["<S-Tab>"], "Rewind keyboard focus", - function () { document.commandDispatcher.rewindFocus(); }); - mappings.add(modes.all, - ["<C-z>"], "Temporarily ignore all " + config.name + " key bindings", - function () { modes.passAllKeys = true; }); - - mappings.add(modes.all, - ["<C-v>"], "Pass through next key", - function () { modes.passNextKey = true; }); - - mappings.add(modes.all, - ["<Nop>"], "Do nothing", - function () { return; }); - - // macros - mappings.add([modes.NORMAL, modes.PLAYER, modes.MESSAGE], - ["q"], "Record a key sequence into a macro", - function (arg) { events.startRecording(arg); }, - { arg: true }); - - mappings.add([modes.NORMAL, modes.PLAYER, modes.MESSAGE], - ["@"], "Play a macro", - function (count, arg) - { - if (count < 1) count = 1; - while (count-- && events.playMacro(arg)) - ; - }, - { arg: true, count: true }); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["delmac[ros]"], - "Delete macros", - function (args) - { - liberator.assert(!args.bang || !args.string, "E474: Invalid argument"); - - if (args.bang) - events.deleteMacros(); - else if (args.string) - events.deleteMacros(args.string); - else - liberator.echoerr("E471: Argument required"); - }, - { - bang: true, - completer: function (context) completion.macro(context) - }); - - commands.add(["macros"], - "List all macros", - function (args) { completion.listCompleter("macro", args[0]); }, - { - argCount: "?", - completer: function (context) completion.macro(context) - }); - - commands.add(["pl[ay]"], - "Replay a recorded macro", - function (args) { events.playMacro(args[0]); }, - { - argCount: "1", - completer: function (context) completion.macro(context) - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const self = { - - /** - * @property {boolean} Whether synthetic key events are currently being - * processed. - */ - feedingKeys: false, - - wantsModeReset: true, // used in onFocusChange since Firefox is so buggy here - - /** - * A destructor called when this module is destroyed. - */ - destroy: function () - { - // removeEventListeners() to avoid mem leaks - liberator.dump("TODO: remove all eventlisteners"); - - try - { - getBrowser().removeProgressListener(this.progressListener); + if (this._macros.get(this._lastMacro)) { + // make sure the page is stopped before starting to play the macro + try { + window.getWebNavigation().stop(nsIWebNavigation.STOP_ALL); } catch (e) {} - window.removeEventListener("popupshown", enterPopupMode, true); - window.removeEventListener("popuphidden", exitPopupMode, true); - window.removeEventListener("DOMMenuBarActive", enterMenuMode, true); - window.removeEventListener("DOMMenuBarInactive", exitMenuMode, true); - - window.removeEventListener("keypress", this.onKeyPress, true); - window.removeEventListener("keydown", this.onKeyDown, true); - }, - - /** - * Initiates the recording of a key event macro. - * - * @param {string} macro The name for the macro. - */ - startRecording: function (macro) - { + buffer.loaded = 1; // even if not a full page load, assume it did load correctly before starting the macro + modes.isReplaying = true; + res = events.feedkeys(this._macros.get(this._lastMacro), { noremap: true }); + modes.isReplaying = false; + } + else { + if (this._lastMacro.length == 1) // TODO: ignore this like Vim? - liberator.assert(/[a-zA-Z0-9]/.test(macro), - "E354: Invalid register name: '" + macro + "'"); - - modes.isRecording = true; - - if (/[A-Z]/.test(macro)) // uppercase (append) - { - currentMacro = macro.toLowerCase(); - if (!macros.get(currentMacro)) - macros.set(currentMacro, ""); // initialize if it does not yet exist - } + liberator.echoerr("Exxx: Register '" + this._lastMacro + "' not set"); else - { - currentMacro = macro; - macros.set(currentMacro, ""); - } - }, - - /** - * Replays a macro. - * - * @param {string} The name of the macro to replay. - * @return {boolean} - */ - playMacro: function (macro) - { - let res = false; - if (!/[a-zA-Z0-9@]/.test(macro) && macro.length == 1) - { - liberator.echoerr("E354: Invalid register name: '" + macro + "'"); - return false; - } - - if (macro == "@") // use lastMacro if it's set - { - if (!lastMacro) - { - liberator.echoerr("E748: No previously used register"); - return false; - } - } - else - { - if (macro.length == 1) - lastMacro = macro.toLowerCase(); // XXX: sets last played macro, even if it does not yet exist + liberator.echoerr("Exxx: Named macro '" + this._lastMacro + "' not set"); + } + return res; + }, + + /** + * Returns all macros matching <b>filter</b>. + * + * @param {string} filter A regular expression filter string. A null + * filter selects all macros. + */ + getMacros: function (filter) { + if (!filter) + return this._macros; + + let re = RegExp(filter); + return ([macro, keys] for ([macro, keys] in this._macros) if (re.test(macro))); + }, + + /** + * Deletes all macros matching <b>filter</b>. + * + * @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 (re.test(item) || !filter) + this._macros.remove(item); + } + }, + + /** + * Pushes keys onto the event queue from liberator. It is similar to + * Vim's feedkeys() method, but cannot cope with 2 partially-fed + * strings, you have to feed one parsable 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 Allow recursive mappings. + * @param {boolean} silent Whether the command should be echoed to the + * command line. + * @returns {boolean} + */ + feedkeys: function (keys, noremap, quiet) { + let doc = window.document; + let view = window.document.defaultView; + + let wasFeeding = this.feedingKeys; + this.feedingKeys = true; + this.duringFeed = this.duringFeed || []; + let wasQuiet = commandline.quiet; + if (quiet) + commandline.quiet = quiet; + + try { + liberator.threadYield(1, true); + + for (let [, evt_obj] in Iterator(events.fromString(keys))) { + let elem = liberator.focus || window.content; + let evt = events.create(doc, "keypress", evt_obj); + + if (typeof noremap == "object") + for (let [k, v] in Iterator(noremap)) + evt[k] = v; else - lastMacro = macro; // e.g. long names are case sensitive - } - - if (macros.get(lastMacro)) - { - // make sure the page is stopped before starting to play the macro - try - { - window.getWebNavigation().stop(nsIWebNavigation.STOP_ALL); + evt.noremap = !!noremap; + evt.isMacro = true; + // A special hack for liberator-specific key names. + if (evt_obj.liberatorString || evt_obj.liberatorShift) { + evt.liberatorString = evt_obj.liberatorString; // for key-less keypress events e.g. <Nop> + evt.liberatorShift = evt_obj.liberatorShift; // for untypable shift keys e.g. <S-1> + events.onKeyPress(evt); } - catch (e) {} - buffer.loaded = 1; // even if not a full page load, assume it did load correctly before starting the macro - modes.isReplaying = true; - res = events.feedkeys(macros.get(lastMacro), { noremap: true }); - modes.isReplaying = false; - } - else - { - if (lastMacro.length == 1) - // TODO: ignore this like Vim? - liberator.echoerr("Exxx: Register '" + lastMacro + "' not set"); else - liberator.echoerr("Exxx: Named macro '" + lastMacro + "' not set"); - } - return res; - }, + elem.dispatchEvent(evt); - /** - * Returns all macros matching <b>filter</b>. - * - * @param {string} filter A regular expression filter string. A null - * filter selects all macros. - */ - getMacros: function (filter) - { - if (!filter) - return macros; - - let re = RegExp(filter); - return ([macro, keys] for ([macro, keys] in macros) if (re.test(macro))); - }, + if (!this.feedingKeys) + break; - /** - * Deletes all macros matching <b>filter</b>. - * - * @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 macros) - { - if (re.test(item) || !filter) - macros.remove(item); + // Stop feeding keys if page loading failed. + if (modes.isReplaying && !this.waitForPageLoad()) + break; } - }, - - /** - * Pushes keys onto the event queue from liberator. It is similar to - * Vim's feedkeys() method, but cannot cope with 2 partially-fed - * strings, you have to feed one parsable 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 Allow recursive mappings. - * @param {boolean} silent Whether the command should be echoed to the - * command line. - * @returns {boolean} - */ - feedkeys: function (keys, noremap, quiet) - { - let doc = window.document; - let view = window.document.defaultView; - - let wasFeeding = this.feedingKeys; - this.feedingKeys = true; - this.duringFeed = this.duringFeed || []; - let wasQuiet = commandline.quiet; + } + finally { + this.feedingKeys = wasFeeding; if (quiet) - commandline.quiet = quiet; + commandline.quiet = wasQuiet; - try - { - liberator.threadYield(1, true); - - for (let [, evt_obj] in Iterator(events.fromString(keys))) - { - let elem = liberator.focus || window.content; - let evt = events.create(doc, "keypress", evt_obj); + if (this.duringFeed.length) { + let duringFeed = this.duringFeed; + this.duringFeed = []; + for (let [, evt] in Iterator(duringFeed)) + evt.target.dispatchEvent(evt); + } + } + }, + + /** + * 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. + */ + create: function (doc, type, opts) { + var DEFAULTS = { + 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 = { + 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 + "Events"); + evt["init" + t + "Event"].apply(evt, + [v for ([k, v] in Iterator(util.extend(DEFAULTS[t], opts)))]); + 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 untypable key-combinations + * in macros. + * + * canonicalKeys(canonicalKeys(x)) == canonicalKeys(x) for all values + * of x. + * + * @param {string} keys Messy form. + * @returns {string} Canonical form. + */ + canonicalKeys: function (keys) { + return events.fromString(keys).map(events.closure.toString).join(""); + }, + + /** + * 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, .liberatorString and + * .liberatorShift these are set for characters that can never by + * typed, but may appear in mappings, for example <Nop> is passed as + * liberatorString, and liberatorShift is set when a user specifies + * <S-@> where @ is a non-case-changable, non-space character. + * + * @param {string} keys The string to parse. + * @return {Array[Object]} + */ + fromString: function (input) { + let out = []; + + let re = RegExp("<.*?>?>|[^<]|<(?!.*>)", "g"); + let match; + + while (match = re.exec(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(); + + if (keyname && !(keyname.length == 1 && modifier.length == 0 || // disallow <> and <a> + !(keyname.length == 1 || this._key_code[keyname] || keyname == "nop" || /mouse$/.test(keyname)))) { // disallow <misteak> + 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.liberatorShift = true; + } - if (typeof noremap == "object") - for (let [k, v] in Iterator(noremap)) - evt[k] = v; - else - evt.noremap = !!noremap; - evt.isMacro = true; - // A special hack for liberator-specific key names. - if (evt_obj.liberatorString || evt_obj.liberatorShift) - { - evt.liberatorString = evt_obj.liberatorString; // for key-less keypress events e.g. <Nop> - evt.liberatorShift = evt_obj.liberatorShift; // for untypable shift keys e.g. <S-1> - events.onKeyPress(evt); + evt_obj.charCode = keyname.charCodeAt(0); + } + else if (keyname == "nop") { + evt_obj.liberatorString = "<Nop>"; + } + 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 - elem.dispatchEvent(evt); - - if (!this.feedingKeys) - break; - - // Stop feeding keys if page loading failed. - if (modes.isReplaying && !waitForPageLoad()) - break; } - } - finally - { - this.feedingKeys = wasFeeding; - if (quiet) - commandline.quiet = wasQuiet; - - if (this.duringFeed.length) - { - let duringFeed = this.duringFeed; - this.duringFeed = []; - for (let [, evt] in Iterator(duringFeed)) - evt.target.dispatchEvent(evt); + 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); - /** - * 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. - */ - create: function (doc, type, opts) - { - var DEFAULTS = { - 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 = { - 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 + "Events"); - evt["init" + t + "Event"].apply(evt, - [v for ([k, v] in Iterator(util.extend(DEFAULTS[t], opts)))]); - return evt; - }, + // 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> - /** - * 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 untypable key-combinations - * in macros. - * - * canonicalKeys(canonicalKeys(x)) == canonicalKeys(x) for all values - * of x. - * - * @param {string} keys Messy form. - * @returns {string} Canonical form. - */ - canonicalKeys: function (keys) - { - return events.fromString(keys).map(events.toString).join(""); - }, - - /** - * 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, .liberatorString and - * .liberatorShift these are set for characters that can never by - * typed, but may appear in mappings, for example <Nop> is passed as - * liberatorString, and liberatorShift is set when a user specifies - * <S-@> where @ is a non-case-changable, non-space character. - * - * @param {string} keys The string to parse. - * @return {Array[Object]} - */ - fromString: function (input) - { - let out = []; - - let re = RegExp("<.*?>?>|[^<]|<(?!.*>)", "g"); - let match; - - while (match = re.exec(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(); - - if (keyname && !(keyname.length == 1 && modifier.length == 0 || // disallow <> and <a> - !(keyname.length == 1 || key_code[keyname] || keyname == "nop" || /mouse$/.test(keyname)))) // disallow <misteak> - { - 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.liberatorShift = true; - } + out.push(evt_obj); + } + return out; + }, + + /** + * Converts the specified event to a string in liberator key-code + * notation. Returns null for an unknown event. + * + * E.g. pressing ctrl+n would result in the string "<C-n>". + * + * @param {Event} event + * @returns {string} + */ + toString: function (event) { + if (!event) + return "[object Mappings]"; + + if (event.liberatorString) + return event.liberatorString; + + 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)) { + if (event.charCode == 0) { + if (event.shiftKey) + modifier += "S-"; - evt_obj.charCode = keyname.charCodeAt(0); - } - else if (keyname == "nop") - { - evt_obj.liberatorString = "<Nop>"; - } - 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 = 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; - } + if (event.keyCode in this._code_key) + key = this._code_key[event.keyCode]; + } + // [Ctrl-Bug] special handling of mysterious <C-[>, <C-\\>, <C-]>, <C-^>, <C-_> bugs (OS/X) + // (i.e., cntrl codes 27--31) + // --- + // For more information, see: + // [*] Vimp FAQ: http://vimperator.org/trac/wiki/Vimperator/FAQ#WhydoesntC-workforEscMacOSX + // [*] Referenced mailing list msg: http://www.mozdev.org/pipermail/vimperator/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?query_format=specific&order=relevance+desc&bug_status=__open__&id=416227 + // [*] Mozilla bug 432951: Ctrl+'foo' doesn't seem same charCode as Meta+'foo' on Cocoa + // https://bugzilla.mozilla.org/show_bug.cgi?query_format=specific&order=relevance+desc&bug_status=__open__&id=432951 + // --- + // + // The following fixes are only activated if liberator.has("MacUnix"). + // 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 has("MacUnix") if desired. + // + else if (liberator.has("MacUnix") && event.ctrlKey && event.charCode >= 27 && event.charCode <= 31) { + if (event.charCode == 27) { // [Ctrl-Bug 1/5] the <C-[> bug + key = "Esc"; + modifier = modifier.replace("C-", ""); } - 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> - - out.push(evt_obj); + else // [Ctrl-Bug 2,3,4,5/5] the <C-\\>, <C-]>, <C-^>, <C-_> bugs + key = String.fromCharCode(event.charCode + 64); } - return out; - }, + // a normal key like a, b, c, 0, etc. + else if (event.charCode > 0) { + key = String.fromCharCode(event.charCode); - /** - * Converts the specified event to a string in liberator key-code - * notation. Returns null for an unknown event. - * - * E.g. pressing ctrl+n would result in the string "<C-n>". - * - * @param {Event} event - * @returns {string} - */ - toString: function (event) - { - if (!event) - return "[object Mappings]"; - - if (event.liberatorString) - return event.liberatorString; - - 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)) - { - if (event.charCode == 0) - { - if (event.shiftKey) + if (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.liberatorShift) modifier += "S-"; - if (event.keyCode in code_key) - key = code_key[event.keyCode]; - } - // [Ctrl-Bug] special handling of mysterious <C-[>, <C-\\>, <C-]>, <C-^>, <C-_> bugs (OS/X) - // (i.e., cntrl codes 27--31) - // --- - // For more information, see: - // [*] Vimp FAQ: http://vimperator.org/trac/wiki/Vimperator/FAQ#WhydoesntC-workforEscMacOSX - // [*] Referenced mailing list msg: http://www.mozdev.org/pipermail/vimperator/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?query_format=specific&order=relevance+desc&bug_status=__open__&id=416227 - // [*] Mozilla bug 432951: Ctrl+'foo' doesn't seem same charCode as Meta+'foo' on Cocoa - // https://bugzilla.mozilla.org/show_bug.cgi?query_format=specific&order=relevance+desc&bug_status=__open__&id=432951 - // --- - // - // The following fixes are only activated if liberator.has("MacUnix"). - // 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 has("MacUnix") if desired. - // - else if (liberator.has("MacUnix") && event.ctrlKey && event.charCode >= 27 && event.charCode <= 31) - { - if (event.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(event.charCode + 64); - } - // a normal key like a, b, c, 0, etc. - else if (event.charCode > 0) - { - key = String.fromCharCode(event.charCode); - - if (key in key_code) - { - // a named charcode key (<Space> and <lt>) space can be shifted, <lt> must be forced - if ((key.match(/^\s$/) && event.shiftKey) || event.liberatorShift) - modifier += "S-"; - - key = code_key[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.liberatorShift) - modifier += "S-"; - else if (modifier.length == 0) - return key; - } + key = this._code_key[this._key_code[key]]; } - if (key == null) - return; - } - 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; + 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.liberatorShift) + modifier += "S-"; + else if (modifier.length == 0) + return key; } } - if (key == null) - return null; + return; + } + 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; + } + } - return "<" + modifier + key + ">"; - }, + if (key == null) + return null; + + return "<" + modifier + key + ">"; + }, + + /** + * Whether <b>key</b> 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 <b>key</b> 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>", + + /** + * Waits for the current buffer to successfully finish loading. Returns + * true for a successful page load otherwise false. + * + * @returns {boolean} + */ + waitForPageLoad: function () { + //liberator.dump("start waiting in loaded state: " + buffer.loaded); + liberator.threadYield(true); // clear queue + + if (buffer.loaded == 1) + return true; + + const maxWaitTime = 25; + let start = Date.now(); + let end = start + (maxWaitTime * 1000); // maximum time to wait - TODO: add option + let now; + while (now = Date.now(), now < end) { + liberator.threadYield(); + //if ((now - start) % 1000 < 10) + // liberator.dump("waited: " + (now - start) + " ms"); + + if (!events.feedingKeys) + return false; - /** - * Whether <b>key</b> 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 <b>key</b> 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>", - - /** - * Waits for the current buffer to successfully finish loading. Returns - * true for a successful page load otherwise false. - * - * @returns {boolean} - */ - waitForPageLoad: function () - { - //liberator.dump("start waiting in loaded state: " + buffer.loaded); - liberator.threadYield(true); // clear queue - - if (buffer.loaded == 1) - return true; - - const maxWaitTime = 25; - let start = Date.now(); - let end = start + (maxWaitTime * 1000); // maximum time to wait - TODO: add option - let now; - while (now = Date.now(), now < end) - { - liberator.threadYield(); - //if ((now - start) % 1000 < 10) - // liberator.dump("waited: " + (now - start) + " ms"); - - if (!events.feedingKeys) - return false; - - if (buffer.loaded > 0) - { - liberator.sleep(250); - break; - } - else - liberator.echo("Waiting for page to load...", commandline.DISALLOW_MULTILINE); + if (buffer.loaded > 0) { + liberator.sleep(250); + break; } - modes.show(); + else + liberator.echo("Waiting for page to load...", commandline.DISALLOW_MULTILINE); + } + modes.show(); + + // TODO: allow macros to be continued when page does not fully load with an option + let ret = (buffer.loaded == 1); + if (!ret) + liberator.echoerr("Page did not load completely in " + maxWaitTime + " seconds. Macro stopped."); + //liberator.dump("done waiting: " + ret); + + // sometimes the input widget had focus when replaying a macro + // maybe this call should be moved somewhere else? + // liberator.focusContent(true); + + return ret; + }, + + // argument "event" is deliberately not used, as i don't seem to have + // access to the real focus target + // Huh? --djk + onFocusChange: function (event) { + // command line has it's own focus change handler + if (liberator.mode == modes.COMMAND_LINE) + return; - // TODO: allow macros to be continued when page does not fully load with an option - let ret = (buffer.loaded == 1); - if (!ret) - liberator.echoerr("Page did not load completely in " + maxWaitTime + " seconds. Macro stopped."); - //liberator.dump("done waiting: " + ret); + function hasHTMLDocument(win) win && win.document && win.document instanceof HTMLDocument - // sometimes the input widget had focus when replaying a macro - // maybe this call should be moved somewhere else? - // liberator.focusContent(true); + let win = window.document.commandDispatcher.focusedWindow; + let elem = window.document.commandDispatcher.focusedElement; - return ret; - }, + if (win && win.top == content && liberator.has("tabs")) + tabs.localStore.focusedFrame = win; - // argument "event" is deliberately not used, as i don't seem to have - // access to the real focus target - // Huh? --djk - onFocusChange: function (event) - { - // command line has it's own focus change handler - if (liberator.mode == modes.COMMAND_LINE) + try { + if (elem && elem.readOnly) return; - function hasHTMLDocument(win) win && win.document && win.document instanceof HTMLDocument - - let win = window.document.commandDispatcher.focusedWindow; - let elem = window.document.commandDispatcher.focusedElement; - - if (win && win.top == content && liberator.has("tabs")) - tabs.localStore.focusedFrame = win; - - try - { - if (elem && elem.readOnly) - return; - - if ((elem instanceof HTMLInputElement && /^(text|password)$/.test(elem.type)) || - (elem instanceof HTMLSelectElement)) - { - liberator.mode = modes.INSERT; - if (hasHTMLDocument(win)) - buffer.lastInputField = elem; - return; - } - if (elem instanceof HTMLEmbedElement || elem instanceof HTMLObjectElement) - { - liberator.mode = modes.EMBED; - return; - } - - if (elem instanceof HTMLTextAreaElement || (elem && elem.contentEditable == "true")) - { - if (options["insertmode"]) - modes.set(modes.INSERT); - else if (elem.selectionEnd - elem.selectionStart > 0) - modes.set(modes.VISUAL, modes.TEXTAREA); - else - modes.main = modes.TEXTAREA; - if (hasHTMLDocument(win)) - buffer.lastInputField = elem; - return; - } - - if (config.focusChange) - return void config.focusChange(win); - - let urlbar = document.getElementById("urlbar"); - if (elem == null && urlbar && urlbar.inputField == lastFocus) - liberator.threadYield(true); - - if (liberator.mode & (modes.EMBED | modes.INSERT | modes.TEXTAREA | modes.VISUAL)) - modes.reset(); + if ((elem instanceof HTMLInputElement && /^(text|password)$/.test(elem.type)) || + (elem instanceof HTMLSelectElement)) { + liberator.mode = modes.INSERT; + if (hasHTMLDocument(win)) + buffer.lastInputField = elem; + return; } - finally - { - lastFocus = elem; + if (elem instanceof HTMLEmbedElement || elem instanceof HTMLObjectElement) { + liberator.mode = modes.EMBED; + return; } - }, - onSelectionChange: function (event) - { - let couldCopy = false; - let controller = document.commandDispatcher.getControllerForCommand("cmd_copy"); - if (controller && controller.isCommandEnabled("cmd_copy")) - couldCopy = true; - - if (liberator.mode != modes.VISUAL) - { - if (couldCopy) - { - if ((liberator.mode == modes.TEXTAREA || - (modes.extended & modes.TEXTAREA)) - && !options["insertmode"]) - modes.set(modes.VISUAL, modes.TEXTAREA); - else if (liberator.mode == modes.CARET) - modes.set(modes.VISUAL, modes.CARET); - } + if (elem instanceof HTMLTextAreaElement || (elem && elem.contentEditable == "true")) { + if (options["insertmode"]) + modes.set(modes.INSERT); + else if (elem.selectionEnd - elem.selectionStart > 0) + modes.set(modes.VISUAL, modes.TEXTAREA); + else + modes.main = modes.TEXTAREA; + if (hasHTMLDocument(win)) + buffer.lastInputField = elem; + return; } - // XXX: disabled, as i think automatically starting visual caret mode does more harm than help - // else - // { - // if (!couldCopy && modes.extended & modes.CARET) - // liberator.mode = modes.CARET; - // } - }, - /** - * The global escape key handler. This is called in ALL modes. - */ - onEscape: function () - { - if (modes.passNextKey) - return; + if (config.focusChange) + return void config.focusChange(win); - if (modes.passAllKeys) - { - modes.passAllKeys = false; - return; + let urlbar = document.getElementById("urlbar"); + if (elem == null && urlbar && urlbar.inputField == this._lastFocus) + liberator.threadYield(true); + + if (liberator.mode & (modes.EMBED | modes.INSERT | modes.TEXTAREA | modes.VISUAL)) + modes.reset(); + } + finally { + this._lastFocus = elem; + } + }, + + onSelectionChange: function (event) { + let couldCopy = false; + let controller = document.commandDispatcher.getControllerForCommand("cmd_copy"); + if (controller && controller.isCommandEnabled("cmd_copy")) + couldCopy = true; + + if (liberator.mode != modes.VISUAL) { + if (couldCopy) { + if ((liberator.mode == modes.TEXTAREA || + (modes.extended & modes.TEXTAREA)) + && !options["insertmode"]) + modes.set(modes.VISUAL, modes.TEXTAREA); + else if (liberator.mode == modes.CARET) + modes.set(modes.VISUAL, modes.CARET); } + } + // XXX: disabled, as i think automatically starting visual caret mode does more harm than help + // else + // { + // if (!couldCopy && modes.extended & modes.CARET) + // liberator.mode = modes.CARET; + // } + }, + + /** + * The global escape key handler. This is called in ALL modes. + */ + onEscape: function () { + if (modes.passNextKey) + return; - switch (liberator.mode) - { - case modes.NORMAL: - // clear any selection made - let selection = window.content.getSelection(); - try - { // a simple if (selection) does not seem to work - selection.collapseToStart(); - } - catch (e) {} + if (modes.passAllKeys) { + modes.passAllKeys = false; + return; + } - modes.reset(); - break; + switch (liberator.mode) { + case modes.NORMAL: + // clear any selection made + let selection = window.content.getSelection(); + try { // a simple if (selection) does not seem to work + selection.collapseToStart(); + } + catch (e) {} - case modes.VISUAL: - if (modes.extended & modes.TEXTAREA) - liberator.mode = modes.TEXTAREA; - else if (modes.extended & modes.CARET) - liberator.mode = modes.CARET; - break; + modes.reset(); + break; + + case modes.VISUAL: + if (modes.extended & modes.TEXTAREA) + liberator.mode = modes.TEXTAREA; + else if (modes.extended & modes.CARET) + liberator.mode = modes.CARET; + break; + + case modes.CARET: + // setting this option will trigger an observer which will + // take care of all other details like setting the NORMAL + // mode + options.setPref("accessibility.browsewithcaret", false); + break; + + case modes.TEXTAREA: + // TODO: different behaviour for text areas and other input + // fields seems unnecessarily complicated. If the user + // likes Vi-mode then they probably like it for all input + // fields, if not they can enter it explicitly for only + // text areas. The mode name TEXTAREA is confusing and + // would be better replaced with something indicating that + // it's a Vi editing mode. Extended modes really need to be + // displayed too. --djk + function isInputField() { + let elem = liberator.focus; + return ((elem instanceof HTMLInputElement && !/image/.test(elem.type)) + || elem instanceof HTMLIsIndexElement); + } - case modes.CARET: - // setting this option will trigger an observer which will - // take care of all other details like setting the NORMAL - // mode - options.setPref("accessibility.browsewithcaret", false); - break; + if (options["insertmode"] || isInputField()) + liberator.mode = modes.INSERT; + else + modes.reset(); + break; - case modes.TEXTAREA: - // TODO: different behaviour for text areas and other input - // fields seems unnecessarily complicated. If the user - // likes Vi-mode then they probably like it for all input - // fields, if not they can enter it explicitly for only - // text areas. The mode name TEXTAREA is confusing and - // would be better replaced with something indicating that - // it's a Vi editing mode. Extended modes really need to be - // displayed too. --djk - function isInputField() - { - let elem = liberator.focus; - return ((elem instanceof HTMLInputElement && !/image/.test(elem.type)) - || elem instanceof HTMLIsIndexElement); - } + case modes.INSERT: + if ((modes.extended & modes.TEXTAREA)) + liberator.mode = modes.TEXTAREA; + else + modes.reset(); + break; - if (options["insertmode"] || isInputField()) - liberator.mode = modes.INSERT; - else - modes.reset(); - break; + default: // HINTS, CUSTOM or COMMAND_LINE + modes.reset(); + break; + } + }, - case modes.INSERT: - if ((modes.extended & modes.TEXTAREA)) - liberator.mode = modes.TEXTAREA; - else - modes.reset(); - break; + // this keypress handler gets always called first, even if e.g. + // the commandline has focus + onKeyPress: function (event) { + function isEscapeKey(key) key == "<Esc>" || key == "<C-[>"; - default: // HINTS, CUSTOM or COMMAND_LINE - modes.reset(); - break; - } - }, + function killEvent() { + event.preventDefault(); + event.stopPropagation(); + } - // this keypress handler gets always called first, even if e.g. - // the commandline has focus - onKeyPress: function (event) - { - function isEscapeKey(key) key == "<Esc>" || key == "<C-[>"; + let key = events.toString(event); + if (!key) + return; - function killEvent() - { - event.preventDefault(); - event.stopPropagation(); + if (modes.isRecording) { + if (key == "q") { // TODO: should not be hardcoded + modes.isRecording = false; + liberator.log("Recorded " + this._currentMacro + ": " + this._macros.get(this._currentMacro), 9); + liberator.echomsg("Recorded macro '" + this._currentMacro + "'"); + return void killEvent(); } + else if (!mappings.hasMap(liberator.mode, this._input.buffer + key)) + this._macros.set(this._currentMacro, this._macros.get(this._currentMacro) + key); + } - let key = events.toString(event); - if (!key) - return; - - if (modes.isRecording) - { - if (key == "q") // TODO: should not be hardcoded - { - modes.isRecording = false; - liberator.log("Recorded " + currentMacro + ": " + macros.get(currentMacro), 9); - liberator.echomsg("Recorded macro '" + currentMacro + "'"); - return void killEvent(); + if (key == "<C-c>") + liberator.interrupted = true; + + // 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.isReplaying) { + modes.isReplaying = false; + setTimeout(function () { liberator.echomsg("Canceled playback of macro '" + this._lastMacro + "'"); }, 100); } - else if (!mappings.hasMap(liberator.mode, input.buffer + key)) - macros.set(currentMacro, macros.get(currentMacro) + key); } + else + events.duringFeed.push(event); - if (key == "<C-c>") - liberator.interrupted = true; - - // 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.isReplaying) - { - modes.isReplaying = false; - setTimeout(function () { liberator.echomsg("Canceled playback of macro '" + lastMacro + "'"); }, 100); - } - } + return void killEvent(); + } + + try { + let stop = false; + + let win = document.commandDispatcher.focusedWindow; + if (win && win.document && win.document.designMode == "on" && !config.isComposeWindow) + stop = true; + // menus have their own command handlers + if (modes.extended & modes.MENU) + stop = true; + // handle Escape-one-key mode (Ctrl-v) + else if (modes.passNextKey && !modes.passAllKeys) { + modes.passNextKey = false; + stop = true; + } + // handle Escape-all-keys mode (Ctrl-q) + else if (modes.passAllKeys) { + if (modes.passNextKey) + modes.passNextKey = false; // and then let flow continue + else if (isEscapeKey(key) || key == "<C-v>") + ; // let flow continue to handle these keys to cancel escape-all-keys mode else - events.duringFeed.push(event); + stop = true; + } - return void killEvent(); + if (stop) { + this._input.buffer = ""; + return; } - try - { - let stop = false; + stop = true; // set to false if we should NOT consume this event but let the host app handle it - let win = document.commandDispatcher.focusedWindow; - if (win && win.document && win.document.designMode == "on" && !config.isComposeWindow) - stop = true; - // menus have their own command handlers - if (modes.extended & modes.MENU) - stop = true; - // handle Escape-one-key mode (Ctrl-v) - else if (modes.passNextKey && !modes.passAllKeys) - { - modes.passNextKey = false; - stop = true; - } - // handle Escape-all-keys mode (Ctrl-q) - else if (modes.passAllKeys) - { - if (modes.passNextKey) - modes.passNextKey = false; // and then let flow continue - else if (isEscapeKey(key) || key == "<C-v>") - ; // let flow continue to handle these keys to cancel escape-all-keys mode - else - stop = true; - } + // just forward event without checking any mappings when the MOW is open + if (liberator.mode == modes.COMMAND_LINE && (modes.extended & modes.OUTPUT_MULTILINE)) { + commandline.onMultilineOutputEvent(event); + return void killEvent(); + } - if (stop) - { - input.buffer = ""; - return; - } + // XXX: ugly hack for now pass certain keys to the host app as + // they are without beeping also fixes key navigation in combo + // boxes, submitting forms, etc. + // FIXME: breaks iabbr for now --mst + if (key in config.ignoreKeys && (config.ignoreKeys[key] & liberator.mode)) { + this._input.buffer = ""; + return; + } - stop = true; // set to false if we should NOT consume this event but let the host app handle it + // TODO: handle middle click in content area - // just forward event without checking any mappings when the MOW is open - if (liberator.mode == modes.COMMAND_LINE && (modes.extended & modes.OUTPUT_MULTILINE)) - { - commandline.onMultilineOutputEvent(event); + if (!isEscapeKey(key)) { + // custom mode... + if (liberator.mode == modes.CUSTOM) { + plugins.onEvent(event); return void killEvent(); } - // XXX: ugly hack for now pass certain keys to the host app as - // they are without beeping also fixes key navigation in combo - // boxes, submitting forms, etc. - // FIXME: breaks iabbr for now --mst - if (key in config.ignoreKeys && (config.ignoreKeys[key] & liberator.mode)) - { - input.buffer = ""; + // All of these special cases for hint mode are driving + // me insane! -Kris + if (modes.extended & modes.HINTS) { + // under HINT mode, certain keys are redirected to hints.onEvent + if (key == "<Return>" || key == "<Tab>" || key == "<S-Tab>" + || key == mappings.getMapLeader() + || (key == "<BS>" && hints.previnput == "number") + || (/^[0-9]$/.test(key) && !hints.escNumbers)) { + hints.onEvent(event); + this._input.buffer = ""; + return void killEvent(); + } + + // others are left to generate the 'input' event or handled by the host app return; } + } - // TODO: handle middle click in content area + // FIXME (maybe): (is an ESC or C-] here): on HINTS mode, it enters + // into 'if (map && !skipMap) below. With that (or however) it + // triggers the onEscape part, where it resets mode. Here I just + // return true, with the effect that it also gets to there (for + // whatever reason). if that happens to be correct, well.. + // XXX: why not just do that as well for HINTS mode actually? - if (!isEscapeKey(key)) - { - // custom mode... - if (liberator.mode == modes.CUSTOM) - { - plugins.onEvent(event); - return void killEvent(); - } + if (liberator.mode == modes.CUSTOM) + return; - // All of these special cases for hint mode are driving - // me insane! -Kris - if (modes.extended & modes.HINTS) - { - // under HINT mode, certain keys are redirected to hints.onEvent - if (key == "<Return>" || key == "<Tab>" || key == "<S-Tab>" - || key == mappings.getMapLeader() - || (key == "<BS>" && hints.previnput == "number") - || (/^[0-9]$/.test(key) && !hints.escNumbers)) - { - hints.onEvent(event); - input.buffer = ""; - return void killEvent(); - } + let inputStr = this._input.buffer + key; + let countStr = inputStr.match(/^[1-9][0-9]*|/)[0]; + let candidateCommand = inputStr.substr(countStr.length); + let map = mappings[event.noremap ? "getDefault" : "get"](liberator.mode, candidateCommand); + + let candidates = mappings.getCandidates(liberator.mode, candidateCommand); + if (candidates.length == 0 && !map) { + map = this._input.pendingMap; + this._input.pendingMap = null; + if (map && map.arg) + this._input.pendingArgMap = map; + } - // others are left to generate the 'input' event or handled by the host app + // counts must be at the start of a complete mapping (10j -> go 10 lines down) + if (countStr && !candidateCommand) { + // no count for insert mode mappings + if (!modes.mainMode.count || modes.mainMode.input) + stop = false; + else + this._input.buffer = inputStr; + } + else if (this._input.pendingArgMap) { + this._input.buffer = ""; + let map = this._input.pendingArgMap; + this._input.pendingArgMap = null; + if (!isEscapeKey(key)) { + if (modes.isReplaying && !this.waitForPageLoad()) return; - } + map.execute(null, this._input.count, key); } - - // FIXME (maybe): (is an ESC or C-] here): on HINTS mode, it enters - // into 'if (map && !skipMap) below. With that (or however) it - // triggers the onEscape part, where it resets mode. Here I just - // return true, with the effect that it also gets to there (for - // whatever reason). if that happens to be correct, well.. - // XXX: why not just do that as well for HINTS mode actually? - - if (liberator.mode == modes.CUSTOM) - return; - - let inputStr = input.buffer + key; - let countStr = inputStr.match(/^[1-9][0-9]*|/)[0]; - let candidateCommand = inputStr.substr(countStr.length); - let map = mappings[event.noremap ? "getDefault" : "get"](liberator.mode, candidateCommand); - - let candidates = mappings.getCandidates(liberator.mode, candidateCommand); - if (candidates.length == 0 && !map) - { - map = input.pendingMap; - input.pendingMap = null; - if (map && map.arg) - input.pendingArgMap = map; + } + // only follow a map if there isn't a longer possible mapping + // (allows you to do :map z yy, when zz is a longer mapping than z) + else if (map && !event.skipmap && candidates.length == 0) { + this._input.pendingMap = null; + this._input.count = parseInt(countStr, 10); + if (isNaN(this._input.count)) + this._input.count = -1; + this._input.buffer = ""; + if (map.arg) { + this._input.buffer = inputStr; + this._input.pendingArgMap = map; + } + else if (this._input.pendingMotionMap) { + if (!isEscapeKey(key)) + this._input.pendingMotionMap.execute(candidateCommand, this._input.count, null); + this._input.pendingMotionMap = null; + } + // no count support for these commands yet + else if (map.motion) { + this._input.pendingMotionMap = map; } + else { + if (modes.isReplaying && !this.waitForPageLoad()) + return void killEvent(); - // counts must be at the start of a complete mapping (10j -> go 10 lines down) - if (countStr && !candidateCommand) - { - // no count for insert mode mappings - if (!modes.mainMode.count || modes.mainMode.input) + let ret = map.execute(null, this._input.count); + if (map.route && ret) stop = false; - else - input.buffer = inputStr; } - else if (input.pendingArgMap) - { - input.buffer = ""; - let map = input.pendingArgMap; - input.pendingArgMap = null; - if (!isEscapeKey(key)) - { - if (modes.isReplaying && !waitForPageLoad()) - return; - map.execute(null, input.count, key); + } + else if (mappings.getCandidates(liberator.mode, candidateCommand).length > 0 && !event.skipmap) { + this._input.pendingMap = map; + this._input.buffer += key; + } + else { // if the key is neither a mapping nor the start of one + // the mode checking is necessary so that things like g<esc> do not beep + if (this._input.buffer != "" && !event.skipmap && + (liberator.mode & (modes.INSERT | modes.COMMAND_LINE | modes.TEXTAREA))) + events.feedkeys(this._input.buffer, { noremap: true, skipmap: true }); + + this._input.buffer = ""; + this._input.pendingArgMap = null; + this._input.pendingMotionMap = null; + this._input.pendingMap = null; + + if (!isEscapeKey(key)) { + // allow key to be passed to the host app if we can't handle it + stop = false; + + if (liberator.mode == modes.COMMAND_LINE) { + if (!(modes.extended & modes.INPUT_MULTILINE)) + commandline.onEvent(event); // reroute event in command line mode } + else if (!modes.mainMode.input) + liberator.beep(); } - // only follow a map if there isn't a longer possible mapping - // (allows you to do :map z yy, when zz is a longer mapping than z) - else if (map && !event.skipmap && candidates.length == 0) - { - input.pendingMap = null; - input.count = parseInt(countStr, 10); - if (isNaN(input.count)) - input.count = -1; - input.buffer = ""; - if (map.arg) - { - input.buffer = inputStr; - input.pendingArgMap = map; - } - else if (input.pendingMotionMap) - { - if (!isEscapeKey(key)) - input.pendingMotionMap.execute(candidateCommand, input.count, null); - input.pendingMotionMap = null; - } - // no count support for these commands yet - else if (map.motion) - { - input.pendingMotionMap = map; - } - else - { - if (modes.isReplaying && !waitForPageLoad()) - return void killEvent(); + } - let ret = map.execute(null, input.count); - if (map.route && ret) - stop = false; - } - } - else if (mappings.getCandidates(liberator.mode, candidateCommand).length > 0 && !event.skipmap) - { - input.pendingMap = map; - input.buffer += key; - } - else // if the key is neither a mapping nor the start of one - { - // the mode checking is necessary so that things like g<esc> do not beep - if (input.buffer != "" && !event.skipmap && - (liberator.mode & (modes.INSERT | modes.COMMAND_LINE | modes.TEXTAREA))) - events.feedkeys(input.buffer, { noremap: true, skipmap: true }); - - input.buffer = ""; - input.pendingArgMap = null; - input.pendingMotionMap = null; - input.pendingMap = null; + if (stop) + killEvent(); + } + finally { + let motionMap = (this._input.pendingMotionMap && this._input.pendingMotionMap.names[0]) || ""; + statusline.updateInputBuffer(motionMap + this._input.buffer); + } + }, - if (!isEscapeKey(key)) - { - // allow key to be passed to the host app if we can't handle it - stop = false; + // this is need for sites like msn.com which focus the input field on keydown + onKeyUpOrDown: function (event) { + if (modes.passNextKey ^ modes.passAllKeys || Events.isInputElemFocused()) + return; - if (liberator.mode == modes.COMMAND_LINE) - { - if (!(modes.extended & modes.INPUT_MULTILINE)) - commandline.onEvent(event); // reroute event in command line mode - } - else if (!modes.mainMode.input) - liberator.beep(); + event.stopPropagation(); + }, + + // TODO: move to buffer.js? + /** + * The liberator document loading progress listener. + */ + progressListener: { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIXULBrowserWindow + ]), + + // XXX: function may later be needed to detect a canceled synchronous openURL() + onStateChange: function (webProgress, request, flags, status) { + // 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)) { + // 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) { + buffer.loaded = 0; + statusline.updateProgress(0); + + autocommands.trigger("PageLoadPre", { url: buffer.URL }); + + // don't reset mode if a frame of the frameset gets reloaded which + // is not the focused frame + if (document.commandDispatcher.focusedWindow == webProgress.DOMWindow) { + setTimeout(function () { modes.reset(false); }, + liberator.mode == modes.HINTS ? 500 : 0); } } + else if (flags & Ci.nsIWebProgressListener.STATE_STOP) { + buffer.loaded = (status == 0 ? 1 : 2); + statusline.updateUrl(); + } + } + }, + // for notifying the user about secure web pages + onSecurityChange: function (webProgress, request, state) { + // TODO: do something useful with STATE_SECURE_MED and STATE_SECURE_LOW + if (state & Ci.nsIWebProgressListener.STATE_IS_INSECURE) + statusline.setClass("insecure"); + else if (state & Ci.nsIWebProgressListener.STATE_IS_BROKEN) + statusline.setClass("broken"); + else if (state & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) + statusline.setClass("extended"); + else if (state & Ci.nsIWebProgressListener.STATE_SECURE_HIGH) + statusline.setClass("secure"); + }, + onStatusChange: function (webProgress, request, status, message) { + statusline.updateUrl(message); + }, + onProgressChange: function (webProgress, request, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) { + statusline.updateProgress(curTotalProgress/maxTotalProgress); + }, + // happens when the users switches tabs + onLocationChange: function () { + statusline.updateUrl(); + statusline.updateProgress(); - if (stop) - killEvent(); + autocommands.trigger("LocationChange", { url: buffer.URL }); + + // if this is not delayed we get the position of the old buffer + setTimeout(function () { statusline.updateBufferPosition(); }, 500); + }, + // called at the very end of a page load + asyncUpdateUI: function () { + setTimeout(function () { statusline.updateUrl(); }, 100); + }, + setOverLink: function (link, b) { + let ssli = options["showstatuslinks"]; + if (link && ssli) { + if (ssli == 1) + statusline.updateUrl("Link: " + link); + else if (ssli == 2) + liberator.echo("Link: " + link, commandline.DISALLOW_MULTILINE); } - finally - { - let motionMap = (input.pendingMotionMap && input.pendingMotionMap.names[0]) || ""; - statusline.updateInputBuffer(motionMap + input.buffer); + + if (link == "") { + if (ssli == 1) + statusline.updateUrl(); + else if (ssli == 2) + modes.show(); } }, - // this is need for sites like msn.com which focus the input field on keydown - onKeyUpOrDown: function (event) - { - if (modes.passNextKey ^ modes.passAllKeys || isInputElemFocused()) - return; + // nsIXULBrowserWindow stubs + setJSDefaultStatus: function (status) {}, + setJSStatus: function (status) {}, - event.stopPropagation(); - }, + // Stub for something else, presumably. Not in any documented + // interface. + onLinkIconAvailable: function () {} + } +}, { + isInputElemFocused: function () { + let elem = liberator.focus; + return ((elem instanceof HTMLInputElement && !/image/.test(elem.type)) || + elem instanceof HTMLTextAreaElement || + elem instanceof HTMLIsIndexElement || + elem instanceof HTMLObjectElement || + elem instanceof HTMLEmbedElement); + }, - // TODO: move to buffer.js? - /** - * The liberator document loading progress listener. - */ - progressListener: { - QueryInterface: XPCOMUtils.generateQI([ - Ci.nsIWebProgressListener, - Ci.nsIXULBrowserWindow - ]), - - // XXX: function may later be needed to detect a canceled synchronous openURL() - onStateChange: function (webProgress, request, flags, status) - { - // 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)) - { - // 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) - { - buffer.loaded = 0; - statusline.updateProgress(0); - - autocommands.trigger("PageLoadPre", { url: buffer.URL }); - - // don't reset mode if a frame of the frameset gets reloaded which - // is not the focused frame - if (document.commandDispatcher.focusedWindow == webProgress.DOMWindow) - { - setTimeout(function () { modes.reset(false); }, - liberator.mode == modes.HINTS ? 500 : 0); - } - } - else if (flags & Ci.nsIWebProgressListener.STATE_STOP) - { - buffer.loaded = (status == 0 ? 1 : 2); - statusline.updateUrl(); - } - } - }, - // for notifying the user about secure web pages - onSecurityChange: function (webProgress, request, state) - { - // TODO: do something useful with STATE_SECURE_MED and STATE_SECURE_LOW - if (state & Ci.nsIWebProgressListener.STATE_IS_INSECURE) - statusline.setClass("insecure"); - else if (state & Ci.nsIWebProgressListener.STATE_IS_BROKEN) - statusline.setClass("broken"); - else if (state & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) - statusline.setClass("extended"); - else if (state & Ci.nsIWebProgressListener.STATE_SECURE_HIGH) - statusline.setClass("secure"); - }, - onStatusChange: function (webProgress, request, status, message) - { - statusline.updateUrl(message); - }, - onProgressChange: function (webProgress, request, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) - { - statusline.updateProgress(curTotalProgress/maxTotalProgress); - }, - // happens when the users switches tabs - onLocationChange: function () - { - statusline.updateUrl(); - statusline.updateProgress(); +}, { + commands: function () { + commands.add(["delmac[ros]"], + "Delete macros", + function (args) { + liberator.assert(!args.bang || !args.string, "E474: Invalid argument"); - autocommands.trigger("LocationChange", { url: buffer.URL }); + if (args.bang) + events.deleteMacros(); + else if (args.string) + events.deleteMacros(args.string); + else + liberator.echoerr("E471: Argument required"); + }, { + bang: true, + completer: function (context) completion.macro(context) + }); - // if this is not delayed we get the position of the old buffer - setTimeout(function () { statusline.updateBufferPosition(); }, 500); - }, - // called at the very end of a page load - asyncUpdateUI: function () - { - setTimeout(function () { statusline.updateUrl(); }, 100); - }, - setOverLink: function (link, b) - { - let ssli = options["showstatuslinks"]; - if (link && ssli) - { - if (ssli == 1) - statusline.updateUrl("Link: " + link); - else if (ssli == 2) - liberator.echo("Link: " + link, commandline.DISALLOW_MULTILINE); - } + commands.add(["macros"], + "List all macros", + function (args) { completion.listCompleter("macro", args[0]); }, { + argCount: "?", + completer: function (context) completion.macro(context) + }); - if (link == "") - { - if (ssli == 1) - statusline.updateUrl(); - else if (ssli == 2) - modes.show(); - } + commands.add(["pl[ay]"], + "Replay a recorded macro", + function (args) { events.playMacro(args[0]); }, { + argCount: "1", + completer: function (context) completion.macro(context) + }); + }, + mappings: function () { + mappings.add(modes.all, + ["<Esc>", "<C-[>"], "Focus content", + function () { events.onEscape(); }); + + // add the ":" mapping in all but insert mode mappings + mappings.add([modes.NORMAL, modes.PLAYER, modes.VISUAL, modes.HINTS, modes.MESSAGE, modes.COMPOSE, modes.CARET, modes.TEXTAREA], + [":"], "Enter command line mode", + function () { commandline.open(":", "", modes.EX); }); + + // focus events + mappings.add([modes.NORMAL, modes.PLAYER, modes.VISUAL, modes.CARET], + ["<Tab>"], "Advance keyboard focus", + function () { document.commandDispatcher.advanceFocus(); }); + + mappings.add([modes.NORMAL, modes.PLAYER, modes.VISUAL, modes.CARET, modes.INSERT, modes.TEXTAREA], + ["<S-Tab>"], "Rewind keyboard focus", + function () { document.commandDispatcher.rewindFocus(); }); + + mappings.add(modes.all, + ["<C-z>"], "Temporarily ignore all " + config.name + " key bindings", + function () { modes.passAllKeys = true; }); + + mappings.add(modes.all, + ["<C-v>"], "Pass through next key", + function () { modes.passNextKey = true; }); + + mappings.add(modes.all, + ["<Nop>"], "Do nothing", + function () { return; }); + + // macros + mappings.add([modes.NORMAL, modes.PLAYER, modes.MESSAGE], + ["q"], "Record a key sequence into a macro", + function (arg) { events.startRecording(arg); }, + { arg: true }); + + mappings.add([modes.NORMAL, modes.PLAYER, modes.MESSAGE], + ["@"], "Play a macro", + function (count, arg) { + if (count < 1) count = 1; + while (count-- && events.playMacro(arg)) + ; }, + { arg: true, count: true }); + }, + tabs: function () { - // nsIXULBrowserWindow stubs - setJSDefaultStatus: function (status) {}, - setJSStatus: function (status) {}, + function triggerLoadAutocmd(name, doc) { + let args = { + url: doc.location.href, + title: doc.title + }; - // Stub for something else, presumably. Not in any documented - // interface. - onLinkIconAvailable: function () {} + if (liberator.has("tabs")) { + args.tab = tabs.getContentIndex(doc) + 1; + args.doc = "tabs.getTab(" + (args.tab - 1) + ").linkedBrowser.contentDocument"; + } + + autocommands.trigger(name, args); + } + + function onDOMContentLoaded(event) { + let doc = event.originalTarget; + if (doc instanceof HTMLDocument && !doc.defaultView.frameElement) + triggerLoadAutocmd("DOMLoad", doc); } - }; //}}} - - window.XULBrowserWindow = self.progressListener; - window.QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIWebNavigation) - .QueryInterface(Ci.nsIDocShellTreeItem) - .treeOwner - .QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIXULWindow) - .XULBrowserWindow = self.progressListener; - try - { - getBrowser().addProgressListener(self.progressListener, Ci.nsIWebProgress.NOTIFY_ALL); - } - catch (e) {} - liberator.registerObserver("shutdown", function () { self.destroy(); }); + // TODO: see what can be moved to onDOMContentLoaded() + function onPageLoad(event) { + if (event.originalTarget instanceof HTMLDocument) { + let doc = event.originalTarget; + // document is part of a frameset + if (doc.defaultView.frameElement) { + // hacky way to get rid of "Transfering data from ..." on sites with frames + // when you click on a link inside a frameset, because asyncUpdateUI + // is not triggered there (Gecko bug?) + setTimeout(function () { statusline.updateUrl(); }, 10); + return; + } - function wrapListener(method) - { - return function (event) { - try - { - self[method](event); - } - catch (e) - { - if (e.message == "Interrupted") - liberator.echoerr("Interrupted"); - else - liberator.echoerr("Processing " + event.type + " event: " + (e.echoerr || e)); - liberator.reportError(e); - } - }; - } - window.addEventListener("keypress", wrapListener("onKeyPress"), true); - window.addEventListener("keydown", wrapListener("onKeyUpOrDown"), true); - window.addEventListener("keyup", wrapListener("onKeyUpOrDown"), true); + // code which should happen for all (also background) newly loaded tabs goes here: + + // mark the buffer as loaded, we can't use buffer.loaded + // since that always refers to the current buffer, while doc can be + // any buffer, even in a background tab + doc.pageIsFullyLoaded = 1; + + // code which is only relevant if the page load is the current tab goes here: + if (doc == getBrowser().contentDocument) { + // we want to stay in command mode after a page has loaded + // TODO: move somewhere else, as focusing can already happen earlier than on "load" + if (options["focuscontent"]) { + setTimeout(function () { + let focused = liberator.focus; + if (focused && (focused.value != null) && focused.value.length == 0) + focused.blur(); + }, 0); + } + } + else // background tab + liberator.echomsg("Background tab loaded: " + doc.title || doc.location.href, 3); - return self; + triggerLoadAutocmd("PageLoad", doc); + } + } -} //}}} + let tabContainer = tabs.getBrowser().mTabContainer; + + tabContainer.addEventListener("TabMove", function (event) { + statusline.updateTabCount(true); + }, false); + tabContainer.addEventListener("TabOpen", function (event) { + statusline.updateTabCount(true); + }, false); + tabContainer.addEventListener("TabClose", function (event) { + statusline.updateTabCount(true); + }, false); + tabContainer.addEventListener("TabSelect", function (event) { + // TODO: is all of that necessary? + // I vote no. --Kris + modes.reset(); + statusline.updateTabCount(true); + tabs.updateSelectionHistory(); + + if (options["focuscontent"]) + setTimeout(function () { liberator.focusContent(true); }, 10); // just make sure, that no widget has focus + }, false); + + tabs.getBrowser().addEventListener("DOMContentLoaded", onDOMContentLoaded, true); + + // this adds an event which is is called on each page load, even if the + // page is loaded in a background tab + tabs.getBrowser().addEventListener("load", onPageLoad, true); + + // called when the active document is scrolled + tabs.getBrowser().addEventListener("scroll", function (event) { + statusline.updateBufferPosition(); + modes.show(); + }, null); + }, +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/finder.js b/common/content/finder.js index 36660945..33334036 100644 --- a/common/content/finder.js +++ b/common/content/finder.js @@ -13,70 +13,192 @@ // : 'linksearch' searches should highlight link matches only // : changing any search settings should also update the search state including highlighting // : incremental searches shouldn't permanently update search modifiers +// +// TODO: Clean up this rat's nest. --Kris /** * @instance finder */ -function Finder() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var found = false; // true if the last search was successful - var backwards = false; // currently searching backwards - var searchString = ""; // current search string (without modifiers) - var searchPattern = ""; // current search string (includes modifiers) - var lastSearchPattern = ""; // the last searched pattern (includes modifiers) - var lastSearchString = ""; // the last searched string (without modifiers) - var lastSearchBackwards = false; // like "backwards", but for the last search, so if you cancel a search with <esc> this is not set - var caseSensitive = false; // search string is case sensitive - var linksOnly = false; // search is limited to link text only - - // Event handlers for search - closure is needed - commandline.registerCallback("change", modes.SEARCH_FORWARD, function (str) { finder.onKeyPress(str); }); - commandline.registerCallback("submit", modes.SEARCH_FORWARD, function (str) { finder.onSubmit(str); }); - commandline.registerCallback("cancel", modes.SEARCH_FORWARD, function () { finder.onCancel(); }); - // TODO: allow advanced myModes in register/triggerCallback - commandline.registerCallback("change", modes.SEARCH_BACKWARD, function (str) { finder.onKeyPress(str); }); - commandline.registerCallback("submit", modes.SEARCH_BACKWARD, function (str) { finder.onSubmit(str); }); - commandline.registerCallback("cancel", modes.SEARCH_BACKWARD, function () { finder.onCancel(); }); +const Finder = Module("finder", { + init: function () { + const self = this; + + this._found = false; // true if the last search was successful + this._backwards = false; // currently searching backwards + this._searchString = ""; // current search string (without modifiers) + this._searchPattern = ""; // current search string (includes modifiers) + this._lastSearchPattern = ""; // the last searched pattern (includes modifiers) + this._lastSearchString = ""; // the last searched string (without modifiers) + this._lastSearchBackwards = false; // like "backwards", but for the last search, so if you cancel a search with <esc> this is not set + this._caseSensitive = false; // search string is case sensitive + this._linksOnly = false; // search is limited to link text only + + /* Stolen from toolkit.jar in Firefox, for the time being. The private + * methods were unstable, and changed. The new version is not remotely + * compatible with what we do. + * The following only applies to this object, and may not be + * necessary, or accurate, but, just in case: + * The Original Code is mozilla.org viewsource frontend. + * + * The Initial Developer of the Original Code is + * Netscape Communications Corporation. + * Portions created by the Initial Developer are Copyright (c) 2003 + * by the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Blake Ross <blake@cs.stanford.edu> (Original Author) + * Masayuki Nakano <masayuki@d-toybox.com> + * Ben Basson <contact@cusser.net> + * Jason Barnabe <jason_barnabe@fastmail.fm> + * Asaf Romano <mano@mozilla.com> + * Ehsan Akhgari <ehsan.akhgari@gmail.com> + * Graeme McCutcheon <graememcc_firefox@graeme-online.co.uk> + */ + this._highlighter = { + + doc: null, + + spans: [], + + search: function (aWord, matchCase) { + var finder = services.create("find"); + if (matchCase !== undefined) + self._caseSensitive = matchCase; + + var range; + while ((range = finder.Find(aWord, this.searchRange, this.startPt, this.endPt))) + yield range; + }, + + highlightDoc: function highlightDoc(win, aWord) { + this.doc = content.document; // XXX + Array.forEach(win.frames, function (frame) this._highlighter.highlightDoc(frame, aWord)); + + var doc = win.document; + if (!doc || !(doc instanceof HTMLDocument)) + return; + + if (!aWord) { + let elems = this._highlighter.spans; + for (let i = elems.length; --i >= 0;) { + let elem = elems[i]; + let docfrag = doc.createDocumentFragment(); + let next = elem.nextSibling; + let parent = elem.parentNode; + + let child; + while (child = elem.firstChild) + docfrag.appendChild(child); + + parent.removeChild(elem); + parent.insertBefore(docfrag, next); + parent.normalize(); + } + return; + } + + var baseNode = <span highlight="Search"/>; + baseNode = util.xmlToDom(baseNode, window.content.document); + + var body = doc.body; + var count = body.childNodes.length; + this.searchRange = doc.createRange(); + this.startPt = doc.createRange(); + this.endPt = doc.createRange(); + + this.searchRange.setStart(body, 0); + this.searchRange.setEnd(body, count); + + this.startPt.setStart(body, 0); + this.startPt.setEnd(body, 0); + this.endPt.setStart(body, count); + this.endPt.setEnd(body, count); + + liberator.interrupted = false; + let n = 0; + for (let retRange in this.search(aWord, this._caseSensitive)) { + // Highlight + var nodeSurround = baseNode.cloneNode(true); + var node = this.highlight(retRange, nodeSurround); + this.startPt = node.ownerDocument.createRange(); + this.startPt.setStart(node, node.childNodes.length); + this.startPt.setEnd(node, node.childNodes.length); + if (n++ % 20 == 0) + liberator.threadYield(true); + if (liberator.interrupted) + break; + } + }, + + highlight: function highlight(aRange, aNode) { + var startContainer = aRange.startContainer; + var startOffset = aRange.startOffset; + var endOffset = aRange.endOffset; + var docfrag = aRange.extractContents(); + var before = startContainer.splitText(startOffset); + var parent = before.parentNode; + aNode.appendChild(docfrag); + parent.insertBefore(aNode, before); + this.spans.push(aNode); + return aNode; + }, + + /** + * Clears all search highlighting. + */ + clear: function () { + this.spans.forEach(function (span) { + if (span.parentNode) { + let el = span.firstChild; + while (el) { + span.removeChild(el); + span.parentNode.insertBefore(el, span); + el = span.firstChild; + } + span.parentNode.removeChild(span); + } + }); + this.spans = []; + }, + + isHighlighted: function (doc) this.doc == doc && this.spans.length > 0 + }; + }, // set searchString, searchPattern, caseSensitive, linksOnly - function processUserPattern(pattern) - { + processUserPattern: function (pattern) { //// strip off pattern terminator and offset //if (backwards) // pattern = pattern.replace(/\?.*/, ""); //else // pattern = pattern.replace(/\/.*/, ""); - searchPattern = pattern; + this._searchPattern = pattern; // links only search - \l wins if both modifiers specified if (/\\l/.test(pattern)) - linksOnly = true; + this._linksOnly = true; else if (/\L/.test(pattern)) - linksOnly = false; + this._linksOnly = false; else if (options["linksearch"]) - linksOnly = true; + this._linksOnly = true; else - linksOnly = false; + this._linksOnly = false; // strip links-only modifiers pattern = pattern.replace(/(\\)?\\[lL]/g, function ($0, $1) { return $1 ? $0 : ""; }); // case sensitivity - \c wins if both modifiers specified if (/\c/.test(pattern)) - caseSensitive = false; + this._caseSensitive = false; else if (/\C/.test(pattern)) - caseSensitive = true; + this._caseSensitive = true; else if (options["ignorecase"] && options["smartcase"] && /[A-Z]/.test(pattern)) - caseSensitive = true; + this._caseSensitive = true; else if (options["ignorecase"]) - caseSensitive = false; + this._caseSensitive = false; else - caseSensitive = true; + this._caseSensitive = true; // strip case-sensitive modifiers pattern = pattern.replace(/(\\)?\\[cC]/g, function ($0, $1) { return $1 ? $0 : ""; }); @@ -84,414 +206,253 @@ function Finder() //{{{ // remove any modifier escape \ pattern = pattern.replace(/\\(\\[cClL])/g, "$1"); - searchString = pattern; - } + this._searchString = pattern; + }, - /* Stolen from toolkit.jar in Firefox, for the time being. The private - * methods were unstable, and changed. The new version is not remotely - * compatible with what we do. - * The following only applies to this object, and may not be - * necessary, or accurate, but, just in case: - * The Original Code is mozilla.org viewsource frontend. + /** + * Called when the search dialog is requested. * - * The Initial Developer of the Original Code is - * Netscape Communications Corporation. - * Portions created by the Initial Developer are Copyright (c) 2003 - * by the Initial Developer. All Rights Reserved. + * @param {number} mode The search mode, either modes.SEARCH_FORWARD or + * modes.SEARCH_BACKWARD. + * @default modes.SEARCH_FORWARD + */ + openPrompt: function (mode) { + this._backwards = mode == modes.SEARCH_BACKWARD; + commandline.open(this._backwards ? "?" : "/", "", mode); + // TODO: focus the top of the currently visible screen + }, + + // TODO: backwards seems impossible i fear :( + /** + * Searches the current buffer for <b>str</b>. * - * Contributor(s): - * Blake Ross <blake@cs.stanford.edu> (Original Author) - * Masayuki Nakano <masayuki@d-toybox.com> - * Ben Basson <contact@cusser.net> - * Jason Barnabe <jason_barnabe@fastmail.fm> - * Asaf Romano <mano@mozilla.com> - * Ehsan Akhgari <ehsan.akhgari@gmail.com> - * Graeme McCutcheon <graememcc_firefox@graeme-online.co.uk> + * @param {string} str The string to find. */ - var highlighter = { - - doc: null, - - spans: [], - - search: function (aWord, matchCase) - { - var finder = services.create("find"); - if (matchCase !== undefined) - finder.caseSensitive = matchCase; - - var range; - while ((range = finder.Find(aWord, this.searchRange, this.startPt, this.endPt))) - yield range; - }, - - highlightDoc: function highlightDoc(win, aWord) - { - this.doc = content.document; // XXX - Array.forEach(win.frames, function (frame) highlighter.highlightDoc(frame, aWord)); - - var doc = win.document; - if (!doc || !(doc instanceof HTMLDocument)) - return; - - if (!aWord) - { - let elems = highlighter.spans; - for (let i = elems.length; --i >= 0;) - { - let elem = elems[i]; - let docfrag = doc.createDocumentFragment(); - let next = elem.nextSibling; - let parent = elem.parentNode; - - let child; - while (child = elem.firstChild) - docfrag.appendChild(child); - - parent.removeChild(elem); - parent.insertBefore(docfrag, next); - parent.normalize(); - } - return; - } - - var baseNode = <span highlight="Search"/>; - baseNode = util.xmlToDom(baseNode, window.content.document); - - var body = doc.body; - var count = body.childNodes.length; - this.searchRange = doc.createRange(); - this.startPt = doc.createRange(); - this.endPt = doc.createRange(); - - this.searchRange.setStart(body, 0); - this.searchRange.setEnd(body, count); - - this.startPt.setStart(body, 0); - this.startPt.setEnd(body, 0); - this.endPt.setStart(body, count); - this.endPt.setEnd(body, count); - - liberator.interrupted = false; - let n = 0; - for (let retRange in this.search(aWord, caseSensitive)) - { - // Highlight - var nodeSurround = baseNode.cloneNode(true); - var node = this.highlight(retRange, nodeSurround); - this.startPt = node.ownerDocument.createRange(); - this.startPt.setStart(node, node.childNodes.length); - this.startPt.setEnd(node, node.childNodes.length); - if (n++ % 20 == 0) - liberator.threadYield(true); - if (liberator.interrupted) - break; - } - }, - - highlight: function highlight(aRange, aNode) - { - var startContainer = aRange.startContainer; - var startOffset = aRange.startOffset; - var endOffset = aRange.endOffset; - var docfrag = aRange.extractContents(); - var before = startContainer.splitText(startOffset); - var parent = before.parentNode; - aNode.appendChild(docfrag); - parent.insertBefore(aNode, before); - this.spans.push(aNode); - return aNode; - }, - - /** - * Clears all search highlighting. - */ - clear: function () - { - this.spans.forEach(function (span) - { - if (span.parentNode) - { - let el = span.firstChild; - while (el) - { - span.removeChild(el); - span.parentNode.insertBefore(el, span); - el = span.firstChild; - } - span.parentNode.removeChild(span); - } - }); - this.spans = []; - }, - - isHighlighted: function (doc) this.doc == doc && this.spans.length > 0 - }; - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["hlsearch", "hls"], - "Highlight previous search pattern matches", - "boolean", "false", - { - setter: function (value) - { - if (value) - finder.highlight(); - else - finder.clear(); - - return value; - } - }); - - options.add(["ignorecase", "ic"], - "Ignore case in search patterns", - "boolean", true); - - options.add(["incsearch", "is"], - "Show where the search pattern matches as it is typed", - "boolean", true); - - options.add(["linksearch", "lks"], - "Limit the search to hyperlink text", - "boolean", false); - - options.add(["smartcase", "scs"], - "Override the 'ignorecase' option if the pattern contains uppercase characters", - "boolean", true); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var myModes = config.browserModes; - myModes = myModes.concat([modes.CARET]); - - mappings.add(myModes, - ["/"], "Search forward for a pattern", - function () { finder.openPrompt(modes.SEARCH_FORWARD); }); - - mappings.add(myModes, - ["?"], "Search backwards for a pattern", - function () { finder.openPrompt(modes.SEARCH_BACKWARD); }); - - mappings.add(myModes, - ["n"], "Find next", - function () { finder.findAgain(false); }); - - mappings.add(myModes, - ["N"], "Find previous", - function () { finder.findAgain(true); }); - - mappings.add(myModes.concat([modes.CARET, modes.TEXTAREA]), ["*"], - "Find word under cursor", - function () - { - found = false; - finder.onSubmit(buffer.getCurrentWord(), false); - }); - - mappings.add(myModes.concat([modes.CARET, modes.TEXTAREA]), ["#"], - "Find word under cursor backwards", - function () - { - found = false; - finder.onSubmit(buffer.getCurrentWord(), true); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["noh[lsearch]"], - "Remove the search highlighting", - function () { finder.clear(); }, - { argCount: "0" }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - /** - * Called when the search dialog is requested. - * - * @param {number} mode The search mode, either modes.SEARCH_FORWARD or - * modes.SEARCH_BACKWARD. - * @default modes.SEARCH_FORWARD - */ - openPrompt: function (mode) - { - backwards = mode == modes.SEARCH_BACKWARD; - commandline.open(backwards ? "?" : "/", "", mode); - // TODO: focus the top of the currently visible screen - }, - - // TODO: backwards seems impossible i fear :( - /** - * Searches the current buffer for <b>str</b>. - * - * @param {string} str The string to find. - */ - find: function (str) - { - let fastFind = getBrowser().fastFind; + find: function (str) { + let fastFind = getBrowser().fastFind; - processUserPattern(str); - fastFind.caseSensitive = caseSensitive; - found = fastFind.find(searchString, linksOnly) != Ci.nsITypeAheadFind.FIND_NOTFOUND; + this._processUserPattern(str); + fastFind.caseSensitive = this._caseSensitive; + this._found = fastFind.find(this._searchString, this._linksOnly) != Ci.nsITypeAheadFind.FIND_NOTFOUND; - if (!found) - setTimeout(function () liberator.echoerr("E486: Pattern not found: " + searchPattern, commandline.FORCE_SINGLELINE), 0); - }, + if (!this._found) + setTimeout(function () liberator.echoerr("E486: Pattern not found: " + this._searchPattern, commandline.FORCE_SINGLELINE), 0); + }, - /** - * Searches the current buffer again for the most recently used search - * string. - * - * @param {boolean} reverse Whether to search forwards or backwards. - * @default false - */ - findAgain: function (reverse) - { - // This hack is needed to make n/N work with the correct string, if - // we typed /foo<esc> after the original search. Since searchString is - // readonly we have to call find() again to update it. - if (getBrowser().fastFind.searchString != lastSearchString) - this.find(lastSearchString); - - let up = reverse ? !lastSearchBackwards : lastSearchBackwards; - let result = getBrowser().fastFind.findAgain(up, linksOnly); - - if (result == Ci.nsITypeAheadFind.FIND_NOTFOUND) - liberator.echoerr("E486: Pattern not found: " + lastSearchPattern, commandline.FORCE_SINGLELINE); - else if (result == Ci.nsITypeAheadFind.FIND_WRAPPED) - { - // hack needed, because wrapping causes a "scroll" event which clears - // our command line - setTimeout(function () { - let msg = up ? "search hit TOP, continuing at BOTTOM" : "search hit BOTTOM, continuing at TOP"; - commandline.echo(msg, commandline.HL_WARNINGMSG, commandline.APPEND_TO_MESSAGES | commandline.FORCE_SINGLELINE); - }, 0); - } - else - { - commandline.echo((up ? "?" : "/") + lastSearchPattern, null, commandline.FORCE_SINGLELINE); - - if (options["hlsearch"]) - this.highlight(lastSearchString); - } - }, - - /** - * Called when the user types a key in the search dialog. Triggers a - * search attempt if 'incsearch' is set. - * - * @param {string} str The search string. - */ - onKeyPress: function (str) - { - if (options["incsearch"]) - this.find(str); - }, - - /** - * Called when the <Enter> key is pressed to trigger a search. - * - * @param {string} str The search string. - * @param {boolean} forcedBackward Whether to search forwards or - * backwards. This overrides the direction set in - * (@link #openPrompt). - * @default false - */ - onSubmit: function (str, forcedBackward) - { - if (typeof forcedBackward === "boolean") - backwards = forcedBackward; - - if (str) - var pattern = str; - else - { - liberator.assert(lastSearchPattern, "E35: No previous search pattern"); - pattern = lastSearchPattern; - } - - this.clear(); - - if (!options["incsearch"] || !str || !found) - { - // prevent any current match from matching again - if (!window.content.getSelection().isCollapsed) - window.content.getSelection().getRangeAt(0).collapse(backwards); - - this.find(pattern); - } - - lastSearchBackwards = backwards; - //lastSearchPattern = pattern.replace(backwards ? /\?.*/ : /\/.*/, ""); // XXX - lastSearchPattern = pattern; - lastSearchString = searchString; - - // TODO: move to find() when reverse incremental searching is kludged in - // need to find again for reverse searching - if (backwards) - setTimeout(function () { finder.findAgain(false); }, 0); + /** + * Searches the current buffer again for the most recently used search + * string. + * + * @param {boolean} reverse Whether to search forwards or backwards. + * @default false + */ + findAgain: function (reverse) { + // This hack is needed to make n/N work with the correct string, if + // we typed /foo<esc> after the original search. Since searchString is + // readonly we have to call find() again to update it. + if (getBrowser().fastFind.searchString != this._lastSearchString) + this.find(this._lastSearchString); + + let up = reverse ? !this._lastSearchBackwards : this._lastSearchBackwards; + let result = getBrowser().fastFind.findAgain(up, this._linksOnly); + + if (result == Ci.nsITypeAheadFind.FIND_NOTFOUND) + liberator.echoerr("E486: Pattern not found: " + this._lastSearchPattern, commandline.FORCE_SINGLELINE); + else if (result == Ci.nsITypeAheadFind.FIND_WRAPPED) { + // hack needed, because wrapping causes a "scroll" event which clears + // our command line + setTimeout(function () { + let msg = up ? "search hit TOP, continuing at BOTTOM" : "search hit BOTTOM, continuing at TOP"; + commandline.echo(msg, commandline.HL_WARNINGMSG, commandline.APPEND_TO_MESSAGES | commandline.FORCE_SINGLELINE); + }, 0); + } + else { + commandline.echo((up ? "?" : "/") + this._lastSearchPattern, null, commandline.FORCE_SINGLELINE); if (options["hlsearch"]) - this.highlight(searchString); + this.highlight(this._lastSearchString); + } + }, - modes.reset(); - }, + /** + * Called when the user types a key in the search dialog. Triggers a + * search attempt if 'incsearch' is set. + * + * @param {string} str The search string. + */ + onKeyPress: function (str) { + if (options["incsearch"]) + this.find(str); + }, - /** - * Called when the search is canceled. For example, if someone presses - * <Esc> while typing a search. - */ - onCancel: function () - { - // TODO: code to reposition the document to the place before search started - }, + /** + * Called when the <Enter> key is pressed to trigger a search. + * + * @param {string} str The search string. + * @param {boolean} forcedBackward Whether to search forwards or + * backwards. This overrides the direction set in + * (@link #openPrompt). + * @default false + */ + onSubmit: function (str, forcedBackward) { + if (typeof forcedBackward === "boolean") + this._backwards = forcedBackward; + + if (str) + var pattern = str; + else { + liberator.assert(this._lastSearchPattern, "E35: No previous search pattern"); + pattern = this._lastSearchPattern; + } - /** - * Highlights all occurances of <b>str</b> in the buffer. - * - * @param {string} str The string to highlight. - */ - highlight: function (str) - { - // FIXME: Thunderbird incompatible - if (config.name == "Muttator") - return; + this.clear(); - if (highlighter.isHighlighted(content.document)) - return; + if (!options["incsearch"] || !str || !this._found) { + // prevent any current match from matching again + if (!window.content.getSelection().isCollapsed) + window.content.getSelection().getRangeAt(0).collapse(this._backwards); - if (!str) - str = lastSearchString; + this.find(pattern); + } - highlighter.highlightDoc(window.content, str); + this._lastSearchBackwards = this._backwards; + //lastSearchPattern = pattern.replace(backwards ? /\?.*/ : /\/.*/, ""); // XXX + this._lastSearchPattern = pattern; + this._lastSearchString = this._searchString; - // recreate selection since highlightDoc collapses the selection - if (window.content.getSelection().isCollapsed) - getBrowser().fastFind.findAgain(backwards, linksOnly); + // TODO: move to find() when reverse incremental searching is kludged in + // need to find again for reverse searching + if (this._backwards) + setTimeout(function () { finder.findAgain(false); }, 0); - // TODO: remove highlighting from non-link matches (HTML - A/AREA with href attribute; XML - Xlink [type="simple"]) - }, + if (options["hlsearch"]) + this.highlight(this._searchString); - /** - * Clears all search highlighting. - */ - clear: function () - { - highlighter.clear(); - } - }; - //}}} -} //}}} + modes.reset(); + }, + + /** + * Called when the search is canceled. For example, if someone presses + * <Esc> while typing a search. + */ + onCancel: function () { + // TODO: code to reposition the document to the place before search started + }, + + /** + * Highlights all occurances of <b>str</b> in the buffer. + * + * @param {string} str The string to highlight. + */ + highlight: function (str) { + // FIXME: Thunderbird incompatible + if (config.name == "Muttator") + return; + + if (this._highlighter.isHighlighted(content.document)) + return; + + if (!str) + str = this._lastSearchString; + + this._highlighter.highlightDoc(window.content, str); + + // recreate selection since highlightDoc collapses the selection + if (window.content.getSelection().isCollapsed) + getBrowser().fastFind.findAgain(this._backwards, this._linksOnly); + + // TODO: remove highlighting from non-link matches (HTML - A/AREA with href attribute; XML - Xlink [type="simple"]) + }, + + /** + * Clears all search highlighting. + */ + clear: function () { + this._highlighter.clear(); + } +}, { + commandline: function () { + const self = this; + // Event handlers for search - closure is needed + commandline.registerCallback("change", modes.SEARCH_FORWARD, function (str) { self.onKeyPress(str); }); + commandline.registerCallback("submit", modes.SEARCH_FORWARD, function (str) { self.onSubmit(str); }); + commandline.registerCallback("cancel", modes.SEARCH_FORWARD, function () { self.onCancel(); }); + // TODO: allow advanced myModes in register/triggerCallback + commandline.registerCallback("change", modes.SEARCH_BACKWARD, function (str) { self.onKeyPress(str); }); + commandline.registerCallback("submit", modes.SEARCH_BACKWARD, function (str) { self.onSubmit(str); }); + commandline.registerCallback("cancel", modes.SEARCH_BACKWARD, function () { self.onCancel(); }); + + }, + commands: function () { + commands.add(["noh[lsearch]"], + "Remove the search highlighting", + function () { finder.clear(); }, + { argCount: "0" }); + }, + mappings: function () { + var myModes = config.browserModes; + myModes = myModes.concat([modes.CARET]); + + mappings.add(myModes, + ["/"], "Search forward for a pattern", + function () { finder.openPrompt(modes.SEARCH_FORWARD); }); + + mappings.add(myModes, + ["?"], "Search backwards for a pattern", + function () { finder.openPrompt(modes.SEARCH_BACKWARD); }); + + mappings.add(myModes, + ["n"], "Find next", + function () { finder.findAgain(false); }); + + mappings.add(myModes, + ["N"], "Find previous", + function () { finder.findAgain(true); }); + + mappings.add(myModes.concat([modes.CARET, modes.TEXTAREA]), ["*"], + "Find word under cursor", + function () { + this._found = false; + finder.onSubmit(buffer.getCurrentWord(), false); + }); + + mappings.add(myModes.concat([modes.CARET, modes.TEXTAREA]), ["#"], + "Find word under cursor backwards", + function () { + this._found = false; + finder.onSubmit(buffer.getCurrentWord(), true); + }); + }, + options: function () { + options.add(["hlsearch", "hls"], + "Highlight previous search pattern matches", + "boolean", "false", { + setter: function (value) { + if (value) + finder.highlight(); + else + finder.clear(); + + return value; + } + }); + + options.add(["ignorecase", "ic"], + "Ignore case in search patterns", + "boolean", true); + + options.add(["incsearch", "is"], + "Show where the search pattern matches as it is typed", + "boolean", true); + + options.add(["linksearch", "lks"], + "Limit the search to hyperlink text", + "boolean", false); + + options.add(["smartcase", "scs"], + "Override the 'ignorecase' option if the pattern contains uppercase characters", + "boolean", true); + }, +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/help.js b/common/content/help.js index a07745f8..67fbba37 100644 --- a/common/content/help.js +++ b/common/content/help.js @@ -4,8 +4,7 @@ // given in the LICENSE.txt file included with this file. -function checkFragment() -{ +function checkFragment() { document.title = document.getElementsByTagNameNS("http://www.w3.org/1999/xhtml", "title")[0].textContent; var frag = document.location.hash.substr(1); var elem = document.getElementById(frag); diff --git a/common/content/hints.js b/common/content/hints.js index 8a309f4d..a0fd440c 100644 --- a/common/content/hints.js +++ b/common/content/hints.js @@ -6,125 +6,116 @@ /** @scope modules */ /** @instance hints */ -function Hints() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const ELEM = 0, TEXT = 1, SPAN = 2, IMG_SPAN = 3; - - var myModes = config.browserModes; - - var hintMode; - var submode = ""; // used for extended mode, can be "o", "t", "y", etc. - var hintString = ""; // the typed string part of the hint is in this string - var hintNumber = 0; // only the numerical part of the hint - var usedTabKey = false; // when we used <Tab> to select an element - var prevInput = ""; // record previous user input type, "text" || "number" - var extendedhintCount; // for the count argument of Mode#action (extended hint only) - - // hints[] = [elem, text, span, imgSpan, elem.style.backgroundColor, elem.style.color] - var pageHints = []; - var validHints = []; // store the indices of the "hints" array with valid elements - - var activeTimeout = null; // needed for hinttimeout > 0 - var canUpdate = false; - - // keep track of the documents which we generated the hints for - // docs = { doc: document, start: start_index in hints[], end: end_index in hints[] } - var docs = []; - - const Mode = new Struct("prompt", "action", "tags"); - Mode.defaultValue("tags", function () function () options.hinttags); - function extended() options.extendedhinttags; - function images() util.makeXPath(["img"]); - - const hintModes = { - ";": Mode("Focus hint", function (elem) buffer.focusElement(elem), extended), - "?": Mode("Show information for hint", function (elem) buffer.showElementInfo(elem), extended), - s: Mode("Save hint", function (elem) buffer.saveLink(elem, true)), - a: Mode("Save hint with prompt", function (elem) buffer.saveLink(elem, false)), - f: Mode("Focus frame", function (elem) elem.ownerDocument.defaultView.focus(), function () util.makeXPath(["body"])), - o: Mode("Follow hint", function (elem) buffer.followLink(elem, liberator.CURRENT_TAB)), - t: Mode("Follow hint in a new tab", function (elem) buffer.followLink(elem, liberator.NEW_TAB)), - b: Mode("Follow hint in a background tab", function (elem) buffer.followLink(elem, liberator.NEW_BACKGROUND_TAB)), - w: Mode("Follow hint in a new window", function (elem) buffer.followLink(elem, liberator.NEW_WINDOW), extended), - F: Mode("Open multiple hints in tabs", followAndReshow), - O: Mode("Generate an ':open URL' using hint", function (elem, loc) commandline.open(":", "open " + loc, modes.EX)), - T: Mode("Generate a ':tabopen URL' using hint", function (elem, loc) commandline.open(":", "tabopen " + loc, modes.EX)), - W: Mode("Generate a ':winopen URL' using hint", function (elem, loc) commandline.open(":", "winopen " + loc, modes.EX)), - v: Mode("View hint source", function (elem, loc) buffer.viewSource(loc, false), extended), - V: Mode("View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true), extended), - y: Mode("Yank hint location", function (elem, loc) util.copyToClipboard(loc, true)), - Y: Mode("Yank hint description", function (elem) util.copyToClipboard(elem.textContent || "", true), extended), - c: Mode("Open context menu", function (elem) buffer.openContextMenu(elem), extended), - i: Mode("Show image", function (elem) liberator.open(elem.src), images), - I: Mode("Show image in a new tab", function (elem) liberator.open(elem.src, liberator.NEW_TAB), images) - }; +const ELEM = 0, TEXT = 1, SPAN = 2, IMG_SPAN = 3; +const Hints = Module("hints", { + init: function () { + + this._hintMode; + this._submode = ""; // used for extended mode, can be "o", "t", "y", etc. + this._hintString = ""; // the typed string part of the hint is in this string + this._hintNumber = 0; // only the numerical part of the hint + this._usedTabKey = false; // when we used <Tab> to select an element + this._prevInput = ""; // record previous user input type, "text" || "number" + this._extendedhintCount; // for the count argument of Mode#action (extended hint only) + + // hints[] = [elem, text, span, imgSpan, elem.style.backgroundColor, elem.style.color] + this._pageHints = []; + this._validHints = []; // store the indices of the "hints" array with valid elements + + this._activeTimeout = null; // needed for hinttimeout > 0 + this._canUpdate = false; + + // keep track of the documents which we generated the hints for + // this._docs = { doc: document, start: start_index in hints[], end: end_index in hints[] } + this._docs = []; + + const Mode = Hints.Mode; + Mode.defaultValue("tags", function () function () options.hinttags); + function extended() options.extendedhinttags; + function images() util.makeXPath(["img"]); + + this._hintModes = { + ";": Mode("Focus hint", function (elem) buffer.focusElement(elem), extended), + "?": Mode("Show information for hint", function (elem) buffer.showElementInfo(elem), extended), + s: Mode("Save hint", function (elem) buffer.saveLink(elem, true)), + a: Mode("Save hint with prompt", function (elem) buffer.saveLink(elem, false)), + f: Mode("Focus frame", function (elem) elem.ownerDocument.defaultView.focus(), function () util.makeXPath(["body"])), + o: Mode("Follow hint", function (elem) buffer.followLink(elem, liberator.CURRENT_TAB)), + t: Mode("Follow hint in a new tab", function (elem) buffer.followLink(elem, liberator.NEW_TAB)), + b: Mode("Follow hint in a background tab", function (elem) buffer.followLink(elem, liberator.NEW_BACKGROUND_TAB)), + w: Mode("Follow hint in a new window", function (elem) buffer.followLink(elem, liberator.NEW_WINDOW), extended), + F: Mode("Open multiple hints in tabs", followAndReshow), + O: Mode("Generate an ':open URL' using hint", function (elem, loc) commandline.open(":", "open " + loc, modes.EX)), + T: Mode("Generate a ':tabopen URL' using hint", function (elem, loc) commandline.open(":", "tabopen " + loc, modes.EX)), + W: Mode("Generate a ':winopen URL' using hint", function (elem, loc) commandline.open(":", "winopen " + loc, modes.EX)), + v: Mode("View hint source", function (elem, loc) buffer.viewSource(loc, false), extended), + V: Mode("View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true), extended), + y: Mode("Yank hint location", function (elem, loc) util.copyToClipboard(loc, true)), + Y: Mode("Yank hint description", function (elem) util.copyToClipboard(elem.textContent || "", true), extended), + c: Mode("Open context menu", function (elem) buffer.openContextMenu(elem), extended), + i: Mode("Show image", function (elem) liberator.open(elem.src), images), + I: Mode("Show image in a new tab", function (elem) liberator.open(elem.src, liberator.NEW_TAB), images) + }; - /** - * Follows the specified hint and then reshows all hints. Used to open - * multiple hints in succession. - * - * @param {Node} elem The selected hint. - */ - function followAndReshow(elem) - { - buffer.followLink(elem, liberator.NEW_BACKGROUND_TAB); - - // TODO: Maybe we find a *simple* way to keep the hints displayed rather than - // showing them again, or is this short flash actually needed as a "usability - // feature"? --mst - hints.show("F"); - } + /** + * Follows the specified hint and then reshows all hints. Used to open + * multiple hints in succession. + * + * @param {Node} elem The selected hint. + */ + function followAndReshow(elem) { + buffer.followLink(elem, liberator.NEW_BACKGROUND_TAB); + + // TODO: Maybe we find a *simple* way to keep the hints displayed rather than + // showing them again, or is this short flash actually needed as a "usability + // feature"? --mst + hints.show("F"); + } + }, /** * Reset hints, so that they can be cleanly used again. */ - function reset() - { + _reset: function () { statusline.updateInputBuffer(""); - hintString = ""; - hintNumber = 0; - usedTabKey = false; - prevInput = ""; - pageHints = []; - validHints = []; - canUpdate = false; - docs = []; + this._hintString = ""; + this._hintNumber = 0; + this._usedTabKey = false; + this._prevInput = ""; + this._pageHints = []; + this._validHints = []; + this._canUpdate = false; + this._docs = []; hints.escNumbers = false; - if (activeTimeout) - clearTimeout(activeTimeout); - activeTimeout = null; - } + if (this._activeTimeout) + clearTimeout(this._activeTimeout); + this._activeTimeout = null; + }, /** * Display the current status to the user. */ - function updateStatusline() - { - statusline.updateInputBuffer((hints.escNumbers ? mappings.getMapLeader() : "") + (hintNumber || "")); - } + _updateStatusline: function () { + statusline.updateInputBuffer((hints.escNumbers ? mappings.getMapLeader() : "") + (this._hintNumber || "")); + }, /** * Get a hint for "input", "textarea" and "select". * * Tries to use <label>s if possible but does not try to guess that a - * neighbouring element might look like a label. Only called by generate(). + * neighbouring element might look like a label. Only called by this._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 {Object} elem The element used to this._generate hint text. * @param {Document} doc The containing document. * * @returns [text, showText] */ - function getInputHint(elem, doc) - { - // <input type="submit|button|reset"> Always use the value + _getInputHint: function (elem, doc) { + // <input type="submit|button|this._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 @@ -134,35 +125,27 @@ function Hints() //{{{ let type = elem.type; - if (elem instanceof HTMLInputElement && /(submit|button|reset)/.test(type)) + if (elem instanceof HTMLInputElement && /(submit|button|this._reset)/.test(type)) return [elem.value, false]; - else - { - for (let [, option] in Iterator(options["hintinputs"].split(","))) - { - if (option == "value") - { - if (elem instanceof HTMLSelectElement) - { + else { + for (let [, option] in Iterator(options["hintinputs"].split(","))) { + if (option == "value") { + if (elem instanceof HTMLSelectElement) { if (elem.selectedIndex >= 0) return [elem.item(elem.selectedIndex).text.toLowerCase(), false]; } - else if (type == "image") - { + else if (type == "image") { if (elem.alt) return [elem.alt.toLowerCase(), true]; } - else if (elem.value && type != "password") - { + 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) - { + 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 + "']", doc).snapshotItem(0); if (label) @@ -175,22 +158,20 @@ function Hints() //{{{ } return ["", false]; - } + }, /** * Gets the actual offset of an imagemap area. * - * Only called by generate(). + * Only called by this._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. */ - function getAreaOffset(elem, leftPos, topPos) - { - try - { + _getAreaOffset: function (elem, leftPos, topPos) { + try { // Need to add the offset to the area element. // Always try to find the top-left point, as per liberator default. let shape = elem.getAttribute("shape").toLowerCase(); @@ -199,24 +180,20 @@ function Hints() //{{{ coordStr = coordStr.replace(/\s+[;,]\s+/g, ",").replace(/\s+/g, ","); let coords = coordStr.split(",").map(Number); - if ((shape == "rect" || shape == "rectangle") && coords.length == 4) - { + if ((shape == "rect" || shape == "rectangle") && coords.length == 4) { leftPos += coords[0]; topPos += coords[1]; } - else if (shape == "circle" && coords.length == 3) - { + 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) - { + 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 noticably suboptimal) - for (let i = 0; i < coords.length; i += 2) - { + for (let i = 0; i < coords.length; i += 2) { leftBound = Math.min(coords[i], leftBound); topBound = Math.min(coords[i + 1], topBound); } @@ -226,13 +203,11 @@ function Hints() //{{{ let curDist = Infinity; // Then find the closest vertex. (we could generalise to nearest point on an edge, but I doubt there is a need) - for (let i = 0; i < coords.length; i += 2) - { + 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) - { + if (dist < curDist) { curDist = dist; curLeft = coords[i]; curTop = coords[i + 1]; @@ -246,50 +221,46 @@ function Hints() //{{{ } 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 - function getContainerOffsets(doc) - { + _getContainerOffsets: function (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)) - { + 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. + * Pushes the hints into the this._pageHints object, but does not display them. * * @param {Window} win The window,defaults to window.content. */ - function generate(win) - { + _generate: function (win) { if (!win) win = window.content; let doc = win.document; let height = win.innerHeight; let width = win.innerWidth; - let [offsetX, offsetY] = getContainerOffsets(doc); + let [offsetX, offsetY] = this._getContainerOffsets(doc); let baseNodeAbsolute = util.xmlToDom(<span highlight="Hint"/>, doc); let elem, text, span, rect, showText; - let res = util.evaluateXPath(hintMode.tags(), doc, null, true); + let res = util.evaluateXPath(this._hintMode.tags(), doc, null, true); let fragment = util.xmlToDom(<div highlight="hints"/>, doc); - let start = pageHints.length; - for (let elem in res) - { + let start = this._pageHints.length; + for (let elem in res) { showText = false; // TODO: for iframes, this calculation is wrong @@ -306,7 +277,7 @@ function Hints() //{{{ continue; if (elem instanceof HTMLInputElement || elem instanceof HTMLSelectElement || elem instanceof HTMLTextAreaElement) - [text, showText] = getInputHint(elem, doc); + [text, showText] = this._getInputHint(elem, doc); else text = elem.textContent.toLowerCase(); @@ -316,27 +287,26 @@ function Hints() //{{{ let topPos = Math.max((rect.top + offsetY), offsetY); if (elem instanceof HTMLAreaElement) - [leftPos, topPos] = getAreaOffset(elem, leftPos, topPos); + [leftPos, topPos] = this._getAreaOffset(elem, leftPos, topPos); span.style.left = leftPos + "px"; span.style.top = topPos + "px"; fragment.appendChild(span); - pageHints.push([elem, text, span, null, elem.style.backgroundColor, elem.style.color, showText]); + this._pageHints.push([elem, text, span, null, elem.style.backgroundColor, elem.style.color, showText]); } let body = doc.body || util.evaluateXPath(["body"], doc).snapshotItem(0); - if (body) - { + if (body) { body.appendChild(fragment); - docs.push({ doc: doc, start: start, end: pageHints.length - 1 }); + this._docs.push({ doc: doc, start: start, end: this._pageHints.length - 1 }); } - // also generate hints for frames - Array.forEach(win.frames, generate); + // also _generate hints for frames + Array.forEach(win.frames, this.closure._generate); return true; - } + }, /** * Update the activeHint. @@ -346,16 +316,15 @@ function Hints() //{{{ * @param {number} newId The hint to make active. * @param {number} oldId The currently active hint. */ - function showActiveHint(newId, oldId) - { - let oldElem = validHints[oldId - 1]; + _showActiveHint: function (newId, oldId) { + let oldElem = this._validHints[oldId - 1]; if (oldElem) - setClass(oldElem, false); + this._setClass(oldElem, false); - let newElem = validHints[newId - 1]; + let newElem = this._validHints[newId - 1]; if (newElem) - setClass(newElem, true); - } + this._setClass(newElem, true); + }, /** * Toggle the highlight of a hint. @@ -363,35 +332,31 @@ function Hints() //{{{ * @param {Object} elem The element to toggle. * @param {boolean} active Whether it is the currently active hint or not. */ - function setClass(elem, active) - { + _setClass: function (elem, active) { let prefix = (elem.getAttributeNS(NS.uri, "class") || "") + " "; if (active) elem.setAttributeNS(NS.uri, "highlight", prefix + "HintActive"); else elem.setAttributeNS(NS.uri, "highlight", prefix + "HintElem"); - } + }, /** - * Display the hints in pageHints that are still valid. + * Display the hints in this._pageHints that are still valid. */ - function showHints() - { + _showHints: function () { let elem, text, rect, span, imgSpan, _a, _b, showText; let hintnum = 1; - let validHint = hintMatcher(hintString.toLowerCase()); - let activeHint = hintNumber || 1; - validHints = []; + let validHint = this._hintMatcher(this._hintString.toLowerCase()); + let activeHint = this._hintNumber || 1; + this._validHints = []; - for (let [,{ doc: doc, start: start, end: end }] in Iterator(docs)) - { - let [offsetX, offsetY] = getContainerOffsets(doc); + for (let [,{ doc: doc, start: start, end: end }] in Iterator(this._docs)) { + let [offsetX, offsetY] = this._getContainerOffsets(doc); inner: - for (let i in (util.interruptibleRange(start, end + 1, 500))) - { - let hint = pageHints[i]; + for (let i in (util.interruptibleRange(start, end + 1, 500))) { + let hint = this._pageHints[i]; [elem, text, span, imgSpan, _a, _b, showText] = hint; let valid = validHint(text); @@ -399,16 +364,13 @@ function Hints() //{{{ if (imgSpan) imgSpan.style.display = (valid ? "" : "none"); - if (!valid) - { + if (!valid) { elem.removeAttributeNS(NS.uri, "highlight"); continue inner; } - if (text == "" && elem.firstChild && elem.firstChild instanceof HTMLImageElement) - { - if (!imgSpan) - { + if (text == "" && elem.firstChild && elem.firstChild instanceof HTMLImageElement) { + if (!imgSpan) { rect = elem.firstChild.getBoundingClientRect(); if (!rect) continue; @@ -421,27 +383,24 @@ function Hints() //{{{ hint[IMG_SPAN] = imgSpan; span.parentNode.appendChild(imgSpan); } - setClass(imgSpan, activeHint == hintnum); + this._setClass(imgSpan, activeHint == hintnum); } span.setAttribute("number", showText ? hintnum + ": " + text.substr(0, 50) : hintnum); if (imgSpan) imgSpan.setAttribute("number", hintnum); else - setClass(elem, activeHint == hintnum); - validHints.push(elem); + this._setClass(elem, activeHint == hintnum); + this._validHints.push(elem); hintnum++; } } - if (getBrowser().markupDocumentViewer.authorStyleDisabled) - { + if (getBrowser().markupDocumentViewer.authorStyleDisabled) { let css = []; // FIXME: Broken for imgspans. - for (let [, { doc: doc }] in Iterator(docs)) - { - for (let elem in util.evaluateXPath("//*[@liberator:highlight and @number]", doc)) - { + for (let [, { doc: doc }] in Iterator(this._docs)) { + for (let elem in util.evaluateXPath(" {//*[@liberator:highlight and @number]", doc)) { let group = elem.getAttributeNS(NS.uri, "highlight"); css.push(highlight.selector(group) + "[number='" + elem.getAttribute("number") + "'] { " + elem.style.cssText + " }"); } @@ -450,27 +409,24 @@ function Hints() //{{{ } return true; - } + }, /** - * Remove all hints from the document, and reset the completions. + * Remove all hints from the document, and this._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. */ - function removeHints(timeout) - { - let firstElem = validHints[0] || null; + _removeHints: function (timeout) { + let firstElem = this._validHints[0] || null; - for (let [,{ doc: doc, start: start, end: end }] in Iterator(docs)) - { + for (let [,{ doc: doc, start: start, end: end }] in Iterator(this._docs)) { for (let elem in util.evaluateXPath("//*[@liberator:highlight='hints']", doc)) elem.parentNode.removeChild(elem); - for (let i in util.range(start, end + 1)) - { - let hint = pageHints[i]; + for (let i in util.range(start, end + 1)) { + let hint = this._pageHints[i]; if (!timeout || hint[ELEM] != firstElem) hint[ELEM].removeAttributeNS(NS.uri, "highlight"); } @@ -481,8 +437,8 @@ function Hints() //{{{ } styles.removeSheet(true, "hint-positions"); - reset(); - } + this._reset(); + }, /** * Finish hinting. @@ -494,41 +450,36 @@ function Hints() //{{{ * link (when 'followhints' is 1 or 2) * */ - function processHints(followFirst) - { - if (validHints.length == 0) - { + __processHints: function (followFirst) { + if (this._validHints.length == 0) { liberator.beep(); return false; } - if (options["followhints"] > 0) - { + if (options["followhints"] > 0) { if (!followFirst) return false; // 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 - if (options["followhints"] == 2 && validHints.length > 1 && !hintNumber) + if (options["followhints"] == 2 && this._validHints.length > 1 && !this._hintNumber) return liberator.beep(); } - if (!followFirst) - { - let firstHref = validHints[0].getAttribute("href") || null; - if (firstHref) - { - if (validHints.some(function (e) e.getAttribute("href") != firstHref)) + if (!followFirst) { + let firstHref = this._validHints[0].getAttribute("href") || null; + if (firstHref) { + if (this._validHints.some(function (e) e.getAttribute("href") != firstHref)) return false; } - else if (validHints.length > 1) + else if (this._validHints.length > 1) return false; } let timeout = followFirst || events.feedingKeys ? 0 : 500; - let activeIndex = (hintNumber ? hintNumber - 1 : 0); - let elem = validHints[activeIndex]; - removeHints(timeout); + let activeIndex = (this._hintNumber ? this._hintNumber - 1 : 0); + let elem = this._validHints[activeIndex]; + this._removeHints(timeout); if (timeout == 0) // force a possible mode change, based on whether an input field has focus @@ -536,29 +487,27 @@ function Hints() //{{{ setTimeout(function () { if (modes.extended & modes.HINTS) modes.reset(); - hintMode.action(elem, elem.href || "", extendedhintCount); + this._hintMode.action(elem, elem.href || "", this._extendedhintCount); }, timeout); return true; - } + }, - function checkUnique() - { - if (hintNumber == 0) + _checkUnique: function () { + if (this._hintNumber == 0) return; - if (hintNumber > validHints.length) + if (this._hintNumber > this._validHints.length) return void liberator.beep(); // 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 (hintNumber > 0 && hintNumber * 10 <= validHints.length) - { + if (this._hintNumber > 0 && this._hintNumber * 10 <= this._validHints.length) { let timeout = options["hinttimeout"]; if (timeout > 0) - activeTimeout = setTimeout(function () { processHints(true); }, timeout); + this._activeTimeout = setTimeout(function () { this._processHints(true); }, timeout); } else // we have a unique hint - processHints(true); - } + this._processHints(true); + }, /** * Handle user input. @@ -568,33 +517,30 @@ function Hints() //{{{ * * @param {Event} event The keypress event. */ - function onInput(event) - { - prevInput = "text"; + __onInput: function (event) { + this._prevInput = "text"; // clear any timeout which might be active after pressing a number - if (activeTimeout) - { - clearTimeout(activeTimeout); - activeTimeout = null; + if (this._activeTimeout) { + clearTimeout(this._activeTimeout); + this._activeTimeout = null; } - hintNumber = 0; - hintString = commandline.command; - updateStatusline(); - showHints(); - if (validHints.length == 1) - processHints(false); - } + this._hintNumber = 0; + this._hintString = commandline.command; + this._updateStatusline(); + this._showHints(); + if (this._validHints.length == 1) + this._processHints(false); + }, /** - * Get the hintMatcher according to user preference. + * Get the this._hintMatcher according to user preference. * - * @param {string} hintString The currently typed hint. - * @returns {hintMatcher} + * @param {string} this._hintString The currently typed hint. + * @returns {this._hintMatcher} */ - function hintMatcher(hintString) //{{{ - { + _hintMatcher: function (hintString) { //{{{ /** * Divide a string by a regular expression. * @@ -607,7 +553,7 @@ function Hints() //{{{ /** * Get a hint matcher for hintmatching=contains * - * The hintMatcher expects the user input to be space delimited and it + * The this._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. * @@ -616,8 +562,7 @@ function Hints() //{{{ * 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) //{{{ - { + function containsMatcher(hintString) { //{{{ let tokens = tokenize(/\s+/, hintString); return function (linkText) { linkText = linkText.toLowerCase(); @@ -626,9 +571,9 @@ function Hints() //{{{ } //}}} /** - * Get a hintMatcher for hintmatching=firstletters|wordstartswith + * Get a this._hintMatcher for hintmatching=firstletters|wordstartswith * - * The hintMatcher will look for any division of the user input that + * The this._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. * @@ -638,8 +583,7 @@ function Hints() //{{{ * @returns {function(String):boolean} A function that will filter only * hints that match as above. */ - function wordStartsWithMatcher(hintString, allowWordOverleaping) //{{{ - { + function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{ let hintStrings = tokenize(/\s+/, hintString); let wordSplitRegex = RegExp(options["wordseparators"]); @@ -657,13 +601,10 @@ function Hints() //{{{ * skipped during matching. * @returns {boolean} Whether a match can be found. */ - function charsAtBeginningOfWords(chars, words, allowWordOverleaping) - { - function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) - { + 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) - { + if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) { let nextWordIdx = wordIdx + 1; if (nextWordIdx == words.length) return false; @@ -671,8 +612,7 @@ function Hints() //{{{ return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping); } - if (matches) - { + if (matches) { let nextCharIdx = charIdx + 1; if (nextCharIdx == chars.length) return true; @@ -686,8 +626,7 @@ function Hints() //{{{ if (charMatched) return true; - if (charMatched == false || beyondLastWord == true) - { + if (charMatched == false || beyondLastWord == true) { let nextInWordIdx = inWordIdx + 1; if (nextInWordIdx == words[wordIdx].length) return false; @@ -717,11 +656,9 @@ function Hints() //{{{ * non-contiguous. * @returns boolean Whether all the strings matched. */ - function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) - { + function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) { let strIdx = 0; - for (let [, word] in Iterator(words)) - { + for (let [, word] in Iterator(words)) { if (word.length == 0) continue; @@ -735,8 +672,7 @@ function Hints() //{{{ return true; } - for (; strIdx < strings.length; strIdx++) - { + for (; strIdx < strings.length; strIdx++) { if (strings[strIdx].length != 0) return false; } @@ -755,321 +691,179 @@ function Hints() //{{{ }; } //}}} - switch (options["hintmatching"]) - { - case "contains" : return containsMatcher(hintString); - case "wordstartswith": return wordStartsWithMatcher(hintString, /*allowWordOverleaping=*/ true); - case "firstletters" : return wordStartsWithMatcher(hintString, /*allowWordOverleaping=*/ false); - case "custom" : return liberator.plugins.customHintMatcher(hintString); - default : liberator.echoerr("Invalid hintmatching type: " + hintMatching); + switch (options["hintmatching"]) { + case "contains" : return containsMatcher(hintString); + case "wordstartswith": return wordStartsWithMatcher(hintString, /*allowWordOverleaping=*/ true); + case "firstletters" : return wordStartsWithMatcher(hintString, /*allowWordOverleaping=*/ false); + case "custom" : return liberator.plugins.customHintMatcher(hintString); + default : liberator.echoerr("Invalid hintmatching type: " + hintMatching); } return null; - } //}}} - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const DEFAULT_HINTTAGS = - util.makeXPath(["input[not(@type='hidden')]", "a", "area", "iframe", "textarea", "button", "select"]) - + " | //*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @role='link']"; - - function checkXPath(val) - { - try - { - util.evaluateXPath(val, document.implementation.createDocument("", "", null)); - return true; - } - catch (e) - { - return false; - } - } + }, //}}} - options.add(["extendedhinttags", "eht"], - "XPath string of hintable elements activated by ';'", - "string", DEFAULT_HINTTAGS, - { validator: checkXPath }); - - options.add(["hinttags", "ht"], - "XPath string of hintable elements activated by 'f' and 'F'", - "string", DEFAULT_HINTTAGS, - { validator: checkXPath }); - - 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 behaviour of <Return> in hint mode", - "number", 0, - { - completer: function () [ - ["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."] - ], - validator: Option.validateCompleter - }); - - options.add(["hintmatching", "hm"], - "How links are matched", - "string", "contains", - { - completer: function (context) [ - ["contains", "The typed characters are split on whitespace. The resulting groups must all appear in the hint."], - ["wordstartswith", "The typed characters are split on whitespace. The resulting groups must all match the beginings of words, in order."], - ["firstletters", "Behaves like wordstartswith, but all groups much match a sequence of words."], - ["custom", "Delegate to a custom function: liberator.plugins.customHintMatcher(hintString)"] - ], - validator: Option.validateCompleter - }); - - options.add(["wordseparators", "wsp"], - "How words are split for hintmatching", - "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]'); - - options.add(["hintinputs", "hin"], - "How text input fields are hinted", - "stringlist", "label,value", - { - completer: function (context) [ - ["value", "Match against the value contained by the input field"], - ["label", "Match against the value of a label for the input field, if one can be found"], - ["name", "Match against the name of an input field, only if neither a name or value could be found."] - ], - validator: Option.validateCompleter - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - mappings.add(myModes, ["f"], - "Start QuickHint mode", - function () { hints.show("o"); }); - - // At the moment, "F" calls - // buffer.followLink(clicked_element, DO_WHAT_FIREFOX_DOES_WITH_CNTRL_CLICK) - // It is not clear that it shouldn't be: - // buffer.followLink(clicked_element, !DO_WHAT_FIREFOX_DOES_WITH_CNTRL_CLICK) - // In fact, it might be nice if there was a "dual" to F (like H and - // gH, except that gF is already taken). --tpp - // - // Likewise, it might be nice to have a liberator.NEW_FOREGROUND_TAB - // and then make liberator.NEW_TAB always do what a Cntrl+Click - // does. --tpp - mappings.add(myModes, ["F"], - "Start QuickHint mode, but open link in a new tab", - function () { hints.show(options.getPref("browser.tabs.loadInBackground") ? "b" : "t"); }); - - mappings.add(myModes, [";"], - "Start an extended hint mode", - function (count) - { - extendedhintCount = count; - commandline.input(";", null, - { - promptHighlight: "Normal", - completer: function (context) - { - context.compare = function () 0; - context.completions = [[k, v.prompt] for ([k, v] in Iterator(hintModes))]; - }, - onChange: function () { modes.pop(); }, - onCancel: function (arg) { arg && setTimeout(function () hints.show(arg), 0); } - }); - }, { count: true }); + /** + * 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) { + this._hintModes[mode] = Hints.Mode.apply(Hints.Mode, Array.slice(arguments, 1)); + }, - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + /** + * Updates the display of hints. + * + * @param {string} minor Which hint mode to use. + * @param {string} filter The filter to use. + * @param {Object} win The window in which we are showing hints. + */ + show: function (minor, filter, win) { + this._hintMode = this._hintModes[minor]; + if (!this._hintMode) + return void liberator.beep(); + commandline.input(this._hintMode.prompt + ": ", null, { onChange: this._onInput }); + modes.extended = modes.HINTS; - return { + this._submode = minor; + this._hintString = filter || ""; + this._hintNumber = 0; + usedTab = false; + this._prevInput = ""; + this._canUpdate = false; - /** - * 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) - { - hintModes[mode] = Mode.apply(Mode, Array.slice(arguments, 1)); - }, + this._generate(win); - /** - * Updates the display of hints. - * - * @param {string} minor Which hint mode to use. - * @param {string} filter The filter to use. - * @param {Object} win The window in which we are showing hints. - */ - show: function (minor, filter, win) - { - hintMode = hintModes[minor]; - if (!hintMode) - return void liberator.beep(); - commandline.input(hintMode.prompt + ": ", null, { onChange: onInput }); - modes.extended = modes.HINTS; + // get all keys from the input queue + liberator.threadYield(true); - submode = minor; - hintString = filter || ""; - hintNumber = 0; - usedTab = false; - prevInput = ""; - canUpdate = false; + this._canUpdate = true; + this._showHints(); - generate(win); - - // get all keys from the input queue - liberator.threadYield(true); - - canUpdate = true; - showHints(); + if (this._validHints.length == 0) { + liberator.beep(); + modes.reset(); + } + else if (this._validHints.length == 1) + this._processHints(false); + else // Ticket #185 + this._checkUnique(); + }, - if (validHints.length == 0) - { - liberator.beep(); - modes.reset(); - } - else if (validHints.length == 1) - processHints(false); - else // Ticket #185 - checkUnique(); - }, + /** + * Cancel all hinting. + */ + hide: function () { + this._removeHints(0); + }, - /** - * Cancel all hinting. - */ - hide: function () - { - removeHints(0); - }, + /** + * Handle a hint mode event. + * + * @param {Event} event The event to handle. + */ + onEvent: function (event) { + let key = events.toString(event); + let followFirst = false; - /** - * Handle a hint mode event. - * - * @param {Event} event The event to handle. - */ - onEvent: function (event) - { - let key = events.toString(event); - let followFirst = false; + // clear any timeout which might be active after pressing a number + if (this._activeTimeout) { + clearTimeout(this._activeTimeout); + this._activeTimeout = null; + } - // clear any timeout which might be active after pressing a number - if (activeTimeout) - { - clearTimeout(activeTimeout); - activeTimeout = null; + switch (key) { + case "<Return>": + followFirst = true; + break; + + case "<Tab>": + case "<S-Tab>": + this._usedTabKey = true; + if (this._hintNumber == 0) + this._hintNumber = 1; + + let oldId = this._hintNumber; + if (key == "<Tab>") { + if (++this._hintNumber > this._validHints.length) + this._hintNumber = 1; } + else { + if (--this._hintNumber < 1) + this._hintNumber = this._validHints.length; + } + this._showActiveHint(this._hintNumber, oldId); + this._updateStatusline(); + return; - switch (key) - { - case "<Return>": - followFirst = true; - break; - - case "<Tab>": - case "<S-Tab>": - usedTabKey = true; - if (hintNumber == 0) - hintNumber = 1; - - let oldId = hintNumber; - if (key == "<Tab>") - { - if (++hintNumber > validHints.length) - hintNumber = 1; - } - else - { - if (--hintNumber < 1) - hintNumber = validHints.length; - } - showActiveHint(hintNumber, oldId); - updateStatusline(); - return; - - case "<BS>": - if (hintNumber > 0 && !usedTabKey) - { - hintNumber = Math.floor(hintNumber / 10); - if (hintNumber == 0) - prevInput = "text"; - } - else - { - usedTabKey = false; - hintNumber = 0; - return void liberator.beep(); - } - break; + case "<BS>": + if (this._hintNumber > 0 && !this._usedTabKey) { + this._hintNumber = Math.floor(this._hintNumber / 10); + if (this._hintNumber == 0) + this._prevInput = "text"; + } + else { + this._usedTabKey = false; + this._hintNumber = 0; + return void liberator.beep(); + } + break; - case mappings.getMapLeader(): - hints.escNumbers = !hints.escNumbers; - if (hints.escNumbers && usedTabKey) // hintNumber not used normally, but someone may wants to toggle - hintNumber = 0; // <tab>s ? reset. Prevent to show numbers not entered. + case mappings.getMapLeader(): + hints.escNumbers = !hints.escNumbers; + if (hints.escNumbers && this._usedTabKey) // this._hintNumber not used normally, but someone may wants to toggle + this._hintNumber = 0; // <tab>s ? this._reset. Prevent to show numbers not entered. - updateStatusline(); - return; + this._updateStatusline(); + return; - default: - if (/^\d$/.test(key)) - { - prevInput = "number"; + default: + if (/^\d$/.test(key)) { + this._prevInput = "number"; - let oldHintNumber = hintNumber; - if (hintNumber == 0 || usedTabKey) - { - usedTabKey = false; - hintNumber = parseInt(key, 10); - } - else - hintNumber = (hintNumber * 10) + parseInt(key, 10); + let oldHintNumber = this._hintNumber; + if (this._hintNumber == 0 || this._usedTabKey) { + this._usedTabKey = false; + this._hintNumber = parseInt(key, 10); + } + else + this._hintNumber = (this._hintNumber * 10) + parseInt(key, 10); - updateStatusline(); + this._updateStatusline(); - if (!canUpdate) - return; + if (!this._canUpdate) + return; - if (docs.length == 0) - { - generate(); - showHints(); - } - showActiveHint(hintNumber, oldHintNumber || 1); + if (this._docs.length == 0) { + this._generate(); + this._showHints(); + } + this._showActiveHint(this._hintNumber, oldHintNumber || 1); - if (hintNumber == 0) - return void liberator.beep(); + if (this._hintNumber == 0) + return void liberator.beep(); - checkUnique(); - } + this._checkUnique(); } + } - updateStatusline(); + this._updateStatusline(); - if (canUpdate) - { - if (docs.length == 0 && hintString.length > 0) - generate(); + if (this._canUpdate) { + if (this._docs.length == 0 && this._hintString.length > 0) + this._generate(); - showHints(); - processHints(followFirst); - } + this._showHints(); + this._processHints(followFirst); } - }; + } // FIXME: add resize support // window.addEventListener("resize", onResize, null); @@ -1083,6 +877,118 @@ function Hints() //{{{ // } //}}} -} //}}} +}, { + Mode: new Struct("prompt", "action", "tags"), +}, { + mappings: function () { + var myModes = config.browserModes; + mappings.add(myModes, ["f"], + "Start QuickHint mode", + function () { hints.show("o"); }); + + // At the moment, "F" calls + // buffer.followLink(clicked_element, DO_WHAT_FIREFOX_DOES_WITH_CNTRL_CLICK) + // It is not clear that it shouldn't be: + // buffer.followLink(clicked_element, !DO_WHAT_FIREFOX_DOES_WITH_CNTRL_CLICK) + // In fact, it might be nice if there was a "dual" to F (like H and + // gH, except that gF is already taken). --tpp + // + // Likewise, it might be nice to have a liberator.NEW_FOREGROUND_TAB + // and then make liberator.NEW_TAB always do what a Cntrl+Click + // does. --tpp + mappings.add(myModes, ["F"], + "Start QuickHint mode, but open link in a new tab", + function () { hints.show(options.getPref("browser.tabs.loadInBackground") ? "b" : "t"); }); + + mappings.add(myModes, [";"], + "Start an extended hint mode", + function (count) { + this._extendedhintCount = count; + commandline.input(";", null, + { + promptHighlight: "Normal", + completer: function (context) { + context.compare = function () 0; + context.completions = [[k, v.prompt] for ([k, v] in Iterator(this._hintModes))]; + }, + onChange: function () { modes.pop(); }, + onCancel: function (arg) { arg && setTimeout(function () hints.show(arg), 0); } + }); + }, { count: true }); + }, + options: function () { + const DEFAULT_HINTTAGS = + util.makeXPath(["input[not(@type='hidden')]", "a", "area", "iframe", "textarea", "button", "select"]) + + " | //*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @role='link']"; + + function checkXPath(val) { + try { + util.evaluateXPath(val, document.implementation.createDocument("", "", null)); + return true; + } + catch (e) { + return false; + } + } + + options.add(["extendedhinttags", "eht"], + "XPath string of hintable elements activated by ';'", + "string", DEFAULT_HINTTAGS, + { validator: checkXPath }); + + options.add(["hinttags", "ht"], + "XPath string of hintable elements activated by 'f' and 'F'", + "string", DEFAULT_HINTTAGS, + { validator: checkXPath }); + + 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 behaviour of <Return> in hint mode", + "number", 0, + { + completer: function () [ + ["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."] + ], + validator: Option.validateCompleter + }); + + options.add(["hintmatching", "hm"], + "How links are matched", + "string", "contains", + { + completer: function (context) [ + ["contains", "The typed characters are split on whitespace. The resulting groups must all appear in the hint."], + ["wordstartswith", "The typed characters are split on whitespace. The resulting groups must all match the beginings of words, in order."], + ["firstletters", "Behaves like wordstartswith, but all groups much match a sequence of words."], + ["custom", "Delegate to a custom function: liberator.plugins.customHintMatcher(this._hintString)"] + ], + validator: Option.validateCompleter + }); + + options.add(["wordseparators", "wsp"], + "How words are split for hintmatching", + "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]'); + + options.add(["hintinputs", "hin"], + "How text input fields are hinted", + "stringlist", "label,value", + { + completer: function (context) [ + ["value", "Match against the value contained by the input field"], + ["label", "Match against the value of a label for the input field, if one can be found"], + ["name", "Match against the name of an input field, only if neither a name or value could be found."] + ], + validator: Option.validateCompleter + }); + } +}); // 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 index 00000000..c9427958 --- /dev/null +++ b/common/content/history.js @@ -0,0 +1,233 @@ +// 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. + +const History = Module("history", { + get format() bookmarks.format, + + get service() services.get("history"), + + get: function get(filter, maxItems) { + // no query parameters will get all history + let query = services.get("history").getNewQuery(); + let options = services.get("history").getNewQueryOptions(); + + if (typeof filter == "string") + filter = { searchTerms: filter }; + for (let [k, v] in Iterator(filter)) + query[k] = v; + options.sortingMode = options.SORT_BY_DATE_DESCENDING; + options.resultType = options.RESULTS_AS_URI; + if (maxItems > 0) + options.maxResults = maxItems; + + // execute the query + let root = services.get("history").executeQuery(query, options).root; + root.containerOpen = true; + let items = util.map(util.range(0, root.childCount), function (i) { + let node = root.getChild(i); + return { + url: node.uri, + title: node.title, + icon: node.icon ? node.icon.spec : DEFAULT_FAVICON + }; + }); + 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 () util.Array.iteritems(this); + for (let i in util.range(0, sh.count)) { + obj[i] = { index: i, __proto__: sh.getEntryAtIndex(i, false) }; + util.memoize(obj[i], "icon", + function (obj) services.get("favicon").getFaviconImageForPage(obj.URI).spec); + } + return obj; + }, + + // TODO: better names + 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) + liberator.beep(); + else { + let index = util.Math.constrain(current + steps, start, end); + window.getWebNavigation().gotoIndex(index); + } + }, + + goToStart: function goToStart() { + let index = window.getWebNavigation().sessionHistory.index; + + if (index > 0) + window.getWebNavigation().gotoIndex(0); + else + liberator.beep(); + + }, + + goToEnd: function goToEnd() { + let sh = window.getWebNavigation().sessionHistory; + let max = sh.count - 1; + + if (sh.index < max) + window.getWebNavigation().gotoIndex(max); + else + liberator.beep(); + + }, + + // if openItems is true, open the matching history items in tabs rather than display + list: function list(filter, openItems, maxItems) { + // 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); + let items = completion.runCompleter("history", filter, maxItems); + + if (items.length) + return liberator.open(items.map(function (i) i.url), liberator.NEW_TAB); + + if (filter.length > 0) + liberator.echoerr("E283: No history matching \"" + filter + "\""); + else + liberator.echoerr("No history set"); + } +}, { +}, { + commands: function () { + commands.add(["ba[ck]"], + "Go back in the browser history", + function (args) { + let url = args.literalArg; + + 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); + liberator.echoerr("Exxx: URL not found in history"); + } + else + history.stepTo(-Math.max(args.count, 1)); + } + }, + { + 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 + }); + + 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); + liberator.echoerr("Exxx: URL not found in history"); + } + else + history.stepTo(Math.max(args.count, 1)); + } + }, + { + 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 + }); + + commands.add(["hist[ory]", "hs"], + "Show recently visited URLs", + function (args) { history.list(args.join(" "), args.bang, args["-max"] || 1000); }, { + bang: true, + completer: function (context) { context.quote = null; completion.history(context); }, + // completer: function (filter) completion.history(filter) + options: [[["-max", "-m"], commands.OPTION_INT]] + }); + }, + completion: function () { + completion.history = function _history(context, maxItems) { + context.format = history.format; + context.title = ["History"]; + context.compare = CompletionContext.Sort.unsorted; + //context.background = true; + if (context.maxItems == null) + context.maxItems = 100; + context.regenerate = true; + context.generate = function () history.get(context.filter, this.maxItems); + }; + + 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 (count) { history.stepTo(-Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, + ["<C-i>"], "Go to a newer position in the jump list", + function (count) { history.stepTo(Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, + ["H", "<A-Left>", "<M-Left>"], "Go back in the browser history", + function (count) { history.stepTo(-Math.max(count, 1)); }, + { count: true }); + + mappings.add(myModes, + ["L", "<A-Right>", "<M-Right>"], "Go forward in the browser history", + function (count) { history.stepTo(Math.max(count, 1)); }, + { count: true }); + }, +}); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/io.js b/common/content/io.js index ffc2c177..ed5f7143 100644 --- a/common/content/io.js +++ b/common/content/io.js @@ -8,88 +8,234 @@ /** @scope modules */ plugins.contexts = {}; -function Script(file) -{ - let self = plugins.contexts[file.path]; - if (self) - { - if (self.onUnload) - self.onUnload(); - return self; - } - plugins.contexts[file.path] = this; - this.NAME = file.leafName.replace(/\..*/, "").replace(/-([a-z])/g, function (m, n1) n1.toUpperCase()); - this.PATH = file.path; - this.__context__ = this; - - // This belongs elsewhere - for (let [, dir] in Iterator(io.getRuntimeDirectories("plugin"))) - { - if (dir.contains(file, false)) - plugins[this.NAME] = this; +const Script = Class("Script", { + init: function (file) { + let self = plugins.contexts[file.path]; + if (self) { + if (self.onUnload) + self.onUnload(); + return self; + } + plugins.contexts[file.path] = this; + this.NAME = file.leafName.replace(/\..*/, "").replace(/-([a-z])/g, function (m, n1) n1.toUpperCase()); + this.PATH = file.path; + this.toString = this.toString; + this.__context__ = this; + this.__proto__ = plugins; + + // This belongs elsewhere + for (let [, dir] in Iterator(io.getRuntimeDirectories("plugin"))) { + if (dir.contains(file, false)) + plugins[this.NAME] = this; + } } -} -Script.prototype = plugins; +}); -// TODO: why are we passing around strings rather than file objects? /** - * Provides a basic interface to common system I/O operations. - * @instance io + * @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 */ -function IO() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const WINDOWS = liberator.has("Win32"); - const EXTENSION_NAME = config.name.toLowerCase(); - - const downloadManager = Cc["@mozilla.org/download-manager;1"].createInstance(Ci.nsIDownloadManager); - - var processDir = services.get("directory").get("CurWorkD", Ci.nsIFile); - var cwd = processDir; - var oldcwd = null; - - var lastRunCommand = ""; // updated whenever the users runs a command with :! - var scriptNames = []; - - // default option values - var cdpath = "," + (services.get("environment").get("CDPATH").replace(/[:;]/g, ",") || ","); - var shell, shellcmdflag; - - if (WINDOWS) - { - shell = "cmd.exe"; - // TODO: setting 'shell' to "something containing sh" updates - // 'shellcmdflag' appropriately at startup on Windows in Vim - shellcmdflag = "/c"; - } - else - { - shell = services.get("environment").get("SHELL") || "sh"; - shellcmdflag = "-c"; - } +const File = Class("File", { + init: function (path, checkPWD) { + if (arguments.length < 2) + checkPWD = true; + + let file = services.create("file"); + + if (path instanceof Ci.nsIFile) + file = path; + else if (/file:\/\//.test(path)) + file = services.create("file:").getFileFromURLSpec(path); + else { + let expandedPath = File.expandPath(path); + + if (!File.isAbsolutePath(expandedPath) && checkPWD) + file = File.joinPaths(io.getCurrentDirectory().path, expandedPath); + else + file.initWithPath(expandedPath); + } + file = XPCSafeJSObjectWrapper(file); + file.__proto__ = File.prototype; + return file; + }, + + /** + * Iterates over the objects in this directory. + */ + iterDirectory: function () { + if (!this.isDirectory()) + throw Error("Not a directory"); + let entries = this.directoryEntries; + while (entries.hasMoreElements()) + yield File(entries.getNext().QueryInterface(Ci.nsIFile)); + }, + /** + * 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; + }, + + /** + * 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); + let icstream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream); + + if (!encoding) + encoding = options["fileencoding"]; + + ifstream.init(this, -1, 0, 0); + icstream.init(ifstream, encoding, 4096, Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER); // 4096 bytes buffering + + let buffer = []; + let str = {}; + while (icstream.readString(4096, str) != 0) + buffer.push(str.value); + + icstream.close(); + ifstream.close(); + return buffer.join(""); + }, + + /** + * Writes the string <b>buf</b> 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 (!encoding) + encoding = options["fileencoding"]; - function expandPathList(list) list.split(",").map(io.expandPath).join(",") + if (mode == ">>") + mode = self.MODE_WRONLY | self.MODE_CREATE | self.MODE_APPEND; + else if (!mode || mode == ">") + mode = self.MODE_WRONLY | self.MODE_CREATE | self.MODE_TRUNCATE; - function getPathsFromPathList(list) - { + if (!perms) + perms = 0644; + + ofstream.init(this, mode, perms, 0); + let ocstream = getStream(0); + try { + ocstream.writeString(buf); + } + catch (e) { + liberator.dump(e); + if (e.result == Cr.NS_ERROR_LOSS_OF_SIGNIFICANT_DATA) { + ocstream = getStream("?".charCodeAt(0)); + ocstream.writeString(buf); + return false; + } + else + throw e; + } + finally { + try { + ocstream.close(); + } + catch (e) {} + ofstream.close(); + } + return true; + }, +}, { + + expandPathList: function (list) list.split(",").map(this.expandPath).join(","), + + expandPath: function (path, relative) { + + // 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( + !liberator.has("Win32") ? /\$(\w+)\b|\${(\w+)}/g + : /\$(\w+)\b|\${(\w+)}|%(\w+)%/g, + function (m, n1, n2, n3) services.get("environment").get(n1 || n2 || n3) || m + ); + path = expand(path); + + // expand ~ + // Yuck. + if (!relative && RegExp("~(?:$|[/" + util.escapeRegex(IO.PATH_SEP) + "])").test(path)) { + // Try $HOME first, on all systems + let home = services.get("environment").get("HOME"); + + // Windows has its own idiosyncratic $HOME variables. + if (!home && liberator.has("Win32")) + home = services.get("environment").get("USERPROFILE") || + services.get("environment").get("HOMEDRIVE") + services.get("environment").get("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("/", IO.PATH_SEP, "g"); + }, + + getPathsFromPathList: function (list) { if (!list) return []; // empty list item means the current directory return list.replace(/,$/, "").split(",") .map(function (dir) dir == "" ? io.getCurrentDirectory().path : dir); - } + }, - function replacePathSep(path) path.replace("/", IO.PATH_SEP, "g"); + replacePathSep: function (path) path.replace("/", IO.PATH_SEP, "g"), - function joinPaths(head, tail) - { - let path = self.File(head); - try - { - path.appendRelativePath(self.expandPath(tail, true)); // FIXME: should only expand env vars and normalise path separators + joinPaths: function (head, tail) { + let path = this(head); + try { + path.appendRelativePath(this.expandPath(tail, true)); // FIXME: should only expand env vars and normalise path separators // TODO: This code breaks the external editor at least in ubuntu // because /usr/bin/gvim becomes /usr/bin/vim.gnome normalized and for // some strange reason it will start without a gui then (which is not @@ -98,277 +244,736 @@ function IO() //{{{ // if (path.exists() && path.normalize) // path.normalize(); } - catch (e) - { + catch (e) { return { exists: function () false, __noSuchMethod__: function () { throw e; } }; } return path; - } + }, - function isAbsolutePath(path) - { - try - { + isAbsolutePath: function (path) { + try { services.create("file").initWithPath(path); return true; } - catch (e) - { + catch (e) { return false; } - } + }, +}); - var downloadListener = { - onDownloadStateChange: function (state, download) - { - if (download.state == downloadManager.DOWNLOAD_FINISHED) - { - let url = download.source.spec; - let title = download.displayName; - let file = download.targetFile.path; - let size = download.size; - - liberator.echomsg("Download of " + title + " to " + file + " finished", 1); - autocommands.trigger("DownloadPost", { url: url, title: title, file: file, size: size }); - } - }, - onStateChange: function () {}, - onProgressChange: function () {}, - onSecurityChange: function () {} - }; - - downloadManager.addListener(downloadListener); - liberator.registerObserver("shutdown", function () { - downloadManager.removeListener(downloadListener); +// TODO: why are we passing around strings rather than file objects? +/** + * Provides a basic interface to common system I/O operations. + * @instance io + */ +const IO = Module("io", { + requires: ["services"], + + init: function () { + this._processDir = services.get("directory").get("CurWorkD", Ci.nsIFile); + this._cwd = this._processDir; + 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.get("downloadManager").DOWNLOAD_FINISHED) { + let url = download.source.spec; + let title = download.displayName; + let file = download.targetFile.path; + let size = download.size; + + liberator.echomsg("Download of " + title + " to " + file + " finished", 1); + autocommands.trigger("DownloadPost", { url: url, title: title, file: file, size: size }); + } + }, + onStateChange: function () {}, + onProgressChange: function () {}, + onSecurityChange: function () {} + }; + + services.get("downloadManager").addListener(this.downloadListener); + }, + + destroy: function () { + services.get("downloadManager").removeListener(this.downloadListener); for (let [, plugin] in Iterator(plugins.contexts)) if (plugin.onUnload) plugin.onUnload(); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["fileencoding", "fenc"], - "Sets the character encoding of read and written files", - "string", "UTF-8", - { - completer: function (context) completion.charset(context), - validator: Option.validateCompleter - }); - options.add(["cdpath", "cd"], - "List of directories searched when executing :cd", - "stringlist", cdpath, - { setter: function (value) expandPathList(value) }); - - options.add(["runtimepath", "rtp"], - "List of directories searched for runtime files", - "stringlist", IO.runtimePath, - { setter: function (value) expandPathList(value) }); - - options.add(["shell", "sh"], - "Shell to use for executing :! and :run commands", - "string", shell, - { setter: function (value) io.expandPath(value) }); - - options.add(["shellcmdflag", "shcf"], - "Flag passed to shell when executing :! and :run commands", - "string", shellcmdflag); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["cd", "chd[ir]"], - "Change the current directory", - function (args) - { - let arg = args.literalArg; - - if (!arg) - arg = "~"; - else if (arg == "-") - { - liberator.assert(oldcwd, "E186: No previous directory"); - arg = oldcwd.path; - } + }, + + /** + * @property {function} File class. + * @final + */ + File: File, + + /** + * @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 {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, + + /** + * Expands "~" and environment variables in <b>path</b>. + * + * "~" 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: File.expandPath, + + // 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} + */ + getCurrentDirectory: function () { + let dir = File(this._cwd.path); + + // 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; + }, + + /** + * 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}. + */ + setCurrentDirectory: function (newDir) { + newDir = newDir || "~"; - arg = io.expandPath(arg); + if (newDir == "-") + [this._cwd, this._oldcwd] = [this._oldcwd, this.getCurrentDirectory()]; + else { + let dir = File(newDir); - // go directly to an absolute path or look for a relative path - // match in 'cdpath' - // TODO: handle ../ and ./ paths - if (isAbsolutePath(arg)) - { - if (io.setCurrentDirectory(arg)) - liberator.echomsg(io.getCurrentDirectory().path); + if (!dir.exists() || !dir.isDirectory()) { + liberator.echoerr("E344: Can't find directory \"" + dir.path + "\" in path"); + return null; } - else - { - let dirs = getPathsFromPathList(options["cdpath"]); - let found = false; - for (let [, dir] in Iterator(dirs)) - { - dir = joinPaths(dir, arg); + [this._cwd, this._oldcwd] = [dir, this.getCurrentDirectory()]; + } - if (dir.exists() && dir.isDirectory() && dir.isReadable()) - { - io.setCurrentDirectory(dir.path); - liberator.echomsg(io.getCurrentDirectory().path); - found = true; + return self.getCurrentDirectory(); + }, + + /** + * Returns all directories named <b>name<b/> in 'runtimepath'. + * + * @param {string} name + * @returns {nsIFile[]) + */ + getRuntimeDirectories: function (name) { + let dirs = File.getPathsFromPathList(options["runtimepath"]); + + dirs = dirs.map(function (dir) File.joinPaths(dir, name)) + .filter(function (dir) dir.exists() && dir.isDirectory() && dir.isReadable()); + return dirs; + }, + + /** + * Returns the first user RC file found in <b>dir</b>. + * + * @param {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 = dir || "~"; + + let rcFile1 = File.joinPaths(dir, "." + config.name.toLowerCase() + "rc"); + let rcFile2 = File.joinPaths(dir, "_" + config.name.toLowerCase() + "rc"); + + if (liberator.has("Win32")) + [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.get("directory").get("TmpD", Ci.nsIFile); + + file.append(config.tempFile); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0600); + + return File(file); + }, + + + /** + * Runs an external program. + * + * @param {string} program The program to run. + * @param {string[]} args An array of arguments to pass to <b>program</b>. + * @param {boolean} blocking Whether to wait until the process terminates. + */ + blockingProcesses: [], + run: function (program, args, blocking) { + args = args || []; + blocking = !!blocking; + + let file; + + if (File.isAbsolutePath(program)) + file = File(program, true); + else { + let dirs = services.get("environment").get("PATH").split(liberator.has("Win32") ? ";" : ":"); + // Windows tries the CWD first TODO: desirable? + if (liberator.has("Win32")) + dirs = [io.getCurrentDirectory().path].concat(dirs); + +lookup: + for (let [, dir] in Iterator(dirs)) { + file = File.joinPaths(dir, program); + try { + if (file.exists()) break; + + // TODO: couldn't we just palm this off to the start command? + // automatically try to add the executable path extensions on windows + if (liberator.has("Win32")) { + let extensions = services.get("environment").get("PATHEXT").split(";"); + for (let [, extension] in Iterator(extensions)) { + file = File.joinPaths(dir, program + extension); + if (file.exists()) + break lookup; + } } } + catch (e) {} + } + } + + if (!file || !file.exists()) { + liberator.echoerr("Command not found: " + program); + return -1; + } + + let process = services.create("process"); + + process.init(file); + process.run(blocking, args.map(String), args.length); + + return process.exitValue; + }, + + // FIXME: multiple paths? + /** + * Sources files found in 'runtimepath'. For each relative path in + * <b>paths</b> 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 <b>all</b> 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 (paths, all) { + let dirs = File.getPathsFromPathList(options["runtimepath"]); + let found = false; + + liberator.echomsg("Searching for \"" + paths.join(" ") + "\" in \"" + options["runtimepath"] + "\"", 2); + + outer: + for (let [, dir] in Iterator(dirs)) { + for (let [, path] in Iterator(paths)) { + let file = File.joinPaths(dir, path); + + liberator.echomsg("Searching for \"" + file.path + "\"", 3); + + if (file.exists() && file.isFile() && file.isReadable()) { + io.source(file.path, false); + found = true; + + if (!all) + break outer; + } + } + } + + if (!found) + liberator.echomsg("not found in 'runtimepath': \"" + paths.join(" ") + "\"", 1); - if (!found) - { - liberator.echoerr("E344: Can't find directory \"" + arg + "\" in cdpath\n" - + "E472: Command failed"); + return found; + }, + + /** + * Reads Ex commands, JavaScript or CSS from <b>filename</b>. + * + * @param {string} filename The name of the file to source. + * @param {boolean} silent Whether errors should be reported. + */ + source: function (filename, silent) { + let wasSourcing = this.sourcing; + try { + var file = File(filename); + this.sourcing = { + file: file.path, + line: 0 + }; + + if (!file.exists() || !file.isReadable() || file.isDirectory()) { + if (!silent) { + if (file.exists() && file.isDirectory()) + liberator.echomsg("Cannot source a directory: \"" + filename + "\"", 0); + else + liberator.echomsg("could not source: \"" + filename + "\"", 1); + + liberator.echoerr("E484: Can't open file " + filename); } + + return; } - }, - { - argCount: "?", - completer: function (context) completion.directory(context, true), - literal: 0 - }); - - // NOTE: this command is only used in :source - commands.add(["fini[sh]"], - "Stop sourcing a script file", - function () { liberator.echoerr("E168: :finish used outside of a sourced file"); }, - { argCount: "0" }); - - commands.add(["pw[d]"], - "Print the current directory name", - function () { liberator.echomsg(io.getCurrentDirectory().path); }, - { argCount: "0" }); - - // "mkv[imperatorrc]" or "mkm[uttatorrc]" - commands.add([EXTENSION_NAME.replace(/(.)(.*)/, "mk$1[$2rc]")], - "Write current key mappings and changed options to the config file", - function (args) - { - liberator.assert(args.length <= 1, "E172: Only one file name allowed"); - - let filename = args[0] || io.getRCFile(null, true).path; - let file = io.File(filename); - - liberator.assert(!file.exists() || args.bang, - "E189: \"" + filename + "\" exists (add ! to override)"); - - // TODO: Use a set/specifiable list here: - let lines = [cmd.serial().map(commands.commandToString) for (cmd in commands) if (cmd.serial)]; - lines = util.Array.flatten(lines); - - // source a user .vimperatorrc file - lines.unshift('"' + liberator.version + "\n"); - - // For the record, I think that adding this line is absurd. --Kris - // I can't disagree. --djk - lines.push(commands.commandToString({ - command: "source", - bang: true, - arguments: [filename + ".local"] - })); - lines.push("\n\" vim: set ft=" + EXTENSION_NAME + ":"); + liberator.echomsg("sourcing \"" + filename + "\"", 2); + + let str = file.read(); + let uri = services.get("io").newFileURI(file); + + // handle pure JavaScript files specially + if (/\.js$/.test(filename)) { + try { + liberator.loadScript(uri.spec, Script(file)); + if (liberator.initialized) + liberator.initHelp(); + } + catch (e) { + let err = new Error(); + for (let [k, v] in Iterator(e)) + err[k] = v; + err.echoerr = <>{file.path}:{e.lineNumber}: {e}</>; + throw err; + } + } + else if (/\.css$/.test(filename)) + storage.styles.registerSheet(uri.spec, false, true); + else { + let heredoc = ""; + let heredocEnd = null; // the string which ends the heredoc + let lines = str.split(/\r\n|[\r\n]/); + + function execute(args) { command.execute(args, special, count, { setFrom: file }); } + + for (let [i, line] in Iterator(lines)) { + if (heredocEnd) { // we already are in a heredoc + if (heredocEnd.test(line)) { + execute(heredoc); + heredoc = ""; + heredocEnd = null; + } + else + heredoc += line + "\n"; + } + else { + this.sourcing.line = i + 1; + // skip line comments and blank lines + line = line.replace(/\r$/, ""); + + if (/^\s*(".*)?$/.test(line)) + continue; + + var [count, cmd, special, args] = commands.parseCommand(line); + var command = commands.get(cmd); + + if (!command) { + let lineNumber = i + 1; + + liberator.echoerr("Error detected while processing " + file.path, commandline.FORCE_MULTILINE); + commandline.echo("line " + lineNumber + ":", commandline.HL_LINENR, commandline.APPEND_TO_MESSAGES); + liberator.echoerr("E492: Not an editor command: " + line); + } + else { + if (command.name == "finish") + break; + else if (command.hereDoc) { + // check for a heredoc + let matches = args.match(/(.*)<<\s*(\S+)$/); + + if (matches) { + args = matches[1]; + heredocEnd = RegExp("^" + matches[2] + "$", "m"); + if (matches[1]) + heredoc = matches[1] + "\n"; + continue; + } + } + + execute(args); + } + } + } + + // if no heredoc-end delimiter is found before EOF then + // process the heredoc anyway - Vim compatible ;-) + if (heredocEnd) + execute(heredoc); + } + + if (this._scriptNames.indexOf(file.path) == -1) + this._scriptNames.push(file.path); + + liberator.echomsg("finished sourcing \"" + filename + "\"", 2); + + liberator.log("Sourced: " + filename, 3); + } + catch (e) { + liberator.reportError(e); + let message = "Sourcing file: " + (e.echoerr || file.path + ": " + e); + if (!silent) + liberator.echoerr(message); + } + finally { + this.sourcing = wasSourcing; + } + }, + + // TODO: when https://bugzilla.mozilla.org/show_bug.cgi?id=68702 is + // fixed use that instead of a tmpfile + /** + * Runs <b>command</b> 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 {string} + */ + system: function (command, input) { + liberator.echomsg("Calling shell to execute: " + command, 4); - try - { - file.write(lines.join("\n")); + function escape(str) '"' + str.replace(/[\\"$]/g, "\\$&") + '"'; + + return this.withTempFiles(function (stdin, stdout, cmd) { + if (input) + stdin.write(input); + + // TODO: implement 'shellredir' + if (liberator.has("Win32")) { + command = "cd /D " + this._cwd.path + " && " + command + " > " + stdout.path + " 2>&1" + " < " + stdin.path; + var res = this.run(options["shell"], options["shellcmdflag"].split(/\s+/).concat(command), true); } - catch (e) - { - liberator.echoerr("E190: Cannot open \"" + filename + "\" for writing"); - liberator.log("Could not write to " + file.path + ": " + e.message); // XXX + else { + cmd.write("cd " + escape(this._cwd.path) + "\n" + + ["exec", ">" + escape(stdout.path), "2>&1", "<" + escape(stdin.path), + escape(options["shell"]), options["shellcmdflag"], escape(command)].join(" ")); + res = this.run("/bin/sh", ["-e", cmd.path], true); } - }, - { - argCount: "*", // FIXME: should be "?" but kludged for proper error message - bang: true, - completer: function (context) completion.file(context, true) - }); - - commands.add(["runt[ime]"], - "Source the specified file from each directory in 'runtimepath'", - function (args) { io.sourceFromRuntimePath(args, args.bang); }, - { - argCount: "+", - bang: true + + let output = stdout.read(); + if (res > 0) + output += "\nshell returned " + res; + // if there is only one \n at the end, chop it off + else if (output && output.indexOf("\n") == output.length - 1) + output = output.substr(0, output.length - 1); + + return output; + }) || ""; + }, + + /** + * Creates a temporary file context for executing external commands. + * <b>func</b> is called with a temp file, created with + * {@link #createTempFile}, for each explicit argument. Ensures that + * all files are removed when <b>func</b> 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 <b>func</b>. + */ + withTempFiles: function (func, self) { + let args = util.map(util.range(0, func.length), this.createTempFile); + if (!args.every(util.identity)) + return false; + + try { + return func.apply(self || this, args); } - ); - - commands.add(["scrip[tnames]"], - "List all sourced script names", - function () - { - let list = template.tabular(["<SNR>", "Filename"], ["text-align: right; padding-right: 1em;"], - ([i + 1, file] for ([i, file] in Iterator(scriptNames)))); // TODO: add colon and remove column titles for pedantic Vim compatibility? - - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - }, - { argCount: "0" }); - - commands.add(["so[urce]"], - "Read Ex commands from a file", - function (args) - { - if (args.length > 1) - liberator.echoerr("E172: Only one file name allowed"); - else - io.source(args[0], 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.literalArg; - - // :!! needs to be treated specially as the command parser sets the - // bang flag but removes the ! from arg - if (args.bang) - arg = "!" + arg; - - // replaceable bang and no previous command? - liberator.assert(!/((^|[^\\])(\\\\)*)!/.test(arg) || lastRunCommand, - "E34: No previous command"); - - // 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 - arg = arg.replace(/(\\)*!/g, - function (m) /^\\(\\\\)*!$/.test(m) ? m.replace("\\!", "!") : m.replace("!", lastRunCommand) - ); - - lastRunCommand = arg; - - let output = io.system(arg); - - commandline.command = "!" + arg; - commandline.echo(template.commandOutput(<span highlight="CmdOutput">{output}</span>)); - - autocommands.trigger("ShellCmdPost", {}); - }, - { - argCount: "?", // TODO: "1" - probably not worth supporting weird Vim edge cases. The dream is dead. --djk - bang: true, - completer: function (context) completion.shellCommand(context), - literal: 0 - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - liberator.registerObserver("load_completion", function () { - completion.setFunctionCompleter([self.File, self.expandPath], + finally { + args.forEach(function (f) f.remove(false)); + } + } +}, { + /** + * @property {string} The value of the $VIMPERATOR_RUNTIME environment + * variable. + */ + get runtimePath() { + const rtpvar = config.name.toUpperCase() + "_RUNTIME"; + let rtp = services.get("environment").get(rtpvar); + if (!rtp) { + rtp = "~/" + (liberator.has("Win32") ? "" : ".") + config.name.toLowerCase(); + services.get("environment").set(rtpvar, rtp); + } + return rtp; + }, + + get PATH_SEP() { + delete this.PATH_SEP; + let f = services.get("directory").get("CurProcD", Ci.nsIFile); + f.append("foo"); + return this.PATH_SEP = f.path.substr(f.parent.path.length, 1); + }, +}, { + commands: function () { + commands.add(["cd", "chd[ir]"], + "Change the current directory", + function (args) { + let arg = args.literalArg; + + if (!arg) + arg = "~"; + else if (arg == "-") { + liberator.assert(io._oldcwd, "E186: No previous directory"); + arg = io._oldcwd.path; + } + + 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)) { + if (io.setCurrentDirectory(arg)) + liberator.echomsg(io.getCurrentDirectory().path); + } + else { + let dirs = File.getPathsFromPathList(options["cdpath"]); + let found = false; + + for (let [, dir] in Iterator(dirs)) { + dir = File.joinPaths(dir, arg); + + if (dir.exists() && dir.isDirectory() && dir.isReadable()) { + io.setCurrentDirectory(dir.path); + liberator.echomsg(io.getCurrentDirectory().path); + found = true; + break; + } + } + + if (!found) { + liberator.echoerr("E344: Can't find directory " + arg.quote() + " in cdpath\n" + + "E472: Command failed"); + } + } + }, { + argCount: "?", + completer: function (context) completion.directory(context, true), + literal: 0 + }); + + // NOTE: this command is only used in :source + commands.add(["fini[sh]"], + "Stop sourcing a script file", + function () { liberator.echoerr("E168: :finish used outside of a sourced file"); }, + { argCount: "0" }); + + commands.add(["pw[d]"], + "Print the current directory name", + function () { liberator.echomsg(io.getCurrentDirectory().path); }, + { argCount: "0" }); + + // "mkv[imperatorrc]" or "mkm[uttatorrc]" + commands.add([config.name.toLowerCase().replace(/(.)(.*)/, "mk$1[$2rc]")], + "Write current key mappings and changed options to the config file", + function (args) { + liberator.assert(args.length <= 1, "E172: Only one file name allowed"); + + let filename = args[0] || io.getRCFile(null, true).path; + let file = io.File(filename); + + liberator.assert(!file.exists() || args.bang, + "E189: \"" + filename + "\" exists (add ! to override)"); + + // TODO: Use a set/specifiable list here: + let lines = [cmd.serial().map(commands.commandToString) for (cmd in commands) if (cmd.serial)]; + lines = util.Array.flatten(lines); + + // source a user .vimperatorrc file + lines.unshift('"' + liberator.version + "\n"); + + // For the record, I think that adding this line is absurd. --Kris + // I can't disagree. --djk + lines.push(commands.commandToString({ + command: "source", + bang: true, + arguments: [filename + ".local"] + })); + + lines.push("\n\" vim: set ft=" + config.name.toLowerCase() + ":"); + + try { + file.write(lines.join("\n")); + } + catch (e) { + liberator.echoerr("E190: Cannot open \"" + filename + "\" for writing"); + liberator.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(["runt[ime]"], + "Source the specified file from each directory in 'runtimepath'", + function (args) { io.sourceFromRuntimePath(args, args.bang); }, { + argCount: "+", + bang: true + } + ); + + commands.add(["scrip[tnames]"], + "List all sourced script names", + function () { + let list = template.tabular(["<SNR>", "Filename"], ["text-align: right; padding-right: 1em;"], + ([i + 1, file] for ([i, file] in Iterator(this._scriptNames)))); // TODO: add colon and remove column titles for pedantic Vim compatibility? + + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + { argCount: "0" }); + + commands.add(["so[urce]"], + "Read Ex commands from a file", + function (args) { + if (args.length > 1) + liberator.echoerr("E172: Only one file name allowed"); + else + io.source(args[0], 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.literalArg; + + // :!! needs to be treated specially as the command parser sets the + // bang flag but removes the ! from arg + if (args.bang) + arg = "!" + arg; + + // replaceable bang and no previous command? + liberator.assert(!/((^|[^\\])(\\\\)*)!/.test(arg) || this._lastRunCommand, + "E34: No previous command"); + + // 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 + arg = arg.replace(/(\\)*!/g, + function (m) /^\\(\\\\)*!$/.test(m) ? m.replace("\\!", "!") : m.replace("!", this._lastRunCommand) + ); + + this._lastRunCommand = arg; + + let output = io.system(arg); + + commandline.command = "!" + arg; + commandline.echo(template.commandOutput(<span highlight="CmdOutput">{output}</span>)); + + autocommands.trigger("ShellCmdPost", {}); + }, { + argCount: "?", // TODO: "1" - probably not worth supporting weird Vim edge cases. The dream is dead. --djk + bang: true, + completer: function (context) completion.shellCommand(context), + literal: 0 + }); + }, + completion: function () { + completion.setFunctionCompleter([this.File, File.expandPath], [function (context, obj, args) { context.quote[2] = ""; completion.file(context, true); @@ -420,18 +1025,15 @@ function IO() //{{{ context.compare = function (a, b) b.isdir - a.isdir || String.localeCompare(a.text, b.text); - if (options["wildignore"]) - { + if (options["wildignore"]) { let wigRegexp = RegExp("(^" + options.get("wildignore").values.join("|") + ")$"); context.filters.push(function ({item: f}) f.isDirectory() || !wigRegexp.test(f.leafName)); } // context.background = true; context.key = dir; - context.generate = function generate_file() - { - try - { + context.generate = function generate_file() { + try { return File(dir).readDirectory(); } catch (e) {} @@ -440,16 +1042,13 @@ function IO() //{{{ completion.shellCommand = function shellCommand(context) { context.title = ["Shell Command", "Path"]; - context.generate = function () - { + context.generate = function () { let dirNames = services.get("environment").get("PATH").split(RegExp(liberator.has("Win32") ? ";" : ":")); let commands = []; - for (let [, dirName] in Iterator(dirNames)) - { + for (let [, dirName] in Iterator(dirNames)) { let dir = io.File(dirName); - if (dir.exists() && dir.isDirectory()) - { + if (dir.exists() && dir.isDirectory()) { commands.push([[file.leafName, dir.path] for (file in dir.iterDirectory()) if (file.isFile() && file.isExecutable())]); } @@ -460,768 +1059,45 @@ function IO() //{{{ }; completion.addUrlCompleter("f", "Local files", completion.file); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// File //////////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - /** - * @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 - */ - function File(path, checkPWD) - { - if (arguments.length < 2) - checkPWD = true; - - let file = services.create("file"); - - if (path instanceof Ci.nsIFile) - file = path; - else if (/file:\/\//.test(path)) - file = services.create("file:").getFileFromURLSpec(path); - else - { - let expandedPath = io.expandPath(path); - - if (!isAbsolutePath(expandedPath) && checkPWD) - file = joinPaths(io.getCurrentDirectory().path, expandedPath); - else - file.initWithPath(expandedPath); + }, + options: function () { + var shell, shellcmdflag; + if (liberator.has("Win32")) { + shell = "cmd.exe"; + // TODO: setting 'shell' to "something containing sh" updates + // 'shellcmdflag' appropriately at startup on Windows in Vim + shellcmdflag = "/c"; } - file = XPCSafeJSObjectWrapper(file); - file.__proto__ = File.prototype; - return file; - } - File.prototype = { - /** - * Iterates over the objects in this directory. - */ - iterDirectory: function () - { - if (!this.isDirectory()) - throw Error("Not a directory"); - let entries = this.directoryEntries; - while (entries.hasMoreElements()) - yield File(entries.getNext().QueryInterface(Ci.nsIFile)); - }, - /** - * 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; - }, - - /** - * 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); - let icstream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream); - - if (!encoding) - encoding = options["fileencoding"]; - - ifstream.init(this, -1, 0, 0); - icstream.init(ifstream, encoding, 4096, Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER); // 4096 bytes buffering - - let buffer = []; - let str = {}; - while (icstream.readString(4096, str) != 0) - buffer.push(str.value); - - icstream.close(); - ifstream.close(); - return buffer.join(""); - }, - - /** - * Writes the string <b>buf</b> 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 (!encoding) - encoding = options["fileencoding"]; - - if (mode == ">>") - mode = self.MODE_WRONLY | self.MODE_CREATE | self.MODE_APPEND; - else if (!mode || mode == ">") - mode = self.MODE_WRONLY | self.MODE_CREATE | self.MODE_TRUNCATE; - - if (!perms) - perms = 0644; - - ofstream.init(this, mode, perms, 0); - let ocstream = getStream(0); - try - { - ocstream.writeString(buf); - } - catch (e) - { - liberator.dump(e); - if (e.result == Cr.NS_ERROR_LOSS_OF_SIGNIFICANT_DATA) - { - ocstream = getStream("?".charCodeAt(0)); - ocstream.writeString(buf); - return false; - } - else - throw e; - } - finally - { - try - { - ocstream.close(); - } - catch (e) {} - ofstream.close(); - } - return true; - }, - }; - - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const self = { - /** - * @property {function} File class. - * @final - */ - File: File, - - /** - * @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 {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, - - /** - * Expands "~" and environment variables in <b>path</b>. - * - * "~" 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: IO.expandPath, - - // 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} - */ - getCurrentDirectory: function () - { - let dir = self.File(cwd.path); - - // 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 processDir; - }, - - /** - * 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}. - */ - setCurrentDirectory: function (newDir) - { - newDir = newDir || "~"; - - if (newDir == "-") - [cwd, oldcwd] = [oldcwd, this.getCurrentDirectory()]; - else - { - let dir = self.File(newDir); - - if (!dir.exists() || !dir.isDirectory()) - { - liberator.echoerr("E344: Can't find directory \"" + dir.path + "\" in path"); - return null; - } - - [cwd, oldcwd] = [dir, this.getCurrentDirectory()]; - } - - return self.getCurrentDirectory(); - }, - - /** - * Returns all directories named <b>name<b/> in 'runtimepath'. - * - * @param {string} name - * @returns {nsIFile[]) - */ - getRuntimeDirectories: function (name) - { - let dirs = getPathsFromPathList(options["runtimepath"]); - - dirs = dirs.map(function (dir) joinPaths(dir, name)) - .filter(function (dir) dir.exists() && dir.isDirectory() && dir.isReadable()); - return dirs; - }, - - /** - * Returns the first user RC file found in <b>dir</b>. - * - * @param {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 = dir || "~"; - - let rcFile1 = joinPaths(dir, "." + EXTENSION_NAME + "rc"); - let rcFile2 = joinPaths(dir, "_" + EXTENSION_NAME + "rc"); - - if (WINDOWS) - [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.get("directory").get("TmpD", Ci.nsIFile); - - file.append(config.tempFile); - file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0600); - - return self.File(file); - }, - - - /** - * Runs an external program. - * - * @param {string} program The program to run. - * @param {string[]} args An array of arguments to pass to <b>program</b>. - * @param {boolean} blocking Whether to wait until the process terminates. - */ - blockingProcesses: [], - run: function (program, args, blocking) - { - args = args || []; - blocking = !!blocking; - - let file; - - if (isAbsolutePath(program)) - file = self.File(program, true); - else - { - let dirs = services.get("environment").get("PATH").split(WINDOWS ? ";" : ":"); - // Windows tries the CWD first TODO: desirable? - if (WINDOWS) - dirs = [io.getCurrentDirectory().path].concat(dirs); - -lookup: - for (let [, dir] in Iterator(dirs)) - { - file = joinPaths(dir, program); - try - { - if (file.exists()) - break; - - // TODO: couldn't we just palm this off to the start command? - // automatically try to add the executable path extensions on windows - if (WINDOWS) - { - let extensions = services.get("environment").get("PATHEXT").split(";"); - for (let [, extension] in Iterator(extensions)) - { - file = joinPaths(dir, program + extension); - if (file.exists()) - break lookup; - } - } - } - catch (e) {} - } - } - - if (!file || !file.exists()) - { - liberator.echoerr("Command not found: " + program); - return -1; - } - - let process = services.create("process"); - - process.init(file); - process.run(blocking, args.map(String), args.length); - - return process.exitValue; - }, - - // FIXME: multiple paths? - /** - * Sources files found in 'runtimepath'. For each relative path in - * <b>paths</b> 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 <b>all</b> 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 (paths, all) - { - let dirs = getPathsFromPathList(options["runtimepath"]); - let found = false; - - liberator.echomsg("Searching for \"" + paths.join(" ") + "\" in \"" + options["runtimepath"] + "\"", 2); - - outer: - for (let [, dir] in Iterator(dirs)) - { - for (let [, path] in Iterator(paths)) - { - let file = joinPaths(dir, path); - - liberator.echomsg("Searching for \"" + file.path + "\"", 3); - - if (file.exists() && file.isFile() && file.isReadable()) - { - io.source(file.path, false); - found = true; - - if (!all) - break outer; - } - } - } - - if (!found) - liberator.echomsg("not found in 'runtimepath': \"" + paths.join(" ") + "\"", 1); - - return found; - }, - - /** - * Reads Ex commands, JavaScript or CSS from <b>filename</b>. - * - * @param {string} filename The name of the file to source. - * @param {boolean} silent Whether errors should be reported. - */ - source: function (filename, silent) - { - let wasSourcing = self.sourcing; - try - { - var file = self.File(filename); - self.sourcing = { - file: file.path, - line: 0 - }; - - if (!file.exists() || !file.isReadable() || file.isDirectory()) - { - if (!silent) - { - if (file.exists() && file.isDirectory()) - liberator.echomsg("Cannot source a directory: \"" + filename + "\"", 0); - else - liberator.echomsg("could not source: \"" + filename + "\"", 1); - - liberator.echoerr("E484: Can't open file " + filename); - } - - return; - } - - liberator.echomsg("sourcing \"" + filename + "\"", 2); - - let str = file.read(); - let uri = services.get("io").newFileURI(file); - - // handle pure JavaScript files specially - if (/\.js$/.test(filename)) - { - try - { - liberator.loadScript(uri.spec, new Script(file)); - if (liberator.initialized) - liberator.initHelp(); - } - catch (e) - { - let err = new Error(); - for (let [k, v] in Iterator(e)) - err[k] = v; - err.echoerr = <>{file.path}:{e.lineNumber}: {e}</>; - throw err; - } - } - else if (/\.css$/.test(filename)) - storage.styles.registerSheet(uri.spec, false, true); - else - { - let heredoc = ""; - let heredocEnd = null; // the string which ends the heredoc - let lines = str.split(/\r\n|[\r\n]/); - - function execute(args) { command.execute(args, special, count, { setFrom: file }); } - - for (let [i, line] in Iterator(lines)) - { - if (heredocEnd) // we already are in a heredoc - { - if (heredocEnd.test(line)) - { - execute(heredoc); - heredoc = ""; - heredocEnd = null; - } - else - heredoc += line + "\n"; - } - else - { - self.sourcing.line = i + 1; - // skip line comments and blank lines - line = line.replace(/\r$/, ""); - - if (/^\s*(".*)?$/.test(line)) - continue; - - var [count, cmd, special, args] = commands.parseCommand(line); - var command = commands.get(cmd); - - if (!command) - { - let lineNumber = i + 1; - - liberator.echoerr("Error detected while processing " + file.path, commandline.FORCE_MULTILINE); - commandline.echo("line " + lineNumber + ":", commandline.HL_LINENR, commandline.APPEND_TO_MESSAGES); - liberator.echoerr("E492: Not an editor command: " + line); - } - else - { - if (command.name == "finish") - break; - else if (command.hereDoc) - { - // check for a heredoc - let matches = args.match(/(.*)<<\s*(\S+)$/); - - if (matches) - { - args = matches[1]; - heredocEnd = RegExp("^" + matches[2] + "$", "m"); - if (matches[1]) - heredoc = matches[1] + "\n"; - continue; - } - } - - execute(args); - } - } - } - - // if no heredoc-end delimiter is found before EOF then - // process the heredoc anyway - Vim compatible ;-) - if (heredocEnd) - execute(heredoc); - } - - if (scriptNames.indexOf(file.path) == -1) - scriptNames.push(file.path); - - liberator.echomsg("finished sourcing \"" + filename + "\"", 2); - - liberator.log("Sourced: " + filename, 3); - } - catch (e) - { - liberator.reportError(e); - let message = "Sourcing file: " + (e.echoerr || file.path + ": " + e); - if (!silent) - liberator.echoerr(message); - } - finally - { - self.sourcing = wasSourcing; - } - }, - - // TODO: when https://bugzilla.mozilla.org/show_bug.cgi?id=68702 is - // fixed use that instead of a tmpfile - /** - * Runs <b>command</b> 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 {string} - */ - system: function (command, input) - { - liberator.echomsg("Calling shell to execute: " + command, 4); - - function escape(str) '"' + str.replace(/[\\"$]/g, "\\$&") + '"'; - - return this.withTempFiles(function (stdin, stdout, cmd) { - if (input) - stdin.write(input); - - // TODO: implement 'shellredir' - if (WINDOWS) - { - command = "cd /D " + cwd.path + " && " + command + " > " + stdout.path + " 2>&1" + " < " + stdin.path; - var res = this.run(options["shell"], options["shellcmdflag"].split(/\s+/).concat(command), true); - } - else - { - cmd.write("cd " + escape(cwd.path) + "\n" + - ["exec", ">" + escape(stdout.path), "2>&1", "<" + escape(stdin.path), - escape(options["shell"]), options["shellcmdflag"], escape(command)].join(" ")); - res = this.run("/bin/sh", ["-e", cmd.path], true); - } - - let output = stdout.read(); - if (res > 0) - output += "\nshell returned " + res; - // if there is only one \n at the end, chop it off - else if (output && output.indexOf("\n") == output.length - 1) - output = output.substr(0, output.length - 1); - - return output; - }) || ""; - }, - - /** - * Creates a temporary file context for executing external commands. - * <b>func</b> is called with a temp file, created with - * {@link #createTempFile}, for each explicit argument. Ensures that - * all files are removed when <b>func</b> 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 <b>func</b>. - */ - withTempFiles: function (func, self) - { - let args = util.map(util.range(0, func.length), this.createTempFile); - if (!args.every(util.identity)) - return false; - - try - { - return func.apply(self || this, args); - } - finally - { - args.forEach(function (f) f.remove(false)); - } + else { + shell = services.get("environment").get("SHELL") || "sh"; + shellcmdflag = "-c"; } - }; //}}} - - return self; -} //}}} - -IO.PATH_SEP = (function () { - let f = services.get("directory").get("CurProcD", Ci.nsIFile); - f.append("foo"); - return f.path.substr(f.parent.path.length, 1); -})(); - -/** - * @property {string} The value of the $VIMPERATOR_RUNTIME environment - * variable. - */ -IO.__defineGetter__("runtimePath", function () { - const rtpvar = config.name.toUpperCase() + "_RUNTIME"; - let rtp = services.get("environment").get(rtpvar); - if (!rtp) - { - rtp = "~/" + (liberator.has("Win32") ? "" : ".") + config.name.toLowerCase(); - services.get("environment").set(rtpvar, rtp); - } - return rtp; + options.add(["fileencoding", "fenc"], + "Sets the character encoding of read and written files", + "string", "UTF-8", { + completer: function (context) completion.charset(context), + validator: Option.validateCompleter + }); + options.add(["cdpath", "cd"], + "List of directories searched when executing :cd", + "stringlist", "," + (services.get("environment").get("CDPATH").replace(/[:;]/g, ",") || ","), + { setter: function (value) File.expandPathList(value) }); + + options.add(["runtimepath", "rtp"], + "List of directories searched for runtime files", + "stringlist", IO.runtimePath, + { setter: function (value) File.expandPathList(value) }); + + options.add(["shell", "sh"], + "Shell to use for executing :! and :run commands", + "string", shell, + { setter: function (value) File.expandPath(value) }); + + options.add(["shellcmdflag", "shcf"], + "Flag passed to shell when executing :! and :run commands", + "string", shellcmdflag); + }, }); -IO.expandPath = function (path, relative) -{ - // TODO: proper pathname separator translation like Vim - this should be done elsewhere - const WINDOWS = liberator.has("Win32"); - - // 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( - !WINDOWS ? /\$(\w+)\b|\${(\w+)}/g - : /\$(\w+)\b|\${(\w+)}|%(\w+)%/g, - function (m, n1, n2, n3) services.get("environment").get(n1 || n2 || n3) || m - ); - path = expand(path); - - // expand ~ - // Yuck. - if (!relative && RegExp("~(?:$|[/" + util.escapeRegex(IO.PATH_SEP) + "])").test(path)) - { - // Try $HOME first, on all systems - let home = services.get("environment").get("HOME"); - - // Windows has its own idiosyncratic $HOME variables. - if (!home && WINDOWS) - home = services.get("environment").get("USERPROFILE") || - services.get("environment").get("HOMEDRIVE") + services.get("environment").get("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("/", IO.PATH_SEP, "g"); -}; - // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/liberator-overlay.js b/common/content/liberator-overlay.js index 645e062d..b2a310b4 100644 --- a/common/content/liberator-overlay.js +++ b/common/content/liberator-overlay.js @@ -11,51 +11,51 @@ const loader = Components.classes["@mozilla.org/moz/jssubscript-loader;1"] .getService(Components.interfaces.mozIJSSubScriptLoader); - function load(script) - { - for (let [i, base] in Iterator(prefix)) - { - try - { + function load(script) { + for (let [i, base] in Iterator(prefix)) { + try { loader.loadSubScript(base + script, modules); return; } - catch (e) - { + catch (e) { if (i + 1 < prefix.length) continue; if (Components.utils.reportError) Components.utils.reportError(e); dump("liberator: Loading script " + script + ": " + e + "\n"); + dump(e.stack + "\n"); } } } - Components.utils.import("resource://liberator/storage.jsm", modules); - let prefix = [BASE]; - ["services.js", - "liberator.js", - "configbase.js", - "config.js"].forEach(load); - modules.config.__proto__ = modules.configbase; - - ["util.js", - "style.js", + ["base.js", + "modules.js", + "autocommands.js", "buffer.js", + "commandline.js", "commands.js", "completion.js", + "config.js", + "configbase.js", + "liberator.js", "editor.js", "events.js", "finder.js", "hints.js", "io.js", "mappings.js", + "marks.js", "modes.js", "options.js", + "services.js", + "statusline.js", + "style.js", "template.js", - "ui.js"].forEach(load); + "util.js", + ].forEach(load); + modules.config.__proto__ = modules.configbase; prefix.unshift("chrome://" + modules.config.name.toLowerCase() + "/content/"); modules.config.scripts.forEach(load); diff --git a/common/content/liberator.js b/common/content/liberator.js index 2ab21379..d9bf4c14 100644 --- a/common/content/liberator.js +++ b/common/content/liberator.js @@ -15,97 +15,1033 @@ const EVAL_ERROR = "__liberator_eval_error"; const EVAL_RESULT = "__liberator_eval_result"; const EVAL_STRING = "__liberator_eval_string"; -function FailedAssertion(message) { - this.message = message; -} -FailedAssertion.prototype = { - __proto__: Error.prototype, -}; - -const liberator = (function () //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - function Runnable(self, func, args) - { - this.self = self; - this.func = func; - this.args = args; - } - Runnable.prototype = { +// Move elsewhere? +const Storage = Module("storage", { + init: function () { + Components.utils.import("resource://liberator/storage.jsm", this); + modules.Timer = this.Timer; // Fix me, please. + return this.storage; + }, +}); + +function Runnable(self, func, args) { + return { QueryInterface: XPCOMUtils.generateQI([Ci.nsIRunnable]), - run: function () { this.func.apply(this.self, this.args); } - }; + run: function () { func.apply(self, args); } + } +} - const observers = {}; +const FailedAssertion = Class("FailedAssertion", Error, { + init: function (message) { + this.message = message; + }, +}); - function registerObserver(type, callback) - { - if (!(type in observers)) - observers[type] = []; - observers[type].push(callback); - } +const Liberator = Module("liberator", { + requires: ["services"], + + init: function () { + window.liberator = this; + window.liberator = this; + modules.liberator = this; + this.observers = {}; + this.modules = modules; - let nError = 0; - function loadModule(name, func) - { - let message = "Loading module " + name + "..."; - try - { - liberator.log(message, 0); - liberator.dump(message); - modules[name] = func(); - liberator.triggerObserver("load_" + name, name); + // NOTE: services.get("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. + /** @property {string} The name of the current user profile. */ + this.profileName = services.get("directory").get("ProfD", Ci.nsIFile).leafName.replace(/^.+?\./, ""); + }, + + destroy: function () { + autocommands.trigger(config.name + "LeavePre", {}); + storage.saveAll(); + liberator.triggerObserver("shutdown", null); + liberator.dump("All liberator modules destroyed\n"); + autocommands.trigger(config.name + "Leave", {}); + }, + + /** + * @property {number} The current main mode. + * @see modes#mainModes + */ + get mode() modes.main, + set mode(value) modes.main = value, + + get menuItems() Liberator.getMenuItems(), + + /** @property {Element} The currently focused element. */ + get focus() document.commandDispatcher.focusedElement, + + get extensions() { + const rdf = services.get("rdf"); + const extensionManager = services.get("extensionManager"); + + let extensions = extensionManager.getItemList(Ci.nsIUpdateItem.TYPE_EXTENSION, {}); + + function getRdfProperty(item, property) { + let resource = rdf.GetResource("urn:mozilla:item:" + item.id); + let value = ""; + + if (resource) { + let target = extensionManager.datasource.GetTarget(resource, + rdf.GetResource("http://www.mozilla.org/2004/em-rdf#" + property), true); + if (target && target instanceof Ci.nsIRDFLiteral) + value = target.Value; + } + + return value; } - catch (e) - { - if (nError++ == 0) - toOpenWindowByType("global:console", "chrome://global/content/console.xul"); - liberator.reportError(e); + + //const Extension = new Struct("id", "name", "description", "icon", "enabled", "version"); + return extensions.map(function (e) ({ + id: e.id, + name: e.name, + description: getRdfProperty(e, "description"), + enabled: getRdfProperty(e, "isDisabled") != "true", + icon: e.iconURL, + options: getRdfProperty(e, "optionsURL"), + version: e.version + })); + }, + + getExtension: function (name) this.extensions.filter(function (e) e.name == name)[0], + + // Global constants + CURRENT_TAB: [], + NEW_TAB: [], + NEW_BACKGROUND_TAB: [], + NEW_WINDOW: [], + + forceNewTab: false, + forceNewWindow: false, + + /** @property {string} The Liberator version string. */ + version: "###VERSION### (created: ###DATE###)", // these VERSION and DATE tokens are replaced by the Makefile + + /** + * @property {Object} The map of command-line options. These are + * specified in the argument to the host application's -liberator + * option. E.g. $ firefox -liberator '+u=tempRcFile ++noplugin' + * Supported options: + * +u=RCFILE Use RCFILE instead of .vimperatorrc. + * ++noplugin Don't load plugins. + */ + 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 (type, callback) { + if (!(type in this.observers)) + this.observers[type] = []; + this.observers[type].push(callback); + }, + + unregisterObserver: function (type, callback) { + if (type in this.observers) + this.observers[type] = this.observers[type].filter(function (c) c != callback); + }, + + // TODO: "zoom": if the zoom value of the current buffer changed + triggerObserver: function (type) { + let args = Array.slice(arguments, 1); + for (let [, func] in Iterator(this.observers[type] || [])) + func.apply(null, args); + }, + + /** + * 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 () { + // FIXME: popups clear the command line + if (options["visualbell"]) { + // flash the visual bell + let popup = document.getElementById("liberator-visualbell"); + let win = config.visualbellWindow; + let rect = win.getBoundingClientRect(); + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + + // NOTE: this doesn't seem to work in FF3 with full box dimensions + popup.openPopup(win, "overlap", 1, 1, false, false); + popup.sizeTo(width - 2, height - 2); + setTimeout(function () { popup.hidePopup(); }, 20); + } + else { + let soundService = Cc["@mozilla.org/sound;1"].getService(Ci.nsISound); + soundService.beep(); } + return false; // so you can do: if (...) return liberator.beep(); + }, + + /** + * Creates a new thread. + */ + newThread: function () services.get("threadManager").newThread(0), + + /** + * Calls a function asynchronously on a new thread. + * + * @param {nsIThread} thread The thread to call the function on. If no + * thread is specified a new one is created. + * @optional + * @param {Object} self The 'this' object used when executing the + * function. + * @param {function} func The function to execute. + * + */ + callAsync: function (thread, self, func) { + thread = thread || services.get("threadManager").newThread(0); + thread.dispatch(Runnable(self, func, Array.slice(arguments, 3)), thread.DISPATCH_NORMAL); + }, + + /** + * Calls a function synchronously on a new thread. + * + * NOTE: Be sure to call GUI related methods like alert() or dump() + * ONLY in the main thread. + * + * @param {nsIThread} thread The thread to call the function on. If no + * thread is specified a new one is created. + * @optional + * @param {function} func The function to execute. + */ + callFunctionInThread: function (thread, func) { + thread = thread || services.get("threadManager").newThread(0); + + // DISPATCH_SYNC is necessary, otherwise strange things will happen + thread.dispatch(Runnable(null, func, Array.slice(arguments, 2)), thread.DISPATCH_SYNC); + }, + + /** + * Prints a message to the console. If <b>msg</b> is an object it is + * pretty printed. + * + * NOTE: the "browser.dom.window.dump.enabled" preference needs to be + * set. + * + * @param {string|Object} msg The message to print. + */ + dump: function () { + let msg = Array.map(arguments, function (msg) { + if (typeof msg == "object") + msg = util.objectToString(msg); + return msg; + }).join(", "); + msg = String.replace(msg, /\n?$/, "\n"); + window.dump(msg.replace(/^./gm, ("config" in modules && config.name.toLowerCase()) + ": $&")); + }, + + /** + * Dumps a stack trace to the console. + * + * @param {string} msg The trace message. + * @param {number} frames The number of frames to print. + */ + dumpStack: function (msg, frames) { + let stack = Error().stack.replace(/(?:.*\n){2}/, ""); + if (frames != null) + [stack] = stack.match(RegExp("(?:.*\n){0," + frames + "}")); + liberator.dump((msg || "Stack") + "\n" + stack); + }, + + /** + * Outputs a plain message to the command line. + * + * @param {string} str The message to output. + * @param {number} flags These control the multiline message behaviour. + * See {@link CommandLine#echo}. + */ + echo: function (str, flags) { + commandline.echo(str, commandline.HL_NORMAL, flags); + }, + + // TODO: Vim replaces unprintable characters in echoerr/echomsg + /** + * Outputs an error message to the command line. + * + * @param {string} str The message to output. + * @param {number} flags These control the multiline message behaviour. + * See {@link CommandLine#echo}. + */ + echoerr: function (str, flags) { + flags |= commandline.APPEND_TO_MESSAGES; + + if (typeof str == "object" && "echoerr" in str) + str = str.echoerr; + else if (str instanceof Error) + str = str.fileName + ":" + str.lineNumber + ": " + str; + + if (options["errorbells"]) + liberator.beep(); + + commandline.echo(str, commandline.HL_ERRORMSG, flags); + }, + + // 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 multiline message behaviour. + * See {@link CommandLine#echo}. + */ + echomsg: function (str, verbosity, flags) { + // TODO: is there a reason for this? --djk + // yes, it doesn't show the MOW on startup if you have e.g. some qmarks in your vimperatorrc. + // Feel free to add another flag like DONT_OPEN_MULTILINE if really needed --mst + // + // But it's _supposed_ to show the MOW on startup when there are + // messages, surely? As far as I'm concerned it essentially works + // exactly as it should with the DISALLOW_MULTILINE flag removed. + // Sending N messages to the command line in a row and having them + // overwrite each other is completely broken. I also think many of + // those messages like "Added quick mark" are plain silly but if + // you don't like them you can set verbose=0, or use :silent when + // someone adds it. I reckon another flag and 'class' of messages + // is just going to unnecessarily complicate things. --djk + flags |= commandline.APPEND_TO_MESSAGES | commandline.DISALLOW_MULTILINE; + + if (verbosity == null) + verbosity = 0; // verbosity level is exclusionary + + if (options["verbose"] >= verbosity) + commandline.echo(str, commandline.HL_INFOMSG, flags); + }, + + /** + * Loads and executes the script referenced by <b>uri</b> in the scope + * of the <b>context</b> 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) { + XML.ignoreWhiteSpace = false; + XML.prettyPrinting = false; + services.get("subscriptLoader").loadSubScript(uri, context); + }, + + eval: function (str, context) { + try { + if (!context) + context = userContext; + context[EVAL_ERROR] = null; + context[EVAL_STRING] = str; + context[EVAL_RESULT] = null; + this.loadScript("chrome://liberator/content/eval.js", context); + if (context[EVAL_ERROR]) { + try { + context[EVAL_ERROR].fileName = io.sourcing.file; + context[EVAL_ERROR].lineNumber += io.sourcing.line; + } + catch (e) {} + throw context[EVAL_ERROR]; + } + return context[EVAL_RESULT]; + } + finally { + delete context[EVAL_ERROR]; + delete context[EVAL_RESULT]; + delete context[EVAL_STRING]; + } + }, + + // partial sixth level expression evaluation + // TODO: what is that really needed for, and where could it be used? + // Or should it be removed? (c) Viktor + // Better name? See other liberator.eval() + // I agree, the name is confusing, and so is the + // description --Kris + evalExpression: function (string) { + string = string.toString().replace(/^\s*/, "").replace(/\s*$/, ""); + let matches = string.match(/^&(\w+)/); + + if (matches) { + let opt = this.options.get(matches[1]); + + if (!opt) + return void this.echoerr("E113: Unknown option: " + matches[1]); + + let type = opt.type; + let value = opt.getter(); + + if (type != "boolean" && type != "number") + value = value.toString(); + + return value; + } + // String + else if (matches = string.match(/^(['"])([^\1]*?[^\\]?)\1/)) { + if (matches) + return matches[2].toString(); + else + return void this.echoerr("E115: Missing quote: " + string); + } + // Number + else if (matches = string.match(/^(\d+)$/)) + return parseInt(matches[1], 10); + + let reference = this.variableReference(string); + + if (!reference[0]) + this.echoerr("E121: Undefined variable: " + string); + else + return reference[0][reference[1]]; + + return; + }, + + /** + * 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 || {}; + + let err = null; + let [count, cmd, special, args] = commands.parseCommand(str.replace(/^'(.*)'$/, "$1")); + let command = commands.get(cmd); + + if (command === null) { + err = "E492: Not a " + config.name.toLowerCase() + " command: " + str; + liberator.focusContent(); + } + else if (command.action === null) + err = "E666: Internal error: command.action === null"; // TODO: need to perform this test? -- djk + else if (count != -1 && !command.count) + err = "E481: No range allowed"; + else if (special && !command.bang) + err = "E477: No ! allowed"; + + liberator.assert(!err, err); + if (!silent) + commandline.command = str.replace(/^\s*:\s*/, ""); + + command.execute(args, special, count, modifiers); + }, + + /** + * Focuses the content window. + * + * @param {boolean} clearFocusedElement Remove focus from any focused + * element. + */ + focusContent: function (clearFocusedElement) { + if (window != services.get("windowWatcher").activeWindow) + return; + + let elem = config.mainWidget || window.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 if (this.has("tabs")) { + let frame = tabs.localStore.focusedFrame; + if (frame && frame.top == window.content) + elem = frame; + } + } + catch (e) {} + + if (clearFocusedElement && liberator.focus) + liberator.focus.blur(); + if (elem && elem != liberator.focus) + elem.focus(); + }, + + /** + * Returns whether this Liberator extension supports <b>feature</b>. + * + * @param {string} feature The feature name. + * @returns {boolean} + */ + has: function (feature) config.features.indexOf(feature) >= 0, + + /** + * Returns whether the host application has the specified extension + * installed. + * + * @param {string} name The extension name. + * @returns {boolean} + */ + hasExtension: function (name) { + let extensions = services.get("extensionManager").getItemList(Ci.nsIUpdateItem.TYPE_EXTENSION, {}); + return extensions.some(function (e) e.name == name); + }, + + /** + * Returns the URL of the specified help <b>topic</b> if it exists. + * + * @param {string} topic The help topic to lookup. + * @param {boolean} unchunked Whether to search the unchunked help page. + * @returns {string} + */ + findHelp: function (topic, unchunked) { + if (topic in services.get("liberator:").FILE_MAP) + return topic; + unchunked = !!unchunked; + let items = completion._runCompleter("help", topic, null, unchunked).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 + * Initialize the help system. + */ + initHelp: function () { + let namespaces = [config.name.toLowerCase(), "liberator"]; + services.get("liberator:").init({}); + let tagMap = services.get("liberator:").HELP_TAGS; + let fileMap = services.get("liberator:").FILE_MAP; + let overlayMap = services.get("liberator:").OVERLAY_MAP; + function XSLTProcessor(sheet) { + let xslt = Cc["@mozilla.org/document-transformer;1?type=xslt"].createInstance(Ci.nsIXSLTProcessor); + xslt.importStylesheet(util.httpGet(sheet).responseXML); + return xslt; + } + + function findHelpFile(file) { + let result = []; + for (let [, namespace] in Iterator(namespaces)) { + let url = ["chrome://", namespace, "/locale/", 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; + } + function addTags(file, doc) { + doc = XSLT.transformToDocument(doc); + for (let elem in util.evaluateXPath("//xhtml:a/@id", doc)) + tagMap[elem.value] = file; + } + + const XSLT = XSLTProcessor("chrome://liberator/content/help.xsl"); + + tagMap.all = "all"; + let files = findHelpFile("all").map(function (doc) + [f.value for (f in util.evaluateXPath( + "//liberator:include/@href", doc))]); + + util.Array.flatten(files).forEach(function (file) { + findHelpFile(file).forEach(function (doc) { + addTags(file, doc); + }); + }); + + XML.ignoreWhiteSpace = false; + XML.prettyPrinting = false; + XML.prettyPrinting = true; // Should be false, but ignoreWhiteSpace=false doesn't work correctly. This is the lesser evil. + XML.prettyIndent = 4; + let body = XML(); + for (let [, context] in Iterator(plugins.contexts)) + if (context.INFO instanceof XML) + 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="chrome://liberator/content/help.xsl"?>\n' + + '<!DOCTYPE document SYSTEM "chrome://liberator/content/liberator.dtd">' + + <document + name="plugins" + title={config.name + " Plugins"} + xmlns={NS}> + <h1 tag="using-plugins">Using Plugins</h1> + + {body} + </document>.toXMLString(); + fileMap["plugins"] = function () ['text/xml;charset=UTF-8', help]; + + addTags("plugins", util.httpGet("liberator://help/plugins").responseXML); + }, + + /** + * Opens the help page containing the specified <b>topic</b> if it + * exists. + * + * @param {string} topic The help topic to open. + * @param {boolean} unchunked Whether to use the unchunked help page. + * @returns {string} + */ + help: function (topic, unchunked) { + if (!topic && !unchunked) { + let helpFile = options["helpfile"]; + if (helpFile in services.get("liberator:").FILE_MAP) + liberator.open("liberator://help/" + helpFile, { from: "help" }); + else + liberator.echomsg("Sorry, help file " + helpFile.quote() + " not found"); + return; + } + + let page = this.findHelp(topic, unchunked); + liberator.assert(page != null, "E149: Sorry, no help for " + topic); + + liberator.open("liberator://help/" + page, { from: "help" }); + if (options.get("activate").has("all", "help")) + content.postMessage("fragmentChange", "*"); + }, + + /** + * The map of global variables. + * + * These are set and accessed with the "g:" prefix. + */ + globalVariables: {}, + + loadPlugins: function () { + function sourceDirectory(dir) { + liberator.assert(dir.isReadable(), "E484: Can't open file " + dir.path); + + liberator.log("Sourcing plugin directory: " + dir.path + "...", 3); + dir.readDirectory(true).forEach(function (file) { + if (file.isFile() && /\.(js|vimp)$/i.test(file.path) && !(file.path in liberator.pluginFiles)) { + try { + io.source(file.path, false); + liberator.pluginFiles[file.path] = true; + } + catch (e) { + liberator.reportError(e); + } + } + else if (file.isDirectory()) + sourceDirectory(file); + }); + } + + let dirs = io.getRuntimeDirectories("plugin"); + + if (dirs.length == 0) { + liberator.log("No user plugin directory found", 3); + return; + } + + liberator.echomsg('Searching for "plugin/**/*.{js,vimp}" in "' + + [dir.path.replace(/.plugin$/, "") for ([, dir] in Iterator(dirs))].join(",") + '"', 2); + + dirs.forEach(function (dir) { + liberator.echomsg("Searching for \"" + (dir.path + "/**/*.{js,vimp}") + "\"", 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 <b>level</b> will be printed. If <b>msg</b> 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 = 0; + if (level == undefined) + level = 1; + + // options does not exist at the very beginning + if (modules.options) + verbose = options.getPref("extensions.liberator.loglevel", 0); + + if (level > verbose) + return; + + if (typeof msg == "object") + msg = util.objectToString(msg, false); + + services.get("console").logStringMessage(config.name.toLowerCase() + ": " + msg); + }, + + /** + * Opens one or more URLs. Returns true when load was initiated, or + * false on error. + * + * @param {string|string[]} urls Either a URL string or an array of URLs. + * The array can look like this: + * ["url1", "url2", "url3", ...] + * or: + * [["url1", postdata1], ["url2", postdata2], ...] + * @param {number|Object} where If ommited, CURRENT_TAB is assumed but NEW_TAB + * is set when liberator.forceNewTab is true. + * @param {boolean} force Don't prompt whether to open more than 20 + * tabs. + * @returns {boolean} + */ + open: function (urls, params, force) { + // convert the string to an array of converted URLs + // -> see util.stringToURLArray for more details + if (typeof urls == "string") { + // rather switch to the tab instead of opening a new url in case of "12: Tab Title" like "urls" + if (liberator.has("tabs")) { + let matches = urls.match(/^(\d+):/); + if (matches) { + tabs.select(parseInt(matches[1], 10) - 1, false); // make it zero-based + return; + } + } + + urls = util.stringToURLArray(urls); + } + + if (urls.length > 20 && !force) { + 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)) + liberator.open(urls, params, true); + }); + return true; + } + + let flags = 0; + params = params || {}; + if (params instanceof Array) + params = { where: params }; + + for (let [opt, flag] in Iterator({ replace: "REPLACE_HISTORY", hide: "BYPASS_HISTORY" })) + if (params[opt]) + flags |= Ci.nsIWebNavigation["LOAD_FLAGS_" + flag]; + + let where = params.where || liberator.CURRENT_TAB; + if ("from" in params && liberator.has("tabs")) { + if (!('where' in params) && options.get("newtab").has("all", params.from)) + where = liberator.NEW_BACKGROUND_TAB; + if (options.get("activate").has("all", params.from)) { + if (where == liberator.NEW_TAB) + where = liberator.NEW_BACKGROUND_TAB; + else if (where == liberator.NEW_BACKGROUND_TAB) + where = liberator.NEW_TAB; + } + } + + if (urls.length == 0) + return false; + + let browser = window.getBrowser(); + + function open(urls, where) { + let url = Array.concat(urls)[0]; + let postdata = Array.concat(urls)[1]; + + // decide where to load the first url + switch (where) { + case liberator.CURRENT_TAB: + browser.loadURIWithFlags(url, flags, null, null, postdata); + break; + + case liberator.NEW_BACKGROUND_TAB: + case liberator.NEW_TAB: + if (!liberator.has("tabs")) + return open(urls, liberator.NEW_WINDOW); + + options.withContext(function () { + options.setPref("browser.tabs.loadInBackground", true); + browser.loadOneTab(url, null, null, postdata, where == liberator.NEW_BACKGROUND_TAB); + }); + break; + + case liberator.NEW_WINDOW: + window.open(); + let win = services.get("windowMediator").getMostRecentWindow("navigator:browser"); + win.loadURI(url, null, postdata); + browser = win.getBrowser(); + break; + + default: + throw Error("Invalid 'where' directive in liberator.open(...)"); + } + } + + if (liberator.forceNewTab) + where = liberator.NEW_TAB; + else if (liberator.forceNewWindow) + where = liberator.NEW_WINDOW; + else if (!where) + where = liberator.CURRENT_TAB; + + for (let [, url] in Iterator(urls)) { + open(url, where); + where = liberator.NEW_BACKGROUND_TAB; + } + + return true; + }, + + pluginFiles: {}, + + // namespace for plugins/scripts. Actually (only) the active plugin must/can set a + // v.plugins.mode = <str> string to show on v.modes.CUSTOM + // v.plugins.stop = <func> hooked on a v.modes.reset() + // v.plugins.onEvent = <func> function triggered, on keypresses (unless <esc>) (see events.js) + plugins: plugins, + + /** + * 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) { + // TODO: Use safeSetPref? + if (saveSession) + options.setPref("browser.startup.page", 3); // start with saved session + else + options.setPref("browser.startup.page", 1); // start with default homepage session + + if (force) + services.get("appStartup").quit(Ci.nsIAppStartup.eForceQuit); + else + window.goQuitApplication(); + }, + + /* + * 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) { + if (!condition) + throw new FailedAssertion(message); + }, + + /** + * 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 (func, self) { + try { + return func.apply(self || this, Array.slice(arguments, 2)); + } + catch (e) { + if (e instanceof FailedAssertion) + liberator.echoerr(e.message); + } + }, + + /** + * Reports an error to both the console and the host application's + * Error Console. + * + * @param {Object} error The error object. + */ + reportError: function (error) { + if (Cu.reportError) + Cu.reportError(error); + + try { + let obj = { + toString: function () String(error), + stack: <>{String.replace(error.stack || Error().stack, /^/mg, "\t")}</> + }; + for (let [k, v] in Iterator(error)) { + if (!(k in obj)) + obj[k] = v; + } + if (liberator.storeErrors) { + let errors = storage.newArray("errors", { store: false }); + errors.toString = function () [String(v[0]) + "\n" + v[1] for ([k, v] in this)].join("\n\n"); + errors.push([new Date, obj + obj.stack]); + } + liberator.dump(String(error)); + liberator.dump(obj); + liberator.dump(""); + } + catch (e) { window.dump(e) } + }, + + /** + * Restart the host application. + */ + restart: function () { + // notify all windows that an application quit has been requested. + var cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(Ci.nsISupportsPRBool); + services.get("observer").notifyObservers(cancelQuit, "quit-application-requested", null); + + // something aborted the quit process. + if (cancelQuit.data) + return; + + // notify all windows that an application quit has been granted. + services.get("observer").notifyObservers(null, "quit-application-granted", null); + + // enumerate all windows and call shutdown handlers + let windows = services.get("windowMediator").getEnumerator(null); + while (windows.hasMoreElements()) { + let win = windows.getNext(); + if (("tryToClose" in win) && !win.tryToClose()) + return; + } + services.get("appStartup").quit(Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit); + }, + + /** + * Parses a Liberator command-line string i.e. the value of the + * -liberator command-line option. + * + * @param {string} cmdline The string to parse for command-line + * options. + * @returns {Object} + * @see Commands#parseArgs + */ + parseCommandLine: function (cmdline) { + const options = [ + [["+u"], commands.OPTIONS_STRING], + [["++noplugin"], commands.OPTIONS_NOARG], + [["++cmd"], commands.OPTIONS_STRING, null, null, true], + [["+c"], commands.OPTIONS_STRING, null, null, true] + ]; + return commands.parseArgs(cmdline, options, "*"); + }, + + sleep: function (delay) { + let mainThread = services.get("threadManager").mainThread; + + let end = Date.now() + delay; + while (Date.now() < end) + mainThread.processNextEvent(true); + return true; + }, + + callInMainThread: function (callback, self) { + let mainThread = services.get("threadManager").mainThread; + if (!services.get("threadManager").isMainThread) + mainThread.dispatch({ run: callback.call(self) }, mainThread.DISPATCH_NORMAL); + else + callback.call(self); + }, + + threadYield: function (flush, interruptable) { + let mainThread = services.get("threadManager").mainThread; + liberator.interrupted = false; + do { + mainThread.processNextEvent(!flush); + if (liberator.interrupted) + throw new Error("Interrupted"); + } + while (flush === true && mainThread.hasPendingEvents()); + }, + + variableReference: function (string) { + if (!string) + return [null, null, null]; + + let matches = string.match(/^([bwtglsv]):(\w+)/); + if (matches) { // Variable + // Other variables should be implemented + if (matches[1] == "g") { + if (matches[2] in this.globalVariables) + return [this.globalVariables, matches[2], matches[1]]; + else + return [null, matches[2], matches[1]]; + } + } + else { // Global variable + if (string in this.globalVariables) + return [this.globalVariables, string, "g"]; + else + return [null, string, "g"]; + } + }, + + /** + * @property {Window[]} Returns an array of all the host application's + * open windows. + */ + get windows() { + let windows = []; + let enumerator = services.get("windowMediator").getEnumerator("navigator:browser"); + while (enumerator.hasMoreElements()) + windows.push(enumerator.getNext()); + + return windows; } +}, { // initially hide all GUI elements, they are later restored unless the user // has :set go= or something similar in his config - function hideGUI() - { + hideGUI: function () { let guioptions = config.guioptions; - for (let option in guioptions) - { + for (let option in guioptions) { guioptions[option].forEach(function (elem) { - try - { + try { document.getElementById(elem).collapsed = true; } catch (e) {} }); } - } + }, // return the platform normalized to Vim values - function getPlatformFeature() - { + getPlatformFeature: function () { let platform = navigator.platform; return /^Mac/.test(platform) ? "MacUnix" : platform == "Win32" ? "Win32" : "Unix"; - } + }, // TODO: move this - function getMenuItems() - { - function addChildren(node, parent) - { - for (let [, item] in Iterator(node.childNodes)) - { + 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 - { + && !/rdf:http:/.test(item.getAttribute("label"))) { // FIXME item.fullMenuPath = parent + item.getAttribute("label"); items.push(item); } - else - { + else { let path = parent; if (item.localName == "menu") path += item.getAttribute("label") + "."; @@ -117,24 +1053,19 @@ const liberator = (function () //{{{ let items = []; addChildren(document.getElementById(config.guioptions["m"][1]), ""); return items; - } + }, // show a usage index either in the MOW or as a full help page - function showHelpIndex(tag, items, inMow) - { + showHelpIndex: function (tag, items, inMow) { if (inMow) liberator.echo(template.usage(items), commandline.FORCE_MULTILINE); else liberator.help(tag); - } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + }, +}, { // Only general options are added here, which are valid for all Liberator extensions - registerObserver("load_options", function () { - + options: function () { options.add(["errorbells", "eb"], "Ring the bell when an error message is displayed", "boolean", false); @@ -146,13 +1077,10 @@ const liberator = (function () //{{{ const groups = { config: { opts: config.guioptions, - setter: function (opts) - { - for (let [opt, [, ids]] in Iterator(this.opts)) - { + setter: function (opts) { + for (let [opt, [, ids]] in Iterator(this.opts)) { ids.map(function (id) document.getElementById(id)) - .forEach(function (elem) - { + .forEach(function (elem) { if (elem) elem.collapsed = (opts.indexOf(opt) == -1); }); @@ -160,10 +1088,16 @@ const liberator = (function () //{{{ } }, scroll: { - 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); + 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 + "]"); if (class.length) @@ -180,8 +1114,7 @@ const liberator = (function () //{{{ n: ["Tab number", highlight.selector("TabNumber")], N: ["Tab number over icon", highlight.selector("TabIconNumber")] }, - setter: function (opts) - { + setter: function (opts) { const self = this; let classes = [v[1] for ([k, v] in Iterator(this.opts)) if (opts.indexOf(k) < 0)]; let css = classes.length ? classes.join(",") + "{ display: none; }" : ""; @@ -194,24 +1127,20 @@ const liberator = (function () //{{{ options.add(["fullscreen", "fs"], "Show the current window fullscreen", - "boolean", false, - { + "boolean", false, { setter: function (value) window.fullScreen = value, getter: function () window.fullScreen }); options.add(["guioptions", "go"], "Show or hide certain GUI elements like the menu or toolbar", - "charlist", config.defaults.guioptions || "", - { - setter: function (value) - { + "charlist", config.defaults.guioptions || "", { + setter: function (value) { for (let [, group] in Iterator(groups)) group.setter(value); return value; }, - completer: function (context) - { + completer: function (context) { let opts = [v.opts for ([k, v] in Iterator(groups))]; opts = opts.map(function (opt) [[k, v[0]] for ([k, v] in Iterator(opt))]); return util.Array.flatten(opts); @@ -232,26 +1161,22 @@ const liberator = (function () //{{{ "Change the title of the window", "string", config.defaults.titlestring || config.hostApplication, { - setter: function (value) - { + setter: function (value) { let win = document.documentElement; - function updateTitle(old, current) - { + function updateTitle(old, current) { document.title = document.title.replace(RegExp("(.*)" + util.escapeRegex(old)), "$1" + current); } // TODO: remove this FF3.5 test when we no longer support 3.0 // : make this a config feature - if (services.get("privateBrowsing")) - { + if (services.get("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.get("privateBrowsing").privateBrowsingEnabled) - { + if (services.get("privateBrowsing").privateBrowsingEnabled) { updateTitle(oldValue + suffix, value + suffix); return value; } @@ -273,27 +1198,20 @@ const liberator = (function () //{{{ "Use visual bell instead of beeping on errors", "boolean", false, { - setter: function (value) - { + setter: function (value) { options.safeSetPref("accessibility.typeaheadfind.enablesound", !value, "See 'visualbell' option"); return value; } }); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - registerObserver("load_mappings", function () { + }, + mappings: function () { mappings.add(modes.all, ["<F1>"], "Open the help page", function () { liberator.help(); }); - if (liberator.has("session")) - { + if (liberator.has("session")) { mappings.add([modes.NORMAL], ["ZQ"], "Quit and don't save the session", function () { liberator.quit(false); }); @@ -302,20 +1220,12 @@ const liberator = (function () //{{{ mappings.add([modes.NORMAL], ["ZZ"], "Quit and save the session", function () { liberator.quit(true); }); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var toolbox; - - registerObserver("load_commands", function () { + }, + commands: function () { commands.add(["addo[ns]"], "Manage available Extensions and Themes", - function () - { + function () { liberator.open("chrome://mozapps/content/extensions/extensions.xul", { from: "addons" }); }, @@ -328,19 +1238,15 @@ const liberator = (function () //{{{ commands.add(["dia[log]"], "Open a " + config.name + " dialog", - function (args) - { + function (args) { let arg = args[0]; - try - { + try { // TODO: why are these sorts of properties arrays? --djk let dialogs = config.dialogs; - for (let [, dialog] in Iterator(dialogs)) - { - if (util.compareIgnoreCase(arg, dialog[0]) == 0) - { + for (let [, dialog] in Iterator(dialogs)) { + if (util.compareIgnoreCase(arg, dialog[0]) == 0) { dialog[2](); return; } @@ -348,16 +1254,13 @@ const liberator = (function () //{{{ liberator.echoerr("E475: Invalid argument: " + arg); } - catch (e) - { + catch (e) { liberator.echoerr("Error opening '" + arg + "': " + e); } - }, - { + }, { argCount: "1", bang: true, - completer: function (context) - { + completer: function (context) { context.ignoreCase = true; return completion.dialog(context); } @@ -365,21 +1268,18 @@ const liberator = (function () //{{{ commands.add(["em[enu]"], "Execute the specified menu item from the command line", - function (args) - { + function (args) { let arg = args.literalArg; - let items = getMenuItems(); + let items = Liberator.getMenuItems(); liberator.assert(items.some(function (i) i.fullMenuPath == arg), "E334: Menu not found: " + arg); - for (let [, item] in Iterator(items)) - { + for (let [, item] in Iterator(items)) { if (item.fullMenuPath == arg) item.doCommand(); } - }, - { + }, { argCount: "1", completer: function (context) completion.menuItem(context), literal: 0 @@ -392,36 +1292,30 @@ const liberator = (function () //{{{ // E.g. :execute "source" io.getRCFile().path // Need to fix commands.parseArgs which currently strips the quotes // from quoted args - function (args) - { - try - { + function (args) { + try { let cmd = liberator.eval(args.string); liberator.execute(cmd, null, true); } - catch (e) - { + catch (e) { liberator.echoerr(e); } }); commands.add(["exta[dd]"], "Install an extension", - function (args) - { + function (args) { let file = io.File(args[0]); if (file.exists() && file.isReadable() && file.isFile()) services.get("extensionManager").installItemFromFile(file, "app-profile"); - else - { + else { if (file.exists() && file.isDirectory()) liberator.echomsg("Cannot install a directory: \"" + file.path + "\"", 0); liberator.echoerr("E484: Can't open file " + file.path); } - }, - { + }, { argCount: "1", completer: function (context) { context.filters.push(function ({ item: f }) f.isDirectory() || /\.xpi$/.test(f.leafName)); @@ -451,15 +1345,13 @@ const liberator = (function () //{{{ ].forEach(function (command) { commands.add([command.name], command.description, - function (args) - { + function (args) { let name = args[0]; function action(e) { services.get("extensionManager")[command.action](e.id); }; if (args.bang) liberator.extensions.forEach(function (e) { action(e); }); - else - { + else { liberator.assert(name, "E471: Argument required"); // XXX let extension = liberator.getExtension(name); @@ -468,12 +1360,10 @@ const liberator = (function () //{{{ else liberator.echoerr("E474: Invalid argument"); } - }, - { + }, { argCount: "?", // FIXME: should be "1" bang: true, - completer: function (context) - { + completer: function (context) { completion.extension(context); if (command.filter) context.filters.push(command.filter); @@ -484,8 +1374,7 @@ const liberator = (function () //{{{ commands.add(["exto[ptions]", "extp[references]"], "Open an extension's preference dialog", - function (args) - { + function (args) { let extension = liberator.getExtension(args[0]); liberator.assert(extension && extension.options, "E474: Invalid argument"); @@ -493,12 +1382,10 @@ const liberator = (function () //{{{ window.openDialog(extension.options, "_blank", "chrome"); else liberator.open(extension.options, { from: "extoptions" }); - }, - { + }, { argCount: "1", bang: true, - completer: function (context) - { + completer: function (context) { completion.extension(context); context.filters.push(function ({ item: e }) e.options); }, @@ -508,13 +1395,11 @@ const liberator = (function () //{{{ // TODO: maybe indicate pending status too? commands.add(["extens[ions]"], "List available extensions", - function (args) - { + function (args) { let filter = args[0] || ""; let extensions = liberator.extensions.filter(function (e) e.name.indexOf(filter) >= 0); - if (extensions.length > 0) - { + if (extensions.length > 0) { let list = template.tabular( ["Name", "Version", "Status", "Description"], [], ([template.icon(e, e.name), @@ -526,8 +1411,7 @@ const liberator = (function () //{{{ commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); } - else - { + else { if (filter) liberator.echoerr("Exxx: No extension matching \"" + filter + "\""); else @@ -538,8 +1422,7 @@ const liberator = (function () //{{{ commands.add(["exu[sage]"], "List all Ex commands with a short description", - function (args) { showHelpIndex("ex-cmd-index", commands, args.bang); }, - { + function (args) { Liberator.showHelpIndex("ex-cmd-index", commands, args.bang); }, { argCount: "0", bang: true }); @@ -548,8 +1431,7 @@ const liberator = (function () //{{{ { name: "h[elp]", description: "Open the help page" - }, - { + }, { name: "helpa[ll]", description: "Open the single unchunked help page" } @@ -558,13 +1440,11 @@ const liberator = (function () //{{{ commands.add([command.name], command.description, - function (args) - { + function (args) { liberator.assert(!args.bang, "E478: Don't panic!"); liberator.help(args.literalArg, unchunked); - }, - { + }, { argCount: "?", bang: true, completer: function (context) completion.help(context, unchunked), @@ -572,45 +1452,22 @@ const liberator = (function () //{{{ }); }); - commands.add(["exporth[elp]"], - "Exports " + config.name + "'s help system to the named zip file", - function (args) - { - liberator.echomsg("Exporting help to " + args[0].quote() + ". Please wait..."); - util.exportHelp(args[0]); - liberator.echomsg("Help exported to " + args[0].quote() + "."); - }, - { - argCount: "1", - completer: function (context) { - context.filters.push(function ({ item: f }) f.isDirectory() || /\.zip/.test(f.leafName)); - completion.file(context); - }, - literal: 0 - }); - commands.add(["javas[cript]", "js"], "Run a JavaScript command through eval()", - function (args) - { - if (args.bang) // open JavaScript console - { + function (args) { + if (args.bang) { // open JavaScript console liberator.open("chrome://global/content/console.xul", { from: "javascript" }); } - else - { - try - { + else { + try { liberator.eval(args.string); } - catch (e) - { + catch (e) { liberator.echoerr(e); } } - }, - { + }, { bang: true, completer: function (context) completion.javascript(context), hereDoc: true, @@ -632,22 +1489,19 @@ const liberator = (function () //{{{ commands.add(["optionu[sage]"], "List all options with a short description", - function (args) { showHelpIndex("option-index", options, args.bang); }, - { + function (args) { Liberator.showHelpIndex("option-index", options, args.bang); }, { argCount: "0", bang: true }); commands.add(["q[uit]"], liberator.has("tabs") ? "Quit current tab" : "Quit application", - function (args) - { + function (args) { if (liberator.has("tabs")) tabs.remove(getBrowser().mCurrentTab, 1, false, 1); else liberator.quit(false, args.bang); - }, - { + }, { argCount: "0", bang: true }); @@ -657,26 +1511,21 @@ const liberator = (function () //{{{ function () { liberator.restart(); }, { argCount: "0" }); - toolbox = document.getElementById("navigator-toolbox"); - if (toolbox) - { + var toolbox = document.getElementById("navigator-toolbox"); + if (toolbox) { function findToolbar(name) util.evaluateXPath( "./*[@toolbarname=" + util.escapeString(name, "'") + "]", document, toolbox).snapshotItem(0); - let tbcmd = function (names, desc, action, filter) - { + let tbcmd = function (names, desc, action, filter) { commands.add(names, desc, - function (args) - { + function (args) { let toolbar = findToolbar(args[0]); liberator.assert(toolbar, "E474: Invalid argument"); action(toolbar); - }, - { + }, { argcount: "1", - completer: function (context) - { + completer: function (context) { completion.toolbar(context) if (filter) context.filters.push(filter); @@ -697,8 +1546,7 @@ const liberator = (function () //{{{ commands.add(["time"], "Profile a piece of code or run a command multiple times", - function (args) - { + function (args) { let count = args.count; let special = args.bang; args = args.string; @@ -708,15 +1556,12 @@ const liberator = (function () //{{{ else method = liberator.eval("(function () {" + args + "})"); - try - { - if (count > 1) - { + try { + if (count > 1) { let each, eachUnits, totalUnits; let total = 0; - for (let i in util.interruptibleRange(0, count, 500)) - { + for (let i in util.interruptibleRange(0, count, 500)) { let now = Date.now(); method(); total += Date.now() - now; @@ -725,19 +1570,16 @@ const liberator = (function () //{{{ if (special) return; - if (total / count >= 100) - { + if (total / count >= 100) { each = total / 1000.0 / count; eachUnits = "sec"; } - else - { + else { each = total / count; eachUnits = "msec"; } - if (total >= 100) - { + if (total >= 100) { total = total / 1000.0; totalUnits = "sec"; } @@ -755,8 +1597,7 @@ const liberator = (function () //{{{ </table>); commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); } - else - { + else { let beforeTime = Date.now(); method(); @@ -771,16 +1612,13 @@ const liberator = (function () //{{{ liberator.echo("Total time: " + (afterTime - beforeTime) + " msec"); } } - catch (e) - { + catch (e) { liberator.echoerr(e); } - }, - { + }, { argCount: "+", bang: true, - completer: function (context) - { + completer: function (context) { if (/^:/.test(context.filter)) return completion.ex(context); else @@ -792,25 +1630,21 @@ const liberator = (function () //{{{ commands.add(["verb[ose]"], "Execute a command with 'verbose' set", - function (args) - { + function (args) { let vbs = options.get("verbose"); let value = vbs.value; let setFrom = vbs.setFrom; - try - { + try { vbs.set(args.count > -1 ? args.count : 1); vbs.setFrom = null; liberator.execute(args[0], null, true); } - finally - { + finally { vbs.set(value); vbs.setFrom = setFrom; } - }, - { + }, { argCount: "+", completer: function (context) completion.ex(context), count: true, @@ -819,33 +1653,26 @@ const liberator = (function () //{{{ commands.add(["ve[rsion]"], "Show version information", - function (args) - { + function (args) { if (args.bang) liberator.open("about:"); else liberator.echo(template.commandOutput(<>{config.name} {liberator.version} running on:<br/>{navigator.userAgent}</>)); - }, - { + }, { argCount: "0", bang: true }); commands.add(["viu[sage]"], "List all mappings with a short description", - function (args) { showHelpIndex("normal-index", mappings, args.bang); }, - { + function (args) { Liberator.showHelpIndex("normal-index", mappings, args.bang); }, { argCount: "0", bang: true }); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + }, - registerObserver("load_completion", function () { + completion: function () { completion.setFunctionCompleter(services.get, [function () services.services]); completion.setFunctionCompleter(services.create, [function () [[c, ""] for (c in services.classes)]]); @@ -888,1236 +1715,124 @@ const liberator = (function () //{{{ context.keys = { text: function (win) liberator.windows.indexOf(win) + 1, description: function (win) win.document.title }; context.completions = liberator.windows; }; - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - modules: modules, - - /** - * @property {number} The current main mode. - * @see modes#mainModes - */ - get mode() modes.main, - set mode(value) modes.main = value, - - get menuItems() getMenuItems(), - - /** @property {Element} The currently focused element. */ - get focus() document.commandDispatcher.focusedElement, - - get extensions() - { - const rdf = services.get("rdf"); - const extensionManager = services.get("extensionManager"); - - let extensions = extensionManager.getItemList(Ci.nsIUpdateItem.TYPE_EXTENSION, {}); - - function getRdfProperty(item, property) - { - let resource = rdf.GetResource("urn:mozilla:item:" + item.id); - let value = ""; - - if (resource) - { - let target = extensionManager.datasource.GetTarget(resource, - rdf.GetResource("http://www.mozilla.org/2004/em-rdf#" + property), true); - if (target && target instanceof Ci.nsIRDFLiteral) - value = target.Value; - } - - return value; - } - - //const Extension = new Struct("id", "name", "description", "icon", "enabled", "version"); - return extensions.map(function (e) ({ - id: e.id, - name: e.name, - description: getRdfProperty(e, "description"), - enabled: getRdfProperty(e, "isDisabled") != "true", - icon: e.iconURL, - options: getRdfProperty(e, "optionsURL"), - version: e.version - })); - }, - - getExtension: function (name) this.extensions.filter(function (e) e.name == name)[0], - - // Global constants - CURRENT_TAB: [], - NEW_TAB: [], - NEW_BACKGROUND_TAB: [], - NEW_WINDOW: [], - - forceNewTab: false, - forceNewWindow: false, - - /** @property {string} The Liberator version string. */ - version: "###VERSION### (created: ###DATE###)", // these VERSION and DATE tokens are replaced by the Makefile - - // NOTE: services.get("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. - /** @property {string} The name of the current user profile. */ - profileName: services.get("directory").get("ProfD", Ci.nsIFile).leafName.replace(/^.+?\./, ""), - - /** - * @property {Object} The map of command-line options. These are - * specified in the argument to the host application's -liberator - * option. E.g. $ firefox -liberator '+u=tempRcFile ++noplugin' - * Supported options: - * +u=RCFILE Use RCFILE instead of .vimperatorrc. - * ++noplugin Don't load plugins. - */ - 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: registerObserver, - - unregisterObserver: function (type, callback) - { - if (type in observers) - observers[type] = observers[type].filter(function (c) c != callback); - }, - - // TODO: "zoom": if the zoom value of the current buffer changed - triggerObserver: function (type) - { - let args = Array.slice(arguments, 1); - for (let [, func] in Iterator(observers[type] || [])) - func.apply(null, args); - }, - - /** - * 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 () - { - // FIXME: popups clear the command line - if (options["visualbell"]) - { - // flash the visual bell - let popup = document.getElementById("liberator-visualbell"); - let win = config.visualbellWindow; - let rect = win.getBoundingClientRect(); - let width = rect.right - rect.left; - let height = rect.bottom - rect.top; - - // NOTE: this doesn't seem to work in FF3 with full box dimensions - popup.openPopup(win, "overlap", 1, 1, false, false); - popup.sizeTo(width - 2, height - 2); - setTimeout(function () { popup.hidePopup(); }, 20); - } - else - { - let soundService = Cc["@mozilla.org/sound;1"].getService(Ci.nsISound); - soundService.beep(); - } - return false; // so you can do: if (...) return liberator.beep(); - }, - - /** - * Creates a new thread. - */ - newThread: function () services.get("threadManager").newThread(0), - - /** - * Calls a function asynchronously on a new thread. - * - * @param {nsIThread} thread The thread to call the function on. If no - * thread is specified a new one is created. - * @optional - * @param {Object} self The 'this' object used when executing the - * function. - * @param {function} func The function to execute. - * - */ - callAsync: function (thread, self, func) - { - thread = thread || services.get("threadManager").newThread(0); - thread.dispatch(new Runnable(self, func, Array.slice(arguments, 3)), thread.DISPATCH_NORMAL); - }, - - /** - * Calls a function synchronously on a new thread. - * - * NOTE: Be sure to call GUI related methods like alert() or dump() - * ONLY in the main thread. - * - * @param {nsIThread} thread The thread to call the function on. If no - * thread is specified a new one is created. - * @optional - * @param {function} func The function to execute. - */ - callFunctionInThread: function (thread, func) - { - thread = thread || services.get("threadManager").newThread(0); - - // DISPATCH_SYNC is necessary, otherwise strange things will happen - thread.dispatch(new Runnable(null, func, Array.slice(arguments, 2)), thread.DISPATCH_SYNC); - }, - - /** - * Prints a message to the console. If <b>msg</b> is an object it is - * pretty printed. - * - * NOTE: the "browser.dom.window.dump.enabled" preference needs to be - * set. - * - * @param {string|Object} msg The message to print. - */ - dump: function () - { - let msg = Array.map(arguments, function (msg) { - if (typeof msg == "object") - msg = util.objectToString(msg); - return msg; - }).join(", "); - msg = String.replace(msg, /\n?$/, "\n"); - window.dump(msg.replace(/^./gm, ("config" in modules && config.name.toLowerCase()) + ": $&")); - }, - - /** - * Dumps a stack trace to the console. - * - * @param {string} msg The trace message. - * @param {number} frames The number of frames to print. - */ - dumpStack: function (msg, frames) - { - let stack = Error().stack.replace(/(?:.*\n){2}/, ""); - if (frames != null) - [stack] = stack.match(RegExp("(?:.*\n){0," + frames + "}")); - liberator.dump((msg || "Stack") + "\n" + stack); - }, - - /** - * 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) - { - if (!condition) - throw new FailedAssertion(message); - }, - - /** - * Outputs a plain message to the command line. - * - * @param {string} str The message to output. - * @param {number} flags These control the multiline message behaviour. - * See {@link CommandLine#echo}. - */ - echo: function (str, flags) - { - commandline.echo(str, commandline.HL_NORMAL, flags); - }, - - // TODO: Vim replaces unprintable characters in echoerr/echomsg - /** - * Outputs an error message to the command line. - * - * @param {string} str The message to output. - * @param {number} flags These control the multiline message behaviour. - * See {@link CommandLine#echo}. - */ - echoerr: function (str, flags) - { - flags |= commandline.APPEND_TO_MESSAGES; - - if (typeof str == "object" && "echoerr" in str) - str = str.echoerr; - else if (str instanceof Error) - str = str.fileName + ":" + str.lineNumber + ": " + str; - - if (options["errorbells"]) - liberator.beep(); - - commandline.echo(str, commandline.HL_ERRORMSG, flags); - }, - - // 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 multiline message behaviour. - * See {@link CommandLine#echo}. - */ - echomsg: function (str, verbosity, flags) - { - // TODO: is there a reason for this? --djk - // yes, it doesn't show the MOW on startup if you have e.g. some qmarks in your vimperatorrc. - // Feel free to add another flag like DONT_OPEN_MULTILINE if really needed --mst - // - // But it's _supposed_ to show the MOW on startup when there are - // messages, surely? As far as I'm concerned it essentially works - // exactly as it should with the DISALLOW_MULTILINE flag removed. - // Sending N messages to the command line in a row and having them - // overwrite each other is completely broken. I also think many of - // those messages like "Added quick mark" are plain silly but if - // you don't like them you can set verbose=0, or use :silent when - // someone adds it. I reckon another flag and 'class' of messages - // is just going to unnecessarily complicate things. --djk - flags |= commandline.APPEND_TO_MESSAGES | commandline.DISALLOW_MULTILINE; - - if (verbosity == null) - verbosity = 0; // verbosity level is exclusionary - - if (options["verbose"] >= verbosity) - commandline.echo(str, commandline.HL_INFOMSG, flags); - }, - - /** - * Loads and executes the script referenced by <b>uri</b> in the scope - * of the <b>context</b> 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) - { - XML.ignoreWhiteSpace = false; - XML.prettyPrinting = false; - services.get("subscriptLoader").loadSubScript(uri, context); - }, - - eval: function (str, context) - { - try - { - if (!context) - context = userContext; - context[EVAL_ERROR] = null; - context[EVAL_STRING] = str; - context[EVAL_RESULT] = null; - this.loadScript("chrome://liberator/content/eval.js", context); - if (context[EVAL_ERROR]) - { - try - { - context[EVAL_ERROR].fileName = io.sourcing.file; - context[EVAL_ERROR].lineNumber += io.sourcing.line; - } - catch (e) {} - throw context[EVAL_ERROR]; - } - return context[EVAL_RESULT]; - } - finally - { - delete context[EVAL_ERROR]; - delete context[EVAL_RESULT]; - delete context[EVAL_STRING]; - } - }, - - // partial sixth level expression evaluation - // TODO: what is that really needed for, and where could it be used? - // Or should it be removed? (c) Viktor - // Better name? See other liberator.eval() - // I agree, the name is confusing, and so is the - // description --Kris - evalExpression: function (string) - { - string = string.toString().replace(/^\s*/, "").replace(/\s*$/, ""); - let matches = string.match(/^&(\w+)/); - - if (matches) - { - let opt = this.options.get(matches[1]); - - if (!opt) - return void this.echoerr("E113: Unknown option: " + matches[1]); - - let type = opt.type; - let value = opt.getter(); - - if (type != "boolean" && type != "number") - value = value.toString(); - - return value; - } - // String - else if (matches = string.match(/^(['"])([^\1]*?[^\\]?)\1/)) - { - if (matches) - return matches[2].toString(); - else - return void this.echoerr("E115: Missing quote: " + string); - } - // Number - else if (matches = string.match(/^(\d+)$/)) - return parseInt(matches[1], 10); - - let reference = this.variableReference(string); - - if (!reference[0]) - this.echoerr("E121: Undefined variable: " + string); - else - return reference[0][reference[1]]; + }, + load: function () { + config.features.push(Liberator.getPlatformFeature()); + + try { + let infoPath = services.create("file"); + infoPath.initWithPath(File.expandPath(IO.runtimePath.replace(/,.*/, ""))); + infoPath.append("info"); + infoPath.append(liberator.profileName); + storage.infoPath = infoPath; + } + catch (e) { + liberator.reportError(e); + } - return; - }, - - /** - * 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; + config.init(); - modifiers = modifiers || {}; + liberator.triggerObserver("load"); - let err = null; - let [count, cmd, special, args] = commands.parseCommand(str.replace(/^'(.*)'$/, "$1")); - let command = commands.get(cmd); + liberator.log("All modules loaded", 3); - if (command === null) - { - err = "E492: Not a " + config.name.toLowerCase() + " command: " + str; - liberator.focusContent(); - } - else if (command.action === null) - err = "E666: Internal error: command.action === null"; // TODO: need to perform this test? -- djk - else if (count != -1 && !command.count) - err = "E481: No range allowed"; - else if (special && !command.bang) - err = "E477: No ! allowed"; - - liberator.assert(!err, err); - if (!silent) - commandline.command = str.replace(/^\s*:\s*/, ""); - - command.execute(args, special, count, modifiers); - }, - - /** - * Focuses the content window. - * - * @param {boolean} clearFocusedElement Remove focus from any focused - * element. - */ - focusContent: function (clearFocusedElement) - { - if (window != services.get("windowWatcher").activeWindow) - return; + services.add("commandLineHandler", "@mozilla.org/commandlinehandler/general-startup;1?type=" + config.name.toLowerCase(), + Ci.nsICommandLineHandler); - let elem = config.mainWidget || window.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 if (this.has("tabs")) - { - let frame = tabs.localStore.focusedFrame; - if (frame && frame.top == window.content) - elem = frame; - } - } - catch (e) {} - - if (clearFocusedElement && liberator.focus) - liberator.focus.blur(); - if (elem && elem != liberator.focus) - elem.focus(); - }, - - /** - * Returns whether this Liberator extension supports <b>feature</b>. - * - * @param {string} feature The feature name. - * @returns {boolean} - */ - has: function (feature) config.features.indexOf(feature) >= 0, - - /** - * Returns whether the host application has the specified extension - * installed. - * - * @param {string} name The extension name. - * @returns {boolean} - */ - hasExtension: function (name) - { - let extensions = services.get("extensionManager").getItemList(Ci.nsIUpdateItem.TYPE_EXTENSION, {}); - return extensions.some(function (e) e.name == name); - }, - - /** - * Returns the URL of the specified help <b>topic</b> if it exists. - * - * @param {string} topic The help topic to lookup. - * @param {boolean} unchunked Whether to search the unchunked help page. - * @returns {string} - */ - findHelp: function (topic, unchunked) - { - if (topic in services.get("liberator:").FILE_MAP) - return topic; - unchunked = !!unchunked; - let items = completion._runCompleter("help", topic, null, unchunked).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; - } + let commandline = services.get("commandLineHandler").optionValue; + if (commandline) { + let args = liberator.parseCommandLine(commandline); + liberator.commandLineOptions.rcFile = args["+u"]; + liberator.commandLineOptions.noPlugins = "++noplugin" in args; + liberator.commandLineOptions.postCommands = args["+c"]; + liberator.commandLineOptions.preCommands = args["++cmd"]; + liberator.dump("Processing command-line option: " + commandline); + } - if (partialMatch) - return format(partialMatch); - return null; - }, - - /** - * @private - * Initialize the help system. - */ - initHelp: function () - { - let namespaces = [config.name.toLowerCase(), "liberator"]; - services.get("liberator:").init({}); - let tagMap = services.get("liberator:").HELP_TAGS; - let fileMap = services.get("liberator:").FILE_MAP; - let overlayMap = services.get("liberator:").OVERLAY_MAP; - function XSLTProcessor(sheet) - { - let xslt = Cc["@mozilla.org/document-transformer;1?type=xslt"].createInstance(Ci.nsIXSLTProcessor); - xslt.importStylesheet(util.httpGet(sheet).responseXML); - return xslt; - } + liberator.log("Command-line options: " + util.objectToString(liberator.commandLineOptions), 3); - function findHelpFile(file) - { - let result = []; - for (let [, namespace] in Iterator(namespaces)) - { - let url = ["chrome://", namespace, "/locale/", 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; - } - function addTags(file, doc) - { - doc = XSLT.transformToDocument(doc); - for (let elem in util.evaluateXPath("//xhtml:a/@id", doc)) - tagMap[elem.value] = file; - } + // first time intro message + const firstTime = "extensions." + config.name.toLowerCase() + ".firsttime"; + if (options.getPref(firstTime, true)) { + setTimeout(function () { + liberator.help(); + options.setPref(firstTime, false); + }, 1000); + } - const XSLT = XSLTProcessor("chrome://liberator/content/help.xsl"); + // always start in normal mode + modes.reset(); - tagMap.all = "all"; - let files = findHelpFile("all").map(function (doc) - [f.value for (f in util.evaluateXPath( - "//liberator:include/@href", doc))]); + // TODO: we should have some class where all this guioptions stuff fits well + Liberator.hideGUI(); - util.Array.flatten(files).map(function (file) { - findHelpFile(file).forEach(function (doc) { - addTags(file, doc); - }); + if (liberator.commandLineOptions.preCommands) + liberator.commandLineOptions.preCommands.forEach(function (cmd) { + liberator.execute(cmd); }); - XML.ignoreWhiteSpace = false; - XML.prettyPrinting = false; - XML.prettyPrinting = true; // Should be false, but ignoreWhiteSpace=false doesn't work correctly. This is the lesser evil. - XML.prettyIndent = 4; - let body = XML(); - for (let [, context] in Iterator(plugins.contexts)) - if (context.INFO instanceof XML) - 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="chrome://liberator/content/help.xsl"?>\n' + - '<!DOCTYPE document SYSTEM "chrome://liberator/content/liberator.dtd">' + - <document - name="plugins" - title={config.name + " Plugins"} - xmlns="http://vimperator.org/namespaces/liberator"> - <h1 tag="using-plugins">Using Plugins</h1> - - {body} - </document>.toXMLString(); - fileMap["plugins"] = function () ['text/xml;charset=UTF-8', help]; - - addTags("plugins", util.httpGet("liberator://help/plugins").responseXML); - }, - - /** - * Opens the help page containing the specified <b>topic</b> if it - * exists. - * - * @param {string} topic The help topic to open. - * @param {boolean} unchunked Whether to use the unchunked help page. - * @returns {string} - */ - help: function (topic, unchunked) - { - if (!topic && !unchunked) - { - let helpFile = options["helpfile"]; - if (helpFile in services.get("liberator:").FILE_MAP) - liberator.open("liberator://help/" + helpFile, { from: "help" }); - else - liberator.echomsg("Sorry, help file " + helpFile.quote() + " not found"); - return; - } - - let page = this.findHelp(topic, unchunked); - liberator.assert(page != null, "E149: Sorry, no help for " + topic); - - liberator.open("liberator://help/" + page, { from: "help" }); - if (options.get("activate").has("all", "help")) - content.postMessage("fragmentChange", "*"); - }, - - /** - * The map of global variables. - * - * These are set and accessed with the "g:" prefix. - */ - globalVariables: {}, - - loadModule: function (name, func) { loadModule(name, func); }, - - loadPlugins: function () - { - function sourceDirectory(dir) - { - liberator.assert(dir.isReadable(), "E484: Can't open file " + dir.path); - - liberator.log("Sourcing plugin directory: " + dir.path + "...", 3); - dir.readDirectory(true).forEach(function (file) { - if (file.isFile() && /\.(js|vimp)$/i.test(file.path) && !(file.path in liberator.pluginFiles)) - { - try - { - io.source(file.path, false); - liberator.pluginFiles[file.path] = true; - } - catch (e) - { - liberator.reportError(e); - } - } - else if (file.isDirectory()) - sourceDirectory(file); - }); - } - - let dirs = io.getRuntimeDirectories("plugin"); - - if (dirs.length == 0) - { - liberator.log("No user plugin directory found", 3); - return; + // finally, read the RC file and source plugins + // make sourcing asynchronous, otherwise commands that open new tabs won't work + setTimeout(function () { + let extensionName = config.name.toUpperCase(); + let init = services.get("environment").get(extensionName + "_INIT"); + let rcFile = io.getRCFile("~"); + + if (liberator.commandLineOptions.rcFile) { + let filename = liberator.commandLineOptions.rcFile; + if (!/^(NONE|NORC)$/.test(filename)) + io.source(io.File(filename).path, false); // let io.source handle any read failure like Vim } - - liberator.echomsg('Searching for "plugin/**/*.{js,vimp}" in "' - + [dir.path.replace(/.plugin$/, "") for ([, dir] in Iterator(dirs))].join(",") + '"', 2); - - dirs.forEach(function (dir) { - liberator.echomsg("Searching for \"" + (dir.path + "/**/*.{js,vimp}") + "\"", 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 <b>level</b> will be printed. If <b>msg</b> 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 = 0; - if (level == undefined) - level = 1; - - // options does not exist at the very beginning - if (modules.options) - verbose = options.getPref("extensions.liberator.loglevel", 0); - - if (level > verbose) - return; - - if (typeof msg == "object") - msg = util.objectToString(msg, false); - - services.get("console").logStringMessage(config.name.toLowerCase() + ": " + msg); - }, - - /** - * Opens one or more URLs. Returns true when load was initiated, or - * false on error. - * - * @param {string|string[]} urls Either a URL string or an array of URLs. - * The array can look like this: - * ["url1", "url2", "url3", ...] - * or: - * [["url1", postdata1], ["url2", postdata2], ...] - * @param {number|Object} where If ommited, CURRENT_TAB is assumed but NEW_TAB - * is set when liberator.forceNewTab is true. - * @param {boolean} force Don't prompt whether to open more than 20 - * tabs. - * @returns {boolean} - */ - open: function (urls, params, force) - { - // convert the string to an array of converted URLs - // -> see util.stringToURLArray for more details - if (typeof urls == "string") - { - // rather switch to the tab instead of opening a new url in case of "12: Tab Title" like "urls" - if (liberator.has("tabs")) - { - let matches = urls.match(/^(\d+):/); - if (matches) - { - tabs.select(parseInt(matches[1], 10) - 1, false); // make it zero-based - return; + else { + if (init) + liberator.execute(init); + else { + if (rcFile) { + io.source(rcFile.path, true); + services.get("environment").set("MY_" + extensionName + "RC", rcFile.path); } + else + liberator.log("No user RC file found", 3); } - urls = util.stringToURLArray(urls); - } - - if (urls.length > 20 && !force) - { - 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)) - liberator.open(urls, params, true); - }); - return true; - } - - let flags = 0; - params = params || {}; - if (params instanceof Array) - params = { where: params }; - - for (let [opt, flag] in Iterator({ replace: "REPLACE_HISTORY", hide: "BYPASS_HISTORY" })) - if (params[opt]) - flags |= Ci.nsIWebNavigation["LOAD_FLAGS_" + flag]; - - let where = params.where || liberator.CURRENT_TAB; - if ("from" in params && liberator.has("tabs")) - { - if (!('where' in params) && options.get("newtab").has("all", params.from)) - where = liberator.NEW_BACKGROUND_TAB; - if (options.get("activate").has("all", params.from)) - { - if (where == liberator.NEW_TAB) - where = liberator.NEW_BACKGROUND_TAB; - else if (where == liberator.NEW_BACKGROUND_TAB) - where = liberator.NEW_TAB; - } - } - - if (urls.length == 0) - return false; - - let browser = window.getBrowser(); - - function open(urls, where) - { - let url = Array.concat(urls)[0]; - let postdata = Array.concat(urls)[1]; - - // decide where to load the first url - switch (where) - { - case liberator.CURRENT_TAB: - browser.loadURIWithFlags(url, flags, null, null, postdata); - break; - - case liberator.NEW_BACKGROUND_TAB: - case liberator.NEW_TAB: - if (!liberator.has("tabs")) - return open(urls, liberator.NEW_WINDOW); - - options.withContext(function () { - options.setPref("browser.tabs.loadInBackground", true); - browser.loadOneTab(url, null, null, postdata, where == liberator.NEW_BACKGROUND_TAB); - }); - break; - - case liberator.NEW_WINDOW: - window.open(); - let win = services.get("windowMediator").getMostRecentWindow("navigator:browser"); - win.loadURI(url, null, postdata); - browser = win.getBrowser(); - break; - - default: - throw Error("Invalid 'where' directive in liberator.open(...)"); - } - } - - if (liberator.forceNewTab) - where = liberator.NEW_TAB; - else if (liberator.forceNewWindow) - where = liberator.NEW_WINDOW; - else if (!where) - where = liberator.CURRENT_TAB; - - for (let [, url] in Iterator(urls)) - { - open(url, where); - where = liberator.NEW_BACKGROUND_TAB; - } - - return true; - }, - - pluginFiles: {}, - - // namespace for plugins/scripts. Actually (only) the active plugin must/can set a - // v.plugins.mode = <str> string to show on v.modes.CUSTOM - // v.plugins.stop = <func> hooked on a v.modes.reset() - // v.plugins.onEvent = <func> function triggered, on keypresses (unless <esc>) (see events.js) - plugins: plugins, - - /** - * 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) - { - // TODO: Use safeSetPref? - if (saveSession) - options.setPref("browser.startup.page", 3); // start with saved session - else - options.setPref("browser.startup.page", 1); // start with default homepage session - - if (force) - services.get("appStartup").quit(Ci.nsIAppStartup.eForceQuit); - else - window.goQuitApplication(); - }, - - /** - * 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 (func, self) - { - try - { - return func.apply(self || this, Array.slice(arguments, 2)); - } - catch (e) - { - if (e instanceof FailedAssertion) - liberator.echoerr(e.message); - } - }, - - /** - * Reports an error to both the console and the host application's - * Error Console. - * - * @param {Object} error The error object. - */ - reportError: function (error) - { - if (Cu.reportError) - Cu.reportError(error); - - try - { - try - { - var string = String(error); - var stack = error.stack; - } - catch (e) {} - - let obj = { - toString: function () string || {}.toString.call(error), - stack: <>{String.replace(stack || Error().stack, /^/mg, "\t")}</> - }; - for (let [k, v] in Iterator(error)) - { - if (!(k in obj)) - obj[k] = v; - } - if (liberator.storeErrors) - { - let errors = storage.newArray("errors", { store: false }); - errors.toString = function () [String(v[0]) + "\n" + v[1] for ([k, v] in this)].join("\n\n"); - errors.push([new Date, obj + obj.stack]); + if (options["exrc"] && !liberator.commandLineOptions.rcFile) { + let localRCFile = io.getRCFile(io.getCurrentDirectory().path); + if (localRCFile && !localRCFile.equals(rcFile)) + io.source(localRCFile.path, true); } - liberator.dump(string); - liberator.dump(obj); - liberator.dump(""); } - catch (e) { window.dump(e) } - }, - - /** - * Restart the host application. - */ - restart: function () - { - // notify all windows that an application quit has been requested. - var cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(Ci.nsISupportsPRBool); - services.get("observer").notifyObservers(cancelQuit, "quit-application-requested", null); - - // something aborted the quit process. - if (cancelQuit.data) - return; - - // notify all windows that an application quit has been granted. - services.get("observer").notifyObservers(null, "quit-application-granted", null); - - // enumerate all windows and call shutdown handlers - let windows = services.get("windowMediator").getEnumerator(null); - while (windows.hasMoreElements()) - { - let win = windows.getNext(); - if (("tryToClose" in win) && !win.tryToClose()) - return; - } - services.get("appStartup").quit(Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit); - }, - - /** - * Parses a Liberator command-line string i.e. the value of the - * -liberator command-line option. - * - * @param {string} cmdline The string to parse for command-line - * options. - * @returns {Object} - * @see Commands#parseArgs - */ - parseCommandLine: function (cmdline) - { - const options = [ - [["+u"], commands.OPTIONS_STRING], - [["++noplugin"], commands.OPTIONS_NOARG], - [["++cmd"], commands.OPTIONS_STRING, null, null, true], - [["+c"], commands.OPTIONS_STRING, null, null, true] - ]; - return commands.parseArgs(cmdline, options, "*"); - }, - - // this function is called when the chrome is ready - startup: function () - { - let start = Date.now(); - liberator.log("Initializing liberator object...", 0); - - config.features.push(getPlatformFeature()); - - try - { - let infoPath = services.create("file"); - infoPath.initWithPath(IO.expandPath(IO.runtimePath.replace(/,.*/, ""))); - infoPath.append("info"); - infoPath.append(liberator.profileName); - storage.infoPath = infoPath; - } - catch (e) - { - liberator.reportError(e); - } - - let img = Image(); - img.src = config.logo || "chrome://" + config.name.toLowerCase() + "/content/logo.png"; - img.onload = function () { - highlight.set("Logo", String(<> - display: inline-block; - background: url({img.src}); - width: {img.width}px; - height: {img.height}px; - </>)); - delete img; - }; - - // commands must always be the first module to be initialized - loadModule("commands", Commands); - loadModule("options", Options); - loadModule("events", Events); - loadModule("mappings", Mappings); - loadModule("buffer", Buffer); - loadModule("commandline", CommandLine); - loadModule("statusline", StatusLine); - loadModule("editor", Editor); - loadModule("autocommands", AutoCommands); - loadModule("io", IO); - loadModule("completion", Completion); - // add options/mappings/commands which are only valid in this particular extension - if (config.init) - config.init(); + if (liberator.commandLineOptions.rcFile == "NONE" || liberator.commandLineOptions.noPlugins) + options["loadplugins"] = false; - liberator.triggerObserver("load"); + if (options["loadplugins"]) + liberator.loadPlugins(); - liberator.log("All modules loaded", 3); + liberator.initHelp(); - services.add("commandLineHandler", "@mozilla.org/commandlinehandler/general-startup;1?type=" + config.name.toLowerCase(), - Ci.nsICommandLineHandler); - - let commandline = services.get("commandLineHandler").wrappedJSObject.optionValue; - if (commandline) - { - let args = liberator.parseCommandLine(commandline); - liberator.commandLineOptions.rcFile = args["+u"]; - liberator.commandLineOptions.noPlugins = "++noplugin" in args; - liberator.commandLineOptions.postCommands = args["+c"]; - liberator.commandLineOptions.preCommands = args["++cmd"]; - liberator.dump("Processing command-line option: " + commandline); + // 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 options) { + // 'encoding' option should not be set at this timing. + // Probably a wrong value is set into the option, + // if current page's encoging is not UTF-8. + if (option.name != "encoding" && option.setter) + option.value = option.value; } - liberator.log("Command-line options: " + util.objectToString(liberator.commandLineOptions), 3); - - // first time intro message - const firstTime = "extensions." + config.name.toLowerCase() + ".firsttime"; - if (options.getPref(firstTime, true)) - { - setTimeout(function () { - liberator.help(); - options.setPref(firstTime, false); - }, 1000); - } - - // always start in normal mode - modes.reset(); - - // TODO: we should have some class where all this guioptions stuff fits well - hideGUI(); - - if (liberator.commandLineOptions.preCommands) - liberator.commandLineOptions.preCommands.forEach(function (cmd) { + if (liberator.commandLineOptions.postCommands) + liberator.commandLineOptions.postCommands.forEach(function (cmd) { liberator.execute(cmd); }); - // finally, read the RC file and source plugins - // make sourcing asynchronous, otherwise commands that open new tabs won't work - setTimeout(function () { - - let extensionName = config.name.toUpperCase(); - let init = services.get("environment").get(extensionName + "_INIT"); - let rcFile = io.getRCFile("~"); - - if (liberator.commandLineOptions.rcFile) - { - let filename = liberator.commandLineOptions.rcFile; - if (!/^(NONE|NORC)$/.test(filename)) - io.source(io.File(filename).path, false); // let io.source handle any read failure like Vim - } - else - { - if (init) - liberator.execute(init); - else - { - if (rcFile) - { - io.source(rcFile.path, true); - services.get("environment").set("MY_" + extensionName + "RC", rcFile.path); - } - else - liberator.log("No user RC file found", 3); - } - - if (options["exrc"] && !liberator.commandLineOptions.rcFile) - { - let localRCFile = io.getRCFile(io.getCurrentDirectory().path); - if (localRCFile && !localRCFile.equals(rcFile)) - io.source(localRCFile.path, true); - } - } - - if (liberator.commandLineOptions.rcFile == "NONE" || liberator.commandLineOptions.noPlugins) - options["loadplugins"] = false; - - if (options["loadplugins"]) - liberator.loadPlugins(); - - liberator.initHelp(); - - // 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 options) - { - // 'encoding' option should not be set at this timing. - // Probably a wrong value is set into the option, - // if current page's encoging is not UTF-8. - if (option.name != "encoding" && option.setter) - option.value = option.value; - } - - if (liberator.commandLineOptions.postCommands) - liberator.commandLineOptions.postCommands.forEach(function (cmd) { - liberator.execute(cmd); - }); - - liberator.triggerObserver("enter", null); - autocommands.trigger(config.name + "Enter", {}); - - liberator.initialized = true; - }, 0); - - statusline.update(); - - liberator.dump("loaded in " + (Date.now() - start) + " ms"); - liberator.log(config.name + " fully initialized", 0); - }, - - shutdown: function () - { - autocommands.trigger(config.name + "LeavePre", {}); - storage.saveAll(); - liberator.triggerObserver("shutdown", null); - liberator.dump("All liberator modules destroyed\n"); - autocommands.trigger(config.name + "Leave", {}); - }, - - sleep: function (delay) - { - let mainThread = services.get("threadManager").mainThread; - - let end = Date.now() + delay; - while (Date.now() < end) - mainThread.processNextEvent(true); - return true; - }, - - callInMainThread: function (callback, self) - { - let mainThread = services.get("threadManager").mainThread; - if (!services.get("threadManager").isMainThread) - mainThread.dispatch({ run: callback.call(self) }, mainThread.DISPATCH_NORMAL); - else - callback.call(self); - }, - - threadYield: function (flush, interruptable) - { - let mainThread = services.get("threadManager").mainThread; - liberator.interrupted = false; - do - { - mainThread.processNextEvent(!flush); - if (liberator.interrupted) - throw new Error("Interrupted"); - } - while (flush === true && mainThread.hasPendingEvents()); - }, - - variableReference: function (string) - { - if (!string) - return [null, null, null]; - - let matches = string.match(/^([bwtglsv]):(\w+)/); - if (matches) // Variable - { - // Other variables should be implemented - if (matches[1] == "g") - { - if (matches[2] in this.globalVariables) - return [this.globalVariables, matches[2], matches[1]]; - else - return [null, matches[2], matches[1]]; - } - } - else // Global variable - { - if (string in this.globalVariables) - return [this.globalVariables, string, "g"]; - else - return [null, string, "g"]; - } - }, - - /** - * @property {Window[]} Returns an array of all the host application's - * open windows. - */ - get windows() - { - let windows = []; - let enumerator = services.get("windowMediator").getEnumerator("navigator:browser"); - while (enumerator.hasMoreElements()) - windows.push(enumerator.getNext()); - - return windows; - } - }; - //}}} -})(); //}}} - -window.liberator = liberator; + liberator.triggerObserver("enter", null); + autocommands.trigger(config.name + "Enter", {}); + }, 0); -// called when the chrome is fully loaded and before the main window is shown -window.addEventListener("load", liberator.startup, false); -window.addEventListener("unload", liberator.shutdown, false); + statusline.update(); + liberator.log(config.name + " fully initialized", 0); + }, +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/liberator.xul b/common/content/liberator.xul index 92800cce..c1acf2bb 100644 --- a/common/content/liberator.xul +++ b/common/content/liberator.xul @@ -10,6 +10,7 @@ <?xml-stylesheet href="chrome://liberator/skin/liberator.css" type="text/css"?> <!DOCTYPE overlay SYSTEM "liberator.dtd" [ <!ENTITY liberator.content "chrome://liberator/content/"> + <!ENTITY and "&&"> ]> <overlay id="liberator" @@ -28,8 +29,8 @@ </stringbundleset> <keyset id="mainKeyset"> - <key id="key_open_vimbar" key=":" oncommand="liberator.modules.commandline.open(':', '', liberator.modules.modes.EX);" modifiers=""/> - <key id="key_stop" keycode="VK_ESCAPE" oncommand="liberator.modules.events.onEscape();"/> + <key id="key_open_vimbar" key=":" oncommand="window.liberator ∧ liberator.modules.commandline.open(':', '', liberator.modules.modes.EX);" modifiers=""/> + <key id="key_stop" keycode="VK_ESCAPE" oncommand="window.liberator ∧ liberator.modules.events.onEscape();"/> <!-- other keys are handled inside the event loop in events.js --> </keyset> @@ -42,24 +43,24 @@ <commandset id="onVimperatorFocus" commandupdater="true" events="focus" - oncommandupdate="if (liberator.modules.events != undefined) liberator.modules.events.onFocusChange(event);"/> + oncommandupdate="if (window.liberator ∧ liberator.modules.events != undefined) liberator.modules.events.onFocusChange(event);"/> <commandset id="onVimperatorSelect" commandupdater="true" events="select" - oncommandupdate="if (liberator.modules.events != undefined) liberator.modules.events.onSelectionChange(event);"/> + oncommandupdate="if (window.liberator ∧ liberator.modules.events != undefined) liberator.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="liberator-container" hidden="false" collapsed="true"> <iframe id="liberator-multiline-output" src="chrome://liberator/content/buffer.xhtml" flex="1" hidden="false" collapsed="false" - onclick="liberator.modules.commandline.onMultilineOutputEvent(event)"/> + onclick="window.liberator ∧ liberator.modules.commandline.onMultilineOutputEvent(event)"/> </vbox> <vbox class="liberator-container" hidden="false" collapsed="true"> <iframe id="liberator-completions" src="chrome://liberator/content/buffer.xhtml" flex="1" hidden="false" collapsed="false" - onclick="liberator.modules.commandline.onMultilineOutputEvent(event)"/> + onclick="window.liberator ∧ liberator.modules.commandline.onMultilineOutputEvent(event)"/> </vbox> <stack orient="horizontal" align="stretch" class="liberator-container" liberator:highlight="CmdLine"> @@ -67,18 +68,18 @@ <hbox id="liberator-commandline" hidden="false" collapsed="true" class="liberator-container" liberator:highlight="Normal"> <label class="plain" id="liberator-commandline-prompt" flex="0" crop="end" value="" collapsed="true"/> <textbox class="plain" id="liberator-commandline-command" flex="1" type="timed" timeout="100" - oninput="liberator.modules.commandline.onEvent(event);" - onkeyup="liberator.modules.commandline.onEvent(event);" - onfocus="liberator.modules.commandline.onEvent(event);" - onblur="liberator.modules.commandline.onEvent(event);"/> + oninput="window.liberator ∧ liberator.modules.commandline.onEvent(event);" + onkeyup="window.liberator ∧ liberator.modules.commandline.onEvent(event);" + onfocus="window.liberator ∧ liberator.modules.commandline.onEvent(event);" + onblur="window.liberator ∧ liberator.modules.commandline.onEvent(event);"/> </hbox> </stack> <vbox class="liberator-container" hidden="false" collapsed="false" liberator:highlight="CmdLine"> <textbox id="liberator-multiline-input" class="plain" flex="1" rows="1" hidden="false" collapsed="true" multiline="true" liberator:highlight="Normal" - onkeypress="liberator.modules.commandline.onMultilineInputEvent(event);" - oninput="liberator.modules.commandline.onMultilineInputEvent(event);" - onblur="liberator.modules.commandline.onMultilineInputEvent(event);"/> + onkeypress="window.liberator ∧ liberator.modules.commandline.onMultilineInputEvent(event);" + oninput="window.liberator ∧ liberator.modules.commandline.onMultilineInputEvent(event);" + onblur="window.liberator ∧ liberator.modules.commandline.onMultilineInputEvent(event);"/> </vbox> </window> diff --git a/common/content/mappings.js b/common/content/mappings.js index f73c32b1..21ade1b3 100644 --- a/common/content/mappings.js +++ b/common/content/mappings.js @@ -27,52 +27,50 @@ * @optional * @private */ -function Map(modes, keys, description, action, extraInfo) //{{{ -{ - modes = Array.concat(modes); - - if (!extraInfo) - extraInfo = {}; - - /** @property {number[]} All of the modes for which this mapping applies. */ - this.modes = modes; - /** @property {string[]} All of this mapping's names (key sequences). */ - this.names = keys.map(events.canonicalKeys); - /** @property {function (number)} The function called to execute this mapping. */ - this.action = action; - /** @property {string} This mapping's description, as shown in :viusage. */ - this.description = description || ""; - - /** @property {boolean} Whether this mapping accepts an argument. */ - this.arg = extraInfo.arg || false; - /** @property {boolean} Whether this mapping accepts a count. */ - this.count = extraInfo.count || false; - /** - * @property {boolean} Whether the mapping accepts a motion mapping - * as an argument. - */ - this.motion = extraInfo.motion || false; - /** - * @property {boolean} Whether the mapping's key events should be - * propagated to the host application. - */ - // TODO: I'm not sure this is the best name but it reflects that which it replaced. --djk - this.route = extraInfo.route || false; - /** @property {boolean} Whether the RHS of the mapping should expand mappings recursively. */ - this.noremap = extraInfo.noremap || false; - /** @property {boolean} Whether any output from the mapping should be echoed on the command line. */ - this.silent = extraInfo.silent || false; - /** @property {string} The literal RHS expansion of this mapping. */ - this.rhs = extraInfo.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. - */ - this.user = extraInfo.user || false; -} - -Map.prototype = { +const Map = Class("Map", { + init: function (modes, keys, description, action, extraInfo) { + modes = Array.concat(modes); + + if (!extraInfo) + extraInfo = {}; + + /** @property {number[]} All of the modes for which this mapping applies. */ + this.modes = modes; + /** @property {string[]} All of this mapping's names (key sequences). */ + this.names = keys.map(events.canonicalKeys); + /** @property {function (number)} The function called to execute this mapping. */ + this.action = action; + /** @property {string} This mapping's description, as shown in :viusage. */ + this.description = description || ""; + + /** @property {boolean} Whether this mapping accepts an argument. */ + this.arg = extraInfo.arg || false; + /** @property {boolean} Whether this mapping accepts a count. */ + this.count = extraInfo.count || false; + /** + * @property {boolean} Whether the mapping accepts a motion mapping + * as an argument. + */ + this.motion = extraInfo.motion || false; + /** + * @property {boolean} Whether the mapping's key events should be + * propagated to the host application. + */ + // TODO: I'm not sure this is the best name but it reflects that which it replaced. --djk + this.route = extraInfo.route || false; + /** @property {boolean} Whether the RHS of the mapping should expand mappings recursively. */ + this.noremap = extraInfo.noremap || false; + /** @property {boolean} Whether any output from the mapping should be echoed on the command line. */ + this.silent = extraInfo.silent || false; + /** @property {string} The literal RHS expansion of this mapping. */ + this.rhs = extraInfo.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. + */ + this.user = extraInfo.user || false; + }, /** * Returns whether this mapping can be invoked by a key sequence matching @@ -93,8 +91,7 @@ Map.prototype = { * @param {string} argument The normal argument if accepted by this * mapping. E.g. "a" for "ma" */ - execute: function (motion, count, argument) - { + execute: function (motion, count, argument) { let args = []; if (this.motion) @@ -112,60 +109,46 @@ Map.prototype = { return liberator.trapErrors(repeat); } -}; //}}} +}); /** * @instance mappings */ -function Mappings() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var main = []; // default mappings - var user = []; // user created mappings - - for (let mode in modes) - { - main[mode] = []; - user[mode] = []; - } +const Mappings = Module("mappings", { + requires: ["modes"], + + init: function () { + this._main = []; // default mappings + this._user = []; // user created mappings + }, - function addMap(map) - { - let where = map.user ? user : main; + _addMap: function (map) { + let where = map.user ? this._user : this._main; map.modes.forEach(function (mode) { if (!(mode in where)) where[mode] = []; where[mode].push(map); }); - } + }, - function getMap(mode, cmd, stack) - { + _getMap: function (mode, cmd, stack) { let maps = stack[mode] || []; - for (let [, map] in Iterator(maps)) - { + for (let [, map] in Iterator(maps)) { if (map.hasName(cmd)) return map; } return null; - } + }, - function removeMap(mode, cmd) - { - let maps = user[mode] || []; + _removeMap: function (mode, cmd) { + let maps = this._user[mode] || []; let names; - for (let [i, map] in Iterator(maps)) - { - for (let [j, name] in Iterator(map.names)) - { - if (name == cmd) - { + for (let [i, map] in Iterator(maps)) { + for (let [j, name] in Iterator(map.names)) { + if (name == cmd) { map.names.splice(j, 1); if (map.names.length == 0) maps.splice(i, 1); @@ -173,152 +156,329 @@ function Mappings() //{{{ } } } - } + }, - function expandLeader(keyString) keyString.replace(/<Leader>/i, mappings.getMapLeader()) + _expandLeader: function (keyString) keyString.replace(/<Leader>/i, mappings.getMapLeader()), // Return all mappings present in all @modes - function mappingsIterator(modes, stack) - { + _mappingsIterator: function (modes, stack) { modes = modes.slice(); return (map for ([i, map] in Iterator(stack[modes.shift()])) if (modes.every(function (mode) stack[mode].some( function (m) m.rhs == map.rhs && m.names[0] == map.names[0])))) - } + }, - function addMapCommands(ch, modes, modeDescription) - { - // 0 args -> list all maps - // 1 arg -> list the maps starting with args - // 2 args -> map arg1 to arg* - function map(args, modes, noremap) - { - if (!args.length) - { - mappings.list(modes); - return; - } + // NOTE: just normal mode for now + /** @property {Iterator(Map)} @private */ + __iterator__: function () this._mappingsIterator([modes.NORMAL], this._main), - let [lhs, rhs] = args; - - if (!rhs) // list the mapping - mappings.list(modes, expandLeader(lhs)); - else - { - // this matches Vim's behaviour - if (/^<Nop>$/i.test(rhs)) - noremap = true; - - mappings.addUserMap(modes, [lhs], - "User defined mapping", - function (count) { events.feedkeys((count > -1 ? count : "") + this.rhs, this.noremap, this.silent); }, - { - count: true, - rhs: events.canonicalKeys(rhs), - noremap: !!noremap, - silent: "<silent>" in args - }); - } + // used by :mkvimperatorrc to save mappings + /** + * Returns a user-defined mappings iterator for the specified + * <b>mode</b>. + * + * @param {number} mode The mode to return mappings from. + * @returns {Iterator(Map)} + */ + getUserIterator: function (mode) this._mappingsIterator(mode, this._user), + + addMode: function (mode) { + if (!(mode in this._user || mode in this._main)) { + this._main[mode] = []; + this._user[mode] = []; } + }, - modeDescription = modeDescription ? " in " + modeDescription + " 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 + * <b>action</b>. + * @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) { + this._addMap(new Map(modes, keys, description, action, extra)); + }, - // :map, :noremap => NORMAL + VISUAL modes - function isMultiMode(map, cmd) - { - return map.modes.indexOf(modules.modes.NORMAL) >= 0 - && map.modes.indexOf(modules.modes.VISUAL) >= 0 - && /^[nv](nore)?map$/.test(cmd); + /** + * 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 + * <b>action</b>. + * @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: function (modes, keys, description, action, extra) { + keys = keys.map(this._expandLeader); + extra = extra || {}; + extra.user = true; + let map = new Map(modes, keys, description || "User defined mapping", action, extra); + + // remove all old mappings to this key sequence + for (let [, name] in Iterator(map.names)) { + for (let [, mode] in Iterator(map.modes)) + this._removeMap(mode, name); } - const opts = { - completer: function (context, args) completion.userMapping(context, args, modes), - options: [ - [["<silent>", "<Silent>"], commands.OPTION_NOARG] - ], - literal: 1, - serial: function () - { - let noremap = this.name.indexOf("noremap") > -1; - return [ - { - command: this.name, - options: map.silent ? { "<silent>": null } : {}, - arguments: [map.names[0]], - literalArg: map.rhs - } - for (map in mappingsIterator(modes, user)) - if (map.rhs && map.noremap == noremap && !isMultiMode(map, this.name)) - ]; + this._addMap(map); + }, + + /** + * Returns the map from <b>mode</b> named <b>cmd</b>. + * + * @param {number} mode The mode to search. + * @param {string} cmd The map name to match. + * @returns {Map} + */ + get: function (mode, cmd) { + mode = mode || modes.NORMAL; + return this._getMap(mode, cmd, this._user) || this._getMap(mode, cmd, this._main); + }, + + /** + * Returns the default map from <b>mode</b> named <b>cmd</b>. + * + * @param {number} mode The mode to search. + * @param {string} cmd The map name to match. + * @returns {Map} + */ + getDefault: function (mode, cmd) { + mode = mode || modes.NORMAL; + return this._getMap(mode, cmd, this._main); + }, + + /** + * Returns an array of maps with names starting with but not equal to + * <b>prefix</b>. + * + * @param {number} mode The mode to search. + * @param {string} prefix The map prefix string to match. + * @returns {Map[]} + */ + getCandidates: function (mode, prefix) { + let mappings = this._user[mode].concat(this._main[mode]); + let matches = []; + + for (let [, map] in Iterator(mappings)) { + for (let [, name] in Iterator(map.names)) { + if (name.indexOf(prefix) == 0 && name.length > prefix.length) { + // for < only return a candidate if it doesn't look like a <c-x> mapping + if (prefix != "<" || !/^<.+>/.test(name)) + matches.push(map); } - }; + } + } + + return matches; + }, + + /* + * Returns the map leader string used to replace the special token + * "<Leader>" when user mappings are defined. + * + * @returns {string} + */ + // FIXME: property + getMapLeader: function () { + let leaderRef = liberator.variableReference("mapleader"); + return leaderRef[0] ? leaderRef[0][leaderRef[1]] : "\\"; + }, + + /** + * Returns whether there is a user-defined mapping <b>cmd</b> for the + * specified <b>mode</b>. + * + * @param {number} mode The mode to search. + * @param {string} cmd The candidate key mapping. + * @returns {boolean} + */ + hasMap: function (mode, cmd) this._user[mode].some(function (map) map.hasName(cmd)), + + /** + * Remove the user-defined mapping named <b>cmd</b> for <b>mode</b>. + * + * @param {number} mode The mode to search. + * @param {string} cmd The map name to match. + */ + remove: function (mode, cmd) { + this._removeMap(mode, cmd); + }, + + /** + * Remove all user-defined mappings for <b>mode</b>. + * + * @param {number} mode The mode to remove all mappings from. + */ + removeAll: function (mode) { + this._user[mode] = []; + }, - commands.add([ch ? ch + "m[ap]" : "map"], - "Map a key sequence" + modeDescription, - function (args) { map(args, modes, false); }, - opts); - - commands.add([ch + "no[remap]"], - "Map a key sequence without remapping keys" + modeDescription, - function (args) { map(args, modes, true); }, - opts); - - commands.add([ch + "mapc[lear]"], - "Remove all mappings" + modeDescription, - function () { modes.forEach(function (mode) { mappings.removeAll(mode); }); }, - { argCount: "0" }); - - commands.add([ch + "unm[ap]"], - "Remove a mapping" + modeDescription, - function (args) - { - args = args[0]; - - let found = false; - for (let [, mode] in Iterator(modes)) + /** + * Lists all user-defined mappings matching <b>filter</b> for the + * specified <b>modes</b>. + * + * @param {number[]} modes An array of modes to search. + * @param {string} filter The filter string to match. + */ + list: function (modes, filter) { + let modeSign = ""; + + // TODO: Vim hides "nv" in a :map and "v" and "n" in :vmap and + // :nmap respectively if the map is not exclusive. + modes.forEach(function (mode) { + for (let m in modules.modes.mainModes) + if (mode == m.mask && modeSign.indexOf(m.char) == -1) + modeSign += m.char; + }); + + let maps = this._mappingsIterator(modes, this._user); + if (filter) + maps = [map for (map in maps) if (map.names[0] == filter)]; + + let list = <table> { - if (mappings.hasMap(mode, args)) - { - mappings.remove(mode, args); - found = true; - } + template.map(maps, function (map) + template.map(map.names, function (name) + <tr> + <td>{modeSign} {name}</td> + <td>{map.noremap ? "*" : " "}</td> + <td>{map.rhs || "function () { ... }"}</td> + </tr>)) } - if (!found) - liberator.echoerr("E31: No such mapping"); - }, - { - argCount: "1", - completer: function (context, args) completion.userMapping(context, args, modes) - }); + </table>; + + // TODO: Move this to an ItemList to show this automatically + if (list.*.length() == list.text().length()) { + liberator.echomsg("No mapping found"); + return; + } + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); } +}, { +}, { + commands: function () { + function addMapCommands(ch, modes, modeDescription) { + // 0 args -> list all maps + // 1 arg -> list the maps starting with args + // 2 args -> map arg1 to arg* + function map(args, modes, noremap) { + if (!args.length) { + mappings.list(modes); + return; + } - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + let [lhs, rhs] = args; + + if (!rhs) // list the mapping + mappings.list(modes, this._expandLeader(lhs)); + else { + // this matches Vim's behaviour + if (/^<Nop>$/i.test(rhs)) + noremap = true; + + mappings.addUserMap(modes, [lhs], + "User defined mapping", + function (count) { events.feedkeys((count > -1 ? count : "") + this.rhs, this.noremap, this.silent); }, { + count: true, + rhs: events.canonicalKeys(rhs), + noremap: !!noremap, + silent: "<silent>" in args + }); + } + } - addMapCommands("", [modes.NORMAL, modes.VISUAL], ""); + modeDescription = modeDescription ? " in " + modeDescription + " mode" : ""; - for (let mode in modes.mainModes) - if (mode.char && !commands.get(mode.char + "map")) - addMapCommands(mode.char, - [m.mask for (m in modes.mainModes) if (m.char == mode.char)], - [mode.disp.toLowerCase()]); + // :map, :noremap => NORMAL + VISUAL modes + function isMultiMode(map, cmd) { + return map.modes.indexOf(modules.modes.NORMAL) >= 0 + && map.modes.indexOf(modules.modes.VISUAL) >= 0 + && /^[nv](nore)?map$/.test(cmd); + } - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + const opts = { + completer: function (context, args) completion.userMapping(context, args, modes), + options: [ + [["<silent>", "<Silent>"], commands.OPTION_NOARG] + ], + literal: 1, + serial: function () { + let noremap = this.name.indexOf("noremap") > -1; + return [ + { + command: this.name, + options: map.silent ? { "<silent>": null } : {}, + arguments: [map.names[0]], + literalArg: map.rhs + } + for (map in this._mappingsIterator(modes, this._user)) + if (map.rhs && map.noremap == noremap && !isMultiMode(map, this.name)) + ]; + } + }; + + commands.add([ch ? ch + "m[ap]" : "map"], + "Map a key sequence" + modeDescription, + function (args) { map(args, modes, false); }, + opts); + + commands.add([ch + "no[remap]"], + "Map a key sequence without remapping keys" + modeDescription, + function (args) { map(args, modes, true); }, + opts); + + commands.add([ch + "mapc[lear]"], + "Remove all mappings" + modeDescription, + function () { modes.forEach(function (mode) { mappings.removeAll(mode); }); }, + { argCount: "0" }); + + commands.add([ch + "unm[ap]"], + "Remove a mapping" + modeDescription, + function (args) { + args = args[0]; + + let found = false; + for (let [, mode] in Iterator(modes)) { + if (mappings.hasMap(mode, args)) { + mappings.remove(mode, args); + found = true; + } + } + if (!found) + liberator.echoerr("E31: No such mapping"); + }, + { + argCount: "1", + completer: function (context, args) completion.userMapping(context, args, modes) + }); + } - liberator.registerObserver("load_completion", function () { + addMapCommands("", [modes.NORMAL, modes.VISUAL], ""); + + for (let mode in modes.mainModes) + if (mode.char && !commands.get(mode.char + "map")) + addMapCommands(mode.char, + [m.mask for (m in modes.mainModes) if (m.char == mode.char)], + [mode.disp.toLowerCase()]); + }, + completion: function () { completion.setFunctionCompleter(mappings.get, [ null, - function (context, obj, args) - { + function (context, obj, args) { let mode = args[0]; return util.Array.flatten( [ [[name, map.description] for ([i, name] in Iterator(map.names))] - for ([i, map] in Iterator(user[mode].concat(main[mode]))) + for ([i, map] in Iterator(this._user[mode].concat(this._main[mode]))) ]); } ]); @@ -327,232 +487,18 @@ function Mappings() //{{{ // FIXME: have we decided on a 'standard' way to handle this clash? --djk modes = modes || [modules.modes.NORMAL]; - if (args.completeArg == 0) - { + if (args.completeArg == 0) { let maps = [[m.names[0], ""] for (m in mappings.getUserIterator(modes))]; context.completions = maps; } }; - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - // NOTE: just normal mode for now - /** @property {Iterator(Map)} @private */ - __iterator__: function () mappingsIterator([modes.NORMAL], main), - - // used by :mkvimperatorrc to save mappings - /** - * Returns a user-defined mappings iterator for the specified - * <b>mode</b>. - * - * @param {number} mode The mode to return mappings from. - * @returns {Iterator(Map)} - */ - getUserIterator: function (mode) mappingsIterator(mode, user), - - addMode: function (mode) - { - if (!(mode in user || mode in main)) - { - main[mode] = []; - user[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 - * <b>action</b>. - * @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) - { - addMap(new Map(modes, keys, description, action, extra)); - }, - - /** - * 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 - * <b>action</b>. - * @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: function (modes, keys, description, action, extra) - { - keys = keys.map(expandLeader); - extra = extra || {}; - extra.user = true; - let map = new Map(modes, keys, description || "User defined mapping", action, extra); - - // remove all old mappings to this key sequence - for (let [, name] in Iterator(map.names)) - { - for (let [, mode] in Iterator(map.modes)) - removeMap(mode, name); - } - - addMap(map); - }, - - /** - * Returns the map from <b>mode</b> named <b>cmd</b>. - * - * @param {number} mode The mode to search. - * @param {string} cmd The map name to match. - * @returns {Map} - */ - get: function (mode, cmd) - { - mode = mode || modes.NORMAL; - return getMap(mode, cmd, user) || getMap(mode, cmd, main); - }, - - /** - * Returns the default map from <b>mode</b> named <b>cmd</b>. - * - * @param {number} mode The mode to search. - * @param {string} cmd The map name to match. - * @returns {Map} - */ - getDefault: function (mode, cmd) - { - mode = mode || modes.NORMAL; - return getMap(mode, cmd, main); - }, - - /** - * Returns an array of maps with names starting with but not equal to - * <b>prefix</b>. - * - * @param {number} mode The mode to search. - * @param {string} prefix The map prefix string to match. - * @returns {Map[]} - */ - getCandidates: function (mode, prefix) - { - let mappings = user[mode].concat(main[mode]); - let matches = []; - - for (let [, map] in Iterator(mappings)) - { - for (let [, name] in Iterator(map.names)) - { - if (name.indexOf(prefix) == 0 && name.length > prefix.length) - { - // for < only return a candidate if it doesn't look like a <c-x> mapping - if (prefix != "<" || !/^<.+>/.test(name)) - matches.push(map); - } - } - } - - return matches; - }, - - /* - * Returns the map leader string used to replace the special token - * "<Leader>" when user mappings are defined. - * - * @returns {string} - */ - // FIXME: property - getMapLeader: function () - { - let leaderRef = liberator.variableReference("mapleader"); - return leaderRef[0] ? leaderRef[0][leaderRef[1]] : "\\"; - }, - - /** - * Returns whether there is a user-defined mapping <b>cmd</b> for the - * specified <b>mode</b>. - * - * @param {number} mode The mode to search. - * @param {string} cmd The candidate key mapping. - * @returns {boolean} - */ - hasMap: function (mode, cmd) user[mode].some(function (map) map.hasName(cmd)), - - /** - * Remove the user-defined mapping named <b>cmd</b> for <b>mode</b>. - * - * @param {number} mode The mode to search. - * @param {string} cmd The map name to match. - */ - remove: function (mode, cmd) - { - removeMap(mode, cmd); - }, - - /** - * Remove all user-defined mappings for <b>mode</b>. - * - * @param {number} mode The mode to remove all mappings from. - */ - removeAll: function (mode) - { - user[mode] = []; - }, - - /** - * Lists all user-defined mappings matching <b>filter</b> for the - * specified <b>modes</b>. - * - * @param {number[]} modes An array of modes to search. - * @param {string} filter The filter string to match. - */ - list: function (modes, filter) - { - let modeSign = ""; - - // TODO: Vim hides "nv" in a :map and "v" and "n" in :vmap and - // :nmap respectively if the map is not exclusive. - modes.forEach(function (mode) { - for (let m in modules.modes.mainModes) - if (mode == m.mask && modeSign.indexOf(m.char) == -1) - modeSign += m.char; - }); - - let maps = mappingsIterator(modes, user); - if (filter) - maps = [map for (map in maps) if (map.names[0] == filter)]; - - let list = <table> - { - template.map(maps, function (map) - template.map(map.names, function (name) - <tr> - <td>{modeSign} {name}</td> - <td>{map.noremap ? "*" : " "}</td> - <td>{map.rhs || "function () { ... }"}</td> - </tr>)) - } - </table>; - - // TODO: Move this to an ItemList to show this automatically - if (list.*.length() == list.text().length()) - { - liberator.echomsg("No mapping found"); - return; - } - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + modes: function () { + for (let mode in modes) { + this._main[mode] = []; + this._user[mode] = []; } - }; - //}}} -} //}}} + }, +}); // 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 index 00000000..7213d8dd --- /dev/null +++ b/common/content/marks.js @@ -0,0 +1,343 @@ +// 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. + +/** + * @scope modules + * @instance marks + */ +const Marks = Module("marks", { + requires: ["storage"], + + init: function init() { + this._localMarks = storage.newMap("local-marks", { store: true, privateData: true }); + this._urlMarks = storage.newMap("url-marks", { store: true, privateData: true }); + + this._pendingJumps = []; + + var appContent = document.getElementById("appcontent"); + if (appContent) + appContent.addEventListener("load", this.closure._onPageLoad, true); + }, + + /** + * @property {Array} Returns all marks, both local and URL, in a sorted + * array. + */ + get all() { + // local marks + let location = window.content.location.href; + let lmarks = [i for (i in this._localMarkIter()) if (i[1].location == location)]; + lmarks.sort(); + + // URL marks + // FIXME: why does umarks.sort() cause a "Component is not available = + // NS_ERROR_NOT_AVAILABLE" exception when used here? + let umarks = [i for (i in this._urlMarks)]; + umarks.sort(function (a, b) a[0].localeCompare(b[0])); + + return lmarks.concat(umarks); + }, + + + /** + * 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. + */ + // TODO: add support for frameset pages + add: function (mark, silent) { + let win = window.content; + let doc = win.document; + + if (!doc.body) + return; + if (doc.body instanceof HTMLFrameSetElement) { + if (!silent) + liberator.echoerr("Marks support for frameset pages not implemented yet"); + return; + } + + let x = win.scrollMaxX ? win.pageXOffset / win.scrollMaxX : 0; + let y = win.scrollMaxY ? win.pageYOffset / win.scrollMaxY : 0; + let position = { x: x, y: y }; + + if (Marks.isURLMark(mark)) { + this._urlMarks.set(mark, { location: win.location.href, position: position, tab: tabs.getTab() }); + if (!silent) + liberator.log("Adding URL mark: " + Marks.markToString(mark, this._urlMarks.get(mark)), 5); + } + else if (Marks.isLocalMark(mark)) { + // remove any previous mark of the same name for this location + this._removeLocalMark(mark); + if (!this._localMarks.get(mark)) + this._localMarks.set(mark, []); + let vals = { location: win.location.href, position: position }; + this._localMarks.get(mark).push(vals); + if (!silent) + liberator.log("Adding local mark: " + Marks.markToString(mark, vals), 5); + } + }, + + /** + * Remove all marks matching <b>filter</b>. If <b>special</b> is + * given, removes all local marks. + * + * @param {string} filter A string containing one character for each + * mark to be removed. + * @param {boolean} special Whether to delete all local marks. + */ + // FIXME: Shouldn't special be replaced with a null filter? + remove: function (filter, special) { + if (special) { + // :delmarks! only deletes a-z marks + for (let [mark, ] in this._localMarks) + this._removeLocalMark(mark); + } + else { + for (let [mark, ] in this._urlMarks) { + if (filter.indexOf(mark) >= 0) + this._removeURLMark(mark); + } + for (let [mark, ] in this._localMarks) { + if (filter.indexOf(mark) >= 0) + this._removeLocalMark(mark); + } + } + }, + + /** + * Jumps to the named mark. See {@link #add} + * + * @param {string} mark The mark to jump to. + */ + jumpTo: function (mark) { + let ok = false; + + if (Marks.isURLMark(mark)) { + let slice = this._urlMarks.get(mark); + if (slice && slice.tab && slice.tab.linkedBrowser) { + if (slice.tab.parentNode != getBrowser().tabContainer) { + this._pendingJumps.push(slice); + // NOTE: this obviously won't work on generated pages using + // non-unique URLs :( + liberator.open(slice.location, liberator.NEW_TAB); + return; + } + let index = tabs.index(slice.tab); + if (index != -1) { + tabs.select(index); + let win = slice.tab.linkedBrowser.contentWindow; + if (win.location.href != slice.location) { + this._pendingJumps.push(slice); + win.location.href = slice.location; + return; + } + liberator.log("Jumping to URL mark: " + Marks.markToString(mark, slice), 5); + buffer.scrollToPercent(slice.position.x * 100, slice.position.y * 100); + ok = true; + } + } + } + else if (Marks.isLocalMark(mark)) { + let win = window.content; + let slice = this._localMarks.get(mark) || []; + + for (let [, lmark] in Iterator(slice)) { + if (win.location.href == lmark.location) { + liberator.log("Jumping to local mark: " + Marks.markToString(mark, lmark), 5); + buffer.scrollToPercent(lmark.position.x * 100, lmark.position.y * 100); + ok = true; + break; + } + } + } + + if (!ok) + liberator.echoerr("E20: Mark not set"); + }, + + /** + * List all marks matching <b>filter</b>. + * + * @param {string} filter + */ + list: function (filter) { + let marks = this.all; + + liberator.assert(marks.length > 0, "No marks set"); + + if (filter.length > 0) { + marks = marks.filter(function (mark) filter.indexOf(mark[0]) >= 0); + liberator.assert(marks.length > 0, "E283: No marks matching " + filter.quote()); + } + + let list = template.tabular( + ["Mark", "Line", "Column", "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)))); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + _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; + } + } + }, + + _removeLocalMark: function _removeLocalMark(mark) { + let localmark = this._localMarks.get(mark); + if (localmark) { + let win = window.content; + for (let [i, ] in Iterator(localmark)) { + if (localmark[i].location == win.location.href) { + liberator.log("Deleting local mark: " + Marks.markToString(mark, localmark[i]), 5); + localmark.splice(i, 1); + if (localmark.length == 0) + this._localMarks.remove(mark); + break; + } + } + } + }, + + _removeURLMark: function _removeURLMark(mark) { + let urlmark = this._urlMarks.get(mark); + if (urlmark) { + liberator.log("Deleting URL mark: " + Marks.markToString(mark, urlmark), 5); + this._urlMarks.remove(mark); + } + }, + + _localMarkIter: function _localMarkIter() { + for (let [mark, value] in this._localMarks) + for (let [, val] in Iterator(value)) + yield [mark, val]; + }, + +}, { + markToString: function markToString(name, mark) { + return name + ", " + mark.location + + ", (" + Math.round(mark.position.x * 100) + + "%, " + Math.round(mark.position.y * 100) + "%)" + + (("tab" in mark) ? ", tab: " + tabs.index(mark.tab) : ""); + }, + + isLocalMark: function isLocalMark(mark) /^['`a-z]$/.test(mark), + isURLMark: function isURLMark(mark) /^[A-Z0-9]$/.test(mark), +}, { + mappings: function () { + var myModes = config.browserModes; + + mappings.add(myModes, + ["m"], "Set mark at the cursor position", + function (arg) { + if (/[^a-zA-Z]/.test(arg)) + return void liberator.beep(); + + 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; + args = args.string; + + // assert(special ^ args) + liberator.assert( special || args, "E471: Argument required"); + liberator.assert(!special || !args, "E474: Invalid argument"); + + let matches; + if (matches = args.match(/(?:(?:^|[^a-zA-Z0-9])-|-(?:$|[^a-zA-Z0-9])|[^a-zA-Z0-9 -]).*/)) { + // NOTE: this currently differs from Vim's behavior which + // deletes any valid marks in the arg list, up to the first + // invalid arg, as well as giving the error message. + liberator.echoerr("E475: Invalid argument: " + matches[0]); + return; + } + // check for illegal ranges - only allow a-z A-Z 0-9 + if (matches = args.match(/[a-zA-Z0-9]-[a-zA-Z0-9]/g)) { + for (let i = 0; i < matches.length; i++) { + let start = matches[i][0]; + let end = matches[i][2]; + if (/[a-z]/.test(start) != /[a-z]/.test(end) || + /[A-Z]/.test(start) != /[A-Z]/.test(end) || + /[0-9]/.test(start) != /[0-9]/.test(end) || + start > end) + { + liberator.echoerr("E475: Invalid argument: " + args.match(matches[i] + ".*")[0]); + return; + } + } + } + + marks.remove(args, special); + }, + { + bang: true, + completer: function (context) completion.mark(context) + }); + + commands.add(["ma[rk]"], + "Mark current location within the web page", + function (args) { + let mark = args[0]; + liberator.assert(mark.length <= 1, "E488: Trailing characters"); + liberator.assert(/[a-zA-Z]/.test(mark), + "E191: Argument must be a letter or forward/backward quote"); + + marks.add(mark); + }, + { argCount: "1" }); + + commands.add(["marks"], + "Show all location marks of current web page", + function (args) { + args = args.string; + + // ignore invalid mark characters unless there are no valid mark chars + liberator.assert(!args || /[a-zA-Z]/.test(args), + "E283: No marks matching " + args.quote()); + + let filter = args.replace(/[^a-zA-Z]/g, ""); + marks.list(filter); + }); + }, + + 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", "Line Column File"]; + context.keys.description = function ([, m]) percent(m.position.y) + "% " + percent(m.position.x) + "% " + m.location; + context.completions = marks.all; + }; + }, +}); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/modes.js b/common/content/modes.js index ebfc7e65..c47ea658 100644 --- a/common/content/modes.js +++ b/common/content/modes.js @@ -5,29 +5,55 @@ /** @scope modules */ -const modes = (function () //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var main = 1; // NORMAL - var extended = 0; // NONE - - var lastShown = null; - - var passNextKey = false; - var passAllKeys = false; - var isRecording = false; - var isReplaying = false; // playing a macro - - var modeStack = []; - - function getModeMessage() - { - if (passNextKey && !passAllKeys) +const Modes = Module("modes", { + requires: ["util"], + + init: function () { + this._main = 1; // NORMAL + this._extended = 0; // NONE + + this._lastShown = null; + + this._passNextKey = false; + this._passAllKeys = false; + this._isRecording = false; + this._isReplaying = false; // playing a macro + + this._modeStack = []; + + this._mainModes = [self.NONE]; + this._lastMode = 0; + this._modeMap = {}; + + // main modes, only one should ever be active + this.addMode("NORMAL", { char: "n", display: -1 }); + this.addMode("INSERT", { char: "i", input: true }); + this.addMode("VISUAL", { char: "v", display: function () "VISUAL" + (this._extended & modes.LINE ? " LINE" : "") }); + this.addMode("COMMAND_LINE", { char: "c", input: true }); + this.addMode("CARET"); // text cursor is visible + this.addMode("TEXTAREA", { char: "i" }); + this.addMode("EMBED", { input: true }); + this.addMode("CUSTOM", { display: function () plugins.mode }); + // this._extended modes, can include multiple modes, and even main modes + this.addMode("EX", true); + this.addMode("HINTS", true); + this.addMode("INPUT_MULTILINE", true); + this.addMode("OUTPUT_MULTILINE", true); + this.addMode("SEARCH_FORWARD", true); + this.addMode("SEARCH_BACKWARD", true); + this.addMode("SEARCH_VIEW_FORWARD", true); + this.addMode("SEARCH_VIEW_BACKWARD", true); + this.addMode("MENU", true); // a popupmenu is active + this.addMode("LINE", true); // linewise visual mode + this.addMode("PROMPT", true); + + config.modes.forEach(function (mode) { this.addMode.apply(this, mode); }, this); + }, + + _getModeMessage: function () { + if (this._passNextKey && !this._passAllKeys) return "-- PASS THROUGH (next) --"; - else if (passAllKeys && !passNextKey) + else if (this._passAllKeys && !this._passNextKey) return "-- PASS THROUGH --"; // when recording a macro @@ -38,57 +64,52 @@ const modes = (function () //{{{ macromode = "replaying"; let ext = ""; - if (extended & modes.MENU) // TODO: desirable? + if (this._extended & modes.MENU) // TODO: desirable? ext += " (menu)"; ext += " --" + macromode; - if (main in modeMap && typeof modeMap[main].display == "function") - return "-- " + modeMap[main].display() + ext; + if (this._main in this._modeMap && typeof this._modeMap[this._main].display == "function") + return "-- " + this._modeMap[this._main].display() + ext; return macromode; - } + }, // NOTE: Pay attention that you don't run into endless loops // Usually you should only indicate to leave a special mode like HINTS // by calling modes.reset() and adding the stuff which is needed // for its cleanup here - function handleModeChange(oldMode, newMode, oldExtended) - { - - switch (oldMode) - { - case modes.TEXTAREA: - case modes.INSERT: - editor.unselectText(); - break; - - case modes.VISUAL: - if (newMode == modes.CARET) - { - try - { // clear any selection made; a simple if (selection) does not work - let selection = window.content.getSelection(); - selection.collapseToStart(); - } - catch (e) {} + _handleModeChange: function (oldMode, newMode, oldExtended) { + + switch (oldMode) { + case modes.TEXTAREA: + case modes.INSERT: + editor.unselectText(); + break; + + case modes.VISUAL: + if (newMode == modes.CARET) { + try { // clear any selection made; a simple if (selection) does not work + let selection = window.content.getSelection(); + selection.collapseToStart(); } - else - editor.unselectText(); - break; - - case modes.CUSTOM: - plugins.stop(); - break; - - case modes.COMMAND_LINE: - // clean up for HINT mode - if (oldExtended & modes.HINTS) - hints.hide(); - commandline.close(); - break; + catch (e) {} + } + else + editor.unselectText(); + break; + + case modes.CUSTOM: + plugins.stop(); + break; + + case modes.COMMAND_LINE: + // clean up for HINT mode + if (oldExtended & modes.HINTS) + hints.hide(); + commandline.close(); + break; } - if (newMode == modes.NORMAL) - { + if (newMode == modes.NORMAL) { // disable caret mode when we want to switch to normal mode if (options.getPref("accessibility.browsewithcaret")) options.setPref("accessibility.browsewithcaret", false); @@ -96,185 +117,134 @@ const modes = (function () //{{{ statusline.updateUrl(); liberator.focusContent(true); } - } + }, - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + NONE: 0, - const self = { + __iterator__: function () util.Array.itervalues(this.all), - NONE: 0, + get all() this._mainModes.slice(), - __iterator__: function () util.Array.itervalues(this.all), + get mainModes() (mode for ([k, mode] in Iterator(modes._modeMap)) if (!mode.extended && mode.name == k)), - get all() mainModes.slice(), + get mainMode() this._modeMap[this._main], - get mainModes() (mode for ([k, mode] in Iterator(modeMap)) if (!mode.extended && mode.name == k)), - - get mainMode() modeMap[main], + addMode: function (name, extended, options) { + let disp = name.replace("_", " ", "g"); + this[name] = 1 << this._lastMode++; + if (typeof extended == "object") { + options = extended; + extended = false; + } + this._modeMap[name] = this._modeMap[this[name]] = util.extend({ + extended: extended, + count: true, + input: false, + mask: this[name], + name: name, + disp: disp + }, options); + this._modeMap[name].display = this._modeMap[name].display || function () disp; + if (!extended) + this._mainModes.push(this[name]); + if ("mappings" in modules) + mappings.addMode(this[name]); + }, + + getMode: function (name) this._modeMap[name], + + // show the current mode string in the command line + show: function () { + let msg = ""; + if (options["showmode"]) + msg = this._getModeMessage(); + + commandline.echo(msg, "ModeMsg", commandline.FORCE_SINGLELINE); + }, + + // add/remove always work on the this._extended mode only + add: function (mode) { + this._extended |= mode; + this.show(); + }, + + // helper function to set both modes in one go + // if silent == true, you also need to take care of the mode handling changes yourself + set: function (mainMode, extendedMode, silent, stack) { + silent = (silent || this._main == mainMode && this._extended == extendedMode); + // if a this._main mode is set, the this._extended is always cleared + let oldMain = this._main, oldExtended = this._extended; + if (typeof extendedMode === "number") + this._extended = extendedMode; + if (typeof mainMode === "number") { + this._main = mainMode; + if (!extendedMode) + this._extended = modes.NONE; + + if (this._main != oldMain) + this._handleModeChange(oldMain, mainMode, oldExtended); + } + liberator.triggerObserver("modeChange", [oldMain, oldExtended], [this._main, this._extended], stack); - addMode: function (name, extended, options) - { - let disp = name.replace("_", " ", "g"); - this[name] = 1 << lastMode++; - if (typeof extended == "object") - { - options = extended; - extended = false; - } - modeMap[name] = modeMap[this[name]] = util.extend({ - extended: extended, - count: true, - input: false, - mask: this[name], - name: name, - disp: disp - }, options); - modeMap[name].display = modeMap[name].display || function () disp; - if (!extended) - mainModes.push(this[name]); - if ("mappings" in modules) - mappings.addMode(this[name]); - }, - - getMode: function (name) modeMap[name], - - // show the current mode string in the command line - show: function () - { - let msg = ""; - if (options["showmode"]) - msg = getModeMessage(); - - commandline.echo(msg, "ModeMsg", commandline.FORCE_SINGLELINE); - }, - - // add/remove always work on the extended mode only - add: function (mode) - { - extended |= mode; + if (!silent) this.show(); - }, - - // helper function to set both modes in one go - // if silent == true, you also need to take care of the mode handling changes yourself - set: function (mainMode, extendedMode, silent, stack) - { - silent = (silent || main == mainMode && extended == extendedMode); - // if a main mode is set, the extended is always cleared - let oldMain = main, oldExtended = extended; - if (typeof extendedMode === "number") - extended = extendedMode; - if (typeof mainMode === "number") - { - main = mainMode; - if (!extendedMode) - extended = modes.NONE; - - if (main != oldMain) - handleModeChange(oldMain, mainMode, oldExtended); - } - liberator.triggerObserver("modeChange", [oldMain, oldExtended], [main, extended], stack); - - if (!silent) - this.show(); - }, - - push: function (mainMode, extendedMode, silent) - { - modeStack.push([main, extended]); - this.set(mainMode, extendedMode, silent, { push: modeStack[modeStack.length - 1] }); - }, - - pop: function (silent) - { - let a = modeStack.pop(); - if (a) - this.set(a[0], a[1], silent, { pop: a }); - else - this.reset(silent); - }, - - // TODO: Deprecate this in favor of addMode? --Kris - // Ya --djk - setCustomMode: function (modestr, oneventfunc, stopfunc) - { - // TODO this.plugin[id]... ('id' maybe submode or what..) - plugins.mode = modestr; - plugins.onEvent = oneventfunc; - plugins.stop = stopfunc; - }, - - // keeps recording state - reset: function (silent) - { - modeStack = []; - if (config.isComposeWindow) - this.set(modes.COMPOSE, modes.NONE, silent); - else - this.set(modes.NORMAL, modes.NONE, silent); - }, - - remove: function (mode) - { - if (extended & mode) - { - extended &= ~mode; - this.show(); - } - }, - - get passNextKey() passNextKey, - set passNextKey(value) { passNextKey = value; this.show(); }, - - get passAllKeys() passAllKeys, - set passAllKeys(value) { passAllKeys = value; this.show(); }, - - get isRecording() isRecording, - set isRecording(value) { isRecording = value; this.show(); }, - - get isReplaying() isReplaying, - set isReplaying(value) { isReplaying = value; this.show(); }, - - get main() main, - set main(value) { this.set(value); }, - - get extended() extended, - set extended(value) { this.set(null, value); } - - }; - - var mainModes = [self.NONE]; - var lastMode = 0; - var modeMap = {}; - - // main modes, only one should ever be active - self.addMode("NORMAL", { char: "n", display: -1 }); - self.addMode("INSERT", { char: "i", input: true }); - self.addMode("VISUAL", { char: "v", display: function () "VISUAL" + (extended & modes.LINE ? " LINE" : "") }); - self.addMode("COMMAND_LINE", { char: "c", input: true }); - self.addMode("CARET"); // text cursor is visible - self.addMode("TEXTAREA", { char: "i" }); - self.addMode("EMBED", { input: true }); - self.addMode("CUSTOM", { display: function () plugins.mode }); - // extended modes, can include multiple modes, and even main modes - self.addMode("EX", true); - self.addMode("HINTS", true); - self.addMode("INPUT_MULTILINE", true); - self.addMode("OUTPUT_MULTILINE", true); - self.addMode("SEARCH_FORWARD", true); - self.addMode("SEARCH_BACKWARD", true); - self.addMode("SEARCH_VIEW_FORWARD", true); - self.addMode("SEARCH_VIEW_BACKWARD", true); - self.addMode("MENU", true); // a popupmenu is active - self.addMode("LINE", true); // linewise visual mode - self.addMode("PROMPT", true); - - config.modes.forEach(function (mode) { self.addMode.apply(self, mode); }); - - return self; - //}}} -})(); //}}} + }, + + push: function (mainMode, extendedMode, silent) { + this._modeStack.push([this._main, this._extended]); + this.set(mainMode, extendedMode, silent, { push: this._modeStack[this._modeStack.length - 1] }); + }, + + pop: function (silent) { + let a = this._modeStack.pop(); + if (a) + this.set(a[0], a[1], silent, { pop: a }); + else + this.reset(silent); + }, + + // TODO: Deprecate this in favor of addMode? --Kris + // Ya --djk + setCustomMode: function (modestr, oneventfunc, stopfunc) { + // TODO this.plugin[id]... ('id' maybe submode or what..) + plugins.mode = modestr; + plugins.onEvent = oneventfunc; + plugins.stop = stopfunc; + }, + + // keeps recording state + reset: function (silent) { + this._modeStack = []; + if (config.isComposeWindow) + this.set(modes.COMPOSE, modes.NONE, silent); + else + this.set(modes.NORMAL, modes.NONE, silent); + }, + + remove: function (mode) { + if (this._extended & mode) { + this._extended &= ~mode; + this.show(); + } + }, + + get passNextKey() this._passNextKey, + set passNextKey(value) { this._passNextKey = value; this.show(); }, + + get passAllKeys() this._passAllKeys, + set passAllKeys(value) { this._passAllKeys = value; this.show(); }, + + get isRecording() this._isRecording, + set isRecording(value) { this._isRecording = value; this.show(); }, + + get isReplaying() this._isReplaying, + set isReplaying(value) { this._isReplaying = value; this.show(); }, + + get main() this._main, + set main(value) { this.set(value); }, + + get extended() this._extended, + set extended(value) { this.set(null, value); } +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/modules.js b/common/content/modules.js new file mode 100644 index 00000000..7e1a0b1b --- /dev/null +++ b/common/content/modules.js @@ -0,0 +1,73 @@ + +const ModuleBase = Class("ModuleBase", { requires: [] }); +function Module(name, inst, clas, moduleInit) { + const module = Class(name, ModuleBase, inst, clas); + module.INIT = moduleInit || {}; + module.requires = inst.requires || []; + Module.list.push(module); + Module.constructors[name] = module; + return module; +} +Module.list = []; +Module.constructors = {}; + +window.addEventListener("load", function () { + function dump(str) window.dump(String.replace(str, /\n?$/, "\n").replace(/^/m, config.name.toLowerCase() + ": ")); + const start = Date.now(); + const deferredInit = { load: [] }; + const seen = set(); + + function load(module, prereq) { + try { + if (module.name in modules) + return; + if (module.name in seen) + throw Error("Module dependency loop."); + set.add(seen, module.name); + + for (let dep in values(module.requires)) + load(Module.constructors[dep], module.name); + + dump("Load" + (isstring(prereq) ? " " + prereq + " dependency: " : ": ") + module.name); + modules[module.name] = module(); + + function init(mod, module) + function () module.INIT[mod].call(modules[module.name], modules[mod]); + for (let [mod, ] in iter(module.INIT)) + try { + if (mod in modules) + init(mod, module)(); + else { + deferredInit[mod] = deferredInit[mod] || []; + deferredInit[mod].push(init(mod, module)); + } + } + catch(e) { + if (modules.liberator) + liberator.reportError(e); + } + for (let [, fn] in iter(deferredInit[module.name] || [])) + fn(); + } + catch (e) { + dump("Loading " + (module && module.name) + ": " + e); + if (e.stack) + dump(e.stack); + } + } + Module.list.forEach(load); + deferredInit['load'].forEach(call) + + for (let module in values(Module.list)) + delete module.INIT; + + dump("Loaded in " + (Date.now() - start) + "ms\n"); +}, false); + +window.addEventListener("unload", function () { + for (let [, mod] in iter(modules)) + if (mod instanceof ModuleBase && 'destroy' in mod) + mod.destroy(); +}, false); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/options.js b/common/content/options.js index a908deae..32e63ee4 100644 --- a/common/content/options.js +++ b/common/content/options.js @@ -26,97 +26,93 @@ * @optional * @private */ -function Option(names, description, type, defaultValue, extraInfo) //{{{ -{ - if (!extraInfo) - extraInfo = {}; - - /** @property {string} The option's canonical name. */ - this.name = names[0]; - /** @property {string[]} All names by which this option is identified. */ - this.names = names; - /** - * @property {string} The option's data type. One of: - * "boolean" - Boolean E.g. true - * "number" - Integer E.g. 1 - * "string" - String E.g. "Vimperator" - * "charlist" - Character list E.g. "rb" - * "stringlist" - String list E.g. "homepage,quickmark,tabopen,paste" - */ - this.type = type; - /** - * @property {number} The scope of the option. This can be local, global, - * or both. - * @see Options#OPTION_SCOPE_LOCAL - * @see Options#OPTION_SCOPE_GLOBAL - * @see Options#OPTION_SCOPE_BOTH - */ - this.scope = (extraInfo.scope & options.OPTION_SCOPE_BOTH) || options.OPTION_SCOPE_GLOBAL; // XXX set to BOTH by default someday? - kstep - /** - * @property {string} This option's description, as shown in :optionusage. - */ - this.description = description || ""; +const Option = Class("Option", { + init: function (names, description, type, defaultValue, extraInfo) { + if (!extraInfo) + extraInfo = {}; + + /** @property {string} The option's canonical name. */ + this.name = names[0]; + /** @property {string[]} All names by which this option is identified. */ + this.names = names; + /** + * @property {string} The option's data type. One of: + * "boolean" - Boolean E.g. true + * "number" - Integer E.g. 1 + * "string" - String E.g. "Vimperator" + * "charlist" - Character list E.g. "rb" + * "stringlist" - String list E.g. "homepage,quickmark,tabopen,paste" + */ + this.type = type; + /** + * @property {number} The scope of the option. This can be local, global, + * or both. + * @see Options#OPTION_SCOPE_LOCAL + * @see Options#OPTION_SCOPE_GLOBAL + * @see Options#OPTION_SCOPE_BOTH + */ + this.scope = (extraInfo.scope & options.OPTION_SCOPE_BOTH) || options.OPTION_SCOPE_GLOBAL; // XXX set to BOTH by default someday? - kstep + /** + * @property {string} This option's description, as shown in :optionusage. + */ + this.description = description || ""; - /** - * @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. - */ - this.defaultValue = (defaultValue === undefined) ? null : defaultValue; // "", 0 are valid default values + /** + * @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. + */ + this.defaultValue = (defaultValue === undefined) ? null : defaultValue; // "", 0 are valid default values - /** - * @property {function} The function called when the option value is set. - */ - this.setter = extraInfo.setter || null; - /** - * @property {function} The function called when the option value is read. - */ - this.getter = extraInfo.getter || null; - /** - * @property {function(CompletionContext, Args)} This option's completer. - * @see CompletionContext - */ - this.completer = extraInfo.completer || null; - /** - * @property {function} The function called to validate the option's value - * when set. - */ - this.validator = extraInfo.validator || null; - /** - * @property The function called to determine whether the option already - * contains a specified value. - * @see #has - */ - this.checkHas = extraInfo.checkHas || null; + /** + * @property {function} The function called when the option value is set. + */ + this.setter = extraInfo.setter || null; + /** + * @property {function} The function called when the option value is read. + */ + this.getter = extraInfo.getter || null; + /** + * @property {function(CompletionContext, Args)} This option's completer. + * @see CompletionContext + */ + this.completer = extraInfo.completer || null; + /** + * @property {function} The function called to validate the option's value + * when set. + */ + this.validator = extraInfo.validator || null; + /** + * @property The function called to determine whether the option already + * contains a specified value. + * @see #has + */ + this.checkHas = extraInfo.checkHas || null; - /** - * @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. - */ - this.hasChanged = false; - /** - * @property {nsIFile} The script in which this option was last set. null - * implies an interactive command. - */ - this.setFrom = null; - - // add no{option} variant of boolean {option} to this.names - if (this.type == "boolean") - { - this.names = []; // reset since order is important - for (let [, name] in Iterator(names)) - { - this.names.push(name); - this.names.push("no" + name); + /** + * @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. + */ + this.hasChanged = false; + /** + * @property {nsIFile} The script in which this option was last set. null + * implies an interactive command. + */ + this.setFrom = null; + + // add no{option} variant of boolean {option} to this.names + if (this.type == "boolean") { + this.names = []; // reset since order is important + for (let [, name] in Iterator(names)) { + this.names.push(name); + this.names.push("no" + name); + } } - } - - if (this.globalValue == undefined) - this.globalValue = this.defaultValue; -} -Option.prototype = { + if (this.globalValue == undefined) + this.globalValue = this.defaultValue; + }, /** @property {value} The option's global value. @see #scope */ get globalValue() options.store.get(this.name), @@ -129,8 +125,7 @@ Option.prototype = { * @param {value} value The option value. * @returns {value|string[]} */ - parseValues: function (value) - { + parseValues: function (value) { if (this.type == "stringlist") return (value === "") ? [] : value.split(","); if (this.type == "charlist") @@ -145,8 +140,7 @@ Option.prototype = { * @param {value|string[]} values The option value. * @returns {value} */ - joinValues: function (values) - { + joinValues: function (values) { if (this.type == "stringlist") return values.join(","); if (this.type == "charlist") @@ -175,8 +169,7 @@ Option.prototype = { * @param {number} scope The scope to apply these values to (see * {@link Option#scope}). */ - setValues: function (values, scope) - { + setValues: function (values, scope) { this.set(this.joinValues(values), scope || this.scope); }, @@ -189,10 +182,8 @@ Option.prototype = { * {@link Option#scope}). * @returns {value} */ - get: function (scope) - { - if (scope) - { + get: function (scope) { + if (scope) { if ((scope & this.scope) == 0) // option doesn't exist in this scope return null; } @@ -200,6 +191,7 @@ Option.prototype = { scope = this.scope; let aValue; + if (liberator.has("tabs") && (scope & options.OPTION_SCOPE_LOCAL)) aValue = tabs.options[this.name]; if ((scope & options.OPTION_SCOPE_GLOBAL) && (aValue == undefined)) @@ -220,8 +212,7 @@ Option.prototype = { * @param {number} scope The scope to apply this value to (see * {@link Option#scope}). */ - set: function (newValue, scope) - { + set: function (newValue, scope) { scope = scope || this.scope; if ((scope & this.scope) == 0) // option doesn't exist in this scope return null; @@ -250,8 +241,7 @@ Option.prototype = { * * @returns {boolean} */ - has: function () - { + has: function () { let self = this; let test = function (val) values.indexOf(val) >= 0; if (this.checkHas) @@ -273,8 +263,7 @@ Option.prototype = { * Returns whether the specified <b>values</b> are valid for this option. * @see Option#validator */ - isValidValue: function (values) - { + isValidValue: function (values) { if (this.validator) return this.validator(values); else @@ -284,8 +273,7 @@ Option.prototype = { /** * Resets the option to its default value. */ - reset: function () - { + reset: function () { this.value = this.defaultValue; }, @@ -298,97 +286,91 @@ Option.prototype = { * {@link #scope}). * @param {boolean} invert Whether this is an invert boolean operation. */ - op: function (operator, values, scope, invert) - { + op: function (operator, values, scope, invert) { let newValue = null; let self = this; - switch (this.type) - { - case "boolean": - if (operator != "=") - break; - - if (invert) - newValue = !this.value; - else - newValue = values; + switch (this.type) { + case "boolean": + if (operator != "=") break; - case "number": - // TODO: support floats? Validators need updating. - if (!/^[+-]?(?:0x[0-9a-f]+|0[0-7]+|0|[1-9]\d*)$/i.test(values)) - return "E521: Number required after := " + this.name + "=" + values; - - let value = parseInt(values/* deduce radix */); - - switch (operator) - { - case "+": - newValue = this.value + value; - break; - case "-": - newValue = this.value - value; - break; - case "^": - newValue = this.value * value; - break; - case "=": - newValue = value; - break; - } + if (invert) + newValue = !this.value; + else + newValue = values; + break; - break; + case "number": + // TODO: support floats? Validators need updating. + if (!/^[+-]?(?:0x[0-9a-f]+|0[0-7]+|0|[1-9]\d*)$/i.test(values)) + return "E521: Number required after := " + this.name + "=" + values; - case "charlist": - case "stringlist": - values = Array.concat(values); - switch (operator) - { - case "+": - newValue = util.Array.uniq(Array.concat(this.values, values), true); - break; - case "^": - // NOTE: Vim doesn't prepend if there's a match in the current value - newValue = util.Array.uniq(Array.concat(values, this.values), true); - break; - case "-": - newValue = this.values.filter(function (item) values.indexOf(item) == -1); - break; - case "=": - newValue = values; - if (invert) - { - let keepValues = this.values.filter(function (item) values.indexOf(item) == -1); - let addValues = values.filter(function (item) self.values.indexOf(item) == -1); - newValue = addValues.concat(keepValues); - } - break; - } + let value = parseInt(values/* deduce radix */); + switch (operator) { + case "+": + newValue = this.value + value; + break; + case "-": + newValue = this.value - value; + break; + case "^": + newValue = this.value * value; + break; + case "=": + newValue = value; break; + } + + break; - case "string": - switch (operator) - { - case "+": - newValue = this.value + values; - break; - case "-": - newValue = this.value.replace(values, ""); - break; - case "^": - newValue = values + this.value; - break; - case "=": - newValue = values; - break; + case "charlist": + case "stringlist": + values = Array.concat(values); + switch (operator) { + case "+": + newValue = util.Array.uniq(Array.concat(this.values, values), true); + break; + case "^": + // NOTE: Vim doesn't prepend if there's a match in the current value + newValue = util.Array.uniq(Array.concat(values, this.values), true); + break; + case "-": + newValue = this.values.filter(function (item) values.indexOf(item) == -1); + break; + case "=": + newValue = values; + if (invert) { + let keepValues = this.values.filter(function (item) values.indexOf(item) == -1); + let addValues = values.filter(function (item) self.values.indexOf(item) == -1); + newValue = addValues.concat(keepValues); } + break; + } + break; + + case "string": + switch (operator) { + case "+": + newValue = this.value + values; + break; + case "-": + newValue = this.value.replace(values, ""); break; + case "^": + newValue = values + this.value; + break; + case "=": + newValue = values; + break; + } - default: - return "E685: Internal error: option type `" + this.type + "' not supported"; + break; + + default: + return "E685: Internal error: option type `" + this.type + "' not supported"; } if (newValue == null) @@ -397,168 +379,571 @@ Option.prototype = { return "E474: Invalid argument: " + values; this.setValues(newValue, scope); } -}; - -// TODO: Run this by default? -/** - * Validates the specified <b>values</b> against values generated by the - * option's completer function. - * - * @param {value|string[]} values The value or array of values to validate. - * @returns {boolean} - */ -Option.validateCompleter = function (values) -{ - let context = CompletionContext(""); - let res = context.fork("", 0, this, this.completer); - if (!res) - res = context.allItems.items.map(function (item) [item.text]); - return Array.concat(values).every(function (value) res.some(function (item) item[0] == value)); -}; //}}} +}, { + // TODO: Run this by default? + /** + * Validates the specified <b>values</b> 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 (values) { + let context = CompletionContext(""); + let res = context.fork("", 0, this, this.completer); + if (!res) + res = context.allItems.items.map(function (item) [item.text]); + return Array.concat(values).every(function (value) res.some(function (item) item[0] == value)); + }, +}); /** * @instance options */ -function Options() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ +const Options = Module("options", { + requires: ["highlight", "storage"], + + init: function () { + for (let [, pref] in Iterator(this.allPrefs(Options.OLD_SAVED))) { + let saved = Options.SAVED + pref.substr(Options.OLD_SAVED.length) + if (!this.getPref(saved)) + this.setPref(saved, this.getPref(pref)); + this.resetPref(pref); + } + + // Host application preferences which need to be changed to work well with + // + + // Work around the popup blocker + // TODO: Make this work like safeSetPref + var popupAllowedEvents = this._loadPreference("dom.popup_allowed_events", "change click dblclick mouseup reset submit"); + if (!/keypress/.test(popupAllowedEvents)) { + this._storePreference("dom.popup_allowed_events", popupAllowedEvents + " keypress"); + liberator.registerObserver("shutdown", function () { + if (this._loadPreference("dom.popup_allowed_events", "") == popupAllowedEvents + " keypress") + this._storePreference("dom.popup_allowed_events", popupAllowedEvents); + }); + } - const SAVED = "extensions.liberator.saved."; - const OLD_SAVED = "liberator.saved."; - const optionHash = {}; + function optionObserver(key, event, option) { + // Trigger any setters. + let opt = options.get(option); + if (event == "change" && opt) + opt.set(opt.value, options.OPTION_SCOPE_GLOBAL); + } - const prefContexts = []; + storage.newMap("options", { store: false }); + storage.addObserver("options", optionObserver, window); - function optionObserver(key, event, option) - { - // Trigger any setters. - let opt = options.get(option); - if (event == "change" && opt) - opt.set(opt.value, options.OPTION_SCOPE_GLOBAL); - } + this._optionHash = {}; + this._prefContexts = []; - storage.newMap("options", { store: false }); - storage.addObserver("options", optionObserver, window); + this.prefObserver.register(); + }, - function storePreference(name, value) - { - if (prefContexts.length) - { - let val = loadPreference(name, null); - if (val != null) - prefContexts[prefContexts.length - 1][name] = val; + destroy: function () { + this.prefObserver.unregister(); + }, + + /** + * @property {number} Global option scope. + * @final + */ + OPTION_SCOPE_GLOBAL: 1, + + /** + * @property {number} Local option scope. Options in this scope only + * apply to the current tab/buffer. + * @final + */ + OPTION_SCOPE_LOCAL: 2, + + /** + * @property {number} Both local and global option scope. + * @final + */ + OPTION_SCOPE_BOTH: 3, + + /** @property {Iterator(Option)} @private */ + __iterator__: function () { + let sorted = [o for ([i, o] in Iterator(this._optionHash))].sort(function (a, b) String.localeCompare(a.name, b.name)); + return (v for ([k, v] in Iterator(sorted))); + }, + + /** @property {Object} Observes preference value changes. */ + prefObserver: { + register: function () { + // better way to monitor all changes? + this._branch = services.get("pref").getBranch("").QueryInterface(Ci.nsIPrefBranch2); + this._branch.addObserver("", this, false); + }, + + unregister: function () { + if (this._branch) + this._branch.removeObserver("", this); + }, + + observe: function (subject, topic, data) { + if (topic != "nsPref:changed") + return; + + // subject is the nsIPrefBranch we're observing (after appropriate QI) + // data is the name of the pref that's been changed (relative to subject) + switch (data) { + case "accessibility.browsewithcaret": + let value = options.getPref("accessibility.browsewithcaret", false); + liberator.mode = value ? modes.CARET : modes.NORMAL; + break; + } + } + }, + + /** + * 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 + * @returns {boolean} Whether the option was created. + */ + add: function (names, description, type, defaultValue, extraInfo) { + if (!extraInfo) + extraInfo = {}; + + let option = new Option(names, description, type, defaultValue, extraInfo); + + if (!option) + return false; + + if (option.name in this._optionHash) { + // never replace for now + liberator.log("Warning: '" + names[0] + "' already exists, NOT replacing existing option.", 1); + return false; } - let type = services.get("pref").getPrefType(name); - switch (typeof value) - { - case "string": - if (type == Ci.nsIPrefBranch.PREF_INVALID || type == Ci.nsIPrefBranch.PREF_STRING) - { - let supportString = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); - supportString.data = value; - services.get("pref").setComplexValue(name, Ci.nsISupportsString, supportString); + // quickly access options with options["wildmode"]: + this.__defineGetter__(option.name, function () option.value); + this.__defineSetter__(option.name, function (value) { option.value = value; }); + + this._optionHash[option.name] = option; + return true; + }, + + /** + * Returns the names of all preferences. + * + * @param {string} branch The branch in which to search preferences. + * @default "" + */ + allPrefs: function (branch) services.get("pref").getChildList(branch || "", { value: 0 }), + + /** + * Returns the option with <b>name</b> in the specified <b>scope</b>. + * + * @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 (name, scope) { + if (!scope) + scope = options.OPTION_SCOPE_BOTH; + + if (name in this._optionHash) + return (this._optionHash[name].scope & scope) && this._optionHash[name]; + + for (let opt in Iterator(options)) { +try { + if (opt.hasName(name)) + return (opt.scope & scope) && opt; +}catch(e) { + liberator.dump(options.__iterator__); + liberator.dump(opt); + liberator.reportError(e); + throw e +} + } + + return null; + }, + + /** + * Lists all options in <b>scope</b> or only those with changed values + * if <b>onlyNonDefault</b> is specified. + * + * @param {boolean} onlyNonDefault Limit the list to prefs with a + * non-default value. + * @param {number} scope Only list options in this scope (see + * {@link Option#scope}). + */ + list: function (onlyNonDefault, scope) { + if (!scope) + scope = options.OPTION_SCOPE_BOTH; + + function opts(opt) { + for (let opt in Iterator(options)) { + let option = { + isDefault: opt.value == opt.defaultValue, + name: opt.name, + default: opt.defaultValue, + pre: "\u00a0\u00a0", // Unicode nonbreaking space. + value: <></> + }; + + if (onlyNonDefault && option.isDefault) + continue; + if (!(opt.scope & scope)) + continue; + + if (opt.type == "boolean") { + if (!opt.value) + option.pre = "no"; + option.default = (option.default ? "" : "no") + opt.name; } - else if (type == Ci.nsIPrefBranch.PREF_INT) - liberator.echoerr("E521: Number required after =: " + name + "=" + value); else - liberator.echoerr("E474: Invalid argument: " + name + "=" + value); - break; - case "number": - if (type == Ci.nsIPrefBranch.PREF_INVALID || type == Ci.nsIPrefBranch.PREF_INT) - services.get("pref").setIntPref(name, value); - else - liberator.echoerr("E474: Invalid argument: " + name + "=" + value); - break; - case "boolean": - if (type == Ci.nsIPrefBranch.PREF_INVALID || type == Ci.nsIPrefBranch.PREF_BOOL) - services.get("pref").setBoolPref(name, value); - else if (type == Ci.nsIPrefBranch.PREF_INT) - liberator.echoerr("E521: Number required after =: " + name + "=" + value); - else - liberator.echoerr("E474: Invalid argument: " + name + "=" + value); - break; - default: - liberator.echoerr("Unknown preference type: " + typeof value + " (" + name + "=" + value + ")"); + option.value = <>={template.highlight(opt.value)}</>; + yield option; + } + }; + + let list = template.options("Options", opts()); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + /** + * Lists all preferences matching <b>filter</b> or only those with + * changed values if <b>onlyNonDefault</b> 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 + */ + listPrefs: function (onlyNonDefault, filter) { + if (!filter) + filter = ""; + + let prefArray = options.allPrefs(); + prefArray.sort(); + function prefs() { + for (let [, pref] in Iterator(prefArray)) { + let userValue = services.get("pref").prefHasUserValue(pref); + if (onlyNonDefault && !userValue || pref.indexOf(filter) == -1) + continue; + + value = options.getPref(pref); + + let option = { + isDefault: !userValue, + default: options._loadPreference(pref, null, true), + value: <>={template.highlight(value, true, 100)}</>, + name: pref, + pre: "\u00a0\u00a0" // Unicode nonbreaking space. + }; + + yield option; + } + }; + + let list = template.options(config.hostApplication + " Options", prefs()); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + /** + * 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 ret = {}; + let matches, prefix, postfix, valueGiven; + + [matches, prefix, ret.name, postfix, valueGiven, ret.operator, ret.value] = + args.match(/^\s*(no|inv)?([a-z_]*)([?&!])?\s*(([-+^]?)=(.*))?\s*$/) || []; + + ret.args = args; + ret.onlyNonDefault = false; // used for :set to print non-default options + if (!args) { + ret.name = "all"; + ret.onlyNonDefault = true; } - } - function loadPreference(name, forcedDefault, defaultBranch) - { + if (matches) + ret.option = options.get(ret.name, ret.scope); + + ret.prefix = prefix; + ret.postfix = postfix; + + ret.all = (ret.name == "all"); + ret.get = (ret.all || postfix == "?" || (ret.option && ret.option.type != "boolean" && !valueGiven)); + ret.invert = (prefix == "inv" || postfix == "!"); + ret.reset = (postfix == "&"); + ret.unsetBoolean = (prefix == "no"); + + ret.scope = modifiers && modifiers.scope; + + if (!ret.option) + return ret; + + if (ret.value === undefined) + ret.value = ""; + + ret.optionValue = ret.option.get(ret.scope); + ret.optionValues = ret.option.getValues(ret.scope); + + ret.values = ret.option.parseValues(ret.value); + + return ret; + }, + + /** + * Remove the option with matching <b>name</b>. + * + * @param {string} name The name of the option to remove. This can be + * any of the options's names. + */ + remove: function (name) { + for each (let option in this._optionHash) { + if (option.hasName(name)) + delete this._optionHash[option.name]; + } + }, + + /** @property {Object} The options store. */ + get store() storage.options, + + /** + * Returns the value of the preference <b>name</b>. + * + * @param {string} name The preference name. + * @param {value} forcedDefault The the default value for this + * preference. Used for internal liberator preferences. + */ + getPref: function (name, forcedDefault) { + return this._loadPreference(name, forcedDefault); + }, + + /** + * Sets the preference <b>name</b> to </b>value</b> 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. + */ + // FIXME: Well it used to. I'm looking at you mst! --djk + safeSetPref: function (name, value, message) { + let val = this._loadPreference(name, null, false); + let def = this._loadPreference(name, null, true); + let lib = this._loadPreference(Options.SAVED + name); + if (lib == null && val != def || val != lib) { + let msg = "Warning: setting preference " + name + ", but it's changed from its default value."; + if (message) + msg += " " + message; + liberator.echomsg(msg); + } + this._storePreference(name, value); + this._storePreference(Options.SAVED + name, value); + }, + + /** + * Sets the preference <b>name</b> to </b>value</b>. + * + * @param {string} name The preference name. + * @param {value} value The new preference value. + */ + setPref: function (name, value) { + this._storePreference(name, value); + }, + + /** + * Resets the preference <b>name</b> to its default value. + * + * @param {string} name The preference name. + */ + resetPref: function (name) { + try { + services.get("pref").clearUserPref(name); + } + catch (e) { + // ignore - thrown if not a user set value + } + }, + + /** + * Toggles the value of the boolean preference <b>name</b>. + * + * @param {string} name The preference name. + */ + invertPref: function (name) { + if (services.get("pref").getPrefType(name) == Ci.nsIPrefBranch.PREF_BOOL) + this.setPref(name, !this.getPref(name)); + else + liberator.echoerr("E488: Trailing characters: " + 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._storePreference(k, v); + }, + + /** + * Executes <b>func</b> with a new preference context. When <b>func</b> + * 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 <b>func</b> + * @see #pushContext + * @see #popContext + */ + withContext: function (func, self) { + try { + this.pushContext(); + return func.call(self); + } + finally { + this.popContext(); + } + }, + + _storePreference: function (name, value) { + if (this._prefContexts.length) { + let val = this._loadPreference(name, null); + if (val != null) + this._prefContexts[this._prefContexts.length - 1][name] = val; + } + + let type = services.get("pref").getPrefType(name); + switch (typeof value) { + case "string": + if (type == Ci.nsIPrefBranch.PREF_INVALID || type == Ci.nsIPrefBranch.PREF_STRING) { + let supportString = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); + supportString.data = value; + services.get("pref").setComplexValue(name, Ci.nsISupportsString, supportString); + } + else if (type == Ci.nsIPrefBranch.PREF_INT) + liberator.echoerr("E521: Number required after =: " + name + "=" + value); + else + liberator.echoerr("E474: Invalid argument: " + name + "=" + value); + break; + case "number": + if (type == Ci.nsIPrefBranch.PREF_INVALID || type == Ci.nsIPrefBranch.PREF_INT) + services.get("pref").setIntPref(name, value); + else + liberator.echoerr("E474: Invalid argument: " + name + "=" + value); + break; + case "boolean": + if (type == Ci.nsIPrefBranch.PREF_INVALID || type == Ci.nsIPrefBranch.PREF_BOOL) + services.get("pref").setBoolPref(name, value); + else if (type == Ci.nsIPrefBranch.PREF_INT) + liberator.echoerr("E521: Number required after =: " + name + "=" + value); + else + liberator.echoerr("E474: Invalid argument: " + name + "=" + value); + break; + default: + liberator.echoerr("Unknown preference type: " + typeof value + " (" + name + "=" + value + ")"); + } + }, + + _loadPreference: function (name, forcedDefault, defaultBranch) { let defaultValue = null; // XXX if (forcedDefault != null) // this argument sets defaults for non-user settable options (like extensions.history.comp_history) defaultValue = forcedDefault; let branch = defaultBranch ? services.get("pref").getDefaultBranch("") : services.get("pref"); let type = services.get("pref").getPrefType(name); - try - { - switch (type) - { - case Ci.nsIPrefBranch.PREF_STRING: - let value = branch.getComplexValue(name, Ci.nsISupportsString).data; - // try in case it's a localized string (will throw an exception if not) - if (!services.get("pref").prefIsLocked(name) && !services.get("pref").prefHasUserValue(name) && - RegExp("chrome://.+/locale/.+\\.properties").test(value)) - value = branch.getComplexValue(name, Ci.nsIPrefLocalizedString).data; - return value; - case Ci.nsIPrefBranch.PREF_INT: - return branch.getIntPref(name); - case Ci.nsIPrefBranch.PREF_BOOL: - return branch.getBoolPref(name); - default: - return defaultValue; + try { + switch (type) { + case Ci.nsIPrefBranch.PREF_STRING: + let value = branch.getComplexValue(name, Ci.nsISupportsString).data; + // try in case it's a localized string (will throw an exception if not) + if (!services.get("pref").prefIsLocked(name) && !services.get("pref").prefHasUserValue(name) && + RegExp("chrome://.+/locale/.+\\.properties").test(value)) + value = branch.getComplexValue(name, Ci.nsIPrefLocalizedString).data; + return value; + case Ci.nsIPrefBranch.PREF_INT: + return branch.getIntPref(name); + case Ci.nsIPrefBranch.PREF_BOOL: + return branch.getBoolPref(name); + default: + return defaultValue; } } - catch (e) - { + catch (e) { return defaultValue; } - } - - function setAction(args, modifiers) - { - let bang = args.bang; - if (!args.length) - args[0] = ""; - - 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*?([a-zA-Z0-9\.\-_{}]+)([?&!])?\s*(([-+^]?)=(.*))?\s*$/); - reset = (postfix == "&"); - invertBoolean = (postfix == "!"); - } + }, +}, { + SAVED: "extensions.liberator.saved.", + OLD_SAVED: "liberator.saved.", +}, { + commandline: function () { + // TODO: maybe reset in .destroy()? + // TODO: move to buffer.js + // we have our own typeahead find implementation + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=348187 + options.safeSetPref("accessibility.typeaheadfind.autostart", false); + options.safeSetPref("accessibility.typeaheadfind", false); // actually the above setting should do it, but has no effect in Firefox + }, + commands: function () { + function setAction(args, modifiers) { + let bang = args.bang; + if (!args.length) + args[0] = ""; + + 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*?([a-zA-Z0-9\.\-_{}]+)([?&!])?\s*(([-+^]?)=(.*))?\s*$/); + reset = (postfix == "&"); + invertBoolean = (postfix == "!"); + } - if (name == "all" && reset) - // TODO: Why? --djk - liberator.echoerr("You can't reset all options, it could make " + config.hostApplication + " unusable."); - else if (name == "all") - options.listPrefs(onlyNonDefault, ""); - else if (reset) - options.resetPref(name); - else if (invertBoolean) - options.invertPref(name); - else if (valueGiven) - { - switch (value) - { + if (name == "all" && reset) + // TODO: Why? --djk + liberator.echoerr("You can't reset all options, it could make " + config.hostApplication + " unusable."); + else if (name == "all") + options.listPrefs(onlyNonDefault, ""); + else if (reset) + options.resetPref(name); + else if (invertBoolean) + options.invertPref(name); + else if (valueGiven) { + switch (value) { case undefined: value = ""; break; @@ -571,329 +956,283 @@ function Options() //{{{ default: if (/^\d+$/.test(value)) value = parseInt(value, 10); + } + options.setPref(name, value); } - options.setPref(name, value); + else + options.listPrefs(onlyNonDefault, name); + return; } - else - options.listPrefs(onlyNonDefault, name); - return; - } - let opt = options.parseOpt(arg, modifiers); - liberator.assert(opt, "Error parsing :set command: " + arg); + let opt = options.parseOpt(arg, modifiers); + liberator.assert(opt, "Error parsing :set command: " + arg); - let option = opt.option; - liberator.assert(option != null || opt.all, - "E518: Unknown option: " + arg); + let option = opt.option; + liberator.assert(option != null || opt.all, + "E518: Unknown option: " + opt.name); - // reset a variable to its default value - if (opt.reset) - { - if (opt.all) - { - for (let option in options) + // reset a variable to its default value + if (opt.reset) { + if (opt.all) { + for (let option in options) + option.reset(); + } + else { + option.setFrom = modifiers.setFrom || null; option.reset(); + } } - else - { - option.setFrom = modifiers.setFrom || null; - option.reset(); - } - } - // read access - else if (opt.get) - { - if (opt.all) - options.list(opt.onlyNonDefault, opt.scope); - else - { - if (option.type == "boolean") - var msg = (opt.optionValue ? " " : "no") + option.name; - else - msg = " " + option.name + "=" + opt.optionValue; + // read access + else if (opt.get) { + if (opt.all) + options.list(opt.onlyNonDefault, opt.scope); + else { + if (option.type == "boolean") + var msg = (opt.optionValue ? " " : "no") + option.name; + else + msg = " " + option.name + "=" + opt.optionValue; - if (options["verbose"] > 0 && option.setFrom) - msg += "\n Last set from " + option.setFrom.path; + if (options["verbose"] > 0 && option.setFrom) + msg += "\n Last set from " + option.setFrom.path; - // FIXME: Message highlight group wrapping messes up the indent up for multi-arg verbose :set queries - liberator.echo(<span highlight="CmdOutput">{msg}</span>); + // FIXME: Message highlight group wrapping messes up the indent up for multi-arg verbose :set queries + liberator.echo(<span highlight="CmdOutput">{msg}</span>); + } } - } - // write access - // NOTE: the behavior is generally Vim compatible but could be - // improved. i.e. Vim's behavior is pretty sloppy to no real benefit - else - { - option.setFrom = modifiers.setFrom || null; + // write access + // NOTE: the behavior is generally Vim compatible but could be + // improved. i.e. Vim's behavior is pretty sloppy to no real benefit + else { + option.setFrom = modifiers.setFrom || null; - if (opt.option.type == "boolean") - { - liberator.assert(!opt.valueGiven, "E474: Invalid argument: " + arg); - opt.values = !opt.unsetBoolean; + if (opt.option.type == "boolean") { + liberator.assert(!opt.valueGiven, "E474: Invalid argument: " + arg); + opt.values = !opt.unsetBoolean; + } + let res = opt.option.op(opt.operator || "=", opt.values, opt.scope, opt.invert); + if (res) + liberator.echoerr(res); } - let res = opt.option.op(opt.operator || "=", opt.values, opt.scope, opt.invert); - if (res) - liberator.echoerr(res); } } - } - function setCompleter(context, args, modifiers) - { - let filter = context.filter; + function setCompleter(context, args, modifiers) { + 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.completions = [ + [this._loadPreference(filter, null, false), "Current Value"], + [this._loadPreference(filter, null, true), "Default Value"] + ].filter(function ([k]) k != null); + return; + } - 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.completions = [ - [loadPreference(filter, null, false), "Current Value"], - [loadPreference(filter, null, true), "Default Value"] - ].filter(function ([k]) k != null); - return; + return completion.preference(context); } - return completion.preference(context); - } + let opt = options.parseOpt(filter, modifiers); + let prefix = opt.prefix; - let opt = options.parseOpt(filter, modifiers); - let prefix = opt.prefix; + if (context.filter.indexOf("=") == -1) { + if (prefix) + context.filters.push(function ({ item: opt }) opt.type == "boolean" || prefix == "inv" && opt.values instanceof Array); + return completion.option(context, opt.scope); + } + else if (prefix == "no") + return; - if (context.filter.indexOf("=") == -1) - { if (prefix) - context.filters.push(function ({ item: opt }) opt.type == "boolean" || prefix == "inv" && opt.values instanceof Array); - return completion.option(context, opt.scope); - } - else if (prefix == "no") - return; + context.advance(prefix.length); - if (prefix) - context.advance(prefix.length); - - let option = opt.option; - context.advance(context.filter.indexOf("=") + 1); + let option = opt.option; + context.advance(context.filter.indexOf("=") + 1); - if (!option) - { - context.message = "No such option: " + opt.name; - context.highlight(0, name.length, "SPELLCHECK"); - } + if (!option) { + context.message = "No such option: " + opt.name; + context.highlight(0, name.length, "SPELLCHECK"); + } - if (opt.get || opt.reset || !option || prefix) - return; - - if (!opt.value) - { - context.fork("default", 0, this, function (context) { - context.title = ["Extra Completions"]; - context.completions = [ - [option.value, "Current value"], - [option.defaultValue, "Default value"] - ].filter(function (f) f[0] != ""); - }); - } + if (opt.get || opt.reset || !option || prefix) + return; - context.fork("values", 0, completion, "optionValue", opt.name, opt.operator); - } + if (!opt.value) { + context.fork("default", 0, this, function (context) { + context.title = ["Extra Completions"]; + context.completions = [ + [option.value, "Current value"], + [option.defaultValue, "Default value"] + ].filter(function (f) f[0] != ""); + }); + } - // - // Host application preferences which need to be changed to work well with - // Liberator - // - - // work around the popup blocker - // TODO: Make this work like safeSetPref - var popupAllowedEvents = loadPreference("dom.popup_allowed_events", "change click dblclick mouseup reset submit"); - if (!/keypress/.test(popupAllowedEvents)) - { - storePreference("dom.popup_allowed_events", popupAllowedEvents + " keypress"); - liberator.registerObserver("shutdown", function () { - if (loadPreference("dom.popup_allowed_events", "") == popupAllowedEvents + " keypress") - storePreference("dom.popup_allowed_events", popupAllowedEvents); - }); - } + context.fork("values", 0, completion, "optionValue", opt.name, opt.operator); + } - // safeSetPref might try to echomsg. Need commandline. - liberator.registerObserver("load_commandline", function () { - // TODO: maybe reset in .destroy()? - // TODO: move to buffer.js - // we have our own typeahead find implementation - // See: https://bugzilla.mozilla.org/show_bug.cgi?id=348187 - options.safeSetPref("accessibility.typeaheadfind.autostart", false); - options.safeSetPref("accessibility.typeaheadfind", false); // actually the above setting should do it, but has no effect in Firefox - }); + commands.add(["let"], + "Set or list a variable", + function (args) { + args = args.string; - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + if (!args) { + let str = + <table> + { + template.map(liberator.globalVariables, function ([i, value]) { + let prefix = typeof value == "number" ? "#" : + typeof value == "function" ? "*" : + " "; + return <tr> + <td style="width: 200px;">{i}</td> + <td>{prefix}{value}</td> + </tr>; + }) + } + </table>; + if (str.*.length()) + liberator.echo(str, commandline.FORCE_MULTILINE); + else + liberator.echomsg("No variables found"); + return; + } - commands.add(["let"], - "Set or list a variable", - function (args) - { - args = args.string; + let matches; + // 1 - type, 2 - name, 3 - +-., 4 - expr + if (matches = args.match(/([$@&])?([\w:]+)\s*([-+.])?=\s*(.+)/)) { + if (!matches[1]) { + let reference = liberator.variableReference(matches[2]); + liberator.assert(reference[0] || !matches[3], + "E121: Undefined variable: " + matches[2]); + + let expr = liberator.evalExpression(matches[4]); + liberator.assert(expr !== undefined, "E15: Invalid expression: " + matches[4]); + + if (!reference[0]) { + if (reference[2] == "g") + reference[0] = liberator.globalVariables; + else + return; // for now + } - if (!args) - { - let str = - <table> - { - template.map(liberator.globalVariables, function ([i, value]) { - let prefix = typeof value == "number" ? "#" : - typeof value == "function" ? "*" : - " "; - return <tr> - <td style="width: 200px;">{i}</td> - <td>{prefix}{value}</td> - </tr>; - }) + if (matches[3]) { + if (matches[3] == "+") + reference[0][reference[1]] += expr; + else if (matches[3] == "-") + reference[0][reference[1]] -= expr; + else if (matches[3] == ".") + reference[0][reference[1]] += expr.toString(); + } + else { + if (!reference[0]) { + if (reference[2] == "g") + reference[0] = liberator.globalVariables; + else + return; // for now + } + + if (matches[3]) { + if (matches[3] == "+") + reference[0][reference[1]] += expr; + else if (matches[3] == "-") + reference[0][reference[1]] -= expr; + else if (matches[3] == ".") + reference[0][reference[1]] += expr.toString(); + } + else + reference[0][reference[1]] = expr; + } } - </table>; - if (str.*.length()) - liberator.echo(str, commandline.FORCE_MULTILINE); - else - liberator.echomsg("No variables found"); - return; + } + // 1 - name + else if (matches = args.match(/^\s*([\w:]+)\s*$/)) { + let reference = liberator.variableReference(matches[1]); + liberator.assert(reference[0], "E121: Undefined variable: " + matches[1]); + + let value = reference[0][reference[1]]; + let prefix = typeof value == "number" ? "#" : + typeof value == "function" ? "*" : + " "; + liberator.echo(reference[1] + "\t\t" + prefix + value); + } + }, + { + literal: 0 } + ); - let matches; - // 1 - type, 2 - name, 3 - +-., 4 - expr - if (matches = args.match(/([$@&])?([\w:]+)\s*([-+.])?=\s*(.+)/)) + commands.add(["setl[ocal]"], + "Set local option", + function (args, modifiers) { + modifiers.scope = options.OPTION_SCOPE_LOCAL; + setAction(args, modifiers); + }, { - if (!matches[1]) - { - let reference = liberator.variableReference(matches[2]); - liberator.assert(reference[0] || !matches[3], - "E121: Undefined variable: " + matches[2]); + bang: true, + count: true, + completer: function (context, args) { + return setCompleter(context, args, { scope: options.OPTION_SCOPE_LOCAL }); + }, + literal: 0 + } + ); - let expr = liberator.evalExpression(matches[4]); - liberator.assert(expr !== undefined, "E15: Invalid expression: " + matches[4]); + commands.add(["setg[lobal]"], + "Set global option", + function (args, modifiers) { + modifiers.scope = options.OPTION_SCOPE_GLOBAL; + setAction(args, modifiers); + }, + { + bang: true, + count: true, + completer: function (context, args) { + return setCompleter(context, args, { scope: options.OPTION_SCOPE_GLOBAL }); + }, + literal: 0 + } + ); - if (!reference[0]) + commands.add(["se[t]"], + "Set an option", + function (args, modifiers) { setAction(args, modifiers); }, + { + bang: true, + completer: function (context, args) { + return setCompleter(context, args); + }, + serial: function () [ { - if (reference[2] == "g") - reference[0] = liberator.globalVariables; - else - return; // for now + command: this.name, + arguments: [opt.type == "boolean" ? (opt.value ? "" : "no") + opt.name + : opt.name + "=" + opt.value] } + for (opt in options) + if (!opt.getter && opt.value != opt.defaultValue && (opt.scope & options.OPTION_SCOPE_GLOBAL)) + ] + }); - if (matches[3]) - { - if (matches[3] == "+") - reference[0][reference[1]] += expr; - else if (matches[3] == "-") - reference[0][reference[1]] -= expr; - else if (matches[3] == ".") - reference[0][reference[1]] += expr.toString(); + commands.add(["unl[et]"], + "Delete a variable", + function (args) { + for (let [, name] in args) { + let reference = liberator.variableReference(name); + if (!reference[0]) { + if (!args.bang) + liberator.echoerr("E108: No such variable: " + name); + return; } - else - reference[0][reference[1]] = expr; + + delete reference[0][reference[1]]; } - } - // 1 - name - else if (matches = args.match(/^\s*([\w:]+)\s*$/)) - { - let reference = liberator.variableReference(matches[1]); - liberator.assert(reference[0], "E121: Undefined variable: " + matches[1]); - - let value = reference[0][reference[1]]; - let prefix = typeof value == "number" ? "#" : - typeof value == "function" ? "*" : - " "; - liberator.echo(reference[1] + "\t\t" + prefix + value); - } - }, - { - literal: 0 - } - ); - - commands.add(["setl[ocal]"], - "Set local option", - function (args, modifiers) - { - modifiers.scope = options.OPTION_SCOPE_LOCAL; - setAction(args, modifiers); - }, - { - bang: true, - count: true, - completer: function (context, args) - { - return setCompleter(context, args, { scope: options.OPTION_SCOPE_LOCAL }); - }, - literal: 0 - } - ); - - commands.add(["setg[lobal]"], - "Set global option", - function (args, modifiers) - { - modifiers.scope = options.OPTION_SCOPE_GLOBAL; - setAction(args, modifiers); - }, - { - bang: true, - count: true, - completer: function (context, args) - { - return setCompleter(context, args, { scope: options.OPTION_SCOPE_GLOBAL }); }, - literal: 0 - } - ); - - commands.add(["se[t]"], - "Set an option", - function (args, modifiers) { setAction(args, modifiers); }, - { - bang: true, - completer: function (context, args) { - return setCompleter(context, args); - }, - serial: function () [ - { - command: this.name, - arguments: [opt.type == "boolean" ? (opt.value ? "" : "no") + opt.name - : opt.name + "=" + opt.value] - } - for (opt in options) - if (!opt.getter && opt.value != opt.defaultValue && (opt.scope & options.OPTION_SCOPE_GLOBAL)) - ] - }); - - commands.add(["unl[et]"], - "Delete a variable", - function (args) - { - for (let [, name] in args) - { - let reference = liberator.variableReference(name); - if (!reference[0]) - { - if (!args.bang) - liberator.echoerr("E108: No such variable: " + name); - return; - } - - delete reference[0][reference[1]]; - } - }, - { - argCount: "+", - bang: true - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMPLETIONS ///////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - liberator.registerObserver("load_completion", function () { + argCount: "+", + bang: true + }); + }, + completion: function () { completion.setFunctionCompleter(options.get, [function () ([o.name, o.description] for (o in options))]); completion.setFunctionCompleter([options.getPref, options.safeSetPref, options.setPref, options.resetPref, options.invertPref], [function () options.allPrefs().map(function (pref) [pref, ""])]); @@ -916,19 +1255,18 @@ function Options() //{{{ let newValues = opt.parseValues(context.filter); let len = context.filter.length; - switch (opt.type) - { - case "boolean": - if (!completer) - completer = function () [["true", ""], ["false", ""]]; - break; - case "stringlist": - let target = newValues.pop(); - len = target ? target.length : 0; - break; - case "charlist": - len = 0; - break; + switch (opt.type) { + case "boolean": + if (!completer) + completer = function () [["true", ""], ["false", ""]]; + break; + case "stringlist": + let target = newValues.pop(); + len = target ? target.length : 0; + break; + case "charlist": + len = 0; + break; } // TODO: Highlight when invalid context.advance(context.filter.length - len); @@ -939,17 +1277,15 @@ function Options() //{{{ return; // Not Vim compatible, but is a significant enough improvement // that it's worth breaking compatibility. - if (newValues instanceof Array) - { + if (newValues instanceof Array) { completions = completions.filter(function (val) newValues.indexOf(val[0]) == -1); - switch (op) - { - case "+": - completions = completions.filter(function (val) curValues.indexOf(val[0]) == -1); - break; - case "-": - completions = completions.filter(function (val) curValues.indexOf(val[0]) > -1); - break; + switch (op) { + case "+": + completions = completions.filter(function (val) curValues.indexOf(val[0]) == -1); + break; + case "-": + completions = completions.filter(function (val) curValues.indexOf(val[0]) > -1); + break; } } context.completions = completions; @@ -961,439 +1297,7 @@ function Options() //{{{ context.keys = { text: function (item) item, description: function (item) options.getPref(item) }; context.completions = options.allPrefs(); }; - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const self = { - - /** - * @property {number} Global option scope. - * @final - */ - OPTION_SCOPE_GLOBAL: 1, - - /** - * @property {number} Local option scope. Options in this scope only - * apply to the current tab/buffer. - * @final - */ - OPTION_SCOPE_LOCAL: 2, - - /** - * @property {number} Both local and global option scope. - * @final - */ - OPTION_SCOPE_BOTH: 3, - - /** @property {Iterator(Option)} @private */ - __iterator__: function () - { - let sorted = [o for ([i, o] in Iterator(optionHash))].sort(function (a, b) String.localeCompare(a.name, b.name)); - return (v for ([k, v] in Iterator(sorted))); - }, - - /** @property {Object} Observes preference value changes. */ - prefObserver: { - register: function () - { - // better way to monitor all changes? - this._branch = services.get("pref").getBranch("").QueryInterface(Ci.nsIPrefBranch2); - this._branch.addObserver("", this, false); - }, - - unregister: function () - { - if (this._branch) - this._branch.removeObserver("", this); - }, - - observe: function (subject, topic, data) - { - if (topic != "nsPref:changed") - return; - - // subject is the nsIPrefBranch we're observing (after appropriate QI) - // data is the name of the pref that's been changed (relative to subject) - switch (data) - { - case "accessibility.browsewithcaret": - let value = options.getPref("accessibility.browsewithcaret", false); - liberator.mode = value ? modes.CARET : modes.NORMAL; - break; - } - } - }, - - /** - * 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 - * @returns {boolean} Whether the option was created. - */ - add: function (names, description, type, defaultValue, extraInfo) - { - if (!extraInfo) - extraInfo = {}; - - let option = new Option(names, description, type, defaultValue, extraInfo); - - if (!option) - return false; - - if (option.name in optionHash) - { - // never replace for now - liberator.log("Warning: '" + names[0] + "' already exists, NOT replacing existing option.", 1); - return false; - } - - // quickly access options with options["wildmode"]: - this.__defineGetter__(option.name, function () option.value); - this.__defineSetter__(option.name, function (value) { option.value = value; }); - - optionHash[option.name] = option; - return true; - }, - - /** - * Returns the names of all preferences. - * - * @param {string} branch The branch in which to search preferences. - * @default "" - */ - allPrefs: function (branch) services.get("pref").getChildList(branch || "", { value: 0 }), - - /** - * Returns the option with <b>name</b> in the specified <b>scope</b>. - * - * @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 (name, scope) - { - if (!scope) - scope = options.OPTION_SCOPE_BOTH; - - if (name in optionHash) - return (optionHash[name].scope & scope) && optionHash[name]; - - for (let opt in Iterator(options)) - { - if (opt.hasName(name)) - return (opt.scope & scope) && opt; - } - - return null; - }, - - /** - * Lists all options in <b>scope</b> or only those with changed values - * if <b>onlyNonDefault</b> is specified. - * - * @param {boolean} onlyNonDefault Limit the list to prefs with a - * non-default value. - * @param {number} scope Only list options in this scope (see - * {@link Option#scope}). - */ - list: function (onlyNonDefault, scope) - { - if (!scope) - scope = options.OPTION_SCOPE_BOTH; - - function opts(opt) { - for (let opt in Iterator(options)) - { - let option = { - isDefault: opt.value == opt.defaultValue, - name: opt.name, - default: opt.defaultValue, - pre: "\u00a0\u00a0", // Unicode nonbreaking space. - value: <></> - }; - - if (onlyNonDefault && option.isDefault) - continue; - if (!(opt.scope & scope)) - continue; - - if (opt.type == "boolean") - { - if (!opt.value) - option.pre = "no"; - option.default = (option.default ? "" : "no") + opt.name; - } - else - option.value = <>={template.highlight(opt.value)}</>; - yield option; - } - }; - - let list = template.options("Options", opts()); - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - }, - - /** - * Lists all preferences matching <b>filter</b> or only those with - * changed values if <b>onlyNonDefault</b> 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 - */ - listPrefs: function (onlyNonDefault, filter) - { - if (!filter) - filter = ""; - - let prefArray = options.allPrefs(); - prefArray.sort(); - function prefs() { - for (let [, pref] in Iterator(prefArray)) - { - let userValue = services.get("pref").prefHasUserValue(pref); - if (onlyNonDefault && !userValue || pref.indexOf(filter) == -1) - continue; - - value = options.getPref(pref); - - let option = { - isDefault: !userValue, - default: loadPreference(pref, null, true), - value: <>={template.highlight(value, true, 100)}</>, - name: pref, - pre: "\u00a0\u00a0" // Unicode nonbreaking space. - }; - - yield option; - } - }; - - let list = template.options(config.hostApplication + " Options", prefs()); - commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - }, - - /** - * 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 ret = {}; - let matches, prefix, postfix, valueGiven; - - [matches, prefix, ret.name, postfix, valueGiven, ret.operator, ret.value] = - args.match(/^\s*(no|inv)?([a-z_]*)([?&!])?\s*(([-+^]?)=(.*))?\s*$/) || []; - - ret.args = args; - ret.onlyNonDefault = false; // used for :set to print non-default options - if (!args) - { - ret.name = "all"; - ret.onlyNonDefault = true; - } - - if (matches) - ret.option = options.get(ret.name, ret.scope); - - ret.prefix = prefix; - ret.postfix = postfix; - - ret.all = (ret.name == "all"); - ret.get = (ret.all || postfix == "?" || (ret.option && ret.option.type != "boolean" && !valueGiven)); - ret.invert = (prefix == "inv" || postfix == "!"); - ret.reset = (postfix == "&"); - ret.unsetBoolean = (prefix == "no"); - - ret.scope = modifiers && modifiers.scope; - - if (!ret.option) - return ret; - - if (ret.value === undefined) - ret.value = ""; - - ret.optionValue = ret.option.get(ret.scope); - ret.optionValues = ret.option.getValues(ret.scope); - - ret.values = ret.option.parseValues(ret.value); - - return ret; - }, - - /** - * Remove the option with matching <b>name</b>. - * - * @param {string} name The name of the option to remove. This can be - * any of the options's names. - */ - remove: function (name) - { - for each (let option in optionHash) - { - if (option.hasName(name)) - delete optionHash[option.name]; - } - }, - - /** @property {Object} The options store. */ - get store() storage.options, - - /** - * Returns the value of the preference <b>name</b>. - * - * @param {string} name The preference name. - * @param {value} forcedDefault The the default value for this - * preference. Used for internal liberator preferences. - */ - getPref: function (name, forcedDefault) - { - return loadPreference(name, forcedDefault); - }, - - /** - * Sets the preference <b>name</b> to </b>value</b> 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. - */ - // FIXME: Well it used to. I'm looking at you mst! --djk - safeSetPref: function (name, value, message) - { - let val = loadPreference(name, null, false); - let def = loadPreference(name, null, true); - let lib = loadPreference(SAVED + name); - if (lib == null && val != def || val != lib) - { - let msg = "Warning: setting preference " + name + ", but it's changed from its default value."; - if (message) - msg += " " + message; - liberator.echomsg(msg); - } - storePreference(name, value); - storePreference(SAVED + name, value); - }, - - /** - * Sets the preference <b>name</b> to </b>value</b>. - * - * @param {string} name The preference name. - * @param {value} value The new preference value. - */ - setPref: function (name, value) - { - storePreference(name, value); - }, - - /** - * Resets the preference <b>name</b> to its default value. - * - * @param {string} name The preference name. - */ - resetPref: function (name) - { - try - { - services.get("pref").clearUserPref(name); - } - catch (e) - { - // ignore - thrown if not a user set value - } - }, - - /** - * Toggles the value of the boolean preference <b>name</b>. - * - * @param {string} name The preference name. - */ - invertPref: function (name) - { - if (services.get("pref").getPrefType(name) == Ci.nsIPrefBranch.PREF_BOOL) - this.setPref(name, !this.getPref(name)); - else - liberator.echoerr("E488: Trailing characters: " + name + "!"); - }, - - /** - * Pushes a new preference context onto the context stack. - * - * @see #withContext - */ - pushContext: function () - { - prefContexts.push({}); - }, - - /** - * Pops the top preference context from the stack. - * - * @see #withContext - */ - popContext: function () - { - for (let [k, v] in Iterator(prefContexts.pop())) - storePreference(k, v); - }, - - /** - * Executes <b>func</b> with a new preference context. When <b>func</b> - * 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 <b>func</b> - * @see #pushContext - * @see #popContext - */ - withContext: function (func, self) - { - try - { - this.pushContext(); - return func.call(self); - } - finally - { - this.popContext(); - } - } - }; //}}} - - for (let [, pref] in Iterator(self.allPrefs(OLD_SAVED))) - { - let saved = SAVED + pref.substr(OLD_SAVED.length) - if (!self.getPref(saved)) - self.setPref(saved, self.getPref(pref)); - self.resetPref(pref); - } - - self.prefObserver.register(); - liberator.registerObserver("shutdown", function () { - self.prefObserver.unregister(); - }); - - return self; - -} //}}} + }, +}); // 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 index 00000000..ed0a8e4c --- /dev/null +++ b/common/content/quickmarks.js @@ -0,0 +1,173 @@ +// 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. + +/** @scope modules */ + +/** +* @instance quickmarks +*/ +const QuickMarks = Module("quickmarks", { + requires: ["storage"], + + init: function () { + this._qmarks = storage.newMap("quickmarks", { store: true }); + }, + + /** + * Adds a new quickmark with name <b>qmark</b> referencing + * the URL <b>location</b>. 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); + liberator.echomsg("Added Quick Mark '" + qmark + "': " + location, 1); + }, + + /** + * Deletes the specified quickmarks. The <b>filter</b> 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 = RegExp("[" + filter.replace(/\s+/g, "") + "]"); + + 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 <b>qmark</b>. + * + * @param {string} qmark The quickmark to open. + * @param {number} where A constant describing where to open the page. + * See {@link Liberator#open}. + */ + jumpTo: function jumpTo(qmark, where) { + let url = this._qmarks.get(qmark); + + if (url) + liberator.open(url, where); + else + liberator.echoerr("E20: QuickMark not set"); + }, + + /** + * Lists all quickmarks matching <b>filter</b> in the message window. + * + * @param {string} filter The list of quickmarks to display. Eg. "abc" + * Ranges are not supported. + */ + // FIXME: filter should match that of quickmarks.remove or vice versa + 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); + + liberator.assert(marks.length > 0, "No QuickMarks set"); + + if (filter.length > 0) { + marks = marks.filter(function (qmark) filter.indexOf(qmark) >= 0); + liberator.assert(marks.length >= 0, "E283: No QuickMarks matching \"" + filter + "\""); + } + + let items = [[mark, this._qmarks.get(mark)] for ([k, mark] in Iterator(marks))]; + template.genericTable(items, { title: ["QuickMark", "URL"] }); + } +}, { +}, { + 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.string) + liberator.assert( args.bang || args.string, "E471: Argument required"); + liberator.assert(!args.bang || !args.string, "E474: Invalid argument"); + + if (args.bang) + quickmarks.removeAll(); + else + quickmarks.remove(args.string); + }, + { + bang: true, + completer: function (context) { + context.title = ["QuickMark", "URL"]; + context.completions = this._qmarks; + } + }); + + commands.add(["qma[rk]"], + "Mark a URL with a letter for quick access", + function (args) { + let matches = args.string.match(/^([a-zA-Z0-9])(?:\s+(.+))?$/); + if (!matches) + liberator.echoerr("E488: Trailing characters"); + else if (!matches[2]) + quickmarks.add(matches[1], buffer.URL); + else + quickmarks.add(matches[1], matches[2]); + }, + { argCount: "+" }); + + commands.add(["qmarks"], + "Show all QuickMarks", + function (args) { + args = args.string; + + // ignore invalid qmark characters unless there are no valid qmark chars + liberator.assert(!args || /[a-zA-Z0-9]/.test(args), "E283: No QuickMarks matching \"" + args + "\""); + + let filter = args.replace(/[^a-zA-Z0-9]/g, ""); + quickmarks.list(filter); + }); + }, + mappings: function () { + var myModes = config.browserModes; + + mappings.add(myModes, + ["go"], "Jump to a QuickMark", + function (arg) { quickmarks.jumpTo(arg, liberator.CURRENT_TAB); }, + { arg: true }); + + mappings.add(myModes, + ["gn"], "Jump to a QuickMark in a new tab", + function (arg) { + quickmarks.jumpTo(arg, + /\bquickmark\b/.test(options["activate"]) ? + liberator.NEW_TAB : liberator.NEW_BACKGROUND_TAB); + }, + { arg: true }); + + mappings.add(myModes, + ["M"], "Add new QuickMark for current URL", + function (arg) { + if (/[^a-zA-Z0-9]/.test(arg)) + return void liberator.beep(); + + quickmarks.add(arg, buffer.URL); + }, + { arg: true }); + }, +}); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/sanitizer.js b/common/content/sanitizer.js index c1e57c2e..cffaeca7 100644 --- a/common/content/sanitizer.js +++ b/common/content/sanitizer.js @@ -13,254 +13,36 @@ // FIXME: // - finish 1.9.0 support if we're going to support sanitizing in Xulmus -function Sanitizer() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const local = {}; // XXX: is there some reason liberator.loadModule doesn't create modules with new? - services.get("subscriptLoader").loadSubScript("chrome://browser/content/sanitize.js", local); - const Sanitizer = local.Sanitizer; - - var prefArgList = [["commandLine", "commandline"], - ["offlineApps", "offlineapps"], - ["siteSettings", "sitesettings"]]; - - function prefToArg(pref) - { - let pref = pref.replace(/.*\./, ""); - return util.Array.toObject(prefArgList)[pref] || pref; - } - - function argToPref(arg) [k for ([, [k, v]] in Iterator(prefArgList)) if (v == arg)][0] || arg - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["sanitizeitems", "si"], - "The default list of private items to sanitize", - "stringlist", "cache,commandline,cookies,formdata,history,marks,sessions", - { - setter: function (values) - { - for (let [, pref] in Iterator(sanitizer.prefNames)) - { - options.setPref(pref, false); - - for (let [, value] in Iterator(this.parseValues(values))) - { - if (prefToArg(pref) == value) - { - options.setPref(pref, true); - break; - } - } - } - - return values; - }, - getter: function () sanitizer.prefNames.filter(function (pref) options.getPref(pref)).map(prefToArg).join(","), - completer: function (value) [ - ["cache", "Cache"], - ["commandline", "Command-line history"], - ["cookies", "Cookies"], - ["downloads", "Download history"], - ["formdata", "Saved form and search history"], - ["history", "Browsing history"], - ["macros", "Saved macros"], - ["marks", "Local and URL marks"], - ["offlineapps", "Offline website data"], - ["passwords", "Saved passwords"], - ["sessions", "Authenticated sessions"], - ["sitesettings", "Site preferences"], - ], - validator: Option.validateCompleter - }); - - options.add(["sanitizetimespan", "sts"], - "The default sanitizer time span", - "number", 1, - { - setter: function (value) - { - options.setPref("privacy.sanitize.timeSpan", value); - return value; - }, - getter: function () options.getPref("privacy.sanitize.timeSpan", this.defaultValue), - completer: function (value) [ - ["0", "Everything"], - ["1", "Last hour"], - ["2", "Last two hours"], - ["3", "Last four hours"], - ["4", "Today"] - ], - validator: Option.validateCompleter - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - commands.add(["sa[nitize]"], - "Clear private data", - function (args) - { - if (options['private']) - return void liberator.echomsg("Cannot sanitize items in private mode"); - - let timespan = args["-timespan"] || options["sanitizetimespan"]; - - sanitizer.range = Sanitizer.getClearRange(timespan); - sanitizer.ignoreTimespan = !sanitizer.range; - - if (args.bang) - { - liberator.assert(args.length == 0, "E488: Trailing characters"); - - liberator.log("Sanitizing all items in 'sanitizeitems'..."); - - let errors = sanitizer.sanitize(); - - if (errors) - { - for (let item in errors) - liberator.echoerr("Error sanitizing " + item + ": " + errors[item]); - } - } - else - { - liberator.assert(args.length > 0, "E471: Argument required"); - - for (let [, item] in Iterator(args.map(argToPref))) - { - liberator.log("Sanitizing " + item + " items..."); - - if (sanitizer.canClearItem(item)) - { - try - { - sanitizer.items[item].range = sanitizer.range; - sanitizer.clearItem(item); - } - catch (e) - { - liberator.echoerr("Error sanitizing " + item + ": " + e); - } - } - else - liberator.echomsg("Cannot sanitize " + item); - } - } - }, - { - argCount: "*", // FIXME: should be + and 0 - bang: true, - completer: function (context) { - context.title = ["Privacy Item", "Description"]; - context.completions = options.get("sanitizeitems").completer(); - }, - options: [ - [["-timespan", "-t"], - commands.OPTION_INT, - function (arg) /^[0-4]$/.test(arg), - function () options.get("sanitizetimespan").completer()] - ] - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var self = new Sanitizer(); - - // TODO: remove this version test - if (/^1.9.1/.test(services.get("xulAppInfo").platformVersion)) - self.prefDomain = "privacy.cpd."; - else - self.prefDomain = "privacy.item."; - - self.prefDomain2 = "extensions.liberator.privacy.cpd."; - - // add liberator-specific private items - [ - { - name: "commandLine", - action: function () - { - let stores = ["command", "search"]; - - if (self.range) - { - stores.forEach(function (store) { - storage["history-" + store].mutate("filter", function (item) { - let timestamp = item.timestamp * 1000; - return timestamp < self.range[0] || timestamp > self.range[1]; - }); - }); - } - else - stores.forEach(function (store) { storage["history-" + store].truncate(0); }); - } - }, - { - name: "macros", - action: function () { storage["macros"].clear(); } - }, - { - name: "marks", - action: function () - { - storage["local-marks"].clear(); - storage["url-marks"].clear(); - } - } - ].forEach(function (item) { - let pref = self.prefDomain2 + item.name; +const Sanitizer = Module("sanitizer", { + requires: ["liberator"], - if (options.getPref(pref) == null) - options.setPref(pref, false); + init: function () { + const self = this; + liberator.loadScript("chrome://browser/content/sanitize.js", Sanitizer); + this.__proto__.__proto__ = new Sanitizer.Sanitizer; // Good enough. - self.items[item.name] = { - canClear: true, - clear: item.action - } - }); - - // call Sanitize autocommand - for (let [name, item] in Iterator(self.items)) - { - let arg = prefToArg(name); - - if (item.clear) - { - let func = item.clear; - item.clear = function () { - autocommands.trigger("Sanitize", { name: arg }) - func.call(item); - } - } - } + // TODO: remove this version test + if (/^1.9.1/.test(services.get("xulAppInfo").platformVersion)) + self.prefDomain = "privacy.cpd."; + else + self.prefDomain = "privacy.item."; - self.getClearRange = Sanitizer.getClearRange; + self.prefDomain2 = "extensions.liberator.privacy.cpd."; + }, // Largely ripped from from browser/base/content/sanitize.js so we can override // the pref strategy without stepping on the global prefs namespace. - self.sanitize = function () { + sanitize: function () { const prefService = services.get("pref"); let branch = prefService.getBranch(this.prefDomain); let branch2 = prefService.getBranch(this.prefDomain2); let errors = null; - function prefSet(name) - { - try - { + function prefSet(name) { + try { return branch.getBoolPref(name); } - catch (e) - { + catch (e) { return branch2.getBoolPref(name); } } @@ -271,25 +53,21 @@ function Sanitizer() //{{{ else range = this.range || Sanitizer.getClearRange(); - for (let itemName in this.items) - { + for (let itemName in this.items) { let item = this.items[itemName]; item.range = range; - if ("clear" in item && item.canClear && prefSet(itemName)) - { + if ("clear" in item && item.canClear && prefSet(itemName)) { liberator.log("Sanitizing " + itemName + " items..."); // Some of these clear() may raise exceptions (see bug #265028) // to sanitize as much as possible, we catch and store them, // rather than fail fast. // Callers should check returned errors and give user feedback // about items that could not be sanitized - try - { + try { item.clear(); } - catch (e) - { + catch (e) { if (!errors) errors = {}; errors[itemName] = e; @@ -299,14 +77,193 @@ function Sanitizer() //{{{ } return errors; - }; + }, + + get prefNames() util.Array.flatten([this.prefDomain, this.prefDomain2].map(options.allPrefs)), +}, { + prefArgList: [["commandLine", "commandline"], + ["offlineApps", "offlineapps"], + ["siteSettings", "sitesettings"]], + prefToArg: function (pref) { + let pref = pref.replace(/.*\./, ""); + return util.Array.toObject(Sanitizer.prefArgList)[pref] || pref; + }, + + argToPref: function (arg) [k for ([k, v] in values(Sanitizer.prefArgList)) if (v == arg)][0] || arg, +}, { + commands: function () { + commands.add(["sa[nitize]"], + "Clear private data", + function (args) { + if (options['private']) + return void liberator.echomsg("Cannot sanitize items in private mode"); + + let timespan = args["-timespan"] || options["sanitizetimespan"]; + + sanitizer.range = Sanitizer.getClearRange(timespan); + sanitizer.ignoreTimespan = !sanitizer.range; + + if (args.bang) { + liberator.assert(args.length == 0, "E488: Trailing characters"); + + liberator.log("Sanitizing all items in 'sanitizeitems'..."); + + let errors = sanitizer.sanitize(); + + if (errors) { + for (let item in errors) + liberator.echoerr("Error sanitizing " + item + ": " + errors[item]); + } + } + else { + liberator.assert(args.length > 0, "E471: Argument required"); + + for (let [, item] in Iterator(args.map(Sanitizer.argToPref))) { + liberator.log("Sanitizing " + item + " items..."); + + if (sanitizer.canClearItem(item)) { + try { + sanitizer.items[item].range = sanitizer.range; + sanitizer.clearItem(item); + } + catch (e) { + liberator.echoerr("Error sanitizing " + item + ": " + e); + } + } + else + liberator.echomsg("Cannot sanitize " + item); + } + } + }, + { + argCount: "*", // FIXME: should be + and 0 + bang: true, + completer: function (context) { + context.title = ["Privacy Item", "Description"]; + context.completions = options.get("sanitizeitems").completer(); + }, + options: [ + [["-timespan", "-t"], + commands.OPTION_INT, + function (arg) /^[0-4]$/.test(arg), + function () options.get("sanitizetimespan").completer()] + ] + }); + }, + options: function () { + const self = this; + + // add liberator-specific private items + [ + { + name: "commandLine", + action: function () { + let stores = ["command", "search"]; + + if (self.range) { + stores.forEach(function (store) { + storage["history-" + store].mutate("filter", function (item) { + let timestamp = item.timestamp * 1000; + return timestamp < self.range[0] || timestamp > self.range[1]; + }); + }); + } + else + stores.forEach(function (store) { storage["history-" + store].truncate(0); }); + } + }, + { + name: "macros", + action: function () { storage["macros"].clear(); } + }, + { + name: "marks", + action: function () { + storage["local-marks"].clear(); + storage["url-marks"].clear(); + } + } + ].forEach(function (item) { + let pref = self.prefDomain2 + item.name; - self.__defineGetter__("prefNames", - function () util.Array.flatten([self.prefDomain, self.prefDomain2].map(options.allPrefs))); - //}}} + if (options.getPref(pref) == null) + options.setPref(pref, false); - return self; + self.items[item.name] = { + canClear: true, + clear: item.action + } + }); + + // call Sanitize autocommand + for (let [name, item] in Iterator(self.items)) { + let arg = Sanitizer.prefToArg(name); + + if (item.clear) { + let func = item.clear; + item.clear = function () { + autocommands.trigger("Sanitize", { name: arg }) + func.call(item); + } + } + } -} //}}} + options.add(["sanitizeitems", "si"], + "The default list of private items to sanitize", + "stringlist", "cache,commandline,cookies,formdata,history,marks,sessions", + { + setter: function (values) { + for (let [, pref] in Iterator(sanitizer.prefNames)) { + continue; + options.setPref(pref, false); + + for (let [, value] in Iterator(this.parseValues(values))) { + if (Sanitizer.prefToArg(pref) == value) { + options.setPref(pref, true); + break; + } + } + } + + return values; + }, + getter: function () sanitizer.prefNames.filter(function (pref) options.getPref(pref)).map(Sanitizer.prefToArg).join(","), + completer: function (value) [ + ["cache", "Cache"], + ["commandline", "Command-line history"], + ["cookies", "Cookies"], + ["downloads", "Download history"], + ["formdata", "Saved form and search history"], + ["history", "Browsing history"], + ["macros", "Saved macros"], + ["marks", "Local and URL marks"], + ["offlineapps", "Offline website data"], + ["passwords", "Saved passwords"], + ["sessions", "Authenticated sessions"], + ["sitesettings", "Site preferences"], + ], + validator: Option.validateCompleter + }); + + options.add(["sanitizetimespan", "sts"], + "The default sanitizer time span", + "number", 1, + { + setter: function (value) { + options.setPref("privacy.sanitize.timeSpan", value); + return value; + }, + getter: function () options.getPref("privacy.sanitize.timeSpan", this.defaultValue), + completer: function (value) [ + ["0", "Everything"], + ["1", "Last hour"], + ["2", "Last two hours"], + ["3", "Last four hours"], + ["4", "Today"] + ], + validator: Option.validateCompleter + }); + }, +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/services.js b/common/content/services.js index 715277f7..2d3f6d7b 100644 --- a/common/content/services.js +++ b/common/content/services.js @@ -15,15 +15,46 @@ const Cu = Components.utils; * * @constructor */ -function Services() -{ - const classes = {}; - const services = {}; +const Services = Module("services", { + init: function () { + this.classes = {}; + this.services = {}; - function create(classes, ifaces, meth) - { - try - { + this.add("appStartup", "@mozilla.org/toolkit/app-startup;1", Ci.nsIAppStartup); + this.add("autoCompleteSearch", "@mozilla.org/autocomplete/search;1?name=history", Ci.nsIAutoCompleteSearch); + this.add("bookmarks", "@mozilla.org/browser/nav-bookmarks-service;1", Ci.nsINavBookmarksService); + this.add("browserSearch", "@mozilla.org/browser/search-service;1", Ci.nsIBrowserSearchService); + this.add("cache", "@mozilla.org/network/cache-service;1", Ci.nsICacheService); + this.add("console", "@mozilla.org/consoleservice;1", Ci.nsIConsoleService); + this.add("liberator:", "@mozilla.org/network/protocol;1?name=liberator"); + this.add("directory", "@mozilla.org/file/directory_service;1", Ci.nsIProperties); + this.add("downloadManager", "@mozilla.org/download-manager;1", Ci.nsIDownloadManager); + this.add("environment", "@mozilla.org/process/environment;1", Ci.nsIEnvironment); + this.add("extensionManager", "@mozilla.org/extensions/manager;1", Ci.nsIExtensionManager); + this.add("favicon", "@mozilla.org/browser/favicon-service;1", Ci.nsIFaviconService); + this.add("history", "@mozilla.org/browser/global-history;2", [Ci.nsIGlobalHistory3, Ci.nsINavHistoryService, Ci.nsIBrowserHistory]); + this.add("io", "@mozilla.org/network/io-service;1", Ci.nsIIOService); + this.add("json", "@mozilla.org/dom/json;1", Ci.nsIJSON, "createInstance"); + this.add("livemark", "@mozilla.org/browser/livemark-service;2", Ci.nsILivemarkService); + this.add("observer", "@mozilla.org/observer-service;1", Ci.nsIObserverService); + this.add("pref", "@mozilla.org/preferences-service;1", [Ci.nsIPrefService, Ci.nsIPrefBranch, Ci.nsIPrefBranch2]); + this.add("profile", "@mozilla.org/toolkit/profile-service;1", Ci.nsIToolkitProfileService); + this.add("rdf", "@mozilla.org/rdf/rdf-service;1", Ci.nsIRDFService); + this.add("sessionStore", "@mozilla.org/browser/sessionstore;1", Ci.nsISessionStore); + this.add("subscriptLoader", "@mozilla.org/moz/jssubscript-loader;1", Ci.mozIJSSubScriptLoader); + this.add("threadManager", "@mozilla.org/thread-manager;1", Ci.nsIThreadManager); + this.add("windowMediator", "@mozilla.org/appshell/window-mediator;1", Ci.nsIWindowMediator); + this.add("windowWatcher", "@mozilla.org/embedcomp/window-watcher;1", Ci.nsIWindowWatcher); + this.add("xulAppInfo", "@mozilla.org/xre/app-info;1", Ci.nsIXULAppInfo); + + this.addClass("file", "@mozilla.org/file/local;1", Ci.nsILocalFile); + this.addClass("file:", "@mozilla.org/network/protocol;1?name=file", Ci.nsIFileProtocolHandler); + this.addClass("find", "@mozilla.org/embedcomp/rangefind;1", Ci.nsIFind); + this.addClass("process", "@mozilla.org/process/util;1", Ci.nsIProcess); + }, + + _create: function (classes, ifaces, meth) { + try { let res = Cc[classes][meth || "getService"](); if (!ifaces) return res.wrappedJSObject; @@ -31,97 +62,52 @@ function Services() ifaces.forEach(function (iface) res.QueryInterface(iface)); return res; } - catch (e) - { + catch (e) { // liberator.log() is not defined at this time, so just dump any error dump("Service creation failed for '" + classes + "': " + e + "\n"); } - } - - const self = { - /* @property {Object} A map of all cached services. */ - get services() services, - - /* @property {Object} A map of all cached classes. */ - get classes() classes, - - /** - * 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 {nsISupports|nsISupports[]} ifaces The interface or array of - * interfaces implemented by this service. - * @param {string} meth The name of the function used to instanciate - * the service. - */ - add: function (name, class, ifaces, meth) - { - return services[name] = 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. - */ - addClass: function (name, class, ifaces) - { - return classes[name] = function () create(class, ifaces, "createInstance"); - }, - - /** - * Returns the cached service with the specified name. - * - * @param {string} name The service's cache key. - */ - get: function (name) services[name], - - /** - * Returns a new instance of the cached class with the specified name. - * - * @param {string} name The class's cache key. - */ - create: function (name) classes[name]() - }; + }, - self.add("appStartup", "@mozilla.org/toolkit/app-startup;1", Ci.nsIAppStartup); - self.add("autoCompleteSearch", "@mozilla.org/autocomplete/search;1?name=history", Ci.nsIAutoCompleteSearch); - self.add("bookmarks", "@mozilla.org/browser/nav-bookmarks-service;1", Ci.nsINavBookmarksService); - self.add("browserSearch", "@mozilla.org/browser/search-service;1", Ci.nsIBrowserSearchService); - self.add("cache", "@mozilla.org/network/cache-service;1", Ci.nsICacheService); - self.add("console", "@mozilla.org/consoleservice;1", Ci.nsIConsoleService); - self.add("directory", "@mozilla.org/file/directory_service;1", Ci.nsIProperties); - self.add("environment", "@mozilla.org/process/environment;1", Ci.nsIEnvironment); - self.add("extensionManager", "@mozilla.org/extensions/manager;1", Ci.nsIExtensionManager); - self.add("favicon", "@mozilla.org/browser/favicon-service;1", Ci.nsIFaviconService); - self.add("json", "@mozilla.org/dom/json;1", Ci.nsIJSON, "createInstance"); - self.add("liberator:", "@mozilla.org/network/protocol;1?name=liberator"); - self.add("livemark", "@mozilla.org/browser/livemark-service;2", Ci.nsILivemarkService); - self.add("observer", "@mozilla.org/observer-service;1", Ci.nsIObserverService); - self.add("io", "@mozilla.org/network/io-service;1", Ci.nsIIOService); - self.add("pref", "@mozilla.org/preferences-service;1", [Ci.nsIPrefService, Ci.nsIPrefBranch, Ci.nsIPrefBranch2]); - self.add("profile", "@mozilla.org/toolkit/profile-service;1", Ci.nsIToolkitProfileService); - self.add("rdf", "@mozilla.org/rdf/rdf-service;1", Ci.nsIRDFService); - self.add("sessionStore", "@mozilla.org/browser/sessionstore;1", Ci.nsISessionStore); - self.add("subscriptLoader", "@mozilla.org/moz/jssubscript-loader;1", Ci.mozIJSSubScriptLoader); - self.add("threadManager", "@mozilla.org/thread-manager;1", Ci.nsIThreadManager); - self.add("windowMediator", "@mozilla.org/appshell/window-mediator;1", Ci.nsIWindowMediator); - self.add("windowWatcher", "@mozilla.org/embedcomp/window-watcher;1", Ci.nsIWindowWatcher); - self.add("xulAppInfo", "@mozilla.org/xre/app-info;1", Ci.nsIXULAppInfo); + /** + * 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 {nsISupports|nsISupports[]} ifaces The interface or array of + * interfaces implemented by this service. + * @param {string} meth The name of the function used to instanciate + * the service. + */ + add: function (name, class, ifaces, meth) { + return this.services[name] = this._create(class, ifaces, meth); + }, - self.addClass("file", "@mozilla.org/file/local;1", Ci.nsILocalFile); - self.addClass("file:", "@mozilla.org/network/protocol;1?name=file", Ci.nsIFileProtocolHandler); - self.addClass("find", "@mozilla.org/embedcomp/rangefind;1", Ci.nsIFind); - self.addClass("process", "@mozilla.org/process/util;1", Ci.nsIProcess); - self.addClass("zipWriter", "@mozilla.org/zipwriter;1", Ci.nsIZipWriter); + /** + * 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. + */ + addClass: function (name, class, ifaces) { + const self = this; + return this.classes[name] = function () self._create(class, ifaces, "createInstance"); + }, - return self; -} + /** + * Returns the cached service with the specified name. + * + * @param {string} name The service's cache key. + */ + get: function (name) this.services[name], -var services = Services(); + /** + * Returns a new instance of the cached class with the specified name. + * + * @param {string} name The class's cache key. + */ + create: function (name) this.classes[name]() +}); // 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 index 00000000..c0408cbe --- /dev/null +++ b/common/content/statusline.js @@ -0,0 +1,245 @@ +// 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. + +/** @scope modules */ + +const StatusLine = Module("statusline", { + init: function () { + this._statusBar = document.getElementById("status-bar"); + this._statusBar.collapsed = true; // it is later restored unless the user sets laststatus=0 + + // our status bar fields + this._statuslineWidget = document.getElementById("liberator-statusline"); + this._urlWidget = document.getElementById("liberator-statusline-field-url"); + this._inputBufferWidget = document.getElementById("liberator-statusline-field-inputbuffer"); + this._progressWidget = document.getElementById("liberator-statusline-field-progress"); + this._tabCountWidget = document.getElementById("liberator-statusline-field-tabcount"); + this._bufferPositionWidget = document.getElementById("liberator-statusline-field-bufferposition"); + }, + + /** + * 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 + */ + setClass: function setClass(type) { + const highlightGroup = { + extended: "StatusLineExtended", + secure: "StatusLineSecure", + broken: "StatusLineBroken", + insecure: "StatusLine" + }; + + this._statusBar.setAttributeNS(NS.uri, "highlight", highlightGroup[type]); + }, + + // update all fields of the statusline + update: function update() { + this.updateUrl(); + this.updateInputBuffer(); + this.updateProgress(); + this.updateTabCount(); + this.updateBufferPosition(); + }, + + /** + * 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. + * @default buffer.URL + */ + updateUrl: function updateUrl(url) { + // ripped from Firefox; modified + function losslessDecodeURI(url) { + // 1. decodeURI decodes %25 to %, which creates unintended + // encoding sequences. + url = url.split("%25").map(decodeURI).join("%25"); + // 2. Re-encode whitespace so that it doesn't get eaten away + // by the location bar (bug 410726). + url = url.replace(/[\r\n\t]/g, encodeURIComponent); + + // Encode invisible characters (soft hyphen, zero-width space, BOM, + // line and paragraph separator, word joiner, invisible times, + // invisible separator, object replacement character) (bug 452979) + url = url.replace(/[\v\x0c\x1c\x1d\x1e\x1f\u00ad\u200b\ufeff\u2028\u2029\u2060\u2062\u2063\ufffc]/g, + encodeURIComponent); + + // Encode bidirectional formatting characters. + // (RFC 3987 sections 3.2 and 4.1 paragraph 6) + url = url.replace(/[\u200e\u200f\u202a\u202b\u202c\u202d\u202e]/g, + encodeURIComponent); + return url; + }; + + if (url == null) + // TODO: this probably needs a more general solution. + url = losslessDecodeURI(buffer.URL); + + // make it even more Vim-like + if (url == "about:blank") { + if (!buffer.title) + url = "[No Name]"; + } + else { + url = url.replace(RegExp("^liberator://help/(\\S+)#(.*)"), function (m, n1, n2) n1 + " " + decodeURIComponent(n2) + " [Help]") + .replace(RegExp("^liberator://help/(\\S+)"), "$1 [Help]"); + } + + // when session information is available, add [+] when we can go + // backwards, [-] when we can go forwards + let modified = ""; + if (window.getWebNavigation) { + let sh = window.getWebNavigation().sessionHistory; + if (sh && sh.index > 0) + modified += "+"; + if (sh && sh.index < sh.count -1) + modified += "-"; + } + if (modules.bookmarks) { + if (bookmarks.isBookmarked(buffer.URL)) + modified += "\u2764"; // a heart symbol: ❤ + //modified += "\u2665"; // a heart symbol: ♥ + } + + if (modified) + url += " [" + modified + "]"; + + this._urlWidget.value = url; + }, + + /** + * 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 + */ + updateInputBuffer: function updateInputBuffer(buffer) { + if (!buffer || typeof buffer != "string") + buffer = ""; + + this._inputBufferWidget.value = buffer; + }, + + /** + * 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. + */ + updateProgress: function updateProgress(progress) { + if (!progress) + progress = ""; + + if (typeof progress == "string") + this._progressWidget.value = progress; + else if (typeof progress == "number") { + let progressStr = ""; + if (progress <= 0) + progressStr = "[ Loading... ]"; + else if (progress < 1) { + progress = Math.floor(progress * 20); + progressStr = "[" + + "====================".substr(0, progress) + + ">" + + " ".substr(0, 19 - progress) + + "]"; + } + this._progressWidget.value = progressStr; + } + }, + + /** + * Display the correct tabcount (e.g., [1/5]) on the status bar. + * + * @param {bool} 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. + */ + updateTabCount: function updateTabCount(delayed) { + if (liberator.has("tabs")) { + if (delayed) + return void setTimeout(function () statusline.updateTabCount(false), 0); + + // update the ordinal which is used for numbered tabs + if (options.get("guioptions").has("n", "N")) + for (let [i, tab] in util.Array.iteritems(getBrowser().mTabs)) + tab.setAttribute("ordinal", i + 1); + + this._tabCountWidget.value = "[" + (tabs.index() + 1) + "/" + tabs.count + "]"; + } + }, + + /** + * 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 || typeof percent != "number") { + let win = document.commandDispatcher.focusedWindow; + if (!win) + return; + percent = win.scrollMaxY == 0 ? -1 : win.scrollY / win.scrollMaxY; + } + + let bufferPositionStr = ""; + percent = Math.round(percent * 100); + if (percent < 0) + bufferPositionStr = "All"; + else if (percent == 0) + bufferPositionStr = "Top"; + else if (percent < 10) + bufferPositionStr = " " + percent + "%"; + else if (percent >= 100) + bufferPositionStr = "Bot"; + else + bufferPositionStr = percent + "%"; + + this._bufferPositionWidget.value = bufferPositionStr; + } + +}, { +}, { + options: function () { + options.add(["laststatus", "ls"], + "Show the status line", + "number", 2, + { + setter: function setter(value) { + if (value == 0) + document.getElementById("status-bar").collapsed = true; + else if (value == 1) + liberator.echoerr("show status line only with > 1 window not implemented yet"); + else + document.getElementById("status-bar").collapsed = false; + + return value; + }, + completer: function completer(context) [ + ["0", "Never display status line"], + ["1", "Display status line only if there are multiple windows"], + ["2", "Always display status line"] + ], + validator: Option.validateCompleter + }); + }, +}); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/style.js b/common/content/style.js index 1cccecf7..121c15ef 100644 --- a/common/content/style.js +++ b/common/content/style.js @@ -231,14 +231,13 @@ Highlights.prototype.CSS = <![CDATA[ * * @author Kris Maglione <maglione.k@gmail.com> */ -function Highlights(name, store) -{ +function Highlights(name, store) { let self = this; let highlight = {}; let styles = storage.styles; const Highlight = Struct("class", "selector", "filter", "default", "value", "base"); - Highlight.defaultValue("filter", function () + Highlight.defaultValue("filter", function () this.base ? this.base.filter : ["chrome://liberator/*", "liberator:*", @@ -256,8 +255,7 @@ function Highlights(name, store) this.__iterator__ = function () (highlight[v] for ([k, v] in Iterator(keys()))); this.get = function (k) highlight[k]; - this.set = function (key, newStyle, force, append) - { + this.set = function (key, newStyle, force, append) { let [, class, selectors] = key.match(/^([a-zA-Z_-]+)(.*)/); if (!(class in highlight)) @@ -270,10 +268,8 @@ function Highlights(name, store) newStyle = (style.value || "").replace(/;?\s*$/, "; " + newStyle); if (/^\s*$/.test(newStyle)) newStyle = null; - if (newStyle == null) - { - if (style.default == null) - { + if (newStyle == null) { + if (style.default == null) { delete highlight[style.class]; styles.removeSheet(true, style.selector); return null; @@ -284,8 +280,7 @@ function Highlights(name, store) let css = newStyle.replace(/(?:!\s*important\s*)?(?:;?\s*$|;)/g, "!important;") .replace(";!important;", ";", "g"); // Seeming Spidermonkey bug - if (!/^\s*(?:!\s*important\s*)?;*\s*$/.test(css)) - { + if (!/^\s*(?:!\s*important\s*)?;*\s*$/.test(css)) { css = style.selector + " { " + css + " }"; let error = styles.addSheet(true, "highlight:" + style.class, style.filter, css, true); @@ -301,8 +296,7 @@ function Highlights(name, store) * * @param {string} class */ - this.selector = function (class) - { + this.selector = function (class) { let [, hl, rest] = class.match(/^(\w*)(.*)/); let pattern = "[liberator|highlight~=" + hl + "]" if (highlight[hl] && highlight[hl].class != class) @@ -314,8 +308,7 @@ function Highlights(name, store) * Clears all highlighting rules. Rules with default values are * reset. */ - this.clear = function () - { + this.clear = function () { for (let [k, v] in Iterator(highlight)) this.set(k, null, true); }; @@ -325,8 +318,7 @@ function Highlights(name, store) * * @param {string} css The rules to load. See {@link Highlights#css}. */ - this.loadCSS = function (css) - { + this.loadCSS = function (css) { css.replace(/^(\s*\S*\s+)\{((?:.|\n)*?)\}\s*$/gm, function (_, _1, _2) _1 + " " + _2.replace(/\n\s*/g, " ")) .split("\n").filter(function (s) /\S/.test(s)) .forEach(function (style) @@ -340,8 +332,7 @@ function Highlights(name, store) if (old && old.value != old.default) style.value = old.value; }); - for (let [class, hl] in Iterator(highlight)) - { + for (let [class, hl] in Iterator(highlight)) { if (hl.value == hl.default) this.set(class); } @@ -356,8 +347,7 @@ function Highlights(name, store) * * @author Kris Maglione <maglione.k@gmail.com> */ -function Styles(name, store) -{ +function Styles(name, store) { // Can't reference liberator or Components inside Styles -- // they're members of the window object, which disappear // with this window. @@ -387,14 +377,12 @@ function Styles(name, store) Sheet.prototype.__defineGetter__("enabled", function () this._enabled); Sheet.prototype.__defineSetter__("enabled", function (on) { this._enabled = Boolean(on); - if (on) - { + if (on) { self.registerSheet(cssUri(this.fullCSS)); if (this.agent) self.registerSheet(cssUri(this.fullCSS), true); } - else - { + else { self.unregisterSheet(cssUri(this.fullCSS)); self.unregisterSheet(cssUri(this.fullCSS), true); } @@ -428,8 +416,7 @@ function Styles(name, store) * "*" is matched as a prefix. * @param {string} css The CSS to be applied. */ - this.addSheet = function (system, name, filter, css, agent) - { + this.addSheet = function (system, name, filter, css, agent) { let sheets = system ? systemSheets : userSheets; let names = system ? systemNames : userNames; if (name && name in names) @@ -437,12 +424,10 @@ function Styles(name, store) let sheet = Sheet(name, id++, filter.split(",").filter(util.identity), String(css), null, system, agent); - try - { + try { sheet.enabled = true; } - catch (e) - { + catch (e) { return e.echoerr || e; } sheets.push(sheet); @@ -459,8 +444,7 @@ function Styles(name, store) * @param {string or number} sheet The sheet to retrieve. Strings indicate * sheet names, while numbers indicate indices. */ - this.get = function get(system, sheet) - { + this.get = function get(system, sheet) { let sheets = system ? systemSheets : userSheets; let names = system ? systemNames : userNames; if (typeof sheet === "number") @@ -478,8 +462,7 @@ function Styles(name, store) * @param {string} css * @param {number} index */ - this.findSheets = function (system, name, filter, css, index) - { + this.findSheets = function (system, name, filter, css, index) { let sheets = system ? systemSheets : userSheets; let names = system ? systemNames : userNames; @@ -508,11 +491,9 @@ function Styles(name, store) * @param {string} css * @param {number} index */ - this.removeSheet = function (system, name, filter, css, index) - { + this.removeSheet = function (system, name, filter, css, index) { let self = this; - if (arguments.length == 0) - { + if (arguments.length == 0) { var matches = [system]; system = sheet.system; } @@ -531,8 +512,7 @@ function Styles(name, store) if (matches.length == 0) return; - for (let [, sheet] in Iterator(matches.reverse())) - { + for (let [, sheet] in Iterator(matches.reverse())) { sheet.enabled = false; if (name) delete names[name]; @@ -540,8 +520,7 @@ function Styles(name, store) sheets.splice(sheets.indexOf(sheet), 1); /* Re-add if we're only changing the site filter. */ - if (filter) - { + if (filter) { let sites = sheet.sites.filter(function (f) f != filter); if (sites.length) this.addSheet(system, name, sites.join(","), css, sheet.agent); @@ -558,8 +537,7 @@ function Styles(name, store) * @param {boolean} reload Whether to reload any sheets that are * already registered. */ - this.registerSheet = function (uri, agent, reload) - { + this.registerSheet = function (uri, agent, reload) { if (reload) this.unregisterSheet(uri, agent); uri = ios.newURI(uri, null, null); @@ -572,298 +550,279 @@ function Styles(name, store) * * @param {string} uri The URI of the sheet to unregister. */ - this.unregisterSheet = function (uri, agent) - { + this.unregisterSheet = function (uri, agent) { uri = ios.newURI(uri, null, null); if (sss.sheetRegistered(uri, agent ? sss.AGENT_SHEET : sss.USER_SHEET)) sss.unregisterSheet(uri, agent ? sss.AGENT_SHEET : sss.USER_SHEET); }; } -let (array = util.Array) -{ - util.extend(Styles.prototype, { - get sites() array([v.sites for ([k, v] in this.userSheets)]).flatten().uniq().__proto__, - completeSite: function (context, content) - { - 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) {} - context.fork("others", 0, this, function (context) { - context.title = ["Site"]; - context.completions = [[s, ""] for ([, s] in Iterator(styles.sites))]; - }); - } - }); -} -/** - * @property {Styles} - */ -const styles = storage.newObject("styles", Styles, { store: false }); - -/** - * @property {Highlights} - */ -const highlight = storage.newObject("highlight", Highlights, { store: false }); - -if (highlight.CSS != Highlights.prototype.CSS) -{ - highlight.CSS = Highlights.prototype.CSS; - highlight.loadCSS(highlight.CSS); -} - -liberator.triggerObserver("load_styles", "styles"); -liberator.triggerObserver("load_highlight", "highlight"); - -/////////////////////////////////////////////////////////////////////////////}}} -////////////////////// COMMANDS //////////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////{{{ - -liberator.registerObserver("load_commands", function () { - - commands.add(["colo[rscheme]"], - "Load a color scheme", - function (args) - { - let scheme = args[0]; - - if (scheme == "default") - highlight.clear(); - else - liberator.assert(!io.sourceFromRuntimePath(["colors/" + scheme + ".vimp"]), - "E185: Cannot find color scheme " + scheme); - autocommands.trigger("ColorScheme", { name: scheme }); - }, - { - argCount: "1", - completer: function (context) completion.colorScheme(context) - }); - - commands.add(["sty[le]"], - "Add or list user styles", - function (args) - { - let [filter, css] = args; - let name = args["-name"]; - - if (!css) - { - let list = Array.concat([i for (i in styles.userNames)], - [i for (i in styles.userSheets) if (!i[1].name)]); - let str = template.tabular(["", "Name", "Filter", "CSS"], - ["min-width: 1em; text-align: center; color: red; font-weight: bold;", - "padding: 0 1em 0 1ex; vertical-align: top;", - "padding: 0 1em 0 0; vertical-align: top;"], - ([sheet.enabled ? "" : "\u00d7", - key, - sheet.sites.join(","), - sheet.css] - for ([i, [key, sheet]] in Iterator(list)) - if ((!filter || sheet.sites.indexOf(filter) >= 0) && (!name || sheet.name == name)))); - commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - } - else - { - if ("-append" in args) - { - let sheet = styles.get(false, name); - if (sheet) - { - filter = sheet.sites.concat(filter).join(","); - css = sheet.css + " " + css; +Module("styles", { + requires: ["liberator", "storage", "util"], + + init: function () { + let (array = util.Array) { + update(Styles.prototype, { + get sites() array([v.sites for ([k, v] in this.userSheets)]).flatten().uniq().__proto__, + completeSite: function (context, content) { + 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) {} + context.fork("others", 0, this, function (context) { + context.title = ["Site"]; + context.completions = [[s, ""] for ([, s] in Iterator(styles.sites))]; + }); } - let err = styles.addSheet(false, name, filter, css); - if (err) - liberator.echoerr(err); - } - }, - { - bang: true, - completer: function (context, args) - { - let compl = []; - if (args.completeArg == 0) - styles.completeSite(context, content); - else if (args.completeArg == 1) - { - let sheet = styles.get(false, args["-name"]); - if (sheet) - context.completions = [[sheet.css, "Current Value"]]; + }); + } + return storage.newObject("styles", Styles, { store: false }); + }, +}, { +}, { + commands: function () { + commands.add(["sty[le]"], + "Add or list user styles", + function (args) { + let [filter, css] = args; + let name = args["-name"]; + + if (!css) { + let list = Array.concat([i for (i in styles.userNames)], + [i for (i in styles.userSheets) if (!i[1].name)]); + let str = template.tabular(["", "Name", "Filter", "CSS"], + ["min-width: 1em; text-align: center; color: red; font-weight: bold;", + "padding: 0 1em 0 1ex; vertical-align: top;", + "padding: 0 1em 0 0; vertical-align: top;"], + ([sheet.enabled ? "" : "\u00d7", + key, + sheet.sites.join(","), + sheet.css] + for ([i, [key, sheet]] in Iterator(list)) + if ((!filter || sheet.sites.indexOf(filter) >= 0) && (!name || sheet.name == name)))); + commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + else { + if ("-append" in args) { + let sheet = styles.get(false, name); + if (sheet) { + filter = sheet.sites.concat(filter).join(","); + css = sheet.css + " " + css; + } + } + let err = styles.addSheet(false, name, filter, css); + if (err) + liberator.echoerr(err); } }, - hereDoc: true, - literal: 1, - options: [[["-name", "-n"], commands.OPTION_STRING, null, function () [[k, v.css] for ([k, v] in Iterator(styles.userNames))]], - [["-append", "-a"], commands.OPTION_NOARG]], - serial: function () [ - { - command: this.name, - bang: true, - options: sty.name ? { "-name": sty.name } : {}, - arguments: [sty.sites.join(",")], - literalArg: sty.css - } for ([k, sty] in styles.userSheets) - ] - }); + { + bang: true, + completer: function (context, args) { + let compl = []; + if (args.completeArg == 0) + styles.completeSite(context, content); + else if (args.completeArg == 1) { + let sheet = styles.get(false, args["-name"]); + if (sheet) + context.completions = [[sheet.css, "Current Value"]]; + } + }, + hereDoc: true, + literal: 1, + options: [[["-name", "-n"], commands.OPTION_STRING, null, function () [[k, v.css] for ([k, v] in Iterator(styles.userNames))]], + [["-append", "-a"], commands.OPTION_NOARG]], + serial: function () [ + { + command: this.name, + bang: true, + options: sty.name ? { "-name": sty.name } : {}, + arguments: [sty.sites.join(",")], + literalArg: sty.css + } for ([k, sty] in styles.userSheets) + ] + }); - [ - { - 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) styles.removeSheet(sheet) - } - ].forEach(function (cmd) { - commands.add(cmd.name, cmd.desc, - function (args) + [ { - styles.findSheets(false, args["-name"], args[0], args.literalArg, args["-index"]) - .forEach(cmd.action); + name: ["stylee[nable]", "stye[nable]"], + desc: "Enable a user style sheet", + action: function (sheet) sheet.enabled = true, + filter: function (sheet) !sheet.enabled }, - { - completer: function (context) { context.completions = styles.sites.map(function (site) [site, ""]); }, - literal: 1, - options: [[["-index", "-i"], commands.OPTION_INT, null, - function (context) { - context.compare = CompletionContext.Sort.number; - return [[i, <>{sheet.sites.join(",")}: {sheet.css.replace("\n", "\\n")}</>] - for ([i, sheet] in styles.userSheets) - if (!cmd.filter || cmd.filter(sheet))] - }], - [["-name", "-n"], commands.OPTION_STRING, null, - function () [[name, sheet.css] - for ([name, sheet] in Iterator(styles.userNames)) - if (!cmd.filter || cmd.filter(sheet))]]] - }); - }); - - 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; - if (clear && css) - return liberator.echo("E488: Trailing characters"); - - if (!css && !clear) { - // List matching keys - let str = template.tabular(["Key", "Sample", "CSS"], - ["padding: 0 1em 0 0; vertical-align: top", - "text-align: center"], - ([h.class, - <span style={"text-align: center; line-height: 1em;" + h.value + style}>XXX</span>, - template.highlightRegexp(h.value, /\b[-\w]+(?=:)/g)] - for (h in highlight) - if (!key || h.class.indexOf(key) > -1))); - commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); - return; - } - if (!key && clear) - return highlight.clear(); - let error = highlight.set(key, css, clear, "-append" in args); - if (error) - liberator.echoerr(error); - }, - { - // TODO: add this as a standard highlight completion function? - completer: function (context, args) + name: ["styled[isable]", "styd[isable]"], + desc: "Disable a user style sheet", + action: function (sheet) sheet.enabled = false, + filter: function (sheet) sheet.enabled + }, { - // 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) - context.completions = [[v.class, v.value] for (v in highlight)]; - else if (args.completeArg == 1) - { - let hl = highlight.get(args[0]); - if (hl) - context.completions = [[hl.value, "Current Value"], [hl.default || "", "Default Value"]]; - } + name: ["stylet[oggle]", "styt[oggle]"], + desc: "Toggle a user style sheet", + action: function (sheet) sheet.enabled = !sheet.enabled }, - hereDoc: true, - literal: 1, - options: [[["-append", "-a"], commands.OPTION_NOARG]], - serial: function () [ - { - command: this.name, - arguments: [k], - literalArg: v - } - for ([k, v] in Iterator(highlight)) - if (v.value != v.default) - ] + { + name: ["dels[tyle]"], + desc: "Remove a user style sheet", + action: function (sheet) styles.removeSheet(sheet) + } + ].forEach(function (cmd) { + commands.add(cmd.name, cmd.desc, + function (args) { + styles.findSheets(false, args["-name"], args[0], args.literalArg, args["-index"]) + .forEach(cmd.action); + }, + { + completer: function (context) { context.completions = styles.sites.map(function (site) [site, ""]); }, + literal: 1, + options: [[["-index", "-i"], commands.OPTION_INT, null, + function (context) { + context.compare = CompletionContext.Sort.number; + return [[i, <>{sheet.sites.join(",")}: {sheet.css.replace("\n", "\\n")}</>] + for ([i, sheet] in styles.userSheets) + if (!cmd.filter || cmd.filter(sheet))] + }], + [["-name", "-n"], commands.OPTION_STRING, null, + function () [[name, sheet.css] + for ([name, sheet] in Iterator(styles.userNames)) + if (!cmd.filter || cmd.filter(sheet))]]] + }); }); + }, + completion: function () { + completion.setFunctionCompleter(["get", "addSheet", "removeSheet", "findSheets"].map(function (m) styles[m]), + [ // Prototype: (system, name, filter, css, index) + null, + function (context, obj, args) args[0] ? styles.systemNames : styles.userNames, + function (context, obj, args) styles.completeSite(context, content), + null, + function (context, obj, args) args[0] ? styles.systemSheets : styles.userSheets + ]); + }, }); -/////////////////////////////////////////////////////////////////////////////}}} -////////////////////// COMPLETIONS ///////////////////////////////////////////// -/////////////////////////////////////////////////////////////////////////////{{{ - -liberator.registerObserver("load_completion", function () { - completion.setFunctionCompleter(["get", "addSheet", "removeSheet", "findSheets"].map(function (m) styles[m]), - [ // Prototype: (system, name, filter, css, index) - null, - function (context, obj, args) args[0] ? styles.systemNames : styles.userNames, - function (context, obj, args) styles.completeSite(context, content), - null, - function (context, obj, args) args[0] ? styles.systemSheets : styles.userSheets - ]); - - completion.colorScheme = function colorScheme(context) { - context.title = ["Color Scheme", "Runtime Path"]; - context.keys = { text: function (f) f.leafName.replace(/\.vimp$/, ""), description: ".parent.path" }; - context.completions = util.Array.flatten( - io.getRuntimeDirectories("colors").map( - function (dir) dir.readDirectory().filter( - function (file) /\.vimp$/.test(file.leafName)))) - }; +Module("highlight", { + requires: ["styles"], - completion.highlightGroup = function highlightGroup(context) { - context.title = ["Highlight Group", "Value"]; - context.completions = [[v.class, v.value] for (v in highlight)]; - }; + init: function () { + const self = storage.newObject("highlight", Highlights, { store: false }); + + if (self.CSS != Highlights.prototype.CSS) { + self.CSS = Highlights.prototype.CSS; + self.loadCSS(self.CSS); + } + return self; + }, +}, { +}, { + commands: function () { + commands.add(["colo[rscheme]"], + "Load a color scheme", + function (args) { + let scheme = args[0]; + + if (scheme == "default") + highlight.clear(); + else + liberator.assert(!io.sourceFromRuntimePath(["colors/" + scheme + ".vimp"]), + "E185: Cannot find color scheme " + 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; + if (clear && css) + return liberator.echo("E488: Trailing characters"); + + if (!css && !clear) { + // List matching keys + let str = template.tabular(["Key", "Sample", "CSS"], + ["padding: 0 1em 0 0; vertical-align: top", + "text-align: center"], + ([h.class, + <span style={"text-align: center; line-height: 1em;" + h.value + style}>XXX</span>, + template.highlightRegexp(h.value, /\b[-\w]+(?=:)/g)] + for (h in highlight) + if (!key || h.class.indexOf(key) > -1))); + commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + return; + } + if (!key && clear) + return highlight.clear(); + let error = highlight.set(key, css, clear, "-append" in args); + if (error) + liberator.echoerr(error); + }, + { + // 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) + context.completions = [[v.class, v.value] for (v in highlight)]; + else if (args.completeArg == 1) { + let hl = highlight.get(args[0]); + if (hl) + context.completions = [[hl.value, "Current Value"], [hl.default || "", "Default Value"]]; + } + }, + hereDoc: true, + literal: 1, + options: [[["-append", "-a"], commands.OPTION_NOARG]], + serial: function () [ + { + command: this.name, + arguments: [k], + literalArg: v + } + for ([k, v] in Iterator(highlight)) + if (v.value != v.default) + ] + }); + }, + completion: function () { + completion.colorScheme = function colorScheme(context) { + context.title = ["Color Scheme", "Runtime Path"]; + context.keys = { text: function (f) f.leafName.replace(/\.vimp$/, ""), description: ".parent.path" }; + context.completions = util.Array.flatten( + io.getRuntimeDirectories("colors").map( + function (dir) dir.readDirectory().filter( + function (file) /\.vimp$/.test(file.leafName)))) + + }; + + completion.highlightGroup = function highlightGroup(context) { + context.title = ["Highlight Group", "Value"]; + context.completions = [[v.class, v.value] for (v in highlight)]; + }; + }, }); -//}}} // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/tabs.js b/common/content/tabs.js index 396e30d2..c81701df 100644 --- a/common/content/tabs.js +++ b/common/content/tabs.js @@ -11,28 +11,508 @@ /** * @instance tabs */ -function Tabs() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var getBrowser = config.getBrowser || window.getBrowser; - - var alternates = [getBrowser().mCurrentTab, null]; - - // used for the "gb" and "gB" mappings to remember the last :buffer[!] command - var lastBufferSwitchArgs = ""; - var lastBufferSwitchSpecial = true; - - // @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 - function indexFromSpec(spec, wrap) - { +const Tabs = Module("tabs", { + init: function () { + this._alternates = [getBrowser().mCurrentTab, null]; + + // 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) + getBrowser().mTabContainer.collapsed = true; // FIXME: see 'stal' comment + }, + + /** + * @property {Object} The previously accessed tab or null if no tab + * other than the current one has been accessed. + */ + get alternate() this._alternates[1], + + /** + * @property {Iterator(Object)} A genenerator that returns all browsers + * in the current window. + */ + get browsers() { + let browsers = getBrowser().browsers; + for (let i = 0; i < browsers.length; i++) + yield [i, browsers[i]]; + }, + + /** + * @property {boolean} Whether the tab numbering XBL binding has been + * applied. + */ + get tabsBound() Boolean(styles.get(true, "tab-binding")), + set tabsBound(val) { + let fragment = liberator.has("MacUnix") ? "tab-mac" : "tab"; + if (!val) + styles.removeSheet(true, "tab-binding"); + else if (!this.tabsBound) + styles.addSheet(true, "tab-binding", "chrome://browser/content/browser.xul", + ".tabbrowser-tab { -moz-binding: url(chrome://liberator/content/bindings.xml#" + fragment + ") !important; }" + + // FIXME: better solution for themes? + ".tabbrowser-tab[busy] > .tab-icon > .tab-icon-image { list-style-image: url('chrome://global/skin/icons/loading_16.png') !important; }"); + }, + + /** + * @property {number} The number of tabs in the current window. + */ + get count() getBrowser().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; + }, + + getBrowser: getBrowser, + + /** + * Returns the local state store for the tab at the specified + * <b>tabIndex</b>. If <b>tabIndex</b> 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? + getLocalStore: function (tabIndex) { + let tab = this.getTab(tabIndex); + if (!tab.liberatorStore) + tab.liberatorStore = {}; + return tab.liberatorStore; + }, + + /** + * @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.get("json").decode(services.get("sessionStore").getClosedTabData(window)), + + /** + * Returns the index of <b>tab</b> or the index of the currently + * selected tab if <b>tab</b> is not specified. This is a 0-based + * index. + * + * @param {Object} tab A tab from the current tab list. + * @returns {number} + */ + index: function (tab) { + if (tab) + return Array.indexOf(getBrowser().mTabs, tab); + else + return getBrowser().mTabContainer.selectedIndex; + }, + + // TODO: implement filter + /** + * Returns an array of all tabs in the tab list. + * + * @returns {Object[]} + */ + // FIXME: why not return the tab element? + // : unused? Remove me. + get: function () { + let buffers = []; + for (let [i, browser] in this.browsers) { + let title = browser.contentTitle || "(Untitled)"; + let uri = browser.currentURI.spec; + let number = i + 1; + buffers.push([number, title, uri]); + } + return buffers; + }, + + /** + * Returns the index of the tab containing <b>content</b>. + * + * @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; + }, + + /** + * Returns the tab at the specified <b>index</b> or the currently + * selected tab if <b>index</b> is not specified. This is a 0-based + * index. + * + * @param {number} index The index of the tab required. + * @returns {Object} + */ + getTab: function (index) { + if (index != undefined) + return getBrowser().mTabs[index]; + else + return getBrowser().mCurrentTab; + }, + + /** + * Lists all tabs matching <b>filter</b>. + * + * @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 <b>spec</b> 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); + getBrowser().moveTabTo(tab, index); + }, + + /** + * Removes the specified <b>tab</b> from the tab list. + * + * @param {Object} tab + * @param {number} count + * @param {boolean} focusLeftTab Focus the tab to the left of the removed tab. + * @param {number} quitOnLastTab Whether to quit if the tab being + * deleted is the only tab in the tab list: + * 1 - quit without saving session + * 2 - quit and save session + */ + // FIXME: what is quitOnLastTab {1,2} all about then, eh? --djk + remove: function (tab, count, focusLeftTab, quitOnLastTab) { + let removeOrBlankTab = { + Firefox: function (tab) { + if (getBrowser().mTabs.length > 1) + getBrowser().removeTab(tab); + else { + if (buffer.URL != "about:blank" || + window.getWebNavigation().sessionHistory.count > 0) { + liberator.open("about:blank", liberator.NEW_BACKGROUND_TAB); + getBrowser().removeTab(tab); + } + else + liberator.beep(); + } + }, + Thunderbird: function (tab) { + if (getBrowser().mTabs.length > 1) + getBrowser().removeTab(tab); + else + liberator.beep(); + }, + Songbird: function (tab) { + if (getBrowser().mTabs.length > 1) + getBrowser().removeTab(tab); + else { + if (buffer.URL != "about:blank" || + window.getWebNavigation().sessionHistory.count > 0) + { + liberator.open("about:blank", liberator.NEW_BACKGROUND_TAB); + getBrowser().removeTab(tab); + } + else + liberator.beep(); + } + } + }[config.hostApplication] || function () {}; + + if (typeof count != "number" || count < 1) + count = 1; + + if (quitOnLastTab >= 1 && getBrowser().mTabs.length <= count) { + if (liberator.windows.length > 1) + window.close(); + else + liberator.quit(quitOnLastTab == 2); + + return; + } + + let index = this.index(tab); + if (focusLeftTab) { + let lastRemovedTab = 0; + for (let i = index; i > index - count && i >= 0; i--) { + removeOrBlankTab(this.getTab(i)); + lastRemovedTab = i > 0 ? i : 1; + } + getBrowser().mTabContainer.selectedIndex = lastRemovedTab - 1; + } + else { + let i = index + count - 1; + if (i >= this.count) + i = this.count - 1; + + for (; i >= index; i--) + removeOrBlankTab(this.getTab(i)); + getBrowser().mTabContainer.selectedIndex = index; + } + }, + + /** + * Removes all tabs from the tab list except the specified <b>tab</b>. + * + * @param {Object} tab The tab to keep. + */ + keepOnly: function (tab) { + getBrowser().removeAllTabsBut(tab); + }, + + /** + * Selects the tab at the position specified by <b>spec</b>. + * + * @param {string} spec See {@link Tabs.indexFromSpec} + * @param {boolean} wrap Whether an out of bounds <b>spec</b> causes + * the selection position to wrap around the start/end of the tab + * list. + */ + select: function (spec, wrap) { + let index = Tabs.indexFromSpec(spec, wrap); + // FIXME: + if (index == -1) + return void liberator.beep(); + getBrowser().mTabContainer.selectedIndex = index; + }, + + /** + * 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) { + if (bypassCache) { + const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY | Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + getBrowser().getBrowserForTab(tab).reloadWithFlags(flags); + } + else + getBrowser().reloadTab(tab); + }, + + /** + * Reloads all tabs. + * + * @param {boolean} bypassCache Whether to bypass the cache when + * reloading. + */ + reloadAll: function (bypassCache) { + if (bypassCache) { + for (let i = 0; i < getBrowser().mTabs.length; i++) { + try { + this.reload(getBrowser().mTabs[i], bypassCache); + } + catch (e) { + // FIXME: can we do anything useful here without stopping the + // other tabs from reloading? + } + } + } + else + getBrowser().reloadAllTabs(); + }, + + /** + * 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 <b>buffer</b>. + * + * @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 == "") + return; + + 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 === undefined || allowNonUnique == null) // XXX + allowNonUnique = this._lastBufferSwitchSpecial; + } + + if (buffer == "#") { + tabs.selectAlternateTab(); + return; + } + + if (!count || count < 1) + count = 1; + if (typeof reverse != "boolean") + reverse = false; + + let matches = buffer.match(/^(\d+):?/); + if (matches) { + tabs.select(parseInt(matches[1], 10) - 1, false); // make it zero-based + return; + } + + matches = []; + let lowerBuffer = buffer.toLowerCase(); + let first = tabs.index() + (reverse ? 0 : 1); + let nbrowsers = getBrowser().browsers.length; + for (let [i, ] in tabs.browsers) { + let index = (i + first) % nbrowsers; + let url = getBrowser().getBrowserAtIndex(index).contentDocument.location.href; + let title = getBrowser().getBrowserAtIndex(index).contentDocument.title.toLowerCase(); + if (url == buffer) { + tabs.select(index, false); + return; + } + + if (url.indexOf(buffer) >= 0 || title.indexOf(lowerBuffer) >= 0) + matches.push(index); + } + if (matches.length == 0) + liberator.echoerr("E94: No matching buffer for " + buffer); + else if (matches.length > 1 && !allowNonUnique) + liberator.echoerr("E93: More than one match for " + buffer); + else { + if (reverse) { + index = matches.length - count; + while (index < 0) + index += matches.length; + } + else + index = (count - 1) % matches.length; + + tabs.select(matches[index], false); + } + }, + + /** + * Clones the specified <b>tab</b> 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 = getBrowser().addTab(); + Tabs.copyTab(newTab, tab); + + if (activate) + getBrowser().mTabContainer.selectedItem = newTab; + + return newTab; + }, + + /** + * Detaches the specified <b>tab</b> 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 = getBrowser().mTabContainer.selectedItem; + + services.get("windowWatcher") + .openWindow(window, window.getBrowserURL(), null, "chrome,dialog=no,all", tab); + }, + + /** + * Selects the alternate tab. + */ + selectAlternateTab: function () { + liberator.assert(tabs.alternate != null && tabs.getTab() != tabs.alternate, + "E23: No alternate page"); + + // NOTE: this currently relies on v.tabs.index() returning the + // currently selected tab index when passed null + let index = tabs.index(tabs.alternate); + + // TODO: since a tab close is more like a bdelete for us we + // should probably reopen the closed tab when a 'deleted' + // alternate is selected + liberator.assert(index >= 0, "E86: Buffer does not exist"); // TODO: This should read "Buffer N does not exist" + tabs.select(index); + }, + + // 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) { + this._alternates = tabs || [this.getTab(), this._alternates[0]]; + } +}, { + copyTab: function (to, from) { + if (!from) + from = getBrowser().mTabContainer.selectedItem; + + let tabState = services.get("sessionStore").getTabState(from); + services.get("sessionStore").setTabState(to, tabState); + }, + + + /** + * @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) { let position = getBrowser().mTabContainer.selectedIndex; let length = getBrowser().mTabs.length; let last = length - 1; @@ -57,430 +537,57 @@ function Tabs() //{{{ position = wrap ? (position % length) + length : 0; return position; - } - - function copyTab(to, from) - { - if (!from) - from = getBrowser().mTabContainer.selectedItem; - - let tabState = services.get("sessionStore").getTabState(from); - services.get("sessionStore").setTabState(to, tabState); - } - - // hide tabs initially to prevent flickering when 'stal' would hide them - // on startup - if (config.hasTabbrowser) - getBrowser().mTabContainer.collapsed = true; // FIXME: see 'stal' comment - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["showtabline", "stal"], - "Control when to show the tab bar of opened web pages", - "number", config.defaults["showtabline"], - { - setter: function (value) - { - // FIXME: we manipulate mTabContainer underneath mStrip so we - // don't have to fight against the host app's attempts to keep - // it open - hack! Adding a filter watch to mStrip is probably - // the cleanest solution. - let tabStrip = getBrowser().mTabContainer; - - if (value == 0) - tabStrip.collapsed = true; - else - { - // FIXME: Why are we preferring our own created preference - // here? --djk - let pref = "browser.tabStrip.autoHide"; - if (options.getPref(pref) == null) // Try for FF 3.0 & 3.1 - pref = "browser.tabs.autoHide"; - options.safeSetPref(pref, value == 1); - tabStrip.collapsed = false; - } - - return value; - }, - completer: function (context) [ - ["0", "Never show tab bar"], - ["1", "Show tab bar only if more than one tab is open"], - ["2", "Always show tab bar"] - ], - validator: Option.validateCompleter - }); - - if (config.hasTabbrowser) - { - options.add(["activate", "act"], - "Define when tabs are automatically activated", - "stringlist", "homepage,quickmark,tabopen,paste", - { - completer: function (context) [ - ["homepage", "gH mapping"], - ["quickmark", "go and gn mappings"], - ["tabopen", ":tabopen[!] command"], - ["paste", "P and gP mappings"] - ], - validator: Option.validateCompleter - }); - - options.add(["newtab"], - "Define which commands should output in a new tab by default", - "stringlist", "", - { - completer: function (context) [ - ["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"] - ], - validator: Option.validateCompleter - }); - - // TODO: Is this really applicable to Xulmus? - options.add(["popups", "pps"], - "Where to show requested popup windows", - "stringlist", "tab", - { - setter: function (value) - { - let [open, restriction] = [1, 0]; - for (let [, opt] in Iterator(this.parseValues(value))) - { - if (opt == "tab") - open = 3; - else if (opt == "window") - open = 2; - else if (opt == "resized") - restriction = 2; - } - - options.safeSetPref("browser.link.open_newwindow", open, "See 'popups' option."); - options.safeSetPref("browser.link.open_newwindow.restriction", restriction, "See 'popups' option."); - return value; - }, - completer: function (context) [ - ["tab", "Open popups in a new tab"], - ["window", "Open popups in a new window"], - ["resized", "Open resized popups in a new window"] - ], - validator: Option.validateCompleter - }); - } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - mappings.add([modes.NORMAL], ["g0", "g^"], - "Go to the first tab", - function (count) { tabs.select(0); }); - - mappings.add([modes.NORMAL], ["g$"], - "Go to the last tab", - function (count) { tabs.select("$"); }); - - mappings.add([modes.NORMAL], ["gt"], - "Go to the next tab", - function (count) - { - if (count > 0) - 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 ? 1 : count), 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 ? 1 : count), true); }, - { count: true }); - - if (config.hasTabbrowser) - { - mappings.add([modes.NORMAL], ["b"], - "Open a prompt to switch buffers", - function (count) - { - if (count != -1) - tabs.switchTo(String(count)); - else - commandline.open(":", "buffer! ", modes.EX); - }, - { count: true }); - - mappings.add([modes.NORMAL], ["B"], - "Show buffer list", - function () { tabs.list(false); }); - - mappings.add([modes.NORMAL], ["d"], + }, +}, { + commands: function () { + commands.add(["bd[elete]", "bw[ipeout]", "bun[load]", "tabc[lose]"], "Delete current buffer", - function (count) { tabs.remove(tabs.getTab(), count, false, 0); }, - { count: true }); - - mappings.add([modes.NORMAL], ["D"], - "Delete current buffer, focus tab to the left", - function (count) { tabs.remove(tabs.getTab(), count, true, 0); }, - { 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 (liberator.has("tabs_undo")) - { - mappings.add([modes.NORMAL], ["u"], - "Undo closing of a tab", - function (count) { commands.get("undo").execute("", false, count); }, - { count: true }); - } - - mappings.add([modes.NORMAL], ["<C-^>", "<C-6>"], - "Select the alternate tab or the [count]th tab", - function (count) - { - if (count < 1) - tabs.selectAlternateTab(); - else - tabs.switchTo(count.toString(), false); - }, - { count: true }); - } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - 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.literalArg; - - if (arg) - { - let removed = 0; - let matches = arg.match(/^(\d+):?/); + function (args) { + let special = args.bang; + let count = args.count; + let arg = args.literalArg; - if (matches) - { - tabs.remove(tabs.getTab(parseInt(matches[1], 10) - 1)); - removed = 1; - } - else - { - let str = arg.toLowerCase(); - let browsers = getBrowser().browsers; + if (arg) { + let removed = 0; + let matches = arg.match(/^(\d+):?/); - 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; - } + if (matches) { + tabs.remove(tabs.getTab(parseInt(matches[1], 10) - 1)); + removed = 1; + } + else { + let str = arg.toLowerCase(); + let browsers = getBrowser().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); + [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))) - { - tabs.remove(tabs.getTab(i)); - removed++; + if (host.indexOf(str) >= 0 || uri == str || + (special && (title.indexOf(str) >= 0 || uri.indexOf(str) >= 0))) { + tabs.remove(tabs.getTab(i)); + removed++; + } } } - } - - if (removed > 0) - liberator.echomsg(removed + " fewer tab(s)", 9); - else - liberator.echoerr("E94: No matching tab for " + arg); - } - else // just remove the current tab - tabs.remove(tabs.getTab(), Math.max(count, 1), special, 0); - }, - { - argCount: "?", - bang: true, - count: true, - completer: function (context) completion.buffer(context), - literal: 0 - }); - - commands.add(["keepa[lt]"], - "Execute a command without changing the current alternate buffer", - function (args) - { - let alternate = tabs.alternate; - - try - { - liberator.execute(args[0], null, true); - } - finally - { - tabs.updateSelectionHistory([tabs.getTab(), alternate]); - } - }, - { - argCount: "+", - completer: function (context) completion.ex(context), - literal: 0 - }); - - // TODO: this should open in a new tab positioned directly after the current one, not at the end - commands.add(["tab"], - "Execute a command and tell it to output in a new tab", - function (args) - { - liberator.forceNewTab = true; - liberator.execute(args.string, null, true); - liberator.forceNewTab = false; - }, - { - argCount: "+", - completer: function (context) completion.ex(context), - literal: 0 - }); - - commands.add(["tabd[o]", "bufd[o]"], - "Execute a command in each tab", - function (args) - { - for (let i = 0; i < tabs.count; i++) - { - tabs.select(i); - liberator.execute(args.string, null, true); - } - }, - { - argCount: "1", - completer: function (context) completion.ex(context), - literal: 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 - liberator.echoerr("E488: Trailing characters"); - } - 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) - { - liberator.assert(/^\d+$/.test(arg), "E488: Trailing characters"); - index = arg - 1; - } - else - index = count - 1; - if (index < tabs.count) - tabs.select(index, true); - else - liberator.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) - { - // TODO: "Zero count" if 0 specified as arg, multiple args and count ranges? - commands.add(["b[uffer]"], - "Switch to a buffer", - function (args) - { - let special = args.bang; - let count = args.count; - let arg = args.literalArg; - - // if a numeric arg is specified any count is ignored; if a - // count and non-numeric arg are both specified then E488 - if (arg && count > 0) - { - if (/^\d+$/.test(arg)) - tabs.switchTo(arg, special); + if (removed > 0) + liberator.echomsg(removed + " fewer tab(s)", 9); else - liberator.echoerr("E488: Trailing characters"); + liberator.echoerr("E94: No matching tab for " + arg); } - else if (count > 0) - tabs.switchTo(count.toString(), special); - else - tabs.switchTo(arg, special); - }, - { + else // just remove the current tab + tabs.remove(tabs.getTab(), Math.max(count, 1), special, 0); + }, { argCount: "?", bang: true, count: true, @@ -488,736 +595,501 @@ function Tabs() //{{{ literal: 0 }); - commands.add(["buffers", "files", "ls", "tabs"], - "Show a list of all buffers", - function (args) { tabs.list(args.literalArg); }, - { - argCount: "?", - literal: 0 - }); + commands.add(["keepa[lt]"], + "Execute a command without changing the current alternate buffer", + function (args) { + let alternate = tabs.alternate; - commands.add(["quita[ll]", "qa[ll]"], - "Quit " + config.name, - function (args) { liberator.quit(false, args.bang); }, - { - argCount: "0", - bang: true + try { + liberator.execute(args[0], null, true); + } + finally { + tabs.updateSelectionHistory([tabs.getTab(), alternate]); + } + }, { + argCount: "+", + completer: function (context) completion.ex(context), + literal: 0 }); - commands.add(["reloada[ll]"], - "Reload all tab pages", - function (args) { tabs.reloadAll(args.bang); }, - { - argCount: "0", - bang: true + // TODO: this should open in a new tab positioned directly after the current one, not at the end + commands.add(["tab"], + "Execute a command and tell it to output in a new tab", + function (args) { + liberator.forceNewTab = true; + liberator.execute(args.string, null, true); + liberator.forceNewTab = false; + }, { + argCount: "+", + completer: function (context) completion.ex(context), + literal: 0 }); - 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 - liberator.assert(!arg || /^([+-]?\d+)$/.test(arg), - "E488: Trailing characters"); - - // if not specified, move to after the last tab - tabs.move(getBrowser().mCurrentTab, arg || "$", args.bang); - }, - { - argCount: "?", - bang: true + commands.add(["tabd[o]", "bufd[o]"], + "Execute a command in each tab", + function (args) { + for (let i = 0; i < tabs.count; i++) { + tabs.select(i); + liberator.execute(args.string, null, true); + } + }, { + argCount: "1", + completer: function (context) completion.ex(context), + literal: 0 }); - commands.add(["tabo[nly]"], - "Close all other tabs", - function () { tabs.keepOnly(getBrowser().mCurrentTab); }, + commands.add(["tabl[ast]", "bl[ast]"], + "Switch to the last tab", + function () tabs.select("$", false), { argCount: "0" }); - commands.add(["tabopen", "t[open]", "tabnew", "tabe[dit]"], - "Open one or more URLs in a new tab", - function (args) - { - let special = args.bang; - args = args.string; + // 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]; - let where = special ? liberator.NEW_TAB : liberator.NEW_BACKGROUND_TAB; - if (args) - liberator.open(args, { from: "tabopen", where: where }); + // count is ignored if an arg is specified, as per Vim + if (arg) { + if (/^\d+$/.test(arg)) + tabs.select("-" + arg, true); + else + liberator.echoerr("E488: Trailing characters"); + } + else if (count > 0) + tabs.select("-" + count, true); else - liberator.open("about:blank", { from: "tabopen", where: where }); - }, - { - bang: true, - completer: function (context) completion.url(context), - literal: 0, - privateData: true + tabs.select("-1", true); + }, { + argCount: "?", + count: true }); - commands.add(["tabde[tach]"], - "Detach current tab to its own window", - function () - { - liberator.assert(tabs.count > 1, "Can't detach the last tab"); + // 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]; - tabs.detachTab(null); - }, - { argCount: "0" }); + if (arg || count > 0) { + let index; - commands.add(["tabdu[plicate]"], - "Duplicate current tab", - function (args) - { - let tab = tabs.getTab(); - - let activate = args.bang ? true : false; - if (/\btabopen\b/.test(options["activate"])) - activate = !activate; + // count is ignored if an arg is specified, as per Vim + if (arg) { + liberator.assert(/^\d+$/.test(arg), "E488: Trailing characters"); + index = arg - 1; + } + else + index = count - 1; - for (let i in util.range(0, Math.max(1, args.count))) - tabs.cloneTab(tab, activate); - }, - { - argCount: "0", - bang: true, + if (index < tabs.count) + tabs.select(index, true); + else + liberator.beep(); + } + else + tabs.select("+1", true); + }, { + argCount: "?", 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) - { - liberator.assert(args.length <= 2 && !args.some(function (i) i && !/^\d+$/.test(i)), - "E488: Trailing characters"); - - let [winIndex, tabIndex] = args.map(parseInt); - let win = liberator.windows[winIndex - 1]; + commands.add(["tabr[ewind]", "tabfir[st]", "br[ewind]", "bf[irst]"], + "Switch to the first tab", + function () { tabs.select(0, false); }, + { argCount: "0" }); - liberator.assert(win, "Window " + winIndex + " does not exist"); - liberator.assert(win != window, "Can't reattach to the same window"); + if (config.hasTabbrowser) { + // TODO: "Zero count" if 0 specified as arg, multiple args and count ranges? + commands.add(["b[uffer]"], + "Switch to a buffer", + function (args) { + let special = args.bang; + let count = args.count; + let arg = args.literalArg; + + // if a numeric arg is specified any count is ignored; if a + // count and non-numeric arg are both specified then E488 + if (arg && count > 0) { + liberator.assert(/^\d+$/.test(arg), "E488: Trailing characters"); + tabs.switchTo(arg, special); + } + else if (count > 0) + tabs.switchTo(count.toString(), special); + else + tabs.switchTo(arg, special); + }, { + argCount: "?", + bang: true, + count: true, + completer: function (context) completion.buffer(context), + literal: 0 + }); + + commands.add(["buffers", "files", "ls", "tabs"], + "Show a list of all buffers", + function (args) { tabs.list(args.literalArg); }, { + argCount: "?", + literal: 0 + }); + + commands.add(["quita[ll]", "qa[ll]"], + "Quit " + config.name, + function (args) { liberator.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 + liberator.assert(!arg || /^([+-]?\d+)$/.test(arg), + "E488: Trailing characters"); + + // if not specified, move to after the last tab + tabs.move(getBrowser().mCurrentTab, arg || "$", args.bang); + }, { + argCount: "?", + bang: true + }); + + commands.add(["tabo[nly]"], + "Close all other tabs", + function () { tabs.keepOnly(getBrowser().mCurrentTab); }, + { argCount: "0" }); + + commands.add(["tabopen", "t[open]", "tabnew", "tabe[dit]"], + "Open one or more URLs in a new tab", + function (args) { + let special = args.bang; + args = args.string; + + let where = special ? liberator.NEW_TAB : liberator.NEW_BACKGROUND_TAB; + if (args) + liberator.open(args, { from: "tabopen", where: where }); + else + liberator.open("about:blank", { from: "tabopen", where: where }); + }, { + bang: true, + completer: function (context) completion.url(context), + literal: 0, + privateData: true + }); + + commands.add(["tabde[tach]"], + "Detach current tab to its own window", + function () { + liberator.assert(tabs.count > 1, "Can't detach the last tab"); + + 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 (/\btabopen\b/.test(options["activate"])) + 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) { + liberator.assert(args.length <= 2 && !args.some(function (i) !/^\d+$/.test(i)), + "E488: Trailing characters"); + + let [winIndex, tabIndex] = args.map(parseInt); + let win = liberator.windows[winIndex - 1]; + + liberator.assert(win, "Window " + winIndex + " does not exist"); + liberator.assert(win != window, "Can't reattach to the same window"); + + 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, util.Math.constrain(tabIndex || last, 0, last)); + browser.selectedTab = dummy; // required + browser.swapBrowsersAndCloseOther(dummy, getBrowser().mCurrentTab); + }, { + argCount: "+", + completer: function (context, args) { + if (args.completeArg == 0) { + context.filters.push(function ({ item: win }) win != window); + completion.window(context); + } + } + }); + } - 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; + if (liberator.has("tabs_undo")) { + commands.add(["u[ndo]"], + "Undo closing of a tab", + function (args) { + if (args.length) + args = args[0]; + else + args = Math.max(args.count, 0); - let last = browser.mTabs.length - 1; + let m; + if (m = /^(\d+)(:|$)/.exec(args || '1')) + 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) + return void window.undoCloseTab(i); - browser.moveTabTo(dummy, util.Math.constrain(tabIndex || last, 0, last)); - browser.selectedTab = dummy; // required - browser.swapBrowsersAndCloseOther(dummy, getBrowser().mCurrentTab); - }, - { - argCount: "+", - completer: function (context, args) - { - if (args.completeArg == 0) - { - context.filters.push(function ({ item: win }) win != window); - completion.window(context); + liberator.echoerr("Exxx: No matching closed tab"); } - } - }); - } - - if (liberator.has("tabs_undo")) - { - commands.add(["u[ndo]"], - "Undo closing of a tab", - function (args) - { - if (args.length) - args = args[0]; - else - args = Math.max(args.count, 0); + }, { + 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 + }); - let m; - if (m = /^(\d+)(:|$)/.exec(args || '1')) - 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) - return void window.undoCloseTab(i); + commands.add(["undoa[ll]"], + "Undo closing of all closed tabs", + function (args) { + for (let i in Iterator(tabs.closedTabs)) + window.undoCloseTab(0); - liberator.echoerr("Exxx: No matching closed tab"); - } - }, - { - 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 - }); + { argCount: "0" }); - commands.add(["undoa[ll]"], - "Undo closing of all closed tabs", - function (args) - { - for (let i in Iterator(tabs.closedTabs)) - window.undoCloseTab(0); + } + if (liberator.has("session")) { + commands.add(["wqa[ll]", "wq", "xa[ll]"], + "Save the session and quit", + function () { liberator.quit(true); }, + { argCount: "0" }); + } + }, + completion: function () { + completion.addUrlCompleter("t", + "Open tabs", + completion.buffer); + }, + mappings: function () { + mappings.add([modes.NORMAL], ["g0", "g^"], + "Go to the first tab", + function (count) { tabs.select(0); }); + + mappings.add([modes.NORMAL], ["g$"], + "Go to the last tab", + function (count) { tabs.select("$"); }); + + mappings.add([modes.NORMAL], ["gt"], + "Go to the next tab", + function (count) { + if (count > 0) + tabs.select(count - 1, false); + else + tabs.select("+1", true); }, - { argCount: "0" }); + { count: true }); - } + mappings.add([modes.NORMAL], ["<C-n>", "<C-Tab>", "<C-PageDown>"], + "Go to the next tab", + function (count) { tabs.select("+" + (count < 1 ? 1 : count), true); }, + { count: true }); - if (liberator.has("session")) - { - commands.add(["wqa[ll]", "wq", "xa[ll]"], - "Save the session and quit", - function () { liberator.quit(true); }, - { argCount: "0" }); - } + mappings.add([modes.NORMAL], ["gT", "<C-p>", "<C-S-Tab>", "<C-PageUp>"], + "Go to previous tab", + function (count) { tabs.select("-" + (count < 1 ? 1 : count), true); }, + { count: true }); - completion.addUrlCompleter("t", - "Open tabs", - completion.buffer); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - /** - * @property {Object} The previously accessed tab or null if no tab - * other than the current one has been accessed. - */ - get alternate() alternates[1], - - /** - * @property {Iterator(Object)} A genenerator that returns all browsers - * in the current window. - */ - get browsers() - { - let browsers = getBrowser().browsers; - for (let i = 0; i < browsers.length; i++) - yield [i, browsers[i]]; - }, - - /** - * @property {boolean} Whether the tab numbering XBL binding has been - * applied. - */ - get tabsBound() Boolean(styles.get(true, "tab-binding")), - set tabsBound(val) - { - let fragment = liberator.has("MacUnix") ? "tab-mac" : "tab"; - if (!val) - styles.removeSheet(true, "tab-binding"); - else if (!this.tabsBound) - styles.addSheet(true, "tab-binding", "chrome://browser/content/browser.xul", - ".tabbrowser-tab { -moz-binding: url(chrome://liberator/content/bindings.xml#" + fragment + ") !important; }" + - // FIXME: better solution for themes? - ".tabbrowser-tab[busy] > .tab-icon > .tab-icon-image { list-style-image: url('chrome://global/skin/icons/loading_16.png') !important; }"); - }, - - /** - * @property {number} The number of tabs in the current window. - */ - get count() getBrowser().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; - }, - - getBrowser: getBrowser, - - /** - * Returns the local state store for the tab at the specified - * <b>tabIndex</b>. If <b>tabIndex</b> 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? - getLocalStore: function (tabIndex) - { - let tab = this.getTab(tabIndex); - if (!tab.liberatorStore) - tab.liberatorStore = {}; - return tab.liberatorStore; - }, - - /** - * @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.get("json").decode(services.get("sessionStore").getClosedTabData(window)), - - /** - * Returns the index of <b>tab</b> or the index of the currently - * selected tab if <b>tab</b> is not specified. This is a 0-based - * index. - * - * @param {Object} tab A tab from the current tab list. - * @returns {number} - */ - index: function (tab) - { - if (tab) - return Array.indexOf(getBrowser().mTabs, tab); - else - return getBrowser().mTabContainer.selectedIndex; - }, - - // TODO: implement filter - /** - * Returns an array of all tabs in the tab list. - * - * @returns {Object[]} - */ - // FIXME: why not return the tab element? - // : unused? Remove me. - get: function () - { - let buffers = []; - for (let [i, browser] in this.browsers) - { - let title = browser.contentTitle || "(Untitled)"; - let uri = browser.currentURI.spec; - let number = i + 1; - buffers.push([number, title, uri]); - } - return buffers; - }, - - /** - * Returns the index of the tab containing <b>content</b>. - * - * @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; - }, - - /** - * Returns the tab at the specified <b>index</b> or the currently - * selected tab if <b>index</b> is not specified. This is a 0-based - * index. - * - * @param {number} index The index of the tab required. - * @returns {Object} - */ - getTab: function (index) - { - if (index != undefined) - return getBrowser().mTabs[index]; - else - return getBrowser().mCurrentTab; - }, - - /** - * Lists all tabs matching <b>filter</b>. - * - * @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 indexFromSpec}. - * @param {boolean} wrap Whether an out of bounds <b>spec</b> causes - * the destination position to wrap around the start/end of the tab - * list. - */ - move: function (tab, spec, wrap) - { - let index = indexFromSpec(spec, wrap); - getBrowser().moveTabTo(tab, index); - }, - - /** - * Removes the specified <b>tab</b> from the tab list. - * - * @param {Object} tab - * @param {number} count - * @param {boolean} focusLeftTab Focus the tab to the left of the removed tab. - * @param {number} quitOnLastTab Whether to quit if the tab being - * deleted is the only tab in the tab list: - * 1 - quit without saving session - * 2 - quit and save session - */ - // FIXME: what is quitOnLastTab {1,2} all about then, eh? --djk - remove: function (tab, count, focusLeftTab, quitOnLastTab) - { - let removeOrBlankTab = { - Firefox: function (tab) - { - if (getBrowser().mTabs.length > 1) - getBrowser().removeTab(tab); - else - { - if (buffer.URL != "about:blank" || - window.getWebNavigation().sessionHistory.count > 0) - { - liberator.open("about:blank", liberator.NEW_BACKGROUND_TAB); - getBrowser().removeTab(tab); - } - else - liberator.beep(); - } - }, - Thunderbird: function (tab) - { - if (getBrowser().mTabs.length > 1) - getBrowser().removeTab(tab); - else - liberator.beep(); - }, - Songbird: function (tab) - { - if (getBrowser().mTabs.length > 1) - getBrowser().removeTab(tab); - else - { - if (buffer.URL != "about:blank" || - window.getWebNavigation().sessionHistory.count > 0) - { - liberator.open("about:blank", liberator.NEW_BACKGROUND_TAB); - getBrowser().removeTab(tab); - } - else - liberator.beep(); - } - } - }[config.hostApplication] || function () {}; + if (config.hasTabbrowser) { + mappings.add([modes.NORMAL], ["b"], + "Open a prompt to switch buffers", + function (count) { + if (count != -1) + tabs.switchTo(String(count)); + else + commandline.open(":", "buffer! ", modes.EX); + }, + { count: true }); - if (typeof count != "number" || count < 1) - count = 1; + mappings.add([modes.NORMAL], ["B"], + "Show buffer list", + function () { tabs.list(false); }); - if (quitOnLastTab >= 1 && getBrowser().mTabs.length <= count) - { - if (liberator.windows.length > 1) - window.close(); - else - liberator.quit(quitOnLastTab == 2); + mappings.add([modes.NORMAL], ["d"], + "Delete current buffer", + function (count) { tabs.remove(tabs.getTab(), count, false, 0); }, + { count: true }); - return; - } + mappings.add([modes.NORMAL], ["D"], + "Delete current buffer, focus tab to the left", + function (count) { tabs.remove(tabs.getTab(), count, true, 0); }, + { count: true }); - let index = this.index(tab); - if (focusLeftTab) - { - let lastRemovedTab = 0; - for (let i = index; i > index - count && i >= 0; i--) - { - removeOrBlankTab(this.getTab(i)); - lastRemovedTab = i > 0 ? i : 1; - } - getBrowser().mTabContainer.selectedIndex = lastRemovedTab - 1; - } - else - { - let i = index + count - 1; - if (i >= this.count) - i = this.count - 1; + mappings.add([modes.NORMAL], ["gb"], + "Repeat last :buffer[!] command", + function (count) { tabs.switchTo(null, null, count, false); }, + { count: true }); - for (; i >= index; i--) - removeOrBlankTab(this.getTab(i)); - getBrowser().mTabContainer.selectedIndex = index; - } - }, - - /** - * Removes all tabs from the tab list except the specified <b>tab</b>. - * - * @param {Object} tab The tab to keep. - */ - keepOnly: function (tab) - { - getBrowser().removeAllTabsBut(tab); - }, - - /** - * Selects the tab at the position specified by <b>spec</b>. - * - * @param {string} spec See {@link indexFromSpec} - * @param {boolean} wrap Whether an out of bounds <b>spec</b> causes - * the selection position to wrap around the start/end of the tab - * list. - */ - select: function (spec, wrap) - { - let index = indexFromSpec(spec, wrap); - // FIXME: - if (index == -1) - return void liberator.beep(); - getBrowser().mTabContainer.selectedIndex = index; - }, - - /** - * 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) - { - if (bypassCache) - { - const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY | Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; - getBrowser().getBrowserForTab(tab).reloadWithFlags(flags); - } - else - getBrowser().reloadTab(tab); - }, - - /** - * Reloads all tabs. - * - * @param {boolean} bypassCache Whether to bypass the cache when - * reloading. - */ - reloadAll: function (bypassCache) - { - if (bypassCache) - { - for (let i = 0; i < getBrowser().mTabs.length; i++) - { - try - { - this.reload(getBrowser().mTabs[i], bypassCache); - } - catch (e) - { - // FIXME: can we do anything useful here without stopping the - // other tabs from reloading? - } - } - } - else - getBrowser().reloadAllTabs(); - }, - - /** - * 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 <b>buffer</b>. - * - * @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 == "") - return; + mappings.add([modes.NORMAL], ["gB"], + "Repeat last :buffer[!] command in reverse direction", + function (count) { tabs.switchTo(null, null, count, true); }, + { count: true }); - if (buffer != null) - { - // store this command, so it can be repeated with "B" - lastBufferSwitchArgs = buffer; - lastBufferSwitchSpecial = allowNonUnique; - } - else - { - buffer = lastBufferSwitchArgs; - if (allowNonUnique === undefined || allowNonUnique == null) // XXX - allowNonUnique = lastBufferSwitchSpecial; + // TODO: feature dependencies - implies "session"? + if (liberator.has("tabs_undo")) { + mappings.add([modes.NORMAL], ["u"], + "Undo closing of a tab", + function (count) { commands.get("undo").execute("", false, count); }, + { count: true }); } - if (buffer == "#") + mappings.add([modes.NORMAL], ["<C-^>", "<C-6>"], + "Select the alternate tab or the [count]th tab", + function (count) { + if (count < 1) + tabs.selectAlternateTab(); + else + tabs.switchTo(count.toString(), false); + }, + { count: true }); + } + }, + options: function () { + options.add(["showtabline", "stal"], + "Control when to show the tab bar of opened web pages", + "number", config.defaults["showtabline"], { - tabs.selectAlternateTab(); - return; - } - - if (!count || count < 1) - count = 1; - if (typeof reverse != "boolean") - reverse = false; + setter: function (value) { + // FIXME: we manipulate mTabContainer underneath mStrip so we + // don't have to fight against the host app's attempts to keep + // it open - hack! Adding a filter watch to mStrip is probably + // the cleanest solution. + let tabStrip = getBrowser().mTabContainer; + + if (value == 0) + tabStrip.collapsed = true; + else { + // FIXME: Why are we preferring our own created preference + // here? --djk + let pref = "browser.tabStrip.autoHide"; + if (options.getPref(pref) == null) // Try for FF 3.0 & 3.1 + pref = "browser.tabs.autoHide"; + options.safeSetPref(pref, value == 1); + tabStrip.collapsed = false; + } - let matches = buffer.match(/^(\d+):?/); - if (matches) - { - tabs.select(parseInt(matches[1], 10) - 1, false); // make it zero-based - return; - } + return value; + }, + completer: function (context) [ + ["0", "Never show tab bar"], + ["1", "Show tab bar only if more than one tab is open"], + ["2", "Always show tab bar"] + ], + validator: Option.validateCompleter + }); - matches = []; - let lowerBuffer = buffer.toLowerCase(); - let first = tabs.index() + (reverse ? 0 : 1); - let nbrowsers = getBrowser().browsers.length; - for (let [i, ] in tabs.browsers) - { - let index = (i + first) % nbrowsers; - let url = getBrowser().getBrowserAtIndex(index).contentDocument.location.href; - let title = getBrowser().getBrowserAtIndex(index).contentDocument.title.toLowerCase(); - if (url == buffer) + if (config.hasTabbrowser) { + options.add(["activate", "act"], + "Define when tabs are automatically activated", + "stringlist", "homepage,quickmark,tabopen,paste", { - tabs.select(index, false); - return; - } - - if (url.indexOf(buffer) >= 0 || title.indexOf(lowerBuffer) >= 0) - matches.push(index); - } - if (matches.length == 0) - liberator.echoerr("E94: No matching buffer for " + buffer); - else if (matches.length > 1 && !allowNonUnique) - liberator.echoerr("E93: More than one match for " + buffer); - else - { - if (reverse) + completer: function (context) [ + ["homepage", "gH mapping"], + ["quickmark", "go and gn mappings"], + ["tabopen", ":tabopen[!] command"], + ["paste", "P and gP mappings"] + ], + validator: Option.validateCompleter + }); + + options.add(["newtab"], + "Define which commands should output in a new tab by default", + "stringlist", "", { - index = matches.length - count; - while (index < 0) - index += matches.length; - } - else - index = (count - 1) % matches.length; + completer: function (context) [ + ["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"] + ], + validator: Option.validateCompleter + }); + + // TODO: Is this really applicable to Xulmus? + options.add(["popups", "pps"], + "Where to show requested popup windows", + "stringlist", "tab", + { + setter: function (value) { + let [open, restriction] = [1, 0]; + for (let [, opt] in Iterator(this.parseValues(value))) { + if (opt == "tab") + open = 3; + else if (opt == "window") + open = 2; + else if (opt == "resized") + restriction = 2; + } - tabs.select(matches[index], false); - } - }, - - /** - * Clones the specified <b>tab</b> 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 = getBrowser().addTab(); - copyTab(newTab, tab); - - if (activate) - getBrowser().mTabContainer.selectedItem = newTab; - - return newTab; - }, - - /** - * Detaches the specified <b>tab</b> 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 = getBrowser().mTabContainer.selectedItem; - - services.get("windowWatcher") - .openWindow(window, window.getBrowserURL(), null, "chrome,dialog=no,all", tab); - }, - - /** - * Selects the alternate tab. - */ - selectAlternateTab: function () - { - liberator.assert(tabs.alternate != null && tabs.getTab() != tabs.alternate, - "E23: No alternate page"); - - // NOTE: this currently relies on v.tabs.index() returning the - // currently selected tab index when passed null - let index = tabs.index(tabs.alternate); - - // TODO: since a tab close is more like a bdelete for us we - // should probably reopen the closed tab when a 'deleted' - // alternate is selected - liberator.assert(index >= 0, "E86: Buffer does not exist"); // TODO: This should read "Buffer N does not exist" - tabs.select(index); - }, - - // 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) - { - alternates = tabs || [this.getTab(), alternates[0]]; + options.safeSetPref("browser.link.open_newwindow", open, "See 'popups' option."); + options.safeSetPref("browser.link.open_newwindow.restriction", restriction, "See 'popups' option."); + return value; + }, + completer: function (context) [ + ["tab", "Open popups in a new tab"], + ["window", "Open popups in a new window"], + ["resized", "Open resized popups in a new window"] + ], + validator: Option.validateCompleter + }); } - }; - //}}} -} //}}} + }, +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/template.js b/common/content/template.js index 8a494366..116f0d54 100644 --- a/common/content/template.js +++ b/common/content/template.js @@ -6,18 +6,16 @@ /** @scope modules */ -const template = { //{{{ +const 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) - { + map: function map(iter, func, sep, interruptable) { if (iter.length) // FIXME: Kludge? iter = util.Array.itervalues(iter); let ret = <></>; let n = 0; - for each (let i in Iterator(iter)) - { + for each (let i in Iterator(iter)) { let val = func(i); if (val == undefined) continue; @@ -30,30 +28,25 @@ const template = { //{{{ return ret; }, - maybeXML: function maybeXML(xml) - { + maybeXML: function maybeXML(xml) { if (typeof xml == "xml") return xml; - try - { + try { return new XMLList(xml); } catch (e) {} return <>{xml}</>; }, - completionRow: function completionRow(item, highlightGroup) - { + completionRow: function completionRow(item, highlightGroup) { if (typeof icon == "function") icon = icon(); - if (highlightGroup) - { + if (highlightGroup) { var text = item[0] || ""; var desc = item[1] || ""; } - else - { + else { var text = this.process[0].call(this, item, item.text); var desc = this.process[1].call(this, item, item.description); } @@ -85,8 +78,7 @@ const template = { //{{{ } </>, - icon: function (item, text) - { + icon: function (item, text) { return <><span highlight="CompIcon">{item.icon ? <img src={item.icon}/> : <></>}</span><span class="td-strut"/>{text}</> }, @@ -108,79 +100,69 @@ const template = { //{{{ // 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) - { + highlight: function highlight(arg, processStrings, clip) { // some objects like window.JSON or getBrowsers()._browsers need the try/catch - try - { + 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>; - return <>{arg}</>; - case "undefined": - return <span highlight="Null">{arg}</span>; - case "object": - // 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>]]>; + 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>; + return <>{arg}</>; + case "undefined": + return <span highlight="Null">{arg}</span>; + case "object": + // 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) - { + catch (e) { return<![CDATA[<unknown>]]>; } }, - highlightFilter: function highlightFilter(str, filter, highlight) - { - return this.highlightSubstrings(str, (function () - { + 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) - { + 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 () - { + highlightRegexp: function highlightRegexp(str, re, highlight) { + return this.highlightSubstrings(str, (function () { let res; while ((res = re.exec(str)) && res[0].length) yield [res.index, res[0].length]; })(), highlight || template.filter); }, - highlightSubstrings: function highlightSubstrings(str, iter, highlight) - { + highlightSubstrings: function highlightSubstrings(str, iter, highlight) { if (typeof str == "xml") return str; if (str == "") @@ -190,8 +172,7 @@ const template = { //{{{ let s = <></>; let start = 0; let n = 0; - for (let [i, length] in iter) - { + for (let [i, length] in iter) { if (n++ > 50) // Prevent infinite loops. return s + <>{str.substr(start)}</>; XML.ignoreWhitespace = false; @@ -202,23 +183,20 @@ const template = { //{{{ return s + <>{str.substr(start)}</>; }, - highlightURL: function highlightURL(str, force) - { + highlightURL: function highlightURL(str, force) { if (force || /^[a-zA-Z]+:\/\//.test(str)) return <a highlight="URL" href={str}>{str}</a>; else return str; }, - commandOutput: function generic(xml) - { + commandOutput: function generic(xml) { return <>:{commandline.command}<br/>{xml}</>; }, // every item must have a .xml property which defines how to draw itself // @param headers is an array of strings, the text for the header columns - genericTable: function genericTable(items, format) - { + genericTable: function genericTable(items, format) { completion.listCompleter(function (context) { context.filterFunc = null; if (format) @@ -227,8 +205,7 @@ const template = { //{{{ }); }, - jumps: function jumps(index, elems) - { + jumps: function jumps(index, elems) { // <e4x> return this.commandOutput( <table> @@ -248,8 +225,7 @@ const template = { //{{{ // </e4x> }, - options: function options(title, opts) - { + options: function options(title, opts) { // <e4x> return this.commandOutput( <table> @@ -269,8 +245,7 @@ const template = { //{{{ // </e4x> }, - table: function table(title, data, indent) - { + table: function table(title, data, indent) { let table = // <e4x> <table> @@ -290,8 +265,7 @@ const template = { //{{{ return table; }, - tabular: function tabular(headings, style, iter) - { + tabular: function tabular(headings, style, iter) { // TODO: This might be mind-bogglingly slow. We'll see. // <e4x> return this.commandOutput( @@ -315,8 +289,7 @@ const template = { //{{{ // </e4x> }, - usage: function usage(iter) - { + usage: function usage(iter) { // <e4x> return this.commandOutput( <table> @@ -330,6 +303,6 @@ const template = { //{{{ </table>); // </e4x> } -}; //}}} +}); // vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/ui.js b/common/content/ui.js deleted file mode 100644 index 61782568..00000000 --- a/common/content/ui.js +++ /dev/null @@ -1,2312 +0,0 @@ -// 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. - -/** @scope modules */ - -let fontSize = util.computedStyle(document.getElementById(config.mainWindowId)).fontSize; - -styles.registerSheet("chrome://liberator/skin/liberator.css"); -let error = styles.addSheet(true, "font-size", "chrome://liberator/content/buffer.xhtml", - "body { font-size: " + fontSize + "; }"); - - -/** - * 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. - */ -function CommandLine() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const callbacks = {}; - - storage.newArray("history-search", { store: true, privateData: true }); - storage.newArray("history-command", { store: true, privateData: true }); - - // Really inideal. - let services = modules.services; // Storage objects are global to all windows, 'modules' isn't. - storage.newObject("sanitize", function () { - ({ - CLEAR: "browser:purge-session-history", - QUIT: "quit-application", - init: function () - { - services.get("observer").addObserver(this, this.CLEAR, false); - services.get("observer").addObserver(this, this.QUIT, false); - }, - observe: function (subject, topic, data) - { - switch (topic) - { - case this.CLEAR: - ["search", "command"].forEach(function (mode) { - History(null, mode).sanitize(); - }); - break; - case this.QUIT: - services.get("observer").removeObserver(this, this.CLEAR); - services.get("observer").removeObserver(this, this.QUIT); - break; - } - } - }).init(); - }, { store: false }); - storage.addObserver("sanitize", - function (key, event, value) { - autocommands.trigger("Sanitize", {}); - }, window); - - var 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 = []; - }, - - add: function add(message) - { - if (!message) - return; - - if (this._messages.length >= options["messages"]) - this._messages.shift(); - - this._messages.push(message); - } - }; //}}} - var lastMowOutput = null; - - var silent = false; - var quiet = false; - var keepCommand = false; - var lastEcho = null; - - /** - * A class for managing the history of an inputField. - * - * @param {HTMLInputElement} inputField - * @param {string} mode The mode for which we need history. - */ - function History(inputField, mode) //{{{ - { - if (!(this instanceof arguments.callee)) - return new arguments.callee(inputField, mode); - - this.mode = mode; - this.input = inputField; - this.store = storage["history-" + mode]; - this.reset(); - } - History.prototype = { - /** - * Reset the history index to the first entry. - */ - reset: function () - { - 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 () - { - if (events.feedingKeys) - return; - let str = this.input.value; - if (/^\s*$/.test(str)) - return; - this.store.mutate("filter", function (line) (line.value || line) != str); - this.store.push({ value: str, timestamp: Date.now(), privateData: this.checkPrivate(str) }); - this.store.truncate(options["history"], true); - }, - /** - * @property {function} Returns whether a data item should be - * considered private. - */ - checkPrivate: function (str) - { - // Not really the ideal place for this check. - if (this.mode == "command") - return (commands.get(commands.parseCommand(str)[1]) || {}).privateData; - return false; - }, - /** - * Removes any private data from this history. - */ - sanitize: function (timespan) - { - let range = [0, Number.MAX_VALUE]; - if (liberator.has("sanitizer") && (timespan || options["sanitizetimespan"])) - range = sanitizer.getClearRange(timespan || options["sanitizetimespan"]); - - this.store.mutate("filter", function (item) { - let timestamp = (item.timestamp || Date.now()/1000) * 1000; - return !line.privateData || timestamp < self.range[0] || timestamp > self.range[1]; - }); - }, - /** - * Replace the current input field value. - * - * @param {string} val The new value. - */ - replace: function (val) - { - this.input.value = val; - commandline.triggerCallback("change", currentExtendedMode, 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 (backward, matchCurrent) - { - // always reset the tab completion if we use up/down keys - 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 - // commandline string - while (true) - { - this.index += diff; - if (this.index < 0 || this.index > this.store.length) - { - this.index = util.Math.constrain(this.index, 0, this.store.length); - liberator.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.get(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 - */ - function Completions(input) //{{{ - { - if (!(this instanceof arguments.callee)) - return new arguments.callee(input); - - let self = this; - this.context = CompletionContext(input.editor); - this.context.onUpdate = function () - { - self._reset(); - }; - this.editor = input.editor; - this.selected = null; - this.wildmode = options.get("wildmode"); - this.itemList = completionList; - this.itemList.setItems(this.context); - this.reset(); - } - Completions.prototype = { - UP: {}, - DOWN: {}, - PAGE_UP: {}, - PAGE_DOWN: {}, - RESET: null, - - get completion() - { - let str = commandline.command; - return str.substring(this.prefix.length, str.length - this.suffix.length); - }, - set completion set_completion(completion) - { - this.previewClear(); - - // Change the completion text. - // The second line is a hack to deal with some substring - // preview corner cases. - commandWidget.value = this.prefix + completion + this.suffix; - this.editor.selection.focusNode.textContent = commandWidget.value; - - // Reset the caret to one position after the completion. - this.caret = this.prefix.length + completion.length; - }, - - get caret() this.editor.selection.focusOffset, - set caret(offset) - { - commandWidget.selectionStart = offset; - commandWidget.selectionEnd = offset; - }, - - get start() this.context.allItems.start, - - get items() this.context.allItems.items, - - get substring() this.context.longestAllSubstring, - - get wildtype() this.wildtypes[this.wildIndex] || "", - - get type() ({ - list: this.wildmode.checkHas(this.wildtype, "list"), - longest: this.wildmode.checkHas(this.wildtype, "longest"), - first: this.wildmode.checkHas(this.wildtype, ""), - full: this.wildmode.checkHas(this.wildtype, "full") - }), - - complete: function complete(show, tabPressed) - { - this.context.reset(); - this.context.tabPressed = tabPressed; - commandline.triggerCallback("complete", currentExtendedMode, this.context); - this.context.updateAsync = true; - this.reset(show, tabPressed); - this.wildIndex = 0; - }, - - 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].text; - 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.text; - break; - } - - // Don't show 1-character substrings unless we've just hit backspace - if (substring.length < 2 && (!this.lastSubstring || 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 = commandWidget.value; - if (cmd.substr(cmd.length - str.length) == str) - commandWidget.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(); - this.selected = null; - this.wildIndex = 0; - } - - this.wildtypes = this.wildmode.values; - this.preview(); - }, - - _reset: function _reset() - { - 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); - - 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 = util.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)) - { - function done() !(idx >= n + context.items.length || idx == -2 && !context.items.length); - while (context.incomplete && !done()) - liberator.threadYield(false, true); - - 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].text; - } - - this.itemList.selectItem(idx); - }, - - tabs: [], - - tab: function tab(reverse) - { - autocompleteTimer.flush(); - // Check if we need to run the completer. - if (this.context.waitingForTab || this.wildIndex == -1) - this.complete(true, true); - - this.tabs.push(reverse); - if (this.waiting) - return; - - while (this.tabs.length) - { - reverse = this.tabs.shift(); - switch (this.wildtype.replace(/.*:/, "")) - { - case "": - this.select(0); - break; - case "longest": - if (this.items.length > 1) - { - if (this.substring && this.substring != this.completion) - this.completion = this.substring; - break; - } - // Fallthrough - case "full": - this.select(reverse ? this.UP : this.DOWN); - break; - } - - if (this.type.list) - completionList.show(); - - this.wildIndex = util.Math.constrain(this.wildIndex + 1, 0, this.wildtypes.length - 1); - this.preview(); - - statusTimer.tell(); - } - - if (this.items.length == 0) - liberator.beep(); - } - }; //}}} - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// TIMERS ////////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var statusTimer = new Timer(5, 100, function statusTell() { - if (completions == null) - return; - if (completions.selected == null) - statusline.updateProgress(""); - else - statusline.updateProgress("match " + (completions.selected + 1) + " of " + completions.items.length); - }); - - var autocompleteTimer = new Timer(200, 500, function autocompleteTell(tabPressed) { - if (!events.feedingKeys && completions && options.get("wildoptions").has("auto")) - { - completions.complete(true, false); - completions.itemList.show(); - } - }); - - // This timer just prevents <Tab>s from queueing up when the - // system is under load (and, thus, giving us several minutes of - // the completion list scrolling). Multiple <Tab> presses are - // still processed normally, as the time is flushed on "keyup". - var tabTimer = new Timer(0, 0, function tabTell(event) { - if (completions) - completions.tab(event.shiftKey); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// VARIABLES /////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const completionList = new ItemList("liberator-completions"); - var completions = null; - var history = null; - - var startHints = false; // whether we're waiting to start hints mode - var lastSubstring = ""; - - // the containing box for the promptWidget and commandWidget - const commandlineWidget = document.getElementById("liberator-commandline"); - // the prompt for the current command, for example : or /. Can be blank - const promptWidget = document.getElementById("liberator-commandline-prompt"); - // the command bar which contains the current command - const commandWidget = document.getElementById("liberator-commandline-command"); - - const messageBox = document.getElementById("liberator-message"); - - commandWidget.inputField.QueryInterface(Ci.nsIDOMNSEditableElement); - messageBox.inputField.QueryInterface(Ci.nsIDOMNSEditableElement); - - // the widget used for multiline output - const multilineOutputWidget = document.getElementById("liberator-multiline-output"); - const outputContainer = multilineOutputWidget.parentNode; - - multilineOutputWidget.contentDocument.body.id = "liberator-multiline-output-content"; - - // the widget used for multiline intput - const multilineInputWidget = document.getElementById("liberator-multiline-input"); - - // we need to save the mode which were in before opening the command line - // this is then used if we focus the command line again without the "official" - // way of calling "open" - var currentExtendedMode = null; // the extended mode which we last openend the command line for - // modules.__defineGetter__("currentExtendedMode", function () _currentExtendedMode) - // modules.__defineSetter__("currentExtendedMode", function (val) (liberator.dumpStack("currentExtendedMode = " + (val && modes.getMode(val).name)), - // _currentExtendedMode = val)) - var currentPrompt = null; - var currentCommand = null; - - // save the arguments for the inputMultiline method which are needed in the event handler - var multilineRegexp = null; - var multilineCallback = null; - - /** - * Highlight the messageBox according to <b>group</b>. - */ - function setHighlightGroup(group) - { - messageBox.setAttributeNS(NS.uri, "highlight", group); - } - - /** - * Determines whether the command line should be visible. - * - * @returns {boolean} - */ - function commandShown() modes.main == modes.COMMAND_LINE && - !(modes.extended & (modes.INPUT_MULTILINE | modes.OUTPUT_MULTILINE)); - - /** - * Set the command-line prompt. - * - * @param {string} val - * @param {string} highlightGroup - */ - function setPrompt(val, highlightGroup) - { - promptWidget.value = val; - promptWidget.size = val.length; - promptWidget.collapsed = (val == ""); - promptWidget.setAttributeNS(NS.uri, "highlight", highlightGroup || commandline.HL_NORMAL); - } - - /** - * Set the command-line input value. The caret is reset to the - * end of the line. - * - * @param {string} cmd - */ - function setCommand(cmd) - { - commandWidget.value = cmd; - commandWidget.selectionStart = cmd.length; - commandWidget.selectionEnd = cmd.length; - } - - /** - * 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. - */ - function echoLine(str, highlightGroup, forceSingle) - { - setHighlightGroup(highlightGroup); - messageBox.value = str; - - liberator.triggerObserver("echoLine", str, highlightGroup, forceSingle); - - if (!commandShown()) - commandline.hide(); - - let field = messageBox.inputField; - if (!forceSingle && field.editor.rootElement.scrollWidth > field.scrollWidth) - echoMultiline(<span highlight="Message">{str}</span>, highlightGroup); - } - - /** - * Display a multiline message. - * - * @param {string} str - * @param {string} highlightGroup - */ - // TODO: resize upon a window resize - function echoMultiline(str, highlightGroup) - { - let doc = multilineOutputWidget.contentDocument; - let win = multilineOutputWidget.contentWindow; - - liberator.triggerObserver("echoMultiline", str, highlightGroup); - - // 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 = typeof str != "xml"; - lastMowOutput = <div class="ex-command-output" style="white-space: nowrap" highlight={highlightGroup}>{template.maybeXML(str)}</div>; - let output = util.xmlToDom(lastMowOutput, doc); - XML.ignoreWhitespace = true; - - // FIXME: need to make sure an open MOW is closed when commands - // that don't generate output are executed - if (outputContainer.collapsed) - doc.body.innerHTML = ""; - - doc.body.appendChild(output); - - commandline.updateOutputHeight(true); - - if (options["more"] && win.scrollMaxY > 0) - { - // start the last executed command's output at the top of the screen - let elements = doc.getElementsByClassName("ex-command-output"); - elements[elements.length - 1].scrollIntoView(true); - } - else - win.scrollTo(0, doc.height); - - win.focus(); - - startHints = false; - modes.set(modes.COMMAND_LINE, modes.OUTPUT_MULTILINE); - commandline.updateMorePrompt(); - } - - /** - * Ensure that the multiline input widget is the correct size. - */ - function autosizeMultilineInputWidget() - { - let lines = multilineInputWidget.value.split("\n").length - 1; - - multilineInputWidget.setAttribute("rows", Math.max(lines, 1)); - } - - /** - * eval() 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. - */ - function echoArgumentToString(arg, useColor) - { - if (!arg) - return ""; - - try - { - arg = liberator.eval(arg); - } - catch (e) - { - liberator.echoerr(e); - return null; - } - - if (typeof arg === "object") - arg = util.objectToString(arg, useColor); - else if (typeof arg == "string" && /\n/.test(arg)) - arg = <span highlight="CmdOutput">{arg}</span>; - else - arg = String(arg); - - return arg; - } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// 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 items to display at once", - "number", 20, - { validator: function (value) value >= 1 }); - - options.add(["messages", "msgs"], - "Number of messages to store in the message history", - "number", 100, - { validator: function (value) value >= 0 }); - - options.add(["more"], - "Pause the message list window when more than one screen of listings is displayed", - "boolean", true); - - options.add(["showmode", "smd"], - "Show the current mode in the command line", - "boolean", true); - - options.add(["suggestengines"], - "Engine Alias which has a feature of suggest", - "stringlist", "google", - { - completer: function completer(value) - { - let engines = services.get("browserSearch").getEngines({}) - .filter(function (engine) engine.supportsResponseType("application/x-suggestions+json")); - - return engines.map(function (engine) [engine.alias, engine.description]); - }, - validator: Option.validateCompleter - }); - - options.add(["complete", "cpt"], - "Items which are completed at the :open prompts", - "charlist", typeof(config.defaults["complete"]) == "string" ? config.defaults["complete"] : "slf", - { - completer: function (context) [k for each (k in completion.urlCompleters)], - validator: Option.validateCompleter - }); - - options.add(["wildcase", "wic"], - "Completion case matching mode", - "string", "smart", - { - completer: function () [ - ["smart", "Case is significant when capital letters are typed"], - ["match", "Case is always significant"], - ["ignore", "Case is never significant"] - ], - validator: Option.validateCompleter - }); - - options.add(["wildignore", "wig"], - "List of file patterns to ignore when completing files", - "stringlist", "", - { - validator: function validator(values) - { - // TODO: allow for escaping the "," - try - { - RegExp("^(" + values.join("|") + ")$"); - return true; - } - catch (e) - { - return false; - } - } - }); - - options.add(["wildmode", "wim"], - "Define how command line completion works", - "stringlist", "list:full", - { - completer: function (context) [ - // Why do we need ""? - ["", "Complete only the first match"], - ["full", "Complete the next full match"], - ["longest", "Complete to 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 common string"] - ], - validator: Option.validateCompleter, - checkHas: function (value, val) - { - let [first, second] = value.split(":", 2); - return first == val || second == val; - } - }); - - options.add(["wildoptions", "wop"], - "Change how command line completion is done", - "stringlist", "", - { - completer: function completer(value) - { - return [ - ["", "Default completion that won't show or sort the results"], - ["auto", "Automatically show completions while you are typing"], - ["sort", "Always sort the completion list"] - ]; - }, - validator: Option.validateCompleter - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var myModes = [modes.COMMAND_LINE]; - - // TODO: move "<Esc>", "<C-[>" here from mappings - mappings.add(myModes, - ["<C-c>"], "Focus content", - function () { events.onEscape(); }); - - // 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 regex in - // mappings.js) - mappings.add(myModes, - ["<Space>", '"', "'"], "Expand command line abbreviation", - function () - { - commandline.resetCompletions(); - return editor.expandAbbreviation("c"); - }, - { route: true }); - - mappings.add(myModes, - ["<C-]>", "<C-5>"], "Expand command line abbreviation", - function () { editor.expandAbbreviation("c"); }); - - mappings.add([modes.NORMAL], - ["g<"], "Redisplay the last command output", - function () - { - if (lastMowOutput) - echoMultiline(lastMowOutput, commandline.HL_NORMAL); - else - liberator.beep(); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - [ - { - name: "ec[ho]", - description: "Echo the expression", - action: liberator.echo - }, - { - name: "echoe[rr]", - description: "Echo the expression as an error message", - action: liberator.echoerr - }, - { - name: "echom[sg]", - description: "Echo the expression as an informational message", - action: liberator.echomsg - } - ].forEach(function (command) { - commands.add([command.name], - command.description, - function (args) - { - let str = echoArgumentToString(args.string, true); - if (str != null) - command.action(str); - }, - { - completer: function (context) completion.javascript(context), - literal: 0 - }); - }); - - commands.add(["mes[sages]"], - "Display previously given messages", - function () - { - // TODO: are all messages single line? Some display an aggregation - // of single line messages at least. E.g. :source - if (messageHistory.length == 1) - { - let message = messageHistory.messages[0]; - commandline.echo(message.str, message.highlight, commandline.FORCE_SINGLELINE); - } - else if (messageHistory.length > 1) - { - XML.ignoreWhitespace = false; - let list = template.map(messageHistory.messages, function (message) - <div highlight={message.highlight + " Message"}>{message.str}</div>); - liberator.echo(list, commandline.FORCE_MULTILINE); - } - }, - { argCount: "0" }); - - commands.add(["messc[lear]"], - "Clear the message history", - function () { messageHistory.clear(); }, - { argCount: "0" }); - - commands.add(["sil[ent]"], - "Run a command silently", - function (args) - { - commandline.runSilently(function () liberator.execute(args[0], null, true)); - }, - { - completer: function (context) completion.ex(context), - literal: 0 - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const self = { - - 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 - - get completionContext() completions.context, - - get mode() (modes.extended == modes.EX) ? "cmd" : "search", - - get silent() silent, - set silent(val) - { - silent = val; - this.quiet = this.quiet; - }, - get quiet() quiet, - set quiet(val) - { - quiet = val; - Array.forEach(document.getElementById("liberator-commandline").childNodes, function (node) { - node.style.opacity = quiet || silent ? "0" : ""; - }); - }, - - // @param type can be: - // "submit": when the user pressed enter in the command line - // "change" - // "cancel" - // "complete" - registerCallback: function (type, mode, func) - { - if (!(type in callbacks)) - callbacks[type] = {}; - callbacks[type][mode] = func; - }, - - triggerCallback: function (type, mode, data) - { - if (callbacks[type] && callbacks[type][mode]) - callbacks[type][mode].call(this, data); - }, - - runSilently: function (func, self) - { - let wasSilent = this.silent; - this.silent = true; - try - { - func.call(self); - } - finally - { - this.silent = wasSilent; - } - }, - - get command() - { - try - { - // The long path is because of complications with the - // completion preview. - return commandWidget.inputField.editor.rootElement.firstChild.textContent; - } - catch (e) - { - return commandWidget.value; - } - }, - set command(cmd) commandWidget.value = cmd, - - get message() messageBox.value, - - /** - * Open the command line. The main mode is set to - * COMMAND_LINE, the extended mode to <b>extendedMode</b>. - * Further, callbacks defined for <b>extendedMode</b> are - * triggered as appropriate (see {@link #registerCallback}). - * - * @param {string} prompt - * @param {string} cmd - * @param {number} extendedMode - */ - open: function open(prompt, cmd, extendedMode) - { - // save the current prompts, we need it later if the command widget - // receives focus without calling the this.open() method - currentPrompt = prompt || ""; - currentCommand = cmd || ""; - currentExtendedMode = extendedMode || null; - keepCommand = false; - - setPrompt(currentPrompt); - setCommand(currentCommand); - commandlineWidget.collapsed = false; - - modes.set(modes.COMMAND_LINE, currentExtendedMode); - - commandWidget.focus(); - - history = History(commandWidget.inputField, (modes.extended == modes.EX) ? "command" : "search"); - completions = Completions(commandWidget.inputField); - - // open the completion list automatically if wanted - if (cmd.length) - commandline.triggerCallback("change", currentExtendedMode, cmd); - }, - - /** - * Closes the command line. This is ordinarily triggered automatically - * by a mode change. Will not hide the command line immediately if - * called directly after a successful command, otherwise it will. - */ - close: function close() - { - let mode = currentExtendedMode; - currentExtendedMode = null; - commandline.triggerCallback("cancel", mode); - - if (history) - history.save(); - - this.resetCompletions(); // cancels any asynchronous completion still going on, must be before we set completions = null - completions = null; - history = null; - - statusline.updateProgress(""); // we may have a "match x of y" visible - liberator.focusContent(false); - - multilineInputWidget.collapsed = true; - completionList.hide(); - - if (!keepCommand || this.silent || this.quiet) - { - outputContainer.collapsed = true; - commandline.updateMorePrompt(); - this.hide(); - } - if (!outputContainer.collapsed) - { - modes.set(modes.COMMAND_LINE, modes.OUTPUT_MULTILINE); - commandline.updateMorePrompt(); - } - keepCommand = false; - }, - - /** - * Hides the command line, and shows any status messages that - * are under it. - */ - hide: function hide() - { - commandlineWidget.collapsed = true; - }, - - /** - * 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. - */ - echo: function echo(str, highlightGroup, flags) - { - // liberator.echo uses different order of flags as it omits the highlight group, change commandline.echo argument order? --mst - if (silent) - return; - - highlightGroup = highlightGroup || this.HL_NORMAL; - - if (flags & this.APPEND_TO_MESSAGES) - messageHistory.add({ str: str, highlight: highlightGroup }); - - // The DOM isn't threadsafe. It must only be accessed from the main thread. - liberator.callInMainThread(function () - { - if ((flags & this.DISALLOW_MULTILINE) && !outputContainer.collapsed) - return; - - let single = flags & (this.FORCE_SINGLELINE | this.DISALLOW_MULTILINE); - let action = echoLine; - - // TODO: this is all a bit convoluted - clean up. - // assume that FORCE_MULTILINE output is fully styled - if (!(flags & this.FORCE_MULTILINE) && !single && (!outputContainer.collapsed || messageBox.value == lastEcho)) - { - highlightGroup += " Message"; - action = echoMultiline; - } - - if ((flags & this.FORCE_MULTILINE) || (/\n/.test(str) || typeof str == "xml") && !(flags & this.FORCE_SINGLELINE)) - action = echoMultiline; - - if (single) - lastEcho = null; - else - { - if (messageBox.value == lastEcho) - echoMultiline(<span highlight="Message">{lastEcho}</span>, - messageBox.getAttributeNS(NS.uri, "highlight")); - lastEcho = (action == echoLine) && str; - } - - if (action) - action(str, highlightGroup, single); - }, this); - }, - - /** - * 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 || {}; - - input = { - submit: callback, - change: extra.onChange, - complete: extra.completer, - cancel: extra.onCancel - }; - - modes.push(modes.COMMAND_LINE, modes.PROMPT); - currentExtendedMode = modes.PROMPT; - - setPrompt(prompt, extra.promptHighlight || this.HL_QUESTION); - setCommand(extra.default || ""); - commandlineWidget.collapsed = false; - commandWidget.focus(); - - completions = Completions(commandWidget.inputField); - }, - - /** - * Get a multiline 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 {RegExp} untilRegexp - * @param {function(string)} callbackFunc - */ - // FIXME: Buggy, especially when pasting. Shouldn't use a RegExp. - inputMultiline: function inputMultiline(untilRegexp, callbackFunc) - { - // Kludge. - let cmd = !commandWidget.collapsed && this.command; - modes.push(modes.COMMAND_LINE, modes.INPUT_MULTILINE); - if (cmd != false) - echoLine(cmd, this.HL_NORMAL); - - // save the arguments, they are needed in the event handler onEvent - multilineRegexp = untilRegexp; - multilineCallback = callbackFunc; - - multilineInputWidget.collapsed = false; - multilineInputWidget.value = ""; - autosizeMultilineInputWidget(); - - setTimeout(function () { multilineInputWidget.focus(); }, 10); - }, - - /** - * Handles all command-line events. All key events are passed here when - * COMMAND_LINE mode is active, as well as all input, keyup, focus, and - * blur events sent to the command-line XUL element. - * - * @param {Event} event - * @private - */ - onEvent: function onEvent(event) - { - let command = this.command; - - if (event.type == "blur") - { - // prevent losing focus, there should be a better way, but it just didn't work otherwise - setTimeout(function () { - if (commandShown() && event.originalTarget == commandWidget.inputField) - commandWidget.inputField.focus(); - }, 0); - } - else if (event.type == "focus") - { - if (!commandShown() && event.target == commandWidget.inputField) - { - event.target.blur(); - liberator.beep(); - } - } - else if (event.type == "input") - { - this.resetCompletions(); - commandline.triggerCallback("change", currentExtendedMode, command); - } - else if (event.type == "keypress") - { - let key = events.toString(event); - if (completions) - completions.previewClear(); - if (!currentExtendedMode) - return; - - // user pressed <Enter> to carry out a command - // user pressing <Esc> is handled in the global onEscape - // FIXME: <Esc> should trigger "cancel" event - if (events.isAcceptKey(key)) - { - let mode = currentExtendedMode; // save it here, as modes.pop() resets it - keepCommand = !userContext.hidden_option_no_command_afterimage; - currentExtendedMode = null; // Don't let modes.pop trigger "cancel" - modes.pop(!this.silent); - commandline.triggerCallback("submit", mode, command); - } - // user pressed <Up> or <Down> arrow to cycle history completion - else if (/^(<Up>|<Down>|<S-Up>|<S-Down>|<PageUp>|<PageDown>)$/.test(key)) - { - // prevent tab from moving to the next field - event.preventDefault(); - event.stopPropagation(); - - if (history) - history.select(/Up/.test(key), !/(Page|S-)/.test(key)); - else - liberator.beep(); - } - // user pressed <Tab> to get completions of a command - else if (key == "<Tab>" || key == "<S-Tab>") - { - // prevent tab from moving to the next field - event.preventDefault(); - event.stopPropagation(); - - tabTimer.tell(event); - } - else if (key == "<BS>") - { - // reset the tab completion - //this.resetCompletions(); - - // and blur the command line if there is no text left - if (command.length == 0) - { - commandline.triggerCallback("cancel", currentExtendedMode); - modes.pop(); - } - } - else // any other key - { - //this.resetCompletions(); - } - // allow this event to be handled by the host app - } - else if (event.type == "keyup") - { - let key = events.toString(event); - if (key == "<Tab>" || key == "<S-Tab>") - tabTimer.flush(); - } - }, - - /** - * Multiline input events, they will come straight from - * #liberator-multiline-input in the XUL. - * - * @param {Event} event - */ - onMultilineInputEvent: function onMultilineInputEvent(event) - { - if (event.type == "keypress") - { - let key = events.toString(event); - if (events.isAcceptKey(key)) - { - let text = multilineInputWidget.value.substr(0, multilineInputWidget.selectionStart); - if (text.match(multilineRegexp)) - { - text = text.replace(multilineRegexp, ""); - modes.pop(); - multilineInputWidget.collapsed = true; - multilineCallback.call(this, text); - } - } - else if (events.isCancelKey(key)) - { - modes.pop(); - multilineInputWidget.collapsed = true; - } - } - else if (event.type == "blur") - { - if (modes.extended & modes.INPUT_MULTILINE) - setTimeout(function () { multilineInputWidget.inputField.focus(); }, 0); - } - else if (event.type == "input") - autosizeMultilineInputWidget(); - return true; - }, - - /** - * Handle events when we are in multiline output mode, these come from - * liberator when modes.extended & modes.MULTILINE_OUTPUT and also from - * #liberator-multiline-output in the XUL. - * - * @param {Event} event - */ - // FIXME: if 'more' is set and the MOW is not scrollable we should still - // allow a down motion after an up rather than closing - onMultilineOutputEvent: function onMultilineOutputEvent(event) - { - let win = multilineOutputWidget.contentWindow; - - let showMoreHelpPrompt = false; - let showMorePrompt = false; - let closeWindow = false; - let passEvent = false; - - let key = events.toString(event); - - // TODO: Wouldn't multiple handlers be cleaner? --djk - if (event.type == "click" && event.target instanceof HTMLAnchorElement) - { - function openLink(where) - { - event.preventDefault(); - // FIXME: Why is this needed? --djk - if (event.target.getAttribute("href") == "#") - liberator.open(event.target.textContent, where); - else - liberator.open(event.target.href, where); - } - - switch (key) - { - case "<LeftMouse>": - if (event.originalTarget.getAttributeNS(NS.uri, "highlight") == "URL buffer-list") - { - event.preventDefault(); - tabs.select(parseInt(event.originalTarget.parentNode.parentNode.firstChild.textContent, 10) - 1); - } - else - openLink(liberator.CURRENT_TAB); - break; - case "<MiddleMouse>": - case "<C-LeftMouse>": - case "<C-M-LeftMouse>": - openLink(liberator.NEW_BACKGROUND_TAB); - break; - case "<S-MiddleMouse>": - case "<C-S-LeftMouse>": - case "<C-M-S-LeftMouse>": - openLink(liberator.NEW_TAB); - break; - case "<S-LeftMouse>": - openLink(liberator.NEW_WINDOW); - break; - } - - return; - } - - if (startHints) - { - statusline.updateInputBuffer(""); - startHints = false; - hints.show(key, undefined, win); - return; - } - - function isScrollable() !win.scrollMaxY == 0; - function atEnd() win.scrollY / win.scrollMaxY >= 1; - - switch (key) - { - case "<Esc>": - closeWindow = true; - break; // handled globally in events.js:onEscape() - - case ":": - commandline.open(":", "", modes.EX); - return; - - // down a line - case "j": - case "<Down>": - if (options["more"] && isScrollable()) - win.scrollByLines(1); - else - passEvent = true; - break; - - case "<C-j>": - case "<C-m>": - case "<Return>": - if (options["more"] && isScrollable() && !atEnd()) - win.scrollByLines(1); - else - closeWindow = true; // don't propagate the event for accept keys - break; - - // up a line - case "k": - case "<Up>": - case "<BS>": - if (options["more"] && isScrollable()) - win.scrollByLines(-1); - else if (options["more"] && !isScrollable()) - showMorePrompt = true; - else - passEvent = true; - break; - - // half page down - case "d": - if (options["more"] && isScrollable()) - win.scrollBy(0, win.innerHeight / 2); - else - passEvent = true; - break; - - // TODO: <LeftMouse> on the prompt line should scroll one page - // page down - case "f": - if (options["more"] && isScrollable()) - win.scrollByPages(1); - else - passEvent = true; - break; - - case "<Space>": - case "<PageDown>": - if (options["more"] && isScrollable() && !atEnd()) - win.scrollByPages(1); - else - passEvent = true; - break; - - // half page up - case "u": - // if (more and scrollable) - if (options["more"] && isScrollable()) - win.scrollBy(0, -(win.innerHeight / 2)); - else - passEvent = true; - break; - - // page up - case "b": - if (options["more"] && isScrollable()) - win.scrollByPages(-1); - else if (options["more"] && !isScrollable()) - showMorePrompt = true; - else - passEvent = true; - break; - - case "<PageUp>": - if (options["more"] && isScrollable()) - win.scrollByPages(-1); - else - passEvent = true; - break; - - // top of page - case "g": - if (options["more"] && isScrollable()) - win.scrollTo(0, 0); - else if (options["more"] && !isScrollable()) - showMorePrompt = true; - else - passEvent = true; - break; - - // bottom of page - case "G": - if (options["more"] && isScrollable() && !atEnd()) - win.scrollTo(0, win.scrollMaxY); - else - passEvent = true; - break; - - // copy text to clipboard - case "<C-y>": - util.copyToClipboard(win.getSelection()); - break; - - // close the window - case "q": - closeWindow = true; - break; - - case ";": - statusline.updateInputBuffer(";"); - startHints = true; - break; - - // unmapped key - default: - if (!options["more"] || !isScrollable() || atEnd() || events.isCancelKey(key)) - passEvent = true; - else - showMoreHelpPrompt = true; - } - - if (passEvent || closeWindow) - { - modes.pop(); - - if (passEvent) - events.onKeyPress(event); - } - else - commandline.updateMorePrompt(showMorePrompt, showMoreHelpPrompt); - }, - - getSpaceNeeded: function getSpaceNeeded() - { - let rect = commandlineWidget.getBoundingClientRect(); - let offset = rect.bottom - window.innerHeight; - return Math.max(0, offset); - }, - - /** - * Update or remove the multiline 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 (outputContainer.collapsed) - return echoLine("", this.HL_NORMAL); - - let win = multilineOutputWidget.contentWindow; - function isScrollable() !win.scrollMaxY == 0; - function atEnd() win.scrollY / win.scrollMaxY >= 1; - - if (showHelp) - echoLine("-- More -- SPACE/d/j: screen/page/line down, b/u/k: up, q: quit", this.HL_MOREMSG, true); - else if (force || (options["more"] && isScrollable() && !atEnd())) - echoLine("-- More --", this.HL_MOREMSG, true); - else - echoLine("Press ENTER or type command to continue", this.HL_QUESTION, true); - }, - - /** - * Changes the height of the multilineOutputWidget to fit in the - * available space. - * - * @param {boolean} open If true, the widget will be opened if it's not - * already so. - */ - updateOutputHeight: function updateOutputHeight(open) - { - if (!open && outputContainer.collapsed) - return; - - let doc = multilineOutputWidget.contentDocument; - - availableHeight = config.outputHeight; - if (!outputContainer.collapsed) - availableHeight += parseFloat(outputContainer.height); - doc.body.style.minWidth = commandlineWidget.scrollWidth + "px"; - outputContainer.height = Math.min(doc.height, availableHeight) + "px"; - doc.body.style.minWidth = ""; - outputContainer.collapsed = false; - }, - - resetCompletions: function resetCompletions() - { - if (completions) - { - completions.context.cancelAll(); - completions.wildIndex = -1; - completions.previewClear(); - } - if (history) - history.reset(); - } - }; - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// CALLBACKS /////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var input = {}; - - self.registerCallback("submit", modes.EX, function (command) { - commands.repeat = command; - liberator.execute(command); - }); - self.registerCallback("complete", modes.EX, function (context) { - context.fork("ex", 0, completion, "ex"); - }); - self.registerCallback("change", modes.EX, function (command) { - autocompleteTimer.tell(false); - }); - - self.registerCallback("cancel", modes.PROMPT, cancelPrompt); - self.registerCallback("submit", modes.PROMPT, closePrompt); - self.registerCallback("change", modes.PROMPT, function (str) { - if (input.complete) - autocompleteTimer.tell(false); - if (input.change) - return input.change.call(commandline, str); - }); - self.registerCallback("complete", modes.PROMPT, function (context) { - if (input.complete) - context.fork("input", 0, commandline, input.complete); - }); - - function cancelPrompt(value) - { - let callback = input.cancel; - input = {}; - if (callback) - callback.call(commandline, value != null ? value : commandline.command); - } - - function closePrompt(value) - { - let callback = input.submit; - input = {}; - if (callback) - callback.call(commandline, value != null ? value : commandline.command); - } - //}}} - - return self; - -} //}}} - -/** - * 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. - */ -function ItemList(id) //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - const WAITING_MESSAGE = "Generating results..."; - - var completionElements = []; - - var iframe = document.getElementById(id); - if (!iframe) - { - liberator.log("No iframe with id: " + id + " found, strange things may happen!"); // "The truth is out there..." -- djk - return; // XXX - } - - function dom(xml, map) util.xmlToDom(xml, doc, map); - - var doc = iframe.contentDocument; - var container = iframe.parentNode; - - doc.body.id = id + "-content"; - doc.body.appendChild(doc.createTextNode("")); - doc.body.style.borderTop = "1px solid black"; // FIXME: For cases where completions/MOW are shown at once, or ls=0. Should use :highlight. - - let gradient = template.gradient("GradientLeft", "GradientRight"); - - var items = null; - var startIndex = -1; // The index of the first displayed item - var endIndex = -1; // The index one *after* the last displayed item - var selIndex = -1; // The index of the currently selected element - var div = null; - var divNodes = {}; - var minHeight = 0; - - function autoSize() - { - if (container.collapsed) - div.style.minWidth = document.getElementById("liberator-commandline").scrollWidth + "px"; - minHeight = Math.max(minHeight, divNodes.completions.getBoundingClientRect().bottom); - container.height = minHeight; - if (container.collapsed) - div.style.minWidth = ""; - // FIXME: Belongs elsewhere. - commandline.updateOutputHeight(false); - setTimeout(function () { container.height -= commandline.getSpaceNeeded() }, 0); - } - - function getCompletion(index) completionElements.snapshotItem(index - startIndex); - - function init() - { - div = 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) - <span highlight="CompItem"> - <li highlight="NonText">~</li> - </span>) - } - </div> - </div>, divNodes); - doc.body.replaceChild(div, doc.body.firstChild); - //div.scrollIntoView(true); - - items.contextList.forEach(function init_eachContext(context) { - delete context.cache.nodes; - if (!context.items.length && !context.message && !context.incomplete) - return; - context.cache.nodes = []; - dom(<div key="root" highlight="CompGroup"> - <div highlight="Completions"> - { context.createRow(context.title || [], "CompTitle") } - </div> - { gradient } - <div key="message" highlight="CompMsg"/> - <div key="up" highlight="CompLess"/> - <div key="items" highlight="Completions"/> - <div key="waiting" highlight="CompMsg">{WAITING_MESSAGE}</div> - <div key="down" highlight="CompMore"/> - </div>, context.cache.nodes); - divNodes.completions.appendChild(context.cache.nodes.root); - }); - - setTimeout(function () { autoSize(); }, 0); - } - - /** - * 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"]. - */ - function fill(offset) - { - XML.ignoreWhiteSpace = false; - let diff = offset - startIndex; - if (items == null || offset == null || diff == 0 || offset < 0) - return false; - - startIndex = offset; - endIndex = Math.min(startIndex + options["maxitems"], items.allItems.items.length); - - let haveCompletions = false; - let off = 0; - let end = startIndex + options["maxitems"]; - function getRows(context) - { - function fix(n) util.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]; - } - - 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 = 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, doc))) - nodes[i] = row; - for (let [i, row] in util.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"; - }); - - divNodes.noCompletions.style.display = haveCompletions ? "none" : "block"; - - completionElements = util.evaluateXPath("//xhtml:div[@liberator:highlight='CompItem']", doc); - - return true; - } - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - clear: function clear() { this.setItems(); doc.body.innerHTML = ""; }, - hide: function hide() { container.collapsed = true; }, - show: function show() { container.collapsed = false; }, - visible: function visible() !container.collapsed, - - reset: function () - { - startIndex = endIndex = selIndex = -1; - div = null; - this.selectItem(-1); - }, - - // if @param selectedItem is given, show the list and select that item - setItems: function setItems(newItems, selectedItem) - { - if (container.collapsed) - minHeight = 0; - startIndex = endIndex = selIndex = -1; - items = newItems; - this.reset(); - if (typeof selectedItem == "number") - { - this.selectItem(selectedItem); - this.show(); - } - }, - - // select index, refill list if necessary - selectItem: function selectItem(index) - { - //if (container.collapsed) // FIXME - // return; - - //let now = Date.now(); - - if (div == null) - init(); - - let sel = selIndex; - let len = items.allItems.items.length; - let newOffset = startIndex; - let maxItems = options["maxitems"]; - let contextLines = Math.min(3, parseInt((maxItems - 1) / 2)); - - if (index == -1 || index == null || index == len) // wrapped around - { - if (selIndex < 0) - newOffset = 0; - selIndex = -1; - index = -1; - } - else - { - if (index <= startIndex + contextLines) - newOffset = index - contextLines; - if (index >= endIndex - contextLines) - newOffset = index + contextLines - maxItems + 1; - - newOffset = Math.min(newOffset, len - maxItems); - newOffset = Math.max(newOffset, 0); - - selIndex = index; - } - - if (sel > -1) - getCompletion(sel).removeAttribute("selected"); - fill(newOffset); - if (index >= 0) - { - getCompletion(index).setAttribute("selected", "true"); - //getCompletion(index).scrollIntoView(false); - } - - //if (index == 0) - // this.start = now; - //if (index == Math.min(len - 1, 100)) - // liberator.dump({ time: Date.now() - this.start }); - }, - - onEvent: function onEvent(event) false - }; - //}}} -} //}}} - -function StatusLine() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - var statusBar = document.getElementById("status-bar"); - statusBar.collapsed = true; // it is later restored unless the user sets laststatus=0 - - // our status bar fields - var statuslineWidget = document.getElementById("liberator-statusline"); - var urlWidget = document.getElementById("liberator-statusline-field-url"); - var inputBufferWidget = document.getElementById("liberator-statusline-field-inputbuffer"); - var progressWidget = document.getElementById("liberator-statusline-field-progress"); - var tabCountWidget = document.getElementById("liberator-statusline-field-tabcount"); - var bufferPositionWidget = document.getElementById("liberator-statusline-field-bufferposition"); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - options.add(["laststatus", "ls"], - "Show the status line", - "number", 2, - { - setter: function setter(value) - { - if (value == 0) - document.getElementById("status-bar").collapsed = true; - else if (value == 1) - liberator.echoerr("show status line only with > 1 window not implemented yet"); - else - document.getElementById("status-bar").collapsed = false; - - return value; - }, - completer: function completer(context) [ - ["0", "Never display status line"], - ["1", "Display status line only if there are multiple windows"], - ["2", "Always display status line"] - ], - validator: Option.validateCompleter - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - - /** - * 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 - */ - setClass: function setClass(type) - { - const highlightGroup = { - extended: "StatusLineExtended", - secure: "StatusLineSecure", - broken: "StatusLineBroken", - insecure: "StatusLine" - }; - - statusBar.setAttributeNS(NS.uri, "highlight", highlightGroup[type]); - }, - - // update all fields of the statusline - update: function update() - { - this.updateUrl(); - this.updateInputBuffer(); - this.updateProgress(); - this.updateTabCount(); - this.updateBufferPosition(); - }, - - /** - * 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. - * @default buffer.URL - */ - updateUrl: function updateUrl(url) - { - // ripped from Firefox; modified - function losslessDecodeURI(url) { - // 1. decodeURI decodes %25 to %, which creates unintended - // encoding sequences. - url = url.split("%25").map(decodeURI).join("%25"); - // 2. Re-encode whitespace so that it doesn't get eaten away - // by the location bar (bug 410726). - url = url.replace(/[\r\n\t]/g, encodeURIComponent); - - // Encode invisible characters (soft hyphen, zero-width space, BOM, - // line and paragraph separator, word joiner, invisible times, - // invisible separator, object replacement character) (bug 452979) - url = url.replace(/[\v\x0c\x1c\x1d\x1e\x1f\u00ad\u200b\ufeff\u2028\u2029\u2060\u2062\u2063\ufffc]/g, - encodeURIComponent); - - // Encode bidirectional formatting characters. - // (RFC 3987 sections 3.2 and 4.1 paragraph 6) - url = url.replace(/[\u200e\u200f\u202a\u202b\u202c\u202d\u202e]/g, - encodeURIComponent); - return url; - }; - - if (url == null) - // TODO: this probably needs a more general solution. - url = losslessDecodeURI(buffer.URL); - - // make it even more Vim-like - if (url == "about:blank") - { - if (!buffer.title) - url = "[No Name]"; - } - else - { - url = url.replace(RegExp("^liberator://help/(\\S+)#(.*)"), function (m, n1, n2) n1 + " " + decodeURIComponent(n2) + " [Help]") - .replace(RegExp("^liberator://help/(\\S+)"), "$1 [Help]"); - } - - // when session information is available, add [+] when we can go - // backwards, [-] when we can go forwards - let modified = ""; - if (window.getWebNavigation) - { - let sh = window.getWebNavigation().sessionHistory; - if (sh && sh.index > 0) - modified += "+"; - if (sh && sh.index < sh.count -1) - modified += "-"; - } - if (liberator.has("bookmarks")) - { - if (bookmarks.isBookmarked(buffer.URL)) - modified += "\u2764"; // a heart symbol: ❤ - //modified += "\u2665"; // a heart symbol: ♥ - } - - if (modified) - url += " [" + modified + "]"; - - urlWidget.value = url; - }, - - /** - * 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 - */ - updateInputBuffer: function updateInputBuffer(buffer) - { - if (!buffer || typeof buffer != "string") - buffer = ""; - - inputBufferWidget.value = buffer; - }, - - /** - * 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. - */ - updateProgress: function updateProgress(progress) - { - if (!progress) - progress = ""; - - if (typeof progress == "string") - progressWidget.value = progress; - else if (typeof progress == "number") - { - let progressStr = ""; - if (progress <= 0) - progressStr = "[ Loading... ]"; - else if (progress < 1) - { - progress = Math.floor(progress * 20); - progressStr = "[" - + "====================".substr(0, progress) - + ">" - + " ".substr(0, 19 - progress) - + "]"; - } - progressWidget.value = progressStr; - } - }, - - /** - * Display the correct tabcount (e.g., [1/5]) on the status bar. - * - * @param {bool} 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. - */ - updateTabCount: function updateTabCount(delayed) - { - if (liberator.has("tabs")) - { - if (delayed) - return void setTimeout(function () statusline.updateTabCount(false), 0); - - // update the ordinal which is used for numbered tabs - if (options.get("guioptions").has("n", "N")) - for (let [i, tab] in util.Array.iteritems(getBrowser().mTabs)) - tab.setAttribute("ordinal", i + 1); - - tabCountWidget.value = "[" + (tabs.index() + 1) + "/" + tabs.count + "]"; - } - }, - - /** - * 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 || typeof percent != "number") - { - let win = document.commandDispatcher.focusedWindow; - if (!win) - return; - percent = win.scrollMaxY == 0 ? -1 : win.scrollY / win.scrollMaxY; - } - - let bufferPositionStr = ""; - percent = Math.round(percent * 100); - if (percent < 0) - bufferPositionStr = "All"; - else if (percent == 0) - bufferPositionStr = "Top"; - else if (percent < 10) - bufferPositionStr = " " + percent + "%"; - else if (percent >= 100) - bufferPositionStr = "Bot"; - else - bufferPositionStr = percent + "%"; - - bufferPositionWidget.value = bufferPositionStr; - } - - }; - //}}} -} //}}} - -// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/util.js b/common/content/util.js index baa2c501..b3c26ff8 100644 --- a/common/content/util.js +++ b/common/content/util.js @@ -10,7 +10,11 @@ const XUL = Namespace("xul", "http://www.mozilla.org/keymaster/gatekeeper/there. const NS = Namespace("liberator", "http://vimperator.org/namespaces/liberator"); default xml namespace = XHTML; -const util = { //{{{ +const Util = Module("util", { + init: function () { + this.Array = Util.Array; + }, + /** * Returns true if its argument is an Array object, regardless * of which context it comes from. @@ -25,8 +29,7 @@ const util = { //{{{ * @param {Object} obj * @returns {Object} */ - cloneObject: function cloneObject(obj) - { + cloneObject: function cloneObject(obj) { if (obj instanceof Array) return obj.slice(); let newObj = {}; @@ -43,8 +46,7 @@ const util = { //{{{ * @param {number} length The length of the returned string. * @returns {string} */ - clip: function clip(str, length) - { + clip: function clip(str, length) { return str.length <= length ? str : str.substr(0, length - 3) + "..."; }, @@ -64,8 +66,7 @@ const util = { //{{{ * @param {Node} node * @returns {Object} */ - computedStyle: function computedStyle(node) - { + computedStyle: function computedStyle(node) { while (node instanceof Text && node.parentNode) node = node.parentNode; return node.ownerDocument.defaultView.getComputedStyle(node, null); @@ -78,8 +79,7 @@ const util = { //{{{ * @param {string} str * @param {boolean} verbose */ - copyToClipboard: function copyToClipboard(str, verbose) - { + copyToClipboard: function copyToClipboard(str, verbose) { const clipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper); clipboardHelper.copyString(str); @@ -94,8 +94,7 @@ const util = { //{{{ * @returns {Object} */ // FIXME: newURI needed too? - createURI: function createURI(str) - { + createURI: function createURI(str) { const fixup = Cc["@mozilla.org/docshell/urifixup;1"].getService(Ci.nsIURIFixup); return fixup.createFixupURI(str, fixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP); }, @@ -107,8 +106,7 @@ const util = { //{{{ * @param {string} str * @returns {string} */ - escapeHTML: function escapeHTML(str) - { + escapeHTML: function escapeHTML(str) { // XXX: the following code is _much_ slower than a simple .replace() // :history display went down from 2 to 1 second after changing // @@ -124,32 +122,28 @@ const util = { //{{{ * @param {string} str * @returns {string} */ - escapeRegex: function escapeRegex(str) - { + escapeRegex: function escapeRegex(str) { return str.replace(/([\\{}()[\].?*+])/g, "\\$1"); }, /** * Escapes quotes, newline and tab characters in <b>str</b>. The returned * string is delimited by <b>delimiter</b> or " if <b>delimiter</b> is not - * specified. + * specified. {@see String#quote}. * * @param {string} str * @param {string} delimiter * @returns {string} */ - escapeString: function escapeString(str, delimiter) - { + escapeString: function escapeString(str, delimiter) { if (delimiter == undefined) delimiter = '"'; return delimiter + str.replace(/([\\'"])/g, "\\$1").replace("\n", "\\n", "g").replace("\t", "\\t", "g") + delimiter; }, - extend: function extend(dest) - { + extend: function extend(dest) { Array.slice(arguments, 1).filter(util.identity).forEach(function (src) { - for (let [k, v] in Iterator(src)) - { + for (let [k, v] in Iterator(src)) { let get = src.__lookupGetter__(k), set = src.__lookupSetter__(k); if (!get && !set) @@ -171,8 +165,7 @@ const util = { //{{{ * @param nodes {Array(string)} * @returns {string} */ - makeXPath: function makeXPath(nodes) - { + makeXPath: function makeXPath(nodes) { return util.Array(nodes).map(function (node) [node, "xhtml:" + node]).flatten() .map(function (node) "//" + node).join(" | "); }, @@ -187,8 +180,7 @@ const util = { //{{{ * passed as the first argument, <b>key</b> as the * second. */ - memoize: function memoize(obj, key, getter) - { + memoize: function memoize(obj, key, getter) { obj.__defineGetter__(key, function () { delete obj[key]; obj[key] = getter(obj, key); @@ -209,14 +201,12 @@ const util = { //{{{ * @param {string} str * @param {RegExp} marker */ - splitLiteral: function splitLiteral(str, marker) - { + splitLiteral: function splitLiteral(str, marker) { let results = []; let resep = RegExp(/^(([^\\'"]|\\.|'([^\\']|\\.)*'|"([^\\"]|\\.)*")*?)/.source + marker.source); let cont = true; - while (cont) - { + while (cont) { cont = false; str = str.replace(resep, function (match, before) { results.push(before); @@ -238,17 +228,14 @@ const util = { //{{{ * @param {boolean} humanReadable Use byte multiples. * @returns {string} */ - formatBytes: function formatBytes(bytes, decimalPlaces, humanReadable) - { + 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) - { + if (humanReadable) { + while (tmpNum >= 1024) { tmpNum /= 1024; if (++unitIndex > (unitVal.length - 1)) break; @@ -273,53 +260,7 @@ const util = { //{{{ return strNum[0] + " " + unitVal[unitIndex]; }, - /** - * Generates an Asciidoc help entry. - * - * @param {Command|Map|Option} obj A liberator <b>Command</b>, - * <b>Map</b> or <b>Option</b> object - * @param {XMLList} extraHelp Extra help text beyond the description. - * @returns {string} - */ - generateHelp: function generateHelp(obj, extraHelp) - { - let spec = util.identity; - let tag = util.identity; - if (obj instanceof Command) - tag = spec = function (cmd) <>:{cmd}</>; - else if (obj instanceof Map && obj.count) - spec = function (map) <><oa xmlns="">count</oa>{map}</>; - else if (obj instanceof Option) - { - spec = function (opt) <o xmlns="">{opt}</o>; - tag = function (opt) <>'{opt}'</>; - } - - // E4X has its warts. - let br = <> - </>; - - default xml namespace = ""; - XML.prettyPrinting = false; - XML.ignoreWhitespace = false; - - return <></> + - <item> - <tags>{template.map(obj.names, tag, " ")}</tags> - <spec>{spec((obj.specs || obj.names)[0])}</spec>{ - !obj.type ? "" : <> - <type>{obj.type}</type> - <default>{obj.defaultValue}</default></>} - <description>{ - obj.description ? br+<p>{obj.description.replace(/\.?$/, ".")}</p> : "" }{ - extraHelp ? br+extraHelp : "" }{ - !(extraHelp || obj.description) ? br+<p>Sorry, no help available.</p> : "" } - </description> - </item>.toXMLString(); - }, - - exportHelp: function (path) - { + exportHelp: function (path) { const FILE = io.File(path); const PATH = FILE.leafName.replace(/\..*/, "") + "/"; const TIME = Date.now(); @@ -337,8 +278,7 @@ const util = { //{{{ .split(" ").map(Array.concat)); let chrome = {}; - for (let [file,] in Iterator(services.get("liberator:").FILE_MAP)) - { + for (let [file,] in Iterator(services.get("liberator:").FILE_MAP)) { liberator.open("liberator://help/" + file); events.waitForPageLoad(); let data = [ @@ -346,10 +286,8 @@ const util = { //{{{ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"\n', ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n' ]; - function fix(node) - { - switch(node.nodeType) - { + function fix(node) { + switch(node.nodeType) { case Node.ELEMENT_NODE: if (node instanceof HTMLScriptElement) return; @@ -358,22 +296,18 @@ const util = { //{{{ if (node instanceof HTMLHtmlElement) data.push(' xmlns="' + XHTML.uri + '"'); - for (let { name: name, value: value } in util.Array.itervalues(node.attributes)) - { - if (name == "liberator:highlight") - { + for (let { name: name, value: value } in util.Array.itervalues(node.attributes)) { + if (name == "liberator:highlight") { name = "class"; value = "hl-" + value; } - if (name == "href") - { + if (name == "href") { if (value.indexOf("liberator://help-tag/") == 0) value = services.get("io").newChannel(value, null, null).originalURI.path.substr(1); if (!/[#\/]/.test(value)) value += ".xhtml"; } - if (name == "src" && value.indexOf(":") > 0) - { + if (name == "src" && value.indexOf(":") > 0) { chrome[value] = value.replace(/.*\//, "");; value = value.replace(/.*\//, ""); } @@ -385,8 +319,7 @@ const util = { //{{{ } if (node.localName in empty) data.push(" />"); - else - { + else { data.push(">"); if (node instanceof HTMLHeadElement) data.push(<link rel="stylesheet" type="text/css" href="help.css"/>.toXMLString()); @@ -429,14 +362,11 @@ const util = { //{{{ * @param {function(XMLHttpRequest)} callback * @returns {XMLHttpRequest} */ - httpGet: function httpGet(url, callback) - { - try - { + httpGet: function httpGet(url, callback) { + try { let xmlhttp = new XMLHttpRequest(); xmlhttp.mozBackgroundRequest = true; - if (callback) - { + if (callback) { xmlhttp.onreadystatechange = function () { if (xmlhttp.readyState == 4) callback(xmlhttp); @@ -446,8 +376,7 @@ const util = { //{{{ xmlhttp.send(null); return xmlhttp; } - catch (e) - { + catch (e) { liberator.log("Error opening " + url + ": " + e, 1); } }, @@ -465,8 +394,7 @@ const util = { //{{{ * @param {boolean} asIterator Whether to return the results as an * XPath iterator. */ - evaluateXPath: function (expression, doc, elem, asIterator) - { + evaluateXPath: function (expression, doc, elem, asIterator) { if (!doc) doc = window.content.document; if (!elem) @@ -475,11 +403,11 @@ const util = { //{{{ expression = util.makeXPath(expression); let result = doc.evaluate(expression, elem, - function lookupNamespaceURI(prefix) - { + function lookupNamespaceURI(prefix) { return { xhtml: "http://www.w3.org/1999/xhtml", xhtml2: "http://www.w3.org/2002/06/xhtml2", + liberator: NS.uri, liberator: NS.uri }[prefix] || null; }, @@ -526,8 +454,7 @@ const util = { //{{{ * @param {function} func * @returns {Array} */ - map: function map(obj, func) - { + map: function map(obj, func) { let ary = []; for (let i in Iterator(obj)) ary.push(func(i)); @@ -558,8 +485,7 @@ const util = { //{{{ * @returns {nsIURI} */ // FIXME: createURI needed too? - newURI: function (uri) - { + newURI: function (uri) { return services.get("io").newURI(uri, null, null); }, @@ -571,10 +497,9 @@ const util = { //{{{ * @param {boolean} color Whether the output should be colored. * @returns {string} */ - objectToString: function objectToString(object, color) - { + objectToString: function objectToString(object, color) { // Use E4X literals so html is automatically quoted - // only when it's asked for. Noone wants to see < + // only when it's asked for. No one wants to see < // on their console or :map :foo in their buffer // when they expect :map <C-f> :foo. XML.prettyPrinting = false; @@ -591,20 +516,17 @@ const util = { //{{{ [XHTML, 'html'], [XUL, 'xul'] ]); - if (object instanceof Element) - { + if (object instanceof Element) { let elem = object; if (elem.nodeType == elem.TEXT_NODE) return elem.data; - function namespaced(node) - { + function namespaced(node) { var ns = NAMESPACES[node.namespaceURI]; if (ns) return ns + ":" + node.localName; return node.localName.toLowerCase(); } - try - { + try { let tag = "<" + [namespaced(elem)].concat( [namespaced(a) + "=" + template.highlight(a.value, true) for ([i, a] in util.Array.iteritems(elem.attributes))]).join(" "); @@ -615,42 +537,34 @@ const util = { //{{{ tag += '>...</' + namespaced(elem) + '>'; return tag; } - catch (e) - { + catch (e) { return {}.toString.call(elem); } } - try - { // for window.JSON + try { // for window.JSON var obj = String(object); } - catch (e) - { + catch (e) { obj = "[Object]"; } 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>::<br/>
</>; let keys = []; - try // window.content often does not want to be queried with "var i in object" - { + try { // window.content often does not want to be queried with "var i in object" let hasValue = !("__iterator__" in object); - if (modules.isPrototypeOf(object)) - { + if (modules.isPrototypeOf(object)) { object = Iterator(object); hasValue = false; } - for (let i in object) - { + for (let i in object) { let value = <![CDATA[<no value>]]>; - try - { + try { value = object[i]; } catch (e) {} - if (!hasValue) - { + if (!hasValue) { if (i instanceof Array && i.length == 2) [i, value] = i; else @@ -668,8 +582,7 @@ const util = { //{{{ } catch (e) {} - function compare(a, b) - { + function compare(a, b) { if (!isNaN(a[0]) && !isNaN(b[0])) return a[0] - b[0]; return String.localeCompare(a[0], b[0]); @@ -688,17 +601,14 @@ const util = { //{{{ * negative. @default 1 * @returns {Iterator(Object)} */ - range: function range(start, end, step) - { + range: function range(start, end, step) { if (!step) step = 1; - if (step > 0) - { + if (step > 0) { for (; start < end; start += step) yield start; } - else - { + else { while (start > end) yield start += step; } @@ -713,13 +623,10 @@ const util = { //{{{ * @param {number} time The time in milliseconds between thread yields. * @returns {Iterator(Object)} */ - interruptibleRange: function interruptibleRange(start, end, time) - { + interruptibleRange: function interruptibleRange(start, end, time) { let endTime = Date.now() + time; - while (start < end) - { - if (Date.now() > endTime) - { + while (start < end) { + if (Date.now() > endTime) { liberator.threadYield(true, true); endTime = Date.now() + time; } @@ -735,12 +642,10 @@ const util = { //{{{ * * @returns {string} */ - readFromClipboard: function readFromClipboard() - { + readFromClipboard: function readFromClipboard() { let str; - try - { + try { const clipboard = Cc["@mozilla.org/widget/clipboard;1"].getService(Ci.nsIClipboard); const transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable); @@ -756,8 +661,7 @@ const util = { //{{{ transferable.getTransferData("text/unicode", data, dataLen); - if (data) - { + if (data) { data = data.value.QueryInterface(Ci.nsISupportsString); str = data.data.substring(0, dataLen.value / 2); } @@ -776,8 +680,7 @@ const util = { //{{{ * @param {string} str * @returns {string[]} */ - stringToURLArray: function stringToURLArray(str) - { + stringToURLArray: function stringToURLArray(str) { let urls; if (options["urlseparator"]) @@ -786,8 +689,7 @@ const util = { //{{{ urls = [str]; return urls.map(function (url) { - try - { + try { // Try to find a matching file. let file = io.File(url); if (file.exists() && file.isReadable()) @@ -834,197 +736,129 @@ const util = { //{{{ * stored here, keyed to the value thereof. * @returns {Node} */ - xmlToDom: function xmlToDom(node, doc, nodes) - { + xmlToDom: function xmlToDom(node, doc, nodes) { XML.prettyPrinting = false; - if (node.length() != 1) - { + if (node.length() != 1) { let domnode = doc.createDocumentFragment(); for each (let child in node) domnode.appendChild(arguments.callee(child, doc, nodes)); return domnode; } - switch (node.nodeKind()) - { - case "text": - return doc.createTextNode(node); - case "element": - let domnode = doc.createElementNS(node.namespace(), node.localName()); - for each (let attr in node.@*) - domnode.setAttributeNS(attr.name() == "highlight" ? NS.uri : attr.namespace(), attr.name(), String(attr)); - for each (let child in node.*) - domnode.appendChild(arguments.callee(child, doc, nodes)); - if (nodes && node.@key) - nodes[node.@key] = domnode; - return domnode; + switch (node.nodeKind()) { + case "text": + return doc.createTextNode(node); + case "element": + let domnode = doc.createElementNS(node.namespace(), node.localName()); + for each (let attr in node.@*) + domnode.setAttributeNS(attr.name() == "highlight" ? NS.uri : attr.namespace(), attr.name(), String(attr)); + for each (let child in node.*) + domnode.appendChild(arguments.callee(child, doc, nodes)); + if (nodes && node.@key) + nodes[node.@key] = domnode; + return domnode; } } -}; //}}} - -// TODO: Why don't we just push all util.BuiltinType up into modules? --djk -/** - * Array utility methods. - */ -util.Array = function Array_(ary) { - var obj = { - __proto__: ary, - __iterator__: function () this.iteritems(), - __noSuchMethod__: function (meth, args) - { - let res = (util.Array[meth] || Array[meth]).apply(null, [this.__proto__].concat(args)); - if (util.Array.isinstance(res)) - return util.Array(res); - return res; +}, { + // TODO: Why don't we just push all util.BuiltinType up into modules? --djk + /** + * Array utility methods. + */ + Array: Class("Array", { + init: function (ary) { + return { + __proto__: ary, + __iterator__: function () this.iteritems(), + __noSuchMethod__: function (meth, args) { + let res = (util.Array[meth] || Array[meth]).apply(null, [this.__proto__].concat(args)); + if (util.Array.isinstance(res)) + return util.Array(res); + return res; + }, + concat: function () [].concat.apply(this.__proto__, arguments), + map: function () this.__noSuchMethod__("map", Array.slice(arguments)) + }; + }, + }, { + isinstance: function isinstance(obj) { + return Object.prototype.toString.call(obj) == "[object Array]"; + }, + /** + * 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]) { obj[k] = v; }); + return obj; }, - concat: function () [].concat.apply(this.__proto__, arguments), - map: function () this.__noSuchMethod__("map", Array.slice(arguments)) - }; - return obj; -} -util.Array.isinstance = function isinstance(obj) { - return Object.prototype.toString.call(obj) == "[object Array]"; -}; -/** - * 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 - */ -util.Array.toObject = function toObject(assoc) -{ - let obj = {}; - assoc.forEach(function ([k, v]) { obj[k] = v; }); - return obj; -}; - -/** - * 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} - */ -util.Array.flatten = function flatten(ary) Array.concat.apply([], ary), - -/** - * Returns an Iterator for an array's values. - * - * @param {Array} ary - * @returns {Iterator(Object)} - */ -util.Array.itervalues = function itervalues(ary) -{ - let length = ary.length; - for (let i = 0; i < length; i++) - yield ary[i]; -}; - -/** - * Returns an Iterator for an array's indices and values. - * - * @param {Array} ary - * @returns {Iterator([{number}, {Object}])} - */ -util.Array.iteritems = function iteritems(ary) -{ - let length = ary.length; - for (let i = 0; i < length; i++) - yield [i, ary[i]]; -}; - -/** - * Filters out all duplicates from an array. If - * <b>unsorted</b> is false, the array is sorted before - * duplicates are removed. - * - * @param {Array} ary - * @param {boolean} unsorted - * @returns {Array} - */ -util.Array.uniq = function uniq(ary, unsorted) -{ - let ret = []; - if (unsorted) - { - for (let [, item] in Iterator(ary)) - if (ret.indexOf(item) == -1) - ret.push(item); - } - else - { - for (let [, item] in Iterator(ary.sort())) - { - if (item != last || !ret.length) - ret.push(item); - var last = item; - } - } - return ret; -}; - -function Struct() -{ - let self = this instanceof Struct ? this : new Struct(); - if (!arguments.length) - return self; - - let args = Array.slice(arguments); - self.__defineGetter__("length", function () args.length); - self.__defineGetter__("members", function () args.slice()); - for (let arg in Iterator(args)) - { - let [i, name] = arg; - self.__defineGetter__(name, function () this[i]); - self.__defineSetter__(name, function (val) { this[i] = val; }); - } - function ConStructor() - { - let self = this instanceof arguments.callee ? this : new arguments.callee(); - //for (let [k, v] in Iterator(Array.slice(arguments))) // That is makes using struct twice as slow as the following code: - for (let i = 0; i < arguments.length; i++) - { - if (arguments[i] != undefined) - self[i] = arguments[i]; - } - return self; - } - ConStructor.prototype = self; - ConStructor.defaultValue = function (key, val) - { - let i = args.indexOf(key); - ConStructor.prototype.__defineGetter__(i, function () (this[i] = val.call(this), this[i])); // Kludge for FF 3.0 - ConStructor.prototype.__defineSetter__(i, function (val) { - let value = val; - this.__defineGetter__(i, function () value); - this.__defineSetter__(i, function (val) { value = val }); - }); - }; - return self.constructor = ConStructor; -} - -Struct.prototype = { - clone: function clone() - { - return this.constructor.apply(null, this.slice()); - }, - // Iterator over our named members - __iterator__: function () - { - let self = this; - return ([v, self[v]] for ([k, v] in Iterator(self.members))) - } -}; + /** + * 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) Array.concat.apply([], ary), + + /** + * Returns an Iterator for an array's values. + * + * @param {Array} ary + * @returns {Iterator(Object)} + */ + itervalues: function itervalues(ary) { + let length = ary.length; + for (let i = 0; i < 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]]; + }, + + /** + * Filters out all duplicates from an array. If + * <b>unsorted</b> is false, the array is sorted before + * duplicates are removed. + * + * @param {Array} ary + * @param {boolean} unsorted + * @returns {Array} + */ + uniq: function uniq(ary, unsorted) { + let ret = []; + if (unsorted) { + for (let [, item] in Iterator(ary)) + if (ret.indexOf(item) == -1) + ret.push(item); + } + else { + for (let [, item] in Iterator(ary.sort())) { + if (item != last || !ret.length) + ret.push(item); + var last = item; + } + } + return ret; + }, + }), +}); -// Add no-sideeffect array methods. Can't set new Array() as the prototype or -// get length() won't work. -for (let [, k] in Iterator(["concat", "every", "filter", "forEach", "indexOf", "join", "lastIndexOf", - "map", "reduce", "reduceRight", "reverse", "slice", "some", "sort"])) - Struct.prototype[k] = Array.prototype[k]; // vim: set fdm=marker sw=4 ts=4 et: |