diff --git a/toolkit/content/tests/fennec-tile-testapp/application.ini b/toolkit/content/tests/fennec-tile-testapp/application.ini new file mode 100644 index 00000000000..6da1d196fba --- /dev/null +++ b/toolkit/content/tests/fennec-tile-testapp/application.ini @@ -0,0 +1,11 @@ +[App] +Vendor=venderr +Name=tile +Version=1.0 +BuildID=20060101 +Copyright=Copyright (c) 2006 Mark Finkle +ID=xulapp@starkravingfinkle.org + +[Gecko] +MinVersion=1.8 +MaxVersion=1.9.2.* diff --git a/toolkit/content/tests/fennec-tile-testapp/chrome/chrome.manifest b/toolkit/content/tests/fennec-tile-testapp/chrome/chrome.manifest new file mode 100644 index 00000000000..118354c81a1 --- /dev/null +++ b/toolkit/content/tests/fennec-tile-testapp/chrome/chrome.manifest @@ -0,0 +1 @@ +content tile file:content/ diff --git a/toolkit/content/tests/fennec-tile-testapp/chrome/content/BrowserView.js b/toolkit/content/tests/fennec-tile-testapp/chrome/content/BrowserView.js new file mode 100644 index 00000000000..96902846122 --- /dev/null +++ b/toolkit/content/tests/fennec-tile-testapp/chrome/content/BrowserView.js @@ -0,0 +1,736 @@ +// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- +/* + * ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is Mozilla Mobile Browser. + * + * The Initial Developer of the Original Code is + * Mozilla Corporation. + * Portions created by the Initial Developer are Copyright (C) 2008 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Roy Frostig + * + * 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 ***** */ + +let Ci = Components.interfaces; + +// --- REMOVE --- +let noop = function() {}; +let endl = '\n'; +// -------------- + +function BrowserView(container, visibleRect) { + bindAll(this); + this.init(container, visibleRect); +} + +/** + * A BrowserView maintains state of the viewport (browser, zoom level, + * dimensions) and the visible rectangle into the viewport, for every + * browser it is given (cf setBrowser()). In updates to the viewport state, + * a BrowserView (using its TileManager) renders parts of the page quasi- + * intelligently, with guarantees of having rendered and appended all of the + * visible browser content (aka the "critical rectangle"). + * + * State is characterized in large part by two rectangles (and an implicit third): + * - Viewport: Always rooted at the origin, ie with (left, top) at (0, 0). The + * width and height (right and bottom) of this rectangle are that of the + * current viewport, which corresponds more or less to the transformed + * browser content (scaled by zoom level). + * - Visible: Corresponds to the client's viewing rectangle in viewport + * coordinates. Has (top, left) corresponding to position, and width & height + * corresponding to the clients viewing dimensions. Take note that the top + * and left of the visible rect are per-browser state, but that the width + * and height persist across setBrowser() calls. This is best explained by + * a simple example: user views browser A, pans to position (x0, y0), switches + * to browser B, where she finds herself at position (x1, y1), tilts her + * device so that visible rectangle's width and height change, and switches + * back to browser A. She expects to come back to position (x0, y0), but her + * device remains tilted. + * - Critical (the implicit one): The critical rectangle is the (possibly null) + * intersection of the visible and viewport rectangles. That is, it is that + * region of the viewport which is visible to the user. We care about this + * because it tells us which region must be rendered as soon as it is dirtied. + * The critical rectangle is mostly state that we do not keep in BrowserView + * but that our TileManager maintains. + * + * Example rectangle state configurations: + * + * + * +-------------------------------+ + * |A | + * | | + * | | + * | | + * | +----------------+ | + * | |B,C | | + * | | | | + * | | | | + * | | | | + * | +----------------+ | + * | | + * | | + * | | + * | | + * | | + * +-------------------------------+ + * + * + * A = viewport ; at (0, 0) + * B = visible ; at (x, y) where x > 0, y > 0 + * C = critical ; at (x, y) + * + * + * + * +-------------------------------+ + * |A | + * | | + * | | + * | | + * +----+-----------+ | + * |B .C | | + * | . | | + * | . | | + * | . | | + * +----+-----------+ | + * | | + * | | + * | | + * | | + * | | + * +-------------------------------+ + * + * + * A = viewport ; at (0, 0) + * B = visible ; at (x, y) where x < 0, y > 0 + * C = critical ; at (0, y) + * + * + * Maintaining per-browser state is a little bit of a hack involving attaching + * an object as the obfuscated dynamic JS property of the browser object, that + * hopefully no one but us will touch. See getViewportStateFromBrowser() for + * the property name. + */ +BrowserView.prototype = ( +function() { + + // ----------------------------------------------------------- + // Privates + // + + const kZoomLevelMin = 0.2; + const kZoomLevelMax = 4.0; + const kZoomLevelPrecision = 10000; + + function visibleRectToCriticalRect(visibleRect, browserViewportState) { + return visibleRect.intersect(browserViewportState.viewportRect); + } + + function clampZoomLevel(zl) { + let bounded = Math.min(Math.max(kZoomLevelMin, zl), kZoomLevelMax); + return Math.round(bounded * kZoomLevelPrecision) / kZoomLevelPrecision; + } + + function pageZoomLevel(visibleRect, browserW, browserH) { + return clampZoomLevel(visibleRect.width / browserW); + } + + function seenBrowser(browser) { + return !!(browser.__BrowserView__vps); + } + + function initBrowserState(browser, visibleRect) { + let [browserW, browserH] = getBrowserDimensions(browser); + + let zoomLevel = pageZoomLevel(visibleRect, browserW, browserH); + let viewportRect = (new wsRect(0, 0, browserW, browserH)).scale(zoomLevel, zoomLevel); + + dump('--- initing browser to ---' + endl); + browser.__BrowserView__vps = new BrowserView.BrowserViewportState(viewportRect, + visibleRect.x, + visibleRect.y, + zoomLevel); + dump(browser.__BrowserView__vps.toString() + endl); + dump('--------------------------' + endl); + } + + function getViewportStateFromBrowser(browser) { + return browser.__BrowserView__vps; + } + + function getBrowserDimensions(browser) { + let cdoc = browser.contentDocument; + + // These might not exist yet depending on page load state + let body = cdoc.body || {}; + let html = cdoc.documentElement || {}; + let w = Math.max(body.scrollWidth || 0, html.scrollWidth); + let h = Math.max(body.scrollHeight || 0, html.scrollHeight); + + return [w, h]; + } + + function getContentScrollValues(browser) { + let cwu = getBrowserDOMWindowUtils(browser); + let scrollX = {}; + let scrollY = {}; + cwu.getScrollXY(false, scrollX, scrollY); + + return [scrollX.value, scrollY.value]; + } + + function getBrowserDOMWindowUtils(browser) { + return browser.contentWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + } + + function getNewBatchOperationState() { + return { + viewportSizeChanged: false, + dirtyAll: false + }; + } + + function clampViewportWH(width, height, visibleRect) { + let minW = visibleRect.width; + let minH = visibleRect.height; + return [Math.max(width, minW), Math.max(height, minH)]; + } + + function initContainer(container, visibleRect) { + container.style.width = visibleRect.width + 'px'; + container.style.height = visibleRect.height + 'px'; + container.style.overflow = '-moz-hidden-unscrollable'; + } + + function resizeContainerToViewport(container, viewportRect) { + container.style.width = viewportRect.width + 'px'; + container.style.height = viewportRect.height + 'px'; + } + + // !!! --- RESIZE HACK BEGIN ----- + function simulateMozAfterSizeChange(browser, width, height) { + let ev = document.createElement("MouseEvents"); + ev.initEvent("FakeMozAfterSizeChange", false, false, window, 0, width, height); + browser.dispatchEvent(ev); + } + // !!! --- RESIZE HACK END ------- + + // --- Change of coordinates functions --- // + + + // The following returned object becomes BrowserView.prototype + return { + + // ----------------------------------------------------------- + // Public instance methods + // + + init: function init(container, visibleRect) { + this._batchOps = []; + this._container = container; + this._browserViewportState = null; + this._renderMode = 0; + this._tileManager = new TileManager(this._appendTile, this._removeTile, this); + this.setVisibleRect(visibleRect); + + // !!! --- RESIZE HACK BEGIN ----- + // remove this eventually + this._resizeHack = { + maxSeenW: 0, + maxSeenH: 0 + }; + // !!! --- RESIZE HACK END ------- + }, + + setVisibleRect: function setVisibleRect(r) { + let bvs = this._browserViewportState; + let vr = this._visibleRect; + + if (!vr) + this._visibleRect = vr = r.clone(); + else + vr.copyFrom(r); + + if (bvs) { + bvs.visibleX = vr.left; + bvs.visibleY = vr.top; + + // reclamp minimally to the new visible rect + //this.setViewportDimensions(bvs.viewportRect.right, bvs.viewportRect.bottom); + } else + this._viewportChanged(false, false); + }, + + getVisibleRect: function getVisibleRect() { + return this._visibleRect.clone(); + }, + + getVisibleRectX: function getVisibleRectX() { return this._visibleRect.x; }, + getVisibleRectY: function getVisibleRectY() { return this._visibleRect.y; }, + getVisibleRectWidth: function getVisibleRectWidth() { return this._visibleRect.width; }, + getVisibleRectHeight: function getVisibleRectHeight() { return this._visibleRect.height; }, + + setViewportDimensions: function setViewportDimensions(width, height, causedByZoom) { + let bvs = this._browserViewportState; + let vis = this._visibleRect; + + if (!bvs) + return; + + //[width, height] = clampViewportWH(width, height, vis); + bvs.viewportRect.right = width; + bvs.viewportRect.bottom = height; + + // XXX we might not want the user's page to disappear from under them + // at this point, which could happen if the container gets resized such + // that visible rect becomes entirely outside of viewport rect. might + // be wise to define what UX should be in this case, like a move occurs. + // then again, we could also argue this is the responsibility of the + // caller who would do such a thing... + + this._viewportChanged(true, !!causedByZoom); + }, + + setZoomLevel: function setZoomLevel(zl) { + let bvs = this._browserViewportState; + + if (!bvs) + return; + + let newZL = clampZoomLevel(zl); + + if (newZL != bvs.zoomLevel) { + let browserW = this.viewportToBrowser(bvs.viewportRect.right); + let browserH = this.viewportToBrowser(bvs.viewportRect.bottom); + bvs.zoomLevel = newZL; // side-effect: now scale factor in transformations is newZL + this.setViewportDimensions(this.browserToViewport(browserW), + this.browserToViewport(browserH)); + } + }, + + getZoomLevel: function getZoomLevel() { + let bvs = this._browserViewportState; + if (!bvs) + return undefined; + + return bvs.zoomLevel; + }, + + beginBatchOperation: function beginBatchOperation() { + this._batchOps.push(getNewBatchOperationState()); + this.pauseRendering(); + }, + + commitBatchOperation: function commitBatchOperation() { + let bops = this._batchOps; + + if (bops.length == 0) + return; + + let opState = bops.pop(); + this._viewportChanged(opState.viewportSizeChanged, opState.dirtyAll); + this.resumeRendering(); + }, + + discardBatchOperation: function discardBatchOperation() { + let bops = this._batchOps; + bops.pop(); + this.resumeRendering(); + }, + + discardAllBatchOperations: function discardAllBatchOperations() { + let bops = this._batchOps; + while (bops.length > 0) + this.discardBatchOperation(); + }, + + moveVisibleBy: function moveVisibleBy(dx, dy) { + let vr = this._visibleRect; + let vs = this._browserViewportState; + + this.onBeforeVisibleMove(dx, dy); + this.onAfterVisibleMove(dx, dy); + }, + + moveVisibleTo: function moveVisibleTo(x, y) { + let visibleRect = this._visibleRect; + let dx = x - visibleRect.x; + let dy = y - visibleRect.y; + this.moveBy(dx, dy); + }, + + /** + * Calls to this function need to be one-to-one with calls to + * resumeRendering() + */ + pauseRendering: function pauseRendering() { + this._renderMode++; + }, + + /** + * Calls to this function need to be one-to-one with calls to + * pauseRendering() + */ + resumeRendering: function resumeRendering(renderNow) { + if (this._renderMode > 0) + this._renderMode--; + + if (renderNow || this._renderMode == 0) + this._tileManager.criticalRectPaint(); + }, + + isRendering: function isRendering() { + return (this._renderMode == 0); + }, + + /** + * @param dx Guess delta to destination x coordinate + * @param dy Guess delta to destination y coordinate + */ + onBeforeVisibleMove: function onBeforeVisibleMove(dx, dy) { + let vs = this._browserViewportState; + let vr = this._visibleRect; + + let destCR = visibleRectToCriticalRect(vr.clone().translate(dx, dy), vs); + + this._tileManager.beginCriticalMove(destCR); + }, + + /** + * @param dx Actual delta to destination x coordinate + * @param dy Actual delta to destination y coordinate + */ + onAfterVisibleMove: function onAfterVisibleMove(dx, dy) { + let vs = this._browserViewportState; + let vr = this._visibleRect; + + vr.translate(dx, dy); + vs.visibleX = vr.left; + vs.visibleY = vr.top; + + let cr = visibleRectToCriticalRect(vr, vs); + + this._tileManager.endCriticalMove(cr, this.isRendering()); + }, + + setBrowser: function setBrowser(browser, skipZoom) { + let currentBrowser = this._browser; + + let browserChanged = (currentBrowser !== browser); + + if (currentBrowser) { + currentBrowser.removeEventListener("MozAfterPaint", this.handleMozAfterPaint, false); + + // !!! --- RESIZE HACK BEGIN ----- + // change to the real event type and perhaps refactor the handler function name + currentBrowser.removeEventListener("FakeMozAfterSizeChange", this.handleMozAfterSizeChange, false); + // !!! --- RESIZE HACK END ------- + + this.discardAllBatchOperations(); + + currentBrowser.setAttribute("type", "content"); + currentBrowser.docShell.isOffScreenBrowser = false; + } + + this._restoreBrowser(browser); + + browser.setAttribute("type", "content-primary"); + + this.beginBatchOperation(); + + browser.addEventListener("MozAfterPaint", this.handleMozAfterPaint, false); + + // !!! --- RESIZE HACK BEGIN ----- + // change to the real event type and perhaps refactor the handler function name + browser.addEventListener("FakeMozAfterSizeChange", this.handleMozAfterSizeChange, false); + // !!! --- RESIZE HACK END ------- + + if (!skipZoom) { + browser.docShell.isOffScreenBrowser = true; + this.zoomToPage(); + } + + this._viewportChanged(browserChanged, browserChanged); + + this.commitBatchOperation(); + }, + + handleMozAfterPaint: function handleMozAfterPaint(ev) { + let browser = this._browser; + let tm = this._tileManager; + let vs = this._browserViewportState; + + let [scrollX, scrollY] = getContentScrollValues(browser); + let clientRects = ev.clientRects; + + // !!! --- RESIZE HACK BEGIN ----- + // remove this, cf explanation in loop below + let hack = this._resizeHack; + let hackSizeChanged = false; + // !!! --- RESIZE HACK END ------- + + let rects = []; + // loop backwards to avoid xpconnect penalty for .length + for (let i = clientRects.length - 1; i >= 0; --i) { + let e = clientRects.item(i); + let r = new wsRect(e.left + scrollX, + e.top + scrollY, + e.width, e.height); + + this.browserToViewportRect(r); + r.round(); + + if (r.right < 0 || r.bottom < 0) + continue; + + // !!! --- RESIZE HACK BEGIN ----- + // remove this. this is where we make 'lazy' calculations + // that hint at a browser size change and fake the size change + // event dispach + if (r.right > hack.maxW) { + hack.maxW = rect.right; + hackSizeChanged = true; + } + if (r.bottom > hack.maxH) { + hack.maxH = rect.bottom; + hackSizeChanged = true; + } + // !!! --- RESIZE HACK END ------- + + r.restrictTo(vs.viewportRect); + rects.push(r); + } + + // !!! --- RESIZE HACK BEGIN ----- + // remove this, cf explanation in loop above + if (hackSizeChanged) + simulateMozAfterSizeChange(browser, hack.maxW, hack.maxH); + // !!! --- RESIZE HACK END ------- + + tm.dirtyRects(rects, this.isRendering()); + }, + + handleMozAfterSizeChange: function handleMozAfterPaint(ev) { + // !!! --- RESIZE HACK BEGIN ----- + // get the correct properties off of the event, these are wrong because + // we're using a MouseEvent since it has an X and Y prop of some sort and + // we piggyback on that. + let w = ev.screenX; + let h = ev.screenY; + // !!! --- RESIZE HACK END ------- + + this.setViewportDimensions(w, h); + }, + + zoomToPage: function zoomToPage() { + let browser = this._browser; + + if (!browser) + return; + + let [w, h] = getBrowserDimensions(browser); + this.setZoomLevel(pageZoomLevel(this._visibleRect, w, h)); + }, + + zoom: function zoom(aDirection) { + if (aDirection == 0) + return; + + var zoomDelta = 0.05; // 1/20 + if (aDirection >= 0) + zoomDelta *= -1; + + this.zoomLevel = this._zoomLevel + zoomDelta; + }, + + viewportToBrowser: function viewportToBrowser(x) { + let bvs = this._browserViewportState; + + if (!bvs) + throw "No browser is set"; + + return x / bvs.zoomLevel; + }, + + browserToViewport: function browserToViewport(x) { + let bvs = this._browserViewportState; + + if (!bvs) + throw "No browser is set"; + + return x * bvs.zoomLevel; + }, + + viewportToBrowserRect: function viewportToBrowserRect(rect) { + let f = this.viewportToBrowser(1.0); + return rect.scale(f, f); + }, + + browserToViewportRect: function browserToViewportRect(rect) { + let f = this.browserToViewport(1.0); + return rect.scale(f, f); + }, + + browserToViewportCanvasContext: function browserToViewportCanvasContext(ctx) { + let f = this.browserToViewport(1.0); + ctx.scale(f, f); + }, + + + // ----------------------------------------------------------- + // Private instance methods + // + + _restoreBrowser: function _restoreBrowser(browser) { + let vr = this._visibleRect; + + if (!seenBrowser(browser)) + initBrowserState(browser, vr); + + let bvs = getViewportStateFromBrowser(browser); + + this._contentWindow = browser.contentWindow; + this._browser = browser; + this._browserViewportState = bvs; + vr.left = bvs.visibleX; + vr.top = bvs.visibleY; + this._tileManager.setBrowser(browser); + }, + + _viewportChanged: function _viewportChanged(viewportSizeChanged, dirtyAll) { + let bops = this._batchOps; + + if (bops.length > 0) { + let opState = bops[bops.length - 1]; + + if (viewportSizeChanged) + opState.viewportSizeChanged = viewportSizeChanged; + + if (dirtyAll) + opState.dirtyAll = dirtyAll; + + return; + } + + let bvs = this._browserViewportState; + let vis = this._visibleRect; + + // !!! --- RESIZE HACK BEGIN ----- + // We want to uncomment this for perf, but we can't with the hack in place + // because the mozAfterPaint gives us rects that we use to create the + // fake mozAfterResize event, so we can't just clear things. + /* + if (dirtyAll) { + // We're about to mark the entire viewport dirty, so we can clear any + // queued afterPaint events that will cause redundant draws + getBrowserDOMWindowUtils(this._browser).clearMozAfterPaintEvents(); + } + */ + // !!! --- RESIZE HACK END ------- + + if (bvs) { + resizeContainerToViewport(this._container, bvs.viewportRect); + + this._tileManager.viewportChangeHandler(bvs.viewportRect, + visibleRectToCriticalRect(vis, bvs), + viewportSizeChanged, + dirtyAll); + } + }, + + _appendTile: function _appendTile(tile) { + let canvas = tile.getContentImage(); + + /* + canvas.style.position = "absolute"; + canvas.style.left = tile.x + "px"; + canvas.style.top = tile.y + "px"; + */ + + canvas.setAttribute("style", "position: absolute; left: " + tile.boundRect.left + "px; " + "top: " + tile.boundRect.top + "px;"); + + this._container.appendChild(canvas); + + //dump('++ ' + tile.toString(true) + endl); + }, + + _removeTile: function _removeTile(tile) { + let canvas = tile.getContentImage(); + + this._container.removeChild(canvas); + + //dump('-- ' + tile.toString(true) + endl); + } + + }; + +} +)(); + + +// ----------------------------------------------------------- +// Helper structures +// + +BrowserView.BrowserViewportState = function(viewportRect, + visibleX, + visibleY, + zoomLevel) { + + this.init(viewportRect, visibleX, visibleY, zoomLevel); +}; + +BrowserView.BrowserViewportState.prototype = { + + init: function init(viewportRect, visibleX, visibleY, zoomLevel) { + this.viewportRect = viewportRect; + this.visibleX = visibleX; + this.visibleY = visibleY; + this.zoomLevel = zoomLevel; + }, + + clone: function clone() { + return new BrowserView.BrowserViewportState(this.viewportRect, + this.visibleX, + this.visibleY, + this.zoomLevel); + }, + + toString: function toString() { + let props = ['\tviewportRect=' + this.viewportRect.toString(), + '\tvisibleX=' + this.visibleX, + '\tvisibleY=' + this.visibleY, + '\tzoomLevel=' + this.zoomLevel]; + + return '[BrowserViewportState] {\n' + props.join(',\n') + '\n}'; + } + +}; + diff --git a/toolkit/content/tests/fennec-tile-testapp/chrome/content/FooScript.js b/toolkit/content/tests/fennec-tile-testapp/chrome/content/FooScript.js new file mode 100644 index 00000000000..be31d93c2e9 --- /dev/null +++ b/toolkit/content/tests/fennec-tile-testapp/chrome/content/FooScript.js @@ -0,0 +1,359 @@ +let noop = function() {}; +Browser = { + updateViewportSize: noop + /************************************************************* + function + let browser = document.getElementById("googlenews"); + let cdoc = browser.contentDocument; + + // These might not exist yet depending on page load state + var body = cdoc.body || {}; + var html = cdoc.documentElement || {}; + + var w = Math.max(body.scrollWidth || 0, html.scrollWidth); + var h = Math.max(body.scrollHeight || 0, html.scrollHeight); + + window.tileManager.viewportHandler(new wsRect(0, 0, w, h), + window.innerWidth, + new wsRect(0, 0, window.innerWidth, window.innerHeight), + false); + *************************************************************/ +}; +let ws = { + beginUpdateBatch: noop, + panTo: noop, + endUpdateBatch: noop +}; +let Ci = Components.interfaces; +let bv = null; +let endl = "\n"; + + +function BrowserView() { + this.init(); + bindAll(this); +} + +BrowserView.prototype = { + + // --- PROPERTIES --- + // public: + // init() + // getViewportInnerBoundsRect(dx, dy) + // tileManager + // scrollbox + // + // private: + // _scrollbox + // _leftbar + // _rightbar + // _topbar + // _browser + // _tileManager + // _viewportRect + // _viewportInnerBoundsRect + // + + get tileManager() { return this._tileManager; }, + get scrollbox() { return this._scrollbox; }, + + init: function init() { + let scrollbox = document.getElementById("scrollbox") + .boxObject + .QueryInterface(Components.interfaces.nsIScrollBoxObject); + this._scrollbox = scrollbox; + + let leftbar = document.getElementById("left_sidebar"); + let rightbar = document.getElementById("right_sidebar"); + let topbar = document.getElementById("top_urlbar"); + this._leftbar = leftbar; + this._rightbar = rightbar; + this._topbar = topbar; + + scrollbox.scrollTo(Math.round(leftbar.getBoundingClientRect().right), 0); + + let tileContainer = document.getElementById("tile_container"); + tileContainer.addEventListener("mousedown", onMouseDown, true); + tileContainer.addEventListener("mouseup", onMouseUp, true); + tileContainer.addEventListener("mousemove", onMouseMove, true); + this._tileContainer = tileContainer; + + let tileManager = new TileManager(this.appendTile, this.removeTile, window.innerWidth); + this._tileManager = tileManager; + + let browser = document.getElementById("googlenews"); + this.setCurrentBrowser(browser, false); // sets this._browser + + let cdoc = browser.contentDocument; + + // These might not exist yet depending on page load state + let body = cdoc.body || {}; + let html = cdoc.documentElement || {}; + + let w = Math.max(body.scrollWidth || 0, html.scrollWidth); + let h = Math.max(body.scrollHeight || 0, html.scrollHeight); + + let viewportRect = new wsRect(0, 0, w, h); + this._viewportRect = viewportRect; + + let viewportInnerBoundsRect = this.getViewportInnerBoundsRect(); + this._viewportInnerBoundsRect = viewportInnerBoundsRect; + + tileManager.viewportHandler(viewportRect, + window.innerWidth, + viewportInnerBoundsRect, + true); + }, + + resizeTileContainer: function resizeTileContainer() { + + }, + + scrollboxToViewportRect: function scrollboxToViewportRect(rect, clip) { + let leftbar = this._leftbar.getBoundingClientRect(); + let rightbar = this._rightbar.getBoundingClientRect(); + let topbar = this._topbar.getBoundingClientRect(); + + let xtrans = -leftbar.width; + let ytrans = -topbar.height; + let x = rect.x + xtrans; + let y = rect.y + ytrans; + + // XXX we're cheating --- this is not really a clip, but its the only + // way this function is used + rect.x = (clip) ? Math.max(x, 0) : x; + rect.y = (clip) ? Math.max(y, 0) : y; + + return rect; + }, + + getScrollboxPosition: function getScrollboxPosition() { + let x = {}; + let y = {}; + this._scrollbox.getPosition(x, y); + return [x.value, y.value]; + }, + + getViewportInnerBoundsRect: function getViewportInnerBoundsRect(dx, dy) { + if (!dx) dx = 0; + if (!dy) dy = 0; + + let w = window.innerWidth; + let h = window.innerHeight; + + let leftbar = this._leftbar.getBoundingClientRect(); + let rightbar = this._rightbar.getBoundingClientRect(); + let topbar = this._topbar.getBoundingClientRect(); + + let leftinner = Math.max(leftbar.right - dx, 0); + let rightinner = Math.min(rightbar.left - dx, w); + let topinner = Math.max(topbar.bottom - dy, 0); + + let [x, y] = this.getScrollboxPosition(); + + return this.scrollboxToViewportRect(new wsRect(x + dx, y + dy, rightinner - leftinner, h - topinner), + true); + }, + + appendTile: function appendTile(tile) { + let canvas = tile.contentImage; + + canvas.style.position = "absolute"; + canvas.style.left = tile.x + "px"; + canvas.style.top = tile.y + "px"; + + let tileContainer = document.getElementById("tile_container"); + tileContainer.appendChild(canvas); + + dump('++ ' + tile.toString() + endl); + }, + + removeTile: function removeTile(tile) { + let canvas = tile.contentImage; + + let tileContainer = document.getElementById("tile_container"); + tileContainer.removeChild(canvas); + + dump('-- ' + tile.toString() + endl); + }, + + scrollBy: function scrollBy(dx, dy) { + // TODO + this.onBeforeScroll(); + this.onAfterScroll(); + }, + + // x: current x + // y: current y + // dx: delta to get to x from current x + // dy: delta to get to y from current y + onBeforeScroll: function onBeforeScroll(x, y, dx, dy) { + this.tileManager.onBeforeScroll(this.getViewportInnerBoundsRect(dx, dy)); + + // shouldn't update margin if it doesn't need to be changed + let sidebars = document.getElementsByClassName("sidebar"); + for (let i = 0; i < sidebars.length; i++) { + let sidebar = sidebars[i]; + sidebar.style.margin = (y + dy) + "px 0px 0px 0px"; + } + + let urlbar = document.getElementById("top_urlbar"); + urlbar.style.margin = "0px 0px 0px " + (x + dx) + "px"; + }, + + onAfterScroll: function onAfterScroll(x, y, dx, dy) { + this.tileManager.onAfterScroll(this.getViewportInnerBoundsRect()); + }, + + setCurrentBrowser: function setCurrentBrowser(browser, skipZoom) { + let currentBrowser = this._browser; + if (currentBrowser) { + // backup state + currentBrowser.mZoomLevel = this.zoomLevel; + currentBrowser.mPanX = ws._viewingRect.x; + currentBrowser.mPanY = ws._viewingRect.y; + + // stop monitor paint events for this browser + currentBrowser.removeEventListener("MozAfterPaint", this.handleMozAfterPaint, false); + currentBrowser.setAttribute("type", "content"); + currentBrowser.docShell.isOffScreenBrowser = false; + } + + browser.setAttribute("type", "content-primary"); + if (!skipZoom) + browser.docShell.isOffScreenBrowser = true; + + // start monitoring paint events for this browser + browser.addEventListener("MozAfterPaint", this.handleMozAfterPaint, false); + + this._browser = browser; + + // endLoading(and startLoading in most cases) calls zoom anyway + if (!skipZoom) { + this.zoomToPage(); + } + + if ("mZoomLevel" in browser) { + // restore last state + ws.beginUpdateBatch(); + ws.panTo(browser.mPanX, browser.mPanY); + this.zoomLevel = browser.mZoomLevel; + ws.endUpdateBatch(true); + + // drop the cache + delete browser.mZoomLevel; + delete browser.mPanX; + delete browser.mPanY; + } + + this.tileManager.browser = browser; + }, + + handleMozAfterPaint: function handleMozAfterPaint(ev) { + this.tileManager.handleMozAfterPaint(ev); + }, + + zoomToPage: function zoomToPage() { + /******************************************************** + let needToPanToTop = this._needToPanToTop; + // Ensure pages are panned at the top before zooming/painting + // combine the initial pan + zoom into a transaction + if (needToPanToTop) { + ws.beginUpdateBatch(); + this._needToPanToTop = false; + ws.panTo(0, -BrowserUI.toolbarH); + } + // Adjust the zoomLevel to fit the page contents in our window width + let [contentW, ] = this._contentAreaDimensions; + let fakeW = this._fakeWidth; + + if (contentW > fakeW) + this.zoomLevel = fakeW / contentW; + + if (needToPanToTop) + ws.endUpdateBatch(); + ********************************************************/ + } + +}; + + +function onResize(e) { + let browser = document.getElementById("googlenews"); + let cdoc = browser.contentDocument; + + // These might not exist yet depending on page load state + var body = cdoc.body || {}; + var html = cdoc.documentElement || {}; + + var w = Math.max(body.scrollWidth || 0, html.scrollWidth); + var h = Math.max(body.scrollHeight || 0, html.scrollHeight); + + if (bv) + bv.tileManager.viewportHandler(new wsRect(0, 0, w, h), + window.innerWidth, + bv.getViewportInnerBoundsRect(), + true); +} + +function onMouseDown(e) { + window._isDragging = true; + window._dragStart = {x: e.clientX, y: e.clientY}; + + bv.tileManager.startPanning(); +} + +function onMouseUp() { + window._isDragging = false; + + bv.tileManager.endPanning(); +} + +function onMouseMove(e) { + if (window._isDragging) { + let scrollbox = bv.scrollbox; + + let x = {}; + let y = {}; + let w = {}; + let h = {}; + scrollbox.getPosition(x, y); + scrollbox.getScrolledSize(w, h); + + let dx = window._dragStart.x - e.clientX; + let dy = window._dragStart.y - e.clientY; + + // XXX if max(x, 0) > scrollwidth we shouldn't do anything (same for y/height) + let newX = Math.max(x.value + dx, 0); + let newY = Math.max(y.value + dy, 0); + + if (newX < w.value || newY < h.value) { + // clip dx and dy to prevent us from going below 0 + dx = Math.max(dx, -x.value); + dy = Math.max(dy, -y.value); + + bv.onBeforeScroll(x.value, y.value, dx, dy); + + /*dump("==========scroll==========" + endl); + dump("delta: " + dx + "," + dy + endl); + let xx = {}; + let yy = {}; + scrollbox.getPosition(xx, yy); + dump(xx.value + "," + yy.value + endl);*/ + + scrollbox.scrollBy(dx, dy); + + /*scrollbox.getPosition(xx, yy); + dump(xx.value + "," + yy.value + endl); + dump("==========================" + endl);*/ + + bv.onAfterScroll(); + } + } + + window._dragStart = {x: e.clientX, y: e.clientY}; +} + +function onLoad() { + bv = new BrowserView(); +} diff --git a/toolkit/content/tests/fennec-tile-testapp/chrome/content/TileManager.js b/toolkit/content/tests/fennec-tile-testapp/chrome/content/TileManager.js new file mode 100644 index 00000000000..b6b169123b1 --- /dev/null +++ b/toolkit/content/tests/fennec-tile-testapp/chrome/content/TileManager.js @@ -0,0 +1,1054 @@ +// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- +/* + * ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is Mozilla Mobile Browser. + * + * The Initial Developer of the Original Code is + * Mozilla Corporation. + * Portions created by the Initial Developer are Copyright (C) 2008 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Roy Frostig + * + * 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 kXHTMLNamespaceURI = "http://www.w3.org/1999/xhtml"; + +// base-2 exponent for width, height of a single tile. +const kTileExponentWidth = 7; +const kTileExponentHeight = 7; +const kTileWidth = Math.pow(2, kTileExponentWidth); // 2^7 = 128 +const kTileHeight = Math.pow(2, kTileExponentHeight); // 2^7 = 128 +const kLazyRoundTimeCap = 500; // millis + + +function bind(f, thisObj) { + return function() { + return f.apply(thisObj, arguments); + }; +} + +function bindSome(instance, methodNames) { + for each (let methodName in methodNames) + if (methodName in instance) + instance[methodName] = bind(instance[methodName], instance); +} + +function bindAll(instance) { + for (let key in instance) + if (instance[key] instanceof Function) + instance[key] = bind(instance[key], instance); +} + + +/** + * The Tile Manager! + * + * @param appendTile The function the tile manager should call in order to + * "display" a tile (e.g. append it to the DOM). The argument to this + * function is a TileManager.Tile object. + * @param removeTile The function the tile manager should call in order to + * "undisplay" a tile (e.g. remove it from the DOM). The argument to this + * function is a TileManager.Tile object. + * @param fakeWidth The width of the widest possible visible rectangle, e.g. + * the width of the screen. This is used in setting the zoomLevel. + */ +function TileManager(appendTile, removeTile, browserView) { + // backref to the BrowserView object that owns us + this._browserView = browserView; + + // callbacks to append / remove a tile to / from the parent + this._appendTile = appendTile; + this._removeTile = removeTile; + + // tile cache holds tile objects and pools them under a given capacity + let self = this; + this._tileCache = new TileManager.TileCache(function(tile) { self._removeTileSafe(tile); }, + -1, -1, 110); + + // Rectangle within the viewport that is visible to the user. It is "critical" + // in the sense that it must be rendered as soon as it becomes dirty + this._criticalRect = null; + + // Current DOM element, holding the content we wish to render. + // This is null when no browser is attached + this._browser = null; + + // if we have an outstanding paint timeout, its value is stored here + // for cancelling when we end page loads + //this._drawTimeout = 0; + this._pageLoadResizerTimeout = 0; + + // timeout of the non-visible-tiles-crawler to cache renders from the browser + this._idleTileCrawlerTimeout = 0; + + // object that keeps state on our current lazyload crawl + this._crawler = null; + + // the max right coordinate we've seen from paint events + // while we were loading a page. If we see something that's bigger than + // our width, we'll trigger a page zoom. + this._pageLoadMaxRight = 0; + this._pageLoadMaxBottom = 0; + + // Tells us to pan to top before first draw + this._needToPanToTop = false; +} + +TileManager.prototype = { + + setBrowser: function setBrowser(b) { this._browser = b; }, + + // This is the callback fired by our client whenever the viewport + // changed somehow (or didn't change but someone asked it to update). + viewportChangeHandler: function viewportChangeHandler(viewportRect, + criticalRect, + boundsSizeChanged, + dirtyAll) { + // !!! --- DEBUG BEGIN ----- + dump("***vphandler***\n"); + dump(viewportRect.toString() + "\n"); + dump(criticalRect.toString() + "\n"); + dump(boundsSizeChanged + "\n"); + dump(dirtyAll + "\n***************\n"); + // !!! --- DEBUG END ------- + + let tc = this._tileCache; + + tc.iBound = Math.ceil(viewportRect.right / kTileWidth); + tc.jBound = Math.ceil(viewportRect.bottom / kTileHeight); + + if (!criticalRect || !criticalRect.equals(this._criticalRect)) { + this.beginCriticalMove(criticalRect); + this.endCriticalMove(criticalRect, !boundsSizeChanged); + } + + if (boundsSizeChanged) { + // TODO fastpath if !dirtyAll + this.dirtyRects([viewportRect.clone()], true); + } + }, + + dirtyRects: function dirtyRects(rects, doCriticalRender) { + let criticalIsDirty = false; + let criticalRect = this._criticalRect; + + for each (let rect in rects) { + this._tileCache.forEachIntersectingRect(rect, false, this._dirtyTile, this); + + if (criticalRect && rect.intersects(criticalRect)) + criticalIsDirty = true; + } + + if (criticalIsDirty && doCriticalRender) + this.criticalRectPaint(); + }, + + criticalRectPaint: function criticalRectPaint() { + let cr = this._criticalRect; + + if (cr) { + let [ctrx, ctry] = cr.centerRounded(); + this.recenterEvictionQueue(ctrx, ctry); + this._renderAppendHoldRect(cr); + } + }, + + beginCriticalMove2: function beginCriticalMove(destCriticalRect) { + let start = Date.now(); + function appendNonDirtyTile(tile) { + if (!tile.isDirty()) + this._appendTileSafe(tile); + } + + if (destCriticalRect) + this._tileCache.forEachIntersectingRect(destCriticalRect, false, appendNonDirtyTile, this); + let end = Date.now(); + dump("start: " + (end-start) + "\n") + }, + + beginCriticalMove: function beginCriticalMove(destCriticalRect) { + /* + function appendNonDirtyTile(tile) { + if (!tile.isDirty()) + this._appendTileSafe(tile); + } + */ + + let start = Date.now(); + + if (destCriticalRect) { + + let rect = destCriticalRect; + + let create = false; + + // this._tileCache.forEachIntersectingRect(destCriticalRect, false, appendNonDirtyTile, this); + let visited = {}; + let evictGuard = null; + if (create) { + evictGuard = function evictGuard(tile) { + return !visited[tile.toString()]; + }; + } + + let starti = rect.left >> kTileExponentWidth; + let endi = rect.right >> kTileExponentWidth; + + let startj = rect.top >> kTileExponentHeight; + let endj = rect.bottom >> kTileExponentHeight; + + let tile = null; + let tc = this._tileCache; + + for (var j = startj; j <= endj; ++j) { + for (var i = starti; i <= endi; ++i) { + + // 'this' for getTile needs to be tc + + //tile = this.getTile(i, j, create, evictGuard); + //if (!tc.inBounds(i, j)) { + if (0 <= i && 0 <= j && i <= tc.iBound && j <= tc.jBound) { + //return null; + break; + } + + tile = null; + + //if (tc._isOccupied(i, j)) { + if (!!(tc._tiles[i] && tc._tiles[i][j])) { + tile = tc._tiles[i][j]; + } else if (create) { + // NOTE: create is false here + tile = tc._createTile(i, j, evictionGuard); + if (tile) tile.markDirty(); + } + + if (tile) { + visited[tile.toString()] = true; + //fn.call(thisObj, tile); + //function appendNonDirtyTile(tile) { + //if (!tile.isDirty()) + if (!tile._dirtyTileCanvas) { + //this._appendTileSafe(tile); + if (!tile._appended) { + let astart = Date.now(); + this._appendTile(tile); + tile._appended = true; + let aend = Date.now(); + dump("append: " + (aend - astart) + "\n"); + } + } + //} + } + } + } + } + + let end = Date.now(); + dump("start: " + (end-start) + "\n") + }, + + endCriticalMove: function endCriticalMove(destCriticalRect, doCriticalPaint) { + let start = Date.now(); + + let tc = this._tileCache; + let cr = this._criticalRect; + + let dcr = destCriticalRect.clone(); + + let f = function releaseOldTile(tile) { + // release old tile + if (!tile.boundRect.intersects(dcr)) + tc.releaseTile(tile); + } + + if (cr) + tc.forEachIntersectingRect(cr, false, f, this); + + this._holdRect(destCriticalRect); + + if (cr) + cr.copyFrom(destCriticalRect); + else + this._criticalRect = cr = destCriticalRect; + + let crpstart = Date.now(); + if (doCriticalPaint) + this.criticalRectPaint(); + dump(" crp: " + (Date.now() - crpstart) + "\n"); + + let end = Date.now(); + dump("end: " + (end - start) + "\n"); + }, + + restartLazyCrawl: function restartLazyCrawl(startRectOrQueue) { + if (!startRectOrQueue || startRectOrQueue instanceof Array) { + this._crawler = new TileManager.CrawlIterator(this._tileCache); + + if (startRectOrQueue) { + let len = startRectOrQueue.length; + for (let k = 0; k < len; ++k) + this._crawler.enqueue(startRectOrQueue[k].i, startRectOrQueue[k].j); + } + } else { + this._crawler = new TileManager.CrawlIterator(this._tileCache, startRectOrQueue); + } + + if (!this._idleTileCrawlerTimeout) + this._idleTileCrawlerTimeout = setTimeout(this._idleTileCrawler, 2000, this); + }, + + stopLazyCrawl: function stopLazyCrawl() { + this._idleTileCrawlerTimeout = 0; + this._crawler = null; + + let cr = this._criticalRect; + if (cr) { + let [ctrx, ctry] = cr.centerRounded(); + this.recenterEvictionQueue(ctrx, ctry); + } + }, + + recenterEvictionQueue: function recenterEvictionQueue(ctrx, ctry) { + let ctri = ctrx >> kTileExponentWidth; + let ctrj = ctry >> kTileExponentHeight; + + function evictFarTiles(a, b) { + let dista = Math.max(Math.abs(a.i - ctri), Math.abs(a.j - ctrj)); + let distb = Math.max(Math.abs(b.i - ctri), Math.abs(b.j - ctrj)); + return dista - distb; + } + + this._tileCache.sortEvictionQueue(evictFarTiles); + }, + + _renderTile: function _renderTile(tile) { + if (tile.isDirty()) + tile.render(this._browser, this._browserView); + }, + + _appendTileSafe: function _appendTileSafe(tile) { + if (!tile._appended) { + this._appendTile(tile); + tile._appended = true; + } + }, + + _removeTileSafe: function _removeTileSafe(tile) { + if (tile._appended) { + this._removeTile(tile); + tile._appended = false; + } + }, + + _dirtyTile: function _dirtyTile(tile) { + if (!this._criticalRect || !tile.boundRect.intersects(this._criticalRect)) + this._removeTileSafe(tile); + + tile.markDirty(); + + if (this._crawler) + this._crawler.enqueue(tile.i, tile.j); + }, + + _holdRect: function _holdRect(rect) { + this._tileCache.holdTilesIntersectingRect(rect); + }, + + _releaseRect: function _releaseRect(rect) { + this._tileCache.releaseTilesIntersectingRect(rect); + }, + + _renderAppendHoldRect: function _renderAppendHoldRect(rect) { + function renderAppendHoldTile(tile) { + if (tile.isDirty()) + this._renderTile(tile); + + this._appendTileSafe(tile); + this._tileCache.holdTile(tile); + } + + this._tileCache.forEachIntersectingRect(rect, true, renderAppendHoldTile, this); + }, + + _idleTileCrawler: function _idleTileCrawler(self) { + if (!self) self = this; + dump('crawl pass.\n'); + let itered = 0, rendered = 0; + + let start = Date.now(); + let comeAgain = true; + + while ((Date.now() - start) <= kLazyRoundTimeCap) { + let tile = self._crawler.next(); + + if (!tile) { + comeAgain = false; + break; + } + + if (tile.isDirty()) { + self._renderTile(tile); + ++rendered; + } + ++itered; + } + + dump('crawl itered:' + itered + ' rendered:' + rendered + '\n'); + + if (comeAgain) { + self._idleTileCrawlerTimeout = setTimeout(self._idleTileCrawler, 2000, self); + } else { + self.stopLazyCrawl(); + dump('crawl end\n'); + } + } + +}; + + +/** + * The tile cache used by the tile manager to hold and index all + * tiles. Also responsible for pooling tiles and maintaining the + * number of tiles under given capacity. + * + * @param onBeforeTileDetach callback set by the TileManager to call before + * we must "detach" a tile from a tileholder due to needing it elsewhere or + * having to discard it on capacity decrease + * @param capacity the initial capacity of the tile cache, i.e. the max number + * of tiles the cache can have allocated + */ +TileManager.TileCache = function TileCache(onBeforeTileDetach, iBound, jBound, capacity) { + if (arguments.length <= 3 || capacity < 0) + capacity = Infinity; + + // We track all pooled tiles in a 2D array (row, column) ordered as + // they "appear on screen". The array is a grid that functions for + // storage of the tiles and as a lookup map. Each array entry is + // a reference to the tile occupying that space ("tileholder"). Entries + // are not unique, so a tile could be referenced by multiple array entries, + // i.e. a tile could "span" many tile placeholders (e.g. if we merge + // neighbouring tiles). + this._tiles = []; + + // holds the same tiles that _tiles holds, but as contiguous array + // elements, for pooling tiles for reuse under finite capacity + this._tilePool = (capacity == Infinity) ? new Array() : new Array(capacity); + + this._capacity = capacity; + this._nTiles = 0; + this._numFree = 0; + + this._onBeforeTileDetach = onBeforeTileDetach; + + this.iBound = iBound; + this.jBound = jBound; +}; + +TileManager.TileCache.prototype = { + + get size() { return this._nTiles; }, + get numFree() { return this._numFree; }, + + // A comparison function that will compare all free tiles as greater + // than all non-free tiles. Useful because, for instance, to shrink + // the tile pool when capacity is lowered, we want to remove all tiles + // at the new cap and beyond, favoring removal of free tiles first. + evictionCmp: function freeTilesLast(a, b) { + if (a.free == b.free) return (a.j == b.j) ? b.i - a.i : b.j - a.j; + return (a.free) ? 1 : -1; + }, + + getCapacity: function getCapacity() { return this._capacity; }, + + setCapacity: function setCapacity(newCap, skipEvictionQueueSort) { + if (newCap < 0) + throw "Cannot set a negative tile cache capacity"; + + if (newCap == Infinity) { + this._capacity = Infinity; + return; + } else if (this._capacity == Infinity) { + // pretend we had a finite capacity all along and proceed normally + this._capacity = this._tilePool.length; + } + + let rem = null; + + if (newCap < this._capacity) { + // This case is obnoxious. We're decreasing our capacity which means + // we may have to get rid of tiles. Depending on our eviction comparator, + // we probably try to get rid free tiles first, but we might have to throw + // out some nonfree ones too. Note that "throwing out" means the cache + // won't keep them, and they'll get GC'ed as soon as all other refholders + // let go of their refs to the tile. + if (!skipEvictionQueueSort) + this.sortEvictionQueue(); + + rem = this._tilePool.splice(newCap); + + } else { + // This case is win. Extend our tile pool array with new empty space. + this._tilePool.push.apply(this._tilePool, new Array(newCap - this._capacity)); + } + + // update state in the case that we threw things out. + let nTilesDeleted = this._nTiles - newCap; + if (nTilesDeleted > 0) { + let nFreeDeleted = 0; + for (let k = 0; k < nTilesDeleted; ++k) { + if (rem[k].free) + nFreeDeleted++; + + this._detachTile(rem[k].i, rem[k].j); + } + + this._nTiles -= nTilesDeleted; + this._numFree -= nFreeDeleted; + } + + this._capacity = newCap; + }, + + _isOccupied: function _isOccupied(i, j) { + return !!(this._tiles[i] && this._tiles[i][j]); + }, + + _detachTile: function _detachTile(i, j) { + let tile = null; + if (this._isOccupied(i, j)) { + tile = this._tiles[i][j]; + + if (this._onBeforeTileDetach) + this._onBeforeTileDetach(tile); + + this.releaseTile(tile); + delete this._tiles[i][j]; + } + return tile; + }, + + _reassignTile: function _reassignTile(tile, i, j) { + this._detachTile(tile.i, tile.j); // detach + tile.init(i, j); // re-init + this._tiles[i][j] = tile; // attach + return tile; + }, + + _evictTile: function _evictTile(evictionGuard) { + let k = this._nTiles - 1; + let pool = this._tilePool; + let victim = null; + + for (; k >= 0; --k) { + if (pool[k].free && + (!evictionGuard || evictionGuard(pool[k]))) + { + victim = pool[k]; + break; + } + } + + return victim; + }, + + _createTile: function _createTile(i, j, evictionGuard) { + if (!this._tiles[i]) + this._tiles[i] = []; + + let tile = null; + + if (this._nTiles < this._capacity) { + // either capacity is infinite, or we still have room to allocate more + tile = new TileManager.Tile(i, j); + this._tiles[i][j] = tile; + this._tilePool[this._nTiles++] = tile; + this._numFree++; + + } else { + // assert: nTiles == capacity + dump("\nevicting\n"); + tile = this._evictTile(evictionGuard); + if (tile) + this._reassignTile(tile, i, j); + } + + return tile; + }, + + inBounds: function inBounds(i, j) { + return 0 <= i && 0 <= j && i <= this.iBound && j <= this.jBound; + }, + + sortEvictionQueue: function sortEvictionQueue(cmp) { + if (!cmp) cmp = this.evictionCmp; + this._tilePool.sort(cmp); + }, + + /** + * Get a tile by its indices + * + * @param i Column + * @param j Row + * @param create Flag true if the tile should be created in case there is no + * tile at (i, j) + * @param reuseCondition Boolean-valued function to restrict conditions under + * which an old tile may be reused for creating this one. This can happen if + * the cache has reached its capacity and must reuse existing tiles in order to + * create this one. The function is given a Tile object as its argument and + * returns true if the tile is OK for reuse. This argument has no effect if the + * create argument is false. + */ + getTile: function getTile(i, j, create, evictionGuard) { + if (!this.inBounds(i, j)) + return null; + + let tile = null; + + if (this._isOccupied(i, j)) { + tile = this._tiles[i][j]; + } else if (create) { + tile = this._createTile(i, j, evictionGuard); + if (tile) tile.markDirty(); + } + + return tile; + }, + + /** + * Look up (possibly creating) a tile from its viewport coordinates. + * + * @param x + * @param y + * @param create Flag true if the tile should be created in case it doesn't + * already exist at the tileholder corresponding to (x, y) + */ + tileFromPoint: function tileFromPoint(x, y, create) { + let i = x >> kTileExponentWidth; + let j = y >> kTileExponentHeight; + + return this.getTile(i, j, create); + }, + + /** + * Hold a tile (i.e. mark it non-free). Returns true if the operation + * actually did something, false elsewise. + */ + holdTile: function holdTile(tile) { + if (tile && tile.free) { + tile._hold(); + this._numFree--; + return true; + } + return false; + }, + + /** + * Release a tile (i.e. mark it free). Returns true if the operation + * actually did something, false elsewise. + */ + releaseTile: function releaseTile(tile) { + if (tile && !tile.free) { + tile._release(); + this._numFree++; + return true; + } + return false; + }, + + // XXX the following two functions will iterate through duplicate tiles + // once we begin to merge tiles. + /** + * Fetch all tiles that share at least one point with this rect. If `create' + * is true then any tileless tileholders will have tiles created for them. + */ + tilesIntersectingRect: function tilesIntersectingRect(rect, create) { + let dx = (rect.right % kTileWidth) - (rect.left % kTileWidth); + let dy = (rect.bottom % kTileHeight) - (rect.top % kTileHeight); + let tiles = []; + + for (let y = rect.top; y <= rect.bottom - dy; y += kTileHeight) { + for (let x = rect.left; x <= rect.right - dx; x += kTileWidth) { + let tile = this.tileFromPoint(x, y, create); + if (tile) + tiles.push(tile); + } + } + + return tiles; + }, + + forEachIntersectingRect: function forEachIntersectingRect(rect, create, fn, thisObj) { + let visited = {}; + let evictGuard = null; + if (create) { + evictGuard = function evictGuard(tile) { + return !visited[tile.toString()]; + }; + } + + let starti = rect.left >> kTileExponentWidth; + let endi = rect.right >> kTileExponentWidth; + + let startj = rect.top >> kTileExponentHeight; + let endj = rect.bottom >> kTileExponentHeight; + + let tile = null; + for (var j = startj; j <= endj; ++j) { + for (var i = starti; i <= endi; ++i) { + tile = this.getTile(i, j, create, evictGuard); + if (tile) { + visited[tile.toString()] = true; + fn.call(thisObj, tile); + } + } + } + }, + + holdTilesIntersectingRect: function holdTilesIntersectingRect(rect) { + this.forEachIntersectingRect(rect, false, this.holdTile, this); + }, + + releaseTilesIntersectingRect: function releaseTilesIntersectingRect(rect) { + this.forEachIntersectingRect(rect, false, this.releaseTile, this); + } + +}; + + + +TileManager.Tile = function Tile(i, j) { + // canvas element is where we keep paint data from browser for this tile + this._canvas = document.createElementNS(kXHTMLNamespaceURI, "canvas"); + this._canvas.setAttribute("width", String(kTileWidth)); + this._canvas.setAttribute("height", String(kTileHeight)); + this._canvas.setAttribute("moz-opaque", "true"); + //this._canvas.style.border = "1px solid red"; + + this.init(i, j); // defines more properties, cf below +}; + +TileManager.Tile.prototype = { + + // essentially, this is part of constructor code, but since we reuse tiles + // in the tile cache, this is here so that we can reinitialize tiles when we + // reuse them + init: function init(i, j) { + if (!this.boundRect) + this.boundRect = new wsRect(i * kTileWidth, j * kTileHeight, kTileWidth, kTileHeight); + else + this.boundRect.setRect(i * kTileWidth, j * kTileHeight, kTileWidth, kTileHeight); + + // indices! + this.i = i; + this.j = j; + + // flags true if we need to repaint our own local canvas + this._dirtyTileCanvas = false; + + // keep a dirty rectangle (i.e. only part of the tile is dirty) + this._dirtyTileCanvasRect = null; + + // flag used by TileManager to avoid re-appending tiles that have already + // been appended + this._appended = false; + + // We keep tile objects around after their use for later reuse, so this + // flags true for an unused pooled tile. We don't actually care about + // this from within the Tile prototype, it is here for the cache to use. + this.free = true; + }, + + // viewport coordinates + get x() { return this.boundRect.left; }, + get y() { return this.boundRect.top; }, + + // the actual canvas that holds the most recently rendered image of this + // canvas + getContentImage: function getContentImage() { return this._canvas; }, + + isDirty: function isDirty() { return this._dirtyTileCanvas; }, + + /** + * Mark this entire tile as dirty (i.e. the whole tile needs to be rendered + * on next render). + */ + markDirty: function markDirty() { this.updateDirtyRegion(); }, + + unmarkDirty: function unmarkDirty() { + this._dirtyTileCanvasRect = null; + this._dirtyTileCanvas = false; + }, + + /** + * This will mark dirty at least everything in dirtyRect (which must be + * specified in canvas coordinates). If dirtyRect is not given then + * the entire tile is marked dirty. + */ + updateDirtyRegion: function updateDirtyRegion(dirtyRect) { + if (!dirtyRect) { + + if (!this._dirtyTileCanvasRect) + this._dirtyTileCanvasRect = this.boundRect.clone(); + else + this._dirtyTileCanvasRect.copyFrom(this.boundRect); + + } else { + + if (!this._dirtyTileCanvasRect) + this._dirtyTileCanvasRect = dirtyRect.intersect(this.boundRect); + else if (dirtyRect.intersects(this.boundRect)) + this._dirtyTileCanvasRect.expandToContain(dirtyRect.intersect(this.boundRect)); + + } + + // TODO if after the above, the dirty rectangle is large enough, + // mark the whole tile dirty. + + if (this._dirtyTileCanvasRect) + this._dirtyTileCanvas = true; + }, + + /** + * Actually draw the browser content into the dirty region of this + * tile. This requires us to actually draw with the + * nsIDOMCanvasRenderingContext2D object's drawWindow method, which + * we expect to be a relatively heavy operation. + * + * You likely want to check if the tile isDirty() before asking it + * to render, as this will cause the entire tile to re-render in the + * case that it is not dirty. + */ + render: function render(browser, browserView) { + if (!this.isDirty()) + this.markDirty(); + + let rect = this._dirtyTileCanvasRect; + + let x = rect.left - this.boundRect.left; + let y = rect.top - this.boundRect.top; + + browserView.viewportToBrowserRect(rect); + //rect.round(); // snap outward to get whole "pixel" (in browser coords) + + let ctx = this._canvas.getContext("2d"); + ctx.save(); + + browserView.browserToViewportCanvasContext(ctx); + + ctx.translate(x, y); + + let cw = browserView._contentWindow; + //let cw = browser.contentWindow; + ctx.drawWindow(cw, + rect.left, rect.top, + rect.right - rect.left, rect.bottom - rect.top, + "grey", + (ctx.DRAWWINDOW_DO_NOT_FLUSH | ctx.DRAWWINDOW_DRAW_CARET)); + + ctx.restore(); + + this.unmarkDirty(); + }, + + toString: function toString(more) { + if (more) { + return 'Tile(' + [this.i, + this.j, + "dirty=" + this.isDirty(), + "boundRect=" + this.boundRect].join(', ') + + ')'; + } + + return 'Tile(' + this.i + ', ' + this.j + ')'; + }, + + _hold: function hold() { this.free = false; }, + _release: function release() { this.free = true; } + +}; + + +/** + * A CrawlIterator is in charge of creating and returning subsequent tiles "crawled" + * over as we render tiles lazily. It supports iterator semantics so you can use + * CrawlIterator objects in for..in loops. + * + * Currently the CrawlIterator is built to expand a rectangle iteratively and return + * subsequent tiles that intersect the boundary of the rectangle. Each expansion of + * the rectangle is one unit of tile dimensions in each direction. This is repeated + * until all tiles from elsewhere have been reused (assuming the cache has finite + * capacity) in this crawl, so that we don't start reusing tiles from the beginning + * of our crawl. Afterward, the CrawlIterator enters a state where it operates as a + * FIFO queue, and calls to next() simply dequeue elements, which must be added with + * enqueue(). + * + * @param tileCache The TileCache over whose tiles this CrawlIterator will crawl + * @param startRect [optional] The rectangle that we grow in the first (rectangle + * expansion) iteration state. + */ +TileManager.CrawlIterator = function CrawlIterator(tileCache, startRect) { + this._tileCache = tileCache; + this._stepRect = startRect; + + // used to remember tiles that we've reused during this crawl + this._visited = {}; + + // filters the tiles we've already reused once from being considered victims + // for reuse when we ask the tile cache to create a new tile + let visited = this._visited; + this._notVisited = function(tile) { return !visited[tile]; }; + + // a generator that generates tile indices corresponding to tiles intersecting + // the boundary of an expanding rectangle + this._crawlIndices = !startRect ? null : (function indicesGenerator(rect, tc) { + let outOfBounds = false; + while (!outOfBounds) { + // expand rect + rect.left -= kTileWidth; + rect.right += kTileWidth; + rect.top -= kTileHeight; + rect.bottom += kTileHeight; + + let dx = (rect.right % kTileWidth) - (rect.left % kTileWidth); + let dy = (rect.bottom % kTileHeight) - (rect.top % kTileHeight); + + outOfBounds = true; + + // top, bottom borders + for each (let y in [rect.top, rect.bottom]) { + for (let x = rect.left; x <= rect.right - dx; x += kTileWidth) { + let i = x >> kTileExponentWidth; + let j = y >> kTileExponentHeight; + if (tc.inBounds(i, j)) { + outOfBounds = false; + yield [i, j]; + } + } + } + + // left, right borders + for each (let x in [rect.left, rect.right]) { + for (let y = rect.top; y <= rect.bottom - dy; y += kTileHeight) { + let i = x >> kTileExponentWidth; + let j = y >> kTileExponentHeight; + if (tc.inBounds(i, j)) { + outOfBounds = false; + yield [i, j]; + } + } + } + } + })(this._stepRect, this._tileCache), // instantiate the generator + + // after we finish the rectangle iteration state, we enter the FIFO queue state + this._queueState = !startRect; + this._queue = []; + + // used to prevent tiles from being enqueued twice --- "patience, we'll get to + // it in a moment" + this._enqueued = {}; +}; + +TileManager.CrawlIterator.prototype = { + __iterator__: function() { + while (true) { + let tile = this.next(); + if (!tile) break; + yield tile; + } + }, + + becomeQueue: function becomeQueue() { + this._queueState = true; + }, + + unbecomeQueue: function unbecomeQueue() { + this._queueState = false; + }, + + next: function next() { + if (this._queueState) + return this.dequeue(); + + let tile = null; + + if (this._crawlIndices) { + try { + let [i, j] = this._crawlIndices.next(); + tile = this._tileCache.getTile(i, j, true, this._notVisited); + } catch (e) { + if (!(e instanceof StopIteration)) + throw e; + } + } + + if (tile) { + this._visited[tile] = true; + } else { + this.becomeQueue(); + return this.next(); + } + + return tile; + }, + + dequeue: function dequeue() { + let tile = null; + do { + let idx = this._queue.shift(); + if (!idx) + return null; + + delete this._enqueued[idx]; + let [i, j] = this._unstrIndices(idx); + tile = this._tileCache.getTile(i, j, false); + + } while (!tile); + + return tile; + }, + + enqueue: function enqueue(i, j) { + let idx = this._strIndices(i, j); + if (!this._enqueued[idx]) { + this._queue.push(idx); + this._enqueued[idx] = true; + } + }, + + _strIndices: function _strIndices(i, j) { + return i + "," + j; + }, + + _unstrIndices: function _unstrIndices(str) { + return str.split(','); + } + +}; diff --git a/toolkit/content/tests/fennec-tile-testapp/chrome/content/WidgetStack.js b/toolkit/content/tests/fennec-tile-testapp/chrome/content/WidgetStack.js new file mode 100644 index 00000000000..335a450052e --- /dev/null +++ b/toolkit/content/tests/fennec-tile-testapp/chrome/content/WidgetStack.js @@ -0,0 +1,1465 @@ +/* -*- Mode: js2; tab-width: 40; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- */ +/* + * ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is Mozilla Mobile Browser. + * + * The Initial Developer of the Original Code is + * Mozilla Corporation. + * Portions created by the Initial Developer are Copyright (C) 2008 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Vladimir Vukicevic + * + * 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 ***** */ + +var gWsDoLog = false; +var gWsLogDiv = null; + +function logbase() { + if (!gWsDoLog) + return; + + if (gWsLogDiv == null && "console" in window) { + console.log.apply(console, arguments); + } else { + var s = ""; + for (var i = 0; i < arguments.length; i++) { + s += arguments[i] + " "; + } + s += "\n"; + if (gWsLogDiv) { + gWsLogDiv.appendChild(document.createElementNS("http://www.w3.org/1999/xhtml", "br")); + gWsLogDiv.appendChild(document.createTextNode(s)); + } + + dump(s); + } +} + +function dumpJSStack(stopAtNamedFunction) { + let caller = Components.stack.caller; + dump("\tStack: " + caller.name); + while ((caller = caller.caller)) { + dump(" <- " + caller.name); + if (stopAtNamedFunction && caller.name != "anonymous") + break; + } + dump("\n"); +} + +function log() { + return; + logbase.apply(window, arguments); +} + +function log2() { + return; + logbase.apply(window, arguments); +} + +let reportError = log; + +/* + * wsBorder class + * + * Simple container for top,left,bottom,right "border" values + */ +function wsBorder(t, l, b, r) { + this.setBorder(t, l, b, r); +} + +wsBorder.prototype = { + + setBorder: function (t, l, b, r) { + this.top = t; + this.left = l; + this.bottom = b; + this.right = r; + }, + + toString: function () { + return "[l:" + this.left + ",t:" + this.top + ",r:" + this.right + ",b:" + this.bottom + "]"; + } +}; + +/* + * wsRect class + * + * Rectangle class, with both x/y/w/h and t/l/b/r accessors. + */ +function wsRect(x, y, w, h) { + this.left = x; + this.top = y; + this.right = x+w; + this.bottom = y+h; +} + +wsRect.prototype = { + + get x() { return this.left; }, + get y() { return this.top; }, + get width() { return this.right - this.left; }, + get height() { return this.bottom - this.top; }, + set x(v) { + let diff = this.left - v; + this.left = v; + this.right -= diff; + }, + set y(v) { + let diff = this.top - v; + this.top = v; + this.bottom -= diff; + }, + set width(v) { this.right = this.left + v; }, + set height(v) { this.bottom = this.top + v; }, + + setRect: function(x, y, w, h) { + this.left = x; + this.top = y; + this.right = x+w; + this.bottom = y+h; + + return this; + }, + + setBounds: function(t, l, b, r) { + this.top = t; + this.left = l; + this.bottom = b; + this.right = r; + + return this; + }, + + equals: function equals(r) { + return (r != null && + this.top == r.top && + this.left == r.left && + this.bottom == r.bottom && + this.right == r.right); + }, + + clone: function clone() { + return new wsRect(this.left, this.top, this.right - this.left, this.bottom - this.top); + }, + + center: function center() { + return [this.left + (this.right - this.left) / 2, + this.top + (this.bottom - this.top) / 2]; + }, + + centerRounded: function centerRounded() { + return this.center().map(Math.round); + }, + + copyFrom: function(r) { + this.top = r.top; + this.left = r.left; + this.bottom = r.bottom; + this.right = r.right; + + return this; + }, + + copyFromTLBR: function(r) { + this.left = r.left; + this.top = r.top; + this.right = r.right; + this.bottom = r.bottom; + + return this; + }, + + translate: function(x, y) { + this.left += x; + this.right += x; + this.top += y; + this.bottom += y; + + return this; + }, + + // return a new wsRect that is the union of that one and this one + union: function(rect) { + let l = Math.min(this.left, rect.left); + let r = Math.max(this.right, rect.right); + let t = Math.min(this.top, rect.top); + let b = Math.max(this.bottom, rect.bottom); + + return new wsRect(l, t, r-l, b-t); + }, + + toString: function() { + return "[" + this.x + "," + this.y + "," + this.width + "," + this.height + "]"; + }, + + expandBy: function(b) { + this.left += b.left; + this.right += b.right; + this.top += b.top; + this.bottom += b.bottom; + return this; + }, + + contains: function(other) { + return !!(other.left >= this.left && + other.right <= this.right && + other.top >= this.top && + other.bottom <= this.bottom); + }, + + intersect: function(r2) { + let xmost1 = this.right; + let xmost2 = r2.right; + + let x = Math.max(this.left, r2.left); + + let temp = Math.min(xmost1, xmost2); + if (temp <= x) + return null; + + let width = temp - x; + + let ymost1 = this.bottom; + let ymost2 = r2.bottom; + let y = Math.max(this.top, r2.top); + + temp = Math.min(ymost1, ymost2); + if (temp <= y) + return null; + + let height = temp - y; + + return new wsRect(x, y, width, height); + }, + + intersects: function(other) { + let xok = (other.left > this.left && other.left < this.right) || + (other.right > this.left && other.right < this.right) || + (other.left <= this.left && other.right >= this.right); + let yok = (other.top > this.top && other.top < this.bottom) || + (other.bottom > this.top && other.bottom < this.bottom) || + (other.top <= this.top && other.bottom >= this.bottom); + return xok && yok; + }, + + /** + * Similar to (and most code stolen from) intersect(). A restriction + * is an intersection, but this modifies the receiving object instead + * of returning a new rect. + */ + restrictTo: function restrictTo(r2) { + let xmost1 = this.right; + let xmost2 = r2.right; + + let x = Math.max(this.left, r2.left); + + let temp = Math.min(xmost1, xmost2); + if (temp <= x) + throw "Intersection is empty but rects cannot be empty"; + + let width = temp - x; + + let ymost1 = this.bottom; + let ymost2 = r2.bottom; + let y = Math.max(this.top, r2.top); + + temp = Math.min(ymost1, ymost2); + if (temp <= y) + throw "Intersection is empty but rects cannot be empty"; + + let height = temp - y; + + return this.setRect(x, y, width, height); + }, + + /** + * Similar to (and most code stolen from) union(). An extension is a + * union (in our sense of the term, not the common set-theoretic sense), + * but this modifies the receiving object instead of returning a new rect. + * Effectively, this rectangle is expanded minimally to contain all of the + * other rect. "Expanded minimally" means that the rect may shrink if + * given a strict subset rect as the argument. + */ + expandToContain: function extendTo(rect) { + let l = Math.min(this.left, rect.left); + let r = Math.max(this.right, rect.right); + let t = Math.min(this.top, rect.top); + let b = Math.max(this.bottom, rect.bottom); + + return this.setRect(l, t, r-l, b-t); + }, + + round: function round(scale) { + if (!scale) scale = 1; + + this.left = Math.floor(this.left * scale) / scale; + this.top = Math.floor(this.top * scale) / scale; + this.right = Math.ceil(this.right * scale) / scale; + this.bottom = Math.ceil(this.bottom * scale) / scale; + + return this; + }, + + scale: function scale(xscl, yscl) { + this.left *= xscl; + this.right *= xscl; + this.top *= yscl; + this.bottom *= yscl; + + return this; + } +}; + +/* + * The "Widget Stack" + * + * Manages a 's children, allowing them to be dragged around + * the stack, subject to specified constraints. Optionally supports + * one widget designated as the viewport, which can be panned over a virtual + * area without needing to draw that area entirely. The viewport widget + * is designated by a 'viewport' attribute on the child element. + * + * Widgets are subject to various constraints, specified in xul via the + * 'constraint' attribute. Current constraints are: + * ignore-x: When panning, ignore any changes to the widget's x position + * ignore-y: When panning, ignore any changes to the widget's y position + * vp-relative: This widget's position should be claculated relative to + * the viewport widget. It will always keep the same offset from that + * widget as initially laid out, regardless of changes to the viewport + * bounds. + * frozen: This widget is in a fixed position and should never pan. + */ +function WidgetStack(el, ew, eh) { + this.init(el, ew, eh); +} + +WidgetStack.prototype = { + // the element + _el: null, + + // object indexed by widget id, with state struct for each object (see _addNewWidget) + _widgetState: null, + + // any barriers + _barriers: null, + + // If a viewport widget is present, this will point to its state object; + // otherwise null. + _viewport: null, + + // a wsRect; the inner bounds of the viewport content + _viewportBounds: null, + // a wsBorder; the overflow area to the side of the bounds where our + // viewport-relative widgets go + _viewportOverflow: null, + + // a wsRect; the viewportBounds expanded by the viewportOverflow + _pannableBounds: null, + get pannableBounds() { + if (!this._pannableBounds) { + this._pannableBounds = this._viewportBounds.clone() + .expandBy(this._viewportOverflow); + } + return this._pannableBounds.clone(); + }, + + // a wsRect; the currently visible part of pannableBounds. + _viewingRect: null, + + // the amount of current global offset applied to all widgets (whether + // static or not). Set via offsetAll(). Can be used to push things + // out of the way for overlaying some other UI. + globalOffsetX: 0, + globalOffsetY: 0, + + // if true (default), panning is constrained to the pannable bounds. + _constrainToViewport: true, + + _viewportUpdateInterval: -1, + _viewportUpdateTimeout: -1, + + _viewportUpdateHandler: null, + _panHandler: null, + + _dragState: null, + + _skipViewportUpdates: 0, + _forceViewportUpdate: false, + + // + // init: + // el: the element whose children are to be managed + // + init: function (el, ew, eh) { + this._el = el; + this._widgetState = {}; + this._barriers = []; + + let rect = this._el.getBoundingClientRect(); + let width = rect.width; + let height = rect.height; + + if (ew != undefined && eh != undefined) { + width = ew; + height = eh; + } + + this._viewportOverflow = new wsBorder(0, 0, 0, 0); + + this._viewingRect = new wsRect(0, 0, width, height); + + // listen for DOMNodeInserted/DOMNodeRemoved/DOMAttrModified + let children = this._el.childNodes; + for (let i = 0; i < children.length; i++) { + let c = this._el.childNodes[i]; + if (c.tagName == "spacer") + this._addNewBarrierFromSpacer(c); + else + this._addNewWidget(c); + } + + // this also updates the viewportOverflow and pannableBounds + this._updateWidgets(); + + if (this._viewport) { + this._viewportBounds = new wsRect(0, 0, this._viewport.rect.width, this._viewport.rect.height); + } else { + this._viewportBounds = new wsRect(0, 0, 0, 0); + } + }, + + // moveWidgetBy: move the widget with the given id by x,y. Should + // not be used on vp-relative or otherwise frozen widgets (using it + // on the x coordinate for x-ignore widgets and similarily for y is + // ok, as long as the other coordinate remains 0.) + moveWidgetBy: function (wid, x, y) { + let state = this._getState(wid); + + state.rect.x += x; + state.rect.y += y; + + this._commitState(state); + }, + + // panBy: pan the entire set of widgets by the given x and y amounts. + // This does the same thing as if the user dragged by the given amount. + // If this is called with an outstanding drag, weirdness might happen, + // but it also might work, so not disabling that. + // + // if ignoreBarriers is true, then barriers are ignored for the pan. + panBy: function panBy(dx, dy, ignoreBarriers) { + dx = Math.round(dx); + dy = Math.round(dy); + + if (dx == 0 && dy == 0) + return false; + + let needsDragWrap = !this._dragging; + + if (needsDragWrap) + this.dragStart(0, 0); + + let panned = this._panBy(dx, dy, ignoreBarriers); + + if (needsDragWrap) + this.dragStop(); + + return panned; + }, + + // panTo: pan the entire set of widgets so that the given x,y + // coordinates are in the upper left of the stack. If either is + // null or undefined, only move the other axis + panTo: function panTo(x, y) { + if (x == undefined || x == null) + x = this._viewingRect.x; + if (y == undefined || y == null) + y = this._viewingRect.y; + this.panBy(x - this._viewingRect.x, y - this._viewingRect.y, true); + }, + + // freeze: set a widget as frozen. A frozen widget won't be moved + // in the stack -- its x,y position will still be tracked in the + // state, but the left/top attributes won't be overwritten. Call unfreeze + // to move the widget back to where the ws thinks it should be. + freeze: function (wid) { + let state = this._getState(wid); + + state.frozen = true; + }, + + unfreeze: function (wid) { + let state = this._getState(wid); + if (!state.frozen) + return; + + state.frozen = false; + this._commitState(state); + }, + + // moveFrozenTo: move a frozen widget with id wid to x, y in the stack. + // can only be used on frozen widgets + moveFrozenTo: function (wid, x, y) { + let state = this._getState(wid); + if (!state.frozen) + throw "moveFrozenTo on non-frozen widget " + wid; + + state.widget.setAttribute("left", x); + state.widget.setAttribute("top", y); + }, + + // moveUnfrozenTo: move an unfrozen, pannable widget with id wid to x, y in + // the stack. should only be used on unfrozen widgets when a dynamic change + // in position needs to be made. we basically remove, adjust and re-add + // the widget + moveUnfrozenTo: function (wid, x, y) { + delete this._widgetState[wid]; + let widget = document.getElementById(wid); + if (x) widget.setAttribute("left", x); + if (y) widget.setAttribute("top", y); + this._addNewWidget(widget); + this._updateWidgets(); + }, + + // we're relying on viewportBounds and viewingRect having the same origin + get viewportVisibleRect () { + let rect = this._viewportBounds.intersect(this._viewingRect); + if (!rect) + rect = new wsRect(0, 0, 0, 0); + return rect; + }, + + isWidgetFrozen: function isWidgetFrozen(wid) { + return this._getState(wid).frozen; + }, + + // isWidgetVisible: return true if any portion of widget with id wid is + // visible; otherwise return false. + isWidgetVisible: function (wid) { + let state = this._getState(wid); + let visibleStackRect = new wsRect(0, 0, this._viewingRect.width, this._viewingRect.height); + + return visibleStackRect.intersects(state.rect); + }, + + // getWidgetVisibility: returns the percentage that the widget is visible + getWidgetVisibility: function (wid) { + let state = this._getState(wid); + let visibleStackRect = new wsRect(0, 0, this._viewingRect.width, this._viewingRect.height); + + let visibleRect = visibleStackRect.intersect(state.rect); + if (visibleRect) + return [visibleRect.width / state.rect.width, visibleRect.height / state.rect.height] + + return [0, 0]; + }, + + // offsetAll: add an offset to all widgets + offsetAll: function (x, y) { + this.globalOffsetX += x; + this.globalOffsetY += y; + + for each (let state in this._widgetState) { + state.rect.x += x; + state.rect.y += y; + + this._commitState(state); + } + }, + + // setViewportBounds + // nb: an object containing top, left, bottom, right properties + // OR + // width, height: integer values; origin assumed to be 0,0 + // OR + // top, left, bottom, right: integer values + // + // Set the bounds of the viewport area; that is, set the size of the + // actual content that the viewport widget will be providing a view + // over. For example, in the case of a 100x100 viewport showing a + // view into a 100x500 webpage, the viewport bounds would be + // { top: 0, left: 0, bottom: 500, right: 100 }. + // + // setViewportBounds will move all the viewport-relative widgets into + // place based on the new viewport bounds. + setViewportBounds: function setViewportBounds() { + let oldBounds = this._viewportBounds.clone(); + + if (arguments.length == 1) { + this._viewportBounds.copyFromTLBR(arguments[0]); + } else if (arguments.length == 2) { + this._viewportBounds.setRect(0, 0, arguments[0], arguments[1]); + } else if (arguments.length == 4) { + this._viewportBounds.setBounds(arguments[0], + arguments[1], + arguments[2], + arguments[3]); + } else { + throw "Invalid number of arguments to setViewportBounds"; + } + + let vp = this._viewport; + + let dleft = this._viewportBounds.left - oldBounds.left; + let dright = this._viewportBounds.right - oldBounds.right; + let dtop = this._viewportBounds.top - oldBounds.top; + let dbottom = this._viewportBounds.bottom - oldBounds.bottom; + + //log2("setViewportBounds dltrb", dleft, dtop, dright, dbottom); + + // move all vp-relative widgets to be the right offset from the bounds again + for each (let state in this._widgetState) { + if (state.vpRelative) { + //log2("vpRelative widget", state.id, state.rect.x, dleft, dright); + if (state.vpOffsetXBefore) { + state.rect.x += dleft; + } else { + state.rect.x += dright; + } + + if (state.vpOffsetYBefore) { + state.rect.y += dtop; + } else { + state.rect.y += dbottom; + } + + //log2("vpRelative widget", state.id, state.rect.x, dleft, dright); + this._commitState(state); + } + } + + for (let bid in this._barriers) { + let barrier = this._barriers[bid]; + + //log2("setViewportBounds: looking at barrier", bid, barrier.vpRelative, barrier.type); + + if (barrier.vpRelative) { + if (barrier.type == "vertical") { + let q = "v barrier moving from " + barrier.x + " to "; + if (barrier.vpOffsetXBefore) { + barrier.x += dleft; + } else { + barrier.x += dright; + } + //log2(q += barrier.x); + } else if (barrier.type == "horizontal") { + let q = "h barrier moving from " + barrier.y + " to "; + if (barrier.vpOffsetYBefore) { + barrier.y += dtop; + } else { + barrier.y += dbottom; + } + //log2(q += barrier.y); + } + } + } + + // clear the pannable bounds cache to make sure it gets rebuilt + this._pannableBounds = null; + + // now let's make sure that the viewing rect and inner bounds are still valid + this._adjustViewingRect(); + + this._viewportUpdate(0, 0, true); + }, + + // setViewportHandler + // uh: A function object + // + // The given function object is called at the end of every drag and viewport + // bounds change, passing in the new rect that's to be displayed in the + // viewport. + // + setViewportHandler: function (uh) { + this._viewportUpdateHandler = uh; + }, + + // setPanHandler + // uh: A function object + // + // The given functin object is called whenever elements pan; it provides + // the new area of the pannable bounds that's visible in the stack. + setPanHandler: function (uh) { + this._panHandler = uh; + }, + + // dragStart: start a drag, with the current coordinates being clientX,clientY + dragStart: function dragStart(clientX, clientY) { + log("(dragStart)", clientX, clientY); + + if (this._dragState) { + reportError("dragStart with drag already in progress? what?"); + this._dragState = null; + } + + this._dragState = { }; + + let t = Date.now(); + + this._dragState.barrierState = []; + + this._dragState.startTime = t; + // outer x, that is outer from the viewport coordinates. In stack-relative coords. + this._dragState.outerStartX = clientX; + this._dragState.outerStartY = clientY; + + this._dragCoordsFromClient(clientX, clientY, t); + + this._dragState.outerLastUpdateDX = 0; + this._dragState.outerLastUpdateDY = 0; + + if (this._viewport) { + // create a copy of these so that we can compute + // deltas correctly to update the viewport + this._viewport.dragStartRect = this._viewport.rect.clone(); + } + + this._dragState.dragging = true; + }, + + _viewportDragUpdate: function viewportDragUpdate() { + let vws = this._viewport; + this._viewportUpdate((vws.dragStartRect.x - vws.rect.x), + (vws.dragStartRect.y - vws.rect.y)); + }, + + // dragStop: stop any drag in progress + dragStop: function dragStop() { + log("(dragStop)"); + + if (!this._dragging) + return; + + if (this._viewportUpdateTimeout != -1) + clearTimeout(this._viewportUpdateTimeout); + + this._viewportDragUpdate(); + + this._dragState = null; + }, + + // dragMove: process a mouse move to clientX,clientY for an ongoing drag + dragMove: function dragMove(clientX, clientY) { + if (!this._dragging) + return false; + + this._dragCoordsFromClient(clientX, clientY); + + let panned = this._dragUpdate(); + + if (this._viewportUpdateInterval != -1) { + if (this._viewportUpdateTimeout != -1) + clearTimeout(this._viewportUpdateTimeout); + let self = this; + this._viewportUpdateTimeout = setTimeout(function () { self._viewportDragUpdate(); }, this._viewportUpdateInterval); + } + + return panned; + }, + + // dragBy: process a mouse move by dx,dy for an ongoing drag + dragBy: function dragBy(dx, dy) { + return this.dragMove(this._dragState.outerCurX + dx, this._dragState.outerCurY + dy); + }, + + // updateSize: tell the WidgetStack to update its size, because it + // was either resized or some other event took place. + updateSize: function updateSize(width, height) { + if (width == undefined || height == undefined) { + let rect = this._el.getBoundingClientRect(); + width = rect.width; + height = rect.height; + } + + // update widget rects and viewportOverflow, since the resize might have + // caused them to change (widgets first, since the viewportOverflow depends + // on them). + + // XXX these methods aren't working correctly yet, but they aren't strictly + // necessary in Fennec's default config + //for each (let s in this._widgetState) + // this._updateWidgetRect(s); + //this._updateViewportOverflow(); + + this._viewingRect.width = width; + this._viewingRect.height = height; + + // Wrap this call in a batch to ensure that we always call the + // viewportUpdateHandler, even if _adjustViewingRect doesn't trigger a pan. + // If it does, the batch also ensures that we don't call the handler twice. + this.beginUpdateBatch(); + this._adjustViewingRect(); + this.endUpdateBatch(); + }, + + beginUpdateBatch: function startUpdate() { + if (!this._skipViewportUpdates) { + this._startViewportBoundsString = this._viewportBounds.toString(); + this._forceViewportUpdate = false; + } + this._skipViewportUpdates++; + }, + + endUpdateBatch: function endUpdate(aForceRedraw) { + if (!this._skipViewportUpdates) + throw new Error("Unbalanced call to endUpdateBatch"); + + this._forceViewportUpdate = this._forceViewportUpdate || aForceRedraw; + + this._skipViewportUpdates--; + if (this._skipViewportUpdates) + return; + + let boundsSizeChanged = + this._startViewportBoundsString != this._viewportBounds.toString(); + this._callViewportUpdateHandler(boundsSizeChanged || this._forceViewportUpdate); + }, + + // + // Internal code + // + + _updateWidgetRect: function(state) { + // don't need to support updating the viewport rect at the moment + // (we'd need to duplicate the vptarget* code from _addNewWidget if we did) + if (state == this._viewport) + return; + + let w = state.widget; + let x = w.getAttribute("left") || 0; + let y = w.getAttribute("top") || 0; + let rect = w.getBoundingClientRect(); + state.rect = new wsRect(parseInt(x), parseInt(y), + rect.right - rect.left, + rect.bottom - rect.top); + if (w.hasAttribute("widgetwidth") && w.hasAttribute("widgetheight")) { + state.rect.width = parseInt(w.getAttribute("widgetwidth")); + state.rect.height = parseInt(w.getAttribute("widgetheight")); + } + }, + + _dumpRects: function () { + dump("WidgetStack:\n"); + dump("\tthis._viewportBounds: " + this._viewportBounds + "\n"); + dump("\tthis._viewingRect: " + this._viewingRect + "\n"); + dump("\tthis._viewport.viewportInnerBounds: " + this._viewport.viewportInnerBounds + "\n"); + dump("\tthis._viewport.rect: " + this._viewport.rect + "\n"); + dump("\tthis._viewportOverflow: " + this._viewportOverflow + "\n"); + dump("\tthis.pannableBounds: " + this.pannableBounds + "\n"); + }, + + // Ensures that _viewingRect is within _pannableBounds (call this when either + // one is resized) + _adjustViewingRect: function _adjustViewingRect() { + let vr = this._viewingRect; + let pb = this.pannableBounds; + + if (pb.contains(vr)) + return; // nothing to do here + + // don't bother adjusting _viewingRect if it can't fit into + // _pannableBounds + if (vr.height > pb.height || vr.width > pb.width) + return; + + let panX = 0, panY = 0; + if (vr.right > pb.right) + panX = pb.right - vr.right; + else if (vr.left < pb.left) + panX = pb.left - vr.left; + + if (vr.bottom > pb.bottom) + panY = pb.bottom - vr.bottom; + else if(vr.top < pb.top) + panY = pb.top - vr.top; + + this.panBy(panX, panY, true); + }, + + _getState: function (wid) { + let w = this._widgetState[wid]; + if (!w) + throw "Unknown widget id '" + wid + "'; widget not in stack"; + return w; + }, + + get _dragging() { + return this._dragState && this._dragState.dragging; + }, + + _viewportUpdate: function _viewportUpdate(dX, dY, boundsChanged) { + if (!this._viewport) + return; + + this._viewportUpdateTimeout = -1; + + let vws = this._viewport; + let vwib = vws.viewportInnerBounds; + let vpb = this._viewportBounds; + + // recover the amount the inner bounds moved by the amount the viewport + // widget moved, but don't include offsets that we're making up from previous + // drags that didn't affect viewportInnerBounds + let [ignoreX, ignoreY] = this._offsets || [0, 0]; + let rx = dX - ignoreX; + let ry = dY - ignoreY; + + [dX, dY] = this._rectTranslateConstrain(rx, ry, vwib, vpb); + + // record the offsets that correspond to the amount of the drag we're ignoring + // to ensure the viewportInnerBounds remains within the viewportBounds + this._offsets = [dX - rx, dY - ry]; + + // adjust the viewportInnerBounds, and snap the viewport back + vwib.translate(dX, dY); + vws.rect.translate(dX, dY); + this._commitState(vws); + + // update this so that we can call this function again during the same drag + // and get the right values. + vws.dragStartRect = vws.rect.clone(); + + this._callViewportUpdateHandler(boundsChanged); + }, + + _callViewportUpdateHandler: function _callViewportUpdateHandler(boundsChanged) { + if (!this._viewport || !this._viewportUpdateHandler || this._skipViewportUpdates) + return; + + let vwb = this._viewportBounds.clone(); + + let vwib = this._viewport.viewportInnerBounds.clone(); + + let vis = this.viewportVisibleRect; + + vwib.left += this._viewport.offsetLeft; + vwib.top += this._viewport.offsetTop; + vwib.right += this._viewport.offsetRight; + vwib.bottom += this._viewport.offsetBottom; + + this._viewportUpdateHandler.apply(window, [vwb, vwib, vis, boundsChanged]); + }, + + _dragCoordsFromClient: function (cx, cy, t) { + this._dragState.curTime = t ? t : Date.now(); + this._dragState.outerCurX = cx; + this._dragState.outerCurY = cy; + + let dx = this._dragState.outerCurX - this._dragState.outerStartX; + let dy = this._dragState.outerCurY - this._dragState.outerStartY; + this._dragState.outerDX = dx; + this._dragState.outerDY = dy; + }, + + _panHandleBarriers: function (dx, dy) { + // XXX unless the barriers are sorted by position, this will break + // with multiple barriers that are near enough to eachother that a + // drag could cross more than one. + + let vr = this._viewingRect; + + // XXX this just stops at the first horizontal and vertical barrier it finds + + // barrier_[xy] is the barrier that was used to get to the final + // barrier_d[xy] value. if null, no barrier, and dx/dy shouldn't + // be replaced with barrier_d[xy]. + let barrier_y = null, barrier_x = null; + let barrier_dy = 0, barrier_dx = 0; + + for (let i = 0; i < this._barriers.length; i++) { + let b = this._barriers[i]; + + //log2("barrier", i, b.type, b.x, b.y); + + if (dx != 0 && b.type == "vertical") { + if (barrier_x != null) { + delete this._dragState.barrierState[i]; + continue; + } + + let alreadyKnownDistance = this._dragState.barrierState[i] || 0; + + //log2("alreadyKnownDistance", alreadyKnownDistance); + + let dbx = 0; + + //100 <= 100 && 100-(-5) > 100 + + if ((vr.left <= b.x && vr.left+dx > b.x) || + (vr.left >= b.x && vr.left+dx < b.x)) + { + dbx = b.x - vr.left; + } else if ((vr.right <= b.x && vr.right+dx > b.x) || + (vr.right >= b.x && vr.right+dx < b.x)) + { + dbx = b.x - vr.right; + } else { + delete this._dragState.barrierState[i]; + continue; + } + + let leftoverDistance = dbx - dx; + + //log2("initial dbx", dbx, leftoverDistance); + + let dist = Math.abs(leftoverDistance + alreadyKnownDistance) - b.size; + + if (dist >= 0) { + if (dx < 0) + dbx -= dist; + else + dbx += dist; + delete this._dragState.barrierState[i]; + } else { + dbx = 0; + this._dragState.barrierState[i] = leftoverDistance + alreadyKnownDistance; + } + + //log2("final dbx", dbx, "state", this._dragState.barrierState[i]); + + if (Math.abs(barrier_dx) <= Math.abs(dbx)) { + barrier_x = b; + barrier_dx = dbx; + + //log2("new barrier_dx", barrier_dx); + } + } + + if (dy != 0 && b.type == "horizontal") { + if (barrier_y != null) { + delete this._dragState.barrierState[i]; + continue; + } + + let alreadyKnownDistance = this._dragState.barrierState[i] || 0; + + //log2("alreadyKnownDistance", alreadyKnownDistance); + + let dby = 0; + + //100 <= 100 && 100-(-5) > 100 + + if ((vr.top <= b.y && vr.top+dy > b.y) || + (vr.top >= b.y && vr.top+dy < b.y)) + { + dby = b.y - vr.top; + } else if ((vr.bottom <= b.y && vr.bottom+dy > b.y) || + (vr.bottom >= b.y && vr.bottom+dy < b.y)) + { + dby = b.y - vr.bottom; + } else { + delete this._dragState.barrierState[i]; + continue; + } + + let leftoverDistance = dby - dy; + + //log2("initial dby", dby, leftoverDistance); + + let dist = Math.abs(leftoverDistance + alreadyKnownDistance) - b.size; + + if (dist >= 0) { + if (dy < 0) + dby -= dist; + else + dby += dist; + delete this._dragState.barrierState[i]; + } else { + dby = 0; + this._dragState.barrierState[i] = leftoverDistance + alreadyKnownDistance; + } + + //log2("final dby", dby, "state", this._dragState.barrierState[i]); + + if (Math.abs(barrier_dy) <= Math.abs(dby)) { + barrier_y = b; + barrier_dy = dby; + + //log2("new barrier_dy", barrier_dy); + } + } + } + + if (barrier_x) { + //log2("did barrier_x", barrier_x, "barrier_dx", barrier_dx); + dx = barrier_dx; + } + + if (barrier_y) { + dy = barrier_dy; + } + + return [dx, dy]; + }, + + _panBy: function _panBy(dx, dy, ignoreBarriers) { + let vr = this._viewingRect; + + // check if any barriers would be crossed by this pan, and take them + // into account. do this first. + if (!ignoreBarriers) + [dx, dy] = this._panHandleBarriers(dx, dy); + + // constrain the full drag of the viewingRect to the pannableBounds. + // note that the viewingRect needs to move in the opposite + // direction of the pan, so we fiddle with the signs here (as you + // pan to the upper left, more of the bottom right becomes visible, + // so the viewing rect moves to the bottom right of the virtual surface). + [dx, dy] = this._rectTranslateConstrain(dx, dy, vr, this.pannableBounds); + + // If the net result is that we don't have any room to move, then + // just return. + if (dx == 0 && dy == 0) + return false; + + // the viewingRect moves opposite of the actual pan direction, see above + vr.x += dx; + vr.y += dy; + + // Go through each widget and move it by dx,dy. Frozen widgets + // will be ignored in commitState. + // The widget rects are in real stack space though, so we need to subtract + // our (now negated) dx, dy from their coordinates. + for each (let state in this._widgetState) { + if (!state.ignoreX) + state.rect.x -= dx; + if (!state.ignoreY) + state.rect.y -= dy; + + this._commitState(state); + } + + /* Do not call panhandler during pans within a transaction. + * Those pans always end-up covering up the checkerboard and + * do not require sliding out the location bar + */ + if (!this._skipViewportUpdates && this._panHandler) + this._panHandler.apply(window, [vr.clone(), dx, dy]); + + return true; + }, + + _dragUpdate: function _dragUpdate() { + let dx = this._dragState.outerLastUpdateDX - this._dragState.outerDX; + let dy = this._dragState.outerLastUpdateDY - this._dragState.outerDY; + + this._dragState.outerLastUpdateDX = this._dragState.outerDX; + this._dragState.outerLastUpdateDY = this._dragState.outerDY; + + return this.panBy(dx, dy); + }, + + // + // widget addition/removal + // + _addNewWidget: function (w) { + let wid = w.getAttribute("id"); + if (!wid) { + reportError("WidgetStack: child widget without id!"); + return; + } + + if (w.getAttribute("hidden") == "true") + return; + + let state = { + widget: w, + id: wid, + + viewport: false, + ignoreX: false, + ignoreY: false, + sticky: false, + frozen: false, + vpRelative: false, + + offsetLeft: 0, + offsetTop: 0, + offsetRight: 0, + offsetBottom: 0 + }; + + this._updateWidgetRect(state); + + if (w.hasAttribute("constraint")) { + let cs = w.getAttribute("constraint").split(","); + for each (let s in cs) { + if (s == "ignore-x") + state.ignoreX = true; + else if (s == "ignore-y") + state.ignoreY = true; + else if (s == "sticky") + state.sticky = true; + else if (s == "frozen") { + state.frozen = true; + } else if (s == "vp-relative") + state.vpRelative = true; + } + } + + if (w.hasAttribute("viewport")) { + if (this._viewport) + reportError("WidgetStack: more than one viewport canvas in stack!"); + + this._viewport = state; + state.viewport = true; + + if (w.hasAttribute("vptargetx") && w.hasAttribute("vptargety") && + w.hasAttribute("vptargetw") && w.hasAttribute("vptargeth")) + { + let wx = parseInt(w.getAttribute("vptargetx")); + let wy = parseInt(w.getAttribute("vptargety")); + let ww = parseInt(w.getAttribute("vptargetw")); + let wh = parseInt(w.getAttribute("vptargeth")); + + state.offsetLeft = state.rect.left - wx; + state.offsetTop = state.rect.top - wy; + state.offsetRight = state.rect.right - (wx + ww); + state.offsetBottom = state.rect.bottom - (wy + wh); + + state.rect = new wsRect(wx, wy, ww, wh); + } + + // initialize inner bounds to top-left + state.viewportInnerBounds = new wsRect(0, 0, state.rect.width, state.rect.height); + } + + this._widgetState[wid] = state; + + log ("(New widget: " + wid + (state.viewport ? " [viewport]" : "") + " at: " + state.rect + ")"); + }, + + _removeWidget: function (w) { + let wid = w.getAttribute("id"); + delete this._widgetState[wid]; + this._updateWidgets(); + }, + + // updateWidgets: + // Go through all the widgets and figure out their viewport-relative offsets. + // If the widget goes to the left or above the viewport widget, then + // vpOffsetXBefore or vpOffsetYBefore is set. + // See setViewportBounds for use of vpOffset* state variables, and for how + // the actual x and y coords of each widget are calculated based on their offsets + // and the viewport bounds. + _updateWidgets: function () { + let vp = this._viewport; + + let ofRect = this._viewingRect.clone(); + + for each (let state in this._widgetState) { + if (vp && state.vpRelative) { + // compute the vpOffset from 0,0 assuming that the viewport rect is 0,0 + if (state.rect.left >= vp.rect.right) { + state.vpOffsetXBefore = false; + state.vpOffsetX = state.rect.left - vp.rect.width; + } else { + state.vpOffsetXBefore = true; + state.vpOffsetX = state.rect.left - vp.rect.left; + } + + if (state.rect.top >= vp.rect.bottom) { + state.vpOffsetYBefore = false; + state.vpOffsetY = state.rect.top - vp.rect.height; + } else { + state.vpOffsetYBefore = true; + state.vpOffsetY = state.rect.top - vp.rect.top; + } + + log("widget", state.id, "offset", state.vpOffsetX, state.vpOffsetXBefore ? "b" : "a", state.vpOffsetY, state.vpOffsetYBefore ? "b" : "a", "rect", state.rect); + } + } + + this._updateViewportOverflow(); + }, + + // updates the viewportOverflow/pannableBounds + _updateViewportOverflow: function() { + let vp = this._viewport; + if (!vp) + return; + + let ofRect = new wsRect(0, 0, this._viewingRect.width, this._viewingRect.height); + + for each (let state in this._widgetState) { + if (vp && state.vpRelative) { + ofRect.left = Math.min(ofRect.left, state.rect.left); + ofRect.top = Math.min(ofRect.top, state.rect.top); + ofRect.right = Math.max(ofRect.right, state.rect.right); + ofRect.bottom = Math.max(ofRect.bottom, state.rect.bottom); + } + } + + // prevent the viewportOverflow from having positive top/left or negative + // bottom/right values, which would otherwise happen if there aren't widgets + // beyond each of those edges + this._viewportOverflow = new wsBorder( + /*top*/ Math.round(Math.min(ofRect.top, 0)), + /*left*/ Math.round(Math.min(ofRect.left, 0)), + /*bottom*/ Math.round(Math.max(ofRect.bottom - vp.rect.height, 0)), + /*right*/ Math.round(Math.max(ofRect.right - vp.rect.width, 0)) + ); + + // clear the _pannableBounds cache, since it depends on the + // viewportOverflow + this._pannableBounds = null; + }, + + _widgetBounds: function () { + let r = new wsRect(0,0,0,0); + + for each (let state in this._widgetState) + r = r.union(state.rect); + + return r; + }, + + _commitState: function (state) { + // if the widget is frozen, don't actually update its left/top; + // presumably the caller is managing those directly for now. + if (state.frozen) + return; + let w = state.widget; + let l = state.rect.x + state.offsetLeft; + let t = state.rect.y + state.offsetTop; + + //cache left/top to avoid calling setAttribute unnessesarily + if (state._left != l) { + state._left = l; + w.setAttribute("left", l); + } + + if (state._top != t) { + state._top = t; + w.setAttribute("top", t); + } + }, + + // constrain translate of rect by dx dy to bounds; return dx dy that can + // be used to bring rect up to the edge of bounds if we'd go over. + _rectTranslateConstrain: function (dx, dy, rect, bounds) { + let newX, newY; + + // If the rect is larger than the bounds, allow it to increase its overlap + let woverflow = rect.width > bounds.width; + let hoverflow = rect.height > bounds.height; + if (woverflow || hoverflow) { + let intersection = rect.intersect(bounds); + let newIntersection = rect.clone().translate(dx, dy).intersect(bounds); + if (woverflow) + newX = (newIntersection.width > intersection.width) ? rect.x + dx : rect.x; + if (hoverflow) + newY = (newIntersection.height > intersection.height) ? rect.y + dy : rect.y; + } + + // Common case, rect fits within the bounds + // clamp new X to within [bounds.left, bounds.right - rect.width], + // new Y to within [bounds.top, bounds.bottom - rect.height] + if (isNaN(newX)) + newX = Math.min(Math.max(bounds.left, rect.x + dx), bounds.right - rect.width); + if (isNaN(newY)) + newY = Math.min(Math.max(bounds.top, rect.y + dy), bounds.bottom - rect.height); + + return [newX - rect.x, newY - rect.y]; + }, + + // add a new barrier from a + _addNewBarrierFromSpacer: function (el) { + let t = el.getAttribute("barriertype"); + + // XXX implement these at some point + // t != "lr" && t != "rl" && + // t != "tb" && t != "bt" && + + if (t != "horizontal" && + t != "vertical") + { + throw "Invalid barrier type: " + t; + } + + let x, y; + + let barrier = {}; + let vp = this._viewport; + + barrier.type = t; + + if (el.getAttribute("left")) + barrier.x = parseInt(el.getAttribute("left")); + else if (el.getAttribute("top")) + barrier.y = parseInt(el.getAttribute("top")); + else + throw "Barrier without top or left attribute"; + + if (el.getAttribute("size")) + barrier.size = parseInt(el.getAttribute("size")); + else + barrier.size = 10; + + if (el.hasAttribute("constraint")) { + let cs = el.getAttribute("constraint").split(","); + for each (let s in cs) { + if (s == "ignore-x") + barrier.ignoreX = true; + else if (s == "ignore-y") + barrier.ignoreY = true; + else if (s == "sticky") + barrier.sticky = true; + else if (s == "frozen") { + barrier.frozen = true; + } else if (s == "vp-relative") + barrier.vpRelative = true; + } + } + + if (barrier.vpRelative) { + if (barrier.type == "vertical") { + if (barrier.x >= vp.rect.right) { + barrier.vpOffsetXBefore = false; + barrier.vpOffsetX = barrier.x - vp.rect.right; + } else { + barrier.vpOffsetXBefore = true; + barrier.vpOffsetX = barrier.x - vp.rect.left; + } + } else if (barrier.type == "horizontal") { + if (barrier.y >= vp.rect.bottom) { + barrier.vpOffsetYBefore = false; + barrier.vpOffsetY = barrier.y - vp.rect.bottom; + } else { + barrier.vpOffsetYBefore = true; + barrier.vpOffsetY = barrier.y - vp.rect.top; + } + + //log2("h barrier relative", barrier.vpOffsetYBefore, barrier.vpOffsetY); + } + } + + this._barriers.push(barrier); + } +}; diff --git a/toolkit/content/tests/fennec-tile-testapp/chrome/content/firefoxOverlay.xul b/toolkit/content/tests/fennec-tile-testapp/chrome/content/firefoxOverlay.xul new file mode 100644 index 00000000000..612f8bb9ff3 --- /dev/null +++ b/toolkit/content/tests/fennec-tile-testapp/chrome/content/firefoxOverlay.xul @@ -0,0 +1,15 @@ + + + + + + + + + +