diff --git a/HACKING b/HACKING index b2ee6c37..f8e1db72 100644 --- a/HACKING +++ b/HACKING @@ -18,16 +18,15 @@ important, please ask. == Coding Style == In general: Just look at the existing source code! -We try to be quite consistent, but of course, that's not always possible. -Also we try to target experienced JavaScript developers which do not -necessarily need to have a good understanding of Vimperator's source code, nor -do they probably know in-depth concepts of other languages like Lisp or Python. -Therefore, the coding style should feel natural to any JavaScript developer -so it is easy to read and understand. Of course, this does not mean, you have -to avoid all new JavaScript features like list comprehension or generators. -Use them, when they make sense, but don't use them, when the resulting code -is hard to read. +We try to target experienced JavaScript developers who do not +necessarily need to have a good understanding of Vimperator's source +code, nor necessarily understand in-depth concepts of other +languages like Lisp or Python. Therefore, the coding style should +feel natural to any JavaScript developer. Of course, this does not +mean, you have to avoid all new JavaScript features like list +comprehension or generators. Use them, when they make sense, but +don't use them when the resulting code is hard to read. === The most important style issues are: === @@ -40,29 +39,41 @@ is hard to read. * Use " for enclosing strings instead of ', unless using ' avoids escaping of lots of " Example: alert("foo") instead of alert('foo'); +* Use // regexp literals rather than RegExp constructors, unless + you're constructing an expression on the fly, or RegExp + constructors allow you to escape less /s than the additional + escaping of special characters required by string quoting. + + Good: /application\/xhtml\+xml/ + Bad: RegExp("application/xhtml\\+xml") + Good: RegExp("http://(www\\.)vimperator.org/(.*)/(.*)") + Bad: /http:\/\/(www\.)vimperator.org\/(.*)\/(.*)/ + * Exactly one space after if/for/while/catch etc. and after a comma, but none after a parenthesis or after a function call: for (pre; condition; post) but: alert("foo"); -* Opening curly brackets { must be on a new line, unless it is used in a closure: - function myFunction () - { - if (foo) - { - baz = false; - return bar; - } - else - { - return baz; - } - } - but: - setTimeout(function () { - ... - }); +* Bracing is formatted as follows: + function myFunction () { + if (foo) + return bar; + else { + baz = false; + return baz; + } + } + var quux = frob("you", + { + a: 1, + b: 42, + c: { + hoopy: "frood" + } + }); + + When in doubt, look for similar code. * No braces for one-line conditional statements: Right: @@ -97,9 +108,9 @@ is hard to read. * Use UNIX new lines (\n), not windows (\r\n) or old Mac ones (\r) -* Use Iterators, Array#forEach, or for (let i = 0; i < ary.length; i++) - to iterate over arrays. for (let i in ary) and for each (let i in ary) - include members in an Array.prototype, which some extensions alter. +* Use Iterators or Array#forEach to iterate over arrays. for (let i + in ary) and for each (let i in ary) include members in an + Array.prototype, which some extensions alter. Right: for (let [,elem] in Iterator(ary)) for (let [k, v] in Iterator(obj)) @@ -137,11 +148,4 @@ Additionally, maybe there should be some benchmark information here -- something to let a developer know what's "too" slow...? Or general guidelines about optimization? -== Source Code Management == - -TODO: Document the existence of remote branches and discuss when and how - to push to them. At least provide an index so that devs know where - to look if an old branch needs to be maintained or a feature needs - to be added to a new branch. - // vim: set ft=asciidoc fdm=marker sw=4 ts=4 et ai: 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 +// +// 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. cmd will be executed when one of the + * specified events occurs and the URL of the applicable buffer + * matches regex. + * + * @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 event and + * regex. + * + * @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 event and + * regex. + * + * @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 event and + * regex. + * + * @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( + + + + + { + template.map(cmds, function ([event, items]) + + + + + + template.map(items, function (item) + + + + )) + } +
----- Auto Commands -----
{event}
 {item.pattern.source}{item.command}
); + + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + /** + * Triggers the execution of all autocommands registered for + * event. A map of args is passed to each autocommand + * when it is being executed. + * + * @param {string} event The event to fire. + * @param {Object} args The args to pass to each autocommand. + */ + trigger: function (event, args) { + if (options.get("eventignore").has("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; +// also includes methods for dealing with keywords and search engines +const Bookmarks = Module("bookmarks", { + requires: ["autocommands", "liberator", "storage", "services"], - 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); + init: function () { + const faviconService = services.get("favicon"); + const bookmarksService = services.get("bookmarks"); + const historyService = services.get("history"); + const tagging = PlacesUtils.tagging; - store = storage["quickmarks"]; - pref = options.getPref(branch + ".quickmarks") - .split("\n"); - while (pref.length > 0) - store.set(pref.shift(), pref.shift()); + this.getFavicon = getFavicon; + function getFavicon(uri) { + try { + return faviconService.getFaviconImageForPage(util.newURI(uri)).spec; + } + catch (e) { + return ""; + } + } - options.resetPref(branch + ".commandline_cmd_history"); - options.resetPref(branch + ".commandline_search_history"); - options.resetPref(branch + ".quickmarks"); + // 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 readBookmark(id) { + return { + itemId: id, + uri: bookmarksService.getBookmarkURI(id).spec, + title: bookmarksService.getItemTitle(id) + }; + } + + function deleteBookmark(id) { + let length = bookmarks.length; + bookmarks = bookmarks.filter(function (item) item.id != id); + return bookmarks.length < length; + } + + this.findRoot = function findRoot(id) { + do { + var root = id; + id = bookmarksService.getFolderIdForItem(id); + } while (id != bookmarksService.placesRoot && id != root); + return root; + } + + this.isBookmark = function (id) rootFolders.indexOf(self.findRoot(id)) >= 0; + + 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; + } + + // 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; + } + + // 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; + }; + + 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(); + }; + + 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; + } + } + } + if (id == undefined) + id = services.get("bookmarks").insertBookmark( + services.get("bookmarks")[starOnly ? "unfiledBookmarksFolder" : "bookmarksMenuFolder"], + uri, -1, title || url); + if (!id) + return false; + + if (keyword) + services.get("bookmarks").setKeywordForBookmark(id, keyword); + if (tags) { + PlacesUtils.tagging.untagURI(uri, null); + PlacesUtils.tagging.tagURI(uri, tags); + } + } + catch (e) { + liberator.log(e, 0); + return false; + } + + return true; + }, + + toggle: function toggle(url) { + if (!url) + return; + + 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); + } + }, + + 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; + + searchEngines.push([engine.alias, engine.description, engine.iconURI && engine.iconURI.spec]); + } + + return searchEngines; + }, + + getSuggestions: function getSuggestions(engineName, query, callback) { + const responseType = "application/x-suggestions+json"; + + let engine = services.get("browserSearch").getEngineByAlias(engineName); + if (engine && engine.supportsResponseType(responseType)) + var queryURI = engine.getSubmission(query, responseType).uri.spec; + if (!queryURI) + return []; + + 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")]; + } + 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 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); + } + + var engine = services.get("browserSearch").getEngineByAlias(keyword); + if (engine) { + var submission = engine.getSubmission(param, null); + return [submission.uri.spec, submission.postData]; + } + + [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]; + } + + 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" }); + + // 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(this._cache.bookmarks))])); + + return [[prefix + tag, tag] for ([i, tag] in Iterator(tags)) if (have.indexOf(tag) < 0)]; + } + + 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 }); + } + + 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"] }); + } + + 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)]] + }); + + 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)) { + 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); + + liberator.echomsg(deletedCount + " bookmark(s) with url " + url.quote() + " deleted", 1, commandline.FORCE_SINGLELINE); + } + + }, + { + argCount: "?", + bang: true, + completer: function completer(context) completion.bookmark(context), + literal: 0 + }); + }, + mappings: function () { + var myModes = config.browserModes; + + 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); + + if (bmarks.length == 1) { + let bmark = bmarks[0]; + + 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; + } + + 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); }); + }, + 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); + }; + + 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); + }; + }); + }; + + 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; + }); + }); + }; + + completion.addUrlCompleter("S", "Suggest engines", completion.searchEngineSuggest); + completion.addUrlCompleter("b", "Bookmarks", completion.bookmark); + completion.addUrlCompleter("s", "Search engines and keyword URLs", completion.search); + }, }); -// 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; - } - catch (e) - { - liberator.dump("Failed to create bookmark for URI: " + node.uri); - liberator.reportError(e); - return null; - } - } - - function readBookmark(id) - { - return { - itemId: id, - uri: bookmarksService.getBookmarkURI(id).spec, - title: bookmarksService.getItemTitle(id) - }; - } - - function deleteBookmark(id) - { - let length = bookmarks.length; - bookmarks = bookmarks.filter(function (item) item.id != id); - return bookmarks.length < length; - } - - this.findRoot = function findRoot(id) - { - do - { - var root = id; - id = bookmarksService.getFolderIdForItem(id); - } while (id != bookmarksService.placesRoot && id != root); - return root; - } - - this.isBookmark = function (id) rootFolders.indexOf(self.findRoot(id)) >= 0; - - 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; - } - - // 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; - } - - // 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; - }; - - 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 = 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); - } - - function getFavicon(uri) - { - try - { - return faviconService.getFaviconImageForPage(util.newURI(uri)).spec; - } - catch (e) - { - return ""; - } - } - - 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; - - 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); - - if (bmarks.length == 1) - { - let bmark = bmarks[0]; - - 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; - } - - 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)]; - } - - 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 }); - } - - 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"] }); - } - - 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)]] - }); - - 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); - } - }); - } - 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); - } - - }, - { - 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])); - } - // 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); - }; - }); - }; - - 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; - }); - }); - }; - - 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; - } - - return true; - }, - - toggle: function toggle(url) - { - if (!url) - return; - - 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); - } - }, - - isBookmarked: function isBookmarked(url) - { - try - { - return bookmarksService.getBookmarkIdsForURI(makeURI(url), {}) - .some(cache.isRegularBookmark); - } - catch (e) - { - return false; - } - }, - - // 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) - { - liberator.log(e, 0); - return 0; - } - }, - - getFavicon: function (url) getFavicon(url), - - // 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; - - searchEngines.push([engine.alias, engine.description, engine.iconURI && engine.iconURI.spec]); - } - - return searchEngines; - }, - - getSuggestions: function getSuggestions(engineName, query, callback) - { - const responseType = "application/x-suggestions+json"; - - let engine = services.get("browserSearch").getEngineByAlias(engineName); - if (engine && engine.supportsResponseType(responseType)) - var queryURI = engine.getSubmission(query, responseType).uri.spec; - if (!queryURI) - return []; - - 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")]; - } - 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 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); - } - - var engine = services.get("browserSearch").getEngineByAlias(keyword); - if (engine) - { - var submission = engine.getSubmission(param, null); - return [submission.uri.spec, submission.postData]; - } - - [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]; - } - - 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, - [""], "Go to an older position in the jump list", - function (count) { history.stepTo(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, - [""], "Go to a newer position in the jump list", - function (count) { history.stepTo(Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, - ["H", "", ""], "Go back in the browser history", - function (count) { history.stepTo(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, - ["L", "", ""], "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; - - 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]] - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// 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 - }; - }); - 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 qmark referencing - * the URL location. Any existing quickmark with the same name - * will be replaced. - * - * @param {string} qmark The name of the quickmark {A-Z}. - * @param {string} location The URL accessed by this quickmark. - */ - add: function add(qmark, location) - { - qmarks.set(qmark, location); - liberator.echomsg("Added Quick Mark '" + qmark + "': " + location, 1); - }, - - /** - * Deletes the specified quickmarks. The filter is a list of - * quickmarks and ranges are supported. Eg. "ab c d e-k". - * - * @param {string} filter The list of quickmarks to delete. - * - */ - remove: function remove(filter) - { - let pattern = 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 qmark. - * - * @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 filter 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"] }); - } - }; - //}}} -} //}}} - // 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 should become 0010 - { + if (number.match(/^0/)) { // add 0009 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 - }); - - // 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 - }); - 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; + // 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); } }, - observe: function (subject, topic, data) + completer: function (context) completion.charset(context), + validator: Option.validateCompleter + }); + + // 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, { - if (topic == "private-browsing") - { - if (data == "enter") - storage.privateMode = true; - else if (data == "exit") - storage.privateMode = false; - storage.fireEvent("private-mode", "change", storage.privateMode); + 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"); + } } - 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); + } + + 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], + [""], "Increment last number in URL", + function (count) { Browser.incrementURL(Math.max(count, 1)); }, + { count: true }); + + mappings.add([modes.NORMAL], + [""], "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 }); + }); + + 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 { + // 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); + + 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/"); + 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], [""], + "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(["o[pen]", "e[dit]"], + "Open one or more URLs in the current tab", + function (args) { + args = args.string; + + if (args) + liberator.open(args); + else + liberator.open("about:blank"); + }, { + completer: function (context) completion.url(context), + literal: 0, + privateData: true, + }); + + commands.add(["redr[aw]"], + "Redraw the screen", + function () { + let wu = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + wu.redraw(); + modes.show(); + }, + { argCount: "0" }); } - - 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], - [""], "Increment last number in URL", - function (count) { incrementURL(Math.max(count, 1)); }, - { count: true }); - - mappings.add([modes.NORMAL], - [""], "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(); - } - else - { - // for all other locations just check if the URL ends with / - return /\/$/.test(url); - } - } - - 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/"); - 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], [""], - "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; - - if (args) - liberator.open(args); - else - liberator.open("about:blank"); - }, - { - completer: function (context) completion.url(context), - literal: 0, - privateData: true, - }); - - commands.add(["redr[aw]"], - "Redraw the screen", - function () - { - let wu = window.QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIDOMWindowUtils); - wu.redraw(); - modes.show(); - }, - { argCount: "0" }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// PUBLIC SECTION ////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ - - return { - // TODO: extract browser-specific functionality from liberator - }; - //}}} -} //}}} +}); // 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 ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ +const Buffer = Module("buffer", { + init: function () { - if ("ZoomManager" in window) - { - const ZOOM_MIN = Math.round(ZoomManager.MIN * 100); - const ZOOM_MAX = Math.round(ZoomManager.MAX * 100); + 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) +  ({type})]; + } + } + + 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]; + } + + // s have higher priority than normal 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 elem. + * + * @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 cols 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 lines 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 pages 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(
, 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:
{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)); + },
); + 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 url. Either the default viewer or the configured + * external editor is used. + * + * @param {string} url The URL of the source. + * @default The current buffer. + * @param {boolean} useExternalEditor View the source in the external editor. + */ + viewSource: function (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 }); - } + }, +}, { + commands: function () { + commands.add(["frameo[nly]"], + "Show only the current frame's page", + function (args) { + liberator.open(tabs.localStore.focusedFrame.document.documentURI); + }, + { argCount: "0" }); - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// OPTIONS ///////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + commands.add(["ha[rdcopy]"], + "Print current document", + function (args) { + let arg = args[0]; - 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"); + // FIXME: arg handling is a bit of a mess, check for filename + liberator.assert(!arg || arg[0] == ">" && !liberator.has("Win32"), + "E488: Trailing characters"); - 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.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..."); - 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.setPref("print.always_print_silent", args.bang); + options.setPref("print.show_print_progress", !args.bang); - options.add(["scroll", "scr"], - "Number of lines to scroll with and 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", ""], - "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, [""], - "Stop loading the current web page", - function () { tabs.stop(getBrowser().mCurrentTab); }); - - // scrolling - mappings.add(myModes, ["j", "", ""], - "Scroll document down", - function (count) { buffer.scrollLines(Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, ["k", "", ""], - "Scroll document up", - function (count) { buffer.scrollLines(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, liberator.has("mail") ? ["h"] : ["h", ""], - "Scroll document to the left", - function (count) { buffer.scrollColumns(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, liberator.has("mail") ? ["l"] : ["l", ""], - "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", ""], - "Go to the top of the document", - function (count) { buffer.scrollToPercent(buffer.scrollXPercent, Math.max(count, 0)); }, - { count: true }); - - mappings.add(myModes, ["G", ""], - "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); - } - - mappings.add(myModes, [""], - "Scroll window downwards in the buffer", - function (count) { scrollByScrollSize(count, true); }, - { count: true }); - - mappings.add(myModes, [""], - "Scroll window upwards in the buffer", - function (count) { scrollByScrollSize(count, false); }, - { count: true }); - - mappings.add(myModes, ["", "", ""], - "Scroll up a full page", - function (count) { buffer.scrollPages(-Math.max(count, 1)); }, - { count: true }); - - mappings.add(myModes, ["", "", ""], - "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"; + getBrowser().contentWindow.print(); }); - if (elements.length > 0) - buffer.focusElement(elements[util.Math.constrain(count, 1, elements.length) - 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", ""], - "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); }); - - mappings.add(myModes, ["R"], - "Reload while skipping the cache", - function () { tabs.reload(getBrowser().mCurrentTab, true); }); - - // yanking - mappings.add(myModes, ["Y"], - "Copy selected text or current word", - function () - { - let sel = buffer.getCurrentWord(); - - if (sel) - util.copyToClipboard(sel, true); - else - liberator.beep(); - }); - - // 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, [""], - "Print the current file name", - function (count) { buffer.showPageInfo(false); }, - { count: true }); - - mappings.add(myModes, ["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)); - } + liberator.echomsg("Printed: " + arg.substr(1)); else - liberator.echomsg("Sending to printer..."); - - options.setPref("print.always_print_silent", args.bang); - options.setPref("print.show_print_progress", !args.bang); - - getBrowser().contentWindow.print(); + liberator.echomsg("Print job sent."); + }, + { + argCount: "?", + literal: 0, + bang: true }); - 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(["pa[geinfo]"], + "Show various page information", + function (args) { buffer.showPageInfo(true, args[0]); }, { - completion.optionValue(context, "pageinfo", "+", ""); - context.title = ["Page Info"]; - } - }); + argCount: "?", + completer: function (context) { + completion.optionValue(context, "pageinfo", "+", ""); + context.title = ["Page Info"]; + } + }); - commands.add(["pagest[yle]", "pas"], - "Select the author style sheet to apply", - function (args) - { - let arg = args.literalArg; + commands.add(["pagest[yle]", "pas"], + "Select the author style sheet to apply", + function (args) { + let arg = args.literalArg; - let titles = buffer.alternateStyleSheets.map(function (stylesheet) stylesheet.title); + let titles = buffer.alternateStyleSheets.map(function (stylesheet) stylesheet.title); - liberator.assert(!arg || titles.indexOf(arg) >= 0, - "E475: Invalid argument: " + arg); + liberator.assert(!arg || titles.indexOf(arg) >= 0, + "E475: Invalid argument: " + arg); - if (options["usermode"]) - options["usermode"] = false; + if (options["usermode"]) + options["usermode"] = false; - window.stylesheetSwitchAll(window.content, arg); - }, - { - argCount: "?", - completer: function (context) completion.alternateStyleSheet(context), - literal: 0 - }); - - commands.add(["re[load]"], - "Reload the current web page", - function (args) { tabs.reload(getBrowser().mCurrentTab, args.bang); }, - { - bang: true, - argCount: "0" - }); - - // 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]; - - if (filename) + window.stylesheetSwitchAll(window.content, arg); + }, { - let file = io.File(filename); + argCount: "?", + completer: function (context) completion.alternateStyleSheet(context), + literal: 0 + }); - liberator.assert(!file.exists() || args.bang, - "E13: File exists (add ! to override)"); - - chosenData = { file: file, uri: window.makeURI(doc.location.href, doc.characterSet) }; - } - - // 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(["re[load]"], + "Reload the current web page", + function (args) { tabs.reload(getBrowser().mCurrentTab, args.bang); }, { - var contentDisposition = window.content - .QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIDOMWindowUtils) - .getDocumentMetadata("content-disposition"); - } - catch (e) {} + bang: true, + argCount: "0" + }); - 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) - }); + // 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]; - commands.add(["st[op]"], - "Stop loading the current web page", - function () { tabs.stop(getBrowser().mCurrentTab); }, - { argCount: "0" }); + if (filename) { + let file = io.File(filename); - 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") - }); + liberator.assert(!file.exists() || args.bang, + "E13: File exists (add ! to override)"); - commands.add(["zo[om]"], - "Set zoom value of current web page", - function (args) - { - let arg = args[0]; - let level; + chosenData = { file: file, uri: window.makeURI(doc.location.href, doc.characterSet) }; + } - if (!arg) - level = 100; - else if (/^\d+$/.test(arg)) - level = parseInt(arg, 10); - else if (/^[+-]\d+$/.test(arg)) + // 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 { + 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); + }, { - if (args.bang) - level = buffer.fullZoom + parseInt(arg, 10); + argCount: "?", + bang: true, + completer: function (context) completion.file(context) + }); + + 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 - level = buffer.textZoom + parseInt(arg, 10); + liberator.assert(false, "E488: Trailing characters"); - // 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 () { + if (args.bang) + buffer.fullZoom = level; + else + buffer.textZoom = level; + }, + { + argCount: "?", + bang: true + }); + }, + completion: function () { completion.alternateStyleSheet = function alternateStylesheet(context) { context.title = ["Stylesheet", "Location"]; @@ -750,1220 +1139,306 @@ function Buffer() //{{{ }; }); }; - }); + }, + mappings: function () { + var myModes = config.browserModes; - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// 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); + 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(); } - catch (e) - { - isFeed = false; - } - } + }, + { count: true }); - 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) +  ({type})]; - } - } - - 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]; - } - - // s have higher priority than normal
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 - })); - }); + mappings.add(myModes, ["i", ""], + "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), + mappings.add(myModes, [""], + "Stop loading the current web page", + function () { tabs.stop(getBrowser().mCurrentTab); }); - /** - * Opens the appropriate context menu for elem. - * - * @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"); - }, + // scrolling + mappings.add(myModes, ["j", "", ""], + "Scroll document down", + function (count) { buffer.scrollLines(Math.max(count, 1)); }, + { count: true }); - /** - * 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; + mappings.add(myModes, ["k", "", ""], + "Scroll document up", + function (count) { buffer.scrollLines(-Math.max(count, 1)); }, + { count: true }); - 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); - } - }, + mappings.add(myModes, liberator.has("mail") ? ["h"] : ["h", ""], + "Scroll document to the left", + function (count) { buffer.scrollColumns(-Math.max(count, 1)); }, + { count: true }); - /** - * Scrolls to the bottom of the current buffer. - */ - scrollBottom: function () - { - scrollToPercent(null, 100); - }, + mappings.add(myModes, liberator.has("mail") ? ["l"] : ["l", ""], + "Scroll document to the right", + function (count) { buffer.scrollColumns(Math.max(count, 1)); }, + { count: true }); - /** - * Scrolls the buffer laterally cols 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); - }, + mappings.add(myModes, ["0", "^"], + "Scroll to the absolute left of the document", + function () { buffer.scrollStart(); }); - /** - * Scrolls to the top of the current buffer. - */ - scrollEnd: function () - { - scrollToPercent(100, null); - }, + mappings.add(myModes, ["$"], + "Scroll to the absolute right of the document", + function () { buffer.scrollEnd(); }); - /** - * Scrolls the buffer vertically lines 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); - }, + mappings.add(myModes, ["gg", ""], + "Go to the top of the document", + function (count) { buffer.scrollToPercent(buffer.scrollXPercent, Math.max(count, 0)); }, + { count: true }); - /** - * Scrolls the buffer vertically pages 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); - }, + mappings.add(myModes, ["G", ""], + "Go to the end of the document", + function (count) { buffer.scrollToPercent(buffer.scrollXPercent, count >= 0 ? count : 100); }, + { count: true }); - /** - * 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) - 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(
, 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:
{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; - } - - 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)); - },
); - 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 url. Either the default viewer or the configured - * external editor is used. - * - * @param {string} url The URL of the source. - * @default The current buffer. - * @param {boolean} useExternalEditor View the source in the external editor. - */ - viewSource: function (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, ["%"], + "Scroll to {count} percent of the document", + function (count) { + if (count > 0 && count <= 100) + buffer.scrollToPercent(buffer.scrollXPercent, count); else - url = PREFIX + url; - liberator.open(url, { hide: true }); - } - }, + liberator.beep(); + }, + { count: 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); - }, + mappings.add(myModes, [""], + "Scroll window downwards in the buffer", + function (count) { buffer._scrollByScrollSize(count, true); }, + { count: true }); - /** - * 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); - } - }; - //}}} -} //}}} + mappings.add(myModes, [""], + "Scroll window upwards in the buffer", + function (count) { buffer._scrollByScrollSize(count, false); }, + { count: true }); -/** - * @instance marks - */ -function Marks() //{{{ -{ - //////////////////////////////////////////////////////////////////////////////// - ////////////////////// PRIVATE SECTION ///////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + mappings.add(myModes, ["", "", ""], + "Scroll up a full page", + function (count) { buffer.scrollPages(-Math.max(count, 1)); }, + { count: true }); - var localMarks = storage.newMap("local-marks", { store: true, privateData: true }); - var urlMarks = storage.newMap("url-marks", { store: true, privateData: true }); + mappings.add(myModes, ["", "", ""], + "Scroll down a full page", + function (count) { buffer.scrollPages(Math.max(count, 1)); }, + { count: true }); - var pendingJumps = []; - var appContent = document.getElementById("appcontent"); + mappings.add(myModes, ["]f"], + "Focus next frame", + function (count) { buffer.shiftFrameFocus(Math.max(count, 1), true); }, + { count: true }); - if (appContent) - appContent.addEventListener("load", onPageLoad, true); + mappings.add(myModes, ["[f"], + "Focus previous frame", + function (count) { buffer.shiftFrameFocus(Math.max(count, 1), false); }, + { count: 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; - } - } - } + mappings.add(myModes, ["]]"], + "Follow the link labeled 'next' or '>' if it exists", + function (count) { buffer.followDocumentRelationship("next"); }, + { count: true }); - 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) : ""); - } + mappings.add(myModes, ["[["], + "Follow the link labeled 'prev', 'previous' or '<' if it exists", + function (count) { buffer.followDocumentRelationship("previous"); }, + { count: true }); - 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; + 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(); } - } - } - } + }, + { count: true }); - function removeURLMark(mark) - { - let urlmark = urlMarks.get(mark); - if (urlmark) - { - liberator.log("Deleting URL mark: " + markToString(mark, urlmark), 5); - urlMarks.remove(mark); - } - } + 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"]); + }); - function isLocalMark(mark) /^['`a-z]$/.test(mark); - function isURLMark(mark) /^[A-Z0-9]$/.test(mark); + mappings.add(myModes, ["p", ""], + "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(); + }); - function localMarkIter() - { - for (let [mark, value] in localMarks) - for (let [, val] in Iterator(value)) - yield [mark, val]; - } + 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(); + }); - function getSortedMarks() - { - // local marks - let location = window.content.location.href; - let lmarks = [i for (i in localMarkIter()) if (i[1].location == location)]; - lmarks.sort(); + // reloading + mappings.add(myModes, ["r"], + "Reload the current web page", + function () { tabs.reload(getBrowser().mCurrentTab, false); }); - // 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])); + mappings.add(myModes, ["R"], + "Reload while skipping the cache", + function () { tabs.reload(getBrowser().mCurrentTab, true); }); - return lmarks.concat(umarks); - } + // yanking + mappings.add(myModes, ["Y"], + "Copy selected text or current word", + function () { + let sel = buffer.getCurrentWord(); - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// MAPPINGS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + if (sel) + util.copyToClipboard(sel, true); + else + liberator.beep(); + }); - var myModes = config.browserModes; + // 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, - ["m"], "Set mark at the cursor position", - function (arg) - { - if (/[^a-zA-Z]/.test(arg)) - return void liberator.beep(); + 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 }); - marks.add(arg); - }, - { arg: 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, - ["'", "`"], "Jump to the mark in the current buffer", - function (arg) { marks.jumpTo(arg); }, - { arg: 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 }); - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// COMMANDS //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////{{{ + mappings.add(myModes, ["zz"], + "Set text zoom value of current web page", + function (count) { buffer.textZoom = count > 1 ? count : 100; }, + { count: true }); - commands.add(["delm[arks]"], - "Delete the specified marks", - function (args) - { - let special = args.bang; - args = args.string; + mappings.add(myModes, ["zI"], + "Enlarge full zoom of current web page", + function (count) { buffer.zoomIn(Math.max(count, 1), true); }, + { count: true }); - // assert(special ^ args) - liberator.assert( special || args, "E471: Argument required"); - liberator.assert(!special || !args, "E474: Invalid argument"); + 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 }); - let matches; - if (matches = args.match(/(?:(?:^|[^a-zA-Z0-9])-|-(?:$|[^a-zA-Z0-9])|[^a-zA-Z0-9 -]).*/)) + 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, [""], + "Print the current file name", + function (count) { buffer.showPageInfo(false); }, + { count: true }); + + mappings.add(myModes, ["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", { - // 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)) + completer: function (context) [[k, v[1]] for ([k, v] in Iterator(this.pageInfo))], + validator: Option.validateCompleter + }); + + options.add(["scroll", "scr"], + "Number of lines to scroll with and 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, { - 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; - } - } - } + 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 + }); - 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); - }); - - /////////////////////////////////////////////////////////////////////////////}}} - ////////////////////// 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) + options.add(["usermode", "um"], + "Show current website with a minimal style sheet to make it easily accessible", + "boolean", false, { - 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 (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 filter. If special 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 - { - 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 filter. - * - * @param {string} filter - */ - list: function (filter) - { - let marks = getSortedMarks(); - - 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); - } - - }; - //}}} -} //}}} + 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 +// +// 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 s from queueing up when the + // system is under load (and, thus, giving us several minutes of + // the completion list scrolling). Multiple 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 group. + */ + _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({str}, 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 =
{template.maybeXML(str)}
; + 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 extendedMode. + * Further, callbacks defined for extendedMode 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({this._lastEcho}, + 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 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 to carry out a command + // user pressing is handled in the global onEscape + // FIXME: 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 or arrow to cycle this._history completion + else if (/^(|||||)$/.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 to get completions of a command + else if (key == "" || key == "") { + // prevent tab from moving to the next field + event.preventDefault(); + event.stopPropagation(); + + this._tabTimer.tell(event); + } + else if (key == "") { + // 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 == "" || key == "") + 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 "": + 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 "": + case "": + case "": + openLink(liberator.NEW_BACKGROUND_TAB); + break; + case "": + case "": + case "": + openLink(liberator.NEW_TAB); + break; + case "": + 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 "": + closeWindow = true; + break; // handled globally in events.js:onEscape() + + case ":": + commandline.open(":", "", modes.EX); + return; + + // down a line + case "j": + case "": + if (options["more"] && isScrollable()) + win.scrollByLines(1); + else + passEvent = true; + break; + + case "": + case "": + case "": + 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 "": + case "": + 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: on the prompt line should scroll one page + // page down + case "f": + if (options["more"] && isScrollable()) + win.scrollByPages(1); + else + passEvent = true; + break; + + case "": + case "": + 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 "": + 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 "": + 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({substring}, + 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 = {arg}; + 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) +
{message.str}
); + 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 "", "" here from mappings + mappings.add(myModes, + [""], "Focus content", + function () { events.onEscape(); }); + + // Any "non-keyword" character triggers abbreviation expansion + // TODO: Add "" and "" to this list + // At the moment, adding "" breaks tab completion. Adding + // "" 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, + ["", '"', "'"], "Expand command line abbreviation", + function () { + commandline.resetCompletions(); + return editor.expandAbbreviation("c"); + }, + { route: true }); + + mappings.add(myModes, + ["", ""], "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