diff options
author | Daniel Bainton <dpb@driftaway.org> | 2008-12-04 07:56:35 +0200 |
---|---|---|
committer | Daniel Bainton <dpb@driftaway.org> | 2008-12-04 07:56:35 +0200 |
commit | 729854c749e46bf97a97951614d96cfee302cd9d (patch) | |
tree | 6a64a10b3303ff7f5b0d3e4d5398a7544b0f58ff /common/content | |
parent | cc6bdfc2fcaa42186dddac10ed96046e4dc7c5f3 (diff) | |
download | pentadactyl-729854c749e46bf97a97951614d96cfee302cd9d.tar.gz |
Rename liberator/ to common/
Diffstat (limited to 'common/content')
-rw-r--r-- | common/content/README.E4X | 149 | ||||
-rw-r--r-- | common/content/bindings.xml | 37 | ||||
-rw-r--r-- | common/content/buffer.js | 1708 | ||||
-rw-r--r-- | common/content/buffer.xhtml | 9 | ||||
-rw-r--r-- | common/content/commands.js | 892 | ||||
-rw-r--r-- | common/content/completion.js | 1704 | ||||
-rw-r--r-- | common/content/editor.js | 1128 | ||||
-rw-r--r-- | common/content/eval.js | 10 | ||||
-rw-r--r-- | common/content/events.js | 1694 | ||||
-rw-r--r-- | common/content/find.js | 473 | ||||
-rw-r--r-- | common/content/help.css | 149 | ||||
-rw-r--r-- | common/content/hints.js | 811 | ||||
-rw-r--r-- | common/content/io.js | 966 | ||||
-rw-r--r-- | common/content/liberator-overlay.js | 58 | ||||
-rw-r--r-- | common/content/liberator.js | 1360 | ||||
-rw-r--r-- | common/content/liberator.xul | 115 | ||||
-rw-r--r-- | common/content/mappings.js | 414 | ||||
-rw-r--r-- | common/content/modes.js | 294 | ||||
-rw-r--r-- | common/content/options.js | 984 | ||||
-rw-r--r-- | common/content/style.js | 582 | ||||
-rw-r--r-- | common/content/tabs.js | 1000 | ||||
-rw-r--r-- | common/content/template.js | 305 | ||||
-rw-r--r-- | common/content/ui.js | 1860 | ||||
-rw-r--r-- | common/content/util.js | 597 |
24 files changed, 17299 insertions, 0 deletions
diff --git a/common/content/README.E4X b/common/content/README.E4X new file mode 100644 index 00000000..3edb7714 --- /dev/null +++ b/common/content/README.E4X @@ -0,0 +1,149 @@ + A terse introduction to E4X + Public Domain + +The inline XML literals in this code are part of E4X, a standard +XML processing interface for ECMAScript. In addition to syntax +for XML literals, E4X provides a new kind of native object, +"xml", and a syntax, similar to XPath, for accessing and +modifying the tree. Here is a brief synopsis of the kind of +usage you'll see herein: + +> let xml = + <foo bar="baz" baz="qux"> + <bar> + <baz id="1"/> + </bar> + <baz id="2"/> + </foo>; + + // Select all bar elements of the root foo element +> xml.bar + <bar><baz id="1"/></bar> + + // Select all baz elements anywhere beneath the root +> xml..baz + <baz id="1"/> + <baz id="2"/> + + // Select all of the immediate children of the root +> xml.* + <bar><baz id="1"/></bar> + <baz id="2"/> + + // Select the bar attribute of the root node +> xml.@bar + baz + + // Select all id attributes in the tree +> xml..@id + 1 + 2 + + // Select all attributes of the root node +> xml.@* + baz + quz + +// Add a quux elemend beneath the first baz +> xml..baz[0] += <quux/> + <baz id="1"/> + <quux/> +> xml + <foo bar="baz" baz="qux"> + <bar> + <baz id="1"/> + <quux/> + </bar> + <baz id="2"/> + </foo> + + // and beneath the second +> xml.baz[1] = <quux id="1"/> +> xml + <foo bar="baz" baz="qux"> + <bar> + <baz id="1"/> + <quux/> + </bar> + <baz id="2"/> + <quux id="1"/> + </foo> + + // Replace bar's subtree with a foo element +> xml.bar.* = <foo id="1"/> +> xml + <foo bar="baz" baz="qux"> + <bar> + <foo id="1"/> + </bar> + <baz id="2"/> + <quux id="1"/> + </foo> + + // Add a bar below bar +> xml.bar.* += <bar id="1"/> + <foo id="1"/> + <bar id="1"/> +> xml + <foo bar="baz" baz="qux"> + <bar> + <foo id="1"/> + <bar id="1"/> + </bar> + <baz id="2"/> + <quux id="1"/> + </foo> + + // Adding a quux attribute to the root +> xml.@quux = "foo" + foo +> xml + <foo bar="baz" baz="qux" quux="foo"> + <bar> + <foo id="1"/> + <bar id="1"/> + </bar> + <baz id="2"/> + <quux id="1"/> + </foo> + +> xml.bar.@id = "0" +> xml..foo[0] = "Foo" + Foo +> xml..bar[1] = "Bar" + Bar +> xml +js> xml +<foo bar="baz" baz="qux" quux="foo" id="0"> + <bar id="0"> + <foo id="1">Foo</foo> + <bar id="1">Bar</bar> + </bar> + <baz id="2"/> + <quux id="1"/> +</foo> + + // Selecting all bar elements where id="1" +> xml..bar.(@id == 1) + Bar + + // Literals: + // XMLList literal. No root node. +> <>Foo<br/>Baz</> + Foo + <br/> + Baz + +// Interpolation. +> let x = "<foo/>" +> <foo bar={x}>{x + "<?>"}</foo> + <foo/><?> +> <foo bar={x}>{x + "<?>"}</foo>.toXMLString() + <foo bar="<foo/>"><foo/><?></foo> + +> let x = <foo/> +> <foo bar={x}>{x}</foo>.toXMLString() + <foo bar=""> + <foo/> + </foo> + diff --git a/common/content/bindings.xml b/common/content/bindings.xml new file mode 100644 index 00000000..8d6474d0 --- /dev/null +++ b/common/content/bindings.xml @@ -0,0 +1,37 @@ +<?xml version="1.0"?> + +<bindings xmlns="http://www.mozilla.org/xbl" + xmlns:liberator="http://vimperator.org/namespaces/liberator" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <binding id="frame"> + <content> + <html:div liberator:highlight="FrameIndicator"/> + <children/> + </content> + </binding> + <binding id="compitem-td"> + <!-- No white space. The table is white-space: pre; :( --> + <content><html:span class="td-strut"/><html:span class="td-span"><children/></html:span></content> + </binding> + <binding id="tab" display="xul:hbox" + extends="chrome://global/content/bindings/tabbox.xml#tab"> + <content chromedir="ltr" closetabtext="Close Tab"> + <xul:stack class="liberator-tab-stack"> + <xul:image xbl:inherits="validate,src=image" class="tab-icon-image" liberator:highlight="TabIcon"/> + <xul:vbox> + <xul:spring flex="1"/> + <xul:label xbl:inherits="value=ordinal" liberator:highlight="TabIconNumber"/> + <xul:spring flex="1"/> + </xul:vbox> + </xul:stack> + <xul:label xbl:inherits="value=ordinal" liberator:highlight="TabNumber"/> + <xul:label flex="1" xbl:inherits="value=label,crop,accesskey" class="tab-text" liberator:highlight="TabText"/> + <xul:toolbarbutton anonid="close-button" tabindex="-1" class="tab-close-button" liberator:highlight="TabClose"/> + </content> + </binding> +</bindings> + +<!-- vim:se ft=xbl sw=4 sts=4 tw=0 et: --> diff --git a/common/content/buffer.js b/common/content/buffer.js new file mode 100644 index 00000000..4d03b6ce --- /dev/null +++ b/common/content/buffer.js @@ -0,0 +1,1708 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +const Point = new Struct("x", "y"); + +function Buffer() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + /* FIXME: This doesn't belong here. */ + let mainWindowID = config.mainWindowID || "main-window"; + let fontSize = util.computedStyle(document.getElementById(mainWindowID)).fontSize; + + styles.registerSheet("chrome://liberator/skin/liberator.css"); + let error = styles.addSheet("font-size", "chrome://liberator/content/buffer.xhtml", + "body { font-size: " + fontSize + "; }", true); + + if ("ZoomManager" in window) + { + const ZOOM_MIN = Math.round(ZoomManager.MIN * 100); + const ZOOM_MAX = Math.round(ZoomManager.MAX * 100); + } + + function setZoom(value, fullZoom) + { + if (value < ZOOM_MIN || value > ZOOM_MAX) + { + liberator.echoerr("Zoom value out of range (" + ZOOM_MIN + " - " + ZOOM_MAX + "%)"); + return; + } + + ZoomManager.useFullZoom = fullZoom; + ZoomManager.zoom = value / 100; + if ("FullZoom" in window) + FullZoom._applySettingToPref(); + liberator.echo((fullZoom ? "Full" : "Text") + " zoom: " + value + "%"); + } + + function bumpZoomLevel(steps, fullZoom) + { + let values = ZoomManager.zoomValues; + let i = values.indexOf(ZoomManager.snap(ZoomManager.zoom)) + steps; + + if (i >= 0 && i < values.length) + setZoom(Math.round(values[i] * 100), fullZoom); + // TODO: I'll leave the behaviour as is for now, but I think this + // should probably just take you to the respective bounds -- djk + else + liberator.beep(); + } + + 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() + { + var win = window.document.commandDispatcher.focusedWindow; + if (win && (win.scrollMaxX > 0 || win.scrollMaxY > 0)) + return win; + + win = window.content; + if (win.scrollMaxX > 0 || win.scrollMaxY > 0) + return win; + + for (let frame in util.Array.iterator(win.frames)) + if (frame.scrollMaxX > 0 || frame.scrollMaxY > 0) + return frame; + + return win; + } + + // both values are given in percent, -1 means no change + function scrollToPercentiles(horizontal, vertical) + { + var win = findScrollableWindow(); + var h, v; + + if (horizontal < 0) + h = win.scrollX; + else + h = win.scrollMaxX / 100 * horizontal; + + if (vertical < 0) + v = win.scrollY; + else + v = win.scrollMaxY / 100 * vertical; + + win.scrollTo(h, v); + } + + // Holds option: [function, title] to generate :pageinfo sections + var pageInfo = {}; + function addPageInfoSection(option, title, fn) + { + pageInfo[option] = [fn, title]; + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["fullscreen", "fs"], + "Show the current window fullscreen", + "boolean", false, + { + setter: function (value) window.fullScreen = value, + getter: function () window.fullScreen + }); + + options.add(["nextpattern"], + "Patterns to use when guessing the 'next' page in a document sequence", + "stringlist", "\\bnext\\b,^>$,^(>>|»)$,^(>|»),(>|»)$,\\bmore\\b"); + + options.add(["previouspattern"], + "Patterns to use when guessing the 'previous' page in a document sequence", + "stringlist", "\\bprev|previous\\b,^<$,^(<<|«)$,^(<|«),(<|«)$"); + + options.add(["pageinfo", "pa"], "Desired info on :pa[geinfo]", "charlist", "gfm", + { + completer: function (filter) [[k, v[1]] for ([k, v] in Iterator(pageInfo))], + validator: Option.validateCompleter + }); + + options.add(["scroll", "scr"], + "Number of lines to scroll with <C-u> and <C-d> commands", + "number", 0, + { validator: function (value) value >= 0 }); + + options.add(["showstatuslinks", "ssli"], + "Show the destination of the link under the cursor in the status bar", + "number", 1, + { + completer: function (filter) [ + ["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) + { + try + { + window.getMarkupDocumentViewer().authorStyleDisabled = value; + } + catch (e) {} + + return value; + }, + getter: function () + { + try + { + return window.getMarkupDocumentViewer().authorStyleDisabled; + } + catch (e) {} + } + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var myModes = config.browserModes; + + mappings.add(myModes, ["."], + "Repeat the last key event", + function (count) + { + if (mappings.repeat) + { + for (let i in util.interruptableRange(0, Math.max(count, 1), 100)) + mappings.repeat(); + } + }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["i", "<Insert>"], + "Start caret mode", + function () + { + // setting this option triggers an observer which takes care of the mode setting + options.setPref("accessibility.browsewithcaret", true); + }); + + mappings.add(myModes, ["<C-c>"], + "Stop loading", + function () { window.BrowserStop(); }); + + // scrolling + mappings.add(myModes, ["j", "<Down>", "<C-e>"], + "Scroll document down", + function (count) { buffer.scrollLines(count > 1 ? count : 1); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["k", "<Up>", "<C-y>"], + "Scroll document up", + function (count) { buffer.scrollLines(-(count > 1 ? count : 1)); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, liberator.has("mail") ? ["h"] : ["h", "<Left>"], + "Scroll document to the left", + function (count) { buffer.scrollColumns(-(count > 1 ? count : 1)); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, liberator.has("mail") ? ["l"] : ["l", "<Right>"], + "Scroll document to the right", + function (count) { buffer.scrollColumns(count > 1 ? count : 1); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["0", "^"], + "Scroll to the absolute left of the document", + function () { buffer.scrollStart(); }); + + mappings.add(myModes, ["$"], + "Scroll to the absolute right of the document", + function () { buffer.scrollEnd(); }); + + mappings.add(myModes, ["gg", "<Home>"], + "Go to the top of the document", + function (count) { buffer.scrollToPercentile(count > 0 ? count : 0); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["G", "<End>"], + "Go to the end of the document", + function (count) { buffer.scrollToPercentile(count >= 0 ? count : 100); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["%"], + "Scroll to {count} percent of the document", + function (count) + { + if (count > 0 && count <= 100) + buffer.scrollToPercentile(count); + else + liberator.beep(); + }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["<C-d>"], + "Scroll window downwards in the buffer", + function (count) { buffer.scrollByScrollSize(count, 1); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["<C-u>"], + "Scroll window upwards in the buffer", + function (count) { buffer.scrollByScrollSize(count, -1); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["<C-b>", "<PageUp>", "<S-Space>"], + "Scroll up a full page", + function (count) { buffer.scrollPages(-(count > 1 ? count : 1)); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["<C-f>", "<PageDown>", "<Space>"], + "Scroll down a full page", + function (count) { buffer.scrollPages(count > 1 ? count : 1); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["]f"], + "Focus next frame", + function (count) { buffer.shiftFrameFocus(count > 1 ? count : 1, true); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["[f"], + "Focus previous frame", + function (count) { buffer.shiftFrameFocus(count > 1 ? count : 1, false); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["]]"], + "Follow the link labeled 'next' or '>' if it exists", + function (count) { buffer.followDocumentRelationship("next"); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["[["], + "Follow the link labeled 'prev', 'previous' or '<' if it exists", + function (count) { buffer.followDocumentRelationship("previous"); }, + { flags: Mappings.flags.COUNT }); + + 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, ["gi"], + "Focus last used input field", + function (count) + { + if (count < 1 && buffer.lastInputField) + { + buffer.lastInputField.focus(); + } + else + { + var elements = []; + var matches = buffer.evaluateXPath( + // TODO: type="file" + "//input[not(@type) or @type='text' or @type='password'] | //textarea[not(@disabled) and not(@readonly)] |" + + "//xhtml:input[not(@type) or @type='text' or @type='password'] | //xhtml:textarea[not(@disabled) and not(@readonly)]" + ); + + for (match in matches) + { + let computedStyle = util.computedStyle(match); + if (computedStyle.visibility != "hidden" && computedStyle.display != "none") + elements.push(match); + } + + if (elements.length > 0) + { + if (count > elements.length) + count = elements.length; + else if (count < 1) + count = 1; + + elements[count - 1].focus(); + } + else + { + liberator.beep(); + } + } + }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["gP"], + "Open (put) a URL based on the current clipboard contents in a new buffer", + function () + { + liberator.open(util.readFromClipboard(), + /\bpaste\b/.test(options["activate"]) ? + liberator.NEW_BACKGROUND_TAB : liberator.NEW_TAB); + }); + + mappings.add(myModes, ["p", "<MiddleMouse>"], + "Open (put) a URL based on the current clipboard contents in the current buffer", + function () { liberator.open(util.readFromClipboard()); }); + + mappings.add(myModes, ["P"], + "Open (put) a URL based on the current clipboard contents in a new buffer", + function () + { + liberator.open(util.readFromClipboard(), + /\bpaste\b/.test(options["activate"]) ? + liberator.NEW_TAB : liberator.NEW_BACKGROUND_TAB); + }); + + // reloading + mappings.add(myModes, ["r"], + "Reload current 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 () + { + var 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(count > 1 ? count : 1, false); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zm"], + "Enlarge text zoom of current web page by a larger amount", + function (count) { buffer.zoomIn((count > 1 ? count : 1) * 3, false); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zo", "-"], + "Reduce text zoom of current web page", + function (count) { buffer.zoomOut(count > 1 ? count : 1, false); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zr"], + "Reduce text zoom of current web page by a larger amount", + function (count) { buffer.zoomOut((count > 1 ? count : 1) * 3, false); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zz"], + "Set text zoom value of current web page", + function (count) { buffer.textZoom = count > 1 ? count : 100; }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zI"], + "Enlarge full zoom of current web page", + function (count) { buffer.zoomIn(count > 1 ? count : 1, true); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zM"], + "Enlarge full zoom of current web page by a larger amount", + function (count) { buffer.zoomIn((count > 1 ? count : 1) * 3, true); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zO"], + "Reduce full zoom of current web page", + function (count) { buffer.zoomOut(count > 1 ? count : 1, true); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zR"], + "Reduce full zoom of current web page by a larger amount", + function (count) { buffer.zoomOut((count > 1 ? count : 1) * 3, true); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["zZ"], + "Set full zoom value of current web page", + function (count) { buffer.fullZoom = count > 1 ? count : 100; }, + { flags: Mappings.flags.COUNT }); + + // page info + mappings.add(myModes, ["<C-g>"], + "Print the current file name", + function (count) { buffer.showPageInfo(false); }, + { flags: Mappings.flags.COUNT }); + + mappings.add(myModes, ["g<C-g>"], + "Print file information", + function () { buffer.showPageInfo(true); }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["ha[rdcopy]"], + "Print current document", + function (args) + { + var aps = options.getPref("print.always_print_silent"); + var spp = options.getPref("print.show_print_progress"); + + liberator.echo("Sending to printer..."); + options.setPref("print.always_print_silent", args.bang); + options.setPref("print.show_print_progress", !args.bang); + + getBrowser().contentWindow.print(); + + options.setPref("print.always_print_silent", aps); + options.setPref("print.show_print_progress", spp); + liberator.echo("Print job sent."); + }, + { + argCount: "0", + bang: true + }); + + commands.add(["pa[geinfo]"], + "Show various page information", + function (args) { buffer.showPageInfo(true, args[0]); }, + { + argCount: "?", + completer: function (context) + { + completion.optionValue(context, "pageinfo", "+", ""); + context.title = ["Page Info"]; + } + }); + + commands.add(["pagest[yle]"], + "Select the author style sheet to apply", + function (args) + { + args = args.string; + + var titles = buffer.alternateStyleSheets.map(function (stylesheet) stylesheet.title); + + if (args && titles.indexOf(args) == -1) + { + liberator.echoerr("E475: Invalid argument: " + args); + return; + } + + if (options["usermode"]) + options["usermode"] = false; + + window.stylesheetSwitchAll(window.content, args); + }, + { + argCount: "?", + completer: function (context) completion.alternateStylesheet(context), + literal: 0 + }); + + commands.add(["re[load]"], + "Reload current 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) + { + let file = io.getFile(filename); + + if (file.exists() && !args.bang) + { + liberator.echoerr("E13: File exists (add ! to override)"); + return; + } + + chosenData = { file: file, uri: 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 + { + var contentDisposition = window.content + .QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIDOMWindowUtils) + .getDocumentMetadata("content-disposition"); + } catch (e) {} + + window.internalSave(doc.location.href, doc, null, contentDisposition, + doc.contentType, false, null, chosenData, doc.referrer ? + window.makeURI(doc.referrer) : null, true); + }, + { + argCount: "?", + bang: true, + completer: function (context) completion.file(context) + }); + + commands.add(["st[op]"], + "Stop loading", + function () { window.BrowserStop(); }, + { 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 level; + + if (!args.string) + { + level = 100; + } + else if (/^\d+$/.test(args.string)) + { + level = parseInt(args.string, 10); + } + else if (/^[+-]\d+$/.test(args.string)) + { + if (args.bang) + level = buffer.fullZoom + parseInt(args.string, 10); + else + level = buffer.textZoom + parseInt(args.string, 10); + + // relative args shouldn't take us out of range + if (level < ZOOM_MIN) + level = ZOOM_MIN; + if (level > ZOOM_MAX) + level = ZOOM_MAX; + } + else + { + liberator.echoerr("E488: Trailing characters"); + return; + } + + if (args.bang) + buffer.fullZoom = level; + else + buffer.textZoom = level; + }, + { bang: true }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PAGE INFO /////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + addPageInfoSection("f", "Feeds", function (verbose) + { + var 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 = (type == "application/rss+xml" || type == "application/atom+xml"); + if (!isFeed) + { + // really slimy: general XML types with magic letters in the title + const titleRegex = /(^|\s)rss($|\s)/i; + isFeed = ((type == "text/xml" || type == "application/rdf+xml" || type == "application/xml") + && titleRegex.test(data.title)); + } + } + + if (isFeed) + { + try + { + window.urlSecurityCheck(data.href, principal, + Components.interfaces.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL); + } + catch (e) + { + isFeed = false; + } + } + + if (type) + data.type = type; + + return isFeed; + } + + // put feeds rss into pageFeeds[] + let nFeed = 0; + var linkNodes = doc.getElementsByTagName("link"); + for (link in util.Array.iterator(linkNodes)) + { + if (!link.href) + return; + + var rel = link.rel && link.rel.toLowerCase(); + + if (rel == "feed" || (link.type && rel == "alternate")) + { + var feed = { title: link.title, href: link.href, type: link.type || "" }; + if (isValidFeed(feed, doc.nodePrincipal, rel == "feed")) + { + nFeed++; + var type = feedTypes[feed.type] || feedTypes["application/rss+xml"]; + if (verbose) + yield [feed.title, template.highlightURL(feed.href, true) + <span class="extra-info"> ({type})</span>]; + } + } + } + + if (!verbose && nFeed) + yield nFeed + " feed" + (nFeed > 1 ? "s" : ""); + }); + + addPageInfoSection("g", "General Info", function (verbose) + { + let doc = window.content.document; + + // get file size + const nsICacheService = Components.interfaces.nsICacheService; + const ACCESS_READ = Components.interfaces.nsICache.ACCESS_READ; + const cacheService = Components.classes["@mozilla.org/network/cache-service;1"] + .getService(nsICacheService); + let cacheKey = doc.location.toString().replace(/#.*$/, ""); + + for (let proto in util.Array.iterator(["HTTP", "FTP"])) + { + try + { + var cacheEntryDescriptor = cacheService.createSession(proto, 0, true) + .openCacheEntry(cacheKey, ACCESS_READ, false); + break; + } + catch (e) {} + } + + var 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 + } + + var lastModVerbose = new Date(doc.lastModified).toLocaleString(); + var lastMod = new Date(doc.lastModified).toLocaleFormat("%x %X"); + // FIXME: probably not portable across different language versions + 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)]; + + var 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[] + var metaNodes = window.content.document.getElementsByTagName("meta"); + + return Array.map(metaNodes, function (node) [(node.name || node.httpEquiv), template.highlightURL(node.content)]) + .sort(function (a, b) String.localeCompare(a[0].toLowerCase(), b[0].toLowerCase())); + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + get alternateStyleSheets() + { + var stylesheets = window.getAllStyleSheets(window.content); + + return stylesheets.filter( + function (stylesheet) /^(screen|all|)$/i.test(stylesheet.media.mediaText) && !/^\s*$/.test(stylesheet.title) + ); + }, + + get pageInfo() pageInfo, + + // 0 if loading, 1 if loaded or 2 if 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; + }, + + // used to keep track of the right field for "gi" + get lastInputField() + { + if (window.content.document.lastInputField) + return window.content.document.lastInputField; + else + return null; + }, + set lastInputField(value) + { + window.content.document.lastInputField = value; + }, + + get URL() + { + return window.content.document.location.href; + }, + + get pageHeight() + { + return window.content.innerHeight; + }, + + get textZoom() + { + return getBrowser().markupDocumentViewer.textZoom * 100; + }, + set textZoom(value) + { + setZoom(value, false); + }, + + get fullZoom() + { + return getBrowser().markupDocumentViewer.fullZoom * 100; + }, + set fullZoom(value) + { + setZoom(value, true); + }, + + get title() + { + return window.content.document.title; + }, + + addPageInfoSection: addPageInfoSection, + + // returns an XPathResult object + evaluateXPath: function (expression, doc, elem, asIterator) + { + if (!doc) + doc = window.content.document; + if (!elem) + elem = doc; + + var result = doc.evaluate(expression, elem, + function lookupNamespaceURI(prefix) + { + switch (prefix) + { + case "xhtml": + return "http://www.w3.org/1999/xhtml"; + case "liberator": + return NS.uri; + default: + return null; + } + }, + asIterator ? XPathResult.UNORDERED_NODE_ITERATOR_TYPE : XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, + null + ); + + result.__iterator__ = asIterator + ? function () { let elem; while ((elem = this.iterateNext())) yield elem; } + : function () { for (let i = 0; i < this.snapshotLength; i++) yield this.snapshotItem(i); }; + + return result; + }, + + // in contrast to vim, returns the selection if one is made, + // otherwise tries to guess the current word under the text cursor + // NOTE: might change the selection + // 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(); + if (selection.isCollapsed) + { + let selController = this.selectionController; + let caretmode = selController.getCaretEnabled(); + selController.setCaretEnabled(true); + selController.wordMove(false, false); + selController.wordMove(true, true); + selController.setCaretEnabled(caretmode); + } + let range = selection.getRangeAt(0); + if (util.computedStyle(range.startContainer).whiteSpace == "pre" + && util.computedStyle(range.endContainer).whiteSpace == "pre") + return String(range); + return String(selection); + }, + + // more advanced than a simple elem.focus() as it also works for iframes + // and image maps + // TODO: merge with followLink()? + focusElement: function (elem) + { + var doc = window.content.document; + var elemTagName = elem.localName.toLowerCase(); + if (elemTagName == "frame" || elemTagName == "iframe") + { + elem.contentWindow.focus(); + return false; + } + else + { + elem.focus(); + } + + var evt = doc.createEvent("MouseEvents"); + var x = 0; + var y = 0; + // for imagemap + if (elemTagName == "area") + { + var coords = elem.getAttribute("coords").split(","); + x = Number(coords[0]); + y = Number(coords[1]); + } + + evt.initMouseEvent("mouseover", true, true, doc.defaultView, 1, x, y, 0, 0, 0, 0, 0, 0, 0, null); + elem.dispatchEvent(evt); + }, + + followDocumentRelationship: function (relationship) + { + function followFrameRelationship(relationship, parsedFrame) + { + var regexps; + var relText; + var patternText; + var revString; + switch (relationship) + { + case "next": + regexps = options.get("nextpattern").values; + revString = "previous"; + break; + case "previous": + // TODO: accept prev\%[ious] + regexps = options.get("previouspattern").values; + revString = "next"; + break; + default: + liberator.echoerr("Bad document relationship: " + relationship); + } + + relText = new RegExp(relationship, "i"); + revText = new RegExp(revString, "i"); + var elems = parsedFrame.document.getElementsByTagName("link"); + // links have higher priority than normal <a> hrefs + for (let i = 0; i < elems.length; i++) + { + if (relText.test(elems[i].rel) || revText.test(elems[i].rev)) + { + liberator.open(elems[i].href); + return true; + } + } + + // no links? ok, look for hrefs + elems = parsedFrame.document.getElementsByTagName("a"); + for (let i = 0; i < elems.length; i++) + { + if (relText.test(elems[i].rel) || revText.test(elems[i].rev)) + { + buffer.followLink(elems[i], liberator.CURRENT_TAB); + return true; + } + } + + for (let pattern = 0; pattern < regexps.length; pattern++) + { + patternText = new RegExp(regexps[pattern], "i"); + for (let i = 0; i < elems.length; i++) + { + if (patternText.test(elems[i].textContent)) + { + buffer.followLink(elems[i], liberator.CURRENT_TAB); + return true; + } + else + { + // images with alt text being href + var children = elems[i].childNodes; + for (let j = 0; j < children.length; j++) + { + if (patternText.test(children[j].alt)) + { + buffer.followLink(elems[i], liberator.CURRENT_TAB); + return true; + } + } + } + } + } + return false; + } + + var retVal; + if (window.content.frames.length != 0) + { + retVal = followFrameRelationship(relationship, window.content); + if (!retVal) + { + // only loop through frames if the main content didnt match + for (let i = 0; i < window.content.frames.length; i++) + { + retVal = followFrameRelationship(relationship, window.content.frames[i]); + if (retVal) + break; + } + } + } + else + { + retVal = followFrameRelationship(relationship, window.content); + } + + if (!retVal) + liberator.beep(); + }, + + // artificially "clicks" a link in order to open it + followLink: function (elem, where) + { + var doc = elem.ownerDocument; + var view = doc.defaultView; + var offsetX = 1; + var offsetY = 1; + + var localName = elem.localName.toLowerCase(); + if (localName == "frame" || localName == "iframe") // broken? + { + elem.contentWindow.focus(); + return false; + } + else if (localName == "area") // for imagemap + { + var coords = elem.getAttribute("coords").split(","); + offsetX = Number(coords[0]) + 1; + offsetY = Number(coords[1]) + 1; + } + + var 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(); + + var evt = doc.createEvent("MouseEvents"); + ["mousedown", "mouseup", "click"].forEach(function (event) { + evt.initMouseEvent(event, true, true, view, 1, offsetX, offsetY, 0, 0, + ctrlKey, /*altKey*/0, shiftKey, /*metaKey*/ ctrlKey, 0, null); + elem.dispatchEvent(evt); + }); + }, + + get selectionController() getBrowser().docShell + .QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsISelectionDisplay) + .QueryInterface(Components.interfaces.nsISelectionController), + + saveLink: function (elem, skipPrompt) + { + var doc = elem.ownerDocument; + var url = window.makeURLAbsolute(elem.baseURI, elem.href); + var 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); + } + }, + + scrollBottom: function () + { + scrollToPercentiles(-1, 100); + }, + + scrollColumns: function (cols) + { + var win = findScrollableWindow(); + const COL_WIDTH = 20; + + if (cols > 0 && win.scrollX >= win.scrollMaxX || cols < 0 && win.scrollX == 0) + liberator.beep(); + + win.scrollBy(COL_WIDTH * cols, 0); + }, + + scrollEnd: function () + { + scrollToPercentiles(100, -1); + }, + + scrollLines: function (lines) + { + var win = findScrollableWindow(); + checkScrollYBounds(win, lines); + win.scrollByLines(lines); + }, + + scrollPages: function (pages) + { + var win = findScrollableWindow(); + checkScrollYBounds(win, pages); + win.scrollByPages(pages); + }, + + scrollByScrollSize: function (count, direction) + { + if (count > 0) + options["scroll"] = count; + + var 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); + }, + + scrollToPercentile: function (percentage) + { + scrollToPercentiles(-1, percentage); + }, + + scrollStart: function () + { + scrollToPercentiles(0, -1); + }, + + scrollTop: function () + { + scrollToPercentiles(-1, 0); + }, + + // TODO: allow callback for filtering out unwanted frames? User defined? + shiftFrameFocus: function (count, forward) + { + if (!window.content.document instanceof HTMLDocument) + return; + + count = Math.max(count, 1); + var frames = []; + + // find all frames - depth-first search + (function (frame) { + if (frame.document.body.localName.toLowerCase() == "body") + 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 + var 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. + var current = frames.indexOf(document.commandDispatcher.focusedWindow); + + // calculate the next frame to focus + var 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; + var indicator = util.xmlToDom(<div highlight="FrameIndicator"/>, doc); + doc.body.appendChild(indicator); + + setTimeout(function () { doc.body.removeChild(indicator); }, 500); + + // Doesn't unattach + //doc.body.setAttributeNS(NS.uri, "activeframe", "true"); + //setTimeout(function () { doc.body.removeAttributeNS(NS.uri, "activeframe"); }, 500); + }, + + // similar to pageInfo + // TODO: print more useful information, just like the DOM inspector + showElementInfo: function (elem) + { + liberator.echo(<>Element:<br/>{util.objectToString(elem, true)}</>, commandline.FORCE_MULTILINE); + }, + + 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"; + + var pageInfoText = <>"{file}" [{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)); + }, <br/>); + liberator.echo(list, commandline.FORCE_MULTILINE); + }, + + viewSelectionSource: function () + { + // copied (and tuned somebit) from browser.jar -> nsContextMenu.js + var focusedWindow = document.commandDispatcher.focusedWindow; + if (focusedWindow == window) + focusedWindow = content; + + var docCharset = null; + if (focusedWindow) + docCharset = "charset=" + focusedWindow.document.characterSet; + + var reference = null; + reference = focusedWindow.getSelection(); + + var docUrl = null; + window.openDialog("chrome://global/content/viewPartialSource.xul", + "_blank", "scrollbars,resizable,chrome,dialog=no", + docUrl, docCharset, reference, "selection"); + }, + + viewSource: function (url, useExternalEditor) + { + url = url || buffer.URL; + + if (useExternalEditor) + editor.editFileExternally(url); + else + liberator.open("view-source:" + url); + }, + + zoomIn: function (steps, fullZoom) + { + bumpZoomLevel(steps, fullZoom); + }, + + zoomOut: function (steps, fullZoom) + { + bumpZoomLevel(-steps, fullZoom); + } + }; + //}}} +}; //}}} + +function Marks() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var localMarks = storage.newMap('local-marks', true); + var urlMarks = storage.newMap('url-marks', true); + + var pendingJumps = []; + var appContent = document.getElementById("appcontent"); + + if (appContent) + appContent.addEventListener("load", onPageLoad, true); + + function onPageLoad(event) + { + var win = event.originalTarget.defaultView; + for (let i = 0, length = pendingJumps.length; i < length; i++) + { + var mark = pendingJumps[i]; + if (win && win.location.href == mark.location) + { + win.scrollTo(mark.position.x * win.scrollMaxX, mark.position.y * win.scrollMaxY); + pendingJumps.splice(i, 1); + return; + } + } + } + + function markToString(name, mark) + { + return name + ", " + mark.location + + ", (" + Math.round(mark.position.x * 100) + + "%, " + Math.round(mark.position.y * 100) + "%)" + + (('tab' in mark) ? ", tab: " + tabs.index(mark.tab) : ""); + } + + function removeLocalMark(mark) + { + var localmark = localMarks.get(mark); + if (localmark) + { + var win = window.content; + for (let [i,] in Iterator(localmark)) + { + if (localmark[i].location == win.location.href) + { + liberator.log("Deleting local mark: " + markToString(mark, localmark[i]), 5); + localmark.splice(i, 1); + if (localmark.length == 0) + localMarks.remove(mark); + break; + } + } + } + } + + function removeURLMark(mark) + { + var urlmark = urlMarks.get(mark); + if (urlmark) + { + liberator.log("Deleting URL mark: " + markToString(mark, urlmark), 5); + urlMarks.remove(mark); + } + } + + function isLocalMark(mark) /^[a-z]$/.test(mark); + function isURLMark(mark) /^[A-Z0-9]$/.test(mark); + + function localMarkIter() + { + for (let [mark, value] in localMarks) + for (let [,val] in Iterator(value)) + yield [mark, val]; + } + + function getSortedMarks() + { + // local marks + let location = window.content.location.href; + let lmarks = [i for (i in localMarkIter()) if (i[1].location == location)]; + lmarks.sort(); + + // URL marks + // FIXME: why does umarks.sort() cause a "Component is not available = + // NS_ERROR_NOT_AVAILABLE" exception when used here? + let umarks = [i for (i in urlMarks)]; + umarks.sort(function (a, b) a[0].localeCompare(b[0])); + + return lmarks.concat(umarks); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var myModes = config.browserModes; + + mappings.add(myModes, + ["m"], "Set mark at the cursor position", + function (arg) + { + if (/[^a-zA-Z]/.test(arg)) + { + liberator.beep(); + return; + } + + marks.add(arg); + }, + { flags: Mappings.flags.ARGUMENT }); + + mappings.add(myModes, + ["'", "`"], "Jump to the mark in the current buffer", + function (arg) { marks.jumpTo(arg); }, + { flags: Mappings.flags.ARGUMENT }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["delm[arks]"], + "Delete the specified marks", + function (args) + { + let special = args.bang; + let args = args.string; + + if (!special && !args) + { + liberator.echoerr("E471: Argument required"); + return; + } + if (special && args) + { + liberator.echoerr("E474: Invalid argument"); + return; + } + var matches; + if (matches = args.match(/(?:(?:^|[^a-zA-Z0-9])-|-(?:$|[^a-zA-Z0-9])|[^a-zA-Z0-9 -]).*/)) + { + // NOTE: this currently differs from Vim's behavior which + // deletes any valid marks in the arg list, up to the first + // invalid arg, as well as giving the error message. + liberator.echoerr("E475: Invalid argument: " + matches[0]); + return; + } + // check for illegal ranges - only allow a-z A-Z 0-9 + if (matches = args.match(/[a-zA-Z0-9]-[a-zA-Z0-9]/g)) + { + for (let i = 0; i < matches.length; i++) + { + var start = matches[i][0]; + var end = matches[i][2]; + if (/[a-z]/.test(start) != /[a-z]/.test(end) || + /[A-Z]/.test(start) != /[A-Z]/.test(end) || + /[0-9]/.test(start) != /[0-9]/.test(end) || + start > end) + { + liberator.echoerr("E475: Invalid argument: " + args.match(matches[i] + ".*")[0]); + return; + } + } + } + + marks.remove(args, special); + }, + { bang: true }); + + commands.add(["ma[rk]"], + "Mark current location within the web page", + function (args) + { + var mark = args[0]; + if (mark.length > 1) + { + liberator.echoerr("E488: Trailing characters"); + return; + } + if (!/[a-zA-Z]/.test(mark)) + { + liberator.echoerr("E191: Argument must be a letter or forward/backward quote"); + return; + } + + 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 + if (args && !/[a-zA-Z]/.test(args)) + { + liberator.echoerr("E283: No marks matching \"" + args + "\""); + return; + } + + var filter = args.replace(/[^a-zA-Z]/g, ""); + marks.list(filter); + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + // TODO: add support for frameset pages + add: function (mark) + { + var win = window.content; + + if (win.document.body.localName.toLowerCase() == "frameset") + { + liberator.echoerr("Marks support for frameset pages not implemented yet"); + return; + } + + var x = win.scrollMaxX ? win.pageXOffset / win.scrollMaxX : 0; + var y = win.scrollMaxY ? win.pageYOffset / win.scrollMaxY : 0; + var position = { x: x, y: y }; + + if (isURLMark(mark)) + { + urlMarks.set(mark, { location: win.location.href, position: position, tab: tabs.getTab() }); + 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); + liberator.log("Adding local mark: " + markToString(mark, vals), 5); + } + }, + + remove: function (filter, special) + { + if (special) + { + // :delmarks! only deletes a-z marks + for (let [mark,] in localMarks) + removeLocalMark(mark); + } + else + { + var pattern = new RegExp("[" + filter.replace(/\s+/g, "") + "]"); + for (let [mark,] in urlMarks) + { + if (pattern.test(mark)) + removeURLMark(mark); + } + for (let [mark,] in localMarks) + { + if (pattern.test(mark)) + removeLocalMark(mark); + } + } + }, + + jumpTo: function (mark) + { + var 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; + } + var index = tabs.index(slice.tab); + if (index != -1) + { + tabs.select(index); + var 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); + win.scrollTo(slice.position.x * win.scrollMaxX, slice.position.y * win.scrollMaxY); + 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); + win.scrollTo(lmark.position.x * win.scrollMaxX, lmark.position.y * win.scrollMaxY); + ok = true; + break; + } + } + } + + if (!ok) + liberator.echoerr("E20: Mark not set"); // FIXME: move up? + }, + + list: function (filter) + { + var marks = getSortedMarks(); + + if (marks.length == 0) + { + liberator.echoerr("No marks set"); + return; + } + + if (filter.length > 0) + { + marks = marks.filter(function (mark) filter.indexOf(mark[0]) >= 0); + if (marks.length == 0) + { + liberator.echoerr("E283: No marks matching \"" + filter + "\""); + return; + } + } + + let list = template.tabular(["mark", "line", "col", "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 each (mark in marks))); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + + }; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/buffer.xhtml b/common/content/buffer.xhtml new file mode 100644 index 00000000..089b1563 --- /dev/null +++ b/common/content/buffer.xhtml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" + "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <title/> + </head> + <body/> +</html> diff --git a/common/content/commands.js b/common/content/commands.js new file mode 100644 index 00000000..f5fff23a --- /dev/null +++ b/common/content/commands.js @@ -0,0 +1,892 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +// Do NOT create instances of this class yourself, use the helper method +// commands.add() instead +function Command(specs, description, action, extraInfo) //{{{ +{ + if (!specs || !action) + return null; + + if (!extraInfo) + extraInfo = {}; + + // convert command name abbreviation specs of the form + // 'shortname[optional-tail]' to short and long versions Eg. 'abc[def]' -> + // 'abc', 'abcdef' + function parseSpecs(specs) + { + // Whoever wrote the following should be ashamed. :( + // Good grief! I have no words... -- djk ;-) + // let shortNames = longNames = names = []; + let names = []; + let longNames = []; + let shortNames = []; + + for (let [,spec] in Iterator(specs)) + { + let matches = spec.match(/(\w+)\[(\w+)\]/); + + if (matches) + { + shortNames.push(matches[1]); + longNames.push(matches[1] + matches[2]); + // order as long1, short1, long2, short2 + names.push(matches[1] + matches[2]); + names.push(matches[1]); + } + else + { + longNames.push(spec); + names.push(spec); + } + } + + return { names: names, longNames: longNames, shortNames: shortNames }; + }; + + let expandedSpecs = parseSpecs(specs); + this.specs = specs; + this.shortNames = expandedSpecs.shortNames; + this.longNames = expandedSpecs.longNames; + + // return the primary command name (the long name of the first spec listed) + this.name = this.longNames[0]; + this.names = expandedSpecs.names; // return all command name aliases + this.description = description || ""; + this.action = action; + this.argCount = extraInfo.argCount || 0; + this.completer = extraInfo.completer || null; + this.hereDoc = extraInfo.hereDoc || false; + this.options = extraInfo.options || []; + this.bang = extraInfo.bang || false; + this.count = extraInfo.count || false; + this.literal = extraInfo.literal == null ? null : extraInfo.literal; + this.serial = extraInfo.serial; + + this.isUserCommand = extraInfo.isUserCommand || false; + this.replacementText = extraInfo.replacementText || null; +}; + +Command.prototype = { + + execute: function (args, bang, count, modifiers) + { + // XXX + bang = !!bang; + count = (count === undefined) ? -1 : count; + modifiers = modifiers || {}; + + let self = this; + function exec(args) + { + // FIXME: Move to parseCommand? + args = self.parseArgs(args); + if (!args) + return; + args.count = count; + args.bang = bang; + self.action.call(self, args, bang, count, modifiers); + } + + if (this.hereDoc) + { + let matches = args.match(/(.*)<<\s*(\S+)$/); + if (matches && matches[2]) + { + commandline.inputMultiline(new RegExp("^" + matches[2] + "$", "m"), + function (args) { exec(matches[1] + "\n" + args) }); + return; + } + } + + exec(args); + }, + + hasName: function (name) + { + for (let [,spec] in Iterator(this.specs)) + { + let fullName = spec.replace(/\[(\w+)]$/, "$1"); + let index = spec.indexOf("["); + let min = index == -1 ? fullName.length : index; + + if (fullName.indexOf(name) == 0 && name.length >= min) + return true; + } + + return false; + }, + + parseArgs: function (args, complete) commands.parseArgs(args, this.options, this.argCount, false, this.literal, complete) + +}; //}}} + +function Commands() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var exCommands = []; + + function parseBool(arg) + { + if (arg == "true" || arg == "1" || arg == "on") + return true; + if (arg == "false" || arg == "0" || arg == "off") + return false; + return NaN; + } + + const quoteMap = { + "\n": "n", + "\t": "t" + } + function quote(q, list) + { + let re = RegExp("[" + list + "]", "g"); + return function (str) q + String.replace(str, re, function ($0) $0 in quoteMap ? quoteMap[$0] : ("\\" + $0)) + q; + } + const complQuote = { // FIXME + '"': ['"', quote("", '\n\t"\\\\'), '"'], + "'": ["'", quote("", "\\\\'"), "'"], + "": ["", quote("", "\\\\ "), ""] + }; + const quoteArg = { + '"': quote('"', '\n\t"\\\\'), + "'": quote("'", "\\\\'"), + "": quote("", "\\\\ ") + } + + const ArgType = new Struct("description", "parse"); + const argTypes = [ + null, + ["no arg", function (arg) !arg], + ["boolean", parseBool], + ["string", function (val) val], + ["int", parseInt], + ["float", parseFloat], + ["list", function (arg) arg && arg.split(/\s*,\s*/)] + ].map(function (x) x && ArgType.apply(null, x)); + + function addCommand(command, isUserCommand, replace) + { + if (!command) // XXX + return false; + + if (exCommands.some(function (c) c.hasName(command.name))) + { + if (isUserCommand && replace) + { + commands.removeUserCommand(command.name); + } + else + { + liberator.log("Warning: :" + command.name + " already exists, NOT replacing existing command.", 1); + return false; + } + } + + exCommands.push(command); + + return true; + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + liberator.registerObserver("load_completion", function () + { + completion.setFunctionCompleter(commands.get, [function () ([c.name, c.description] for (c in commands))]); + }); + + var commandManager = { + + // FIXME: remove later, when our option handler is better + OPTION_ANY: 0, // can be given no argument or an argument of any type, + // caller is responsible for parsing the return value + OPTION_NOARG: 1, + OPTION_BOOL: 2, + OPTION_STRING: 3, + OPTION_INT: 4, + OPTION_FLOAT: 5, + OPTION_LIST: 6, + + COUNT_NONE: -1, + COUNT_ALL: -2, // :%... + + __iterator__: function () + { + let sorted = exCommands.sort(function (a, b) a.name > b.name); + return util.Array.iterator(sorted); + }, + + add: function (names, description, action, extra) + { + return addCommand(new Command(names, description, action, extra), false, false); + }, + + addUserCommand: function (names, description, action, extra, replace) + { + extra = extra || {}; + extra.isUserCommand = true; + description = description || "User defined command"; + + return addCommand(new Command(names, description, action, extra), true, replace); + }, + + commandToString: function (args) + { + let res = [args.command + (args.bang ? "!" : "")]; + function quote(str) quoteArg[/\s/.test(str) ? '"' : ""](str); + + for (let [opt, val] in Iterator(args.options || {})) + { + res.push(opt); + if (val != null) + res.push(quote(val)); + } + for (let [,arg] in Iterator(args.arguments || [])) + res.push(quote(arg)); + + let str = args.literalArg; + if (str) + res.push(/\n/.test(str) ? "<<EOF\n" + str + "EOF" : str); + return res.join(" "); + }, + + get: function (name) + { + return exCommands.filter(function (cmd) cmd.hasName(name))[0] || null; + }, + + getUserCommand: function (name) + { + return exCommands.filter(function (cmd) cmd.isUserCommand && cmd.hasName(name))[0] || null; + }, + + getUserCommands: function () + { + return exCommands.filter(function (cmd) cmd.isUserCommand); + }, + + // in '-quoted strings, only ' and \ itself are escaped + // in "-quoted strings, also ", \n and \t are translated + // in non-quoted strings everything is taken literally apart from "\ " and "\\" + // + // @param str: something like "-x=foo -opt=bar arg1 arg2" + // "options" is an array [name, type, validator, completions] and could look like: + // options = [[["-force"], OPTION_NOARG], + // [["-fullscreen", "-f"], OPTION_BOOL], + // [["-language"], OPTION_STRING, validateFunc, ["perl", "ruby"]], + // [["-speed"], OPTION_INT], + // [["-acceleration"], OPTION_FLOAT], + // [["-accessories"], OPTION_LIST, null, ["foo", "bar"]], + // [["-other"], OPTION_ANY]]; + // @param argCount can be: + // "0": no arguments + // "1": exactly one argument + // "+": one or more arguments + // "*": zero or more arguments (default if unspecified) + // "?": zero or one arguments + // @param allowUnknownOptions: -foo won't result in an error, if -foo isn't + // specified in "options" + // TODO: should it handle comments? + parseArgs: function (str, options, argCount, allowUnknownOptions, literal, complete) + { + // returns [count, parsed_argument] + function getNextArg(str) + { + var stringDelimiter = null; + var escapeNext = false; + + var arg = ""; + + outer: + for (let i = 0; i < str.length; i++) + { + inner: + switch (str[i]) + { + case '"': + case "'": + if (escapeNext) + { + escapeNext = false; + break; + } + switch (stringDelimiter) + { + case str[i]: + stringDelimiter = null; + continue outer; + case null: + stringDelimiter = str[i]; + continue outer; + } + break; + + // \ is an escape key for non quoted or "-quoted strings + // for '-quoted strings it is taken literally, apart from \' and \\ + case "\\": + if (escapeNext) + { + escapeNext = false; + break; + } + else + { + // in non-quoted strings, only escape "\\" and "\ ", otherwise drop "\\" + if (!stringDelimiter && str[i + 1] != "\\" && str[i + 1] != " ") + continue outer; + // in single quoted strings, only escape "\\" and "\'", otherwise keep "\\" + if (stringDelimiter == "'" && str[i + 1] != "\\" && str[i + 1] != "'") + break; + escapeNext = true; + continue outer; + } + break; + + default: + if (stringDelimiter == "'") + { + escapeNext = false; + break; + } + if (escapeNext) + { + escapeNext = false; + switch (str[i]) + { + case "n": arg += "\n"; break; + case "t": arg += "\t"; break; + default: + break inner; // this makes "a\fb" -> afb; wanted or should we return ab? --mst + } + continue outer; + } + else if (stringDelimiter != '"' && /\s/.test(str[i])) + { + return [i, arg]; + } + break; + } + arg += str[i]; + } + + // TODO: add parsing of a " comment here: + if (stringDelimiter) + return [str.length, arg, stringDelimiter]; + if (escapeNext) + return [str.length, arg, "\\"]; + else + return [str.length, arg]; + } + + if (!options) + options = []; + + if (!argCount) + argCount = "*"; + + var args = []; // parsed options + args.__iterator__ = util.Array.iterator2; + args.string = str; // for access to the unparsed string + args.literalArg = ""; + + var invalid = false; + // FIXME: best way to specify these requirements? + var onlyArgumentsRemaining = allowUnknownOptions || options.length == 0 || false; // after a -- has been found + var arg = null; + var count = 0; // the length of the argument + var i = 0; + var completeOpts; + + // XXX + function matchOpts(arg) + { + // Push possible option matches into completions + if (complete && !onlyArgumentsRemaining) + completeOpts = [[opt[0], opt[0][0]] for ([i, opt] in Iterator(options)) if (!(opt[0][0] in args))]; + } + function resetCompletions() + { + completeOpts = null; + args.completeArg = null; + args.completeOpt = null; + args.completeFilter = null; + args.completeStart = i; + args.quote = complQuote[""]; + } + if (complete) + { + resetCompletions(); + matchOpts(""); + args.completeArg = 0; + } + + function echoerr(error) + { + if (complete) + complete.message = error; + else + liberator.echoerr(error); + } + + outer: + while (i < str.length || complete) + { + // skip whitespace + while (/\s/.test(str[i]) && i < str.length) + i++; + if (i == str.length && !complete) + break; + + if (complete) + resetCompletions(); + + var sub = str.substr(i); + if ((!onlyArgumentsRemaining) && /^--(\s|$)/.test(sub)) + { + onlyArgumentsRemaining = true; + i += 2; + continue; + } + + var optname = ""; + if (!onlyArgumentsRemaining) + { + for (let [,opt] in Iterator(options)) + { + for (let [,optname] in Iterator(opt[0])) + { + if (sub.indexOf(optname) == 0) + { + invalid = false; + arg = null; + quote = null; + count = 0; + let sep = sub[optname.length]; + if (sep == "=" || /\s/.test(sep) && opt[1] != this.OPTION_NOARG) + { + [count, arg, quote] = getNextArg(sub.substr(optname.length + 1)); + + // if we add the argument to an option after a space, it MUST not be empty + if (sep != "=" && !quote && arg.length == 0) + arg = null; + + count++; // to compensate the "=" character + } + else if (!/\s/.test(sep)) // this isn't really an option as it has trailing characters, parse it as an argument + { + invalid = true; + } + + let context = null; + if (!complete && quote) + { + liberator.echoerr("Invalid argument for option " + optname); + return null; + } + + if (!invalid) + { + if (complete && count > 0) + { + args.completeStart += optname.length + 1; + args.completeOpt = opt; + args.completeFilter = arg; + args.quote = complQuote[quote] || complQuote[""]; + } + let type = argTypes[opt[1]]; + if (type && (!complete || arg != null)) + { + let orig = arg; + arg = type.parse(arg); + if (arg == null || (typeof arg == "number" && isNaN(arg))) + { + if (!complete || orig != "" || args.completeStart != str.length) + echoerr("Invalid argument for " + type.description + " option: " + optname); + if (complete) + complete.highlight(args.completeStart, count - 1, "SPELLCHECK"); + else + return null; + } + } + + // we have a validator function + if (typeof opt[2] == "function") + { + if (opt[2].call(this, arg) == false) + { + echoerr("Invalid argument for option: " + optname); + if (complete) + complete.highlight(args.completeStart, count - 1, "SPELLCHECK"); + else + return null; + } + } + + args[opt[0][0]] = arg; // always use the first name of the option + i += optname.length + count; + if (i == str.length) + break outer; + continue outer; + } + // if it is invalid, just fall through and try the next argument + } + } + } + } + + matchOpts(sub); + + if (complete) + { + if (argCount == "0" || args.length > 0 && (/[1?]/.test(argCount))) + complete.highlight(i, sub.length, "SPELLCHECK") + } + + if (args.length == literal) + { + if (complete) + args.completeArg = args.length; + args.literalArg = sub; + args.push(sub); + args.quote = null; + break; + } + + // if not an option, treat this token as an argument + var [count, arg, quote] = getNextArg(sub); + if (complete) + { + args.quote = complQuote[quote] || complQuote[""]; + args.completeFilter = arg || ""; + } + else if (count == -1) + { + liberator.echoerr("Error parsing arguments: " + arg); + return null; + } + else if (!onlyArgumentsRemaining && /^-/.test(arg)) + { + liberator.echoerr("Invalid option: " + arg); + return null; + } + + if (arg != null) + args.push(arg); + if (complete) + args.completeArg = args.length - 1; + + i += count; + if (count <= 0 || i == str.length) + break; + } + + if (complete) + { + if (args.completeOpt) + { + let opt = args.completeOpt; + let context = complete.fork(opt[0][0], args.completeStart); + context.filter = args.completeFilter; + if (typeof opt[3] == "function") + var compl = opt[3](context, args); + else + compl = opt[3] || []; + context.title = [opt[0][0]]; + context.quote = args.quote; + context.completions = compl; + } + complete.advance(args.completeStart); + complete.title = ["Options"]; + if (completeOpts) + complete.completions = completeOpts; + } + + // check for correct number of arguments + if (args.length == 0 && /^[1+]$/.test(argCount) || + literal != null && /[1+]/.test(argCount) && !/\S/.test(args.literalArg || "")) + { + if (!complete) + { + liberator.echoerr("E471: Argument required"); + return null; + } + } + else if (args.length == 1 && (argCount == "0") || + args.length > 1 && /^[01?]$/.test(argCount)) + { + echoerr("E488: Trailing characters"); + return null; + } + + return args; + }, + + // return [null, null, null, null, heredoc_tag || false]; + // [count, cmd, special, args] = match; + parseCommand: function (str, tag) + { + // remove comments + str.replace(/\s*".*$/, ""); + + if (tag) // we already have a multiline heredoc construct + { + if (str == tag) + return [null, null, null, null, false]; + else + return [null, null, null, str, tag]; + } + + // 0 - count, 1 - cmd, 2 - special, 3 - args, 4 - heredoc tag + let matches = str.match(/^:*(\d+|%)?([a-zA-Z]+|!)(!)?(?:\s*(.*?))?$/); + //var matches = str.match(/^:*(\d+|%)?([a-zA-Z]+|!)(!)?(?:\s*(.*?)\s*)?$/); + if (!matches) + return [null, null, null, null, null]; + let [, count, cmd, special, args, heredoc] = matches; + + // parse count + if (count) + count = count == "%" ? this.COUNT_ALL: parseInt(count, 10); + else + count = this.COUNT_NONE; + + if (args) + { + tag = args.match(/<<\s*(\w+)\s*$/); + if (tag && tag[1]) + heredoc = tag[1]; + } + + return [count, cmd, !!special, args || "", heredoc]; + }, + + get quoteArg() quoteArg, + + removeUserCommand: function (name) + { + exCommands = exCommands.filter(function (cmd) !(cmd.isUserCommand && cmd.hasName(name))); + }, + + // FIXME: still belong here? Also used for autocommand parameters + replaceTokens: function replaceTokens(str, tokens) + { + return str.replace(/<((?:q-)?)([a-zA-Z]+)?>/g, function (match, quote, token) + { + if (token == "lt") // Don't quote, as in vim (but, why so in vim? You'd think people wouldn't say <q-lt> if they didn't want it) + return "<"; + let res = tokens[token]; + if (res == undefined) // Ignore anything undefined + res = "<" + token + ">"; + if (quote && typeof res != "number") + return quoteArg['"'](res); + return res; + }); + } + }; + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + function userCommand(args, modifiers) + { + let tokens = { + args: this.argCount && args.string, + bang: this.bang && args.bang ? "!" : "", + count: this.count && args.count + }; + + liberator.execute(commands.replaceTokens(this.replacementText, tokens)); + } + + // TODO: offer completion.ex? + var completeOptionMap = { + altstyle: "alternateStylesheet", bookmark: "bookmark", + buffer: "buffer", color: "colorScheme", command: "command", + dialog: "dialog", dir: "directory", environment: "environment", + event: "autocmdEvent", file: "file", help: "help", + highlight: "highlightGroup", javascript: "javascript", macro: "macro", + mapping: "userMapping", menu: "menuItem", option: "option", + preference: "preference", search: "search", + shellcmd: "shellCommand", sidebar: "sidebar", url: "url", + usercommand: "userCommand" + }; + + // TODO: Vim allows commands to be defined without {rep} if there are {attr}s + // specified - useful? + commandManager.add(["com[mand]"], + "List and define commands", + function (args) + { + let cmd = args[0]; + + if (cmd != null && /\W/.test(cmd)) + { + liberator.echoerr("E182: Invalid command name"); + return; + } + + if (args.literalArg) + { + let nargsOpt = args["-nargs"] || "0"; + let bangOpt = "-bang" in args; + let countOpt = "-count" in args; + let completeOpt = args["-complete"]; + + let completeFunc = null; // default to no completion for user commands + + if (completeOpt) + { + let func; + + if (/^custom,/.test(completeOpt)) + func = completeOpt.replace("custom,", ""); + else + func = "completion." + completeOptionMap[completeOpt]; + + completeFunc = eval(func); + } + + if (!commands.addUserCommand( + [cmd], + "User defined command", + userCommand, + { + argCount: nargsOpt, + bang: bangOpt, + count: countOpt, + completer: completeFunc, + replacementText: args.literalArg + }, + args.bang) + ) + { + liberator.echoerr("E174: Command already exists: add ! to replace it"); + } + } + else + { + function completerToString(completer) + { + if (completer) + return [k for ([k, v] in Iterator(completeOptionMap)) + if (v == completer.name)][0] || "custom"; + else + return ""; + } + + // TODO: using an array comprehension here generates flakey results across repeated calls + // : perhaps we shouldn't allow options in a list call but just ignore them for now + let cmds = exCommands.filter(function (c) c.isUserCommand && (!cmd || c.name.match("^" + cmd))); + + if (cmds.length > 0) + { + let str = template.tabular(["", "Name", "Args", "Range", "Complete", "Definition"], ["padding-right: 2em;"], + ([cmd.bang ? "!" : " ", + cmd.name, + cmd.argCount, + cmd.count ? "0c" : "", + completerToString(cmd.completer), + cmd.replacementText || "function () { ... }"] + for each (cmd in cmds))); + + commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + else + { + liberator.echo("No user-defined commands found"); + } + } + }, + { + bang: true, + completer: function (context) completion.userCommand(context), + options: [ + [["-nargs"], commandManager.OPTION_STRING, + function (arg) /^[01*?+]$/.test(arg), ["0", "1", "*", "?", "+"]], + [["-bang"], commandManager.OPTION_NOARG], + [["-count"], commandManager.OPTION_NOARG], + [["-complete"], commandManager.OPTION_STRING, + function (arg) arg in completeOptionMap || /custom,\w+/.test(arg)] + ], + literal: 1, + serial: function () [ + { + command: this.name, + bang: true, + // Yeah, this is a bit scary. Perhaps I'll fix it when I'm + // awake. + options: util.Array.assocToObj( + util.map({ argCount: "-nargs", bang: "-bang", count: "-count" }, + function ([k, v]) k in cmd && cmd[k] != "0" && [v, typeof cmd[k] == "boolean" ? null : cmd[k]]) + .filter(util.identity)), + arguments: [cmd.name], + literalArg: cmd.replacementText + } + for ([k, cmd] in Iterator(exCommands)) + if (cmd.isUserCommand && cmd.replacementText) + ] + }); + + commandManager.add(["comc[lear]"], + "Delete all user-defined commands", + function () + { + commands.getUserCommands().forEach(function (cmd) { commands.removeUserCommand(cmd.name); }); + }, + { argCount: "0" }); + + commandManager.add(["delc[ommand]"], + "Delete the specified user-defined command", + function (args) + { + let name = args[0]; + + if (commands.get(name)) + commands.removeUserCommand(name); + else + liberator.echoerr("E184: No such user-defined command: " + name); + }, + { + argCount: "1", + completer: function (context) completion.userCommand(context) + }); + + //}}} + + return commandManager; + +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/completion.js b/common/content/completion.js new file mode 100644 index 00000000..33387b3f --- /dev/null +++ b/common/content/completion.js @@ -0,0 +1,1704 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +function CompletionContext(editor, name, offset) +{ + if (!(this instanceof arguments.callee)) + return new arguments.callee(editor, name, offset); + if (!name) + name = ""; + + let self = this; + if (editor instanceof arguments.callee) + { + let parent = editor; + name = parent.name + "/" + name; + this.contexts = parent.contexts; + if (name in this.contexts) + self = this.contexts[name]; + else + self.contexts[name] = this; + + self.parent = parent; + ["filters", "keys", "title", "quote"].forEach(function (key) + self[key] = parent[key] && util.cloneObject(parent[key])); + ["anchored", "compare", "editor", "_filter", "filterFunc", "keys", "_process", "top"].forEach(function (key) + self[key] = parent[key]); + + self.__defineGetter__("value", function () this.top.value); + + self.offset = parent.offset; + self.advance(offset); + + self.incomplete = false; + self.message = null; + self.waitingForTab = false; + //delete self._filter; // FIXME? + delete self._generate; + delete self._ignoreCase; + if (self != this) + return self; + ["_caret", "contextList", "maxItems", "onUpdate", "selectionTypes", "tabPressed", "updateAsync", "value"].forEach(function (key) { + self.__defineGetter__(key, function () this.top[key]); + self.__defineSetter__(key, function (val) this.top[key] = val); + }); + } + else + { + if (typeof editor == "string") + this._value = editor; + else + this.editor = editor; + this.compare = function (a, b) String.localeCompare(a.text, b.text); + + this.filterFunc = function (items) + { + let self = this; + return this.filters. + reduce(function (res, filter) res.filter(function (item) filter.call(self, item)), + items); + } + this.filters = [function (item) { + let text = Array.concat(this.getKey(item, "text")); + for (let [i, str] in Iterator(text)) + { + if (this.match(String(str))) + { + item.text = String(text[i]); + return true; + } + } + return false; + }]; + this.contexts = { name: this }; + this.keys = { text: 0, description: 1, icon: "icon" }; + this.offset = offset || 0; + this.onUpdate = function () true; + this.top = this; + this.__defineGetter__("incomplete", function () this.contextList.some(function (c) c.parent && c.incomplete)); + this.__defineGetter__("waitingForTab", function () this.contextList.some(function (c) c.parent && c.waitingForTab)); + this.reset(); + } + this.cache = {}; + this.itemCache = {}; + this.key = ""; + this.message = null; + this.name = name || ""; + this._completions = []; // FIXME + this.getKey = function (item, key) (typeof self.keys[key] == "function") ? self.keys[key].call(this, item.item) : + key in self.keys ? item.item[self.keys[key]] + : item.item[key]; +} +CompletionContext.prototype = { + // Temporary + get allItems() + { + try + { + let self = this; + let minStart = Math.min.apply(Math, [context.offset for ([k, context] in Iterator(this.contexts)) if (context.items.length && context.hasItems)]); + let items = this.contextList.map(function (context) { + if (!context.hasItems) + return []; + let prefix = self.value.substring(minStart, context.offset); + return context.items.map(function makeItem(item) ({ text: prefix + item.text, item: item.item })); + }); + return { start: minStart, items: util.Array.flatten(items), longestSubstring: this.longestAllSubstring } + } + catch (e) + { + liberator.reportError(e); + return { start: 0, items: [], longestAllSubstring: "" } + } + }, + // Temporary + get allSubstrings() + { + let contexts = this.contextList.filter(function (c) c.hasItems && c.items.length); + let minStart = Math.min.apply(Math, contexts.map(function (c) c.offset)); + let lists = contexts.map(function (context) { + let prefix = context.value.substring(minStart, context.offset); + return context.substrings.map(function (s) prefix + s); + }); + + let substrings = lists.reduce( + function (res, list) res.filter(function (str) list.some(function (s) s.substr(0, str.length) == str)), + lists.pop()); + if (!substrings) // FIXME: How is this undefined? + return []; + return util.Array.uniq(substrings); + }, + // Temporary + get longestAllSubstring() + { + return this.allSubstrings.reduce(function (a, b) a.length > b.length ? a : b, ""); + }, + + get caret() this._caret - this.offset, + set caret(val) this._caret = val + this.offset, + + get compare() this._compare || function () 0, + set compare(val) this._compare = val, + + get completions() this._completions || [], + set completions(items) + { + // Accept a generator + if (!(items instanceof Array)) + items = [x for (x in Iterator(items))]; + delete this.cache.filtered; + delete this.cache.filter; + this.cache.rows = []; + this.hasItems = items.length > 0; + this._completions = items; + let self = this; + if (this.updateAsync && !this.noUpdate) + liberator.callInMainThread(function () { self.onUpdate.call(self) }); + }, + + get createRow() this._createRow || template.completionRow, // XXX + set createRow(createRow) this._createRow = createRow, + + get filterFunc() this._filterFunc || util.identity, + set filterFunc(val) this._filterFunc = val, + + get filter() this._filter != null ? this._filter : this.value.substr(this.offset, this.caret), + set filter(val) + { + delete this._ignoreCase; + return this._filter = val + }, + + get format() ({ + title: this.title, + keys: this.keys, + process: this.process + }), + set format(format) + { + this.title = format.title || this.title; + this.keys = format.keys || this.keys; + this.process = format.process || this.process; + }, + + get message() this._message || (this.waitingForTab ? "Waiting for <Tab>" : null), + set message(val) this._message = val, + + get regenerate() this._generate && (!this.completions || !this.itemCache[this.key] || this.cache.offset != this.offset), + set regenerate(val) { if (val) delete this.itemCache[this.key] }, + + get generate() !this._generate ? null : function () + { + if (this.offset != this.cache.offset) + this.itemCache = {}; + this.cache.offset = this.offset; + if (!this.itemCache[this.key]) + this.itemCache[this.key] = this._generate.call(this); + return this.itemCache[this.key]; + }, + set generate(arg) + { + this.hasItems = true; + this._generate = arg; + //**/ liberator.dump(this.name + ": set generate()"); + if (this.background && this.regenerate) + { + //**/ this.__i = (this.__i || 0) + 1; + //**/ let self = this; + //**/ function dump(msg) liberator.callInMainThread(function () liberator.dump(self.name + ":" + self.__i + ": " + msg)); + //**/ dump("set generate() regenerating"); + + let lock = {}; + this.cache.backgroundLock = lock; + this.incomplete = true; + let thread = this.getCache("backgroundThread", liberator.newThread); + //**/ dump(thread); + liberator.callAsync(thread, this, function () { + //**/ dump("In async"); + if (this.cache.backgroundLock != lock) + { + //**/ dump("Lock !ok"); + return; + } + let items = this.generate(); + //**/ dump("Generated"); + if (this.cache.backgroundLock != lock) + { + //**/ dump("Lock !ok"); + return; + } + this.incomplete = false; + //**/ dump("completions="); + this.completions = items; + //**/ dump("completions=="); + }); + } + }, + + get ignoreCase() + { + if ("_ignoreCase" in this) + return this._ignoreCase; + let mode = options["wildcase"]; + if (mode == "match") + return this._ignoreCase = false; + if (mode == "ignore") + return this._ignoreCase = true; + return this._ignoreCase = !/[A-Z]/.test(this.filter); + }, + set ignoreCase(val) this._ignoreCase = val, + + get items() + { + if (!this.hasItems || this.backgroundLock) + return []; + if (this.cache.filtered && this.cache.filter == this.filter) + return this.cache.filtered; + this.cache.rows = []; + let items = this.completions; + if (this.generate && !this.background) + { + // XXX + this.noUpdate = true; + this.completions = items = this.generate(); + this.noUpdate = false; + } + this.cache.filter = this.filter; + if (items == null) + return items; + + let self = this; + delete this._substrings; + + let filtered = this.filterFunc(items.map(function (item) ({ text: self.getKey({ item: item }, "text"), item: item }))); + if (this.maxItems) + filtered = filtered.slice(0, this.maxItems); + + if (options.get("wildoptions").has("sort") && this.compare) + filtered.sort(this.compare); + let quote = this.quote; + if (quote) + filtered.forEach(function (item) { + item.unquoted = item.text; + item.text = quote[0] + quote[1](item.text) + quote[2]; + }) + return this.cache.filtered = filtered; + }, + + get process() // FIXME + { + let self = this; + let process = this._process; + process = [process[0] || template.icon, process[1] || function (item, k) k]; + let first = process[0]; + let filter = this.filter; + if (!this.anchored) + process[0] = function (item, text) first.call(self, item, template.highlightFilter(item.text, filter)); + return process; + }, + set process(process) + { + this._process = process; + }, + + get substrings() + { + let items = this.items; + if (items.length == 0 || !this.hasItems) + return []; + if (this._substrings) + return this._substrings; + + let fixCase = this.ignoreCase ? String.toLowerCase : util.identity; + let text = fixCase(items[0].unquoted || items[0].text); + let filter = fixCase(this.filter); + if (this.anchored) + { + function compare (text, s) text.substr(0, s.length) == s; + substrings = util.map(util.range(filter.length, text.length + 1), + function (end) text.substring(0, end)); + } + else + { + function compare (text, s) text.indexOf(s) >= 0; + substrings = []; + let start = 0; + let idx; + let length = filter.length; + while ((idx = text.indexOf(filter, start)) > -1 && idx < text.length) + { + for (let end in util.range(idx + length, text.length + 1)) + substrings.push(text.substring(idx, end)); + start = idx + 1; + } + } + substrings = items.reduce( + function (res, item) res.filter(function (str) compare(fixCase(item.unquoted || item.text), str)), + substrings); + let quote = this.quote; + if (quote) + substrings = substrings.map(function (str) quote[0] + quote[1](str)); + return this._substrings = substrings; + }, + + advance: function advance(count) + { + delete this._ignoreCase; + if (this.quote) + { + count = this.quote[0].length + this.quote[1](this.filter.substr(0, count)).length; + this.quote[0] = ""; + this.quote[2] = ""; + } + this.offset += count; + if (this._filter) + this._filter = this._filter.substr(count); + }, + + getCache: function (key, defVal) + { + if (!(key in this.cache)) + this.cache[key] = defVal(); + return this.cache[key]; + }, + + getItems: function getItems(start, end) + { + let self = this; + let items = this.items; + let reverse = start > end; + start = Math.max(0, start || 0); + end = Math.min(items.length, end ? end : items.length); + return util.map(util.range(start, end, reverse), function (i) items[i]); + }, + + getRows: function getRows(start, end, doc) + { + let self = this; + let items = this.items; + let cache = this.cache.rows; + let reverse = start > end; + start = Math.max(0, start || 0); + end = Math.min(items.length, end != null ? end : items.length); + for (let i in util.range(start, end, reverse)) + yield [i, cache[i] = cache[i] || util.xmlToDom(self.createRow(items[i]), doc)]; + }, + + fork: function fork(name, offset, self, completer) + { + if (typeof completer == "string") + completer = self[completer] + let context = new CompletionContext(this, name, offset); + this.contextList.push(context); + if (completer) + return completer.apply(self || this, [context].concat(Array.slice(arguments, 4))); + return context; + }, + + getText: function getText(item) + { + let text = item[self.keys["text"]]; + if (self.quote) + return self.quote(text); + return text; + }, + + highlight: function highlight(start, length, type) + { + try // Firefox <3.1 doesn't have repaintSelection + { + this.selectionTypes[type] = null; + const selType = Components.interfaces.nsISelectionController["SELECTION_" + type]; + const editor = this.editor; + let sel = editor.selectionController.getSelection(selType); + if (length == 0) + sel.removeAllRanges(); + else + { + let range = editor.selection.getRangeAt(0).cloneRange(); + range.setStart(range.startContainer, this.offset + start); + range.setEnd(range.startContainer, this.offset + start + length); + sel.addRange(range); + } + editor.selectionController.repaintSelection(selType); + } + catch (e) {} + }, + + match: function match(str) + { + let filter = this.filter; + if (this.ignoreCase) + { + filter = filter.toLowerCase(); + str = str.toLowerCase(); + } + if (this.anchored) + return str.substr(0, filter.length) == filter; + return str.indexOf(filter) > -1; + }, + + reset: function reset() + { + let self = this; + if (this.parent) + throw Error(); + // Not ideal. + for (let type in this.selectionTypes) + this.highlight(0, 0, type); + this.contextList = []; + this.offset = 0; + this.process = []; + this.selectionTypes = {}; + this.tabPressed = false; + this.title = ["Completions"]; + this.waitingForTab = false; + this.updateAsync = false; + if (this.editor) + { + this.value = this.editor.selection.focusNode.textContent; + this._caret = this.editor.selection.focusOffset; + } + else + { + this.value = this._value; + this._caret = this.value.length; + } + //for (let key in (k for ([k, v] in Iterator(self.contexts)) if (v.offset > this.caret))) + // delete this.contexts[key]; + for each (let context in this.contexts) + { + context.hasItems = false; + context.incomplete = false; + } + }, + + wait: function wait(interruptable, timeout) + { + let end = Date.now() + timeout; + while (this.incomplete && (!timeout || Date.now() > end)) + liberator.threadYield(false, interruptable); + return this.incomplete; + } +} + +function Completion() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + try + { + var completionService = Components.classes["@mozilla.org/browser/global-history;2"] + .getService(Components.interfaces.nsIAutoCompleteSearch); + } + catch (e) {} + + const EVAL_TMP = "__liberator_eval_tmp"; + + function Javascript() + { + let json = Components.classes["@mozilla.org/dom/json;1"] + .createInstance(Components.interfaces.nsIJSON); + const OFFSET = 0, CHAR = 1, STATEMENTS = 2, DOTS = 3, FULL_STATEMENTS = 4, COMMA = 5, FUNCTIONS = 6; + let stack = []; + let functions = []; + let top = []; /* The element on the top of the stack. */ + let last = ""; /* The last opening char pushed onto the stack. */ + let lastNonwhite = ""; /* Last non-whitespace character we saw. */ + let lastChar = ""; /* Last character we saw, used for \ escaping quotes. */ + let compl = []; + let str = ""; + + let lastIdx = 0; + + let cacheKey = null; + + this.completers = {}; + + this.iter = function iter(obj) + { + let iterator = (function objIter() + { + for (let k in obj) + { + // Some object members are only accessible as function calls + try + { + yield [k, obj[k]]; + continue; + } + catch (e) {} + yield [k, <>inaccessable</>] + } + })(); + try + { + // The point of 'for k in obj' is to get keys + // that are accessible via . or [] notation. + // Iterators quite often return values of no + // use whatsoever for this purpose, so, we try + // this rather dirty hack of getting a standard + // object iterator for any object that defines its + // own. + if ("__iterator__" in obj) + { + let oldIter = obj.__iterator__; + delete obj.__iterator__; + iterator = Iterator(obj); + obj.__iterator__ = oldIter; + } + } + catch (e) {} + return iterator; + } + + /* Search the object for strings starting with @key. + * If @last is defined, key is a quoted string, it's + * wrapped in @last after @offset characters are sliced + * off of it and it's quoted. + */ + this.objectKeys = function objectKeys(obj) + { + // Things we can dereference + if (["object", "string", "function"].indexOf(typeof obj) == -1) + return []; + if (!obj) + return []; + + // XPCNativeWrappers, etc, don't show all accessible + // members until they're accessed, so, we look at + // the wrappedJSObject instead, and return any keys + // available in the object itself. + let orig = obj; + + // v[0] in orig and orig[v[0]] catch different cases. XPCOM + // objects are problematic, to say the least. + if (modules.isPrototypeOf(obj)) + compl = [v for (v in Iterator(obj))]; + else + { + if (obj.wrappedJSObject) + obj = obj.wrappedJSObject; + compl = [v for (v in this.iter(obj)) + if ((typeof orig == "object" && v[0] in orig) || orig[v[0]] !== undefined)]; + } + + // And if wrappedJSObject happens to be available, + // return that, too. + if (orig.wrappedJSObject) + compl.push(["wrappedJSObject", obj]); + + // Add keys for sorting later. + // Numbers are parsed to ints. + // Constants, which should be unsorted, are found and marked null. + compl.forEach(function (item) { + let key = item[0]; + if (!isNaN(key)) + key = parseInt(key); + else if (/^[A-Z_]+$/.test(key)) + key = ""; + item.key = key; + }); + + return compl; + } + + this.eval = function eval(arg, key, tmp) + { + let cache = this.context.cache.eval; + let context = this.context.cache.evalContext; + + if (!key) + key = arg; + if (key in cache) + return cache[key]; + + context[EVAL_TMP] = tmp; + try + { + return cache[key] = liberator.eval(arg, context); + } + catch (e) + { + return null; + } + finally + { + delete context[EVAL_TMP]; + } + } + + /* Get an element from the stack. If @n is negative, + * count from the top of the stack, otherwise, the bottom. + * If @m is provided, return the @mth value of element @o + * of the stack entey at @n. + */ + let get = function get(n, m, o) + { + let a = stack[n >= 0 ? n : stack.length + n]; + if (o != null) + a = a[o]; + if (m == null) + return a; + return a[a.length - m - 1]; + } + + function buildStack(filter) + { + let self = this; + /* Push and pop the stack, maintaining references to 'top' and 'last'. */ + let push = function push(arg) + { + top = [i, arg, [i], [], [], [], []]; + last = top[CHAR]; + stack.push(top); + } + let pop = function pop(arg) + { + if (top[CHAR] != arg) + { + self.context.highlight(top[OFFSET], i - top[OFFSET], "SPELLCHECK"); + self.context.highlight(top[OFFSET], 1, "FIND"); + throw new Error("Invalid JS"); + } + if (i == self.context.caret - 1) + self.context.highlight(top[OFFSET], 1, "FIND"); + // The closing character of this stack frame will have pushed a new + // statement, leaving us with an empty statement. This doesn't matter, + // now, as we simply throw away the frame when we pop it, but it may later. + if (top[STATEMENTS][top[STATEMENTS].length - 1] == i) + top[STATEMENTS].pop(); + top = get(-2); + last = top[CHAR]; + let ret = stack.pop(); + return ret; + } + + let i = 0, c = ""; /* Current index and character, respectively. */ + + // Reuse the old stack. + if (str && filter.substr(0, str.length) == str) + { + i = str.length; + if (this.popStatement) + top[STATEMENTS].pop(); + } + else + { + stack = []; + functions = []; + push("#root"); + } + + /* Build a parse stack, discarding entries as opening characters + * match closing characters. The stack is walked from the top entry + * and down as many levels as it takes us to figure out what it is + * that we're completing. + */ + str = filter; + let length = str.length; + for (; i < length; lastChar = c, i++) + { + c = str[i]; + if (last == '"' || last == "'" || last == "/") + { + if (lastChar == "\\") // Escape. Skip the next char, whatever it may be. + { + c = ""; + i++; + } + else if (c == last) + pop(c); + } + else + { + // A word character following a non-word character, or simply a non-word + // character. Start a new statement. + if (/[\w$]/.test(c) && !/[\w\d$]/.test(lastChar) || !/[\w\d\s$]/.test(c)) + top[STATEMENTS].push(i); + + // A "." or a "[" dereferences the last "statement" and effectively + // joins it to this logical statement. + if ((c == "." || c == "[") && /[\w\d$\])"']/.test(lastNonwhite) + || lastNonwhite == "." && /[\w$]/.test(c)) + top[STATEMENTS].pop(); + + switch (c) + { + case "(": + /* Function call, or if/while/for/... */ + if (/[\w\d$]/.test(lastNonwhite)) + { + functions.push(i); + top[FUNCTIONS].push(i); + top[STATEMENTS].pop(); + } + case '"': + case "'": + case "/": + case "{": + push(c); + break; + case "[": + push(c); + break; + case ".": + top[DOTS].push(i); + break; + case ")": pop("("); break; + case "]": pop("["); break; + case "}": pop("{"); /* Fallthrough */ + case ";": + top[FULL_STATEMENTS].push(i); + case ",": + top[COMMA]; + break; + } + + if (/\S/.test(c)) + lastNonwhite = c; + } + } + + this.popStatement = false; + if (!/[\w\d$]/.test(lastChar) && lastNonwhite != ".") + { + this.popStatement = true; + top[STATEMENTS].push(i); + } + + lastIdx = i; + } + + this.complete = function _complete(context) + { + this.context = context; + + let self = this; + try + { + buildStack.call(this, context.filter); + } + catch (e) + { + if (e.message != "Invalid JS") + liberator.reportError(e); + lastIdx = 0; + return; + } + + let cache = this.context.cache; + this.context.getCache("eval", Object); + this.context.getCache("evalContext", function () ({ __proto__: userContext })); + + /* Okay, have parse stack. Figure out what we're completing. */ + + // Find any complete statements that we can eval before we eval our object. + // This allows for things like: let doc = window.content.document; let elem = doc.createElement...; elem.<Tab> + let prev = 0; + for (let [,v] in Iterator(get(0)[FULL_STATEMENTS])) + { + let key = str.substring(prev, v + 1); + if (checkFunction(prev, v, key)) + return; + this.eval(key); + prev = v + 1; + } + + // Don't eval any function calls unless the user presses tab. + function checkFunction(start, end, key) + { + let res = functions.some(function (idx) idx >= start && idx < end); + if (!res || self.context.tabPressed || key in cache.eval) + return false; + self.context.waitingForTab = true; + return true; + } + + // For each DOT in a statement, prefix it with TMP, eval it, + // and save the result back to TMP. The point of this is to + // cache the entire path through an object chain, mainly in + // the presence of function calls. There are drawbacks. For + // instance, if the value of a variable changes in the course + // of inputting a command (let foo=bar; frob(foo); foo=foo.bar; ...), + // we'll still use the old value. But, it's worth it. + function getObj(frame, stop) + { + let statement = get(frame, 0, STATEMENTS) || 0; // Current statement. + let prev = statement; + let obj; + let cacheKey; + for (let [i, dot] in Iterator(get(frame)[DOTS].concat(stop))) + { + if (dot < statement) + continue; + if (dot > stop || dot <= prev) + break; + let s = str.substring(prev, dot); + + if (prev != statement) + s = EVAL_TMP + "." + s; + cacheKey = str.substring(statement, dot); + + if (checkFunction(prev, dot, cacheKey)) + return []; + + prev = dot + 1; + obj = self.eval(s, cacheKey, obj); + } + return [[obj, cacheKey]] + } + + function getObjKey(frame) + { + let dot = get(frame, 0, DOTS) || -1; // Last dot in frame. + let statement = get(frame, 0, STATEMENTS) || 0; // Current statement. + let end = (frame == -1 ? lastIdx : get(frame + 1)[OFFSET]); + + cacheKey = null; + let obj = [[cache.evalContext, "Local Variables"], [userContext, "Global Variables"], + [modules, "modules"], [window, "window"]]; // Default objects; + /* Is this an object dereference? */ + if (dot < statement) // No. + dot = statement - 1; + else // Yes. Set the object to the string before the dot. + obj = getObj(frame, dot); + + let [, space, key] = str.substring(dot + 1, end).match(/^(\s*)(.*)/); + return [dot + 1 + space.length, obj, key]; + } + + function fill(context, obj, name, compl, anchored, key, last, offset) + { + context.title = [name]; + context.anchored = anchored; + context.filter = key; + context.itemCache = context.parent.itemCache; + context.key = name; + + if (last != null) + context.quote = [last, function (text) util.escapeString(text.substr(offset), ""), last]; + else // We're not looking for a quoted string, so filter out anything that's not a valid identifier + context.filters.push(function (item) /^[\w$][\w\d$]*$/.test(item.text)); + + compl.call(self, context, obj); + } + + function complete(objects, key, compl, string, last) + { + let orig = compl; + if (!compl) + { + compl = function (context, obj) + { + context.process = [null, function highlight(item, v) template.highlight(v, true)]; + // Sort in a logical fasion for object keys: + // Numbers are sorted as numbers, rather than strings, and appear first. + // Constants are unsorted, and appear before other non-null strings. + // Other strings are sorted in the default manner. + let compare = context.compare; + context.compare = function (a, b) + { + if (!isNaN(a.item.key) && !isNaN(b.item.key)) + return a.item.key - b.item.key; + return isNaN(b.item.key) - isNaN(a.item.key) || compare(a, b); + } + if (!context.anchored) // We've already listed anchored matches, so don't list them again here. + context.filters.push(function (item) util.compareIgnoreCase(item.text.substr(0, this.filter.length), this.filter)); + if (obj == cache.evalContext) + context.regenerate = true; + context.generate = function () self.objectKeys(obj); + } + } + // TODO: Make this a generic completion helper function. + let filter = key + (string || ""); + for (let [,obj] in Iterator(objects)) + { + this.context.fork(obj[1], top[OFFSET], this, fill, + obj[0], obj[1], compl, compl != orig, filter, last, key.length); + } + if (orig) + return; + for (let [,obj] in Iterator(objects)) + { + obj[1] += " (substrings)"; + this.context.fork(obj[1], top[OFFSET], this, fill, + obj[0], obj[1], compl, false, filter, last, key.length); + } + } + + // In a string. Check if we're dereferencing an object. + // Otherwise, do nothing. + if (last == "'" || last == '"') + { + /* + * str = "foo[bar + 'baz" + * obj = "foo" + * key = "bar + ''" + */ + + // The top of the stack is the sting we're completing. + // Wrap it in its delimiters and eval it to process escape sequences. + let string = str.substring(get(-1)[OFFSET] + 1, lastIdx); + string = eval(last + string + last); + + function getKey() + { + if (last == "") + return ""; + // After the opening [ upto the opening ", plus '' to take care of any operators before it + let key = str.substring(get(-2, 0, STATEMENTS), get(-1, null, OFFSET)) + "''"; + // Now eval the key, to process any referenced variables. + return this.eval(key); + } + + /* Is this an object accessor? */ + if (get(-2)[CHAR] == "[") // Are we inside of []? + { + /* Stack: + * [-1]: "... + * [-2]: [... + * [-3]: base statement + */ + + // Yes. If the [ starts at the begining of a logical + // statement, we're in an array literal, and we're done. + if (get(-3, 0, STATEMENTS) == get(-2)[OFFSET]) + return; + + // Begining of the statement upto the opening [ + let obj = getObj(-3, get(-2)[OFFSET]); + + return complete.call(this, obj, getKey(), null, string, last); + } + + // Is this a function call? + if (get(-2)[CHAR] == "(") + { + /* Stack: + * [-1]: "... + * [-2]: (... + * [-3]: base statement + */ + + // Does the opening "(" mark a function call? + if (get(-3, 0, FUNCTIONS) != get(-2)[OFFSET]) + return; // No. We're done. + + let [offset, obj, func] = getObjKey(-3); + if (!obj.length) + return; + + try + { + var completer = obj[0][0][func].liberatorCompleter; + } + catch (e) {} + if (!completer) + completer = this.completers[func]; + if (!completer) + return; + + // Split up the arguments + let prev = get(-2)[OFFSET]; + let args = []; + for (let [i, idx] in Iterator(get(-2)[COMMA])) + { + let arg = str.substring(prev + 1, idx); + prev = idx; + args.__defineGetter__(i, function () self.eval(ret)); + } + let key = getKey(); + args.push(key + string); + + compl = function (context, obj) + { + let res = completer.call(self, context, func, obj, args); + if (res) + context.completions = res; + } + + obj[0][1] += "." + func + "(... [" + args.length + "]"; + return complete.call(this, obj, key, compl, string, last); + } + + // In a string that's not an obj key or a function arg. + // Nothing to do. + return; + } + + /* + * str = "foo.bar.baz" + * obj = "foo.bar" + * key = "baz" + * + * str = "foo" + * obj = [modules, window] + * key = "foo" + */ + + let [offset, obj, key] = getObjKey(-1); + + // Wait for a keypress before completing the default objects. + if (!this.context.tabPressed && key == "" && obj.length > 1) + { + this.context.waitingForTab = true; + this.context.message = "Waiting for key press"; + return; + } + + if (!/^(?:\w[\w\d]*)?$/.test(key)) + return; /* Not a word. Forget it. Can this even happen? */ + + top[OFFSET] = offset; + return complete.call(this, obj, key); + } + }; + let javascript = new Javascript(); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + let self = { + + setFunctionCompleter: function setFunctionCompleter(funcs, completers) + { + funcs = Array.concat(funcs); + for (let [,func] in Iterator(funcs)) + { + func.liberatorCompleter = function liberatorCompleter(context, func, obj, args) { + let completer = completers[args.length - 1]; + if (!completer) + return []; + return completer.call(this, context, obj, args); + }; + } + }, + + // FIXME + _runCompleter: function _runCompleter(name, filter, maxItems) + { + let context = CompletionContext(filter); + context.maxItems = maxItems; + let res = context.fork.apply(context, ["run", 0, this, name].concat(Array.slice(arguments, 3))); + if (res) // FIXME + return { items: res.map(function (i) ({ item: i })) }; + context.wait(true); + return context.allItems; + }, + + runCompleter: function runCompleter(name, filter, maxItems) + { + return this._runCompleter.apply(this, Array.slice(arguments)) + .items.map(function (i) i.item); + }, + + // cancel any ongoing search + cancel: function cancel() + { + if (completionService) + completionService.stopSearch(); + }, + + // generic helper function which checks if the given "items" array pass "filter" + // items must be an array of strings + match: function match(items, filter, caseSensitive) + { + if (typeof filter != "string" || !items) + return false; + + var itemsStr = items.join(" "); + if (!caseSensitive) + { + filter = filter.toLowerCase(); + itemsStr = itemsStr.toLowerCase(); + } + + return filter.split(/\s+/).every(function strIndex(str) itemsStr.indexOf(str) > -1); + }, + + listCompleter: function listCompleter(name, filter, maxItems) + { + let context = CompletionContext(filter || ""); + context.maxItems = maxItems; + context.fork.apply(context, ["list", 0, completion, name].concat(Array.slice(arguments, 3))); + context = context.contexts["/list"]; + context.wait(); + + let list = template.generic( + <div highlight="Completions"> + { template.completionRow(context.title, "CompTitle") } + { template.map(context.items, function (item) context.createRow(item), null, 100) } + </div>); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// COMPLETION TYPES //////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + autocmdEvent: function autocmdEvent(context) + { + context.completions = config.autocommands; + }, + + bookmark: function bookmark(context, tags) + { + context.title = ["Bookmark", "Title"]; + context.format = bookmarks.format; + // 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); + }, + + buffer: function buffer(context) + { + filter = context.filter.toLowerCase(); + context.title = ["Buffer", "URL"]; + context.keys = { text: "text", description: "url", icon: "icon" }; + let process = context.process[0]; + context.process = [function (item) + <> + <span highlight="Indicator" style="display: inline-block; width: 1.5em; text-align: center">{item.item.indicator}</span> + { process.call(this, item) } + </>]; + + context.completions = util.map(tabs.browsers, function ([i, browser]) { + if (i == tabs.index()) + indicator = "%" + else if (i == tabs.index(tabs.alternate)) + indicator = "#"; + else + indicator = " "; + + let tab = tabs.getTab(i); + i = i + 1; + let url = browser.contentDocument.location.href; + + return { + text: [i + ": " + (tab.label || "(Untitled)"), i + ": " + url], + url: url, + indicator: indicator, + icon: tab.image || DEFAULT_FAVICON + }; + }); + }, + + colorScheme: function colorScheme(context) + { + options.get("runtimepath").values.forEach(function (path) { + context.fork(path, 0, null, function (context) { + context.filter = path + "/colors/" + context.filter; + completion.file(context, true); + context.title = [path + "/colors/"]; + context.quote = function (text) text.replace(/\.vimp$/, ""); + }); + }); + }, + + command: function command(context) + { + context.title = ["Command"]; + context.anchored = true; + context.keys = { text: "longNames", description: "description" }; + context.completions = [k for (k in commands)]; + }, + + dialog: function dialog(context) + { + context.title = ["Dialog"]; + context.completions = config.dialogs; + }, + + directory: function directory(context, tail) + { + this.file(context, tail); + context.filters.push(function (item) this.getKey(item, "description") == "Directory"); + }, + + environment: function environment(context) + { + let command = liberator.has("Win32") ? "set" : "env"; + let lines = io.system(command).split("\n"); + lines.pop(); + + context.title = ["Environment Variable", "Value"]; + context.generate = function () lines.map(function (line) (line.match(/([^=]+)=(.+)/) || []).slice(1)); + }, + + // provides completions for ex commands, including their arguments + ex: function ex(context) + { + // if there is no space between the command name and the cursor + // then get completions of the command name + let [count, cmd, bang, args] = commands.parseCommand(context.filter); + let [, prefix, junk] = context.filter.match(/^(:*\d*)\w*(.?)/) || []; + context.advance(prefix.length) + if (!junk) + return context.fork("", 0, this, "command"); + + // dynamically get completions as specified with the command's completer function + let command = commands.get(cmd); + if (!command) + { + context.highlight(0, cmd.length, "SPELLCHECK"); + return; + } + + [prefix] = context.filter.match(/^(?:\w*[\s!]|!)\s*/); + let cmdContext = context.fork(cmd, prefix.length); + let argContext = context.fork("args", prefix.length); + args = command.parseArgs(cmdContext.filter, argContext); + if (args) + { + // FIXME: Move to parseCommand + args.count = count; + args.bang = bang; + if (!args.completeOpt && command.completer) + { + cmdContext.advance(args.completeStart); + cmdContext.quote = args.quote; + cmdContext.filter = args.completeFilter; + let compObject = command.completer.call(command, cmdContext, args); + if (compObject instanceof Array) // for now at least, let completion functions return arrays instead of objects + compObject = { start: compObject[0], items: compObject[1] }; + if (compObject != null) + { + cmdContext.advance(compObject.start); + cmdContext.filterFunc = null; + cmdContext.completions = compObject.items; + } + } + context.updateAsync = true; + } + }, + + // TODO: support file:// and \ or / path separators on both platforms + // if "tail" is true, only return names without any directory components + file: function file(context, tail) + { + let [dir] = context.filter.match(/^(?:.*[\/\\])?/); + // dir == "" is expanded inside readDirectory to the current dir + + context.title = ["Path", "Type"]; + if (tail) + context.advance(dir.length); + context.keys = { text: 0, description: 1, icon: 2 }; + context.anchored = true; + context.background = true; + context.key = dir; + context.generate = function generate_file() + { + context.cache.dir = dir; + + try + { + let files = io.readDirectory(dir); + + if (options["wildignore"]) + { + let wigRegexp = RegExp("(^" + options["wildignore"].replace(",", "|", "g") + ")$"); + files = files.filter(function (f) f.isDirectory() || !wigRegexp.test(f.leafName)) + } + + return files.map( + function (file) [tail ? file.leafName : dir + file.leafName, + file.isDirectory() ? "Directory" : "File", + file.isDirectory() ? "resource://gre/res/html/folder.png" + : "moz-icon://" + file.leafName] + ); + } + catch (e) {} + return []; + }; + }, + + help: function help(context) + { + context.title = ["Help"]; + context.generate = function () + { + let res = config.helpFiles.map(function (file) { + let resp = util.httpGet("chrome://liberator/locale/" + file); + if (!resp) + return []; + let doc = resp.responseXML; + return Array.map(doc.getElementsByClassName("tag"), + function (elem) [elem.textContent, file]); + }); + return util.Array.flatten(res); + } + }, + + history: function _history(context, maxItems) + { + context.format = history.format; + context.title = ["History"] + context.compare = null; + //context.background = true; + if (context.maxItems == null) + context.maxItems = 100; + context.regenerate = true; + context.generate = function () history.get(context.filter, this.maxItems); + }, + + get javascriptCompleter() javascript, + + javascript: function _javascript(context) javascript.complete(context), + + location: function location(context) + { + if (!completionService) + return + context.title = ["Smart Completions"]; + context.keys.icon = 2; + context.incomplete = true; + context.hasItems = context.completions.length > 0; // XXX + context.filterFunc = null; + context.compare = null; + let timer = new util.Timer(50, 100, function (result) { + context.incomplete = result.searchResult >= result.RESULT_NOMATCH_ONGOING; + context.completions = [ + [result.getValueAt(i), result.getCommentAt(i), result.getImageAt(i)] + for (i in util.range(0, result.matchCount)) + ]; + }); + completionService.stopSearch(); + completionService.startSearch(context.filter, "", context.result, { + onSearchResult: function onSearchResult(search, result) { + context.result = result; + timer.tell(result); + if (result.searchResult <= result.RESULT_SUCCESS) + timer.flush(); + } + }); + }, + + macro: function macro(context) + { + context.title = ["Macro", "Keys"]; + context.completions = [item for (item in events.getMacros())]; + }, + + menuItem: function menuItem(filter) commands.get("emenu").completer(filter), // XXX + + option: function option(context, scope) + { + context.title = ["Option"]; + context.anchored = true; + context.keys = { text: "names", description: "description" }; + context.completions = options; + if (scope) + context.filters.push(function ({ item: opt }) opt.scope & scope); + }, + + optionValue: function (context, name, op, curValue) + { + let opt = options.get(name); + let completer = opt.completer; + if (!completer) + return; + + let curValues = curValue != null ? opt.parseValues(curValue) : opt.values; + let newValues = opt.parseValues(context.filter); + + let len = context.filter.length; + switch (opt.type) + { + case "boolean": + if (!completer) + completer = function () [["true", ""], ["false", ""]]; + break; + case "stringlist": + len = newValues.pop().length; + break; + case "charlist": + len = 0; + break; + } + // TODO: Highlight when invalid + context.advance(context.filter.length - len); + + context.title = ["Option Value"]; + let completions = completer(context); + if (!completions) + return; + /* Not vim compatible, but is a significant enough improvement + * that it's worth breaking compatibility. + */ + if (newValues instanceof Array) + { + completions = completions.filter(function (val) newValues.indexOf(val[0]) == -1); + switch (op) + { + case "+": + completions = completions.filter(function (val) curValues.indexOf(val[0]) == -1); + break; + case "-": + completions = completions.filter(function (val) curValues.indexOf(val[0]) > -1); + break; + } + } + context.completions = completions; + }, + + preference: function preference(context) + { + let prefs = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); + context.title = ["Firefox Preference", "Value"]; + context.keys = { text: function (item) item, description: function (item) options.getPref(item) }; + context.completions = prefs.getChildList("", { value: 0 }); + }, + + 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.anchored = true; + 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.anchored = true; + context.background = true; + context.compare = null; + 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); + return item; + } + }).filter(util.identity); + }; + }); + }, + + searchEngineSuggest: function searchEngineSuggest(context, engineAliases, kludge) + { + if (!context.filter) + return; + + let ss = Components.classes["@mozilla.org/browser/search-service;1"] + .getService(Components.interfaces.nsIBrowserSearchService); + let engineList = (engineAliases || options["suggestengines"] || "google").split(","); + + let completions = []; + engineList.forEach(function (name) { + let engine = ss.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 = null; + ctxt.incomplete = true; + bookmarks.getSuggestions(name, ctxt.filter, function (compl) { + ctxt.incomplete = false; + ctxt.completions = compl; + }); + }); + }, + + shellCommand: function shellCommand(context) + { + context.title = ["Shell Command", "Path"]; + context.generate = function () + { + const environmentService = Components.classes["@mozilla.org/process/environment;1"] + .getService(Components.interfaces.nsIEnvironment); + + let dirNames = environmentService.get("PATH").split(RegExp(liberator.has("Win32") ? ";" : ":")); + let commands = []; + + for (let [,dirName] in Iterator(dirNames)) + { + let dir = io.getFile(dirName); + if (dir.exists() && dir.isDirectory()) + { + commands.push([[file.leafName, dir.path] for ([i, file] in Iterator(io.readDirectory(dir))) + if (file.isFile() && file.isExecutable())]); + } + } + + return util.Array.flatten(commands); + } + }, + + sidebar: function sidebar(context) + { + let menu = document.getElementById("viewSidebarMenu"); + context.title = ["Sidebar Panel"]; + context.completions = Array.map(menu.childNodes, function (n) [n.label, ""]); + }, + + alternateStylesheet: function alternateStylesheet(context) + { + context.title = ["Stylesheet", "Location"]; + context.keys = { text: "title", description: function (item) item.href }; + + // unify split style sheets + let completions = buffer.alternateStyleSheets; + completions.forEach(function (stylesheet) { + stylesheet.href = stylesheet.href || "inline"; + completions = completions.filter(function (sheet) { + if (stylesheet.title == sheet.title && stylesheet != sheet) + { + stylesheet.href += ", " + sheet.href; + return false; + } + return true; + }); + }); + context.completions = completions; + }, + + // filter a list of urls + // + // may consist of search engines, filenames, bookmarks and history, + // depending on the 'complete' option + // if the 'complete' argument is passed like "h", it temporarily overrides the complete option + url: function url(context, complete) + { + var numLocationCompletions = 0; // how many async completions did we already return to the caller? + var start = 0; + var skip = context.filter.match("^.*" + options["urlseparator"]); // start after the last 'urlseparator' + if (skip) + context.advance(skip[0].length); + + // Will, and should, throw an error if !(c in opts) + Array.forEach(complete || options["complete"], + function (c) context.fork(c, 0, completion, completion.urlCompleters[c].completer)); + }, + + urlCompleters: {}, + + addUrlCompleter: function addUrlCompleter(opt) + { + this.urlCompleters[opt] = UrlCompleter.apply(null, Array.slice(arguments)); + }, + + urls: function (context, tags) + { + let compare = String.localeCompare; + let contains = String.indexOf + if (context.ignoreCase) + { + compare = util.compareIgnoreCase; + contains = function (a, b) a && a.toLowerCase().indexOf(b.toLowerCase()) > -1; + } + + if (tags) + context.filters.push(function (item) tags. + every(function (tag) (context.getKey(item, "tags") || []). + some(function (t) !compare(tag, t)))); + + if (!context.title) + context.title = ["URL", "Title"]; + + context.fork("additional", 0, this, function (context) { + context.title[0] += " (additional)"; + context.filter = context.parent.filter; // FIXME + context.completions = context.parent.completions; + // For items whose URL doesn't exactly match the filter, + // accept them if all tokens match either the URL or the title. + // Filter out all directly matching strings. + let match = context.filters[0]; + context.filters[0] = function (item) !match.call(this, item); + // and all that don't match the tokens. + let tokens = context.filter.split(/\s+/); + context.filters.push(function (item) tokens.every( + function (tok) contains(context.getKey(item, "url"), tok) || + contains(context.getKey(item, "title"), tok))); + + let re = RegExp(tokens.filter(util.identity).map(util.escapeRegex).join("|"), "g"); + function highlight(item, text, i) process[i].call(this, item, template.highlightRegexp(text, re)); + let process = [template.icon, function (item, k) k]; + context.process = [ + function (item, text) highlight.call(this, item, item.text, 0), + function (item, text) highlight.call(this, item, text, 1) + ]; + }); + }, + + userCommand: function userCommand(context) + { + context.title = ["User Command", "Definition"]; + context.keys = { text: "name", description: "replacementText" }; + context.completions = commands.getUserCommands(); + }, + + userMapping: function userMapping(context, args, modes) + { + if (args.completeArg == 0) + { + let maps = [[m.names[0], ""] for (m in mappings.getUserIterator(modes))]; + context.completions = maps; + } + } + // }}} + }; + + const UrlCompleter = new Struct("name", "description", "completer"); + self.addUrlCompleter("S", "Suggest engines", self.searchEngineSuggest); + self.addUrlCompleter("b", "Bookmarks", self.bookmark); + self.addUrlCompleter("h", "History", self.history); + self.addUrlCompleter("f", "Local files", self.file); + self.addUrlCompleter("l", "Firefox location bar entries (bookmarks and history sorted in an intelligent way)", self.location); + self.addUrlCompleter("s", "Search engines and keyword URLs", self.search); + + return self; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/editor.js b/common/content/editor.js new file mode 100644 index 00000000..af52c089 --- /dev/null +++ b/common/content/editor.js @@ -0,0 +1,1128 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +// command names taken from: +// http://developer.mozilla.org/en/docs/Editor_Embedding_Guide + +function Editor() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + // store our last search with f, F, t or T + var lastFindChar = null; + var lastFindCharFunc = null; + var abbrev = {}; // abbrev["lhr"][0]["{i,c,!}","rhs"] + + function getEditor() + { + return window.document.commandDispatcher.focusedElement; + } + + function getController() + { + var ed = getEditor(); + if (!ed || !ed.controllers) + return null; + + return ed.controllers.getControllerForCommand("cmd_beginLine"); + } + + function selectPreviousLine() + { + editor.executeCommand("cmd_selectLinePrevious"); + if ((modes.extended & modes.LINE) && !editor.selectedText()) + editor.executeCommand("cmd_selectLinePrevious"); + } + function selectNextLine() + { + editor.executeCommand("cmd_selectLineNext"); + if ((modes.extended & modes.LINE) && !editor.selectedText()) + editor.executeCommand("cmd_selectLineNext"); + } + + // add mappings for commands like h,j,k,l,etc. in CARET, VISUAL and TEXTAREA mode + function addMovementMap(keys, hasCount, caretModeMethod, caretModeArg, textareaCommand, visualTextareaCommand) + { + var extraInfo = {}; + if (hasCount) + extraInfo.flags = Mappings.flags.COUNT; + + mappings.add([modes.CARET], keys, "", + function (count) + { + if (typeof count != "number" || count < 1) + count = 1; + + var controller = getBrowser().docShell + .QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsISelectionDisplay) + .QueryInterface(Components.interfaces.nsISelectionController); + + while (count--) + controller[caretModeMethod](caretModeArg, false); + }, + extraInfo); + + mappings.add([modes.VISUAL], keys, "", + function (count) + { + if (typeof count != "number" || count < 1 || !hasCount) + count = 1; + + var controller = getBrowser().docShell + .QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsISelectionDisplay) + .QueryInterface(Components.interfaces.nsISelectionController); + + while (count--) + { + if (modes.extended & modes.TEXTAREA) + { + if (typeof visualTextareaCommand == "function") + visualTextareaCommand(); + else + editor.executeCommand(visualTextareaCommand); + } + else + controller[caretModeMethod](caretModeArg, true); + } + }, + extraInfo); + + mappings.add([modes.TEXTAREA], keys, "", + function (count) + { + if (typeof count != "number" || count < 1) + count = 1; + + editor.executeCommand(textareaCommand, count); + }, + extraInfo); + } + + // add mappings for commands like i,a,s,c,etc. in TEXTAREA mode + function addBeginInsertModeMap(keys, commands) + { + mappings.add([modes.TEXTAREA], keys, "", + function (count) + { + commands.forEach(function (cmd) + editor.executeCommand(cmd, 1)); + modes.set(modes.INSERT, modes.TEXTAREA); + }); + } + + function addMotionMap(key) + { + mappings.add([modes.TEXTAREA], [key], + "Motion command", + function (motion, count) { editor.executeCommandWithMotion(key, motion, count); }, + { flags: Mappings.flags.MOTION | Mappings.flags.COUNT }); + } + + // For the record, some of this code I've just finished throwing + // away makes me want to pull someone else's hair out. --Kris + function abbrevs() + { + for (let [lhs, abbr] in Iterator(abbrev)) + for (let [,rhs] in Iterator(abbr)) + yield [lhs, rhs]; + } + + // mode = "i" -> add :iabbrev, :iabclear and :iunabbrev commands + function addAbbreviationCommands(ch, modeDescription) + { + var mode = ch || "!"; + modeDescription = modeDescription ? " in " + modeDescription + " mode" : ""; + + commands.add([ch ? ch + "a[bbrev]" : "ab[breviate]"], + "Abbreviate a key sequence" + modeDescription, + function (args) + { + let [lhs, rhs] = args; + if (rhs) + editor.addAbbreviation(mode, lhs, rhs); + else + editor.listAbbreviations(mode, lhs || ""); + }, + { + literal: 1, + serial: function () [ + { + command: this.name, + arguments: [lhs], + literalArg: abbr[1] + } + for ([lhs, abbr] in abbrevs()) + if (abbr[0] == mode) + ] + }); + + commands.add([ch ? ch + "una[bbrev]" : "una[bbreviate]"], + "Remove an abbreviation" + modeDescription, + function (args) { editor.removeAbbreviation(mode, args.string); }); + + commands.add([ch + "abc[lear]"], + "Remove all abbreviations" + modeDescription, + function () { editor.removeAllAbbreviations(mode); }, + { argCount: "0" }); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["editor"], + "Set the external text editor", + "string", "gvim -f"); + + options.add(["insertmode", "im"], + "Use Insert mode as the default for text areas", + "boolean", true); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var myModes = [modes.INSERT, modes.COMMAND_LINE]; + + /* KEYS COUNT CARET TEXTAREA VISUAL_TEXTAREA */ + addMovementMap(["k", "<Up>"], true, "lineMove", false, "cmd_linePrevious", selectPreviousLine); + addMovementMap(["j", "<Down>", "<Return>"], true, "lineMove", true, "cmd_lineNext", selectNextLine); + addMovementMap(["h", "<Left>", "<BS>"], true, "characterMove", false, "cmd_charPrevious", "cmd_selectCharPrevious"); + addMovementMap(["l", "<Right>", "<Space>"], true, "characterMove", true, "cmd_charNext", "cmd_selectCharNext"); + addMovementMap(["b", "B", "<C-Left>"], true, "wordMove", false, "cmd_wordPrevious", "cmd_selectWordPrevious"); + addMovementMap(["w", "W", "e", "<C-Right>"], true, "wordMove", true, "cmd_wordNext", "cmd_selectWordNext"); + addMovementMap(["<C-f>", "<PageDown>"], true, "pageMove", true, "cmd_movePageDown", "cmd_selectNextPage"); + addMovementMap(["<C-b>", "<PageUp>"], true, "pageMove", false, "cmd_movePageUp", "cmd_selectPreviousPage"); + addMovementMap(["gg", "<C-Home>"], false, "completeMove", false, "cmd_moveTop", "cmd_selectTop"); + addMovementMap(["G", "<C-End>"], false, "completeMove", true, "cmd_moveBottom", "cmd_selectBottom"); + addMovementMap(["0", "^", "<Home>"], false, "intraLineMove", false, "cmd_beginLine", "cmd_selectBeginLine"); + addMovementMap(["$", "<End>"], false, "intraLineMove", true, "cmd_endLine" , "cmd_selectEndLine" ); + + addBeginInsertModeMap(["i", "<Insert"], []); + addBeginInsertModeMap(["a"], ["cmd_charNext"]); + addBeginInsertModeMap(["I", "gI"], ["cmd_beginLine"]); + addBeginInsertModeMap(["A"], ["cmd_endLine"]); + addBeginInsertModeMap(["s"], ["cmd_deleteCharForward"]); + addBeginInsertModeMap(["S"], ["cmd_deleteToEndOfLine", "cmd_deleteToBeginningOfLine"]); + addBeginInsertModeMap(["C"], ["cmd_deleteToEndOfLine"]); + + addMotionMap("d"); // delete + addMotionMap("c"); // change + addMotionMap("y"); // yank + + // insert mode mappings + mappings.add(myModes, + ["<C-o>", "<C-f>", "<C-g>", "<C-n>", "<C-p>"], + "Ignore certain " + config.hostApplication + " key bindings", + function () { /*liberator.beep();*/ }); + + mappings.add(myModes, + ["<C-w>"], "Delete previous word", + function () { editor.executeCommand("cmd_deleteWordBackward", 1); }); + + mappings.add(myModes, + ["<C-u>"], "Delete until beginning of current line", + function () + { + // broken in FF3, deletes the whole line: + // editor.executeCommand("cmd_deleteToBeginningOfLine", 1); + editor.executeCommand("cmd_selectBeginLine", 1); + if (getController().isCommandEnabled("cmd_delete")) + editor.executeCommand("cmd_delete", 1); + }); + + mappings.add(myModes, + ["<C-k>"], "Delete until end of current line", + function () { editor.executeCommand("cmd_deleteToEndOfLine", 1); }); + + mappings.add(myModes, + ["<C-a>"], "Move cursor to beginning of current line", + function () { editor.executeCommand("cmd_beginLine", 1); }); + + mappings.add(myModes, + ["<C-e>"], "Move cursor to end of current line", + function () { editor.executeCommand("cmd_endLine", 1); }); + + mappings.add(myModes, + ["<C-h>"], "Delete character to the left", + function () { editor.executeCommand("cmd_deleteCharBackward", 1); }); + + mappings.add(myModes, + ["<C-d>"], "Delete character to the right", + function () { editor.executeCommand("cmd_deleteCharForward", 1); }); + + /*mappings.add(myModes, + ["<C-Home>"], "Move cursor to beginning of text field", + function () { editor.executeCommand("cmd_moveTop", 1); }); + + mappings.add(myModes, + ["<C-End>"], "Move cursor to end of text field", + function () { editor.executeCommand("cmd_moveBottom", 1); });*/ + + mappings.add(myModes, + ["<S-Insert>"], "Insert clipboard/selection", + function () { editor.pasteClipboard(); }); + + mappings.add([modes.INSERT, modes.TEXTAREA, modes.COMPOSE], + ["<C-i>"], "Edit text field with an external editor", + function () { editor.editFieldExternally(); }); + + // FIXME: <esc> does not work correctly + mappings.add([modes.INSERT], + ["<C-t>"], "Edit text field in vi mode", + function () { liberator.mode = modes.TEXTAREA; }); + + mappings.add([modes.INSERT], + ["<Space>", "<Return>"], "Expand insert mode abbreviation", + function () { editor.expandAbbreviation("i"); }, + { flags: Mappings.flags.ALLOW_EVENT_ROUTING }); + + mappings.add([modes.INSERT], + ["<Tab>"], "Expand insert mode abbreviation", + function () { editor.expandAbbreviation("i"); document.commandDispatcher.advanceFocus(); }); + + mappings.add([modes.INSERT], + ["<C-]>", "<C-5>"], "Expand insert mode abbreviation", + function () { editor.expandAbbreviation("i"); }); + + // textarea mode + mappings.add([modes.TEXTAREA], + ["u"], "Undo", + function (count) + { + editor.executeCommand("cmd_undo", count); + liberator.mode = modes.TEXTAREA; + }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.TEXTAREA], + ["<C-r>"], "Redo", + function (count) + { + editor.executeCommand("cmd_redo", count); + liberator.mode = modes.TEXTAREA; + }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.TEXTAREA], + ["D"], "Delete the characters under the cursor until the end of the line", + function () { editor.executeCommand("cmd_deleteToEndOfLine"); }); + + mappings.add([modes.TEXTAREA], + ["o"], "Open line below current", + function (count) + { + editor.executeCommand("cmd_endLine", 1); + modes.set(modes.INSERT, modes.TEXTAREA); + events.feedkeys("<Return>"); + }); + + mappings.add([modes.TEXTAREA], + ["O"], "Open line above current", + function (count) + { + editor.executeCommand("cmd_beginLine", 1); + modes.set(modes.INSERT, modes.TEXTAREA); + events.feedkeys("<Return>"); + editor.executeCommand("cmd_linePrevious", 1); + }); + + mappings.add([modes.TEXTAREA], + ["X"], "Delete character to the left", + function (count) { editor.executeCommand("cmd_deleteCharBackward", count); }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.TEXTAREA], + ["x"], "Delete character to the right", + function (count) { editor.executeCommand("cmd_deleteCharForward", count); }, + { flags: Mappings.flags.COUNT }); + + // visual mode + mappings.add([modes.CARET, modes.TEXTAREA, modes.VISUAL], + ["v"], "Start visual mode", + function (count) { modes.set(modes.VISUAL, liberator.mode); }); + + mappings.add([modes.TEXTAREA], + ["V"], "Start visual line mode", + function (count) + { + modes.set(modes.VISUAL, modes.TEXTAREA | modes.LINE); + editor.executeCommand("cmd_beginLine", 1); + editor.executeCommand("cmd_selectLineNext", 1); + }); + + mappings.add([modes.VISUAL], + ["c", "s"], "Change selected text", + function (count) + { + if (modes.extended & modes.TEXTAREA) + { + editor.executeCommand("cmd_cut"); + modes.set(modes.INSERT, modes.TEXTAREA); + } + else + liberator.beep(); + }); + + mappings.add([modes.VISUAL], + ["d"], "Delete selected text", + function (count) + { + if (modes.extended & modes.TEXTAREA) + { + editor.executeCommand("cmd_cut"); + modes.set(modes.TEXTAREA); + } + else + liberator.beep(); + }); + + mappings.add([modes.VISUAL], + ["y"], "Yank selected text", + function (count) + { + if (modes.extended & modes.TEXTAREA) + { + editor.executeCommand("cmd_copy"); + modes.set(modes.TEXTAREA); + } + else + { + var sel = window.content.document.getSelection(); + if (sel) + util.copyToClipboard(sel, true); + else + liberator.beep(); + } + }); + + mappings.add([modes.VISUAL, modes.TEXTAREA], + ["p"], "Paste clipboard contents", + function (count) + { + if (!(modes.extended & modes.CARET)) + { + if (!count) count = 1; + while (count--) + editor.executeCommand("cmd_paste"); + liberator.mode = modes.TEXTAREA; + } + else + liberator.beep(); + }); + + // finding characters + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["f"], "Move to a character on the current line after the cursor", + function (count, arg) + { + var pos = editor.findCharForward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos, true, liberator.mode == modes.VISUAL); + }, + { flags: Mappings.flags.ARGUMENT | Mappings.flags.COUNT }); + + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["F"], "Move to a charater on the current line before the cursor", + function (count, arg) + { + var pos = editor.findCharBackward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos, false, liberator.mode == modes.VISUAL); + }, + { flags: Mappings.flags.ARGUMENT | Mappings.flags.COUNT }); + + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["t"], "Move before a character on the current line", + function (count, arg) + { + var pos = editor.findCharForward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos - 1, true, liberator.mode == modes.VISUAL); + }, + { flags: Mappings.flags.ARGUMENT | Mappings.flags.COUNT }); + + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["T"], "Move before a character on the current line, backwards", + function (count, arg) + { + var pos = editor.findCharBackward(arg, count); + if (pos >= 0) + editor.moveToPosition(pos + 1, false, liberator.mode == modes.VISUAL); + }, + { flags: Mappings.flags.ARGUMENT | Mappings.flags.COUNT }); + + // textarea and visual mode + mappings.add([modes.TEXTAREA, modes.VISUAL], + ["~"], "Switch case of the character under the cursor and move the cursor to the right", + function (count) + { + if (modes.main == modes.VISUAL) + { + count = getEditor().selectionEnd - getEditor().selectionStart; + } + if (typeof count != "number" || count < 1) + { + count = 1; + } + + while (count-- > 0) + { + var text = getEditor().value; + var pos = getEditor().selectionStart; + if (pos >= text.length) + { + liberator.beep(); + return; + } + var chr = text[pos]; + getEditor().value = text.substring(0, pos) + + (chr == chr.toLocaleLowerCase() ? chr.toLocaleUpperCase() : chr.toLocaleLowerCase()) + + text.substring(pos + 1); + editor.moveToPosition(pos + 1, true, false); + } + modes.set(modes.TEXTAREA); + }, + { flags: Mappings.flags.COUNT }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + addAbbreviationCommands("", ""); + addAbbreviationCommands("i", "insert"); + addAbbreviationCommands("c", "command line"); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + line: function () + { + var line = 1; + var text = getEditor().value; + for (let i = 0; i < getEditor().selectionStart; i++) + if (text[i] == "\n") + line++; + return line; + }, + + col: function () + { + var col = 1; + var text = getEditor().value; + for (let i = 0; i < getEditor().selectionStart; i++) + { + col++; + if (text[i] == "\n") + col = 1; + } + return col; + }, + + unselectText: function () + { + var elt = window.document.commandDispatcher.focusedElement; + if (elt && elt.selectionEnd) + elt.selectionEnd = elt.selectionStart; + }, + + selectedText: function () + { + var text = getEditor().value; + return text.substring(getEditor().selectionStart, getEditor().selectionEnd); + }, + + pasteClipboard: function () + { + var elt = window.document.commandDispatcher.focusedElement; + + if (elt.setSelectionRange && util.readFromClipboard()) + // readFromClipboard would return 'undefined' if not checked + // dunno about .setSelectionRange + { + var rangeStart = elt.selectionStart; // caret position + var rangeEnd = elt.selectionEnd; + var tempStr1 = elt.value.substring(0, rangeStart); + var tempStr2 = util.readFromClipboard(); + var tempStr3 = elt.value.substring(rangeEnd); + elt.value = tempStr1 + tempStr2 + tempStr3; + elt.selectionStart = rangeStart + tempStr2.length; + elt.selectionEnd = elt.selectionStart; + } + }, + + // count is optional, defaults to 1 + executeCommand: function (cmd, count) + { + var controller = getController(); + if (!controller || !controller.supportsCommand(cmd) || !controller.isCommandEnabled(cmd)) + { + liberator.beep(); + return false; + } + + if (typeof count != "number" || count < 1) + count = 1; + + var didCommand = false; + while (count--) + { + // some commands need this try/catch workaround, because a cmd_charPrevious triggered + // at the beginning of the textarea, would hang the doCommand() + // good thing is, we need this code anyway for proper beeping + try + { + controller.doCommand(cmd); + didCommand = true; + } + catch (e) + { + if (!didCommand) + liberator.beep(); + return false; + } + } + + return true; + }, + + // cmd = y, d, c + // motion = b, 0, gg, G, etc. + executeCommandWithMotion: function (cmd, motion, count) + { + if (!typeof count == "number" || count < 1) + count = 1; + + if (cmd == motion) + { + motion = "j"; + count--; + } + + modes.set(modes.VISUAL, modes.TEXTAREA); + + switch (motion) + { + case "j": + this.executeCommand("cmd_beginLine", 1); + this.executeCommand("cmd_selectLineNext", count+1); + break; + case "k": + this.executeCommand("cmd_beginLine", 1); + this.executeCommand("cmd_lineNext", 1); + this.executeCommand("cmd_selectLinePrevious", count+1); + break; + case "h": + this.executeCommand("cmd_selectCharPrevious", count); + break; + case "l": + this.executeCommand("cmd_selectCharNext", count); + break; + case "e": + case "w": + this.executeCommand("cmd_selectWordNext", count); + break; + case "b": + this.executeCommand("cmd_selectWordPrevious", count); + break; + case "0": + case "^": + this.executeCommand("cmd_selectBeginLine", 1); + break; + case "$": + this.executeCommand("cmd_selectEndLine", 1); + break; + case "gg": + this.executeCommand("cmd_endLine", 1); + this.executeCommand("cmd_selectTop", 1); + this.executeCommand("cmd_selectBeginLine", 1); + break; + case "G": + this.executeCommand("cmd_beginLine", 1); + this.executeCommand("cmd_selectBottom", 1); + this.executeCommand("cmd_selectEndLine", 1); + break; + + default: + liberator.beep(); + return false; + } + + switch (cmd) + { + case "d": + this.executeCommand("cmd_delete", 1); + // need to reset the mode as the visual selection changes it + modes.main = modes.TEXTAREA; + break; + case "c": + this.executeCommand("cmd_delete", 1); + modes.set(modes.INSERT, modes.TEXTAREA); + break; + case "y": + this.executeCommand("cmd_copy", 1); + this.unselectText(); + break; + + default: + liberator.beep(); + return false; + } + return true; + }, + + // This function will move/select up to given "pos" + // Simple setSelectionRange() would be better, but we want to maintain the correct + // order of selectionStart/End (a firefox bug always makes selectionStart <= selectionEnd) + // Use only for small movements! + moveToPosition: function (pos, forward, select) + { + if (!select) + { + getEditor().setSelectionRange(pos, pos); + return; + } + + if (forward) + { + if (pos <= getEditor().selectionEnd || pos > getEditor().value.length) + return false; + + do // TODO: test code for endless loops + { + this.executeCommand("cmd_selectCharNext", 1); + } + while (getEditor().selectionEnd != pos); + } + else + { + if (pos >= getEditor().selectionStart || pos < 0) + return false; + + do // TODO: test code for endless loops + { + this.executeCommand("cmd_selectCharPrevious", 1); + } + while (getEditor().selectionStart != pos); + } + }, + + // returns the position of char + findCharForward: function (ch, count) + { + if (!getEditor()) + return -1; + + lastFindChar = ch; + lastFindCharFunc = this.findCharForward; + + var text = getEditor().value; + if (!typeof count == "number" || count < 1) + count = 1; + + for (let i = getEditor().selectionEnd + 1; i < text.length; i++) + { + if (text[i] == "\n") + break; + if (text[i] == ch) + count--; + if (count == 0) + return i + 1; // always position the cursor after the char + } + + liberator.beep(); + return -1; + }, + + // returns the position of char + findCharBackward: function (ch, count) + { + if (!getEditor()) + return -1; + + lastFindChar = ch; + lastFindCharFunc = this.findCharBackward; + + var text = getEditor().value; + if (!typeof count == "number" || count < 1) + count = 1; + + for (let i = getEditor().selectionStart - 1; i >= 0; i--) + { + if (text[i] == "\n") + break; + if (text[i] == ch) + count--; + if (count == 0) + return i; + } + + liberator.beep(); + return -1; + }, + + editFileExternally: function (path) + { + // TODO: save return value in v:shell_error + let args = commands.parseArgs(options["editor"], [], "*", true); + + if (args.length < 1) + { + liberator.echoerr("No editor specified"); + return; + } + + args.push(path); + liberator.callFunctionInThread(null, io.run, args.shift(), args, true); + }, + + // TODO: clean up with 2 functions for textboxes and currentEditor? + editFieldExternally: function () + { + if (!options["editor"]) + return false; + + var textBox = null; + if (!(config.isComposeWindow)) + textBox = document.commandDispatcher.focusedElement; + + var text = ""; // XXX + if (textBox) + text = textBox.value; + else if (typeof GetCurrentEditor == "function") // Thunderbird composer + text = GetCurrentEditor().outputToString("text/plain", 2); + else + return false; + + try + { + var tmpfile = io.createTempFile(); + } + catch (e) + { + liberator.echoerr("Could not create temporary file: " + e.message); + return false; + } + try + { + io.writeFile(tmpfile, text); + } + catch (e) + { + liberator.echoerr("Could not write to temporary file " + tmpfile.path + ": " + e.message); + return false; + } + + if (textBox) + { + textBox.setAttribute("readonly", "true"); + var oldBg = textBox.style.backgroundColor; + var tmpBg = "yellow"; + textBox.style.backgroundColor = "#bbbbbb"; + } + + this.editFileExternally(tmpfile.path); + + if (textBox) + textBox.removeAttribute("readonly"); + + // if (v:shell_error != 0) + // { + // tmpBg = "red"; + // liberator.echoerr("External editor returned with exit code " + retcode); + // } + // else + // { + try + { + var val = io.readFile(tmpfile); + if (textBox) + textBox.value = val; + else + { + //document.getElementById("content-frame").contentDocument.designMode = "on"; + var editor = GetCurrentEditor(); + var wholeDocRange = editor.document.createRange(); + var rootNode = editor.rootElement.QueryInterface(Components.interfaces.nsIDOMNode); + wholeDocRange.selectNodeContents(rootNode); + editor.selection.addRange(wholeDocRange); + editor.selection.deleteFromDocument(); + editor.insertText(val); + //setTimeout(function () { + // document.getElementById("content-frame").contentDocument.designMode = "off"; + //}, 100); + } + } + catch (e) + { + tmpBg = "red"; + liberator.echoerr("Could not read from temporary file " + tmpfile.path + ": " + e.message); + } + // } + + // blink the textbox after returning + if (textBox) + { + var timeout = 100; + var colors = [tmpBg, oldBg, tmpBg, oldBg]; + (function () { + textBox.style.backgroundColor = colors.shift(); + if (colors.length > 0) + setTimeout(arguments.callee, timeout); + })(); + } + + tmpfile.remove(false); + return true; + }, + + // Abbreviations {{{ + + // filter is i, c or "!" (insert or command abbreviations or both) + listAbbreviations: function (filter, lhs) + { + if (lhs) // list only that one + { + if (abbrev[lhs]) + { + for (let [,abbr] in Iterator(abbrev[lhs])) + { + if (abbr[0] == filter) + liberator.echo(abbr[0] + " " + lhs + " " + abbr[1]); + // Is it me, or is this clearly very wrong? --Kris + return true; + } + } + liberator.echoerr("No abbreviations found"); + return false; + } + else // list all (for that filter {i,c,!}) + { + var searchFilter = (filter == "!") ? "!ci" + : filter + "!"; // ! -> list all, on c or i ! matches too) + + let list = [[rhs[0], lhs, rhs[1]] for ([lhs, rhs] in abbrevs()) if (searchFilter.indexOf(rhs[0]) > -1)]; + + if (!list.length) + return liberator.echoerr("No abbreviations found"); + list = template.tabular(["", "LHS", "RHS"], [], list); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + }, + + // System for adding abbreviations: + // + // filter == ! delete all, and set first (END) + // + // if filter == ! remove all and add it as only END + // + // variant 1: rhs matches anywere in loop + // + // 1 mod matches anywhere in loop + // a) simple replace and + // I) (maybe there's another rhs that matches? not possible) + // (when there's another item, it's opposite mod with different rhs) + // (so do nothing further but END) + // + // 2 mod does not match + // a) the opposite is there -> make a ! and put it as only and END + // (b) a ! is there. do nothing END) + // + // variant 2: rhs matches *no*were in loop and filter is c or i + // everykind of current combo is possible to 1 {c,i,!} or two {c and i} + // + // 1 mod is ! split into two i + c END + // 1 not !: opposite mode (first), add/change 'second' and END + // 1 not !: same mode (first), overwrite first this END + // + addAbbreviation: function (filter, lhs, rhs) + { + if (!abbrev[lhs]) + { + abbrev[lhs] = []; + abbrev[lhs][0] = [filter, rhs]; + return; + } + + if (filter == "!") + { + if (abbrev[lhs][1]) + abbrev[lhs][1] = ""; + abbrev[lhs][0] = [filter, rhs]; + return; + } + + for (let i = 0; i < abbrev[lhs].length; i++) + { + if (abbrev[lhs][i][1] == rhs) + { + if (abbrev[lhs][i][0] == filter) + { + abbrev[lhs][i] = [filter, rhs]; + return; + } + else + { + if (abbrev[lhs][i][0] != "!") + { + if (abbrev[lhs][1]) + abbrev[lhs][1] = ""; + abbrev[lhs][0] = ["!", rhs]; + return; + } + else + { + return; + } + } + } + } + + if (abbrev[lhs][0][0] == "!") + { + var tmpOpp = ("i" == filter) ? "c" : "i"; + abbrev[lhs][1] = [tmpOpp, abbrev[lhs][0][1]]; + abbrev[lhs][0] = [filter, rhs]; + return; + } + + if (abbrev[lhs][0][0] != filter) + abbrev[lhs][1] = [filter, rhs]; + else + abbrev[lhs][0] = [filter, rhs]; + }, + + removeAbbreviation: function (filter, lhs) + { + if (!lhs) + { + liberator.echoerr("E474: Invalid argument"); + return false; + } + + if (abbrev[lhs]) // abbrev exists + { + if (filter == "!") + { + abbrev[lhs] = ""; + return true; + } + else + { + if (!abbrev[lhs][1]) // only one exists + { + if (abbrev[lhs][0][0] == "!") // exists as ! -> no 'full' delete + { + abbrev[lhs][0][0] = (filter == "i") ? "c" : "i"; // ! - i = c; ! - c = i + return true; + } + else if (abbrev[lhs][0][0] == filter) + { + abbrev[lhs] = ""; + return true; + } + } + else // two abbrev's exists ( 'i' or 'c' (filter as well)) + { + if (abbrev[lhs][0][0] == "c" && filter == "c") + abbrev[lhs][0] = abbrev[lhs][1]; + + abbrev[lhs][1] = ""; + + return true; + } + } + } + + liberator.echoerr("E24: No such abbreviation"); + return false; + }, + + removeAllAbbreviations: function (filter) + { + if (filter == "!") + { + abbrev = {}; + } + else + { + for (let lhs in abbrev) + { + for (let i = 0; i < abbrev[lhs].length; i++) + { + if (abbrev[lhs][i][0] == "!" || abbrev[lhs][i][0] == filter) + this.removeAbbreviation(filter, lhs); + } + } + } + }, + + expandAbbreviation: function (filter) // try to find an candidate and replace accordingly + { + var textbox = getEditor(); + if (!textbox) + return; + var text = textbox.value; + var currStart = textbox.selectionStart; + var currEnd = textbox.selectionEnd; + var foundWord = text.substring(0, currStart).replace(/^(.|\n)*?(\S+)$/m, "$2"); // get last word \b word boundary + if (!foundWord) + return true; + + for (let lhs in abbrev) + { + for (let i = 0; i < abbrev[lhs].length; i++) + { + if (lhs == foundWord && (abbrev[lhs][i][0] == filter || abbrev[lhs][i][0] == "!")) + { + // if found, replace accordingly + var len = foundWord.length; + var abbrText = abbrev[lhs][i][1]; + text = text.substring(0, currStart - len) + abbrText + text.substring(currStart); + textbox.value = text; + textbox.selectionStart = currStart - len + abbrText.length; + textbox.selectionEnd = currEnd - len + abbrText.length; + break; + } + } + } + return true; + } + //}}} + }; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/eval.js b/common/content/eval.js new file mode 100644 index 00000000..14f5fd95 --- /dev/null +++ b/common/content/eval.js @@ -0,0 +1,10 @@ +try { __liberator_eval_result = eval(__liberator_eval_string) +} +catch (e) +{ + __liberator_eval_error = e; +} +// Important: The eval statement *must* remain on the first line +// in order for line numbering in any errors to remain correct. +// +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/events.js b/common/content/events.js new file mode 100644 index 00000000..730ec02f --- /dev/null +++ b/common/content/events.js @@ -0,0 +1,1694 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +function AutoCommands() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var store = []; + + function matchAutoCmd(autoCmd, event, regex) + { + return (!event || autoCmd.event == event) && + (!regex || autoCmd.pattern.source == regex); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["eventignore", "ei"], + "List of autocommand event names which should be ignored", + "stringlist", "", + { + completer: function () config.autocommands.concat([["all", "All events"]]), + validator: Option.validateCompleter + }); + + options.add(["focuscontent", "fc"], + "Try to stay in normal mode after loading a web page", + "boolean", false); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["au[tocmd]"], + "Execute commands automatically on events", + function (args) + { + let [event, regex, cmd] = args; + let events = null; + 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(","); + if (!events.every(function (event) validEvents.indexOf(event) >= 0)) + { + liberator.echoerr("E216: No such group or event: " + event); + return; + } + } + + if (cmd) // add new command, possibly removing all others with the same event/pattern + { + if (args.bang) + autocommands.remove(event, regex); + 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 + }); + + // TODO: expand target to all buffers + commands.add(["doauto[all]"], + "Apply the autocommands matching the specified URL pattern to all buffers", + function (args) + { + commands.get("doautocmd").action.call(this, args); + }, + { + completer: function (context) completion.autocmdEvent(context), + literal: 0 + } + ); + + // TODO: restrict target to current buffer + commands.add(["do[autocmd]"], + "Apply the autocommands matching the specified URL pattern to the current buffer", + function (args) + { + args = args.string; + if (/^\s*$/.test(args)) + return liberator.echo("No matching autocommands"); + + let [, event, url] = args.match(/^(\S+)(?:\s+(\S+))?$/); + url = url || buffer.URL; + + let validEvents = config.autocommands.map(function (e) e[0]); + + if (event == "*") + { + liberator.echoerr("E217: Can't execute autocommands for ALL events"); + } + else if (validEvents.indexOf(event) == -1) + { + liberator.echoerr("E216: No such group or event: " + args); + } + else + { + // TODO: perhaps trigger could return the number of autocmds triggered + // TODO: Perhaps this should take -args to pass to the command? + if (!autocommands.get(event).some(function (c) c.pattern.test(url))) + liberator.echo("No matching autocommands"); + else + autocommands.trigger(event, { url: url }); + } + }, + { + completer: function (context) completion.autocmdEvent(context), + literal: 0 + } + ); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + liberator.registerObserver("load_completion", function () + { + completion.setFunctionCompleter(autocommands.get, [function () config.autocommands]); + }); + + + return { + + __iterator__: function () util.Array.iterator(store), + + 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) + store.push({ event: event, pattern: RegExp(regex), command: cmd })); + }, + + get: function (event, regex) + { + return store.filter(function (autoCmd) matchAutoCmd(autoCmd, event, regex)); + }, + + remove: function (event, regex) + { + store = store.filter(function (autoCmd) !matchAutoCmd(autoCmd, event, regex)); + }, + + list: function (event, regex) + { + let cmds = {}; + + // XXX + store.forEach(function (autoCmd) { + if (matchAutoCmd(autoCmd, event, regex)) + { + cmds[autoCmd.event] = cmds[autoCmd.event] || []; + cmds[autoCmd.event].push(autoCmd); + } + }); + + var list = template.generic( + <table> + <tr highlight="Title"> + <td colspan="2">----- Auto Commands -----</td> + </tr> + { + template.map(cmds, function ([event, items]) + <tr highlight="Title"> + <td colspan="2">{event}</td> + </tr> + + + template.map(items, function (item) + <tr> + <td> {item.pattern.source}</td> + <td>{item.command}</td> + </tr>)) + } + </table>); + + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + trigger: function (event, args) + { + if (options.get("eventignore").has("all", event)) + return; + + let autoCmds = store.filter(function (autoCmd) autoCmd.event == event); + + liberator.echomsg("Executing " + event + " Auto commands for \"*\"", 8); + + let lastPattern = null; + + let url = args.url || ""; + for (let [,autoCmd] in Iterator(autoCmds)) + { + if (autoCmd.pattern.test(url)) + { + if (!lastPattern || lastPattern.source != autoCmd.pattern.source) + liberator.echomsg("Executing " + event + " Auto commands for \"" + autoCmd.pattern.source + "\"", 8); + + lastPattern = autoCmd.pattern; + + liberator.echomsg("autocommand " + autoCmd.command, 9); + liberator.execute(commands.replaceTokens(autoCmd.command, args)); + } + } + } + }; + //}}} +}; //}}} + +function Events() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const input = liberator.input; + + var fullscreen = window.fullScreen; + + var inputBufferLength = 0; // count the number of keys in v.input.buffer (can be different from v.input.buffer.length) + var skipMap = false; // while feeding the keys (stored in v.input.buffer | no map found) - ignore mappings + + var macros = storage.newMap('macros', true); + + var currentMacro = ""; + var lastMacro = ""; + + try // not every extension has a getBrowser() method + { + var tabcontainer = getBrowser().mTabContainer; + if (tabcontainer) // not every VIM-like extension has a tab container + { + tabcontainer.addEventListener("TabMove", function (event) + { + statusline.updateTabCount(); + }, false); + tabcontainer.addEventListener("TabOpen", function (event) + { + statusline.updateTabCount(); + }, false); + tabcontainer.addEventListener("TabClose", function (event) + { + statusline.updateTabCount(); + }, false); + tabcontainer.addEventListener("TabSelect", function (event) + { + // TODO: is all of that necessary? + modes.reset(); + commandline.clear(); + modes.show(); + statusline.updateTabCount(); + tabs.updateSelectionHistory(); + + if (options["focuscontent"]) + setTimeout(function () { liberator.focusContent(true); }, 10); // just make sure, that no widget has focus + }, false); + } + + getBrowser().addEventListener("DOMContentLoaded", onDOMContentLoaded, true); + + // this adds an event which is is called on each page load, even if the + // page is loaded in a background tab + getBrowser().addEventListener("load", onPageLoad, true); + + // called when the active document is scrolled + getBrowser().addEventListener("scroll", function (event) + { + statusline.updateBufferPosition(); + modes.show(); + }, null); + } + catch (e) {} + +// getBrowser().addEventListener("submit", function (event) +// { +// // reset buffer loading state as early as possible, important for macros +// buffer.loaded = 0; +// }, null); + + ///////////////////////////////////////////////////////// + // track if a popup is open or the menubar is active + var activeMenubar = false; + function enterPopupMode(event) + { + if (event.originalTarget.localName == "tooltip" || event.originalTarget.id == "liberator-visualbell") + return; + + modes.add(modes.MENU); + } + function exitPopupMode() + { + // gContextMenu is set to NULL by Firefox, when a context menu is closed + if (typeof gContextMenu != "undefined" && gContextMenu == null && !activeMenubar) + modes.remove(modes.MENU); + } + function enterMenuMode() + { + activeMenubar = true; + modes.add(modes.MENU); + } + function exitMenuMode() + { + activeMenubar = false; + modes.remove(modes.MENU); + } + window.addEventListener("popupshown", enterPopupMode, true); + window.addEventListener("popuphidden", exitPopupMode, true); + window.addEventListener("DOMMenuBarActive", enterMenuMode, true); + window.addEventListener("DOMMenuBarInactive", exitMenuMode, true); + window.addEventListener("resize", onResize, true); + + // window.document.addEventListener("DOMTitleChanged", function (event) + // { + // liberator.log("titlechanged"); + // }, null); + + // NOTE: the order of ["Esc", "Escape"] or ["Escape", "Esc"] + // matters, so use that string as the first item, that you + // want to refer to within liberator's source code for + // comparisons like if (key == "<Esc>") { ... } + var keyTable = [ + [ KeyEvent.DOM_VK_ESCAPE, ["Esc", "Escape"] ], + [ KeyEvent.DOM_VK_LEFT_SHIFT, ["<"] ], + [ KeyEvent.DOM_VK_RIGHT_SHIFT, [">"] ], + [ KeyEvent.DOM_VK_RETURN, ["Return", "CR", "Enter"] ], + [ KeyEvent.DOM_VK_TAB, ["Tab"] ], + [ KeyEvent.DOM_VK_DELETE, ["Del"] ], + [ KeyEvent.DOM_VK_BACK_SPACE, ["BS"] ], + [ KeyEvent.DOM_VK_HOME, ["Home"] ], + [ KeyEvent.DOM_VK_INSERT, ["Insert", "Ins"] ], + [ KeyEvent.DOM_VK_END, ["End"] ], + [ KeyEvent.DOM_VK_LEFT, ["Left"] ], + [ KeyEvent.DOM_VK_RIGHT, ["Right"] ], + [ KeyEvent.DOM_VK_UP, ["Up"] ], + [ KeyEvent.DOM_VK_DOWN, ["Down"] ], + [ KeyEvent.DOM_VK_PAGE_UP, ["PageUp"] ], + [ KeyEvent.DOM_VK_PAGE_DOWN, ["PageDown"] ], + [ KeyEvent.DOM_VK_F1, ["F1"] ], + [ KeyEvent.DOM_VK_F2, ["F2"] ], + [ KeyEvent.DOM_VK_F3, ["F3"] ], + [ KeyEvent.DOM_VK_F4, ["F4"] ], + [ KeyEvent.DOM_VK_F5, ["F5"] ], + [ KeyEvent.DOM_VK_F6, ["F6"] ], + [ KeyEvent.DOM_VK_F7, ["F7"] ], + [ KeyEvent.DOM_VK_F8, ["F8"] ], + [ KeyEvent.DOM_VK_F9, ["F9"] ], + [ KeyEvent.DOM_VK_F10, ["F10"] ], + [ KeyEvent.DOM_VK_F11, ["F11"] ], + [ KeyEvent.DOM_VK_F12, ["F12"] ], + [ KeyEvent.DOM_VK_F13, ["F13"] ], + [ KeyEvent.DOM_VK_F14, ["F14"] ], + [ KeyEvent.DOM_VK_F15, ["F15"] ], + [ KeyEvent.DOM_VK_F16, ["F16"] ], + [ KeyEvent.DOM_VK_F17, ["F17"] ], + [ KeyEvent.DOM_VK_F18, ["F18"] ], + [ KeyEvent.DOM_VK_F19, ["F19"] ], + [ KeyEvent.DOM_VK_F20, ["F20"] ], + [ KeyEvent.DOM_VK_F21, ["F21"] ], + [ KeyEvent.DOM_VK_F22, ["F22"] ], + [ KeyEvent.DOM_VK_F23, ["F23"] ], + [ KeyEvent.DOM_VK_F24, ["F24"] ] + ]; + + function getKeyCode(str) + { + str = str.toLowerCase(); + + for (let [,key] in Iterator(keyTable)) + { + for (let [,name] in Iterator(key[1])) + { + // we don't store lowercase keys in the keyTable, because we + // also need to get good looking strings for the reverse action + if (name.toLowerCase() == str) + return key[0]; + } + } + + return 0; + } + + function isFormElemFocused() + { + var elt = window.document.commandDispatcher.focusedElement; + if (elt == null) + return false; + + try + { // sometimes the elt doesn't have .localName + var tagname = elt.localName.toLowerCase(); + var type = elt.type.toLowerCase(); + + if ((tagname == "input" && (type != "image")) || + tagname == "textarea" || + // tagName == "SELECT" || + // tagName == "BUTTON" || + tagname == "isindex") // isindex is a deprecated one-line input box + return true; + } + catch (e) + { + // FIXME: do nothing? + } + + return false; + } + + function triggerLoadAutocmd(name, doc) + { + let args = { + url: doc.location.href, + title: doc.title + }; + + if (liberator.has("tabs")) + { + args.tab = tabs.getContentIndex(doc) + 1; + args.doc = "tabs.getTab(" + (args.tab - 1) + ").linkedBrowser.contentDocument"; + } + + autocommands.trigger(name, args); + } + + function onResize(event) + { + if (window.fullScreen != fullscreen) + { + fullscreen = window.fullScreen; + liberator.triggerObserver("fullscreen", fullscreen); + autocommands.trigger("Fullscreen", { state: fullscreen }); + } + } + + function onDOMContentLoaded(event) + { + if (event.originalTarget instanceof HTMLDocument) + triggerLoadAutocmd("DOMLoad", event.originalTarget); + } + + // TODO: see what can be moved to onDOMContentLoaded() + function onPageLoad(event) + { + if (event.originalTarget instanceof HTMLDocument) + { + let doc = event.originalTarget; + // document is part of a frameset + if (doc.defaultView.frameElement) + { + // hacky way to get rid of "Transfering data from ..." on sites with frames + // when you click on a link inside a frameset, because asyncUpdateUI + // is not triggered there (firefox bug?) + setTimeout(statusline.updateUrl, 10); + return; + } + + // code which should happen for all (also background) newly loaded tabs goes here: + + let url = doc.location.href; + let title = doc.title; + + // mark the buffer as loaded, we can't use buffer.loaded + // since that always refers to the current buffer, while doc can be + // any buffer, even in a background tab + doc.pageIsFullyLoaded = 1; + + // code which is only relevant if the page load is the current tab goes here: + if (doc == getBrowser().contentDocument) + { + // we want to stay in command mode after a page has loaded + // TODO: move somehwere else, as focusing can already happen earlier than on "load" + if (options["focuscontent"]) + { + setTimeout(function () { + var focused = document.commandDispatcher.focusedElement; + if (focused && (focused.value !== undefined) && focused.value.length == 0) + focused.blur(); + }, 100); + } + } + else // background tab + { + liberator.echomsg("Background tab loaded: " + title || url, 3); + } + + triggerLoadAutocmd("PageLoad", doc); + } + } + + function wrapListener(method) + { + return function (event) + { + try + { + eventManager[method](event); + } + catch (e) + { + if (e.message == "Interrupted") + liberator.echoerr("Interrupted"); + else + liberator.echoerr("Processing " + event.type + " event: " + (e.echoerr || e)); + liberator.reportError(e); + } + }; + } + + // return true when load successful, or false otherwise + function waitForPageLoaded() + { + liberator.dump("start waiting in loaded state: " + buffer.loaded); + liberator.threadYield(true); // clear queue + + if (buffer.loaded == 1) + return true; + + let start = Date.now(); + let end = start + 25000; // maximum time to wait - TODO: add option + let now; + while (now = Date.now(), now < end) + { + liberator.threadYield(); + if ((now - start) % 1000 < 10) + liberator.dump("waited: " + (now - start) + " ms"); + + if (!events.feedingKeys) + return false; + + if (buffer.loaded > 0) + { + liberator.sleep(250); + break; + } + else + liberator.echo("Waiting for page to load..."); + } + modes.show(); + + // TODO: allow macros to be continued when page does not fully load with an option + var ret = (buffer.loaded == 1); + if (!ret) + liberator.echoerr("Page did not load completely in " + ms + " milliseconds. Macro stopped."); + liberator.dump("done waiting: " + ret); + + // sometimes the input widget had focus when replaying a macro + // maybe this call should be moved somewhere else? + // liberator.focusContent(true); + + return ret; + } + + // load all macros inside ~/.vimperator/macros/ + // setTimeout needed since io. is loaded after events. + setTimeout (function () { + // FIXME: largely duplicated for loading plugins + try + { + let dirs = io.getRuntimeDirectories("macros"); + + if (dirs.length > 0) + { + for (let [,dir] in Iterator(dirs)) + { + liberator.echomsg("Searching for \"macros/*\" in \"" + dir.path + "\"", 2); + + liberator.log("Sourcing macros directory: " + dir.path + "...", 3); + + let files = io.readDirectory(dir.path); + + files.forEach(function (file) { + if (!file.exists() || file.isDirectory() || + !file.isReadable() || !/^[\w_-]+(\.vimp)?$/i.test(file.leafName)) + return; + + let name = file.leafName.replace(/\.vimp$/i, ""); + macros.set(name, io.readFile(file).split("\n")[0]); + + liberator.log("Macro " + name + " added: " + macros.get(name), 5); + }); + } + } + else + { + liberator.log("No user macros directory found", 3); + } + } + catch (e) + { + // thrown if directory does not exist + liberator.log("Error sourcing macros directory: " + e, 9); + } + }, 100); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + mappings.add(modes.all, + ["<Esc>", "<C-[>"], "Focus content", + function () { events.onEscape(); }); + + // add the ":" mapping in all but insert mode mappings + mappings.add([modes.NORMAL, modes.VISUAL, modes.HINTS, modes.MESSAGE, modes.COMPOSE, modes.CARET, modes.TEXTAREA], + [":"], "Enter command line mode", + function () { commandline.open(":", "", modes.EX); }); + + // focus events + mappings.add([modes.NORMAL, modes.VISUAL, modes.CARET], + ["<Tab>"], "Advance keyboard focus", + function () { document.commandDispatcher.advanceFocus(); }); + + mappings.add([modes.NORMAL, modes.VISUAL, modes.CARET, modes.INSERT, modes.TEXTAREA], + ["<S-Tab>"], "Rewind keyboard focus", + function () { document.commandDispatcher.rewindFocus(); }); + + mappings.add(modes.all, + ["<C-z>"], "Temporarily ignore all " + config.name + " key bindings", + function () { modes.passAllKeys = true; }); + + mappings.add(modes.all, + ["<C-v>"], "Pass through next key", + function () { modes.passNextKey = true; }); + + mappings.add(modes.all, + ["<Nop>"], "Do nothing", + function () { return; }); + + // macros + mappings.add([modes.NORMAL, modes.MESSAGE], + ["q"], "Record a key sequence into a macro", + function (arg) { events.startRecording(arg); }, + { flags: Mappings.flags.ARGUMENT }); + + mappings.add([modes.NORMAL, modes.MESSAGE], + ["@"], "Play a macro", + function (count, arg) + { + if (count < 1) count = 1; + while (count-- && events.playMacro(arg)) + ; + }, + { flags: Mappings.flags.ARGUMENT | Mappings.flags.COUNT }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["delmac[ros]"], + "Delete macros", + function (args) + { + if (args.bang) + args.string = ".*"; // XXX + + events.deleteMacros(args.string); + }, + { + bang: true, + completer: function (context) completion.macro(context), + literal: 0 + }); + + commands.add(["macros"], + "List all macros", + function (args) { completion.listCompleter("macro", args[0]) }, + { + argCount: "?", + completer: function (context) completion.macro(context) + }); + + commands.add(["pl[ay]"], + "Replay a recorded macro", + function (args) { events.playMacro(args[0]); }, + { + argCount: "1", + completer: function (context) completion.macro(context) + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var eventManager = { + + feedingKeys: false, + + wantsModeReset: true, // used in onFocusChange since Firefox is so buggy here + + destroy: function () + { + // removeEventListeners() to avoid mem leaks + liberator.dump("TODO: remove all eventlisteners"); + + if (typeof getBrowser != "undefined") + getBrowser().removeProgressListener(this.progressListener); + + window.removeEventListener("popupshown", enterPopupMode, true); + window.removeEventListener("popuphidden", exitPopupMode, true); + window.removeEventListener("DOMMenuBarActive", enterMenuMode, true); + window.removeEventListener("DOMMenuBarInactive", exitMenuMode, true); + + window.removeEventListener("keypress", this.onKeyPress, true); + window.removeEventListener("keydown", this.onKeyDown, true); + }, + + startRecording: function (macro) + { + if (!/[a-zA-Z0-9]/.test(macro)) + { + // TODO: ignore this like Vim? + liberator.echoerr("E354: Invalid register name: '" + macro + "'"); + return; + } + + modes.isRecording = true; + + if (/[A-Z]/.test(macro)) // uppercase (append) + { + currentMacro = macro.toLowerCase(); + if (!macros.get(currentMacro)) + macros.set(currentMacro, ""); // initialize if it does not yet exist + } + else + { + currentMacro = macro; + macros.set(currentMacro, ""); + } + }, + + playMacro: function (macro) + { + var res = false; + if (!/[a-zA-Z0-9@]/.test(macro) && macro.length == 1) + { + liberator.echoerr("E354: Invalid register name: '" + macro + "'"); + return false; + } + + if (macro == "@") // use lastMacro if it's set + { + if (!lastMacro) + { + liberator.echoerr("E748: No previously used register"); + return false; + } + } + else + { + if (macro.length == 1) + lastMacro = macro.toLowerCase(); // XXX: sets last played macro, even if it does not yet exist + else + lastMacro = macro; // e.g. long names are case sensitive + } + + if (macros.get(lastMacro)) + { + // make sure the page is stopped before starting to play the macro + try + { + window.getWebNavigation().stop(nsIWebNavigation.STOP_ALL); + } + catch (e) {} + + buffer.loaded = 1; // even if not a full page load, assume it did load correctly before starting the macro + modes.isReplaying = true; + res = events.feedkeys(macros.get(lastMacro), true); // true -> noremap + modes.isReplaying = false; + } + else + { + if (lastMacro.length == 1) + // TODO: ignore this like Vim? + liberator.echoerr("Exxx: Register " + lastMacro + " not set"); + else + liberator.echoerr("Exxx: Named macro '" + lastMacro + "' not set"); + } + return res; + }, + + getMacros: function (filter) + { + if (!filter) + return macros; + + var re = new RegExp(filter); + return ([macro, keys] for ([macro, keys] in macros) if (re.test(macro))); + }, + + deleteMacros: function (filter) + { + var re = new RegExp(filter); + + for (let [item,] in macros) + { + if (re.test(item)) + macros.remove(item); + } + }, + + // This method pushes keys into the event queue from liberator + // it is similar to vim's feedkeys() method, but cannot cope with + // 2 partially feeded strings, you have to feed one parsable string + // + // @param keys: a string like "2<C-f>" to pass + // if you want < to be taken literally, prepend it with a \\ + feedkeys: function (keys, noremap, silent) + { + var doc = window.document; + var view = window.document.defaultView; + var escapeKey = false; // \ to escape some special keys + + var wasFeeding = this.feedingKeys; + this.feedingKeys = true; + var wasSilent = commandline.silent; + if (silent) + commandline.silent = silent; + + try + { + noremap = !!noremap; + + for (var i = 0; i < keys.length; i++) + { + let charCode = keys.charCodeAt(i); + let keyCode = 0; + let shift = false, ctrl = false, alt = false, meta = false; + let string = null; + + //if (keys[i] == "\\") // FIXME: support the escape key + if (keys[i] == "<" && !escapeKey) // start a complex key + { + let [match, modifier, keyname] = keys.substr(i).match(/<([CSMA]-)*(.+?)>/i) || []; + if (keyname) + { + if (modifier) // check for modifiers + { + ctrl = /C-/i.test(modifier); + alt = /A-/i.test(modifier); + shift = /S-/i.test(modifier); + meta = /M-/i.test(modifier); + } + if (keyname.length == 1) + { + if (!ctrl && !alt && !shift && !meta) + return false; // an invalid key like <a> + charCode = keyname.charCodeAt(0); + } + else if (keyname.toLowerCase() == "space") + { + charCode = 32; + } + else if (keyname.toLowerCase() == "nop") + { + string = "<Nop>"; + } + else if (keyCode = getKeyCode(keyname)) + { + charCode = 0; + } + else // an invalid key like <A-xxx> was found, stop propagation here (like Vim) + { + break; + } + + i += match.length - 1; + } + } + else // a simple key + { + // FIXME: does not work for non A-Z keys like Ö,Ä,... + shift = (keys[i] >= "A" && keys[i] <= "Z"); + } + + let elem = window.document.commandDispatcher.focusedElement; + if (!elem) + elem = window.content; + + let evt = doc.createEvent("KeyEvents"); + evt.initKeyEvent("keypress", true, true, view, ctrl, alt, shift, meta, keyCode, charCode); + evt.noremap = noremap; + evt.isMacro = true; + if (string) + { + evt.liberatorString = string; + events.onKeyPress(evt); + } + else + { + elem.dispatchEvent(evt); + } + if (!this.feedingKeys) + break; + // stop feeding keys if page loading failed + if (modes.isReplaying && !waitForPageLoaded()) + break; + // else // a short break between keys often helps + // liberator.sleep(50); + } + } + finally + { + this.feedingKeys = wasFeeding; + if (silent) + commandline.silent = wasSilent; + } + return i == keys.length; + }, + + // this function converts the given event to + // a keycode which can be used in mappings + // e.g. pressing ctrl+n would result in the string "<C-n>" + // null if unknown key + toString: function (event) + { + if (!event) + return "[object Mappings]"; + + if (event.liberatorString) + return event.liberatorString; + + let key = null; + let modifier = ""; + + if (event.ctrlKey) + modifier += "C-"; + if (event.altKey) + modifier += "A-"; + if (event.metaKey) + modifier += "M-"; + + if (event.type == "keypress") + { + if (event.charCode == 0) + { + if (event.shiftKey) + modifier += "S-"; + + for (let i = 0; i < keyTable.length; i++) + { + if (keyTable[i][0] == event.keyCode) + { + key = keyTable[i][1][0]; + break; + } + } + } + // special handling of the Space key + else if (event.charCode == 32) + { + if (event.shiftKey) + modifier += "S-"; + key = "Space"; + } + // a normal key like a, b, c, 0, etc. + else if (event.charCode > 0) + { + key = String.fromCharCode(event.charCode); + if (modifier.length == 0) + return key; + } + } + else if (event.type == "click" || event.type == "dblclick") + { + if (event.shiftKey) + modifier += "S-"; + if (event.type == "dblclick") + modifier += "2-"; + // TODO: triple and quadruple click + + switch (event.button) + { + case 0: + key = "LeftMouse"; + break; + case 1: + key = "MiddleMouse"; + break; + case 2: + key = "RightMouse"; + break; + } + } + + if (key == null) + return null; + + // a key like F1 is always enclosed in < and > + return "<" + modifier + key + ">"; + }, + + isAcceptKey: function (key) + { + return (key == "<Return>" || key == "<C-j>" || key == "<C-m>"); + }, + + isCancelKey: function (key) + { + return (key == "<Esc>" || key == "<C-[>" || key == "<C-c>"); + }, + + // argument "event" is delibarately not used, as i don't seem to have + // access to the real focus target + // + // the ugly wantsModeReset is needed, because firefox generates a massive + // amount of focus changes for things like <C-v><C-k> (focusing the search field) + onFocusChange: function (event) + { + // command line has it's own focus change handler + if (liberator.mode == modes.COMMAND_LINE) + return; + + function hasHTMLDocument(win) win && win.document && win.document instanceof HTMLDocument + + var win = window.document.commandDispatcher.focusedWindow; + var elem = window.document.commandDispatcher.focusedElement; + + if (win && win.top == content && liberator.has("tabs")) + tabs.localStore.focusedFrame = win; + + if (elem && elem.readOnly) + return; + + if (elem && ( + (elem instanceof HTMLInputElement && (elem.type.toLowerCase() == "text" || elem.type.toLowerCase() == "password")) || + (elem instanceof HTMLSelectElement) + )) + { + this.wantsModeReset = false; + liberator.mode = modes.INSERT; + if (hasHTMLDocument(win)) + buffer.lastInputField = elem; + return; + } + + if (elem && elem instanceof HTMLTextAreaElement) + { + this.wantsModeReset = false; + if (options["insertmode"]) + modes.set(modes.INSERT, modes.TEXTAREA); + else if (elem.selectionEnd - elem.selectionStart > 0) + modes.set(modes.VISUAL, modes.TEXTAREA); + else + modes.main = modes.TEXTAREA; + if (hasHTMLDocument(win)) + buffer.lastInputField = elem; + return; + } + + if (config.name == "Muttator") + { + // we switch to -- MESSAGE -- mode for muttator, when the main HTML widget gets focus + if (hasHTMLDocument(win) || elem instanceof HTMLAnchorElement) + { + if (config.isComposeWindow) + { + liberator.dump("Compose editor got focus"); + modes.set(modes.INSERT, modes.TEXTAREA); + } + else if (liberator.mode != modes.MESSAGE) + liberator.mode = modes.MESSAGE; + return; + } + } + + if (liberator.mode == modes.INSERT || + liberator.mode == modes.TEXTAREA || + liberator.mode == modes.MESSAGE || + liberator.mode == modes.VISUAL) + { + // FIXME: currently this hack is disabled to make macros work + // this.wantsModeReset = true; + // setTimeout(function () { + // if (events.wantsModeReset) + // { + // events.wantsModeReset = false; + modes.reset(); + // } + // }, 0); + } + }, + + onSelectionChange: function (event) + { + var couldCopy = false; + var controller = document.commandDispatcher.getControllerForCommand("cmd_copy"); + if (controller && controller.isCommandEnabled("cmd_copy")) + couldCopy = true; + + if (liberator.mode != modes.VISUAL) + { + if (couldCopy) + { + if ((liberator.mode == modes.TEXTAREA || + (modes.extended & modes.TEXTAREA)) + && !options["insertmode"]) + modes.set(modes.VISUAL, modes.TEXTAREA); + else if (liberator.mode == modes.CARET) + modes.set(modes.VISUAL, modes.CARET); + } + } + // XXX: disabled, as i think automatically starting visual caret mode does more harm than help + // else + // { + // if (!couldCopy && modes.extended & modes.CARET) + // liberator.mode = modes.CARET; + // } + }, + + // global escape handler, is called in ALL modes + onEscape: function () + { + if (!modes.passNextKey) + { + if (modes.passAllKeys) + { + modes.passAllKeys = false; + return; + } + + switch (liberator.mode) + { + case modes.NORMAL: + // clear any selection made + var selection = window.content.getSelection(); + try + { // a simple if (selection) does not seem to work + selection.collapseToStart(); + } + catch (e) {} + commandline.clear(); + + modes.reset(); + liberator.focusContent(true); + break; + + case modes.VISUAL: + if (modes.extended & modes.TEXTAREA) + liberator.mode = modes.TEXTAREA; + else if (modes.extended & modes.CARET) + liberator.mode = modes.CARET; + break; + + case modes.CARET: + // setting this option will trigger an observer which will + // care about all other details like setting the NORMAL mode + options.setPref("accessibility.browsewithcaret", false); + break; + + case modes.INSERT: + if ((modes.extended & modes.TEXTAREA) && !options["insertmode"]) + { + liberator.mode = modes.TEXTAREA; + } + else + { + modes.reset(); + liberator.focusContent(true); + } + break; + + default: // HINTS, CUSTOM or COMMAND_LINE + modes.reset(); + break; + } + } + }, + + // this keypress handler gets always called first, even if e.g. + // the commandline has focus + onKeyPress: function (event) + { + let key = events.toString(event); + if (!key) + return true; + + //liberator.log(key + " in mode: " + liberator.mode); + //liberator.dump(key + " in mode: " + liberator.mode); + + if (modes.isRecording) + { + if (key == "q") // TODO: should not be hardcoded + { + modes.isRecording = false; + liberator.log("Recorded " + currentMacro + ": " + macros.get(currentMacro), 9); + liberator.echo("Recorded macro '" + currentMacro + "'"); + event.preventDefault(); + event.stopPropagation(); + return true; + } + else if (!mappings.hasMap(liberator.mode, input.buffer + key)) + { + macros.set(currentMacro, macros.get(currentMacro) + key); + } + } + + if (key == "<C-c>") + liberator.interrupted = true; + + // feedingKeys needs to be separate from interrupted so + // we can differentiate between a recorded <C-c> + // interrupting whatever it's started and a real <C-c> + // interrupting our playback. + if (events.feedingKeys) + { + if (key == "<C-c>" && !event.isMacro) + { + events.feedingKeys = false; + setTimeout(function () { liberator.echo("Canceled playback of macro '" + lastMacro + "'") }, 100); + event.preventDefault(); + event.stopPropagation(); + return true; + } + } + + let stop = true; // set to false if we should NOT consume this event but let Firefox handle it + + let win = document.commandDispatcher.focusedWindow; + if (win && win.document.designMode == "on" && !config.isComposeWindow) + return false; + + // menus have their own command handlers + if (modes.extended & modes.MENU) + return false; + + // handle Escape-one-key mode (Ctrl-v) + if (modes.passNextKey && !modes.passAllKeys) + { + modes.passNextKey = false; + return false; + } + // handle Escape-all-keys mode (Ctrl-q) + if (modes.passAllKeys) + { + if (modes.passNextKey) + modes.passNextKey = false; // and then let flow continue + else if (key == "<Esc>" || key == "<C-[>" || key == "<C-v>") + ; // let flow continue to handle these keys to cancel escape-all-keys mode + else + return false; + } + + // just forward event without checking any mappings when the MOW is open + if (liberator.mode == modes.COMMAND_LINE && + (modes.extended & modes.OUTPUT_MULTILINE)) + { + commandline.onMultilineOutputEvent(event); + event.preventDefault(); + event.stopPropagation(); + return false; + } + + // XXX: ugly hack for now pass certain keys to firefox as they are without beeping + // also fixes key navigation in combo boxes, submitting forms, etc. + // FIXME: breaks iabbr for now --mst + if ((config.name == "Vimperator" && liberator.mode == modes.NORMAL) + || liberator.mode == modes.INSERT) + { + if (key == "<Return>") + return false; + else if (key == "<Space>" || key == "<Up>" || key == "<Down>") + return false; + } + + // // FIXME: handle middle click in content area {{{ + // // alert(event.target.id); + // if (/*event.type == 'mousedown' && */event.button == 1 && event.target.id == 'content') + // { + // //echo("foo " + event.target.id); + // //if (document.commandDispatcher.focusedElement == command_line.inputField) + // { + // //alert(command_line.value.substring(0, command_line.selectionStart)); + // command_line.value = command_line.value.substring(0, command_line.selectionStart) + + // window.readFromClipboard() + + // command_line.value.substring(command_line.selectionEnd, command_line.value.length); + // alert(command_line.value); + // } + // //else + // // { + // // openURLs(window.readFromClipboard()); + // // } + // return true; + // } }}} + + if (key != "<Esc>" && key != "<C-[>") + { + // custom mode... + if (liberator.mode == modes.CUSTOM) + { + plugins.onEvent(event); + event.preventDefault(); + event.stopPropagation(); + return false; + } + + if (modes.extended & modes.HINTS) + { + // under HINT mode, certain keys are redirected to hints.onEvent + if (key == "<Return>" || key == "<Tab>" || key == "<S-Tab>" + || key == mappings.getMapLeader() + || (key == "<BS>" && hints.previnput == "number") + || (/^[0-9]$/.test(key) && !hints.escNumbers)) + { + hints.onEvent(event); + event.preventDefault(); + event.stopPropagation(); + return false; + } + + // others are left to generate the 'input' event or handled by firefox + return; + } + } + + // FIXME (maybe): (is an ESC or C-] here): on HINTS mode, it enters + // into 'if (map && !skipMap) below. With that (or however) it + // triggers the onEscape part, where it resets mode. Here I just + // return true, with the effect that it also gets to there (for + // whatever reason). if that happens to be correct, well.. + // XXX: why not just do that as well for HINTS mode actually? + + if (liberator.mode == modes.CUSTOM) + return true; + + let countStr = input.buffer.match(/^[0-9]*/)[0]; + let candidateCommand = (input.buffer + key).replace(countStr, ""); + let map; + if (event.noremap) + map = mappings.getDefault(liberator.mode, candidateCommand); + else + map = mappings.get(liberator.mode, candidateCommand); + + let candidates = mappings.getCandidates(liberator.mode, candidateCommand); + if (candidates.length == 0 && !map) + { + map = input.pendingMap; + input.pendingMap = null; + } + + // counts must be at the start of a complete mapping (10j -> go 10 lines down) + if (/^[1-9][0-9]*$/.test(input.buffer + key)) + { + // no count for insert mode mappings + if (liberator.mode == modes.INSERT || liberator.mode == modes.COMMAND_LINE) + stop = false; + else + { + input.buffer += key; + inputBufferLength++; + } + } + else if (input.pendingArgMap) + { + input.buffer = ""; + inputBufferLength = 0; + let tmp = input.pendingArgMap; // must be set to null before .execute; if not + input.pendingArgMap = null; // v.input.pendingArgMap is still 'true' also for new feeded keys + if (key != "<Esc>" && key != "<C-[>") + { + if (modes.isReplaying && !waitForPageLoaded()) + return true; + + tmp.execute(null, input.count, key); + } + } + // only follow a map if there isn't a longer possible mapping + // (allows you to do :map z yy, when zz is a longer mapping than z) + // TODO: map.rhs is only defined for user defined commands, should add a "isDefault" property + else if (map && !skipMap && (map.rhs || candidates.length == 0)) + { + input.count = parseInt(countStr, 10); + if (isNaN(input.count)) + input.count = -1; + if (map.flags & Mappings.flags.ARGUMENT) + { + input.pendingArgMap = map; + input.buffer += key; + inputBufferLength++; + } + else if (input.pendingMotionMap) + { + if (key != "<Esc>" && key != "<C-[>") + { + input.pendingMotionMap.execute(candidateCommand, input.count, null); + } + input.pendingMotionMap = null; + input.buffer = ""; + inputBufferLength = 0; + } + // no count support for these commands yet + else if (map.flags & Mappings.flags.MOTION) + { + input.pendingMotionMap = map; + input.buffer = ""; + inputBufferLength = 0; + } + else + { + input.buffer = ""; + inputBufferLength = 0; + + if (modes.isReplaying && !waitForPageLoaded()) + return true; + + let ret = map.execute(null, input.count); + if (map.flags & Mappings.flags.ALLOW_EVENT_ROUTING && ret) + stop = false; + } + } + else if (mappings.getCandidates(liberator.mode, candidateCommand).length > 0 && !skipMap) + { + input.pendingMap = map; + input.buffer += key; + inputBufferLength++; + } + else // if the key is neither a mapping nor the start of one + { + // the mode checking is necessary so that things like g<esc> do not beep + if (input.buffer != "" && !skipMap && (liberator.mode == modes.INSERT || + liberator.mode == modes.COMMAND_LINE || liberator.mode == modes.TEXTAREA)) + { + // no map found -> refeed stuff in v.input.buffer (only while in INSERT, CO... modes) + skipMap = true; // ignore maps while doing so + events.feedkeys(input.buffer, true); + } + if (skipMap) + { + if (--inputBufferLength == 0) // inputBufferLength == 0. v.input.buffer refeeded... + skipMap = false; // done... + } + + input.buffer = ""; + input.pendingArgMap = null; + input.pendingMotionMap = null; + input.pendingMap = null; + + if (key != "<Esc>" && key != "<C-[>") + { + // allow key to be passed to firefox if we can't handle it + stop = false; + + if (liberator.mode == modes.COMMAND_LINE) + { + if (!(modes.extended & modes.INPUT_MULTILINE)) + commandline.onEvent(event); // reroute event in command line mode + } + else if (liberator.mode != modes.INSERT && liberator.mode != modes.TEXTAREA) + { + liberator.beep(); + } + } + } + + if (stop) + { + event.preventDefault(); + event.stopPropagation(); + } + + let motionMap = (input.pendingMotionMap && input.pendingMotionMap.names[0]) || ""; + statusline.updateInputBuffer(motionMap + input.buffer); + return false; + }, + + // this is need for sites like msn.com which focus the input field on keydown + onKeyUpOrDown: function (event) + { + if (modes.passNextKey ^ modes.passAllKeys || isFormElemFocused()) + return true; + + event.stopPropagation(); + return false; + }, + + // TODO: move to buffer.js? + progressListener: { + QueryInterface: function (aIID) + { + if (aIID.equals(Components.interfaces.nsIWebProgressListener) || + aIID.equals(Components.interfaces.nsIXULBrowserWindow) || // for setOverLink(); + aIID.equals(Components.interfaces.nsISupportsWeakReference) || + aIID.equals(Components.interfaces.nsISupports)) + return this; + throw Components.results.NS_NOINTERFACE; + }, + + // XXX: function may later be needed to detect a canceled synchronous openURL() + onStateChange: function (webProgress, request, flags, status) + { + // STATE_IS_DOCUMENT | STATE_IS_WINDOW is important, because we also + // receive statechange events for loading images and other parts of the web page + if (flags & (Components.interfaces.nsIWebProgressListener.STATE_IS_DOCUMENT | + Components.interfaces.nsIWebProgressListener.STATE_IS_WINDOW)) + { + // This fires when the load event is initiated + // only thrown for the current tab, not when another tab changes + if (flags & Components.interfaces.nsIWebProgressListener.STATE_START) + { + buffer.loaded = 0; + statusline.updateProgress(0); + + autocommands.trigger("PageLoadPre", { url: buffer.URL }); + + // don't reset mode if a frame of the frameset gets reloaded which + // is not the focused frame + if (document.commandDispatcher.focusedWindow == webProgress.DOMWindow) + { + setTimeout(function () { modes.reset(false); }, + liberator.mode == modes.HINTS ? 500 : 0); + } + } + else if (flags & Components.interfaces.nsIWebProgressListener.STATE_STOP) + { + buffer.loaded = (status == 0 ? 1 : 2); + statusline.updateUrl(); + } + } + }, + // for notifying the user about secure web pages + onSecurityChange: function (webProgress, aRequest, aState) + { + const nsIWebProgressListener = Components.interfaces.nsIWebProgressListener; + if (aState & nsIWebProgressListener.STATE_IS_INSECURE) + statusline.setClass("insecure"); + else if (aState & nsIWebProgressListener.STATE_IS_BROKEN) + statusline.setClass("broken"); + else if (aState & nsIWebProgressListener.STATE_IS_SECURE) + statusline.setClass("secure"); + }, + onStatusChange: function (webProgress, request, status, message) + { + statusline.updateUrl(message); + }, + onProgressChange: function (webProgress, request, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) + { + statusline.updateProgress(curTotalProgress/maxTotalProgress); + }, + // happens when the users switches tabs + onLocationChange: function () + { + statusline.updateUrl(); + statusline.updateProgress(); + + autocommands.trigger("LocationChange", { url: buffer.URL }); + + // if this is not delayed we get the position of the old buffer + setTimeout(function () { statusline.updateBufferPosition(); }, 100); + }, + // called at the very end of a page load + asyncUpdateUI: function () + { + setTimeout(statusline.updateUrl, 100); + }, + setOverLink : function (link, b) + { + var ssli = options["showstatuslinks"]; + if (link && ssli) + { + if (ssli == 1) + statusline.updateUrl("Link: " + link); + else if (ssli == 2) + liberator.echo("Link: " + link, commandline.DISALLOW_MULTILINE); + } + + if (link == "") + { + if (ssli == 1) + statusline.updateUrl(); + else if (ssli == 2) + modes.show(); + } + }, + + // stub functions for the interfaces + setJSStatus: function (status) { ; }, + setJSDefaultStatus: function (status) { ; }, + setDefaultStatus: function (status) { ; }, + onLinkIconAvailable: function () { ; } + }, + + // TODO: move to options.js? + prefObserver: { + register: function () + { + var prefService = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefService); + this._branch = prefService.getBranch(""); // better way to monitor all changes? + this._branch.QueryInterface(Components.interfaces.nsIPrefBranch2); + this._branch.addObserver("", this, false); + }, + + unregister: function () + { + if (this._branch) + this._branch.removeObserver("", this); + }, + + observe: function (aSubject, aTopic, aData) + { + if (aTopic != "nsPref:changed") + return; + + // aSubject is the nsIPrefBranch we're observing (after appropriate QI) + // aData is the name of the pref that's been changed (relative to aSubject) + switch (aData) + { + case "accessibility.browsewithcaret": + var value = options.getPref("accessibility.browsewithcaret", false); + liberator.mode = value ? modes.CARET : modes.NORMAL; + break; + } + } + } + }; //}}} + + window.XULBrowserWindow = eventManager.progressListener; + window.QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIWebNavigation) + .QueryInterface(Components.interfaces.nsIDocShellTreeItem).treeOwner + .QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIXULWindow) + .XULBrowserWindow = window.XULBrowserWindow; + try + { + getBrowser().addProgressListener(eventManager.progressListener, Components.interfaces.nsIWebProgress.NOTIFY_ALL); + } + catch (e) {} + + eventManager.prefObserver.register(); + liberator.registerObserver("shutdown", function () { + eventManager.destroy(); + eventManager.prefObserver.unregister(); + }); + + window.addEventListener("keypress", wrapListener("onKeyPress"), true); + window.addEventListener("keydown", wrapListener("onKeyUpOrDown"), true); + window.addEventListener("keyup", wrapListener("onKeyUpOrDown"), true); + + return eventManager; + +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/find.js b/common/content/find.js new file mode 100644 index 00000000..b4c0ffe6 --- /dev/null +++ b/common/content/find.js @@ -0,0 +1,473 @@ +/***** B/GIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +// TODO: proper backwards search - implement our own component? +// : implement our own highlighter? +// : frameset pages +// : <ESC> should cancel search highlighting in 'incsearch' mode and jump +// back to the presearch page location - can probably use the same +// solution as marks +// : 'linksearch' searches should highlight link matches only +// : changing any search settings should also update the search state including highlighting +// : incremental searches shouldn't permanently update search modifiers + +// make sure you only create this object when the "liberator" object is ready +function Search() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + // FIXME: + //var self = this; // needed for callbacks since "this" is the "liberator" object in a callback + var found = false; // true if the last search was successful + var backwards = false; // currently searching backwards + var searchString = ""; // current search string (without modifiers) + var searchPattern = ""; // current search string (includes modifiers) + var lastSearchPattern = ""; // the last searched pattern (includes modifiers) + var lastSearchString = ""; // the last searched string (without modifiers) + var lastSearchBackwards = false; // like "backwards", but for the last search, so if you cancel a search with <esc> this is not set + var caseSensitive = false; // search string is case sensitive + var linksOnly = false; // search is limited to link text only + + // Event handlers for search - closure is needed + liberator.registerCallback("change", modes.SEARCH_FORWARD, function (command) { search.searchKeyPressed(command); }); + liberator.registerCallback("submit", modes.SEARCH_FORWARD, function (command) { search.searchSubmitted(command); }); + liberator.registerCallback("cancel", modes.SEARCH_FORWARD, function () { search.searchCanceled(); }); + // TODO: allow advanced myModes in register/triggerCallback + liberator.registerCallback("change", modes.SEARCH_BACKWARD, function (command) { search.searchKeyPressed(command); }); + liberator.registerCallback("submit", modes.SEARCH_BACKWARD, function (command) { search.searchSubmitted(command); }); + liberator.registerCallback("cancel", modes.SEARCH_BACKWARD, function () { search.searchCanceled(); }); + + // set searchString, searchPattern, caseSensitive, linksOnly + function processUserPattern(pattern) + { + //// strip off pattern terminator and offset + //if (backwards) + // pattern = pattern.replace(/\?.*/, ""); + //else + // pattern = pattern.replace(/\/.*/, ""); + + searchPattern = pattern; + + // links only search - \l wins if both modifiers specified + if (/\\l/.test(pattern)) + linksOnly = true; + else if (/\L/.test(pattern)) + linksOnly = false; + else if (options["linksearch"]) + linksOnly = true; + else + linksOnly = false; + + // strip links-only modifiers + pattern = pattern.replace(/(\\)?\\[lL]/g, function ($0, $1) { return $1 ? $0 : ""; }); + + // case sensitivity - \c wins if both modifiers specified + if (/\c/.test(pattern)) + caseSensitive = false; + else if (/\C/.test(pattern)) + caseSensitive = true; + else if (options["ignorecase"] && options["smartcase"] && /[A-Z]/.test(pattern)) + caseSensitive = true; + else if (options["ignorecase"]) + caseSensitive = false; + else + caseSensitive = true; + + // strip case-sensitive modifiers + pattern = pattern.replace(/(\\)?\\[cC]/g, function ($0, $1) { return $1 ? $0 : ""; }); + + // remove any modifer escape \ + pattern = pattern.replace(/\\(\\[cClL])/g, "$1"); + + searchString = pattern; + } + + /* Stolen from toolkit.jar in Firefox, for the time being. The private + * methods were unstable, and changed. The new version is not remotely + * compatible with what we do. + * The following only applies to this object, and may not be + * necessary, or accurate, but, just in case: + * The Original Code is mozilla.org viewsource frontend. + * + * The Initial Developer of the Original Code is + * Netscape Communications Corporation. + * Portions created by the Initial Developer are Copyright (C) 2003 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Blake Ross <blake@cs.stanford.edu> (Original Author) + * Masayuki Nakano <masayuki@d-toybox.com> + * Ben Basson <contact@cusser.net> + * Jason Barnabe <jason_barnabe@fastmail.fm> + * Asaf Romano <mano@mozilla.com> + * Ehsan Akhgari <ehsan.akhgari@gmail.com> + * Graeme McCutcheon <graememcc_firefox@graeme-online.co.uk> + */ + var highlightObj = { + search: function (aWord, matchCase) + { + var finder = Components.classes["@mozilla.org/embedcomp/rangefind;1"] + .createInstance() + .QueryInterface(Components.interfaces.nsIFind); + if (matchCase !== undefined) + finder.caseSensitive = matchCase; + + var range; + while ((range = finder.Find(aWord, + this.searchRange, + this.startPt, + this.endPt))) + yield range; + }, + + highlightDoc: function highlightDoc(win, aWord) + { + Array.forEach(win.frames, function (frame) highlightObj.highlightDoc(frame, aWord)); + + var doc = win.document; + if (!doc || !(doc instanceof HTMLDocument)) + return; + + if (!aWord) + { + let elems = highlightObj.getSpans(doc); + for (let i = elems.snapshotLength; --i >= 0;) + { + let elem = elems.snapshotItem(i); + let docfrag = doc.createDocumentFragment(); + let next = elem.nextSibling; + let parent = elem.parentNode; + + let child; + while (child = elem.firstChild) + docfrag.appendChild(child); + + parent.removeChild(elem); + parent.insertBefore(docfrag, next); + parent.normalize(); + } + return; + } + + var baseNode = <span highlight="Search"/> + baseNode = util.xmlToDom(baseNode, window.content.document); + + var body = doc.body; + var count = body.childNodes.length; + this.searchRange = doc.createRange(); + this.startPt = doc.createRange(); + this.endPt = doc.createRange(); + + this.searchRange.setStart(body, 0); + this.searchRange.setEnd(body, count); + + this.startPt.setStart(body, 0); + this.startPt.setEnd(body, 0); + this.endPt.setStart(body, count); + this.endPt.setEnd(body, count); + + liberator.interrupted = false; + let n = 0; + for (let retRange in this.search(aWord, caseSensitive)) + { + // Highlight + var nodeSurround = baseNode.cloneNode(true); + var node = this.highlight(retRange, nodeSurround); + this.startPt = node.ownerDocument.createRange(); + this.startPt.setStart(node, node.childNodes.length); + this.startPt.setEnd(node, node.childNodes.length); + if (n++ % 20 == 0) + liberator.threadYield(true); + if (liberator.interrupted) + break; + } + }, + + highlight: function highlight(aRange, aNode) + { + var startContainer = aRange.startContainer; + var startOffset = aRange.startOffset; + var endOffset = aRange.endOffset; + var docfrag = aRange.extractContents(); + var before = startContainer.splitText(startOffset); + var parent = before.parentNode; + aNode.appendChild(docfrag); + parent.insertBefore(aNode, before); + return aNode; + }, + + getSpans: function (doc) buffer.evaluateXPath("//*[@liberator:highlight='Search']", doc) + }; + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["hlsearch", "hls"], + "Highlight previous search pattern matches", + "boolean", "false", + { + setter: function (value) + { + if (value) + search.highlight(); + else + search.clear(); + + return value; + } + }); + + options.add(["ignorecase", "ic"], + "Ignore case in search patterns", + "boolean", true); + + options.add(["incsearch", "is"], + "Show where the search pattern matches as it is typed", + "boolean", true); + + options.add(["linksearch", "lks"], + "Limit the search to hyperlink text", + "boolean", false); + + options.add(["smartcase", "scs"], + "Override the 'ignorecase' option if the pattern contains uppercase characters", + "boolean", true); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var myModes = config.browserModes; + myModes = myModes.concat([modes.CARET]); + + mappings.add(myModes, + ["/"], "Search forward for a pattern", + function () { search.openSearchDialog(modes.SEARCH_FORWARD); }); + + mappings.add(myModes, + ["?"], "Search backwards for a pattern", + function () { search.openSearchDialog(modes.SEARCH_BACKWARD); }); + + mappings.add(myModes, + ["n"], "Find next", + function () { search.findAgain(false); }); + + mappings.add(myModes, + ["N"], "Find previous", + function () { search.findAgain(true); }); + + mappings.add(myModes.concat([modes.CARET, modes.TEXTAREA]), ["*"], + "Find word under cursor", + function () + { + search.searchSubmitted(buffer.getCurrentWord(), false); + search.findAgain(); + }); + + mappings.add(myModes.concat([modes.CARET, modes.TEXTAREA]), ["#"], + "Find word under cursor backwards", + function () + { + search.searchSubmitted(buffer.getCurrentWord(), true); + search.findAgain(); + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["noh[lsearch]"], + "Remove the search highlighting", + function () { search.clear(); }, + { argCount: "0" }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + // Called when the search dialog is asked for + // If you omit "mode", it will default to forward searching + openSearchDialog: function (mode) + { + if (mode == modes.SEARCH_BACKWARD) + { + commandline.open("?", "", modes.SEARCH_BACKWARD); + backwards = true; + } + else + { + commandline.open("/", "", modes.SEARCH_FORWARD); + backwards = false; + } + + // TODO: focus the top of the currently visible screen + }, + + // Finds text in a page + // TODO: backwards seems impossible i fear :( + find: function (str, backwards) + { + var fastFind = getBrowser().fastFind; + + processUserPattern(str); + + fastFind.caseSensitive = caseSensitive; + found = fastFind.find(searchString, linksOnly) != Components.interfaces.nsITypeAheadFind.FIND_NOTFOUND; + + if (!found) + setTimeout(function () { liberator.echoerr("E486: Pattern not found: " + searchPattern); }, 0); + + return found; + }, + + // Called when the current search needs to be repeated + findAgain: function (reverse) + { + // this hack is needed to make n/N work with the correct string, if + // we typed /foo<esc> after the original search. Since searchString is + // readonly we have to call find() again to update it. + if (getBrowser().fastFind.searchString != lastSearchString) + this.find(lastSearchString, false); + + var up = reverse ? !lastSearchBackwards : lastSearchBackwards; + var result = getBrowser().fastFind.findAgain(up, linksOnly); + + if (result == Components.interfaces.nsITypeAheadFind.FIND_NOTFOUND) + { + liberator.echoerr("E486: Pattern not found: " + lastSearchPattern); + } + else if (result == Components.interfaces.nsITypeAheadFind.FIND_WRAPPED) + { + // hack needed, because wrapping causes a "scroll" event which clears + // our command line + setTimeout(function () { + if (up) + commandline.echo("search hit TOP, continuing at BOTTOM", + commandline.HL_WARNINGMSG, commandline.APPEND_TO_MESSAGES); + else + commandline.echo("search hit BOTTOM, continuing at TOP", + commandline.HL_WARNINGMSG, commandline.APPEND_TO_MESSAGES); + }, 0); + } + else + { + liberator.echo((up ? "?" : "/") + lastSearchPattern, null, commandline.FORCE_SINGLELINE); + + if (options["hlsearch"]) + this.highlight(lastSearchString); + } + }, + + // Called when the user types a key in the search dialog. Triggers a find attempt if 'incsearch' is set + searchKeyPressed: function (command) + { + if (options["incsearch"]) + this.find(command, backwards); + }, + + // Called when the enter key is pressed to trigger a search + // use forcedBackward if you call this function directly + searchSubmitted: function (command, forcedBackward) + { + if (typeof forcedBackward === "boolean") + backwards = forcedBackward; + + // use the last pattern if none specified + if (!command) + command = lastSearchPattern; + + if (!options["incsearch"] || !found) + { + this.clear(); + this.find(command, backwards); + } + + lastSearchBackwards = backwards; + //lastSearchPattern = command.replace(backwards ? /\?.*/ : /\/.*/, ""); // XXX + lastSearchPattern = command; + lastSearchString = searchString; + + // TODO: move to find() when reverse incremental searching is kludged in + // need to find again for reverse searching + if (backwards) + setTimeout(function () { search.findAgain(false); }, 0); + + if (options["hlsearch"]) + this.highlight(searchString); + + modes.reset(); + }, + + // Called when the search is canceled - for example if someone presses + // escape while typing a search + searchCanceled: function () + { + this.clear(); + // TODO: code to reposition the document to the place before search started + }, + + // FIXME: thunderbird incompatible + // this is not dependent on the value of 'hlsearch' + highlight: function (text) + { + if (config.name == "Muttator") + return; + + // already highlighted? + if (highlightObj.getSpans(content.document).snapshotLength > 0) + return; + + if (!text) + text = lastSearchString; + + highlightObj.highlightDoc(window.content, text); + + // recreate selection since _highlightDoc collapses the selection backwards + getBrowser().fastFind.findAgain(false, linksOnly); + + // TODO: remove highlighting from non-link matches (HTML - A/AREA with href attribute; XML - Xlink [type="simple"]) + }, + + clear: function () + { + highlightObj.highlightDoc(window.content); + // need to manually collapse the selection if the document is not + // highlighted + getBrowser().fastFind.collapseSelection(); + } + + }; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/help.css b/common/content/help.css new file mode 100644 index 00000000..e35994ae --- /dev/null +++ b/common/content/help.css @@ -0,0 +1,149 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +div.main { + font-family: -moz-fixed; + white-space: -moz-pre-wrap; + width: 800px; + margin-left: auto; + margin-right: auto; +} + +h1 { + text-align: center; +} + +p.tagline { + text-align: center; + font-weight: bold; +} + +table.vimperator { + border-width: 1px; + border-style: dotted; + border-color: gray; + /*margin-bottom: 2em; /* FIXME: just a quick hack until we have proper pages */ +} +table.vimperator td { + border: none; + padding: 3px; +} +tr.separator { + height: 10px; +} +hr { + height: 1px; + background-color: white; + border-style: none; + margin-top: 0; + margin-bottom: 0; +} +td.taglist { + text-align: right; + vertical-align: top; + border-spacing: 13px 10px; +} +td.taglist td { + width: 100px; + padding: 3px 0px; +} +tr.taglist code, td.usage code { + margin: 0px 2px; +} +td.usage code { + white-space: nowrap; +} +td.taglist code { + margin-left: 2em; +} +code.tag { + font-weight: bold; + color: rgb(255, 0, 255); /* magenta */ + padding-left: 5px; +} +tr.description { + margin-bottom: 4px; +} +table.commands { + background-color: rgb(250, 240, 230); + color: black; +} +table.mappings { + background-color: rgb(230, 240, 250); + color: black; +} +table.options { + background-color: rgb(240, 250, 230); + color: black; +} + +fieldset.paypal { + border: none; +} + +.argument { + color: #6A97D4; +} + +.command { + font-weight: bold; + color: #632610; +} + +.mapping { + font-weight: bold; + color: #102663; +} + +.option { + font-weight: bold; + color: #106326; +} + +.code { + color: #108826; +} + +.shorthelp { + font-weight: bold; +} + +.version { + position: absolute; + top: 10px; + right: 2%; + color: #C0C0C0; + text-align: right; +} + +.warning { + font-weight: bold; + color: red; +} + +/* vim: set fdm=marker sw=4 ts=4 et: */ diff --git a/common/content/hints.js b/common/content/hints.js new file mode 100644 index 00000000..c1f18eb1 --- /dev/null +++ b/common/content/hints.js @@ -0,0 +1,811 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +function Hints() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const ELEM = 0, TEXT = 1, SPAN = 2, IMGSPAN = 3; + + var myModes = config.browserModes; + + var hintMode; + var submode = ""; // used for extended mode, can be "o", "t", "y", etc. + var hintString = ""; // the typed string part of the hint is in this string + var hintNumber = 0; // only the numerical part of the hint + var usedTabKey = false; // when we used <Tab> to select an element + var prevInput = ""; // record previous user input type, "text" || "number" + + // hints[] = [elem, text, span, imgspan, elem.style.backgroundColor, elem.style.color] + var pageHints = []; + var validHints = []; // store the indices of the "hints" array with valid elements + + var escapeNumbers = false; // escape mode for numbers. true -> treated as hint-text + var activeTimeout = null; // needed for hinttimeout > 0 + var canUpdate = false; + + // keep track of the documents which we generated the hints for + // docs = { doc: document, start: start_index in hints[], end: end_index in hints[] } + var docs = []; + + const Mode = new Struct("prompt", "action", "tags"); + Mode.defaultValue("tags", function () function () options.hinttags); + function extended() options.extendedhinttags; + const hintModes = { + ";": Mode("Focus hint", function (elem) buffer.focusElement(elem), extended), + a: Mode("Save hint with prompt", function (elem) buffer.saveLink(elem, false)), + s: Mode("Save hint", function (elem) buffer.saveLink(elem, true)), + o: Mode("Follow hint", function (elem) buffer.followLink(elem, liberator.CURRENT_TAB)), + t: Mode("Follow hint in a new tab", function (elem) buffer.followLink(elem, liberator.NEW_TAB)), + b: Mode("Follow hint in a background tab", function (elem) buffer.followLink(elem, liberator.NEW_BACKGROUND_TAB)), + v: Mode("View hint source", function (elem, loc) buffer.viewSource(loc, false), extended), + V: Mode("View hint source", function (elem, loc) buffer.viewSource(loc, true), extended), + w: Mode("Follow hint in a new window", function (elem) buffer.followLink(elem, liberator.NEW_WINDOW), extended), + + "?": Mode("Show information for hint", function (elem) buffer.showElementInfo(elem), extended), + O: Mode("Open location based on hint", function (elem, loc) commandline.open(":", "open " + loc, modes.EX)), + T: Mode("Open new tab based on hint", function (elem, loc) commandline.open(":", "tabopen " + loc, modes.EX)), + W: Mode("Open new window based on hint", function (elem, loc) commandline.open(":", "winopen " + loc, modes.EX)), + y: Mode("Yank hint location", function (elem, loc) util.copyToClipboard(loc, true)), + Y: Mode("Yank hint description", function (elem) util.copyToClipboard(elem.textContent || "", true), extended), + }; + + // reset all important variables + function reset() + { + statusline.updateInputBuffer(""); + hintString = ""; + hintNumber = 0; + usedTabKey = false; + prevInput = ""; + pageHints = []; + validHints = []; + canUpdate = false; + docs = []; + escapeNumbers = false; + + if (activeTimeout) + clearTimeout(activeTimeout); + activeTimeout = null; + } + + function updateStatusline() + { + statusline.updateInputBuffer((escapeNumbers ? mappings.getMapLeader() : "") + (hintNumber || "")); + } + + function generate(win) + { + if (!win) + win = window.content; + + var doc = win.document; + var height = win.innerHeight; + var width = win.innerWidth; + var scrollX = doc.defaultView.scrollX; + var scrollY = doc.defaultView.scrollY; + + var baseNodeAbsolute = util.xmlToDom(<span highlight="Hint"/>, doc); + + var elem, tagname, text, span, rect; + var res = buffer.evaluateXPath(hintMode.tags(), doc, null, true); + + var fragment = util.xmlToDom(<div highlight="hints"/>, doc); + var start = pageHints.length; + for (let elem in res) + { + // TODO: for iframes, this calculation is wrong + rect = elem.getBoundingClientRect(); + if (!rect || rect.top > height || rect.bottom < 0 || rect.left > width || rect.right < 0) + continue; + + rect = elem.getClientRects()[0]; + if (!rect) + continue; + + var computedStyle = doc.defaultView.getComputedStyle(elem, null); + if (computedStyle.getPropertyValue("visibility") == "hidden" || computedStyle.getPropertyValue("display") == "none") + continue; + + // TODO: mozilla docs recommend localName instead of tagName + tagname = elem.tagName.toLowerCase(); + if (tagname == "input" || tagname == "textarea") + text = elem.value; + else if (tagname == "select") + { + if (elem.selectedIndex >= 0) + text = elem.item(elem.selectedIndex).text; + else + text = ""; + } + else + text = elem.textContent.toLowerCase(); + + span = baseNodeAbsolute.cloneNode(true); + span.style.left = (rect.left + scrollX) + "px"; + span.style.top = (rect.top + scrollY) + "px"; + fragment.appendChild(span); + + pageHints.push([elem, text, span, null, elem.style.backgroundColor, elem.style.color]); + } + + if (doc.body) + { + doc.body.appendChild(fragment); + docs.push({ doc: doc, start: start, end: pageHints.length - 1 }); + } + + // also generate hints for frames + Array.forEach(win.frames, function (frame) { generate(frame); }); + + return true; + } + + // TODO: make it aware of imgspans + function showActiveHint(newID, oldID) + { + var oldElem = validHints[oldID - 1]; + if (oldElem) + setClass(oldElem, false); + + var newElem = validHints[newID - 1]; + if (newElem) + setClass(newElem, true); + } + + function setClass(elem, active) + { + let prefix = (elem.getAttributeNS(NS.uri, "class") || "") + " "; + if (active) + elem.setAttributeNS(NS.uri, "highlight", prefix + "HintActive"); + else + elem.setAttributeNS(NS.uri, "highlight", prefix + "HintElem"); + } + + function showHints() + { + + let elem, tagname, text, rect, span, imgspan; + let hintnum = 1; + let validHint = hintMatcher(hintString.toLowerCase()); + let activeHint = hintNumber || 1; + validHints = []; + + for (let [,{ doc: doc, start: start, end: end }] in Iterator(docs)) + { + let scrollX = doc.defaultView.scrollX; + let scrollY = doc.defaultView.scrollY; + + inner: + for (let i in (util.interruptableRange(start, end + 1, 500))) + { + let hint = pageHints[i]; + [elem, text, span, imgspan] = hint; + + if (!validHint(text)) + { + span.style.display = "none"; + if (imgspan) + imgspan.style.display = "none"; + + elem.removeAttributeNS(NS.uri, "highlight"); + continue inner; + } + + if (text == "" && elem.firstChild && elem.firstChild.tagName == "IMG") + { + if (!imgspan) + { + rect = elem.firstChild.getBoundingClientRect(); + if (!rect) + continue; + + imgspan = util.xmlToDom(<span highlight="Hint"/>, doc); + imgspan.setAttributeNS(NS.uri, "class", "HintImage"); + imgspan.style.left = (rect.left + scrollX) + "px"; + imgspan.style.top = (rect.top + scrollY) + "px"; + imgspan.style.width = (rect.right - rect.left) + "px"; + imgspan.style.height = (rect.bottom - rect.top) + "px"; + hint[IMGSPAN] = imgspan; + span.parentNode.appendChild(imgspan); + } + setClass(imgspan, activeHint == hintnum) + } + + span.setAttribute("number", hintnum++); + if (imgspan) + imgspan.setAttribute("number", hintnum); + else + setClass(elem, activeHint == hintnum); + validHints.push(elem); + } + } + + if (options.usermode) + { + let css = []; + // FIXME: Broken for imgspans. + for (let [,{ doc: doc }] in Iterator(docs)) + { + for (let elem in buffer.evaluateXPath("//*[@liberator:highlight and @number]", doc)) + { + let group = elem.getAttributeNS(NS.uri, "highlight"); + css.push(highlight.selector(group) + "[number='" + elem.getAttribute("number") + "'] { " + elem.style.cssText + " }"); + } + } + styles.addSheet("hint-positions", "*", css.join("\n"), true, true); + } + + return true; + } + + function removeHints(timeout) + { + var firstElem = validHints[0] || null; + + for (let [,{ doc: doc, start: start, end: end }] in Iterator(docs)) + { + for (let elem in buffer.evaluateXPath("//*[@liberator:highlight='hints']", doc)) + elem.parentNode.removeChild(elem); + for (let i in util.range(start, end + 1)) + { + let hint = pageHints[i]; + if (!timeout || hint[ELEM] != firstElem) + hint[ELEM].removeAttributeNS(NS.uri, "highlight"); + } + + // animate the disappearance of the first hint + if (timeout && firstElem) + { + // USE THIS FOR MAKING THE SELECTED ELEM RED + // firstElem.style.backgroundColor = "red"; + // firstElem.style.color = "white"; + // setTimeout(function () { + // firstElem.style.backgroundColor = firstElemBgColor; + // firstElem.style.color = firstElemColor; + // }, 200); + // OR USE THIS FOR BLINKING: + // var counter = 0; + // var id = setInterval(function () { + // firstElem.style.backgroundColor = "red"; + // if (counter % 2 == 0) + // firstElem.style.backgroundColor = "yellow"; + // else + // firstElem.style.backgroundColor = "#88FF00"; + // + // if (counter++ >= 2) + // { + // firstElem.style.backgroundColor = firstElemBgColor; + // firstElem.style.color = firstElemColor; + // clearTimeout(id); + // } + // }, 100); + setTimeout(function () { firstElem.removeAttributeNS(NS.uri, "highlight") }, timeout); + } + } + styles.removeSheet("hint-positions", null, null, null, true); + + reset(); + } + + function processHints(followFirst) + { + if (validHints.length == 0) + { + liberator.beep(); + return false; + } + + if (options["followhints"] > 0) + { + if (!followFirst) + return false; // no return hit; don't examine uniqueness + + // OK. return hit. But there's more than one hint, and + // there's no tab-selected current link. Do not follow in mode 2 + if (options["followhints"] == 2 && validHints.length > 1 && !hintNumber) + return liberator.beep(); + } + + if (!followFirst) + { + let firstHref = validHints[0].getAttribute("href") || null; + if (firstHref) + { + if (validHints.some(function (e) e.getAttribute("href") != firstHref)) + return false; + } + else if (validHints.length > 1) + { + return false; + } + } + + var timeout = followFirst || events.feedingKeys ? 0 : 500; + var activeIndex = (hintNumber ? hintNumber - 1 : 0); + var elem = validHints[activeIndex]; + removeHints(timeout); + + if (timeout == 0) + // force a possible mode change, based on wheter an input field has focus + events.onFocusChange(); + setTimeout(function () { + if (modes.extended & modes.HINTS) + modes.reset(); + hintMode.action(elem, elem.href || ""); + }, timeout); + return true; + } + + function onInput (event) + { + prevInput = "text"; + + // clear any timeout which might be active after pressing a number + if (activeTimeout) + { + clearTimeout(activeTimeout); + activeTimeout = null; + } + + hintNumber = 0; + hintString = commandline.getCommand(); + updateStatusline(); + showHints(); + if (validHints.length == 1) + processHints(false); + } + + function hintMatcher(hintString) //{{{ + { + function containsMatcher(hintString) //{{{ + { + var tokens = hintString.split(/ +/); + return function (linkText) tokens.every(function (token) linkText.indexOf(token) >= 0); + } //}}} + + function wordStartsWithMatcher(hintString, allowWordOverleaping) //{{{ + { + var hintStrings = hintString.split(/ +/); + var wordSplitRegex = new RegExp(options["wordseparators"]); + + // What the **** does this do? --Kris + function charsAtBeginningOfWords(chars, words, allowWordOverleaping) + { + var charIdx = 0; + var numMatchedWords = 0; + for (let [,word] in Iterator(words)) + { + if (word.length == 0) + continue; + + let wcIdx = 0; + // Check if the current word matches same characters as the previous word. + // Each already matched word has matched at least one character. + if (charIdx > numMatchedWords) + { + let matchingStarted = false; + for (let i in util.range(numMatchedWords, charIdx)) + { + if (chars[i] == word[wcIdx]) + { + matchingStarted = true; + wcIdx++; + } + else if (matchingStarted) + { + wcIdx = 0; + break; + } + } + } + + // the current word matches same characters as the previous word + var prevCharIdx; + if (wcIdx > 0) + { + prevCharIdx = charIdx; + // now check if it matches additional characters + for (; wcIdx < word.length && charIdx < chars.length; wcIdx++, charIdx++) + { + if (word[wcIdx] != chars[charIdx]) + break; + } + + // the word doesn't match additional characters, now check if the + // already matched characters are equal to the next characters for matching, + // if yes, then consume them + if (prevCharIdx == charIdx) + { + for (let i = 0; i < wcIdx && charIdx < chars.length; i++, charIdx++) + { + if (word[i] != chars[charIdx]) + break; + } + } + + numMatchedWords++; + } + // the current word doesn't match same characters as the previous word, just + // try to match the next characters + else + { + prevCharIdx = charIdx; + for (let i = 0; i < word.length && charIdx < chars.length; i++, charIdx++) + { + if (word[i] != chars[charIdx]) + break; + } + + if (prevCharIdx == charIdx) + { + if (!allowWordOverleaping) + return false; + } + else + numMatchedWords++; + } + + if (charIdx == chars.length) + return true; + } + + return (charIdx == chars.length); + } + + function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) + { + var strIdx = 0; + for (let [,word] in Iterator(words)) + { + if (word.length == 0) + continue; + + let str = strings[strIdx]; + if (str.length == 0 || word.indexOf(str) == 0) + strIdx++; + else if (!allowWordOverleaping) + return false; + + if (strIdx == strings.length) + return true; + } + + for (; strIdx < strings.length; strIdx++) + { + if (strings[strIdx].length != 0) + return false; + } + return true; + } + + function wordStartsWith(linkText) + { + if (hintStrings.length == 1 && hintStrings[0].length == 0) + return true; + + let words = linkText.split(wordSplitRegex).map(String.toLowerCase); + if (hintStrings.length == 1) + return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping); + else + return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping); + } + + return wordStartsWith; + } //}}} + + var hintMatching = options["hintmatching"]; + switch (hintMatching) + { + case "contains" : return containsMatcher(hintString); + case "wordstartswith": return wordStartsWithMatcher(hintString, /*allowWordOverleaping=*/ true); + case "firstletters" : return wordStartsWithMatcher(hintString, /*allowWordOverleaping=*/ false); + case "custom" : return liberator.plugins.customHintMatcher(hintString); + default : liberator.echoerr("Invalid hintmatching type: " + hintMatching); + } + return null; + } //}}} + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const DEFAULT_HINTTAGS = "//*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @class='lk' or @class='s'] | " + + "//input[not(@type='hidden')] | //a | //area | //iframe | //textarea | //button | //select | " + + "//xhtml:*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @class='lk' or @class='s'] | " + + "//xhtml:input[not(@type='hidden')] | //xhtml:a | //xhtml:area | //xhtml:iframe | //xhtml:textarea | //xhtml:button | //xhtml:select"; + + options.add(["extendedhinttags", "eht"], + "XPath string of hintable elements activated by ';'", + "string", DEFAULT_HINTTAGS); + + options.add(["hinttags", "ht"], + "XPath string of hintable elements activated by 'f' and 'F'", + "string", DEFAULT_HINTTAGS); + + options.add(["hinttimeout", "hto"], + "Timeout before automatically following a non-unique numerical hint", + "number", 0, + { validator: function (value) value >= 0 }); + + options.add(["followhints", "fh"], + // FIXME: this description isn't very clear but I can't think of a + // better one right now. + "Change the behaviour of <Return> in hint mode", + "number", 0, + { + completer: function () [ + ["0", "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on [m]<Return>[m]."], + ["1", "Follow the selected hint on [m]<Return>[m]."], + ["2", "Follow the selected hint on [m]<Return>[m] only it's been [m]<Tab>[m]-selected."] + ], + validator: function (value) Option.validateCompleter + }); + + options.add(["hintmatching", "hm"], + "How links are matched", + "string", "contains", + { + completer: function (filter) + { + return [[m, ""] for each (m in ["contains", "wordstartswith", "firstletters", "custom"])]; + }, + validator: Option.validateCompleter + }); + + options.add(["wordseparators", "wsp"], + "How words are split for hintmatching", + "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]'); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + mappings.add(myModes, ["f"], + "Start QuickHint mode", + function () { hints.show("o"); }); + + mappings.add(myModes, ["F"], + "Start QuickHint mode, but open link in a new tab", + function () { hints.show("t"); }); + + mappings.add(myModes, [";"], + "Start an extended hint mode", + function (arg) { hints.show(arg); }, + { flags: Mappings.flags.ARGUMENT }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + addMode: function (mode) + { + hintModes[mode] = Mode.apply(Mode, Array.slice(arguments, 1)); + }, + + show: function (minor, filter, win) + { + hintMode = hintModes[minor]; + if (!hintMode) + { + liberator.beep(); + return; + } + commandline.input(hintMode.prompt + ":", null, { onChange: onInput }); + modes.extended = modes.HINTS; + + submode = minor; + hintString = filter || ""; + hintNumber = 0; + usedTab = false; + prevInput = ""; + canUpdate = false; + + generate(win); + + // get all keys from the input queue + liberator.threadYield(true); + + canUpdate = true; + showHints(); + + if (validHints.length == 0) + { + liberator.beep(); + modes.reset(); + return false; + } + else if (validHints.length == 1) + { + processHints(true); + return false; + } + else // still hints visible + return true; + }, + + hide: function () + { + removeHints(0); + }, + + onEvent: function (event) + { + var key = events.toString(event); + var followFirst = false; + + // clear any timeout which might be active after pressing a number + if (activeTimeout) + { + clearTimeout(activeTimeout); + activeTimeout = null; + } + + switch (key) + { + case "<Return>": + followFirst = true; + break; + + case "<Tab>": + case "<S-Tab>": + usedTabKey = true; + if (hintNumber == 0) + hintNumber = 1; + + var oldID = hintNumber; + if (key == "<Tab>") + { + if (++hintNumber > validHints.length) + hintNumber = 1; + } + else + { + if (--hintNumber < 1) + hintNumber = validHints.length; + } + showActiveHint(hintNumber, oldID); + updateStatusline(); + return; + + case "<BS>": + if (hintNumber > 0 && !usedTabKey) + { + hintNumber = Math.floor(hintNumber / 10); + if (hintNumber == 0) + prevInput = "text"; + } + else + { + usedTabKey = false; + hintNumber = 0; + liberator.beep(); + return; + } + break; + + case mappings.getMapLeader(): + escapeNumbers = !escapeNumbers; + if (escapeNumbers && usedTabKey) // hintNumber not used normally, but someone may wants to toggle + hintNumber = 0; // <tab>s ? reset. Prevent to show numbers not entered. + + updateStatusline(); + return; + + default: + if (/^\d$/.test(key)) + { + // FIXME: Kludge. + if (escapeNumbers) + { + let cmdline = document.getElementById("liberator-commandline-command"); + let start = cmdline.selectionStart; + let end = cmdline.selectionEnd; + cmdline.value = cmdline.value.substr(0, start) + key + cmdline.value.substr(start); + cmdline.selectionStart = start + 1; + cmdline.selectionEnd = end + 1; + return; + } + + prevInput = "number"; + + var oldHintNumber = hintNumber; + if (hintNumber == 0 || usedTabKey) + { + usedTabKey = false; + hintNumber = parseInt(key, 10); + } + else + hintNumber = (hintNumber * 10) + parseInt(key, 10); + + updateStatusline(); + + if (!canUpdate) + return; + + if (docs.length == 0) + { + generate(); + showHints(); + } + showActiveHint(hintNumber, oldHintNumber || 1); + + if (hintNumber == 0 || hintNumber > validHints.length) + { + liberator.beep(); + return; + } + + // if we write a numeric part like 3, but we have 45 hints, only follow + // the hint after a timeout, as the user might have wanted to follow link 34 + if (hintNumber > 0 && hintNumber * 10 <= validHints.length) + { + var timeout = options["hinttimeout"]; + if (timeout > 0) + activeTimeout = setTimeout(function () { processHints(true); }, timeout); + + return false; + } + // we have a unique hint + processHints(true); + return; + } + } + + updateStatusline(); + + if (canUpdate) + { + if (docs.length == 0 && hintString.length > 0) + generate(); + + showHints(); + processHints(followFirst); + } + } + }; + + // FIXME: add resize support + // window.addEventListener("resize", onResize, null); + + // function onResize(event) + // { + // if (event) + // doc = event.originalTarget; + // else + // doc = window.content.document; + // } + + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/io.js b/common/content/io.js new file mode 100644 index 00000000..5078e898 --- /dev/null +++ b/common/content/io.js @@ -0,0 +1,966 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + Code based on venkman + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +plugins.contexts = {}; +function Script(file) +{ + let self = plugins.contexts[file.path] + if (self) + { + if (self.onUnload) + self.onUnload(); + return self; + } + plugins.contexts[file.path] = this; + this.NAME = file.leafName.replace(/\..*/, "").replace(/-([a-z])/, function (_0, _1) _1.toUpperCase()); + this.PATH = file.path; + this.__context__ = this; + + // This belongs elsewhere + for (let [,dir] in Iterator(io.getRuntimeDirectories("plugin"))) + { + if (dir.contains(file, false)) + plugins[name] = this.NAME; + } +} +Script.prototype = plugins; + +// TODO: why are we passing around strings rather than file objects? +function IO() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const WINDOWS = liberator.has("Win32"); + const EXTENSION_NAME = config.name.toLowerCase(); // "vimperator" or "muttator" + + const ioService = Components.classes['@mozilla.org/network/io-service;1'] + .getService(Components.interfaces.nsIIOService); + const environmentService = Components.classes["@mozilla.org/process/environment;1"] + .getService(Components.interfaces.nsIEnvironment); + const directoryService = Components.classes["@mozilla.org/file/directory_service;1"] + .getService(Components.interfaces.nsIProperties); + const downloadManager = Components.classes["@mozilla.org/download-manager;1"] + .createInstance(Components.interfaces.nsIDownloadManager); + + var processDir = directoryService.get("CurWorkD", Components.interfaces.nsIFile); + var cwd = processDir; + var oldcwd = null; + + var lastRunCommand = ""; // updated whenever the users runs a command with :! + var scriptNames = []; + + // default option values + var cdpath = "," + (environmentService.get("CDPATH").replace(/[:;]/g, ",") || ","); + var runtimepath = "~/" + (WINDOWS ? "" : ".") + EXTENSION_NAME; + var shell, shellcmdflag; + + if (WINDOWS) + { + shell = "cmd.exe"; + // TODO: setting 'shell' to "something containing sh" updates + // 'shellcmdflag' appropriately at startup on Windows in Vim + shellcmdflag = "/c"; + } + else + { + shell = environmentService.get("SHELL") || "sh"; + shellcmdflag = "-c"; + } + + function expandPathList(list) list.split(",").map(io.expandPath).join(",") + + function getPathsFromPathList(list) + { + if (!list) + return []; + else + // empty list item means the current directory + return list.replace(/,$/, "") + .split(",") + .map(function (dir) dir == "" ? io.getCurrentDirectory().path : dir); + } + + function replacePathSep(path) + { + if (WINDOWS) + return path.replace("/", "\\"); + return path; + } + + function joinPaths(head, tail) + { + let path = ioManager.getFile(head); + path.appendRelativePath(ioManager.expandPath(tail)); // FIXME: should only expand env vars and normalise path separators + return path; + } + + function isAbsolutePath(path) + { + try + { + Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile) + .initWithPath(path); + return true; + } + catch (e) + { + return false; + } + } + + var downloadListener = { + onDownloadStateChange: function (state, download) + { + if (download.state == downloadManager.DOWNLOAD_FINISHED) + { + let url = download.source.spec; + let title = download.displayName; + let file = download.targetFile.path; + let size = download.size; + + liberator.echomsg("Download of " + title + " to " + file + " finished", 1); + autocommands.trigger("DownloadPost", { url: url, title: title, file: file, size: size }); + } + }, + onStateChange: function () {}, + onProgressChange: function () {}, + onSecurityChange: function () {} + }; + + downloadManager.addListener(downloadListener); + liberator.registerObserver("shutdown", function () { + downloadManager.removeListener(downloadListener); + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["cdpath", "cd"], + "List of directories searched when executing :cd", + "stringlist", cdpath, + { setter: function (value) expandPathList(value) }); + + options.add(["runtimepath", "rtp"], + "List of directories searched for runtime files", + "stringlist", runtimepath, + { setter: function (value) expandPathList(value) }); + + options.add(["shell", "sh"], + "Shell to use for executing :! and :run commands", + "string", shell, + { setter: function (value) io.expandPath(value) }); + + options.add(["shellcmdflag", "shcf"], + "Flag passed to shell when executing :! and :run commands", + "string", shellcmdflag); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["cd", "chd[ir]"], + "Change the current directory", + function (args) + { + args = args.literalArg; + + if (!args) + { + args = "~"; + } + else if (args == "-") + { + if (oldcwd) + { + args = oldcwd.path; + } + else + { + liberator.echoerr("E186: No previous directory"); + return; + } + } + + args = io.expandPath(args); + + // go directly to an absolute path or look for a relative path + // match in 'cdpath' + // TODO: handle ../ and ./ paths + if (isAbsolutePath(args)) + { + if (io.setCurrentDirectory(args)) + liberator.echo(io.getCurrentDirectory().path); + } + else + { + let dirs = getPathsFromPathList(options["cdpath"]); + let found = false; + + for (let [,dir] in Iterator(dirs)) + { + dir = joinPaths(dir, args); + + if (dir.exists() && dir.isDirectory() && dir.isReadable()) + { + io.setCurrentDirectory(dir.path); + liberator.echo(io.getCurrentDirectory().path); + found = true; + break; + } + } + + if (!found) + { + liberator.echoerr("E344: Can't find directory \"" + args + "\" in cdpath" + + "\n" + + "E472: Command failed"); + } + } + }, + { + argCount: "?", + completer: function (context) completion.directory(context, true), + literal: 0 + }); + + // NOTE: this command is only used in :source + commands.add(["fini[sh]"], + "Stop sourcing a script file", + function () { liberator.echoerr("E168: :finish used outside of a sourced file"); }, + { argCount: "0" }); + + commands.add(["pw[d]"], + "Print the current directory name", + function () { liberator.echo(io.getCurrentDirectory().path); }, + { argCount: "0" }); + + // "mkv[imperatorrc]" or "mkm[uttatorrc]" + commands.add([EXTENSION_NAME.replace(/(.)(.*)/, "mk$1[$2rc]")], + "Write current key mappings and changed options to the config file", + function (args) + { + // TODO: "E172: Only one file name allowed" + let filename = args[0] || "~/" + (WINDOWS ? "_" : ".") + EXTENSION_NAME + "rc"; + let file = io.getFile(filename); + + if (file.exists() && !args.bang) + { + liberator.echoerr("E189: \"" + filename + "\" exists (add ! to override)"); + return; + } + + // FIXME: Use a set/specifiable list here: + let lines = [cmd.serial().map(commands.commandToString) for (cmd in commands) if (cmd.serial)]; + lines = util.Array.flatten(lines); + + // :mkvimrc doesn't save autocommands, so we don't either - remove this code at some point + // line += "\n\" Auto-Commands\n"; + // for (let item in autocommands) + // line += "autocmd " + item.event + " " + item.pattern.source + " " + item.command + "\n"; + + // if (mappings.getMapLeader() != "\\") + // line += "\nlet mapleader = \"" + mappings.getMapLeader() + "\"\n"; + + // source a user .vimperatorrc file + lines.unshift('"' + liberator.version); + lines.push("\nsource! " + filename + ".local"); + lines.push("\n\" vim: set ft=vimperator:"); + + try + { + io.writeFile(file, lines.join("\n")); + } + catch (e) + { + liberator.echoerr("E190: Cannot open \"" + filename + "\" for writing"); + liberator.log("Could not write to " + file.path + ": " + e.message); // XXX + } + }, + { + argCount: "?", + bang: true, + completer: function (context) completion.file(context, true) + }); + + commands.add(["runt[ime]"], + "Source the specified file from each directory in 'runtimepath'", + function (args) { io.sourceFromRuntimePath(args, args.bang); }, + { + argCount: "+", + bang: true + } + ); + + commands.add(["scrip[tnames]"], + "List all sourced script names", + function () + { + let list = template.tabular(["<SNR>", "Filename"], ["text-align: right; padding-right: 1em;"], + ([i + 1, file] for ([i, file] in Iterator(scriptNames)))); // TODO: add colon? + + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + { argCount: "0" }); + + commands.add(["so[urce]"], + "Read Ex commands from a file", + function (args) + { + // FIXME: "E172: Only one file name allowed" + io.source(args[0], args.bang); + }, + { + argCount: "1", + bang: true, + completer: function (context) completion.file(context, true) + }); + + commands.add(["!", "run"], + "Run a command", + function (args) + { + let special = args.bang; + args = args.string; + + // :!! needs to be treated specially as the command parser sets the + // special flag but removes the ! from args + if (special) + args = "!" + args; + + // replaceable bang and no previous command? + if (/((^|[^\\])(\\\\)*)!/.test(args) && !lastRunCommand) + { + liberator.echoerr("E34: No previous command"); + return; + } + + // NOTE: Vim doesn't replace ! preceded by 2 or more backslashes and documents it - desirable? + // pass through a raw bang when escaped or substitute the last command + args = args.replace(/(\\)*!/g, + function (m) /^\\(\\\\)*!$/.test(m) ? m.replace("\\!", "!") : m.replace("!", lastRunCommand) + ); + + lastRunCommand = args; + + let output = io.system(args); + let command = ":" + util.escapeHTML(commandline.getCommand()) + "<br/>"; + + liberator.echo(template.generic(<span style="white-space: pre">{output}</span>)) + liberator.echo(command + util.escapeHTML(output)); + + autocommands.trigger("ShellCmdPost", {}); + }, + { + argCount: "?", // TODO: "1" - probably not worth supporting weird Vim edge cases. The dream is dead. --djk + bang: true, + completer: function (context) completion.shellCommand(context), + literal: 0 + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + liberator.registerObserver("load_completion", function () + { + completion.setFunctionCompleter([ioManager.getFile, ioManager.expandPath], + [function (context, obj, args) { + context.quote[2] = ""; + completion.file(context, true); + }]); + }); + + var ioManager = { + + MODE_RDONLY: 0x01, + MODE_WRONLY: 0x02, + MODE_RDWR: 0x04, + MODE_CREATE: 0x08, + MODE_APPEND: 0x10, + MODE_TRUNCATE: 0x20, + MODE_SYNC: 0x40, + MODE_EXCL: 0x80, + + sourcing: null, + + expandPath: function (path) + { + // TODO: proper pathname separator translation like Vim - this should be done elsewhere + if (WINDOWS) + path = path.replace("/", "\\", "g"); + + // expand "~" to VIMPERATOR_HOME or HOME (USERPROFILE or HOMEDRIVE\HOMEPATH on Windows if HOME is not set) + if (/^~/.test(path)) + { + let home = environmentService.get("VIMPERATOR_HOME"); + + if (!home) + home = environmentService.get("HOME"); + + if (WINDOWS && !home) + home = environmentService.get("USERPROFILE") || + environmentService.get("HOMEDRIVE") + environmentService.get("HOMEPATH"); + + path = home + path.substr(1); + } + + // expand any $ENV vars - this is naive but so is Vim and we like to be compatible + // TODO: Vim does not expand variables set to an empty string (and documents it). + // Kris reckons we shouldn't replicate this 'bug'. --djk + // TODO: should we be doing this for all paths? + path = path.replace( + RegExp("\\$(\\w+)\\b|\\${(\\w+)}" + (WINDOWS ? "|%(\\w+)%" : ""), "g"), + function (m, n1, n2, n3) environmentService.get(n1 || n2 || n3) || m + ); + + return path.replace("\\ ", " ", "g"); + }, + + // TODO: there seems to be no way, short of a new component, to change + // Firefox's CWD - see // https://bugzilla.mozilla.org/show_bug.cgi?id=280953 + getCurrentDirectory: function () + { + let dir = ioManager.getFile(cwd.path); + + // NOTE: the directory could have been deleted underneath us so + // fallback to Firefox's CWD + if (dir.exists() && dir.isDirectory()) + return dir; + else + return processDir; + }, + + setCurrentDirectory: function (newdir) + { + newdir = newdir || "~"; + + if (newdir == "-") + { + [cwd, oldcwd] = [oldcwd, this.getCurrentDirectory()]; + } + else + { + let dir = ioManager.getFile(newdir); + + if (!dir.exists() || !dir.isDirectory()) + { + liberator.echoerr("E344: Can't find directory \"" + dir.path + "\" in path"); + return null; + } + + [cwd, oldcwd] = [dir, this.getCurrentDirectory()]; + } + + return ioManager.getCurrentDirectory(); + }, + + getRuntimeDirectories: function (specialDirectory) + { + let dirs = getPathsFromPathList(options["runtimepath"]); + + dirs = dirs.map(function (dir) joinPaths(dir, specialDirectory)) + .filter(function (dir) dir.exists() && dir.isDirectory() && dir.isReadable()); + + return dirs; + }, + + getRCFile: function (dir) + { + dir = dir || "~"; + + let rcFile1 = joinPaths(dir, "." + EXTENSION_NAME + "rc"); + let rcFile2 = joinPaths(dir, "_" + EXTENSION_NAME + "rc"); + + if (WINDOWS) + [rcFile1, rcFile2] = [rcFile2, rcFile1]; + + if (rcFile1.exists() && rcFile1.isFile()) + return rcFile1; + else if (rcFile2.exists() && rcFile2.isFile()) + return rcFile2; + else + return null; + }, + + // return a nsILocalFile for path where you can call isDirectory(), etc. on + // caller must check with .exists() if the returned file really exists + // also expands relative paths + getFile: function (path, noCheckPWD) + { + let file = Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile); + let protocolHandler = Components.classes["@mozilla.org/network/protocol;1?name=file"] + .createInstance(Components.interfaces.nsIFileProtocolHandler); + + if (/file:\/\//.test(path)) + { + file = protocolHandler.getFileFromURLSpec(path); + } + else + { + let expandedPath = ioManager.expandPath(path); + + if (!isAbsolutePath(expandedPath) && !noCheckPWD) + file = joinPaths(ioManager.getCurrentDirectory().path, expandedPath); + else + file.initWithPath(expandedPath); + } + + return file; + }, + + // TODO: make secure + // returns a nsILocalFile or null if it could not be created + createTempFile: function () + { + let tmpName = EXTENSION_NAME + ".tmp"; + + switch (EXTENSION_NAME) + { + case "muttator": + tmpName = "mutt-ator-mail"; // to allow vim to :set ft=mail automatically + break; + case "vimperator": + try + { + if (window.content.document.location.hostname) + tmpName = EXTENSION_NAME + "-" + window.content.document.location.hostname + ".tmp"; + } + catch (e) {} + break; + } + + let file = directoryService.get("TmpD", Components.interfaces.nsIFile); + file.append(tmpName); + file.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0600); + + if (file.exists()) + return file; + else + return null; // XXX + + }, + + // file is either a full pathname or an instance of file instanceof nsILocalFile + readDirectory: function (file, sort) + { + if (typeof file == "string") + file = ioManager.getFile(file); + else if (!(file instanceof Components.interfaces.nsILocalFile)) + throw Components.results.NS_ERROR_INVALID_ARG; // FIXME: does not work as expected, just shows undefined: undefined + + if (file.isDirectory()) + { + let entries = file.directoryEntries; + let array = []; + while (entries.hasMoreElements()) + { + let entry = entries.getNext(); + entry.QueryInterface(Components.interfaces.nsIFile); + array.push(entry); + } + if (sort) + return array.sort(function (a, b) b.isDirectory() - a.isDirectory() || String.localeCompare(a.path, b.path)); + return array; + } + else + return []; // XXX: or should it throw an error, probably yes? + }, + + // file is either a full pathname or an instance of file instanceof nsILocalFile + // reads a file in "text" mode and returns the string + readFile: function (file) + { + let ifstream = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + let icstream = Components.classes["@mozilla.org/intl/converter-input-stream;1"] + .createInstance(Components.interfaces.nsIConverterInputStream); + + let charset = "UTF-8"; + if (typeof file == "string") + file = ioManager.getFile(file); + else if (!(file instanceof Components.interfaces.nsILocalFile)) + throw Components.results.NS_ERROR_INVALID_ARG; // FIXME: does not work as expected, just shows undefined: undefined + + ifstream.init(file, -1, 0, 0); + const replacementChar = Components.interfaces.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER; + icstream.init(ifstream, charset, 4096, replacementChar); // 4096 bytes buffering + + let buffer = ""; + let str = {}; + while (icstream.readString(4096, str) != 0) + buffer += str.value; + + icstream.close(); + ifstream.close(); + + return buffer; + }, + + // file is either a full pathname or an instance of file instanceof nsILocalFile + // default permission = 0644, only used when creating a new file, does not change permissions if the file exists + // mode can be ">" or ">>" in addition to the normal MODE_* flags + writeFile: function (file, buf, mode, perms) + { + let ofstream = Components.classes["@mozilla.org/network/file-output-stream;1"] + .createInstance(Components.interfaces.nsIFileOutputStream); + let ocstream = Components.classes["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Components.interfaces.nsIConverterOutputStream); + + let charset = "UTF-8"; // Can be any character encoding name that Mozilla supports + if (typeof file == "string") + file = ioManager.getFile(file); + else if (!(file instanceof Components.interfaces.nsILocalFile)) + throw Components.results.NS_ERROR_INVALID_ARG; // FIXME: does not work as expected, just shows undefined: undefined + + if (mode == ">>") + mode = ioManager.MODE_WRONLY | ioManager.MODE_CREATE | ioManager.MODE_APPEND; + else if (!mode || mode == ">") + mode = ioManager.MODE_WRONLY | ioManager.MODE_CREATE | ioManager.MODE_TRUNCATE; + + if (!perms) + perms = 0644; + + ofstream.init(file, mode, perms, 0); + ocstream.init(ofstream, charset, 0, 0x0000); + ocstream.writeString(buf); + + ocstream.close(); + ofstream.close(); + }, + + run: function (program, args, blocking) + { + args = args || []; + blocking = !!blocking; + + let file; + + if (isAbsolutePath(program)) + { + file = ioManager.getFile(program, true); + } + else + { + let dirs = environmentService.get("PATH").split(WINDOWS ? ";" : ":"); + // Windows tries the cwd first TODO: desirable? + if (WINDOWS) + dirs = [io.getCurrentDirectory().path].concat(dirs); + +lookup: + for (let [,dir] in Iterator(dirs)) + { + file = joinPaths(dir, program); + try + { + if (file.exists()) + break; + + // TODO: couldn't we just palm this off to the start command? + // automatically try to add the executable path extensions on windows + if (WINDOWS) + { + let extensions = environmentService.get("PATHEXT").split(";"); + for (let [,extension] in Iterator(extensions)) + { + file = joinPaths(dir, program + extension); + if (file.exists()) + break lookup; + } + } + } + catch (e) {} + } + } + + if (!file || !file.exists()) + { + liberator.echoerr("Command not found: " + program); + return -1; + } + + let process = Components.classes["@mozilla.org/process/util;1"] + .createInstance(Components.interfaces.nsIProcess); + + process.init(file); + process.run(blocking, args, args.length); + + return process.exitValue; + }, + + // when https://bugzilla.mozilla.org/show_bug.cgi?id=68702 is fixed + // is fixed, should use that instead of a tmpfile + system: function (command, input) + { + liberator.echomsg("Calling shell to execute: " + command, 4); + + let stdoutFile = ioManager.createTempFile(); + let stderrFile = ioManager.createTempFile(); + + function escapeQuotes(str) str.replace('"', '\\"', "g"); + + if (!stdoutFile || !stderrFile) // FIXME: error reporting + return ""; + + if (WINDOWS) + command = "cd /D " + cwd.path + " && " + command + " > " + stdoutFile.path + " 2> " + stderrFile.path; + else + // TODO: should we only attempt the actual command conditionally on a successful cd? + command = "cd " + escapeQuotes(cwd.path) + "; " + command + " > \"" + escapeQuotes(stdoutFile.path) + "\"" + + " 2> \"" + escapeQuotes(stderrFile.path) + "\""; + + let stdinFile = null; + + if (input) + { + stdinFile = ioManager.createTempFile(); // FIXME: no returned file? + ioManager.writeFile(stdinFile, input); + command += " < \"" + escapeQuotes(stdinFile.path) + "\""; + } + + let res = ioManager.run(options["shell"], [options["shellcmdflag"], command], true); + + if (res > 0) + var output = ioManager.readFile(stderrFile) + "\nshell returned " + res; + else + var output = ioManager.readFile(stdoutFile); + + stdoutFile.remove(false); + stderrFile.remove(false); + + if (stdinFile) + stdinFile.remove(false); + + // if there is only one \n at the end, chop it off + if (output && output.indexOf("\n") == output.length - 1) + output = output.substr(0, output.length - 1); + + return output; + }, + + // FIXME: multiple paths? + sourceFromRuntimePath: function (paths, all) + { + let dirs = getPathsFromPathList(options["runtimepath"]); + let found = false; + + // FIXME: should use original arg string + liberator.echomsg("Searching for \"" + paths.join(" ") + "\" in \"" + options["runtimepath"] + "\"", 2); + + outer: + for (let [,dir] in Iterator(dirs)) + { + for (let [,path] in Iterator(paths)) + { + let file = joinPaths(dir, path); + + liberator.echomsg("Searching for \"" + file.path, 3); + + if (file.exists() && file.isFile() && file.isReadable()) + { + io.source(file.path, false); + found = true; + + if (!all) + break outer; + } + } + } + + if (!found) + liberator.echomsg("not found in 'runtimepath': \"" + paths.join(" ") + "\"", 1); // FIXME: should use original arg string + + return found; + }, + + // files which end in .js are sourced as pure javascript files, + // no need (actually forbidden) to add: js <<EOF ... EOF around those files + source: function (filename, silent) + { + let wasSourcing = ioManager.sourcing; + try + { + var file = ioManager.getFile(filename); + ioManager.sourcing = { + file: file.path, + line: 0 + }; + + if (!file.exists() || !file.isReadable() || file.isDirectory()) + { + if (!silent) + { + if (file.exists() && file.isDirectory()) + liberator.echomsg("Cannot source a directory: \"" + filename + "\"", 0); + else + liberator.echomsg("could not source: \"" + filename + "\"", 1); + + liberator.echoerr("E484: Can't open file " + filename); + } + + return; + } + + liberator.echomsg("sourcing \"" + filename + "\"", 2); + + let str = ioManager.readFile(file); + let uri = ioService.newFileURI(file); + + // handle pure javascript files specially + if (/\.js$/.test(filename)) + { + try + { + liberator.loadScript(uri.spec, new Script(file)); + } + catch (e) + { + let err = new Error(); + for (let [k, v] in Iterator(e)) + err[k] = v; + err.echoerr = file.path + ":" + e.lineNumber + ": " + e; + throw err; + } + } + else if (/\.css$/.test(filename)) + { + storage.styles.registerSheet(uri.spec, !silent, true); + } + else + { + let heredoc = ""; + let heredocEnd = null; // the string which ends the heredoc + let lines = str.split("\n"); + + for (let [i, line] in Iterator(lines)) + { + if (heredocEnd) // we already are in a heredoc + { + if (heredocEnd.test(line)) + { + command.execute(heredoc, special, count); + heredoc = ""; + heredocEnd = null; + } + else + { + heredoc += line + "\n"; + } + } + else + { + ioManager.sourcing.line = i + 1; + // skip line comments and blank lines + if (/^\s*(".*)?$/.test(line)) + continue; + + var [count, cmd, special, args] = commands.parseCommand(line); + var command = commands.get(cmd); + + if (!command) + { + let lineNumber = i + 1; + + // FIXME: messages need to be able to specify + // whether they can be cleared/overwritten or + // should be appended to and the MOW opened + liberator.echoerr("Error detected while processing " + file.path, + commandline.FORCE_MULTILINE); + commandline.echo("line " + lineNumber + ":", commandline.HL_LINENR, + commandline.APPEND_TO_MESSAGES); + liberator.echoerr("E492: Not an editor command: " + line); + } + else + { + if (command.name == "finish") + { + break; + } + else if (command.hereDoc) + { + // check for a heredoc + let matches = args.match(/(.*)<<\s*(\S+)$/); + + if (matches) + { + args = matches[1]; + heredocEnd = new RegExp("^" + matches[2] + "$", "m"); + if (matches[1]) + heredoc = matches[1] + "\n"; + } + else + { + command.execute(args, special, count); + } + } + else + { + // execute a normal liberator command + liberator.execute(line); + } + } + } + } + + // if no heredoc-end delimiter is found before EOF then + // process the heredoc anyway - Vim compatible ;-) + if (heredocEnd) + command.execute(heredoc, special, count); + } + + if (scriptNames.indexOf(file.path) == -1) + scriptNames.push(file.path); + + liberator.echomsg("finished sourcing \"" + filename + "\"", 2); + + liberator.log("Sourced: " + file.path, 3); + } + catch (e) + { + let message = "Sourcing file: " + (e.echoerr || file.path + ": " + e); + liberator.reportError(e); + if (!silent) + liberator.echoerr(message); + } + finally + { + ioManager.sourcing = wasSourcing; + } + } + }; //}}} + + return ioManager; + +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/liberator-overlay.js b/common/content/liberator-overlay.js new file mode 100644 index 00000000..5175b75b --- /dev/null +++ b/common/content/liberator-overlay.js @@ -0,0 +1,58 @@ + +(function () { + const modules = {}; + const BASE = "chrome://liberator/content/"; + + modules.modules = modules; + + const loader = Components.classes["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Components.interfaces.mozIJSSubScriptLoader); + function load(script) + { + for (let [i, base] in Iterator(prefix)) + { + try + { + loader.loadSubScript(base + script, modules) + return; + } + catch (e) + { + if (i + 1 < prefix.length) + continue; + if (Components.utils.reportError) + Components.utils.reportError(e); + dump("liberator: Loading script " + script + ": " + e + "\n"); + } + } + } + + Components.utils.import("resource://liberator/storage.jsm", modules); + + let prefix = [BASE]; + + ["liberator.js", + "config.js", + "util.js", + "style.js", + "buffer.js", + "commands.js", + "completion.js", + "editor.js", + "events.js", + "find.js", + "hints.js", + "io.js", + "mappings.js", + "modes.js", + "options.js", + "template.js", + "ui.js"].forEach(load); + + prefix.unshift("chrome://" + modules.config.name.toLowerCase() + "/content/"); + if (modules.config.scripts) + modules.config.scripts.forEach(load); + +})() + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/liberator.js b/common/content/liberator.js new file mode 100644 index 00000000..9f6472d8 --- /dev/null +++ b/common/content/liberator.js @@ -0,0 +1,1360 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +const plugins = {}; +plugins.__proto__ = modules; + +const EVAL_ERROR = "__liberator_eval_error"; +const EVAL_RESULT = "__liberator_eval_result"; +const EVAL_STRING = "__liberator_eval_string"; +const userContext = { + __proto__: modules +}; + +const liberator = (function () //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const threadManager = Components.classes["@mozilla.org/thread-manager;1"] + .getService(Components.interfaces.nsIThreadManager); + function Runnable(self, func, args) + { + this.self = self; + this.func = func; + this.args = args; + } + Runnable.prototype = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIRunnable]), + run: function () { this.func.apply(this.self, this.args); } + }; + + var callbacks = []; + var observers = []; + function registerObserver(type, callback) + { + observers.push([type, callback]); + } + + function loadModule(name, func) + { + var message = "Loading module " + name + "..."; + try + { + liberator.log(message, 0); + liberator.dump(message); + modules[name] = func(); + liberator.triggerObserver("load_" + name, name); + } + catch (e) + { + window.toJavaScriptConsole(); + liberator.reportError(e); + } + } + + // Only general options are added here, which are valid for all vimperator like extensions + registerObserver("load_options", function () + { + options.add(["errorbells", "eb"], + "Ring the bell when an error message is displayed", + "boolean", false); + + options.add(["exrc", "ex"], + "Allow reading of an RC file in the current directory", + "boolean", false); + + const tabopts = [ + ["n", "Tab number", null, highlight.selector("TabNumber")], + ["N", "Tab number over icon", null, highlight.selector("TabIconNumber")] + ]; + options.add(["guioptions", "go"], + "Show or hide certain GUI elements like the menu or toolbar", + "charlist", config.defaults.guioptions || "", + { + setter: function (value) + { + for (let [opt, ids] in Iterator(config.guioptions)) + { + ids.forEach(function (id) + { + try + { + document.getElementById(id).collapsed = (value.indexOf(opt) < 0); + } + catch (e) {} + }); + } + let classes = tabopts.filter(function (o) value.indexOf(o[0]) == -1) + .map(function (a) a[3]) + if (!classes.length) + { + styles.removeSheet("taboptions", null, null, null, true); + } + else + { + storage.styles.addSheet("taboptions", "chrome://*", classes.join(",") + "{ display: none; }", true, true); + statusline.updateTabCount(); + } + + return value; + }, + completer: function (filter) + { + return [ + ["m", "Menubar"], + ["T", "Toolbar"], + ["b", "Bookmark bar"] + ].concat(!liberator.has("tabs") ? [] : tabopts); + }, + validator: Option.validateCompleter + }); + + options.add(["helpfile", "hf"], + "Name of the main help file", + "string", "intro.html"); + + options.add(["loadplugins", "lpl"], + "Load plugin scripts when starting up", + "boolean", true); + + options.add(["verbose", "vbs"], + "Define which info messages are displayed", + "number", 0, + { validator: function (value) value >= 0 && value <= 15 }); + + options.add(["visualbell", "vb"], + "Use visual bell instead of beeping on errors", + "boolean", false, + { + setter: function (value) + { + options.setPref("accessibility.typeaheadfind.enablesound", !value); + return value; + } + }); + }); + + registerObserver("load_mappings", function () + { + mappings.add(modes.all, ["<F1>"], + "Open help window", + function () { liberator.help(); }); + + if (liberator.has("session")) + { + mappings.add([modes.NORMAL], ["ZQ"], + "Quit and don't save the session", + function () { liberator.quit(false); }); + } + + mappings.add([modes.NORMAL], ["ZZ"], + "Quit and save the session", + function () { liberator.quit(true); }); + }); + + registerObserver("load_commands", function () + { + commands.add(["addo[ns]"], + "Manage available Extensions and Themes", + function () + { + liberator.open("chrome://mozapps/content/extensions/extensions.xul", + (options["newtab"] && options.get("newtab").has("all", "addons")) + ? liberator.NEW_TAB: liberator.CURRENT_TAB); + }, + { argCount: "0" }); + + commands.add(["beep"], + "Play a system beep", + function () { liberator.beep(); }, + { argCount: "0" }); + + commands.add(["dia[log]"], + "Open a " + config.name + " dialog", + function (args) + { + args = args[0]; + + try + { + var dialogs = config.dialogs; + for (let i = 0; i < dialogs.length; i++) + { + if (dialogs[i][0] == args) + return dialogs[i][2](); + } + liberator.echoerr(args ? "Dialog \"" + args + "\" not available" : "E474: Invalid argument"); + } + catch (e) + { + liberator.echoerr("Error opening '" + args + "': " + e); + } + }, + { + argCount: "1", + bang: true, + completer: function (context, args) completion.dialog(context) + }); + + // TODO: move this + function getMenuItems() + { + function addChildren(node, parent) + { + for (let [,item] in Iterator(node.childNodes)) + { + if (item.childNodes.length == 0 && item.localName == "menuitem" + && !/rdf:http:/.test(item.label)) // FIXME + { + item.fullMenuPath = parent + item.label; + items.push(item); + } + else + { + path = parent; + if (item.localName == "menu") + path += item.label + "."; + addChildren(item, path); + } + } + } + + let items = []; + addChildren(document.getElementById(config.guioptions["m"]), ""); + return items; + } + + commands.add(["em[enu]"], + "Execute the specified menu item from the command line", + function (args) + { + args = args.string; + let items = getMenuItems(); + + if (!items.some(function (i) i.fullMenuPath == args)) + { + liberator.echoerr("E334: Menu not found: " + args); + return; + } + + for (let [i, item] in Iterator(items)) + { + if (item.fullMenuPath == args) + item.doCommand(); + } + }, + { + argCount: "1", + // TODO: add this as a standard menu completion function + completer: function (context) + { + context.title = ["Menu Path", "Label"]; + context.keys = { text: "fullMenuPath", description: "label" }; + context.completions = getMenuItems(); + }, + literal: 0 + }); + + commands.add(["exe[cute]"], + "Execute the argument as an Ex command", + // FIXME: this should evaluate each arg separately then join + // with " " before executing. + // E.g. :execute "source" io.getRCFile().path + // Need to fix commands.parseArgs which currently strips the quotes + // from quoted args + function (args) + { + try + { + var cmd = liberator.eval(args.string); + liberator.execute(cmd); + } + catch (e) + { + liberator.echoerr(e); + return; + } + }); + + commands.add(["exu[sage]"], + "List all Ex commands with a short description", + function (args) { showHelpIndex("ex-cmd-index", commands, args.bang); }, + { + argCount: "0", + bang: true + }); + + commands.add(["h[elp]"], + "Display help", + function (args) + { + if (args.bang) + { + liberator.echoerr("E478: Don't panic!"); + return; + } + + liberator.help(args.string); + }, + { + argCount: "?", + bang: true, + completer: function (context) completion.help(context), + literal: 0 // FIXME: why literal? + }); + + commands.add(["javas[cript]", "js"], + "Run a JavaScript command through eval()", + function (args) + { + if (args.bang) // open javascript console + { + liberator.open("chrome://global/content/console.xul", + (options["newtab"] && options.get("newtab").has("all", "javascript")) + ? liberator.NEW_TAB : liberator.CURRENT_TAB); + } + else + { + try + { + liberator.eval(args.string); + } + catch (e) + { + liberator.echoerr(e); + } + } + }, + { + bang: true, + completer: function (context) completion.javascript(context), + hereDoc: true, + literal: 0 + }); + + commands.add(["loadplugins", "lpl"], + "Load all plugins immediately", + function () { liberator.loadPlugins(); }, + { argCount: "0" }); + + commands.add(["norm[al]"], + "Execute Normal mode commands", + function (args) { events.feedkeys(args.string, args.bang); }, + { + argCount: "+", + bang: true + }); + + commands.add(["optionu[sage]"], + "List all options with a short description", + function (args) { showHelpIndex("option-index", options, args.bang); }, + { + argCount: "0", + bang: true + }); + + commands.add(["q[uit]"], + liberator.has("tabs") ? "Quit current tab" : "Quit application", + function (args) + { + if (liberator.has("tabs")) + tabs.remove(getBrowser().mCurrentTab, 1, false, 1); + else + liberator.quit(false, args.bang); + }, + { + argCount: "0", + bang: true + }); + + commands.add(["res[tart]"], + "Force " + config.name + " to restart", + function () { liberator.restart(); }, + { argCount: "0" }); + + commands.add(["time"], + "Profile a piece of code or run a command multiple times", + function (args) + { + let count = args.count; + let special = args.bang; + args = args.string; + + if (args[0] == ":") + var method = function () liberator.execute(args); + else + method = liberator.eval("(function () {" + args + "})"); + + try + { + if (count > 1) + { + let each, eachUnits, totalUnits; + let total = 0; + + for (let i in util.interruptableRange(0, count, 500)) + { + let now = Date.now(); + method(); + total += Date.now() - now; + } + + if (special) + return; + + if (total / count >= 100) + { + each = total / 1000.0 / count; + eachUnits = "sec"; + } + else + { + each = total / count; + eachUnits = "msec"; + } + + if (total >= 100) + { + total = total / 1000.0; + totalUnits = "sec"; + } + else + { + totalUnits = "msec"; + } + + var str = template.generic( + <table> + <tr highlight="Title" align="left"> + <th colspan="3">Code execution summary</th> + </tr> + <tr><td>  Executed:</td><td align="right"><span class="times-executed">{count}</span></td><td>times</td></tr> + <tr><td>  Average time:</td><td align="right"><span class="time-average">{each.toFixed(2)}</span></td><td>{eachUnits}</td></tr> + <tr><td>  Total time:</td><td align="right"><span class="time-total">{total.toFixed(2)}</span></td><td>{totalUnits}</td></tr> + </table>); + commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + else + { + var beforeTime = Date.now(); + method(); + + if (special) + return; + + var afterTime = Date.now(); + + if (afterTime - beforeTime >= 100) + liberator.echo("Total time: " + ((afterTime - beforeTime) / 1000.0).toFixed(2) + " sec"); + else + liberator.echo("Total time: " + (afterTime - beforeTime) + " msec"); + } + } + catch (e) + { + liberator.echoerr(e); + } + }, + { + argCount: "+", + bang: true, + completer: function (context) + { + if (/^:/.test(context.filter)) + return completion.ex(context); + else + return completion.javascript(context); + }, + count: true, + literal: 0 + }); + + commands.add(["ve[rsion]"], + "Show version information", + function (args) + { + if (args.bang) + liberator.open("about:"); + else + liberator.echo(":" + util.escapeHTML(commandline.getCommand()) + "\n" + + config.name + " " + liberator.version + + " running on:\n" + navigator.userAgent); + }, + { + argCount: "0", + bang: true + }); + + commands.add(["viu[sage]"], + "List all mappings with a short description", + function (args) { showHelpIndex("normal-index", mappings, args.bang); }, + { + argCount: "0", + bang: true + }); + }); + + // initially hide all GUI, it is later restored unless the user has :set go= or something + // similar in his config + function hideGUI() + { + var guioptions = config.guioptions; + for (let option in guioptions) + { + guioptions[option].forEach(function (elem) { + try + { + document.getElementById(elem).collapsed = true; + } + catch (e) {} + }); + } + } + + // return the platform normalised to Vim values + function getPlatformFeature() + { + let platform = navigator.platform; + + return /^Mac/.test(platform) ? "MacUnix" : platform == "Win32" ? "Win32" : "Unix"; + } + + // show a usage index either in the MOW or as a full help page + function showHelpIndex(tag, items, inMow) + { + if (inMow) + liberator.echo(template.usage(items), commandline.FORCE_MULTILINE); + else + liberator.help(tag); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + modules: modules, + + get mode() modes.main, + set mode(value) modes.main = value, + + // Global constants + CURRENT_TAB: 1, + NEW_TAB: 2, + NEW_BACKGROUND_TAB: 3, + NEW_WINDOW: 4, + + forceNewTab: false, + + // ###VERSION### and ###DATE### are replaced by the Makefile + version: "###VERSION### (created: ###DATE###)", + + // TODO: move to events.js? + input: { + buffer: "", // partial command storage + pendingMotionMap: null, // e.g. "d{motion}" if we wait for a motion of the "d" command + pendingArgMap: null, // pending map storage for commands like m{a-z} + count: -1 // parsed count from the input buffer + }, + + // @param type can be: + // "submit": when the user pressed enter in the command line + // "change" + // "cancel" + // "complete" + // TODO: "zoom": if the zoom value of the current buffer changed + // TODO: move to ui.js? + registerCallback: function (type, mode, func) + { + // TODO: check if callback is already registered + callbacks.push([type, mode, func]); + }, + + triggerCallback: function (type, mode, data) + { + // liberator.dump("type: " + type + " mode: " + mode + "data: " + data + "\n"); + for (let i = 0; i < callbacks.length; i++) + { + var [thistype, thismode, thisfunc] = callbacks[i]; + if (mode == thismode && type == thistype) + return thisfunc.call(this, data); + } + return false; + }, + + registerObserver: registerObserver, + + triggerObserver: function (type, data) + { + for (let [,[thistype, callback]] in Iterator(observers)) + { + if (thistype == type) + callback(data); + } + }, + + beep: function () + { + // FIXME: popups clear the command-line + if (options["visualbell"]) + { + // flash the visual bell + let popup = document.getElementById("liberator-visualbell"); + let win = config.visualbellWindow; + let rect = win.getBoundingClientRect(); + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + + // NOTE: this doesn't seem to work in FF3 with full box dimensions + popup.openPopup(win, "overlap", 1, 1, false, false); + popup.sizeTo(width - 2, height - 2); + setTimeout(function () { popup.hidePopup(); }, 20); + } + else + { + let soundService = Components.classes["@mozilla.org/sound;1"] + .getService(Components.interfaces.nsISound); + soundService.beep(); + } + return false; // so you can do: if (...) return liberator.beep(); + }, + + newThread: function () threadManager.newThread(0), + + callAsync: function (thread, self, func) + { + hread = thread || threadManager.newThread(0); + thread.dispatch(new Runnable(self, func, Array.slice(arguments, 2)), thread.DISPATCH_NORMAL); + }, + + // be sure to call GUI related methods like alert() or dump() ONLY in the main thread + callFunctionInThread: function (thread, func) + { + thread = thread || threadManager.newThread(0); + + // DISPATCH_SYNC is necessary, otherwise strange things will happen + thread.dispatch(new Runnable(null, func, Array.slice(arguments, 2)), thread.DISPATCH_SYNC); + }, + + // NOTE: "browser.dom.window.dump.enabled" preference needs to be set + dump: function (message) + { + if (typeof message == "object") + message = util.objectToString(message); + else + message += "\n"; + window.dump(("config" in modules && config.name.toLowerCase()) + ": " + message); + }, + + dumpStack: function (msg, frames) + { + let stack = Error().stack.replace(/(?:.*\n){2}/, ""); + if (frames != null) + [stack] = stack.match(RegExp("(?:.*\n){0," + frames + "}")); + liberator.dump((msg || "Stack") + "\n" + stack); + }, + + echo: function (str, flags) + { + commandline.echo(str, commandline.HL_NORMAL, flags); + }, + + // TODO: Vim replaces unprintable characters in echoerr/echomsg + echoerr: function (str, flags) + { + flags |= commandline.APPEND_TO_MESSAGES; + + if (typeof str == "object" && "echoerr" in str) + str = str.echoerr; + else if (str instanceof Error) + str = str.fileName + ":" + str.lineNumber + ": " + str; + + if (options["errorbells"]) + liberator.beep(); + + commandline.echo(str, commandline.HL_ERRORMSG, flags); + }, + + // TODO: add proper level constants + echomsg: function (str, verbosity, flags) + { + flags |= commandline.APPEND_TO_MESSAGES | commandline.FORCE_SINGLELINE; + + if (verbosity == null) + verbosity = 0; // verbosity level is exclusionary + + if (options["verbose"] >= verbosity) + commandline.echo(str, commandline.HL_INFOMSG, flags); + }, + + loadScript: function (uri, context) + { + let loader = Components.classes["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Components.interfaces.mozIJSSubScriptLoader); + loader.loadSubScript(uri, context); + }, + + eval: function (str, context) + { + try + { + if (!context) + context = userContext; + context[EVAL_ERROR] = null; + context[EVAL_STRING] = str; + context[EVAL_RESULT] = null; + this.loadScript("chrome://liberator/content/eval.js", context); + if (context[EVAL_ERROR]) + throw context[EVAL_ERROR]; + return context[EVAL_RESULT]; + } + finally + { + delete context[EVAL_ERROR]; + delete context[EVAL_RESULT]; + delete context[EVAL_STRING]; + } + }, + + // partial sixth level expression evaluation + // TODO: what is that really needed for, and where could it be used? + // Or should it be removed? (c) Viktor + // Better name? See other liberator.eval() + // I agree, the name is confusing, and so is the + // description --Kris + evalExpression: function (string) + { + string = string.toString().replace(/^\s*/, "").replace(/\s*$/, ""); + var matches = string.match(/^&(\w+)/); + if (matches) + { + var opt = this.options.get(matches[1]); + if (!opt) + { + this.echoerr("E113: Unknown option: " + matches[1]); + return; + } + var type = opt.type; + var value = opt.getter(); + if (type != "boolean" && type != "number") + value = value.toString(); + return value; + } + + // String + else if (matches = string.match(/^(['"])([^\1]*?[^\\]?)\1/)) + { + if (matches) + return matches[2].toString(); + else + { + this.echoerr("E115: Missing quote: " + string); + return; + } + } + + // Number + else if (matches = string.match(/^(\d+)$/)) + { + return parseInt(match[1], 10); + } + + var reference = this.variableReference(string); + if (!reference[0]) + this.echoerr("E121: Undefined variable: " + string); + else + return reference[0][reference[1]]; + + return; + }, + + // Execute an Ex command like str=":zoom 300" + execute: function (str, modifiers) + { + // skip comments and blank lines + if (/^\s*("|$)/.test(str)) + return; + + modifiers = modifiers || {}; + + let err = null; + let [count, cmd, special, args] = commands.parseCommand(str.replace(/^'(.*)'$/, "$1")); + let command = commands.get(cmd); + + if (command === null) + { + err = "E492: Not a " + config.name.toLowerCase() + " command: " + str; + liberator.focusContent(); + } + else if (command.action === null) + { + err = "E666: Internal error: command.action === null"; // TODO: need to perform this test? -- djk + } + else if (count != -1 && !command.count) + { + err = "E481: No range allowed"; + } + else if (special && !command.bang) + { + err = "E477: No ! allowed"; + } + + if (!err) + command.execute(args, special, count, modifiers); + else + liberator.echoerr(err); + }, + + // TODO: move to buffer.focus()? + // after pressing Escape, put focus on a non-input field of the browser document + // if clearFocusedElement, also blur a focused link + focusContent: function (clearFocusedElement) + { + let ww = Components.classes["@mozilla.org/embedcomp/window-watcher;1"] + .getService(Components.interfaces.nsIWindowWatcher); + if (window != ww.activeWindow) + return; + + let elem = config.mainWidget || window.content; + // TODO: make more generic + try + { + if (liberator.has("mail") && clearFocusedElement && !config.isComposeWindow) + { + let i = gDBView.selection.currentIndex; + if (i == -1 && gDBView.rowCount >= 0) + i = 0; + gDBView.selection.select(i); + } + + if (this.has("tabs")) + { + let frame = tabs.localStore.focusedFrame; + if (frame && frame.top == window.content) + elem = frame; + } + } + catch (e) {} + if (clearFocusedElement && document.commandDispatcher.focusedElement) + document.commandDispatcher.focusedElement.blur(); + if (elem && (elem != document.commandDispatcher.focusedElement)) + elem.focus(); + }, + + // does this liberator extension have a certain feature? + has: function (feature) config.features.indexOf(feature) >= 0, + + hasExtension: function (name) + { + let manager = Components.classes["@mozilla.org/extensions/manager;1"] + .getService(Components.interfaces.nsIExtensionManager); + let extensions = manager.getItemList(Components.interfaces.nsIUpdateItem.TYPE_EXTENSION, {}); + + return extensions.some(function (e) e.name == name); + }, + + help: function (topic) + { + var where = (options["newtab"] && options.get("newtab").has("all", "help")) + ? liberator.NEW_TAB : liberator.CURRENT_TAB; + + if (!topic) + { + var helpFile = options["helpfile"]; + + if (config.helpFiles.indexOf(helpFile) != -1) + liberator.open("chrome://liberator/locale/" + helpFile, where); + else + liberator.echo("Sorry, help file \"" + helpFile + "\" not found"); + + return; + } + + function jumpToTag(file, tag) + { + liberator.open("chrome://liberator/locale/" + file, where); + // TODO: it would be better to wait for pageLoad + setTimeout(function () { + let elem = buffer.evaluateXPath('//*[@class="tag" and text()="' + tag + '"]').snapshotItem(0); + if (elem) + window.content.scrollTo(0, elem.getBoundingClientRect().top - 10); // 10px context + else + liberator.dump('no element: ' + '@class="tag" and text()="' + tag + '"\n' ); + }, 500); + } + + var items = completion.runCompleter("help", topic); + var partialMatch = -1; + + for (let [i, item] in Iterator(items)) + { + if (item[0] == topic) + { + jumpToTag(item[1], item[0]); + return; + } + else if (partialMatch == -1 && item[0].indexOf(topic) > -1) + { + partialMatch = i; + } + } + + if (partialMatch > -1) + jumpToTag(items[partialMatch][1], items[partialMatch][0]); + else + liberator.echoerr("E149: Sorry, no help for " + topic); + }, + + globalVariables: {}, + + loadModule: function (name, func) { loadModule(name, func); }, + + loadPlugins: function () + { + // FIXME: largely duplicated for loading macros + try + { + let dirs = io.getRuntimeDirectories("plugin"); + + if (dirs.length == 0) + { + liberator.log("No user plugin directory found", 3); + return; + } + for (let [,dir] in Iterator(dirs)) + { + // TODO: search plugins/**/* for plugins + liberator.echomsg("Searching for \"plugin/*.{js,vimp}\" in \"" + dir.path + "\"", 2); + + liberator.log("Sourcing plugin directory: " + dir.path + "...", 3); + + let files = io.readDirectory(dir.path, true); + + files.forEach(function (file) { + if (!file.isDirectory() && /\.(js|vimp)$/i.test(file.path) && !(file.path in liberator.pluginFiles)) + { + try + { + io.source(file.path, false); + liberator.pluginFiles[file.path] = true; + } + catch (e) + { + liberator.reportError(e); + } + } + }); + } + } + catch (e) + { + // thrown if directory does not exist + liberator.log("Error sourcing plugin directory: " + e, 9); + } + }, + + // logs a message to the javascript error console + // if msg is an object, it is beautified + // TODO: add proper level constants + log: function (msg, level) + { + var verbose = 0; + if (typeof level != "number") // XXX + level = 1; + + // options does not exist at the very beginning + if (modules.options) + verbose = options.getPref("extensions.liberator.loglevel", 0); + + if (level > verbose) + return; + + if (typeof msg == "object") + msg = util.objectToString(msg, false); + + var consoleService = Components.classes["@mozilla.org/consoleservice;1"] + .getService(Components.interfaces.nsIConsoleService); + consoleService.logStringMessage(config.name.toLowerCase() + ": " + msg); + }, + + // open one or more URLs + // + // @param urls: either a string or an array of urls + // The array can look like this: + // ["url1", "url2", "url3", ...] or: + // [["url1", postdata1], ["url2", postdata2], ...] + // @param where: if ommited, CURRENT_TAB is assumed + // but NEW_TAB is set when liberator.forceNewTab is true. + // @param force: Don't prompt whether to open more than 20 tabs. + // @returns true when load was initiated, or false on error + open: function (urls, where, force) + { + // convert the string to an array of converted URLs + // -> see util.stringToURLArray for more details + if (typeof urls == "string") + urls = util.stringToURLArray(urls); + + if (urls.length > 20 && !force) + { + commandline.input("This will open " + urls.length + " new tabs. Would you like to continue? (yes/[no])", + function (resp) { + if (resp && resp.match(/^y(es)?$/i)) + liberator.open(urls, where, true); + }); + return true; + } + + if (urls.length == 0) + return false; + + function open(urls, where) + { + let url = Array.concat(urls)[0]; + let postdata = Array.concat(urls)[1]; + let whichwindow = window; + + // decide where to load the first url + switch (where) + { + case liberator.CURRENT_TAB: + getBrowser().loadURIWithFlags(url, nsIWebNavigation.LOAD_FLAGS_NONE, null, null, postdata); + break; + + case liberator.NEW_BACKGROUND_TAB: + case liberator.NEW_TAB: + if (!liberator.has("tabs")) + return open(urls, liberator.NEW_WINDOW); + + let tab = getBrowser().addTab(url, null, null, postdata); + if (where == liberator.NEW_TAB) + getBrowser().selectedTab = tab; + break; + + case liberator.NEW_WINDOW: + const wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + window.open(); + whichwindow = wm.getMostRecentWindow("navigator:browser"); + whichwindow.loadURI(url, null, postdata); + break; + + default: + liberator.echoerr("Exxx: Invalid 'where' directive in liberator.open(...)"); + return false; + } + } + + if (liberator.forceNewTab) + where = liberator.NEW_TAB; + else if (!where) + where = liberator.CURRENT_TAB; + + for (let [i, url] in Iterator(urls)) + { + open(url, where); + if (i == 0 && !liberator.has("tabs")) + break; + where = liberator.NEW_BACKGROUND_TAB; + } + + return true; + }, + + pluginFiles: {}, + + // namespace for plugins/scripts. Actually (only) the active plugin must/can set a + // v.plugins.mode = <str> string to show on v.modes.CUSTOM + // v.plugins.stop = <func> hooked on a v.modes.reset() + // v.plugins.onEvent = <func> function triggered, on keypresses (unless <esc>) (see events.js) + plugins: plugins, + + // quit liberator, no matter how many tabs/windows are open + quit: function (saveSession, force) + { + if (saveSession) + options.setPref("browser.startup.page", 3); // start with saved session + else + options.setPref("browser.startup.page", 1); // start with default homepage session + + const nsIAppStartup = Components.interfaces.nsIAppStartup; + if (force) + Components.classes["@mozilla.org/toolkit/app-startup;1"] + .getService(nsIAppStartup) + .quit(nsIAppStartup.eForceQuit); + else + window.goQuitApplication(); + }, + + reportError: function (error) + { + if (Components.utils.reportError) + Components.utils.reportError(error); + try + { + let obj = { + toString: function () error.toString(), + stack: <>{error.stack.replace(/^/mg, "\t")}</> + }; + for (let [k, v] in Iterator(error)) + { + if (!(k in obj)) + obj[k] = v; + } + liberator.dump(obj); + liberator.dump(""); + } + catch (e) {} + }, + + restart: function () + { + const nsIAppStartup = Components.interfaces.nsIAppStartup; + + // notify all windows that an application quit has been requested. + var os = Components.classes["@mozilla.org/observer-service;1"] + .getService(Components.interfaces.nsIObserverService); + var cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"] + .createInstance(Components.interfaces.nsISupportsPRBool); + os.notifyObservers(cancelQuit, "quit-application-requested", null); + + // something aborted the quit process. + if (cancelQuit.data) + return; + + // notify all windows that an application quit has been granted. + os.notifyObservers(null, "quit-application-granted", null); + + // enumerate all windows and call shutdown handlers + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var windows = wm.getEnumerator(null); + while (windows.hasMoreElements()) + { + var win = windows.getNext(); + if (("tryToClose" in win) && !win.tryToClose()) + return; + } + Components.classes["@mozilla.org/toolkit/app-startup;1"] + .getService(nsIAppStartup) + .quit(nsIAppStartup.eRestart | nsIAppStartup.eAttemptQuit); + }, + + // TODO: move to {muttator,vimperator,...}.js + // this function is called when the chrome is ready + startup: function () + { + let start = Date.now(); + liberator.log("Initializing liberator object...", 0); + + config.features = config.features || []; + config.features.push(getPlatformFeature()); + config.defaults = config.defaults || {}; + config.guioptions = config.guioptions || {}; + config.browserModes = config.browserModes || [modes.NORMAL]; + config.mailModes = config.mailModes || [modes.NORMAL]; + // TODO: suitable defaults? + //config.mainWidget + //config.mainWindowID + //config.visualbellWindow + //config.styleableChrome + config.autocommands = config.autocommands || []; + config.dialogs = config.dialogs || []; + config.helpFiles = config.helpFiles || []; + + // commands must always be the first module to be initialized + loadModule("commands", Commands); + loadModule("options", Options); + loadModule("mappings", Mappings); + loadModule("buffer", Buffer); + loadModule("events", Events); + loadModule("commandline", CommandLine); + loadModule("statusline", StatusLine); + loadModule("editor", Editor); + loadModule("autocommands", AutoCommands); + loadModule("io", IO); + loadModule("completion", Completion); + + // add options/mappings/commands which are only valid in this particular extension + if (config.init) + config.init(); + + liberator.log("All modules loaded", 3); + + // first time intro message + const firstTime = "extensions." + config.name.toLowerCase() + ".firsttime"; + if (options.getPref(firstTime, true)) + { + setTimeout(function () { + liberator.help(); + options.setPref(firstTime, false); + }, 1000); + } + + // always start in normal mode + modes.reset(); + + // TODO: we should have some class where all this guioptions stuff fits well + hideGUI(); + + // finally, read a ~/.vimperatorrc and plugin/**.{vimp,js} + // make sourcing asynchronous, otherwise commands that open new tabs won't work + setTimeout(function () { + + let rcFile = io.getRCFile("~"); + + if (rcFile) + io.source(rcFile.path, true); + else + liberator.log("No user RC file found", 3); + + if (options["exrc"]) + { + let localRcFile = io.getRCFile(io.getCurrentDirectory().path); + if (localRcFile) + io.source(localRcFile.path, true); + } + + if (options["loadplugins"]) + liberator.loadPlugins(); + + // after sourcing the initialization files, this function will set + // all gui options to their default values, if they have not been + // set before by any rc file + for (let option in options) + { + if (option.setter) + option.value = option.value; + } + + liberator.triggerObserver("enter", null); + autocommands.trigger(config.name + "Enter", {}); + }, 0); + + statusline.update(); + + liberator.dump("loaded in " + (Date.now() - start) + " ms"); + liberator.log(config.name + " fully initialized", 0); + }, + + shutdown: function () + { + autocommands.trigger(config.name + "LeavePre", {}); + + storage.saveAll(); + + liberator.triggerObserver("shutdown", null); + + liberator.dump("All liberator modules destroyed\n"); + + autocommands.trigger(config.name + "Leave", {}); + }, + + sleep: function (delay) + { + let mainThread = threadManager.mainThread; + + let end = Date.now() + delay; + while (Date.now() < end) + mainThread.processNextEvent(true); + return true; + }, + + callInMainThread: function (callback) + { + let mainThread = threadManager.mainThread; + if (!threadManager.isMainThread) + mainThread.dispatch({ run: callback }, mainThread.DISPATCH_NORMAL); + else + callback(); + }, + + threadYield: function (flush, interruptable) + { + let mainThread = threadManager.mainThread; + liberator.interrupted = false; + do + { + mainThread.processNextEvent(!flush); + if (liberator.interrupted) + throw new Error("Interrupted"); + } + while (flush && mainThread.hasPendingEvents()); + }, + + variableReference: function (string) + { + if (!string) + return [null, null, null]; + + var matches = string.match(/^([bwtglsv]):(\w+)/); + if (matches) // Variable + { + // Other variables should be implemented + if (matches[1] == "g") + { + if (matches[2] in this.globalVariables) + return [this.globalVariables, matches[2], matches[1]]; + else + return [null, matches[2], matches[1]]; + } + } + else // Global variable + { + if (string in this.globalVariables) + return [this.globalVariables, string, "g"]; + else + return [null, string, "g"]; + } + }, + + get windows() + { + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var wa = []; + var enumerator = wm.getEnumerator("navigator:browser"); + while (enumerator.hasMoreElements()) + wa.push(enumerator.getNext()); + return wa; + } + }; + //}}} +})(); //}}} + +window.liberator = liberator; + +// called when the chrome is fully loaded and before the main window is shown +window.addEventListener("load", liberator.startup, false); +window.addEventListener("unload", liberator.shutdown, false); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/liberator.xul b/common/content/liberator.xul new file mode 100644 index 00000000..fe7c63a7 --- /dev/null +++ b/common/content/liberator.xul @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<!-- ***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK ***** --> + +<?xml-stylesheet href="chrome://liberator/skin/liberator.css" type="text/css"?> +<!DOCTYPE overlay SYSTEM "liberator.dtd" [ + <!ENTITY liberator.content "chrome://liberator/content/"> +]> + +<overlay id="liberator" + xmlns:liberator="http://vimperator.org/namespaces/liberator" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:nc="http://home.netscape.com/NC-rdf#" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/x-javascript;version=1.8" src="&liberator.content;liberator-overlay.js"/> + + <window id="&liberator.mainWindow;"> + + <keyset id="mainKeyset"> + <key id="key_open_vimbar" key=":" oncommand="liberator.modules.commandline.open(':', '', liberator.modules.modes.EX);" modifiers=""/> + <key id="key_stop" keycode="VK_ESCAPE" oncommand="liberator.modules.events.onEscape();"/> + <!-- other keys are handled inside the event loop in events.js --> + </keyset> + + <popupset> + <panel id="liberator-visualbell" liberator:highlight="Bell"/> + </popupset> + + <!--this notifies us also of focus events in the XUL + from: http://developer.mozilla.org/en/docs/XUL_Tutorial:Updating_Commands !--> + <commandset id="onVimperatorFocus" + commandupdater="true" + events="focus" + oncommandupdate="if (typeof liberator.modules.events != 'undefined') liberator.modules.events.onFocusChange(event);"/> + <commandset id="onVimperatorSelect" + commandupdater="true" + events="select" + oncommandupdate="if (typeof liberator.modules.events != 'undefined') liberator.modules.events.onSelectionChange(event);"/> + + <!-- As of Firefox 3.1pre, <iframe>.height changes do not seem to have immediate effect, + therefore we need to put them into a <vbox> for which that works just fine --> + <vbox class="liberator-container" hidden="false" collapsed="true"> + <iframe id="liberator-multiline-output" src="chrome://liberator/content/buffer.xhtml" + flex="1" hidden="false" collapsed="false" + onclick="liberator.modules.commandline.onMultilineOutputEvent(event)"/> + </vbox> + + <vbox class="liberator-container" hidden="false" collapsed="true"> + <iframe id="liberator-completions" src="chrome://liberator/content/buffer.xhtml" + flex="1" hidden="false" collapsed="false" + onclick="liberator.modules.commandline.onMultilineOutputEvent(event)"/> + </vbox> + + <hbox id="liberator-commandline" hidden="false" liberator:highlight="Normal"> + <label class="plain" id="liberator-commandline-prompt" flex="0" crop="end" value="" collapsed="true"/> + <textbox class="plain" id="liberator-commandline-command" flex="1" type="timed" timeout="100" + oninput="liberator.modules.commandline.onEvent(event);" + onfocus="liberator.modules.commandline.onEvent(event);" + onblur="liberator.modules.commandline.onEvent(event);"/> + </hbox> + + <vbox class="liberator-container" hidden="false" collapsed="false"> + <textbox id="liberator-multiline-input" class="plain" flex="1" rows="1" hidden="false" collapsed="true" multiline="true" + onkeypress="liberator.modules.commandline.onMultilineInputEvent(event);" + oninput="liberator.modules.commandline.onMultilineInputEvent(event);" + onblur="liberator.modules.commandline.onMultilineInputEvent(event);"/> + </vbox> + + </window> + + <statusbar id="status-bar" liberator:highlight="StatusLine"> + <hbox insertbefore="&liberator.statusBefore;" insertafter="&liberator.statusAfter;" + id="liberator-statusline" flex="1" hidden="false" align="center"> + <textbox class="plain" id="liberator-statusline-field-url" readonly="false" flex="1" crop="end"/> + <label class="plain" id="liberator-statusline-field-inputbuffer" flex="0"/> + <label class="plain" id="liberator-statusline-field-progress" flex="0"/> + <label class="plain" id="liberator-statusline-field-tabcount" flex="0"/> + <label class="plain" id="liberator-statusline-field-bufferposition" flex="0"/> + </hbox> + <!-- just hide them since other elements expect them --> + <statusbarpanel id="statusbar-display" hidden="true"/> + <statusbarpanel id="statusbar-progresspanel" hidden="true"/> + </statusbar> + +</overlay> + +<!-- vim: set fdm=marker sw=4 ts=4 et: --> diff --git a/common/content/mappings.js b/common/content/mappings.js new file mode 100644 index 00000000..b10c4e42 --- /dev/null +++ b/common/content/mappings.js @@ -0,0 +1,414 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +// Do NOT create instances of this class yourself, use the helper method +// mappings.add() instead +function Map(modes, cmds, description, action, extraInfo) //{{{ +{ + if (!modes || (!cmds || !cmds.length) || !action) + return null; + + if (!extraInfo) + extraInfo = {}; + + this.modes = modes; + // only store keysyms with uppercase modifier strings + this.names = cmds.map(function (cmd) cmd.replace(/[casm]-/g, String.toUpperCase)); + this.action = action; + + this.flags = extraInfo.flags || 0; + this.description = description || ""; + this.rhs = extraInfo.rhs || null; + this.noremap = extraInfo.noremap || false; + this.silent = extraInfo.silent || false; +}; + +Map.prototype = { + + hasName: function (name) + { + return this.names.indexOf(name) >= 0; + }, + + execute: function (motion, count, argument) + { + var args = []; + + if (this.flags & Mappings.flags.MOTION) + args.push(motion); + if (this.flags & Mappings.flags.COUNT) + args.push(count); + if (this.flags & Mappings.flags.ARGUMENT) + args.push(argument); + + let self = this; + // FIXME: Kludge. + if (this.names[0] != ".") + mappings.repeat = function () self.action.apply(self, args); + + return this.action.apply(this, args); + } + +}; //}}} + +function Mappings() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var main = []; // default mappings + var user = []; // user created mappings + + for (let mode in modes) + { + main[mode] = []; + user[mode] = []; + } + + function addMap(map, userMap) + { + var where = userMap ? user : main; + map.modes.forEach(function (mode) { + if (!(mode in where)) + where[mode] = []; + where[mode].push(map); + }); + } + + function getMap(mode, cmd, stack) + { + var maps = stack[mode] || []; + + for (let [,map] in Iterator(maps)) + { + if (map.hasName(cmd)) + return map; + } + + return null; + } + + function removeMap(mode, cmd) + { + var maps = user[mode] || []; + var names; + + for (let [i, map] in Iterator(maps)) + { + for (let [j, name] in Iterator(map.names)) + { + if (name == cmd) + { + map.names.splice(j, 1); + if (map.names.length == 0) + maps.splice(i, 1); + return; + } + } + } + } + + function expandLeader(keyString) keyString.replace(/<Leader>/i, mappings.getMapLeader()) + + // Return all mappings present in all @modes + function mappingsIterator(modes, stack) + { + modes = modes.slice(); + return (map for ([i, map] in Iterator(stack[modes.shift()])) + if (modes.every(function (mode) stack[mode].some( + function (m) m.rhs == map.rhs && m.names[0] == map.names[0])))) + } + + function addMapCommands(ch, modes, modeDescription) + { + // 0 args -> list all maps + // 1 arg -> list the maps starting with args + // 2 args -> map arg1 to arg* + function map(args, mode, noremap) + { + if (!args.length) + { + mappings.list(mode); + return; + } + + // ?:\s+ <- don't remember; (...)? optional = rhs + let [lhs, rhs] = args; + + if (!rhs) // list the mapping + { + mappings.list(mode, expandLeader(lhs)); + } + else + { + for (let [,m] in Iterator(mode)) + { + mappings.addUserMap([m], [lhs], + "User defined mapping", + function (count) { events.feedkeys((count > 1 ? count : "") + this.rhs, this.noremap, this.silent); }, + { + flags: Mappings.flags.COUNT, + rhs: rhs, + noremap: !!noremap, + silent: "<silent>" in args + }); + } + } + } + + modeDescription = modeDescription ? " in " + modeDescription + " mode" : ""; + + const opts = { + completer: function (context, args) completion.userMapping(context, args, modes), + options: [ + [["<silent>", "<Silent>"], commands.OPTION_NOARG] + ], + literal: 1, + serial: function () { + let noremap = this.name.indexOf("noremap") > -1; + return [ + { + command: this.name, + options: map.silent ? { "<silent>": null } : {}, + arguments: [map.names[0]], + literalArg: map.rhs + } + for (map in mappingsIterator(modes, user)) + if (map.rhs && map.noremap == noremap) + ] + } + }; + + commands.add([ch ? ch + "m[ap]" : "map"], + "Map a key sequence" + modeDescription, + function (args) { map(args, modes, false); }, + opts); + + commands.add([ch + "no[remap]"], + "Map a key sequence without remapping keys" + modeDescription, + function (args) { map(args, modes, true); }, + opts); + + commands.add([ch + "mapc[lear]"], + "Remove all mappings" + modeDescription, + function () { modes.forEach(function (mode) { mappings.removeAll(mode); }); }, + { argCount: "0" }); + + commands.add([ch + "unm[ap]"], + "Remove a mapping" + modeDescription, + function (args) + { + args = args[0]; + + let found = false; + for (let [,mode] in Iterator(modes)) + { + if (mappings.hasMap(mode, args)) + { + mappings.remove(mode, args); + found = true; + } + } + if (!found) + liberator.echoerr("E31: No such mapping"); + }, + { + argCount: "1", + completer: function (context, args) completion.userMapping(context, args, modes) + }); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + addMapCommands("", [modes.NORMAL], ""); + addMapCommands("c", [modes.COMMAND_LINE], "command line"); + addMapCommands("i", [modes.INSERT, modes.TEXTAREA], "insert"); + if (liberator.has("mail")) + addMapCommands("m", [modes.MESSAGE], "message"); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + liberator.registerObserver("load_completion", function () + { + completion.setFunctionCompleter(mappings.get, + [ + null, + function (context, obj, args) + { + let mode = args[0] + return util.Array.flatten( + [ + [[name, map.description] for ([i, name] in Iterator(map.names))] + for ([i, map] in Iterator(user[mode].concat(main[mode]))) + ]) + } + ]); + }); + + // FIXME: + Mappings.flags = { + ALLOW_EVENT_ROUTING: 1 << 0, // if set, return true inside the map command to pass the event further to firefox + MOTION: 1 << 1, + COUNT: 1 << 2, + ARGUMENT: 1 << 3 + }; + + return { + + // NOTE: just normal mode for now + __iterator__: function () mappingsIterator([modes.NORMAL], main), + + // used by :mkvimperatorrc to save mappings + getUserIterator: function (mode) mappingsIterator(mode, user), + + add: function (modes, keys, description, action, extra) + { + addMap(new Map(modes, keys, description, action, extra), false); + }, + + addUserMap: function (modes, keys, description, action, extra) + { + keys = keys.map(expandLeader); + var map = new Map(modes, keys, description || "User defined mapping", action, extra); + + // remove all old mappings to this key sequence + for (let [,name] in Iterator(map.names)) + { + for (let [,mode] in Iterator(map.modes)) + removeMap(mode, name); + } + + addMap(map, true); + }, + + get: function (mode, cmd) + { + mode = mode || modes.NORMAL; + return getMap(mode, cmd, user) || getMap(mode, cmd, main); + }, + + getDefault: function (mode, cmd) + { + mode = mode || modes.NORMAL; + return getMap(mode, cmd, main); + }, + + // returns an array of mappings with names which START with "cmd" (but are NOT "cmd") + getCandidates: function (mode, cmd) + { + let mappings = user[mode].concat(main[mode]); + let matches = []; + + for (let [,map] in Iterator(mappings)) + { + for (let [,name] in Iterator(map.names)) + { + if (name.indexOf(cmd) == 0 && name.length > cmd.length) + { + // for < only return a candidate if it doesn't look like a <c-x> mapping + if (cmd != "<" || !/^<.+>/.test(name)) + matches.push(map); + } + } + } + + return matches; + }, + + getMapLeader: function () + { + var leaderRef = liberator.variableReference("mapleader"); + return leaderRef[0] ? leaderRef[0][leaderRef[1]] : "\\"; + }, + + // returns whether the user added a custom user map + hasMap: function (mode, cmd) + { + return user[mode].some(function (map) map.hasName(cmd)); + }, + + remove: function (mode, cmd) + { + removeMap(mode, cmd); + }, + + removeAll: function (mode) + { + user[mode] = []; + }, + + list: function (modes, filter) + { + let modeSign = ""; + modes.forEach(function (mode) + { + if (mode == modes.NORMAL) + modeSign += "n"; + if ((mode == modes.INSERT || mode == modes.TEXTAREA) && modeSign.indexOf("i") == -1) + modeSign += "i"; + if (mode == modes.COMMAND_LINE) + modeSign += "c"; + if (mode == modes.MESSAGRE) + modeSign += "m"; + }); + + let maps = mappingsIterator(modes, user); + if (filter) + maps = [map for (map in maps) if (map.names[0] == filter)]; + + let list = <table> + { + template.map(maps, function (map) + template.map(map.names, function (name) + <tr> + <td>{modeSign} {name}</td> + <td>{map.noremap ? "*" : " "}</td> + <td>{map.rhs || "function () { ... }"}</td> + </tr>)) + } + </table>; + + // TODO: Move this to an ItemList to show this automatically + if (list.*.length() == list.text().length()) + { + liberator.echo(<div highlight="Title">No mappings found</div>, commandline.FORCE_MULTILINE); + return; + } + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + + }; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/modes.js b/common/content/modes.js new file mode 100644 index 00000000..0f94f589 --- /dev/null +++ b/common/content/modes.js @@ -0,0 +1,294 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +const modes = (function () //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var main = 1; // NORMAL + var extended = 0; // NONE + + var passNextKey = false; + var passAllKeys = false; + var isRecording = false; + var isReplaying = false; // playing a macro + + var modeStack = []; + + function getModeMessage() + { + if (passNextKey && !passAllKeys) + return "-- PASS THROUGH (next) --"; + else if (passAllKeys && !passNextKey) + return "-- PASS THROUGH --"; + + // when recording a macro + var macromode = ""; + if (modes.isRecording) + macromode = "recording"; + else if (modes.isReplaying) + macromode = "replaying"; + + var ext = ""; + if (extended & modes.MENU) // TODO: desirable? + ext += " (menu)"; + ext += " --" + macromode; + + if (main in modeMap && typeof modeMap[main].display == "function") + return "-- " + modeMap[main].display() + ext; + return macromode; + } + + // NOTE: Pay attention that you don't run into endless loops + // Usually you should only indicate to leave a special mode like HINTS + // by calling modes.reset() and adding the stuff which is needed + // for its cleanup here + function handleModeChange(oldMode, newMode) + { + // TODO: fix v.log() to work with verbosity level + //liberator.log("switching from mode " + oldMode + " to mode " + newMode, 7); + //liberator.dump("switching from mode " + oldMode + " to mode " + newMode + "\n"); + + switch (oldMode) + { + case modes.TEXTAREA: + case modes.INSERT: + editor.unselectText(); + break; + + case modes.VISUAL: + if (newMode == modes.CARET) + { + // clear any selection made + var selection = window.content.getSelection(); + try + { // a simple if (selection) does not work + selection.collapseToStart(); + } + catch (e) {} + } + else + editor.unselectText(); + break; + + case modes.CUSTOM: + plugins.stop(); + break; + + case modes.COMMAND_LINE: + // clean up for HINT mode + if (modes.extended & modes.HINTS) + hints.hide(); + commandline.close(); + break; + } + + if (newMode == modes.NORMAL) + { + // disable caret mode when we want to switch to normal mode + var value = options.getPref("accessibility.browsewithcaret", false); + if (value) + options.setPref("accessibility.browsewithcaret", false); + + statusline.updateUrl(); + // Kludge to prevent the input field losing focus on MS/Mac + setTimeout(function () { + let focus = document.commandDispatcher.focusedElement; + let urlbar = document.getElementById("urlbar"); + if (!urlbar || focus != urlbar.inputField) + liberator.focusContent(false); + }, 100); + } + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var self = { + NONE: 0, + + __iterator__: function () util.Array.iterator(this.all), + + get all() mainModes.slice(), + + addMode: function (name, extended, display) { + let disp = name.replace("_", " ", "g"); + this[name] = 1 << lastMode++; + modeMap[name] = modeMap[this[name]] = { + extended: extended, + mask: this[name], + name: name, + display: display || function () disp + }; + if (!extended) + mainModes.push(this[name]); + }, + + // show the current mode string in the command line + show: function () + { + if (!options["showmode"]) + return; + + // never show mode messages if we are in command line mode + if (main == modes.COMMAND_LINE) + return; + + commandline.echo(getModeMessage(), commandline.HL_MODEMSG, + commandline.DISALLOW_MULTILINE); + }, + + // add/remove always work on the extended mode only + add: function (mode) + { + extended |= mode; + this.show(); + }, + + // helper function to set both modes in one go + // if silent == true, you also need to take care of the mode handling changes yourself + set: function (mainMode, extendedMode, silent) + { + // if a main mode is set, the extended is always cleared + if (typeof mainMode === "number") + { + main = mainMode; + if (!extendedMode) + extended = modes.NONE; + + if (!silent && mainMode != main) + handleModeChange(main, mainMode); + } + if (typeof extendedMode === "number") + extended = extendedMode; + + if (!silent) + this.show(); + }, + + push: function (mainMode, extendedMode, silent) + { + modeStack.push([main, extended]); + this.set(mainMode, extendedMode, silent); + }, + + pop: function (silent) + { + var a = modeStack.pop(); + if (a) + this.set(a[0], a[1], silent); + else + this.reset(silent); + }, + + setCustomMode: function (modestr, oneventfunc, stopfunc) + { + // TODO this.plugin[id]... ('id' maybe submode or what..) + plugins.mode = modestr; + plugins.onEvent = oneventfunc; + plugins.stop = stopfunc; + }, + + // keeps recording state + reset: function (silent) + { + modeStack = []; + if (config.isComposeWindow) + this.set(modes.COMPOSE, modes.NONE, silent); + else + this.set(modes.NORMAL, modes.NONE, silent); + }, + + remove: function (mode) + { + extended &= ~mode; + this.show(); + }, + + get passNextKey() passNextKey, + set passNextKey(value) { passNextKey = value; this.show(); }, + + get passAllKeys() passAllKeys, + set passAllKeys(value) { passAllKeys = value; this.show(); }, + + get isRecording() isRecording, + set isRecording(value) { isRecording = value; this.show(); }, + + get isReplaying() isReplaying, + set isReplaying(value) { isReplaying = value; this.show(); }, + + get main() main, + set main(value) { + if (value != main) + handleModeChange(main, value); + + main = value; + // setting the main mode always resets any extended mode + extended = modes.NONE; + this.show(); + }, + + get extended() extended, + set extended(value) { extended = value; this.show(); } + + }; + + var mainModes = [self.NONE]; + var lastMode = 0; + var modeMap = {}; + + // main modes, only one should ever be active + self.addMode("NORMAL", false, -1); + self.addMode("INSERT"); + self.addMode("VISUAL", false, function () "VISUAL" + (extended & modes.LINE ? " LINE" : "")); + self.addMode("COMMAND_LINE"); + self.addMode("CARET"); // text cursor is visible + self.addMode("TEXTAREA"); // text cursor is in a HTMLTextAreaElement + self.addMode("MESSAGE"); // for now only used in Muttator when the message has focus + self.addMode("COMPOSE"); + self.addMode("CUSTOM", false, function () plugins.mode); + // extended modes, can include multiple modes, and even main modes + self.addMode("EX", true); + self.addMode("HINTS", true); + self.addMode("INPUT_MULTILINE", true); + self.addMode("OUTPUT_MULTILINE", true); + self.addMode("SEARCH_FORWARD", true); + self.addMode("SEARCH_BACKWARD", true); + self.addMode("MENU", true); // a popupmenu is active + self.addMode("LINE", true); // linewise visual mode + self.addMode("RECORDING", true); + self.addMode("PROMPT", true); + + return self; + //}}} +})(); //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/options.js b/common/content/options.js new file mode 100644 index 00000000..e376c16e --- /dev/null +++ b/common/content/options.js @@ -0,0 +1,984 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +// do NOT create instances of this class yourself, use the helper method +// options.add() instead +function Option(names, description, type, defaultValue, extraInfo) //{{{ +{ + if (!names || !type) + return null; + + if (!extraInfo) + extraInfo = {}; + + this.name = names[0]; + this.names = names; + this.type = type; + this.scope = (extraInfo.scope & options.OPTION_SCOPE_BOTH) || + options.OPTION_SCOPE_GLOBAL; + // XXX set to BOTH by default someday? - kstep + this.description = description || ""; + + // "", 0 are valid default values + this.defaultValue = (defaultValue === undefined) ? null : defaultValue; + + this.setter = extraInfo.setter || null; + this.getter = extraInfo.getter || null; + this.completer = extraInfo.completer || null; + this.validator = extraInfo.validator || null; + this.checkHas = extraInfo.checkHas || null; + + // this property is set to true whenever the option is first set + // useful to see whether it was changed by some rc file + this.hasChanged = false; + + // add no{option} variant of boolean {option} to this.names + if (this.type == "boolean") + { + this.names = []; // reset since order is important + for (let [,name] in Iterator(names)) + { + this.names.push(name); + this.names.push("no" + name); + } + } + + if (this.globalvalue == undefined) + this.globalvalue = this.defaultValue; +} +Option.prototype = { + get globalvalue() options.store.get(this.name), + set globalvalue(val) { options.store.set(this.name, val) }, + + parseValues: function (value) + { + if (this.type == "stringlist") + return value.split(","); + if (this.type == "charlist") + return Array.slice(value); + return value; + }, + + joinValues: function (values) + { + if (this.type == "stringlist") + return values.join(","); + if (this.type == "charlist") + return values.join(""); + return values; + }, + + get values() this.parseValues(this.value), + set values(values) this.setValues(this.scope, values), + + getValues: function (scope) this.parseValues(this.get(scope)), + + setValues: function (values, scope) + { + this.set(this.joinValues(values), scope || this.scope); + }, + + get: function (scope) + { + if (scope) + { + if ((scope & this.scope) == 0) // option doesn't exist in this scope + return null; + } + else + { + scope = this.scope; + } + + var aValue; + + if (liberator.has("tabs") && (scope & options.OPTION_SCOPE_LOCAL)) + aValue = tabs.options[this.name]; + if ((scope & options.OPTION_SCOPE_GLOBAL) && (aValue == undefined)) + aValue = this.globalvalue; + + if (this.getter) + this.getter.call(this, aValue); + + return aValue; + }, + + set: function (newValue, scope) + { + scope = scope || this.scope; + if ((scope & this.scope) == 0) // option doesn't exist in this scope + return null; + + if (this.setter) + { + let tmpValue = newValue; + newValue = this.setter.call(this, newValue); + + if (newValue === undefined) + { + newValue = tmpValue; + liberator.log("DEPRECATED: '" + this.name + "' setter should return a value"); + } + } + + if (liberator.has("tabs") && (scope & options.OPTION_SCOPE_LOCAL)) + tabs.options[this.name] = newValue; + if ((scope & options.OPTION_SCOPE_GLOBAL) && newValue != this.globalValue) + this.globalvalue = newValue; + + this.hasChanged = true; + }, + + get value() this.get(), + set value(val) this.set(val), + + has: function () + { + let self = this; + let test = function (val) values.indexOf(val) >= 0; + if (this.checkHas) + test = function (val) values.some(function (value) self.checkHas(value, val)); + let values = this.values; + /* Return whether some argument matches */ + return Array.some(arguments, function (val) test(val)) + }, + + hasName: function (name) this.names.indexOf(name) >= 0, + + isValidValue: function (values) + { + if (this.validator) + return this.validator(values); + else + return true; + }, + + reset: function () + { + this.value = this.defaultValue; + }, + + op: function (operator, values, scope, invert) + { + let newValue = null; + let self = this; + + switch (this.type) + { + case "boolean": + if (operator != "=") + break; + + if (invert) + newValue = !this.value; + else + newValue = values; + break; + + case "number": + let value = parseInt(values); // deduce radix + + if (isNaN(value)) + return "E521: Number required"; + + switch (operator) + { + case "+": + newValue = this.value + value; + break; + case "-": + newValue = this.value - value; + break; + case "^": + newValue = this.value * value; + break; + case "=": + newValue = value; + break; + } + + break; + + case "charlist": + case "stringlist": + values = Array.concat(values); + switch (operator) + { + case "+": + newValue = util.Array.uniq(Array.concat(this.values, values), true); + break; + case "^": + // NOTE: Vim doesn't prepend if there's a match in the current value + newValue = util.Array.uniq(Array.concat(values, this.values), true); + break; + case "-": + newValue = this.values.filter(function (item) values.indexOf(item) == -1); + break; + case "=": + newValue = values; + if (invert) + { + let keepValues = this.values.filter(function (item) values.indexOf(item) == -1); + let addValues = values.filter(function (item) self.values.indexOf(item) == -1); + newValue = addValues.concat(keepValues); + } + break; + } + + break; + + case "string": + switch (operator) + { + case "+": + newValue = this.value + values; + break; + case "-": + newValue = this.value.replace(values, ""); + break; + case "^": + newValue = values + this.value; + break; + case "=": + newValue = values; + break; + } + + break; + + default: + return "E685: Internal error: option type `" + option.type + "' not supported"; + } + + if (newValue == null) + return "Operator " + operator + " not supported for option type " + this.type; + if (!this.isValidValue(newValue)) + return "E474: Invalid argument: " + values; + this.setValues(newValue, scope); + } +}; + // TODO: Run this by default? +Option.validateCompleter = function (values) +{ + let context = CompletionContext(""); + let res = context.fork("", 0, this, this.completer); + if (!res) + res = context.allItems.items.map(function (item) [item.text]); + return Array.concat(values).every( + function (value) res.some(function (item) item[0] == value)); +}; //}}} + +function Options() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var prefService = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); + var optionHash = {}; + + function optionObserver(key, event, option) + { + // Trigger any setters. + let opt = options.get(option); + if (event == "change" && opt) + opt.set(opt.value, options.OPTION_SCOPE_GLOBAL); + } + + storage.newMap("options", false); + storage.addObserver("options", optionObserver); + liberator.registerObserver("shutdown", function () { + storage.removeObserver("options", optionObserver); + }); + + function storePreference(name, value) + { + var type = prefService.getPrefType(name); + switch (typeof value) + { + case "string": + if (type == prefService.PREF_INVALID || type == prefService.PREF_STRING) + prefService.setCharPref(name, value); + else if (type == prefService.PREF_INT) + liberator.echoerr("E521: Number required after =: " + name + "=" + value); + else + liberator.echoerr("E474: Invalid argument: " + name + "=" + value); + break; + case "number": + if (type == prefService.PREF_INVALID || type == prefService.PREF_INT) + prefService.setIntPref(name, value); + else + liberator.echoerr("E474: Invalid argument: " + name + "=" + value); + break; + case "boolean": + if (type == prefService.PREF_INVALID || type == prefService.PREF_BOOL) + prefService.setBoolPref(name, value); + else if (type == prefService.PREF_INT) + liberator.echoerr("E521: Number required after =: " + name + "=" + value); + else + liberator.echoerr("E474: Invalid argument: " + name + "=" + value); + break; + default: + liberator.echoerr("Unknown preference type: " + typeof value + " (" + name + "=" + value + ")"); + } + } + + function loadPreference(name, forcedDefault, defaultBranch) + { + var defaultValue = null; // XXX + if (forcedDefault != null) // this argument sets defaults for non-user settable options (like extensions.history.comp_history) + defaultValue = forcedDefault; + + var branch = defaultBranch ? prefService.getDefaultBranch("") : prefService; + var type = prefService.getPrefType(name); + try + { + switch (type) + { + case prefService.PREF_STRING: + var value = branch.getComplexValue(name, Components.interfaces.nsISupportsString).data; + // try in case it's a localized string (will throw an exception if not) + if (!prefService.prefIsLocked(name) && !prefService.prefHasUserValue(name) && + /^chrome:\/\/.+\/locale\/.+\.properties/.test(value)) + value = branch.getComplexValue(name, Components.interfaces.nsIPrefLocalizedString).data; + return value; + case prefService.PREF_INT: + return branch.getIntPref(name); + case prefService.PREF_BOOL: + return branch.getBoolPref(name); + default: + return defaultValue; + } + } + catch (e) + { + return defaultValue; + } + } + + // + // firefox preferences which need to be changed to work well with vimperator + // + + // work around firefox popup blocker + var popupAllowedEvents = loadPreference("dom.popup_allowed_events", "change click dblclick mouseup reset submit"); + if (!/keypress/.test(popupAllowedEvents)) + { + storePreference("dom.popup_allowed_events", popupAllowedEvents + " keypress"); + liberator.registerObserver("shutdown", function () + { + if (loadPreference("dom.popup_allowed_events", "") + == popupAllowedEvents + " keypress") + storePreference("dom.popup_allowed_events", popupAllowedEvents); + }); + } + + // TODO: maybe reset in .destroy()? + // TODO: move to vim.js or buffer.js + // we have our own typeahead find implementation + storePreference("accessibility.typeaheadfind.autostart", false); + storePreference("accessibility.typeaheadfind", false); // actually the above setting should do it, but has no effect in firefox + + // start with saved session + storePreference("browser.startup.page", 3); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["let"], + "Set or list a variable", + function (args) + { + args = args.string; + + if (!args) + { + var str = + <table> + { + template.map(liberator.globalVariables, function ([i, value]) { + let prefix = typeof value == "number" ? "#" : + typeof value == "function" ? "*" : + " "; + return <tr> + <td style="width: 200px;">{i}</td> + <td>{prefix}{value}</td> + </tr> + }) + } + </table>; + if (str.*.length()) + liberator.echo(str, commandline.FORCE_MULTILINE); + else + liberator.echo("No variables found"); + return; + } + + var matches; + // 1 - type, 2 - name, 3 - +-., 4 - expr + if (matches = args.match(/([$@&])?([\w:]+)\s*([-+.])?=\s*(.+)/)) + { + if (!matches[1]) + { + var reference = liberator.variableReference(matches[2]); + if (!reference[0] && matches[3]) + { + liberator.echoerr("E121: Undefined variable: " + matches[2]); + return; + } + + var expr = liberator.evalExpression(matches[4]); + if (expr === undefined) + { + liberator.echoerr("E15: Invalid expression: " + matches[4]); + return; + } + else + { + if (!reference[0]) + { + if (reference[2] == "g") + reference[0] = liberator.globalVariables; + else + return; // for now + } + + if (matches[3]) + { + if (matches[3] == "+") + reference[0][reference[1]] += expr; + else if (matches[3] == "-") + reference[0][reference[1]] -= expr; + else if (matches[3] == ".") + reference[0][reference[1]] += expr.toString(); + } + else + reference[0][reference[1]] = expr; + } + } + } + // 1 - name + else if (matches = args.match(/^\s*([\w:]+)\s*$/)) + { + var reference = liberator.variableReference(matches[1]); + if (!reference[0]) + { + liberator.echoerr("E121: Undefined variable: " + matches[1]); + return; + } + + var value = reference[0][reference[1]]; + let prefix = typeof value == "number" ? "#" : + typeof value == "function" ? "*" : + " "; + liberator.echo(reference[1] + "\t\t" + prefix + value); + } + }); + + commands.add(["pref[erences]", "prefs"], + "Show " + config.hostApplication + " preferences", + function (args) + { + if (args.bang) // open Firefox settings GUI dialog + { + liberator.open("about:config", + (options["newtab"] && options.get("newtab").has("all", "prefs")) + ? liberator.NEW_TAB : liberator.CURRENT_TAB); + } + else + { + window.openPreferences(); + } + }, + { + argCount: "0", + bang: true + }); + + commands.add(["setl[ocal]"], + "Set local option", + function (args) + { + commands.get("set").execute(args.string, args.bang, args.count, { scope: options.OPTION_SCOPE_LOCAL }); + }, + { + bang: true, + count: true, + completer: function (context, args) + { + return commands.get("set").completer(context.filter, args.bang, args.count, { scope: options.OPTION_SCOPE_LOCAL }); + }, + literal: 0 + } + ); + + commands.add(["setg[lobal]"], + "Set global option", + function (args) + { + commands.get("set").execute(args.string, args.bang, args.count, { scope: options.OPTION_SCOPE_GLOBAL }); + }, + { + bang: true, + count: true, + completer: function (context, args) + { + return commands.get("set").completer(context.filter, args.bang, args.count, { scope: options.OPTION_SCOPE_GLOBAL }); + }, + literal: 0 + } + ); + + // TODO: support setting multiple options at once + commands.add(["se[t]"], + "Set an option", + function (args, modifiers) + { + let bang = args.bang; + args = args.string; + if (bang) + { + var onlyNonDefault = false; + if (!args) + { + args = "all"; + onlyNonDefault = true; + } + + let [matches, name, postfix, valueGiven, operator, value] = + args.match(/^\s*?([a-zA-Z0-9\.\-_{}]+)([?&!])?\s*(([-+^]?)=(.*))?\s*$/); + let reset = (postfix == "&"); + let invertBoolean = (postfix == "!"); + + if (name == "all" && reset) + liberator.echoerr("You can't reset all options, it could make " + config.hostApplication + " unusable."); + else if (name == "all") + options.listPrefs(onlyNonDefault, ""); + else if (reset) + options.resetPref(name); + else if (invertBoolean) + options.invertPref(name); + else if (valueGiven) + { + switch (value) + { + case undefined: + value = ""; + break; + case "true": + value = true; + break; + case "false": + value = false; + break; + default: + if (/^\d+$/.test(value)) + value = parseInt(value, 10); + } + options.setPref(name, value); + } + else + { + options.listPrefs(onlyNonDefault, name); + } + return; + } + + let opt = options.parseOpt(args, modifiers); + if (!opt) + { + liberator.echoerr("Error parsing :set command: " + args); + return; + } + + let option = opt.option; + if (option == null && !opt.all) + { + liberator.echoerr("No such option: " + opt.name); + return; + } + + // reset a variable to its default value + if (opt.reset) + { + if (opt.all) + { + for (let option in options) + option.reset(); + } + else + { + option.reset(); + } + } + // read access + else if (opt.get) + { + if (opt.all) + { + options.list(opt.onlyNonDefault, opt.scope); + } + else + { + if (option.type == "boolean") + liberator.echo((opt.optionValue ? " " : "no") + option.name); + else + liberator.echo(" " + option.name + "=" + opt.optionValue); + } + } + // write access + // NOTE: the behavior is generally Vim compatible but could be + // improved. i.e. Vim's behavior is pretty sloppy to no real benefit + else + { + if (opt.option.type == "boolean") + { + if (opt.valueGiven) + { + liberator.echoerr("E474: Invalid argument: " + args); + return; + } + opt.values = !opt.unsetBoolean; + } + let res = opt.option.op(opt.operator || "=", opt.values, opt.scope, opt.invert); + if (res) + liberator.echoerr(res); + } + }, + { + bang: true, + completer: function (context, args, modifiers) + { + let filter = context.filter; + var optionCompletions = []; + + if (args.bang) // list completions for about:config entries + { + if (filter[filter.length - 1] == "=") + { + context.advance(filter.length); + context.completions = [options.getPref(filter.substr(0, filter.length - 1)), "Current Value"]; + return; + } + + return completion.preference(context); + } + + let opt = options.parseOpt(filter, modifiers); + let prefix = opt.prefix; + + if (context.filter.indexOf("=") == -1) + { + if (prefix) + context.filters.push(function ({ item: opt }) opt.type == "boolean" || prefix == "inv" && opt.values instanceof Array); + return completion.option(context, opt.scope); + } + else if (prefix == "no") + return; + + if (prefix) + context.advance(prefix.length); + + let option = opt.option; + context.advance(context.filter.indexOf("=") + 1); + + if (!option) + { + context.message = "No such option: " + opt.name; + context.highlight(0, name.length, "SPELLCHECK"); + } + + if (opt.get || opt.reset || !option || prefix) + return; + + if (!opt.value) + { + context.fork("default", 0, this, function (context) { + context.title = ["Extra Completions"]; + context.completions = [[option.value, "Current value"], [option.defaultValue, "Default value"]].filter(function (f) f[0]) + }); + } + + completion.optionValue(context, opt.name, opt.operator); + }, + literal: 0, + serial: function () [ + { + command: this.name, + literalArg: opt.type == "boolean" ? (opt.value ? "" : "no") + opt.name + : opt.name + "=" + opt.value + } + for (opt in options) + if (!opt.getter && opt.value != opt.defaultValue && (opt.scope & options.OPTION_SCOPE_GLOBAL)) + ] + }); + + commands.add(["unl[et]"], + "Delete a variable", + function (args) + { + //var names = args.split(/ /); + //if (typeof names == "string") names = [names]; + + //var length = names.length; + //for (let i = 0, name = names[i]; i < length; name = names[++i]) + for (let [,name] in args) + { + var name = args[i]; + var reference = liberator.variableReference(name); + if (!reference[0]) + { + if (!args.bang) + liberator.echoerr("E108: No such variable: " + name); + return; + } + + delete reference[0][reference[1]]; + } + }, + { + argCount: "+", + bang: true + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + // TODO: Does this belong elsewhere? + liberator.registerObserver("load_completion", function () + { + completion.setFunctionCompleter(options.get, [function () ([o.name, o.description] for (o in options))]); + completion.setFunctionCompleter([options.getPref, options.setPref, options.resetPref, options.invertPref], + [function () Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch) + .getChildList("", { value: 0 }) + .map(function (pref) [pref, ""])]); + }); + + return { + + OPTION_SCOPE_GLOBAL: 1, + OPTION_SCOPE_LOCAL: 2, + OPTION_SCOPE_BOTH: 3, + + __iterator__: function () + { + let sorted = [o for ([i, o] in Iterator(optionHash))].sort(function (a, b) String.localeCompare(a.name, b.name)); + return (v for ([k, v] in Iterator(sorted))); + }, + + add: function (names, description, type, defaultValue, extraInfo) + { + if (!extraInfo) + extraInfo = {}; + + let option = new Option(names, description, type, defaultValue, extraInfo); + + if (!option) + return false; + + if (option.name in optionHash) + { + // never replace for now + liberator.log("Warning: '" + names[0] + "' already exists, NOT replacing existing option.", 1); + return false; + } + + // quickly access options with options["wildmode"]: + this.__defineGetter__(option.name, function () option.value); + this.__defineSetter__(option.name, function (value) { option.value = value; }); + + optionHash[option.name] = option; + return true; + }, + + get: function (name, scope) + { + if (!scope) + scope = options.OPTION_SCOPE_BOTH; + + if (name in optionHash) + return (optionHash[name].scope & scope) && optionHash[name]; + + for (let opt in Iterator(options)) + { + if (opt.hasName(name)) + return (opt.scope & scope) && opt; + } + + return null; + }, + + list: function (onlyNonDefault, scope) + { + if (!scope) + scope = options.OPTION_SCOPE_BOTH; + + let opts = function (opt) { + for (let opt in Iterator(options)) + { + let option = { + isDefault: opt.value == opt.defaultValue, + name: opt.name, + default: opt.defaultValue, + pre: "\u00a0\u00a0", /* Unicode nonbreaking space. */ + value: <></> + }; + + if (onlyNonDefault && option.isDefault) + continue; + if (!(opt.scope & scope)) + continue; + + if (opt.type == "boolean") + { + if (!opt.value) + option.pre = "no"; + option.default = (option.default ? "" : "no") + opt.name; + } + else + { + option.value = <>={template.highlight(opt.value)}</>; + } + yield option; + } + }; + + let list = template.options("Options", opts()); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + listPrefs: function (onlyNonDefault, filter) + { + if (!filter) + filter = ""; + + var prefArray = prefService.getChildList("", { value: 0 }); + prefArray.sort(); + let prefs = function () { + for each (let pref in prefArray) + { + var userValue = prefService.prefHasUserValue(pref); + if (onlyNonDefault && !userValue || pref.indexOf(filter) == -1) + continue; + + value = options.getPref(pref); + + let option = { + isDefault: !userValue, + default: loadPreference(pref, null, true), + value: <>={template.highlight(value, true, 100)}</>, + name: pref, + pre: "\u00a0\u00a0" /* Unicode nonbreaking space. */ + }; + + yield option; + } + }; + + let list = template.options(config.hostApplication + " Options", prefs()); + commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + }, + + parseOpt: function parseOpt(args, modifiers) + { + let ret = {}; + let matches, prefix, postfix, valueGiven; + + [matches, prefix, ret.name, postfix, valueGiven, ret.operator, ret.value] = + args.match(/^\s*(no|inv)?([a-z_]*)([?&!])?\s*(([-+^]?)=(.*))?\s*$/) || []; + + ret.args = args; + ret.onlyNonDefault = false; // used for :set to print non-default options + if (!args) + { + ret.name = "all"; + ret.onlyNonDefault = true; + } + + if (matches) + ret.option = options.get(ret.name, ret.scope); + + ret.prefix = prefix; + ret.postfix = postfix; + + ret.all = (ret.name == "all"); + ret.get = (ret.all || postfix == "?" || (ret.option && ret.option.type != "boolean" && !valueGiven)); + ret.invert = (prefix == "inv" || postfix == "!"); + ret.reset = (postfix == "&"); + ret.unsetBoolean = (prefix == "no"); + + ret.scope = modifiers && modifiers.scope; + + if (!ret.option) + return ret; + + if (ret.value === undefined) + ret.value = ""; + + ret.optionValue = ret.option.get(ret.scope); + ret.optionValues = ret.option.getValues(ret.scope); + + ret.values = ret.option.parseValues(ret.value); + + return ret; + }, + + get store() storage.options, + + getPref: function (name, forcedDefault) + { + return loadPreference(name, forcedDefault); + }, + + setPref: function (name, value) + { + return storePreference(name, value); + }, + + resetPref: function (name) + { + return prefService.clearUserPref(name); + }, + + // this works only for booleans + invertPref: function (name) + { + if (prefService.getPrefType(name) == prefService.PREF_BOOL) + this.setPref(name, !this.getPref(name)); + else + liberator.echoerr("E488: Trailing characters: " + name + "!"); + } + }; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/style.js b/common/content/style.js new file mode 100644 index 00000000..a3d7b12e --- /dev/null +++ b/common/content/style.js @@ -0,0 +1,582 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ + ©2008 Kris Maglione <maglione.k at Gmail> + Distributable under the terms of the MIT license, which allows + for sublicensing under any compatible license, including the MPL, + GPL, and MPL. Anyone who changes this file is welcome to relicense + it under any or all of those licenseses. +}}} ***** END LICENSE BLOCK *****/ + +Highlights.prototype.CSS = <![CDATA[ + Boolean color: red; + Function color: navy; + Null color: blue; + Number color: blue; + Object color: maroon; + String color: green; + + Normal color: black; background: white; + ErrorMsg color: white; background: red; font-weight: bold; + InfoMsg color: black; background: white; + ModeMsg color: black; background: white; + MoreMsg color: green; background: white; + WarningMsg color: red; background: white; + Message white-space: normal; min-width: 100%; padding-left: 2em; text-indent: -2em; display: block; + NonText color: blue; min-height: 16px; padding-left: 2px; + Preview color: gray; + + CompGroup + CompGroup:not(:first-of-type) margin-top: .5em; + CompTitle color: magenta; background: white; font-weight: bold; + CompTitle>* /* border-bottom: 1px dashed magenta; */ + CompMsg font-style: italic; margin-left: 16px; + CompItem + CompItem[selected] background: yellow; + CompItem>* padding: 0 .5ex; + CompIcon width: 16px; min-width: 16px; display: inline-block; margin-right: .5ex; + CompIcon>img max-width: 16px; max-height: 16px; vertical-align: middle; + CompResult width: 45%; overflow: hidden; + CompDesc color: gray; width: 50%; + CompLess text-align: center; height: 0; line-height: .5ex; padding-top: 1ex; + CompLess::after content: "\2303" /* Unicode up arrowhead */ + CompMore text-align: center; height: .5ex; line-height: .5ex; margin-bottom: -.5ex; + CompMore::after content: "\2304" /* Unicode down arrowhead */ + + Gradient height: 1px; margin-bottom: -1px; margin-top: -1px; + GradientLeft background-color: magenta; + GradientRight background-color: white; + + Indicator color: blue; + Filter font-weight: bold; + + Keyword color: red; + Tag color: blue; + + LineNr color: orange; background: white; + Question color: green; background: white; font-weight: bold; + + StatusLine color: white; background: black; + StatusLineBroken color: black; background: #FF6060; /* light-red */ + StatusLineSecure color: black; background: #B0FF00; /* light-green */ + + TabClose + TabIcon + TabText + TabNumber font-weight: bold; margin: 0px; padding-right: .3ex; + TabIconNumber { + font-weight: bold; + color: white; + text-align: center; + text-shadow: black -1px 0 1px, black 0 1px 1px, black 1px 0 1px, black 0 -1px 1px; + } + + Title color: magenta; background: white; font-weight: bold; + URL text-decoration: none; color: green; background: inherit; + URL:hover text-decoration: underline; cursor: pointer; + + FrameIndicator,,* { + background-color: red; + opacity: 0.5; + z-index: 999; + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + Bell border: none; background-color: black; + Hint,,* { + font-family: monospace; + font-size: 10px; + font-weight: bold; + color: white; + background-color: red; + border-color: ButtonShadow; + border-width: 0px; + border-style: solid; + padding: 0px 1px 0px 1px; + } + Hint::after,,* content: attr(number); + HintElem,,* background-color: yellow; color: black; + HintActive,,* background-color: #88FF00; color: black; + HintImage,,* opacity: .5; + + Search,,* { + font-size: inherit; + padding: 0; + color: black; + background-color: yellow; + padding: 0; + } + ]]>.toString(); +function Highlights(name, store, serial) +{ + var self = this; + var highlight = {}; + var styles = storage.styles; + + const Highlight = Struct("class", "selector", "filter", "default", "value"); + Highlight.defaultValue("filter", function () "chrome://liberator/content/buffer.xhtml" + "," + config.styleableChrome); + Highlight.defaultValue("selector", function () self.selector(this.class)); + Highlight.defaultValue("value", function () this.default); + Highlight.prototype.toString = function () "Highlight(" + this.class + ")\n\t" + [k + ": " + util.escapeString(v || "undefined") for ([k, v] in this)].join("\n\t"); + + function keys() [k for ([k, v] in Iterator(highlight))].sort(); + this.__iterator__ = function () (highlight[v] for ([k, v] in Iterator(keys()))); + + this.get = function (k) highlight[k]; + this.set = function (key, newStyle, force, append) + { + let [, class, selectors] = key.match(/^([a-zA-Z_-]+)(.*)/); + + if (!(class in highlight)) + return "Unknown highlight keyword: " + class; + + let style = highlight[key] || new Highlight(key); + styles.removeSheet(style.selector, null, null, null, true); + + if (append) + newStyle = (style.value || "").replace(/;?\s*$/, "; " + newStyle); + if (/^\s*$/.test(newStyle)) + newStyle = null; + if (newStyle == null) + { + if (style.default == null) + { + delete highlight[style.class]; + styles.removeSheet(style.selector, null, null, null, true); + return null; + } + newStyle = style.default; + force = true; + } + + let css = newStyle.replace(/(?:!\s*important\s*)?(?:;?\s*$|;)/g, "!important;") + .replace(";!important;", ";", "g"); // Seeming Spidermonkey bug + css = style.selector + " { " + css + " }"; + + let error = styles.addSheet(style.selector, style.filter, css, true, force); + if (error) + return error; + style.value = newStyle; + highlight[style.class] = style; + } + + this.selector = function (class) + { + let [, hl, rest] = class.match(/^(\w*)(.*)/); + return "[liberator|highlight~=" + hl + "]" + rest + }; + + this.reload = function () + { + this.CSS.replace(/\{((?:.|\n)*?)\}/g, function (_, _1) _1.replace(/\n\s*/g, " ")) + .split("\n").filter(function (s) /\S/.test(s)) + .forEach(function (style) + { + style = Highlight.apply(Highlight, Array.slice(style.match(/^\s*([^,\s]+)(?:,([^,\s]+)?)?(?:,([^,\s]+))?\s*(.*)$/), 1)); + let old = highlight[style.class]; + highlight[style.class] = style; + if (old && old.value != old.default) + style.value = old.value; + }); + for (let [class, hl] in Iterator(highlight)) + { + if (hl.value == hl.default) + this.set(class); + } + } +} + +function Styles(name, store, serial) +{ + /* Can't reference liberator or Components inside Styles -- + * they're members of the window object, which disappear + * with this window. + */ + const util = modules.util; + const sleep = liberator.sleep; + const storage = modules.storage; + const consoleService = Components.classes["@mozilla.org/consoleservice;1"] + .getService(Components.interfaces.nsIConsoleService); + const ios = Components.classes["@mozilla.org/network/io-service;1"] + .getService(Components.interfaces.nsIIOService); + const sss = Components.classes["@mozilla.org/content/style-sheet-service;1"] + .getService(Components.interfaces.nsIStyleSheetService); + const namespace = '@namespace html "' + XHTML + '";\n' + + '@namespace xul "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";\n' + + '@namespace liberator "' + NS.uri + '";\n'; + const Sheet = new Struct("name", "sites", "css", "ref"); + + let cssUri = function (css) "chrome-data:text/css," + encodeURI(css); + + let userSheets = []; + let systemSheets = []; + let userNames = {}; + let systemNames = {}; + + this.__iterator__ = function () Iterator(userSheets.concat(systemSheets)); + this.__defineGetter__("systemSheets", function () Iterator(systemSheets)); + this.__defineGetter__("userSheets", function () Iterator(userSheets)); + this.__defineGetter__("systemNames", function () Iterator(systemNames)); + this.__defineGetter__("userNames", function () Iterator(userNames)); + + this.addSheet = function (name, filter, css, system, force) + { + let sheets = system ? systemSheets : userSheets; + let names = system ? systemNames : userNames; + if (name && name in names) + this.removeSheet(name, null, null, null, system); + + let sheet = sheets.filter(function (s) s.sites.join(",") == filter && s.css == css)[0]; + if (!sheet) + sheet = new Sheet(name, filter.split(",").filter(util.identity), css, null); + + if (sheet.ref == null) // Not registered yet + { + sheet.ref = []; + try + { + this.registerSheet(cssUri(wrapCSS(sheet)), !force); + } + catch (e) + { + return e.echoerr || e; + } + sheets.push(sheet); + } + if (name) + { + sheet.ref.push(name); + names[name] = sheet; + } + return null; + } + + this.findSheets = function (name, filter, css, index, system) + { + let sheets = system ? systemSheets : userSheets; + let names = system ? systemNames : userNames; + + // Grossly inefficient. + let matches = [k for ([k, v] in Iterator(sheets))]; + if (index) + matches = String(index).split(",").filter(function (i) i in sheets); + if (name) + matches = matches.filter(function (i) sheets[i] == names[name]); + if (css) + matches = matches.filter(function (i) sheets[i].css == css); + if (filter) + matches = matches.filter(function (i) sheets[i].sites.indexOf(filter) >= 0); + return matches.map(function (i) sheets[i]); + }; + + this.removeSheet = function (name, filter, css, index, system) + { + let self = this; + let sheets = system ? systemSheets : userSheets; + let names = system ? systemNames : userNames; + + if (filter && filter.indexOf(",") > -1) + return filter.split(",").reduce( + function (n, f) n + self.removeSheet(name, f, index, system), 0); + + if (filter == undefined) + filter = ""; + + let matches = this.findSheets(name, filter, css, index, system); + if (matches.length == 0) + return; + + for (let [,sheet] in Iterator(matches.reverse())) + { + if (name) + { + if (sheet.ref.indexOf(name) > -1) + sheet.ref.splice(sheet.ref.indexOf(name), 1); + delete names[name]; + } + if (!sheet.ref.length) + { + this.unregisterSheet(cssUri(wrapCSS(sheet))); + if (sheets.indexOf(sheet) > -1) + sheets.splice(sheets.indexOf(sheet), 1); + } + if (filter) + { + let sites = sheet.sites.filter(function (f) f != filter); + if (sites.length) + this.addSheet(name, sites.join(","), css, system, true); + } + } + return matches.length; + } + + this.registerSheet = function (uri, doCheckSyntax, reload) + { + //dump (uri + "\n\n"); + if (doCheckSyntax) + checkSyntax(uri); + if (reload) + this.unregisterSheet(uri); + uri = ios.newURI(uri, null, null); + if (reload || !sss.sheetRegistered(uri, sss.USER_SHEET)) + sss.loadAndRegisterSheet(uri, sss.USER_SHEET); + } + + this.unregisterSheet = function (uri) + { + uri = ios.newURI(uri, null, null); + if (sss.sheetRegistered(uri, sss.USER_SHEET)) + sss.unregisterSheet(uri, sss.USER_SHEET); + } + + function wrapCSS(sheet) + { + let filter = sheet.sites; + let css = sheet.css; + if (filter[0] == "*") + return namespace + css; + let selectors = filter.map(function (part) (/[*]$/.test(part) ? "url-prefix" : + /[\/:]/.test(part) ? "url" + : "domain") + + '("' + part.replace(/"/g, "%22").replace(/[*]$/, "") + '")') + .join(", "); + return namespace + "@-moz-document " + selectors + "{\n" + css + "\n}\n"; + } + + let queryinterface = XPCOMUtils.generateQI([Components.interfaces.nsIConsoleListener]); + /* What happens if more than one thread tries to use this? */ + let testDoc = document.implementation.createDocument(XHTML, "doc", null); + function checkSyntax(uri) + { + let errors = []; + let listener = { + QueryInterface: queryinterface, + observe: function (message) + { + try + { + message = message.QueryInterface(Components.interfaces.nsIScriptError); + if (message.sourceName == uri) + errors.push(message); + } + catch (e) {} + } + }; + + try + { + consoleService.registerListener(listener); + if (testDoc.documentElement.firstChild) + testDoc.documentElement.removeChild(testDoc.documentElement.firstChild); + testDoc.documentElement.appendChild(util.xmlToDom( + <html><head><link type="text/css" rel="stylesheet" href={uri}/></head></html>, testDoc)); + + while (true) + { + try + { + // Throws NS_ERROR_DOM_INVALID_ACCESS_ERR if not finished loading + testDoc.styleSheets[0].cssRules.length; + break; + } + catch (e) + { + if (e.name != "NS_ERROR_DOM_INVALID_ACCESS_ERR") + return [e.toString()]; + sleep(10); + } + } + } + finally + { + consoleService.unregisterListener(listener); + } + if (errors.length) + { + let err = new Error("", errors[0].sourceName.replace(/^(chrome-data:text\/css,).*/, "$1..."), errors[0].lineNumber); + err.name = "CSSError" + err.message = errors.reduce(function (msg, e) msg + "; " + e.lineNumber + ": " + e.errorMessage, + errors.shift().errorMessage); + err.echoerr = err.fileName + ":" + err.lineNumber + ": " + err.message; + throw err; + } + } +} +let (array = util.Array) +{ + Styles.prototype = { + get sites() array.uniq(array.flatten([v.sites for ([k, v] in this.userSheets)])) + }; +} + +const styles = storage.newObject("styles", Styles, false); +const highlight = storage.newObject("highlight", Highlights, false); +highlight.CSS = Highlights.prototype.CSS; +highlight.reload(); + +liberator.triggerObserver("load_styles", "styles"); +liberator.triggerObserver("load_highlight", "highlight"); + +liberator.registerObserver("load_commands", function () +{ + // TODO: :colo default needs :hi clear + commands.add(["colo[rscheme]"], + "Load a color scheme", + function (args) + { + let scheme = args[0]; + + if (io.sourceFromRuntimePath(["colors/" + scheme + ".vimp"])) + autocommands.trigger("ColorScheme", { name: scheme }); + else + liberator.echoerr("E185: Cannot find color scheme " + scheme); + }, + { + argCount: "1", + completer: function (context) completion.colorScheme(context) + }); + + commands.add(["sty[le]"], + "Add or list user styles", + function (args) + { + let [filter, css] = args; + let name = args["-name"]; + + if (!css) + { + let list = Array.concat([i for (i in styles.userNames)], + [i for (i in styles.userSheets) if (!i[1].ref.length)]); + let str = template.tabular(["", "Filter", "CSS"], + ["padding: 0 1em 0 1ex; vertical-align: top", "padding: 0 1em 0 0; vertical-align: top"], + ([k, v[1].join(","), v[2]] + for ([i, [k, v]] in Iterator(list)) + if ((!filter || v[1].indexOf(filter) >= 0) && (!name || v[0] == name)))); + commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + } + else + { + if ("-append" in args) + { + let sheet = styles.findSheets(name, null, null, null, false)[0]; + if (sheet) + { + filter = sheet.sites.concat(filter).join(","); + css = sheet.css.replace(/;?\s*$/, "; " + css); + } + } + let err = styles.addSheet(name, filter, css, false, args.bang); + if (err) + liberator.echoerr(err); + } + }, + { + bang: true, + completer: function (context, args) { + let compl = []; + if (args.completeArg == 0) + { + try + { + compl.push([content.location.host, "Current Host"]); + compl.push([content.location.href, "Current URL"]); + } + catch (e) {} + context.completions = compl.concat([[s, ""] for each (s in styles.sites)]) + } + else if (args.completeArg == 1) + { + let sheet = styles.findSheets(args["-name"], null, null, null, false)[0]; + if (sheet) + context.completions = [[sheet.css, "Current Value"]]; + } + }, + hereDoc: true, + literal: 1, + options: [[["-name", "-n"], commands.OPTION_STRING, null, function () [[k, v.css] for ([k, v] in Iterator(styles.userNames))]], + [["-append", "-a"], commands.OPTION_NOARG]], + serial: function () [ + { + command: this.name, + bang: true, + options: sty.name ? { "-name": sty.name } : {}, + arguments: [sty.sites.join(",")], + literalArg: sty.css + } for ([k, sty] in styles.userSheets) + ] + }); + + commands.add(["dels[tyle]"], + "Remove a user stylesheet", + function (args) { + styles.removeSheet(args["-name"], args[0], args.literalArg, args["-index"], false); + }, + { + completer: function (context) { context.completions = styles.sites.map(function (site) [site, ""]); }, + literal: 1, + options: [[["-index", "-i"], commands.OPTION_INT, null, function () [[i, <>{s.sites.join(",")}: {s.css.replace("\n", "\\n")}</>] for ([i, s] in styles.userSheets)]], + [["-name", "-n"], commands.OPTION_STRING, null, function () [[k, v.css] for ([k, v] in Iterator(styles.userNames))]]] + }); + + commands.add(["hi[ghlight]"], + "Set the style of certain display elements", + function (args) + { + let style = <![CDATA[ + ; + display: inline-block !important; + position: static !important; + margin: 0px !important; padding: 0px !important; + width: 3em !important; min-width: 3em !important; max-width: 3em !important; + height: 1em !important; min-height: 1em !important; max-height: 1em !important; + overflow: hidden !important; + ]]>; + let [key, css] = args; + if (!css && !(key && args.bang)) + { + let str = template.tabular(["Key", "Sample", "CSS"], + ["padding: 0 1em 0 0; vertical-align: top", "text-align: center"], + ([h.class, + <span style={"text-align: center; line-height: 1em;" + h.value + style}>XXX</span>, + template.highlightRegexp(h.value, /\b[-\w]+(?=:)/g)] + for (h in highlight) + if (!key || h.class.indexOf(key) > -1))); + commandline.echo(str, commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + return; + } + let error = highlight.set(key, css, args.bang, "-append" in args); + if (error) + liberator.echoerr(error); + }, + { + bang: true, + // TODO: add this as a standard highlight completion function? + completer: function (context, args) + { + if (args.completeArg == 0) + context.completions = [[v.class, ""] for (v in highlight)]; + else if (args.completeArg == 1) + { + let hl = highlight.get(args[0]); + if (hl) + context.completions = [[hl.value, "Current Value"], [hl.default || "", "Default Value"]]; + } + }, + hereDoc: true, + literal: 1, + options: [[["-append", "-a"], commands.OPTION_NOARG]], + serial: function () [ + { + command: this.name, + arguments: [k], + literalArg: v + } + for ([k, v] in Iterator(highlight)) + if (v.value != v.default) + ] + }); +}); + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/tabs.js b/common/content/tabs.js new file mode 100644 index 00000000..80ba66af --- /dev/null +++ b/common/content/tabs.js @@ -0,0 +1,1000 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +// TODO: many methods do not work with Thunderbird correctly yet + +function Tabs() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var tabmail; + var getBrowser = (function () { + if (config.hostApplication == "Thunderbird") + { + return function () + { + if (!tabmail) + { + tabmail = document.getElementById('tabmail'); + tabmail.__defineGetter__('mTabContainer', function () this.tabContainer); + tabmail.__defineGetter__('mTabs', function () this.tabContainer.childNodes); + tabmail.__defineGetter__('mCurrentTab', function () this.tabContainer.selectedItem); + tabmail.__defineGetter__('mStrip', function () this.tabStrip); + tabmail.__defineGetter__('browsers', function () [browser for (browser in Iterator(this.mTabs))] ); + } + return tabmail; + }; + } + else + return window.getBrowser; + })(); + var alternates = [getBrowser().mCurrentTab, null]; + + // used for the "gb" and "gB" mappings to remember the last :buffer[!] command + var lastBufferSwitchArgs = ""; + var lastBufferSwitchSpecial = true; + + // @param spec can either be: + // - an absolute integer + // - "" for the current tab + // - "+1" for the next tab + // - "-3" for the tab, which is 3 positions left of the current + // - "$" for the last tab + function indexFromSpec(spec, wrap) + { + var position = getBrowser().mTabContainer.selectedIndex; + var length = getBrowser().mTabs.length; + var last = length - 1; + + if (spec === undefined || spec === "") + return position; + + if (typeof spec === "number") + position = spec; + else if (spec === "$") + position = last; + else if (/^[+-]\d+$/.test(spec)) + position += parseInt(spec, 10); + else if (/^\d+$/.test(spec)) + position = parseInt(spec, 10); + else + return -1; + + if (position > last) + position = wrap ? position % length : last; + else if (position < 0) + position = wrap ? (position % length) + length : 0; + + return position; + } + + function copyTab(to, from) + { + var ss = Components.classes["@mozilla.org/browser/sessionstore;1"] + .getService(Components.interfaces.nsISessionStore); + + if (!from) + from = getBrowser().mTabContainer.selectedItem; + + var tabState = ss.getTabState(from); + ss.setTabState(to, tabState); + } + + // hide tabs initially + if (config.name == "Vimperator") + getBrowser().mStrip.getElementsByClassName("tabbrowser-tabs")[0].collapsed = true; + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["showtabline", "stal"], + "Control when to show the tab bar of opened web pages", + "number", config.name == "Vimperator" ? 2 : 0, + { + setter: function (value) + { + let tabStrip = tabs.tabStrip; + + if (!tabStrip) + return; + + if (value == 0) + { + tabStrip.collapsed = true; + } + else + { + let pref = "browser.tabStrip.autoHide"; + if (options.getPref(pref) == null) + pref = "browser.tabs.autoHide"; + options.setPref(pref, value == 1); + tabStrip.collapsed = false; + } + + return value; + }, + completer: function (filter) + { + return [ + ["0", "Never show tab bar"], + ["1", "Show tab bar only if more than one tab is open"], + ["2", "Always show tab bar"] + ]; + }, + validator: Option.validateCompleter + }); + + if (config.name == "Vimperator") + { + options.add(["activate", "act"], + "Define when tabs are automatically activated", + "stringlist", "homepage,quickmark,tabopen,paste", + { + completer: function (filter) + { + return [ + ["homepage", "gH mapping"], + ["quickmark", "go and gn mappings"], + ["tabopen", ":tabopen[!] command"], + ["paste", "P and gP mappings"] + ]; + }, + validator: Option.validateCompleter + }); + + options.add(["newtab"], + "Define which commands should output in a new tab by default", + "stringlist", "", + { + completer: function (filter) + { + return [ + ["all", "All commands"], + ["addons", ":addo[ns] command"], + ["downloads", ":downl[oads] command"], + ["help", ":h[elp] command"], + ["javascript", ":javascript! or :js! command"], + ["prefs", ":pref[erences]! or :prefs! command"] + ]; + }, + validator: Option.validateCompleter + }); + + options.add(["popups", "pps"], + "Where to show requested popup windows", + "number", 1, + { + setter: function (value) + { + var values = [[0, 1], // always in current tab + [0, 3], // in a new tab + [2, 3], // in a new window if it has specified sizes + [1, 2], // always in new window + [2, 1]];// current tab unless it has specified sizes + + options.setPref("browser.link.open_newwindow.restriction", values[value][0]); + options.setPref("browser.link.open_newwindow", values[value][1]); + + return value; + }, + completer: function (filter) + { + return [ + ["0", "Force to open in the current tab"], + ["1", "Always open in a new tab"], + ["2", "Open in a new window if it has a specific requested size (default in Firefox)"], + ["3", "Always open in a new window"], + ["4", "Open in the same tab unless it has a specific requested size"] + ]; + }, + validator: Option.validateCompleter + }); + // TODO: Add option, or only apply when go~=[nN] + styles.addSheet("tab-binding", "chrome://browser/content/browser.xul", + ".tabbrowser-tab { -moz-binding: url(chrome://liberator/content/bindings.xml#tab) !important; }", true); + + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + mappings.add([modes.NORMAL], ["g0", "g^"], + "Go to the first tab", + function (count) { tabs.select(0); }); + + mappings.add([modes.NORMAL], ["g$"], + "Go to the last tab", + function (count) { tabs.select("$"); }); + + mappings.add([modes.NORMAL], ["gt", "<C-n>", "<C-Tab>", "<C-PageDown>"], + "Go to the next tab", + function (count) { tabs.select(count > 0 ? count - 1: "+1", count > 0 ? false : true); }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.NORMAL], ["gT", "<C-p>", "<C-S-Tab>", "<C-PageUp>"], + "Go to previous tab", + function (count) { tabs.select("-" + (count < 1 ? 1 : count), true); }, + { flags: Mappings.flags.COUNT }); + + if (config.name == "Vimperator") + { + mappings.add([modes.NORMAL], ["b"], + "Open a prompt to switch buffers", + function () { commandline.open(":", "buffer! ", modes.EX); }); + + mappings.add([modes.NORMAL], ["B"], + "Show buffer list", + function () { tabs.list(false); }); + + mappings.add([modes.NORMAL], ["d"], + "Delete current buffer", + function (count) { tabs.remove(getBrowser().mCurrentTab, count, false, 0); }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.NORMAL], ["D"], + "Delete current buffer, focus tab to the left", + function (count) { tabs.remove(getBrowser().mCurrentTab, count, true, 0); }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.NORMAL], ["gb"], + "Repeat last :buffer[!] command", + function (count) { tabs.switchTo(null, null, count, false); }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.NORMAL], ["gB"], + "Repeat last :buffer[!] command in reverse direction", + function (count) { tabs.switchTo(null, null, count, true); }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.NORMAL], ["u"], + "Undo closing of a tab", + function (count) { commands.get("undo").execute("", false, count); }, + { flags: Mappings.flags.COUNT }); + + mappings.add([modes.NORMAL], ["<C-^>", "<C-6>"], + "Select the alternate tab or the [count]th tab", + function (count) + { + if (count < 1) + tabs.selectAlternateTab(); + else + tabs.switchTo(count.toString(), false); + }, + { flags: Mappings.flags.COUNT }); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + commands.add(["bd[elete]", "bw[ipeout]", "bun[load]", "tabc[lose]"], + "Delete current buffer", + function (args) + { + let special = args.bang; + let count = args.count; + args = args.string; + + if (args) + { + args = args.toLowerCase(); + let removed = 0; + let matches = args.match(/^(\d+):?/); + + if (matches) + { + tabs.remove(tabs.getTab(parseInt(matches[1], 10) - 1)); + removed = 1; + } + else + { + let browsers = getBrowser().browsers; + for (let i = browsers.length - 1; i >= 0; i--) + { + let title = browsers[i].contentTitle.toLowerCase() || ""; + let uri = browsers[i].currentURI.spec.toLowerCase(); + let host = browsers[i].currentURI.host.toLowerCase(); + + if (host.indexOf(args) >= 0 || uri == args || + (special && (title.indexOf(args) >= 0 || uri.indexOf(args) >= 0))) + { + tabs.remove(tabs.getTab(i)); + removed++; + } + } + } + + if (removed > 0) + liberator.echo(removed + " fewer tab(s)"); + else + liberator.echoerr("E94: No matching tab for " + args); + } + else // just remove the current tab + tabs.remove(getBrowser().mCurrentTab, count > 0 ? count : 1, special, 0); + }, + { + bang: true, + count: true, + completer: function (context) completion.buffer(context), + literal: 0 + }); + + // TODO: this should open in a new tab positioned directly after the current one, not at the end + commands.add(["tab"], + "Execute a command and tell it to output in a new tab", + function (args) + { + liberator.forceNewTab = true; + liberator.execute(args.string); + liberator.forceNewTab = false; + }, + { + argCount: "+", + completer: function (context) completion.ex(context), + literal: 0 + }); + + commands.add(["tabl[ast]", "bl[ast]"], + "Switch to the last tab", + function () tabs.select("$", false), + { argCount: "0" }); + + // TODO: "Zero count" if 0 specified as arg + commands.add(["tabp[revious]", "tp[revious]", "tabN[ext]", "tN[ext]", "bp[revious]", "bN[ext]"], + "Switch to the previous tab or go [count] tabs back", + function (args) + { + let count = args.count; + args = args.string; + + // count is ignored if an arg is specified, as per Vim + if (args) + { + if (/^\d+$/.test(args)) + tabs.select("-" + args, true); // FIXME: urgh! + else + liberator.echoerr("E488: Trailing characters"); + } + else if (count > 0) + { + tabs.select("-" + count, true); + } + else + { + tabs.select("-1", true); + } + }, + { count: true }); + + // TODO: "Zero count" if 0 specified as arg + commands.add(["tabn[ext]", "tn[ext]", "bn[ext]"], + "Switch to the next or [count]th tab", + function (args) + { + let count = args.count; + args = args.string; + + if (args || count > 0) + { + var index; + + // count is ignored if an arg is specified, as per Vim + if (args) + { + if (/^\d+$/.test(args)) + { + index = args - 1; + } + else + { + liberator.echoerr("E488: Trailing characters"); + return; + } + } + else + { + index = count - 1; + } + + if (index < tabs.count) + tabs.select(index, true); + else + liberator.beep(); + } + else + { + tabs.select("+1", true); + } + }, + { count: true }); + + commands.add(["tabr[ewind]", "tabfir[st]", "br[ewind]", "bf[irst]"], + "Switch to the first tab", + function () { tabs.select(0, false); }, + { argCount: "0" }); + + if (config.name == "Vimperator") + { + // TODO: "Zero count" if 0 specified as arg, multiple args and count ranges? + commands.add(["b[uffer]"], + "Switch to a buffer", + function (args) + { + let count = args.count; + let special = args.special; + args = args.string; + + // if a numeric arg is specified any count is ignored; if a + // count and non-numeric arg are both specified then E488 + if (args && count > 0) + { + if (/^\d+$/.test(args)) + tabs.switchTo(args, special); + else + liberator.echoerr("E488: Trailing characters"); + } + else if (count > 0) + { + tabs.switchTo(count.toString(), special); + } + else + { + tabs.switchTo(args, special); + } + }, + { + argCount: "?", + bang: true, + count: true, + completer: function (context) completion.buffer(context), + literal: 0 + }); + + commands.add(["buffers", "files", "ls", "tabs"], + "Show a list of all buffers", + function (args) { tabs.list(args.literalArg); }, + { + argCount: "?", + literal: 0 + }); + + commands.add(["quita[ll]", "qa[ll]"], + "Quit " + config.name, + function (args) { liberator.quit(false, args.bang); }, + { + argCount: "0", + bang: true + }); + + commands.add(["reloada[ll]"], + "Reload all tab pages", + function (args) { tabs.reloadAll(args.bang); }, + { + argCount: "0", + bang: true + }); + + // TODO: add count support + commands.add(["tabm[ove]"], + "Move the current tab after tab N", + function (args) + { + let special = args.bang; + args = args.string; + + // FIXME: tabmove! N should probably produce an error + if (!/^([+-]?\d+|)$/.test(args)) + { + liberator.echoerr("E488: Trailing characters"); + return; + } + + if (!args) + args = "$"; // if not specified, move to the last tab + + tabs.move(getBrowser().mCurrentTab, args, special); + }, + { bang: true }); + + commands.add(["tabo[nly]"], + "Close all other tabs", + function () { tabs.keepOnly(getBrowser().mCurrentTab); }, + { argCount: "0" }); + + commands.add(["tabopen", "t[open]", "tabnew", "tabe[dit]"], + "Open one or more URLs in a new tab", + function (args) + { + let special = args.bang; + args = args.string; + + var where = special ? liberator.NEW_TAB : liberator.NEW_BACKGROUND_TAB; + if (/\btabopen\b/.test(options["activate"])) + where = special ? liberator.NEW_BACKGROUND_TAB : liberator.NEW_TAB; + + if (args) + liberator.open(args, where); + else + liberator.open("about:blank", where); + }, + { + bang: true, + completer: function (context) completion.url(context), + literal: 0 + }); + + commands.add(["tabde[tach]"], + "Detach current tab to its own window", + function () { tabs.detachTab(null); }, + { argCount: "0" }); + + commands.add(["tabd[uplicate]"], + "Duplicate current tab", + function (args) + { + var tab = tabs.getTab(); + + var activate = args.bang ? true : false; + if (/\btabopen\b/.test(options["activate"])) + activate = !activate; + + for (let i in util.range(0, Math.max(1, args.count))) + tabs.cloneTab(tab, activate); + }, + { + argCount: "0", + bang: true, + count: true + }); + } + + if (liberator.has("session")) + { + // TODO: extract common functionality of "undoall" + commands.add(["u[ndo]"], + "Undo closing of a tab", + function (args) + { + let count = args.count; + args = args[0] + + if (count < 1) + count = 1; + + if (args) + { + count = 0; + for (let [i, item] in Iterator(tabs.closedTabs)) + { + if (item.state.entries[0].url == args) + { + count = i + 1; + break; + } + } + + if (!count) + { + liberator.echoerr("Exxx: No matching closed tab"); + return; + } + } + + window.undoCloseTab(count - 1); + }, + { + argCount: "?", + completer: function (context) + { + context.keys = { text: function (item) item.state.entries[0].url, description: "title" }; + context.completions = tabs.closedTabs; + }, + count: true, + literal: 0 + }); + + commands.add(["undoa[ll]"], + "Undo closing of all closed tabs", + function (args) + { + for (let i in Itarator(tabs.closedTabs)) + window.undoCloseTab(0); + + }, + { argCount: "0" }); + + commands.add(["wqa[ll]", "wq", "xa[ll]"], + "Save the session and quit", + function () { liberator.quit(true); }, + { argCount: "0" }); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + get alternate() alternates[1], + + get browsers() + { + let browsers = getBrowser().browsers; + for (let i = 0; i < browsers.length; i++) + yield [i, browsers[i]]; + }, + + get count() getBrowser().mTabs.length, + + get options() + { + let store = this.localStore; + if (!("options" in store)) + store.options = {}; + return store.options; + }, + + get localStore() + { + let tab = this.getTab(); + if (!tab.liberatorStore) + tab.liberatorStore = {}; + return tab.liberatorStore; + }, + + get tabStrip() + { + if (config.hostApplication == "Firefox") + return getBrowser().mStrip.getElementsByClassName("tabbrowser-tabs")[0]; + else if (config.hostApplication == "Thunderbird") + return getBrowser().mStrip; + }, + + // @returns the index of the currently selected tab starting with 0 + index: function (tab) + { + if (tab) + return Array.indexOf(getBrowser().mTabs, tab); + + return getBrowser().mTabContainer.selectedIndex; + }, + + // TODO: implement filter + // @returns an array of tabs which match filter + get: function (filter) + { + var buffers = []; + for (let [i, browser] in this.browsers) + { + var title = browser.contentTitle || "(Untitled)"; + var uri = browser.currentURI.spec; + var number = i + 1; + buffers.push([number, title, uri]); + } + return buffers; + }, + + getContentIndex: function (content) + { + for (let [i, browser] in this.browsers) + { + if (browser.contentWindow == content) + return i; + if (browser.contentDocument == content) + return i; + } + }, + + getTab: function (index) + { + if (index != undefined) + return getBrowser().mTabs[index]; + + return getBrowser().mTabContainer.selectedItem; + }, + + get closedTabs() + { + const json = Components.classes["@mozilla.org/dom/json;1"] + .createInstance(Components.interfaces.nsIJSON); + const ss = Components.classes["@mozilla.org/browser/sessionstore;1"] + .getService(Components.interfaces.nsISessionStore); + return json.decode(ss.getClosedTabData(window)); + }, + + list: function (filter) + { + completion.listCompleter("buffer", filter); + }, + + // wrap causes the movement to wrap around the start and end of the tab list + // NOTE: position is a 0 based index + move: function (tab, spec, wrap) + { + var index = indexFromSpec(spec, wrap); + getBrowser().moveTabTo(tab, index); + }, + + // quitOnLastTab = 1: quit without saving session + // quitOnLastTab = 2: quit and save session + remove: function (tab, count, focusLeftTab, quitOnLastTab) + { + var removeOrBlankTab = { + Firefox: function (tab) + { + if (getBrowser().mTabs.length > 1) + getBrowser().removeTab(tab); + else + { + if (buffer.URL != "about:blank" || + window.getWebNavigation().sessionHistory.count > 0) + { + liberator.open("about:blank", liberator.NEW_BACKGROUND_TAB); + getBrowser().removeTab(tab); + } + else + liberator.beep(); + } + }, + Thunderbird: function (tab) + { + if (getBrowser().mTabs.length > 1) + getBrowser().removeTab(tab); + else + liberator.beep(); + } + }[config.hostApplication] || function () {}; + + if (typeof count != "number" || count < 1) + count = 1; + + if (quitOnLastTab >= 1 && getBrowser().mTabs.length <= count) + { + if (liberator.windows.length > 1) + window.close(); + else + liberator.quit(quitOnLastTab == 2); + + return; + } + + let index = this.index(tab); + if (focusLeftTab) + { + let lastRemovedTab = 0; + for (let i = index; i > index - count && i >= 0; i--) + { + removeOrBlankTab(this.getTab(i)); + lastRemovedTab = i > 0 ? i : 1; + } + getBrowser().mTabContainer.selectedIndex = lastRemovedTab - 1; + } + else + { + let i = index + count - 1; + if (i >= this.count) + i = this.count - 1; + + for (; i >= index; i--) + removeOrBlankTab(this.getTab(i)); + getBrowser().mTabContainer.selectedIndex = index; + } + }, + + keepOnly: function (tab) + { + getBrowser().removeAllTabsBut(tab); + }, + + select: function (spec, wrap) + { + var index = indexFromSpec(spec, wrap); + // FIXME: + if (index === -1) + { + liberator.beep(); // XXX: move to ex-handling? + return; + } + getBrowser().mTabContainer.selectedIndex = index; + }, + + reload: function (tab, bypassCache) + { + if (bypassCache) + { + const nsIWebNavigation = Components.interfaces.nsIWebNavigation; + const flags = nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY | nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + getBrowser().getBrowserForTab(tab).reloadWithFlags(flags); + } + else + { + getBrowser().reloadTab(tab); + } + }, + + reloadAll: function (bypassCache) + { + if (bypassCache) + { + for (let i = 0; i < getBrowser().mTabs.length; i++) + { + try + { + this.reload(getBrowser().mTabs[i], bypassCache); + } + catch (e) + { + // FIXME: can we do anything useful here without stopping the + // other tabs from reloading? + } + } + } + else + { + getBrowser().reloadAllTabs(); + } + }, + + // "buffer" is a string which matches the URL or title of a buffer, if it + // is null, the last used string is used again + switchTo: function (buffer, allowNonUnique, count, reverse) + { + if (buffer == "") + return; + + if (buffer != null) + { + // store this command, so it can be repeated with "B" + lastBufferSwitchArgs = buffer; + lastBufferSwitchSpecial = allowNonUnique; + } + else + { + buffer = lastBufferSwitchArgs; + if (allowNonUnique === undefined || allowNonUnique == null) // XXX + allowNonUnique = lastBufferSwitchSpecial; + } + + if (buffer == "#") + { + tabs.selectAlternateTab(); + return; + } + + if (!count || count < 1) + count = 1; + if (typeof reverse != "boolean") + reverse = false; + + let matches = buffer.match(/^(\d+):?/); + if (matches) + { + tabs.select(parseInt(matches[1], 10) - 1, false); // make it zero-based + return; + } + + matches = []; + let lowerBuffer = buffer.toLowerCase(); + let first = tabs.index() + (reverse ? 0 : 1); + let nbrowsers = getBrowser().browsers.length; + for (let [i,] in tabs.browsers) + { + let index = (i + first) % nbrowsers; + let url = getBrowser().getBrowserAtIndex(index).contentDocument.location.href; + let title = getBrowser().getBrowserAtIndex(index).contentDocument.title.toLowerCase(); + if (url == buffer) + { + tabs.select(index, false); + return; + } + + if (url.indexOf(buffer) >= 0 || title.indexOf(lowerBuffer) >= 0) + matches.push(index); + } + if (matches.length == 0) + liberator.echoerr("E94: No matching buffer for " + buffer); + else if (matches.length > 1 && !allowNonUnique) + liberator.echoerr("E93: More than one match for " + buffer); + else + { + if (reverse) + { + index = matches.length - count; + while (index < 0) + index += matches.length; + } + else + index = (count - 1) % matches.length; + + tabs.select(matches[index], false); + } + }, + + cloneTab: function (tab, activate) + { + var newTab = getBrowser().addTab(); + copyTab(newTab, tab); + + if (activate) + getBrowser().mTabContainer.selectedItem = newTab; + + return newTab; + }, + + detachTab: function (tab) + { + if (!tab) + tab = getBrowser().mTabContainer.selectedItem; + + window.open(); + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var win = wm.getMostRecentWindow("navigator:browser"); + + copyTab(win.getBrowser().mCurrentTab, tab); + this.remove(tab, 1, false, 1); + }, + + selectAlternateTab: function () + { + if (tabs.alternate == null || tabs.getTab() == tabs.alternate) + { + liberator.echoerr("E23: No alternate page"); + return; + } + + // NOTE: this currently relies on v.tabs.index() returning the + // currently selected tab index when passed null + var index = tabs.index(tabs.alternate); + + // TODO: since a tab close is more like a bdelete for us we + // should probably reopen the closed tab when a 'deleted' + // alternate is selected + if (index == -1) + liberator.echoerr("E86: Buffer does not exist"); // TODO: This should read "Buffer N does not exist" + else + tabs.select(index); + }, + + // NOTE: when restarting a session FF selects the first tab and then the + // tab that was selected when the session was created. As a result the + // alternate after a restart is often incorrectly tab 1 when there + // shouldn't be one yet. + updateSelectionHistory: function () + { + alternates = [this.getTab(), alternates[0]]; + } + }; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/template.js b/common/content/template.js new file mode 100644 index 00000000..b3c832fa --- /dev/null +++ b/common/content/template.js @@ -0,0 +1,305 @@ +const template = { + add: function add(a, b) a + b, + join: function join(c) function (a, b) a + c + b, + + map: function map(iter, fn, sep, interruptable) + { + if (iter.length) /* Kludge? */ + iter = util.Array.iterator(iter); + let ret = <></>; + let n = 0; + for each (let i in Iterator(iter)) + { + let val = fn(i); + if (val == undefined) + continue; + if (sep && n++) + ret += sep; + if (interruptable && n % interruptable == 0) + liberator.threadYield(true, true); + ret += val; + } + return ret; + }, + + maybeXML: function maybeXML(xml) + { + if (typeof xml == "xml") + return xml; + try + { + return new XMLList(xml); + } + catch (e) {} + return <>{xml}</>; + }, + + completionRow: function completionRow(item, class) + { + if (typeof icon == "function") + icon = icon(); + + if (class) + { + var text = item[0] || ""; + var desc = item[1] || ""; + } + else + { + var text = this.process[0].call(this, item, item.text || this.getKey(item, "text")); + var desc = this.process[1].call(this, item, this.getKey(item, "description")); + } + + return <div highlight={class || "CompItem"} style="white-space: nowrap"> + <!-- The non-breaking spaces prevent empty elements + - from pushing the baseline down and enlarging + - the row. + --> + <li highlight="CompResult">{text} </li> + <li highlight="CompDesc">{desc} </li> + </div>; + }, + + bookmarkDescription: function (item, text) + { + let extra = this.getKey(item, "extra"); + return <> + <a href="#" highlight="URL">{text}</a>  + { + !(extra && extra.length) ? "" : + <span class="extra-info"> + ({ + template.map(extra, function (e) + <>{e[0]}: <span highlight={e[2]}>{e[1]}</span></>, + <> </>/* Non-breaking space */) + }) + </span> + } + </> + }, + + icon: function (item, text) + { + let icon = this.getKey(item, "icon"); + return <><span highlight="CompIcon">{icon ? <img src={icon}/> : <></>}</span><span class="td-strut"/>{text}</> + }, + + filter: function (str) <span highlight="Filter">{str}</span>, + + // if "processStrings" is true, any passed strings will be surrounded by " and + // any line breaks are displayed as \n + highlight: function highlight(arg, processStrings, clip) + { + // some objects like window.JSON or getBrowsers()._browsers need the try/catch + let str = clip ? util.clip(String(arg), clip) : String(arg); + try + { + switch (arg == null ? "undefined" : typeof arg) + { + case "number": + return <span highlight="Number">{str}</span>; + case "string": + if (processStrings) + str = str.quote(); + return <span highlight="String">{str}</span>; + case "boolean": + return <span highlight="Boolean">{str}</span>; + case "function": + // Vim generally doesn't like /foo*/, because */ looks like a comment terminator. + // Using /foo*(:?)/ instead. + if (processStrings) + return <span highlight="Function">{str.replace(/\{(.|\n)*(?:)/g, "{ ... }")}</span>; + return <>{arg}</>; + case "undefined": + return <span highlight="Null">{arg}</span>; + case "object": + // for java packages value.toString() would crash so badly + // that we cannot even try/catch it + if (/^\[JavaPackage.*\]$/.test(arg)) + return <>[JavaPackage]</>; + if (processStrings && false) + str = template.highlightFilter(str, "\n", function () <span highlight="NonText">^J</span>); + return <span highlight="Object">{str}</span>; + case "xml": + return arg; + default: + return <![CDATA[<unknown type>]]>; + } + } + catch (e) + { + return<![CDATA[<unknown>]]>; + } + }, + + highlightFilter: function highlightFilter(str, filter, highlight) + { + return this.highlightSubstrings(str, (function () + { + if (filter.length == 0) + return; + let lcstr = String.toLowerCase(str); + let lcfilter = filter.toLowerCase(); + let start = 0; + while ((start = lcstr.indexOf(lcfilter, start)) > -1) + { + yield [start, filter.length]; + start += filter.length; + } + })(), highlight || template.filter); + }, + + highlightRegexp: function highlightRegexp(str, re, highlight) + { + return this.highlightSubstrings(str, (function () + { + let res; + while ((res = re.exec(str)) && res[0].length) + yield [res.index, res[0].length]; + })(), highlight || template.filter); + }, + + highlightSubstrings: function highlightSubstrings(str, iter, highlight) + { + if (typeof str == "xml") + return str; + if (str == "") + return <>{str}</>; + + str = String(str).replace(" ", "\u00a0"); + let s = <></>; + let start = 0; + let n = 0; + for (let [i, length] in iter) + { + if (n++ > 50) // Prevent infinite loops. + return s + <>{str.substr(start)}</>; + XML.ignoreWhitespace = false; + s += <>{str.substring(start, i)}</>; + s += highlight(str.substr(i, length)); + start = i + length; + } + return s + <>{str.substr(start)}</>; + }, + + highlightURL: function highlightURL(str, force) + { + if (force || /^[a-zA-Z]+:\/\//.test(str)) + return <a highlight="URL" href="#">{str}</a>; + else + return str; + }, + + generic: function generic(xml) + { + return <>:{commandline.getCommand()}<br/></> + xml; + }, + + // every item must have a .xml property which defines how to draw itself + // @param headers is an array of strings, the text for the header columns + genericTable: function genericTable(items, format) + { + this.listCompleter(function (context) { + context.filterFunc = null; + if (format) + context.format = format; + context.completions = items; + }); + }, + + jumps: function jumps(index, elems) + { + return this.generic( + <table> + <tr style="text-align: left;" highlight="Title"> + <th colspan="2">jump</th><th>title</th><th>URI</th> + </tr> + { + this.map(Iterator(elems), function ([idx, val]) + <tr> + <td class="indicator">{idx == index ? ">" : ""}</td> + <td>{Math.abs(idx - index)}</td> + <td style="width: 250px; max-width: 500px; overflow: hidden;">{val.title}</td> + <td><a href="#" highlight="URL jump-list">{val.URI.spec}</a></td> + </tr>) + } + </table>); + }, + + options: function options(title, opts) + { + return this.generic( + <table> + <tr highlight="Title" align="left"> + <th>--- {title} ---</th> + </tr> + { + this.map(opts, function (opt) + <tr> + <td> + <span style={opt.isDefault ? "" : "font-weight: bold"}>{opt.pre}{opt.name}</span><span>{opt.value}</span> + {opt.isDefault || opt.default == null ? "" : <span class="extra-info"> (default: {opt.default})</span>} + </td> + </tr>) + } + </table>); + }, + + table: function table(title, data, indent) + { + let table = + <table> + <tr highlight="Title" align="left"> + <th colspan="2">{title}</th> + </tr> + { + this.map(data, function (datum) + <tr> + <td style={"font-weight: bold; min-width: 150px; padding-left: " + (indent || "2ex")}>{datum[0]}</td> + <td>{template.maybeXML(datum[1])}</td> + </tr>) + } + </table>; + if (table.tr.length() > 1) + return table; + }, + + tabular: function tabular(headings, style, iter) + { + /* This might be mind-bogglingly slow. We'll see. */ + return this.generic( + <table> + <tr highlight="Title" align="left"> + { + this.map(headings, function (h) + <th>{h}</th>) + } + </tr> + { + this.map(iter, function (row) + <tr> + { + template.map(Iterator(row), function ([i, d]) + <td style={style[i] || ""}>{d}</td>) + } + </tr>) + } + </table>); + }, + + usage: function usage(iter) + { + return this.generic( + <table> + { + this.map(iter, function (item) + <tr> + <td highlight="Title" style="padding-right: 20px">{item.name || item.names[0]}</td> + <td>{item.description}</td> + </tr>) + } + </table>); + } +}; + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/ui.js b/common/content/ui.js new file mode 100644 index 00000000..5a3dda32 --- /dev/null +++ b/common/content/ui.js @@ -0,0 +1,1860 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +/* + * This class is used for prompting of user input and echoing of messages + * + * it consists of a prompt and command field + * be sure to only create objects of this class when the chrome is ready + */ +function CommandLine() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const UNINITIALIZED = {}; // notifies us, if we need to start history/tab-completion from the beginning + + storage.newArray("history-search", true); + storage.newArray("history-command", true); + + var inputHistory = { + get mode() (modes.extended == modes.EX) ? "command" : "search", + + get store() storage["history-" + this.mode], + + get length() this.store.length, + + get: function get(index) this.store.get(index), + + add: function add(str) + { + if (!str) + return; + + this.store.mutate('filter', function (line) line != str); + this.store.push(str); + this.store.truncate(options["history"], true); + } + }; + + var historyIndex = UNINITIALIZED; + var historyStart = ""; + + var messageHistory = { + _messages: [], + get messages() + { + let max = options["messages"]; + + // resize if 'messages' has changed + if (this._messages.length > max) + this._messages = this._messages.splice(this._messages.length - max); + + return this._messages; + }, + + get length() this._messages.length, + + add: function add(message) + { + if (!message) + return; + + if (this._messages.length >= options["messages"]) + this._messages.shift(); + + this._messages.push(message); + } + }; + var lastMowOutput = null; + + var silent = false; + + function Completions(context) + { + let self = this; + context.onUpdate = function () + { + self.reset(); + }; + this.context = context; + this.editor = context.editor; + this.selected = null; + this.wildmode = options.get("wildmode"); + this.itemList = completionList; + this.itemList.setItems(context); + this.reset(); + } + Completions.prototype = { + UP: {}, + DOWN: {}, + PAGE_UP: {}, + PAGE_DOWN: {}, + RESET: null, + + get completion() + { + let str = commandline.getCommand(); + return str.substring(this.prefix.length, str.length - this.suffix.length); + }, + set completion set_completion(completion) + { + this.previewClear(); + + // Change the completion text. + // The second line is a hack to deal with some substring + // preview corner cases. + commandWidget.value = this.prefix + completion + this.suffix; + this.editor.selection.focusNode.textContent = commandWidget.value; + + // Reset the caret to one position after the completion. + this.caret = this.prefix.length + completion.length; + }, + + get caret() this.editor.selection.focusOffset, + set caret(offset) + { + commandWidget.selectionStart = offset; + commandWidget.selectionEnd = offset; + }, + + get start() this.context.allItems.start, + + get items() this.context.allItems.items, + + get substring() this.context.longestAllSubstring, + + get wildtype() this.wildtypes[this.wildIndex] || "", + + get type() ({ + list: this.wildmode.checkHas(this.wildtype, "list"), + longest: this.wildmode.checkHas(this.wildtype, "longest"), + first: this.wildmode.checkHas(this.wildtype, ""), + full: this.wildmode.checkHas(this.wildtype, "full") + }), + + complete: function (show, tabPressed) + { + this.context.reset(); + this.context.tabPressed = tabPressed; + liberator.triggerCallback("complete", currentExtendedMode, this.context); + this.reset(show, tabPressed); + }, + + preview: function preview() + { + if (this.wildtype < 0 || this.suffix || !this.items.length) + return; + this.previewClear(); + + 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; + + // highlight="Preview" won't work in the editor. + let node = util.xmlToDom(<span style={highlight.get("Preview").value}>{substring}</span>, + document); + let start = this.caret; + this.editor.insertNode(node, this.editor.rootElement, 1); + this.caret = start; + }, + + previewClear: function previewClear() + { + let node = this.editor.rootElement.firstChild; + if (node && node.nextSibling) + this.editor.deleteNode(node.nextSibling); + else if (this.removeSubstring) + { + let str = this.removeSubstring; + let cmd = commandWidget.value; + if (cmd.substr(cmd.length - str.length) == str) + commandWidget.value = cmd.substr(0, cmd.length - str.length); + } + delete this.removeSubstring; + }, + + reset: function reset(show) + { + this.wildtypes = this.wildmode.values; + this.wildIndex = -1; + + this.prefix = this.context.value.substr(0, this.start); + this.value = this.context.value.substr(this.start, this.context.caret); + this.suffix = this.context.value.substr(this.context.caret); + + if (show) + { + this.itemList.reset(); + this.select(this.RESET); + this.wildIndex = 0; + } + + this.preview(); + }, + + select: function select(idx) + { + switch (idx) + { + case this.UP: + if (this.selected == null) + idx = this.items.length - 1; + else + idx = this.selected - 1; + break; + case this.DOWN: + if (this.selected == null) + idx = 0; + else + idx = this.selected + 1; + break; + case this.RESET: + idx = null; + break; + default: idx = Math.max(0, Math.max(this.items.length - 1, idx)); + } + this.itemList.selectItem(idx); + if (idx < 0 || idx >= this.items.length || idx == null) + { + // Wrapped. Start again. + this.selected = null; + this.completion = this.value; + } + else + { + this.selected = idx; + this.completion = this.items[idx].text; + } + }, + + tab: function tab(reverse) + { + // Check if we need to run the completer. + if (this.context.waitingForTab || this.wildIndex == -1) + this.complete(true, true); + + if (this.items.length == 0) + { + // No items. Wait for any unfinished completers. + let end = Date.now() + 5000; + while (this.context.incomplete && this.items.length == 0 && Date.now() < end) + liberator.threadYield(); + + if (this.items.length == 0) + return liberator.beep(); + } + + 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; + liberator.triggerCallback("change", currentExtendedMode, commandline.getCommand()); + } + break; + } + // Fallthrough + case "full": + this.select(reverse ? this.UP : this.DOWN) + break; + } + + if (this.type.list) + completionList.show(); + + this.wildIndex = Math.max(0, Math.min(this.wildtypes.length - 1, this.wildIndex + 1)); + this.preview(); + + statusTimer.tell(); + } + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// TIMERS ////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var statusTimer = new util.Timer(5, 100, function statusTell() { + if (completions.selected == null) + statusline.updateProgress(""); + else + statusline.updateProgress("match " + (completions.selected + 1) + " of " + completions.items.length); + }); + + var autocompleteTimer = new util.Timer(201, 300, function autocompleteTell(tabPressed) { + if (events.feedingKeys || !completions) + return; + completions.complete(true, false); + completions.itemList.show(); + }); + + var tabTimer = new util.Timer(10, 10, function tabTell(event) { + if (completions) + completions.tab(event.shiftKey); + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// CALLBACKS /////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + // callback for prompt mode + var promptSubmitCallback = null; + var promptChangeCallback = null; + var promptCompleter = null; + + liberator.registerCallback("submit", modes.EX, function (command) { liberator.execute(command); }); + liberator.registerCallback("complete", modes.EX, function (context) { + context.fork("ex", 0, completion, "ex"); + }); + liberator.registerCallback("change", modes.EX, function (command) { + if (options.get("wildoptions").has("auto")) + autocompleteTimer.tell(false); + else + completions.reset(); + }); + + liberator.registerCallback("cancel", modes.PROMPT, closePrompt); + liberator.registerCallback("submit", modes.PROMPT, closePrompt); + liberator.registerCallback("change", modes.PROMPT, function (str) { + if (promptChangeCallback) + return promptChangeCallback(str); + }); + liberator.registerCallback("complete", modes.PROMPT, function (context) { + if (promptCompleter) + promptCompleter(context); + }); + + function closePrompt(value) + { + let callback = promptSubmitCallback; + promptSubmitCallback = null; + currentExtendedMode = null; + commandline.clear(); + if (callback) + callback(value); + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// VARIABLES /////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const completionList = new ItemList("liberator-completions"); + var completions = null; + + var wildIndex = 0; // keep track how often we press <Tab> in a row + var startHints = false; // whether we're waiting to start hints mode + var lastSubstring = ""; + + // the containing box for the promptWidget and commandWidget + const commandlineWidget = document.getElementById("liberator-commandline"); + // the prompt for the current command, for example : or /. Can be blank + const promptWidget = document.getElementById("liberator-commandline-prompt"); + // the command bar which contains the current command + const commandWidget = document.getElementById("liberator-commandline-command"); + + commandWidget.inputField.QueryInterface(Components.interfaces.nsIDOMNSEditableElement); + + // the widget used for multiline output + const multilineOutputWidget = document.getElementById("liberator-multiline-output"); + const outputContainer = multilineOutputWidget.parentNode; + + multilineOutputWidget.contentDocument.body.id = "liberator-multiline-output-content"; + + // the widget used for multiline intput + const multilineInputWidget = document.getElementById("liberator-multiline-input"); + + // we need to save the mode which were in before opening the command line + // this is then used if we focus the command line again without the "official" + // way of calling "open" + var currentExtendedMode = null; // the extended mode which we last openend the command line for + var currentPrompt = null; + var currentCommand = null; + + // save the arguments for the inputMultiline method which are needed in the event handler + var multilineRegexp = null; + var multilineCallback = null; + + function setHighlightGroup(group) + { + commandlineWidget.setAttributeNS(NS.uri, "highlight", group); + } + + // sets the prompt - for example, : or / + function setPrompt(pmt, highlightGroup) + { + promptWidget.value = pmt; + + if (pmt) + { + promptWidget.size = pmt.length; + promptWidget.collapsed = false; + } + else + { + promptWidget.collapsed = true; + } + promptWidget.setAttributeNS(NS.uri, "highlight", highlightGroup || commandline.HL_NORMAL); + } + + // sets the command - e.g. 'tabopen', 'open http://example.com/' + function setCommand(cmd) + { + commandWidget.value = cmd; + } + + function setLine(str, highlightGroup, forceSingle) + { + setHighlightGroup(highlightGroup); + setPrompt(""); + setCommand(str); + if (!forceSingle && + commandWidget.inputField.editor.rootElement + .scrollWidth > commandWidget.inputField.scrollWidth) + { + setCommand(""); + setMultiline(<span highlight="Message">{str}</span>, highlightGroup); + } + } + + // TODO: extract CSS + // : resize upon a window resize + // : echoed lines longer than v-c-c.width should wrap and use MOW + function setMultiline(str, highlightGroup) + { + //outputContainer.collapsed = true; + let doc = multilineOutputWidget.contentDocument; + let win = multilineOutputWidget.contentWindow; + + /* 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"; + let output = util.xmlToDom(<div class={"ex-command-output "} style="white-space: nowrap" highlight={highlightGroup}>{template.maybeXML(str)}</div>, doc); + XML.ignoreWhitespace = true; + + lastMowOutput = output; + + // FIXME: need to make sure an open MOW is closed when commands + // that don't generate output are executed + if (outputContainer.collapsed) + doc.body.innerHTML = ""; + + doc.body.appendChild(output); + + commandline.updateOutputHeight(true); + + if (options["more"] && win.scrollMaxY > 0) + { + // start the last executed command's output at the top of the screen + let elements = doc.getElementsByClassName("ex-command-output"); + elements[elements.length - 1].scrollIntoView(true); + } + else + { + win.scrollTo(0, doc.height); + } + commandline.updateMorePrompt(); + + win.focus(); + + startHints = false; + modes.set(modes.COMMAND_LINE, modes.OUTPUT_MULTILINE); + } + + function autosizeMultilineInputWidget() + { + let lines = multilineInputWidget.value.split("\n").length - 1; + + if (lines == 0) + lines = 1; + + multilineInputWidget.setAttribute("rows", String(lines)); + } + + // used for the :echo[err] commands + function echoArgumentToString(arg, useColor) + { + if (!arg) + return ""; + + try + { + arg = liberator.eval(arg); + } + catch (e) + { + liberator.echoerr(e); + return null; + } + + if (typeof arg === "object") + arg = util.objectToString(arg, useColor); + else if (typeof arg != "xml") + arg = String(arg); + + return arg; + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["history", "hi"], + "Number of Ex commands and search patterns to store in the command-line history", + "number", 500, + { validator: function (value) value >= 0 }); + + options.add(["messages", "msgs"], + "Number of messages to store in the message history", + "number", 100, + { validator: function (value) value >= 0 }); + + options.add(["more"], + "Pause the message list window when more than one screen of listings is displayed", + "boolean", true); + + options.add(["showmode", "smd"], + "Show the current mode in the command line", + "boolean", true); + + options.add(["suggestengines"], + "Engine Alias which has a feature of suggest", + "stringlist", "google", + { + completer: function completer(value) + { + let ss = Components.classes["@mozilla.org/browser/search-service;1"] + .getService(Components.interfaces.nsIBrowserSearchService); + let engines = ss.getEngines({}) + .filter(function (engine) engine.supportsResponseType("application/x-suggestions+json")); + + return engines.map(function (engine) [engine.alias, engine.description]); + }, + validator: Option.validateCompleter + }); + + // TODO: these belong in ui.js + options.add(["complete", "cpt"], + "Items which are completed at the :[tab]open prompt", + "charlist", "sfl", + { + completer: function completer(filter) [k for each (k in completion.urlCompleters)], + validator: Option.validateCompleter + }); + + options.add(["wildcase", "wic"], + "Completion case matching mode", + "string", "smart", + { + completer: function () [ + ["smart", "Case is significant when capital letters are typed"], + ["match", "Case is always significant"], + ["ignore", "Case is never significant"] + ], + validator: Option.validateCompleter + }); + + options.add(["wildignore", "wig"], + "List of file patterns to ignore when completing files", + "stringlist", "", + { + validator: function validator(values) + { + // TODO: allow for escaping the "," + try + { + RegExp("^(" + values.join("|") + ")$"); + return true; + } + catch (e) + { + return false; + } + } + }); + + options.add(["wildmode", "wim"], + "Define how command line completion works", + "stringlist", "list:full", + { + completer: function completer(filter) + { + return [ + // 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 [ + ["auto", "Automatically show completions while you are typing"], + ["sort", "Always sort the completion list"] + ]; + }, + validator: Option.validateCompleter + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// MAPPINGS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var myModes = [modes.COMMAND_LINE]; + + // TODO: move "<Esc>", "<C-[" here from mappings + mappings.add(myModes, + ["<C-c>"], "Focus content", + function () { events.onEscape(); }); + + mappings.add(myModes, + ["<Space>"], "Expand command line abbreviation", + function () + { + commandline.resetCompletions(); + return editor.expandAbbreviation("c"); + }, + { flags: Mappings.flags.ALLOW_EVENT_ROUTING }); + + mappings.add(myModes, + ["<C-]>", "<C-5>"], "Expand command line abbreviation", + function () { editor.expandAbbreviation("c"); }); + + // FIXME: Should be "g<" but that doesn't work unless it has a non-null + // rhs, getCandidates broken? + mappings.add([modes.NORMAL], + ["g."], "Redisplay the last command output", + function () + { + if (lastMowOutput) + commandline.echo(lastMowOutput, + commandline.HL_NORMAL, commandline.FORCE_MULTILINE); + else + liberator.beep(); + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// COMMANDS //////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var echoCommands = [ + { + 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 + } + ]; + + echoCommands.forEach(function (command) { + commands.add([command.name], + command.description, + function (args) + { + var str = echoArgumentToString(args.string, true); + if (str != null) + command.action(str); + }, + { + completer: function (context) completion.javascript(context), + literal: 0 + }); + }); + + commands.add(["mes[sages]"], + "Display previously given messages", + function () + { + // TODO: are all messages single line? Some display an aggregation + // of single line messages at least. E.g. :source + // FIXME: I retract my retraction, this command-line/MOW mismatch _is_ really annoying -- djk + if (messageHistory.length == 1) + { + let message = messageHistory.messages[0]; + commandline.echo(message.str, message.highlight, commandline.FORCE_SINGLELINE); + } + else if (messageHistory.length > 1) + { + let list = <></>; + + for (let [,message] in Iterator(messageHistory.messages)) + list += <div highlight={message.highlight + " Message"}>{message.str}</div>; + + liberator.echo(list, commandline.FORCE_MULTILINE); + } + }, + { argCount: "0" }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + HL_NORMAL : "Normal", + HL_ERRORMSG : "ErrorMsg", + HL_MODEMSG : "ModeMsg", + HL_MOREMSG : "MoreMsg", + HL_QUESTION : "Question", + HL_INFOMSG : "InfoMsg", + HL_WARNINGMSG : "WarningMsg", + HL_LINENR : "LineNr", + + FORCE_MULTILINE : 1 << 0, + FORCE_SINGLELINE : 1 << 1, + DISALLOW_MULTILINE : 1 << 2, // if an echo() should try to use the single line + // but output nothing when the MOW is open; when also + // FORCE_MULTILINE is given, FORCE_MULTILINE takes precedence + APPEND_TO_MESSAGES : 1 << 3, // add the string to the message history + + get completionContext() completions.context, + + get mode() (modes.extended == modes.EX) ? "cmd" : "search", + + get silent() silent, + set silent(val) { + silent = val; + if (silent) + storage.styles.addSheet("silent-mode", "chrome://*", "#liberator-commandline > * { opacity: 0 }", true, true); + else + storage.styles.removeSheet("silent-mode", null, null, null, true); + }, + + getCommand: function getCommand() + { + try + { + return commandWidget.inputField.editor.rootElement.firstChild.textContent; + } + catch (e) {} + return commandWidget.value; + }, + + open: function open(prompt, cmd, extendedMode) + { + // save the current prompts, we need it later if the command widget + // receives focus without calling the this.open() method + currentPrompt = prompt || ""; + currentCommand = cmd || ""; + currentExtendedMode = extendedMode || null; + + historyIndex = UNINITIALIZED; + + modes.set(modes.COMMAND_LINE, currentExtendedMode); + setHighlightGroup(this.HL_NORMAL); + setPrompt(currentPrompt); + setCommand(currentCommand); + + commandWidget.focus(); + + completions = new Completions(CompletionContext(commandWidget.inputField.editor)); + + // open the completion list automatically if wanted + if (/\s/.test(cmd) && + options.get("wildoptions").has("auto") && + extendedMode == modes.EX) + autocompleteTimer.tell(false); + }, + + // normally used when pressing esc, does not execute a command + close: function close() + { + let res = liberator.triggerCallback("cancel", currentExtendedMode); + inputHistory.add(this.getCommand()); + statusline.updateProgress(""); // we may have a "match x of y" visible + this.clear(); + }, + + clear: function clear() + { + multilineInputWidget.collapsed = true; + outputContainer.collapsed = true; + completionList.hide(); + this.resetCompletions(); + + setLine("", this.HL_NORMAL); + }, + + // liberator.echo uses different order of flags as it omits the hightlight group, change v.commandline.echo argument order? --mst + echo: function echo(str, highlightGroup, flags) + { + let focused = document.commandDispatcher.focusedElement; + if (focused && focused == commandWidget.inputField || focused == multilineInputWidget.inputField) + return false; + if (silent) + return false; + if (modes.main == modes.COMMAND_LINE) + return false; + + highlightGroup = highlightGroup || this.HL_NORMAL; + + if (flags & this.APPEND_TO_MESSAGES) + messageHistory.add({ str: str, highlight: highlightGroup }); + + liberator.callInMainThread(function () { + let where = setLine; + if (flags & commandline.FORCE_MULTILINE) + where = setMultiline; + else if (flags & commandline.FORCE_SINGLELINE) + where = function () setLine(str, highlightGroup, true); + else if (flags & commandline.DISALLOW_MULTILINE) + { + if (!outputContainer.collapsed) + where = null; + else + where = function () setLine(str, highlightGroup, true); + } + else if (/\n|<br\/?>/.test(str)) + where = setMultiline; + + if (where) + where(str, highlightGroup); + + currentExtendedMode = null; + }); + + return true; + }, + + // this will prompt the user for a string + // commandline.input("(s)ave or (o)pen the file?") + input: function input(prompt, callback, extra) + { + extra = extra || {}; + + promptSubmitCallback = callback; + promptChangeCallback = extra.onChange; + promptCompleter = extra.completer; + modes.push(modes.COMMAND_LINE, modes.PROMPT); + currentExtendedMode = modes.PROMPT; + setPrompt(prompt + " ", this.HL_QUESTION); + setCommand(extra.default || ""); + commandWidget.focus(); + }, + + // reads a multi line input and returns the string once the last line matches + // @param untilRegexp + inputMultiline: function inputMultiline(untilRegexp, callbackFunc) + { + // save the mode, because we need to restore it + modes.push(modes.COMMAND_LINE, modes.INPUT_MULTILINE); + + // save the arguments, they are needed in the event handler onEvent + multilineRegexp = untilRegexp; + multilineCallback = callbackFunc; + + multilineInputWidget.collapsed = false; + multilineInputWidget.value = ""; + autosizeMultilineInputWidget(); + + setTimeout(function () { multilineInputWidget.focus(); }, 10); + }, + + onEvent: function onEvent(event) + { + completions.previewClear(); + let command = this.getCommand(); + + if (event.type == "blur") + { + // prevent losing focus, there should be a better way, but it just didn't work otherwise + setTimeout(function () { + if (liberator.mode == modes.COMMAND_LINE && + !(modes.extended & modes.INPUT_MULTILINE) && + !(modes.extended & modes.OUTPUT_MULTILINE) && + event.originalTarget == commandWidget.inputField) + { + commandWidget.inputField.focus(); + } + }, 0); + } + else if (event.type == "focus") + { + if (!currentExtendedMode && event.target == commandWidget.inputField) + { + event.target.blur(); + liberator.beep(); + } + } + else if (event.type == "input") + { + liberator.triggerCallback("change", currentExtendedMode, command); + } + else if (event.type == "keypress") + { + if (!currentExtendedMode) + return true; + + let key = events.toString(event); + //liberator.log("command line handling key: " + key + "\n"); + + // user pressed ENTER to carry out a command + // user pressing ESCAPE is handled in the global onEscape + // FIXME: <Esc> should trigger "cancel" event + if (events.isAcceptKey(key)) + { + let mode = currentExtendedMode; // save it here, as setMode() resets it + currentExtendedMode = null; /* Don't let modes.pop trigger "cancel" */ + inputHistory.add(command); + modes.pop(!commandline.silent); + this.resetCompletions(); + completionList.hide(); + liberator.focusContent(false); + statusline.updateProgress(""); // we may have a "match x of y" visible + return liberator.triggerCallback("submit", mode, command); + } + // user pressed UP or DOWN arrow to cycle history completion + else if (/^(<Up>|<Down>|<S-Up>|<S-Down>|<PageUp>|<PageDown>)$/.test(key)) + { + function loadHistoryItem(index) + { + setCommand(inputHistory.get(historyIndex)); + liberator.triggerCallback("change", currentExtendedMode, commandline.getCommand()); + } + + let previousItem = /Up/.test(key); + let matchCurrent = !/(Page|S-)/.test(key); + + event.preventDefault(); + event.stopPropagation(); + + // always reset the tab completion if we use up/down keys + completions.select(completions.RESET); + + // save 'start' position for iterating through the history + if (historyIndex == UNINITIALIZED) + { + historyIndex = inputHistory.length; + historyStart = command; + } + + // search the history for the first item matching the current + // commandline string + while (historyIndex >= -1 && historyIndex <= inputHistory.length) + { + previousItem ? historyIndex-- : historyIndex++; + + // user pressed DOWN when there is no newer history item + if (historyIndex == inputHistory.length) + { + setCommand(historyStart); + liberator.triggerCallback("change", currentExtendedMode, this.getCommand()); + break; + } + + // cannot go past history start/end + if (historyIndex <= -1) + { + historyIndex = 0; + liberator.beep(); + break; + } + else if (historyIndex >= inputHistory.length + 1) + { + historyIndex = inputHistory.length; + liberator.beep(); + break; + } + + if (matchCurrent) + { + if (inputHistory.get(historyIndex).indexOf(historyStart) == 0) + { + loadHistoryItem(historyIndex); + break; + } + } + else + { + loadHistoryItem(historyIndex); + break; + } + } + } + // user pressed TAB to get completions of a command + else if (key == "<Tab>" || key == "<S-Tab>") + { + tabTimer.tell(event); + // prevent tab from moving to the next field + event.preventDefault(); + event.stopPropagation(); + return false; + } + else if (key == "<BS>") + { + // reset the tab completion + completionIndex = historyIndex = UNINITIALIZED; + completions.reset(); + + // and blur the command line if there is no text left + if (command.length == 0) + { + liberator.triggerCallback("cancel", currentExtendedMode); + modes.pop(); // FIXME: use mode stack + } + } + else // any other key + { + this.resetCompletions(); + } + return true; // allow this event to be handled by Firefox + } + }, + + onMultilineInputEvent: function onMultilineInputEvent(event) + { + if (event.type == "keypress") + { + let key = events.toString(event); + if (events.isAcceptKey(key)) + { + let text = multilineInputWidget.value.substr(0, multilineInputWidget.selectionStart); + if (text.match(multilineRegexp)) + { + text = text.replace(multilineRegexp, ""); + modes.pop(); + multilineInputWidget.collapsed = true; + multilineCallback.call(this, text); + } + } + else if (events.isCancelKey(key)) + { + modes.pop(); + multilineInputWidget.collapsed = true; + } + } + else if (event.type == "blur") + { + if (modes.extended & modes.INPUT_MULTILINE) + setTimeout(function () { multilineInputWidget.inputField.focus(); }, 0); + } + else if (event.type == "input") + { + autosizeMultilineInputWidget(); + } + return true; + }, + + // FIXME: if 'more' is set and the MOW is not scrollable we should still + // allow a down motion after an up rather than closing + onMultilineOutputEvent: function onMultilineOutputEvent(event) + { + let win = multilineOutputWidget.contentWindow; + + let showMoreHelpPrompt = false; + let showMorePrompt = false; + let closeWindow = false; + let passEvent = false; + + function isScrollable() !win.scrollMaxY == 0; + function atEnd() win.scrollY / win.scrollMaxY >= 1; + + let key = events.toString(event); + + if (startHints) + { + statusline.updateInputBuffer(""); + startHints = false; + hints.show(key, undefined, win); + return; + } + + switch (key) + { + case "<Esc>": + closeWindow = true; + break; // handled globally in events.js:onEscape() + + case ":": + commandline.open(":", "", modes.EX); + return; + + // down a line + case "j": + case "<Down>": + if (options["more"] && isScrollable()) + win.scrollByLines(1); + else + passEvent = true; + break; + + case "<C-j>": + case "<C-m>": + case "<Return>": + if (options["more"] && isScrollable() && !atEnd()) + win.scrollByLines(1); + else + closeWindow = true; // don't propagate the event for accept keys + break; + + // up a line + case "k": + case "<Up>": + case "<BS>": + if (options["more"] && isScrollable()) + win.scrollByLines(-1); + else if (options["more"] && !isScrollable()) + showMorePrompt = true; + else + passEvent = true; + break; + + // half page down + case "d": + if (options["more"] && isScrollable()) + win.scrollBy(0, win.innerHeight / 2); + else + passEvent = true; + break; + + // TODO: <LeftMouse> on the prompt line should scroll one page + case "<LeftMouse>": + if (event.originalTarget.getAttributeNS(NS.uri, "highlight") == "URL buffer-list") + { + tabs.select(parseInt(event.originalTarget.parentNode.parentNode.firstChild.textContent, 10) - 1); + closeWindow = true; + break; + } + else if (event.originalTarget.localName.toLowerCase() == "a") + { + liberator.open(event.originalTarget.textContent); + break; + } + case "<A-LeftMouse>": // for those not owning a 3-button mouse + case "<MiddleMouse>": + if (event.originalTarget.localName.toLowerCase() == "a") + { + let where = /\btabopen\b/.test(options["activate"]) ? + liberator.NEW_TAB : liberator.NEW_BACKGROUND_TAB; + liberator.open(event.originalTarget.textContent, where); + } + break; + + // let firefox handle those to select table cells or show a context menu + case "<C-LeftMouse>": + case "<RightMouse>": + case "<C-S-LeftMouse>": + break; + + // page down + case "f": + if (options["more"] && isScrollable()) + win.scrollByPages(1); + else + passEvent = true; + break; + + case "<Space>": + case "<PageDown>": + if (options["more"] && isScrollable() && !atEnd()) + win.scrollByPages(1); + else + passEvent = true; + break; + + // half page up + case "u": + // if (more and scrollable) + if (options["more"] && isScrollable()) + win.scrollBy(0, -(win.innerHeight / 2)); + else + passEvent = true; + break; + + // page up + case "b": + if (options["more"] && isScrollable()) + win.scrollByPages(-1); + else if (options["more"] && !isScrollable()) + showMorePrompt = true; + else + passEvent = true; + break; + + case "<PageUp>": + if (options["more"] && isScrollable()) + win.scrollByPages(-1); + else + passEvent = true; + break; + + // top of page + case "g": + if (options["more"] && isScrollable()) + win.scrollTo(0, 0); + else if (options["more"] && !isScrollable()) + showMorePrompt = true; + else + passEvent = true; + break; + + // bottom of page + case "G": + if (options["more"] && isScrollable() && !atEnd()) + win.scrollTo(0, win.scrollMaxY); + else + passEvent = true; + break; + + // copy text to clipboard + case "<C-y>": + util.copyToClipboard(win.getSelection()); + break; + + // close the window + case "q": + closeWindow = true; + break; + + case ";": + statusline.updateInputBuffer(";"); + startHints = true; + break; + + // unmapped key + default: + if (!options["more"] || !isScrollable() || atEnd() || events.isCancelKey(key)) + passEvent = true; + else + showMoreHelpPrompt = true; + } + + if (passEvent || closeWindow) + { + // FIXME: use mode stack + modes.pop(); + this.clear(); + + if (passEvent) + events.onKeyPress(event); + } + else // set update the prompt string + { + commandline.updateMorePrompt(showMorePrompt, showMoreHelpPrompt); + } + }, + + updateMorePrompt: function updateMorePrompt(force, showHelp) + { + if (modes.main == modes.COMMAND_LINE) + return; + let win = multilineOutputWidget.contentWindow; + function isScrollable() !win.scrollMaxY == 0; + function atEnd() win.scrollY / win.scrollMaxY >= 1; + + if (showHelp) + setLine("-- More -- SPACE/d/j: screen/page/line down, b/u/k: up, q: quit", this.HL_MOREMSG); + else if (force || (options["more"] && isScrollable() && !atEnd())) + setLine("-- More --", this.HL_MOREMSG); + else + setLine("Press ENTER or type command to continue", this.HL_QUESTION); + }, + + updateOutputHeight: function updateOutputHeight(open) + { + if (!open && outputContainer.collapsed) + return; + + let doc = multilineOutputWidget.contentDocument; + outputContainer.collapsed = true; + let availableHeight = 250; + try + { + availableHeight = getBrowser().mPanelContainer ? + getBrowser().mPanelContainer.boxObject.height : getBrowser().boxObject.height; + } + catch (e) {} + doc.body.style.minWidth = commandlineWidget.scrollWidth + "px"; + outputContainer.height = Math.min(doc.height, availableHeight) + "px"; + doc.body.style.minWidth = undefined; + outputContainer.collapsed = false; + }, + + // TODO: does that function need to be public? + resetCompletions: function resetCompletions() + { + autocompleteTimer.reset(); + if (completions) + { + completions.context.reset(); + completions.reset(); + } + historyIndex = UNINITIALIZED; + removeSuffix = ""; + } + }; + //}}} +}; //}}} + +/** + * The list which is used for the completion box (and QuickFix window in future) + * + * @param id: the id of the the XUL <iframe> which we want to fill + * it MUST be inside a <vbox> (or any other html element, + * because otherwise setting the height does not work properly + */ +function ItemList(id) //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + const CONTEXT_LINES = 3; + var maxItems = 20; + var completionElements = []; + + var iframe = document.getElementById(id); + if (!iframe) + { + liberator.log("No iframe with id: " + id + " found, strange things may happen!"); // "The truth is out there..." -- djk + return; + } + + function dom(xml, map) util.xmlToDom(xml, doc, map); + function elemToString(elem) elem.nodeType == elem.TEXT_NODE ? elem.data : + "<" + [elem.localName].concat([a.name + "=" + a.value.quote() for (a in util.Array.iterator(elem.attributes))]).join(" ") + ">"; + var doc = iframe.contentDocument; + var container = iframe.parentNode; + + doc.body.id = id + "-content"; + doc.body.appendChild(doc.createTextNode("")); + doc.body.style.borderTop = "1px solid black"; // FIXME: For cases where completions/MOW are shown at once, or ls=0. Should use :highlight. + + let gradient = + <div highlight="Gradient"> + <div style="height: 0px"> + <div highlight="GradientRight Gradient" + style="border: 0 !important; margin: 0 !important; padding: 0 !important;"/> + </div> + <table width="100%" style="height: 100%"> + <tr> + { template.map(util.range(0, 100), function (i) + <td highlight="GradientLeft" style={"opacity: " + (1 - i / 100)}/>) } + </tr> + </table> + </div>; + + var items = null; + var startIndex = -1; // The index of the first displayed item + var endIndex = -1; // The index one *after* the last displayed item + var selIndex = -1; // The index of the currently selected element + var div = null; + var divNodes = {}; + var minHeight = 0; + + function autoSize() + { + if (container.collapsed) + div.style.minWidth = document.getElementById("liberator-commandline").scrollWidth + "px"; + minHeight = Math.max(minHeight, divNodes.completions.getBoundingClientRect().bottom); + container.height = minHeight; + if (container.collapsed) + div.style.minWidth = undefined; + // FIXME: Belongs elsewhere. + commandline.updateOutputHeight(false); + } + + function getCompletion(index) completionElements.snapshotItem(index - startIndex); + + function init() + { + div = dom( + <div class="ex-command-output" highlight="Normal" style="white-space: nowrap"> + <div highlight="Completions" key="noCompletions"><span highlight="Title">No Completions</span></div> + <div key="completions"/> + <div highlight="Completions"> + { + template.map(util.range(0, maxItems * 2), function (i) + <span highlight="CompItem"> + <li highlight="NonText">~</li> + </span>) + } + </div> + </div>, divNodes); + doc.body.replaceChild(div, doc.body.firstChild); + + items.contextList.forEach(function init_eachContext(context) { + delete context.cache.nodes; + if (!context.items.length && !context.message && !context.incomplete) + return; + context.cache.nodes = []; + dom(<div key="root" highlight="CompGroup"> + <div highlight="Completions"> + { context.createRow(context.title || [], "CompTitle") } + </div> + { gradient } + <div key="message" highlight="CompMsg"/> + <div key="up" highlight="CompLess"/> + <div key="items" highlight="Completions"/> + <div key="waiting" highlight="CompMsg">Waiting...</div> + <div key="down" highlight="CompMore"/> + </div>, context.cache.nodes); + divNodes.completions.appendChild(context.cache.nodes.root); + }); + } + + /** + * uses the entries in "items" to fill the listbox + * does incremental filling to speed up things + * + * @param offset: start at this index and show maxItems + */ + function fill(offset) + { + XML.ignoreWhiteSpace = false; + let diff = offset - startIndex; + if (items == null || offset == null || diff == 0 || offset < 0) + return false; + + startIndex = offset; + endIndex = Math.min(startIndex + maxItems, items.allItems.items.length); + + let haveCompletions = false; + let off = 0; + let end = startIndex + maxItems; + function getRows(context) + { + function fix(n) Math.max(0, Math.min(len, n)); + end -= context.message + context.incomplete; + let len = context.items.length; + let start = off; + off += len; + let res = [fix(offset - start), fix(end - start)]; + res[2] = (context.incomplete && res[1] >= offset && off - 1 < end); + return res; + } + + items.contextList.forEach(function fill_eachContext(context) { + let nodes = context.cache.nodes; + if (!nodes) + return; + haveCompletions = true; + + let root = nodes.root + let items = nodes.items; + let [start, end, waiting] = getRows(context); + + if (context.message) + nodes.message.textContent = context.message; + nodes.message.style.display = context.message ? "block" : "none"; + nodes.waiting.style.display = waiting ? "block" : "none"; + nodes.up.style.opacity = "0"; + nodes.down.style.display = "none"; + + for (let [i, row] in Iterator(context.getRows(start, end, doc))) + nodes[i] = row; + for (let [i, row] in util.Array.iterator2(nodes)) + { + if (!row) + continue; + let display = (i >= start && i < end); + if (display && row.parentNode != items) + { + do + { + var next = nodes[++i]; + if (next && next.parentNode != items) + next = null; + } + while (!next && i < end) + items.insertBefore(row, next); + } + else if (!display && row.parentNode == items) + items.removeChild(row); + } + if (context.items.length == 0) + return; + nodes.up.style.opacity = (start == 0) ? "0" : "1"; + if (end != context.items.length) + nodes.down.style.display = "block" + else + nodes.up.style.display = "block" + }); + + divNodes.noCompletions.style.display = haveCompletions ? "none" : "block"; + + completionElements = buffer.evaluateXPath("//xhtml:div[@liberator:highlight='CompItem']", doc); + + autoSize(); + return true; + } + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + clear: function clear() { this.setItems(); doc.body.innerHTML = ""; }, + hide: function hide() { container.collapsed = true; }, + show: function show() { container.collapsed = false; }, + visible: function visible() !container.collapsed, + + reset: function () + { + startIndex = endIndex = selIndex = -1; + div = null; + this.selectItem(-1); + }, + + // if @param selectedItem is given, show the list and select that item + setItems: function setItems(newItems, selectedItem) + { + if (container.collapsed) + minHeight = 0; + startIndex = endIndex = selIndex = -1; + items = newItems; + this.reset(); + if (typeof selectedItem == "number") + { + this.selectItem(selectedItem); + this.show(); + } + }, + + // select index, refill list if necessary + selectItem: function selectItem(index) + { + //if (container.collapsed) // fixme + // return; + + //let now = Date.now(); + + if (div == null) + init(); + + let sel = selIndex; + let len = items.allItems.items.length; + let newOffset = startIndex; + + if (index == -1 || index == null || index == len) // wrapped around + { + if (selIndex < 0) + newOffset = 0; + selIndex = -1; + index = -1; + } + else + { + if (index <= startIndex + CONTEXT_LINES) + newOffset = index - CONTEXT_LINES; + if (index >= endIndex - CONTEXT_LINES) + newOffset = index + CONTEXT_LINES - maxItems + 1; + + newOffset = Math.min(newOffset, len - maxItems); + newOffset = Math.max(newOffset, 0); + + selIndex = index; + } + + if (sel > -1) + getCompletion(sel).removeAttribute("selected"); + fill(newOffset); + if (index >= 0) + getCompletion(index).setAttribute("selected", "true"); + + //if (index == 0) + // this.start = now; + //if (index == Math.min(len - 1, 100)) + // liberator.dump({ time: Date.now() - this.start }); + }, + + onEvent: function onEvent(event) false + }; + //}}} +}; //}}} + +function StatusLine() //{{{ +{ + //////////////////////////////////////////////////////////////////////////////// + ////////////////////// PRIVATE SECTION ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + var statusBar = document.getElementById("status-bar"); + statusBar.collapsed = true; // it is later restored unless the user sets laststatus=0 + + // our status bar fields + var statuslineWidget = document.getElementById("liberator-statusline"); + var urlWidget = document.getElementById("liberator-statusline-field-url"); + var inputBufferWidget = document.getElementById("liberator-statusline-field-inputbuffer"); + var progressWidget = document.getElementById("liberator-statusline-field-progress"); + var tabCountWidget = document.getElementById("liberator-statusline-field-tabcount"); + var bufferPositionWidget = document.getElementById("liberator-statusline-field-bufferposition"); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// OPTIONS ///////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + options.add(["laststatus", "ls"], + "Show the status line", + "number", 2, + { + setter: function setter(value) + { + if (value == 0) + document.getElementById("status-bar").collapsed = true; + else if (value == 1) + liberator.echo("show status line only with > 1 window not implemented yet"); + else + document.getElementById("status-bar").collapsed = false; + + return value; + }, + completer: function completer(filter) + { + return [ + ["0", "Never display status line"], + ["1", "Display status line only if there are multiple windows"], + ["2", "Always display status line"] + ]; + }, + validator: Option.validateCompleter + }); + + /////////////////////////////////////////////////////////////////////////////}}} + ////////////////////// PUBLIC SECTION ////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////////{{{ + + return { + + setClass: function setClass(type) + { + const highlightGroup = { + secure: "StatusLineSecure", + broken: "StatusLineBroken", + insecure: "StatusLine" + }; + + statusBar.setAttributeNS(NS.uri, "highlight", highlightGroup[type]); + }, + + // update all fields of the statusline + update: function update() + { + this.updateUrl(); + this.updateInputBuffer(); + this.updateProgress(); + this.updateTabCount(); + this.updateBufferPosition(); + }, + + // if "url" is ommited, build a usable string for the URL + updateUrl: function updateUrl(url) + { + if (typeof url == "string") + { + urlWidget.value = url; + return; + } + + url = buffer.URL; + + // make it even more vim-like + if (url == "about:blank") + { + if (!buffer.title) + url = "[No Name]"; + } + else + { + url = url.replace(RegExp("^chrome://liberator/locale/(\\S+\\.html)$"), "$1 [Help]"); + } + + // when session information is available, add [+] when we can go backwards + if (config.name == "Vimperator") + { + let sh = window.getWebNavigation().sessionHistory; + let modified = ""; + if (sh.index > 0) + modified += "+"; + if (sh.index < sh.count -1) + modified += "-"; + if (bookmarks.isBookmarked(buffer.URL)) + modified += "\u2764"; // a heart symbol: ❤ + //modified += "\u2665"; // a heart symbol: ♥ + + if (modified) + url += " [" + modified + "]"; + } + + urlWidget.value = url; + }, + + updateInputBuffer: function updateInputBuffer(buffer) + { + if (!buffer || typeof buffer != "string") + buffer = ""; + + inputBufferWidget.value = buffer; + }, + + updateProgress: function updateProgress(progress) + { + if (!progress) + progress = ""; + + if (typeof progress == "string") + { + progressWidget.value = progress; + } + else if (typeof progress == "number") + { + let progressStr = ""; + if (progress <= 0) + progressStr = "[ Loading... ]"; + else if (progress < 1) + { + progress = Math.floor(progress * 20); + progressStr = "[" + + "====================".substr(0, progress) + + ">" + + "\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0".substr(0, 19 - progress) + + "]"; + } + progressWidget.value = progressStr; + } + }, + + // you can omit either of the 2 arguments + updateTabCount: function updateTabCount(currentIndex, totalTabs) + { + if (!liberator.has("tabs")) + { + tabCountWidget = ""; + return; + } + + // update the ordinal which is used for numbered tabs only when the user has + // tab numbers set, and the host application supports it + if (config.hostApplication == "Firefox" && + (options.get("guioptions").has("n") || options.get("guioptions").has("N"))) + { + for (let [i, tab] in Iterator(Array.slice(getBrowser().mTabs))) + tab.setAttribute("ordinal", i + 1); + } + + if (!currentIndex || typeof currentIndex != "number") + currentIndex = tabs.index() + 1; + if (!totalTabs || typeof currentIndex != "number") + totalTabs = tabs.count; + + tabCountWidget.value = "[" + currentIndex + "/" + totalTabs + "]"; + }, + + // percent is given between 0 and 1 + updateBufferPosition: function updateBufferPosition(percent) + { + if (!percent || typeof percent != "number") + { + let win = document.commandDispatcher.focusedWindow; + if (!win) + return; + percent = win.scrollMaxY == 0 ? -1 : win.scrollY / win.scrollMaxY; + } + + let bufferPositionStr = ""; + percent = Math.round(percent * 100); + if (percent < 0) + bufferPositionStr = "All"; + else if (percent == 0) + bufferPositionStr = "Top"; + else if (percent < 10) + bufferPositionStr = " " + percent + "%"; + else if (percent >= 100) + bufferPositionStr = "Bot"; + else + bufferPositionStr = percent + "%"; + + bufferPositionWidget.value = bufferPositionStr; + } + + }; + //}}} +}; //}}} + +// vim: set fdm=marker sw=4 ts=4 et: diff --git a/common/content/util.js b/common/content/util.js new file mode 100644 index 00000000..cc961c54 --- /dev/null +++ b/common/content/util.js @@ -0,0 +1,597 @@ +/***** BEGIN LICENSE BLOCK ***** {{{ +Version: MPL 1.1/GPL 2.0/LGPL 2.1 + +The contents of this file are subject to the Mozilla Public License Version +1.1 (the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at +http://www.mozilla.org/MPL/ + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the +License. + +(c) 2006-2008: Martin Stubenschrott <stubenschrott@gmx.net> + +Alternatively, the contents of this file may be used under the terms of +either the GNU General Public License Version 2 or later (the "GPL"), or +the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), +in which case the provisions of the GPL or the LGPL are applicable instead +of those above. If you wish to allow use of your version of this file only +under the terms of either the GPL or the LGPL, and not to allow others to +use your version of this file under the terms of the MPL, indicate your +decision by deleting the provisions above and replace them with the notice +and other provisions required by the GPL or the LGPL. If you do not delete +the provisions above, a recipient may use your version of this file under +the terms of any one of the MPL, the GPL or the LGPL. +}}} ***** END LICENSE BLOCK *****/ + +const XHTML = "http://www.w3.org/1999/xhtml"; +const NS = Namespace("liberator", "http://vimperator.org/namespaces/liberator"); +default xml namespace = XHTML; + +const util = { //{{{ + + Array: { + // [["a", "b"], ["c", "d"]] -> { a: "b", c: "d" } + // From Common Lisp, more or less + assocToObj: function assocToObj(assoc) + { + let obj = {}; + assoc.forEach(function ([k, v]) { obj[k] = v }); + return obj; + }, + + // flatten an array: [["foo", ["bar"]], ["baz"], "quux"] -> ["foo", ["bar"], "baz", "quux"] + flatten: function flatten(ary) Array.concat.apply([], ary), + + iterator: function iterator(ary) + { + let length = ary.length; + for (let i = 0; i < length; i++) + yield ary[i]; + }, + + iterator2: function (ary) + { + let length = ary.length; + for (let i = 0; i < length; i++) + yield [i, ary[i]]; + }, + + uniq: function uniq(ary, unsorted) + { + let ret = []; + if (unsorted) + { + for (let [,item] in Iterator(ary)) + if (ret.indexOf(item) == -1) + ret.push(item); + } + else + { + for (let [,item] in Iterator(ary.sort())) + { + if (item != last || !ret.length) + ret.push(item); + var last = item; + } + } + return ret; + } + }, + + // TODO: class could have better variable names/documentation + Timer: function Timer(minInterval, maxInterval, callback) + { + let timer = Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + this.doneAt = 0; + this.latest = 0; + this.notify = function (aTimer) + { + timer.cancel(); + this.latest = 0; + /* minInterval is the time between the completion of the command and the next firing. */ + this.doneAt = Date.now() + minInterval; + + try + { + callback(this.arg); + } + finally + { + this.doneAt = Date.now() + minInterval; + } + }; + this.tell = function (arg) + { + if (arg !== undefined) + this.arg = arg; + + let now = Date.now(); + if (this.doneAt == -1) + timer.cancel(); + + let timeout = minInterval; + if (now > this.doneAt && this.doneAt > -1) + timeout = 0; + else if (this.latest) + timeout = Math.min(timeout, this.latest - now); + else + this.latest = now + maxInterval; + + timer.initWithCallback(this, Math.max(timeout, 0), timer.TYPE_ONE_SHOT); + this.doneAt = -1; + }; + this.reset = function () + { + timer.cancel(); + this.doneAt = 0; + }; + this.flush = function () + { + if (this.latest) + this.notify(); + }; + }, + + cloneObject: function cloneObject(obj) + { + if (obj instanceof Array) + return obj.slice(); + let newObj = {}; + for (let [k, v] in Iterator(obj)) + newObj[k] = v; + return newObj; + }, + + clip: function clip(str, length) + { + return str.length <= length ? str : str.substr(0, length - 3) + "..."; + }, + + compareIgnoreCase: function compareIgnoreCase(a, b) String.localeCompare(a.toLowerCase(), b.toLowerCase()), + + computedStyle: function computedStyle(node) + { + while (node instanceof Text && node.parentNode) + node = node.parentNode; + return node.ownerDocument.defaultView.getComputedStyle(node, null); + }, + + copyToClipboard: function copyToClipboard(str, verbose) + { + const clipboardHelper = Components.classes["@mozilla.org/widget/clipboardhelper;1"] + .getService(Components.interfaces.nsIClipboardHelper); + clipboardHelper.copyString(str); + + if (verbose) + liberator.echo("Yanked " + str, commandline.FORCE_SINGLELINE); + }, + + createURI: function createURI(str) + { + const fixup = Components.classes["@mozilla.org/docshell/urifixup;1"] + .getService(Components.interfaces.nsIURIFixup); + return fixup.createFixupURI(str, fixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP); + }, + + escapeHTML: function escapeHTML(str) + { + // XXX: the following code is _much_ slower than a simple .replace() + // :history display went down from 2 to 1 second after changing + // + // var e = window.content.document.createElement("div"); + // e.appendChild(window.content.document.createTextNode(str)); + // return e.innerHTML; + return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + }, + + escapeRegex: function escapeRegex(str) + { + return str.replace(/([\\{}()[\].?*+])/g, "\\$1"); + }, + + escapeString: function escapeString(str, delimiter) + { + if (delimiter == undefined) + delimiter = '"'; + return delimiter + str.replace(/([\\'"])/g, "\\$1").replace("\n", "\\n", "g").replace("\t", "\\t", "g") + delimiter; + }, + + formatBytes: function formatBytes(num, decimalPlaces, humanReadable) + { + const unitVal = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + let unitIndex = 0; + let tmpNum = parseInt(num, 10) || 0; + let strNum = [tmpNum + ""]; + + if (humanReadable) + { + while (tmpNum >= 1024) + { + tmpNum /= 1024; + if (++unitIndex > (unitVal.length - 1)) + break; + } + + let decPower = Math.pow(10, decimalPlaces); + strNum = ((Math.round(tmpNum * decPower) / decPower) + "").split(".", 2); + + if (!strNum[1]) + strNum[1] = ""; + + while (strNum[1].length < decimalPlaces) // pad with "0" to the desired decimalPlaces) + strNum[1] += "0"; + } + + for (let u = strNum[0].length - 3; u > 0; u -= 3) // make a 10000 a 10,000 + strNum[0] = strNum[0].substr(0, u) + "," + strNum[0].substr(u); + + if (unitIndex) // decimalPlaces only when > Bytes + strNum[0] += "." + strNum[1]; + + return strNum[0] + " " + unitVal[unitIndex]; + }, + + // generates an Asciidoc help entry, "command" can also be a mapping + generateHelp: function generateHelp(command, extraHelp) + { + let start = "", end = ""; + if (command instanceof liberator.Command) + start = ":"; + else if (command instanceof liberator.Option) + start = end = "'"; + + let ret = ""; + let longHelp = false; + if ((command.help && command.description) && (command.help.length + command.description.length) > 50) + longHelp = true; + + // the tags which are printed on the top right + for (let j = command.names.length - 1; j >= 0; j--) + ret += "|" + start + command.names[j] + end + "| "; + + if (longHelp) + ret += "+"; + + ret += "\n"; + + // the usage information for the command + let usage = command.names[0]; + if (command.specs) // for :commands + usage = command.specs[0]; + + usage = usage.replace(/{/, "\\\\{").replace(/}/, "\\\\}"); + usage = usage.replace(/'/, "\\'").replace(/`/, "\\`"); + ret += "||" + start + usage + end + "||"; + if (usage.length > 15) + ret += " +"; + + ret += "\n________________________________________________________________________________\n"; + + // the actual help text + if (command.description) + { + ret += command.description + "."; // the help description + if (extraHelp) + ret += " +\n" + extraHelp; + } + else + ret += "Sorry, no help available"; + + // add more space between entries + ret += "\n________________________________________________________________________________\n\n\n"; + + return ret; + }, + + httpGet: function httpGet(url, callback) + { + try + { + let xmlhttp = new XMLHttpRequest(); + xmlhttp.mozBackgroundRequest = true; + if (callback) + { + xmlhttp.onreadystatechange = function () { + if (xmlhttp.readyState == 4) + callback(xmlhttp) + } + } + xmlhttp.open("GET", url, !!callback); + xmlhttp.send(null); + return xmlhttp; + } + catch (e) + { + liberator.log("Error opening " + url + ": " + e, 1); + } + }, + + identity: function identity(k) k, + + map: function map(obj, fn) + { + let ary = []; + for (let i in Iterator(obj)) + ary.push(fn(i)); + return ary; + }, + + // if color = true it uses HTML markup to color certain items + objectToString: function objectToString(object, color) + { + /* Use E4X literals so html is automatically quoted + * only when it's asked for. Noone wants to see < + * on their console or :map :foo in their buffer + * when they expect :map <C-f> :foo. + */ + XML.prettyPrinting = false; + XML.ignoreWhitespace = false; + + if (object === null) + return "null\n"; + + if (typeof object != "object") + return false; + + try + { // for window.JSON + var obj = String(object); + } + catch (e) + { + obj = "[Object]"; + } + obj = template.highlightFilter(util.clip(obj, 150), "\n", !color ? function () "^J" : function () <span highlight="NonText">^J</span>); + let string = <><span highlight="Title Object">{obj}</span>::<br/>
</>; + + let keys = []; + try // window.content often does not want to be queried with "var i in object" + { + let hasValue = !("__iterator__" in object); + if (modules.isPrototypeOf(object)) + { + object = Iterator(object); + hasValue = false; + } + for (let i in object) + { + let value = <![CDATA[<no value>]]>; + try + { + value = object[i]; + } + catch (e) {} + if (!hasValue) + { + if (i instanceof Array && i.length == 2) + [i, value] = i; + else + var noVal = true; + } + + value = template.highlight(value, true, 150); + // FIXME: Inline style. + key = <span style="font-weight: bold;">{i}</span>; + if (!isNaN(i)) + i = parseInt(i); + else if (/^[A-Z_]+$/.test(i)) + i = ""; + keys.push([i, <>{key}{noVal ? "" : <>: {value}</> // Vim / + }<br/>
</>]); + } + } + catch (e) {} + + function compare(a, b) + { + if (!isNaN(a[0]) && !isNaN(b[0])) + return a[0] - b[0]; + return String.localeCompare(a[0], b[0]); + } + string += template.map(keys.sort(compare), function (f) f[1]); + return color ? string : [s for each (s in string)].join(""); + }, + + range: function range(start, end, reverse) + { + if (!reverse) + { + while (start < end) + yield start++; + } + else + { + while (start > end) + yield --start; + } + }, + + interruptableRange: function interruptableRange(start, end, time) + { + let endTime = Date.now() + time; + while (start < end) + { + if (Date.now() > endTime) + { + liberator.threadYield(true, true); + endTime = Date.now() + time; + } + yield start++; + } + }, + + // same as Firefox's readFromClipboard function, but needed for apps like Thunderbird + readFromClipboard: function readFromClipboard() + { + let url; + + try + { + const clipboard = Components.classes['@mozilla.org/widget/clipboard;1'] + .getService(Components.interfaces.nsIClipboard); + const transferable = Components.classes['@mozilla.org/widget/transferable;1'] + .createInstance(Components.interfaces.nsITransferable); + + transferable.addDataFlavor("text/unicode"); + + if (clipboard.supportsSelectionClipboard()) + clipboard.getData(transferable, clipboard.kSelectionClipboard); + else + clipboard.getData(transferable, clipboard.kGlobalClipboard); + + let data = {}; + let dataLen = {}; + + transferable.getTransferData("text/unicode", data, dataLen); + + if (data) + { + data = data.value.QueryInterface(Components.interfaces.nsISupportsString); + url = data.data.substring(0, dataLen.value / 2); + } + } + catch (e) {} + + return url; + }, + + // takes a string like 'google bla, www.osnews.com' + // and returns an array ['www.google.com/search?q=bla', 'www.osnews.com'] + stringToURLArray: function stringToURLArray(str) + { + let urls = str.split(new RegExp("\s*" + options["urlseparator"] + "\s*")); + + return urls.map(function (url) { + try + { + let file = io.getFile(url); + if (file.exists() && file.isReadable()) + return file.path; + } + catch (e) {} + + // removes spaces from the string if it starts with http:// or something like that + if (/^\w+:\/\//.test(url)) + url = url.replace(/\s+/g, ""); + + // strip each 'URL' - makes things simpler later on + url = url.replace(/^\s+|\s+$/, ""); + + // if the string doesn't look like a valid URL (i.e. contains a space + // or does not contain any of: .:/) try opening it with a search engine + // or keyword bookmark + if (/\s/.test(url) || !/[.:\/]/.test(url)) + { + // TODO: it would be clearer if the appropriate call to + // getSearchURL was made based on whether or not the first word was + // indeed an SE alias rather than seeing if getSearchURL can + // process the call usefully and trying again if it fails - much + // like the comments below ;-) + + // check for a search engine match in the string + let searchURL = bookmarks.getSearchURL(url, false); + if (searchURL) + { + return searchURL; + } + else // no search engine match, search for the whole string in the default engine + { + searchURL = bookmarks.getSearchURL(url, true); + if (searchURL) + return searchURL; + } + } + // if we are here let Firefox handle the url and hope it does + // something useful with it :) + return url; + }); + }, + + xmlToDom: function xmlToDom(node, doc, nodes) + { + XML.prettyPrinting = false; + switch (node.nodeKind()) + { + case "text": + return doc.createTextNode(node); + case "element": + let domnode = doc.createElementNS(node.namespace(), node.localName()); + for each (let attr in node.@*) + domnode.setAttributeNS(attr.name() == "highlight" ? NS.uri : attr.namespace(), attr.name(), String(attr)); + for each (let child in node.*) + domnode.appendChild(arguments.callee(child, doc, nodes)); + if (nodes && node.@key) + nodes[node.@key] = domnode; + return domnode; + } + } +}; //}}} + +// Struct is really slow, AT LEAST 5 times slower than using structs or simple Objects +// main reason is the function ConStructor(), which i couldn't get faster. +// Maybe it's a TraceMonkey problem, for now don't use it for anything which must be fast (like bookmarks or history) +function Struct() +{ + let self = this instanceof Struct ? this : new Struct(); + if (!arguments.length) + return self; + + let args = Array.slice(arguments); + self.__defineGetter__("length", function () args.length); + self.__defineGetter__("members", function () args.slice()); + for (let arg in Iterator(args)) + { + let [i, name] = arg; + self.__defineGetter__(name, function () this[i]); + self.__defineSetter__(name, function (val) { this[i] = val; }); + } + function ConStructor() + { + let self = this instanceof arguments.callee ? this : new arguments.callee(); + //for (let [k, v] in Iterator(Array.slice(arguments))) // That is makes using struct twice as slow as the following code: + for (let i = 0; i < arguments.length; i++) + { + if (arguments[i] != undefined) + self[i] = arguments[i]; + } + + return self; + } + ConStructor.prototype = self; + ConStructor.defaultValue = function (key, val) + { + let i = args.indexOf(key); + ConStructor.prototype.__defineGetter__(i, function () (this[i] = val.call(this), this[i])); // Kludge for FF 3.0 + ConStructor.prototype.__defineSetter__(i, function (val) { + let value = val; + this.__defineGetter__(i, function () value); + this.__defineSetter__(i, function (val) { value = val }); + }); + }; + return self.constructor = ConStructor; +} + +Struct.prototype = { + clone: function clone() + { + return this.constructor.apply(null, this.slice()); + }, + // Iterator over our named members + __iterator__: function () + { + let self = this; + return ([v, self[v]] for ([k, v] in Iterator(self.members))) + } +} + +// 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: |