// import Mantissa
// import MochiKit
// import MochiKit.Base
// import MochiKit.Iter
// import MochiKit.DOM



Mantissa.ScrollTable.NoSuchWebID = Divmod.Error.subclass("Mantissa.ScrollTable.NoSuchWebID");
Mantissa.ScrollTable.NoSuchWebID.methods(
    function __init__(self, webID) {
        self.webID = webID;
    },

    function toString(self) {
        return "WebID " + self.webID + " not found";
    });


/**
 * Error class indicating that an operation which requires an active row was
 * attempted when there was no active row.
 */
Mantissa.ScrollTable.NoActiveRow = Divmod.Error.subclass("Mantissa.ScrollTable.NoActiveRow");


Mantissa.ScrollTable.Action = Divmod.Class.subclass('Mantissa.ScrollTable.Action');
/**
 * An action that can be performed on a scrolltable row.
 * (Currently on a single scrolltable row at a time).
 *
 * @ivar name: internal name for this action.  this will be used server-side
 *             to look up the action method.
 *
 * @ivar displayName: external name for this action.
 *
 * @ivar handler: optional.  function that will be called when the remote
 *                method successfully returns.  it will be passed the
 *                L{ScrollingWidget} the row was clicked in, the row that was
 *                clicked (a mapping of column names to values) and the result
 *                of the remote call that was made.  if not set, no action
 *                will be taken.  Alternatively, you can subclass and override
 *                L{handleSuccess}.
 *
 * @ivar icon: optional.  if set, then it will be used for the src attribute of
 *             an <IMG> element that will get placed inside the action link,
 *             instead of C{name}.
 */
Mantissa.ScrollTable.Action.methods(
    function __init__(self, name, displayName, handler/*=undefined*/, icon/*=undefined*/) {
        self.name = name;
        self.displayName = displayName;
        self._successHandler = handler;
        self.icon = icon;
    },

    /**
     * Called by onclick handler created in L{toNode}.
     * Responsible for calling remote method, and dispatching the result to
     * C{self.handler}, if one is set.
     *
     * Arguments are the same as L{toNode}
     *
     * @rtype: L{Divmod.Defer.Deferred}
     */
    function enact(self, scrollingWidget, row) {
        var D = scrollingWidget.callRemote(
                    "performAction", self.name, row.__id__);
        return D.addCallbacks(
                function(result) {
                    return self.handleSuccess(scrollingWidget, row, result);
                },
                function(err) {
                    return self.handleFailure(scrollingWidget, row, err);
                });
    },

    /**
     * Called when the remote method successfully returns, with its result.
     * Calls the function supplied as C{handler} to L{__init__}, if defined.
     *
     * First two arguments are the same as L{toNode}
     */
    function handleSuccess(self, scrollingWidget, row, result) {
        if(self._successHandler) {
            return self._successHandler(scrollingWidget, row, result);
        }
    },

    /**
     * Called when the remote method, or one of its callbacks throws an error.
     * Displays an error dialog to the user.
     *
     * First two arguments are the same as L{toNode}
     */
    function handleFailure(self, scrollingWidget, row, err) {
        scrollingWidget.showErrorDialog("performAction", err);
    },

    /**
     * Called by L{Mantissa.ScrollTable.ScrollingWidget}.
     * Responsible for turning this action into a link node.
     *
     * @param scrollingWidget: L{Mantissa.ScrollTable.ScrollingWidget}
     * @param row: L{Object} mapping column names to column values of the row
     *             that this action will act on when clicked
     */
    function toNode(self, scrollingWidget, row) {
        var onclick = function() {
            self.enact(scrollingWidget, row);
            return false;
        }
        var linkBody;
        if(self.icon) {
            linkBody = MochiKit.DOM.IMG({border: 0, src: self.icon});
        } else {
            linkBody = self.displayName;
        }
        return MochiKit.DOM.A({onclick: onclick, href: "#"}, linkBody);
    },

    /**
     * Called by L{Mantissa.ScrollTable.ScrollingWidget}.
     *
     * @type row: C{object}
     * @return: boolean indicating whether this action should be enabled for
     * C{row}
     */
    function enableForRow(self, row) {
        return true;
    });


/**
 * Structured representation of the rows in a scrolltable.
 *
 * @ivar _rows: A sparse array of row data for this table.
 *
 * @ivar _activeRow: null or a reference to the row data which is currently
 * considered "active".  At most one row can be active at a time.  The active
 * row can be manipulated with L{activateRow} and L{deactivateRow}.  Changes to
 * the active row are broadcast to all listeners registered with
 * L{addObserver}.
 *
 * @ivar _totalRowCount: An integer giving the total number of rows in the
 * model, ignoring whether row data is available for all of them or not.
 *
 * @ivar _selectionObservers: An array of objects which have been added as
 * selection observers using the addObserver method.  These will be notified of
 * changes to the active row and the row selection group.
 */
Mantissa.ScrollTable.ScrollModel = Divmod.Class.subclass('Mantissa.ScrollTable.ScrollModel');
Mantissa.ScrollTable.ScrollModel.methods(
    function __init__(self) {
        self._rows = [];
        self._activeRow = null;
        self._totalRowCount = 0;
        self._selectionObservers = [];
    },

    /**
     * @rtype: integer
     * @return: The number of rows in the model which we have already fetched.
     */
    function rowCount(self) {
        return self._rows.length;
    },

    /**
     * @rtype: integer
     * @return: The total number of rows in the model, i.e. the maximum number
     * of rows we could fetch
     */
    function totalRowCount(self) {
        return self._totalRowCount;
    },

    /**
     * Change the total number of rows in the model.
     * @type count: integer
     */
    function setTotalRowCount(self, count) {
        self._totalRowCount = count;
    },

    /**
     * Retrieve the index for the row data associated with the given webID.
     *
     * @type webID: string
     *
     * @rtype: integer
     *
     * @throw NoSuchWebID: Thrown if the given webID corresponds to no row in
     * the model.
     */
    function findIndex(self, webID) {
        for (var i = 0; i < self._rows.length; i++) {
            if (self._rows[i] != undefined && self._rows[i].__id__ == webID) {
                return i;
            }
        }
        throw Mantissa.ScrollTable.NoSuchWebID(webID);
    },

    /**
     * Set the data associated with a particular row.
     *
     * @type index: integer
     * @param index: The index of the row for which to set the data.
     *
     * @type data: The data to associate with the row.
     *
     * @throw Divmod.IndexError: Thrown if the row's index is less than zero.
     * @throw Error: Thrown if the row data's __id__ property is not a string.
     */
    function setRowData(self, index, data) {
        if (index < 0) {
            throw Divmod.IndexError("Specified index (" + index + ") out of bounds in setRowData.");
        }
        /*
         * XXX I hate `typeof'.  It is an abomination.  Why the hell is
         *
         *  typeof '' == 'string'
         *
         * but not
         *
         *  '' instanceof String?"
         *
         */
        if (typeof data.__id__ != 'string') {
            throw new Error("Specified row data has invalid __id__ property.");
        }

        /*
         * XXX No one should be setting row data for rows which already have
         * data, but we don't explicitly forbid it, so it may happen.  We do
         * _not_ preserve selection here, nor do we broadcast a deselection
         * event to observers (or a selection event if the new row data is
         * marked with __selected__).  Row activation is also totally bogus,
         * since if the clobbered row was active, it will still be referenced
         * by _activeRow.  If replacing existing rows is actually important,
         * this needs to be fixed.
         */
        self._rows[index] = data;
    },

    /**
     * Retrieve the row data for the row at the given index.
     *
     * @type index: integer
     *
     * @rtype: object
     * @return: The structured data associated with the row at the given index.
     *
     * @throw Divmod.IndexError: Thrown if the given index is out of bounds.
     */
    function getRowData(self, index) {
        if (index < 0 || index >= self._rows.length) {
            throw Divmod.IndexError("Specified index (" + index + ") out of bounds in getRowData.");
        }
        if (self._rows[index] === undefined) {
            return undefined;
        }
        return self._rows[index];
    },

    /**
     * Retrieve an array of indices for which local data is available.
     */
    function getRowIndices(self) {
        var indices = Divmod.dir(self._rows);
        for (var i = 0; i < indices.length; ++i) {
            indices[i] = parseInt(indices[i]);
        }
        return indices.sort(
            function(a, b) {
                if (a < b) {
                    return -1;
                }
                if (a > b) {
                    return 1;
                }
                return 0;
            });
    },

    /**
     * Find the row data for the row with web id C{webID}.
     *
     * @type webID: string
     *
     * @rtype: object
     * @return: The structured data associated with the given webID.
     *
     * @throw Error: Thrown if the given webID is not found.
     */
    function findRowData(self, webID) {
        return self.getRowData(self.findIndex(webID));
    },

    /**
     * Find the first row which appears after C{row} in the scrolltable and
     * satisfies C{predicate}
     *
     * @type webID: string
     * @param webID: The web ID of the node at which to begin.
     *
     * @type predicate: function(rowIndex, rowData, rowNode) -> boolean
     * @param predicate: A optional callable which, if supplied, will be called
     * with each row to determine if it suitable to be returned.
     *
     * @rtype: string
     * @return: The web ID for the first set of arguments that satisfies
     * C{predicate}.  C{null} is returned if no rows are found after the given
     * web ID.
     */
    function findNextRow(self, webID, predicate) {
        var row;
        for (var i = self.findIndex(webID) + 1; i < self.rowCount(); ++i) {
            row = self.getRowData(i);
            if (row != undefined) {
                if (!predicate || predicate.call(null, i, row, row.__node__)) {
                    return row.__id__;
                }
            }
        }
        return null;
    },

    /**
     * Same as L{findNextRow}, except returns the first row which appears before C{row}
     */
    function findPrevRow(self, webID, predicate) {
        var row;
        for (var i = self.findIndex(webID) - 1; i > -1; --i) {
            row = self.getRowData(i);
            if (row != undefined) {
                if (!predicate || predicate.call(null, i, row, row.__node__)) {
                    return row.__id__;
                }
            }
        }
        return null;
    },

    /**
     * Set up an object to receive notification of selection changes.
     */
    function addObserver(self, observer) {
        self._selectionObservers.push(observer);
    },

    /**
     * Check whether or not a particular row is selected.
     */
    function isSelected(self, identifier) {
        var row = self.findRowData(identifier);
        return (row.__selected__ === true);
    },

    /**
     * Call a function with each selected row or with the active row if there
     * is one and there are no selected rows.
     *
     * @param visitor: A one-argument function which will be invoked once for
     * each selected row, or once with the active row if there is one and there
     * are no selected rows.
     *
     * @return: undefined
     */
    function visitSelectedRows(self, visitor) {
        var row;
        var anySelected = false;
        var indices = self.getRowIndices();
        for (var i = 0; i < indices.length; ++i) {
            row = self._rows[indices[i]];
            if (row.__selected__) {
                visitor(row);
                anySelected = true;
            }
        }
        if (!anySelected) {
            row = self.activeRow();
            if (row !== null) {
                visitor(row);
            }
        }
    },

    /**
     * Add the specified row to the selection.  Any number of rows may be
     * selected at once.
     *
     * @type identifier: String
     * @param identifier: The unique identifier for the row to select.
     *
     * @throw Error: Thrown if the given identifier is not found.
     */
    function selectRow(self, identifier) {
        var row = self.findRowData(identifier);
        row.__selected__ = true;
        for (var i = 0; i < self._selectionObservers.length; ++i) {
            self._selectionObservers[i].rowSelected(row);
        }
    },

    /**
     * Remove the specified row from the selection.
     *
     * @type identifier: String
     * @param identifier: The unique identifier for the row to unselect.
     *
     * @throw Error: Thrown if the given identifier is not found.
     */
    function unselectRow(self, identifier) {
        var row = self.findRowData(identifier);
        row.__selected__ = false;
        for (var i = 0; i < self._selectionObservers.length; ++i) {
            self._selectionObservers[i].rowUnselected(row);
        }
    },

    /**
     * Mark the specified row as active.  Activation differs from selection in
     * that only a single row can be active at a time.
     *
     * @type identifier: String
     * @param identifier: The unique identifier for the row to activate.
     *
     * @throw NoSuchWebID: Thrown if the given identifier is not found.
     */
    function activateRow(self, identifier) {
        if (self._activeRow != null) {
            self.deactivateRow();
        }
        self._activeRow = self.findRowData(identifier);
        for (var i = 0; i < self._selectionObservers.length; ++i) {
            self._selectionObservers[i].rowActivated(self._activeRow);
        }
    },

    /**
     * Make the currently active row non-active.
     *
     * @throw NoActiveRow: Thrown if there is no active row.
     */
    function deactivateRow(self) {
        if (self._activeRow == null) {
            throw Mantissa.ScrollTable.NoActiveRow();
        }
        for (var i = 0; i < self._selectionObservers.length; ++i) {
            self._selectionObservers[i].rowDeactivated(self._activeRow);
        }
        self._activeRow = null;
    },

    /**
     * @return: The currently active row object, or C{null} if no row is
     * active.
     */
    function activeRow(self) {
        return self._activeRow;
    },

    /**
     * Remove a particular row from the scrolltable.
     *
     * @type webID: integer
     * @param webID: The index of the row to remove.
     *
     * @return: The row data which was removed.
     */
    function removeRow(self, index) {
        var row = self._rows.splice(index, 1)[0];
        if (row == self._activeRow) {
            self.deactivateRow();
        }
        return row;
    },

    /**
     * Remove all rows from the scrolltable.
     */
    function empty(self) {
        self._rows = [];
        if (self._activeRow != null) {
            self.deactivateRow();
        }
    });

Mantissa.ScrollTable.PlaceholderModel = Divmod.Class.subclass('Mantissa.ScrollTable.PlaceholderModel');
Mantissa.ScrollTable.PlaceholderModel.methods(
    function __init__(self) {
        self._placeholderRanges = [];
    },

    /**
     * Find the index of the placeholder which spans the area that the row at
     * C{rowIndex} will appear at
     *
     * @param rowIndex: integer
     *
     * @rtype: integer
     */
    function findPlaceholderIndexForRowIndex(self, rowIndex) {
        var pranges = self._placeholderRanges,
            len = pranges.length,
            lo = 0, hi = len, mid, midnew;

        while(true) {
            midnew = Math.floor((lo + hi) / 2);
            if(len-1 < midnew || midnew < 0 || mid == midnew) {
                return null;
            }
            mid = midnew;
            if(pranges[mid].stop <= rowIndex) {
                lo = mid + 1;
            } else if(rowIndex < pranges[mid].start) {
                hi = mid - 1;
            } else {
                return mid;
            }
       }
    },

    /**
     * Find the index of the first placeholder that starts after the row at
     * index C{rowIndex}.
     *
     * @param rowIndex: index of the reference row
     * @type rowIndex: integer
     *
     * @rtype: placeholder or null
     */
    function findFirstPlaceholderIndexAfterRowIndex(self, rowIndex) {
        var pranges = self._placeholderRanges,
            len = pranges.length,
            lo = 0, hi = len, mid;

        while(true) {
            mid = Math.floor((lo + hi) / 2);
            if(len-1 < mid || mid < 0) {
                return null;
            }
            /* this is difficult to think about.  what we're trying to say is
             * that we're done when we find the placeholder such that:
             *       * the placeholder starts after the index of the row we want
             *       * the placeholder before it stops before the index of the row we want
             *    OR
             *       * the placeholder starts after the index of the row we want
             *       * there is no placeholder before it
             *
             *  example:
             *
             *  | start: 0, stop: 1 |
             *  | start: 1, stop: 2 |
             *  | start: 3, stop: 4 |
             *  | start: 5, stop: 6 |
             *
             *  for a row index of 2, we want the 3 - 4 placeholder because:
             *       * 1 - 2 stops before the index of the row we want
             *       * 3 - 4 starts after the index of the row we want
             */
            if(pranges[mid].start <= rowIndex) {
                lo = mid + 1;
            } else if(0 < mid && rowIndex < pranges[mid-1].stop-1) {
                hi = mid - 1;
            } else {
                return mid;
            }
        }
    },

    /**
     * Called after a row has been removed.  Adjusts the placeholder state to
     * take this into account.
     *
     * @param rowIndex: index of the row that was removed
     * @type rowIndex: integer
     */
    function removedRow(self, rowIndex) {
        var i = self.findFirstPlaceholderIndexAfterRowIndex(rowIndex),
            pranges = self._placeholderRanges;
        if(i == null) {
            return;
        }
        for(; i < pranges.length; i++) {
            pranges[i].start--;
            pranges[i].stop--;
        }
    },

    /**
     * Find the placeholder object stored at index C{index}
     *
     * @type index: integer
     * @rtype: placeholder
     */
    function getPlaceholderWithIndex(self, index) {
        return self._placeholderRanges[index];
    },

    /**
     * Get the number of placeholders
     *
     * @rtype: integer
     */
    function getPlaceholderCount(self) {
        return self._placeholderRanges.length;
    },

    /**
     * Create and register a placeholder which extends from the zeroth row to
     * the end of the last row, overwriting any other placeholders.
     *
     * @param totalRowCount: total number of rows
     * @param node: same as L{createPlaceholder}'s C{node} argument
     */
    function registerInitialPlaceholder(self, totalRowCount, node) {
        self._placeholderRanges = [self.createPlaceholder(0, totalRowCount, node)];
    },

    /**
     * Replace placeholder with index C{index} with C{replacement}
     *
     * @type index: integer
     * @type replacement: placeholder
     */
    function replacePlaceholder(self, index, replacement) {
        self._placeholderRanges[index] = replacement;
    },

    /**
     * Divide the placeholder with index C{index} into two placeholders,
     * C{above} and C{below}.
     *
     * @type index: integer
     * @type above: placeholder
     * @type below: placeholder
     */
    function dividePlaceholder(self, index, above, below) {
        self._placeholderRanges.splice.apply(
            self._placeholderRanges, [index, 1].concat([above, below]));
    },

    /**
     * Remove the placeholder at C{index}
     *
     * @type index: integer
     */
    function removePlaceholder(self, index) {
        self._placeholderRanges.splice(index, 1);
    },

    /**
     * Remove all placeholders
     */
    function empty(self) {
        self._placeholderRanges = [];
    },

    /**
     * Create a placeholder which starts at C{start}, stops at C{stop}, and
     * is represented by the DOM node C{node}
     *
     * @param start: the index of the row that the placeholder starts at
     * @param stop: the index of the row that the placeholder stops at
     *
     * @return: object with "start" and "stop" members, corresponding to the
     * arguments of this method
     *
     * For a scrolltable like this:
     * | 0: REAL ROW    |
     * | 1: REAL ROW    |
     * | 2: PLACEHOLDER |
     * | 3: REAL ROW    |
     * the placeholder at index #2 would have start=2 and stop=3
     */
    function createPlaceholder(self, start, stop, node) {
        return {start: start, stop: stop, node: node};
    });


/**
 * @ivar actions: An array of L{Mantissa.ScrollTable.Action} instances which
 * define ways which a user can interact with rows in this scrolltable.  May
 * be undefined to indicate there are no possible actions.
 *
 * @ivar columnAliases: An object mapping column names to text which will be
 * used in the construction of column headers.  If a column name is present
 * as a property on this object, the value will be placed in the column
 * heading, instead of the column name.  Any column name may be absent, in
 * which case the case-normalized column name will be used to construct the
 * header.  If this property is undefined, it will be treated the same as if
 * it were an object with no properties.
 *
 * @ivar lastScrollPos: Height in pixels of the top of the viewport in the
 * scrolling region after the most recent scroll event.  C{0} initially and
 * after being emptied.
 */
Mantissa.ScrollTable.ScrollingWidget = Nevow.Athena.Widget.subclass('Mantissa.ScrollTable.ScrollingWidget');

Mantissa.ScrollTable.ScrollingWidget.methods(
    function __init__(self, node, metadata) {
        Mantissa.ScrollTable.ScrollingWidget.upcall(self, '__init__', node);

        self._rowTimeout = null;
        self._requestWaiting = false;
        self._moreAfterRequest = false;

        self.scrollingDown = true;
        self.lastScrollPos = 0;

        self._scrollViewport = self.nodeByAttribute('class', 'scroll-viewport');
        self._headerRow = self.nodeByAttribute('class', 'scroll-header-row');

        /*
         * A list of Deferreds which have been returned from the L{scrolled}
         * method and have yet to be fired.
         */
        self._scrollDeferreds = [];

        self.placeholderModel = Mantissa.ScrollTable.PlaceholderModel();
        self.model = Mantissa.ScrollTable.ScrollModel();

        self.setTableMetadata.apply(self, metadata);
        self.initializationDeferred = self._getSomeRows(true);
    },

    /**
     * Retrieve the structural definition of this table.
     *
     * @return: A Deferred which fires with an array with five elements.  They
     * are::
     *
     *    An array of strings naming the columns in this table.
     *
     *    An array of two-arrays giving the type and sortability of the columns
     *    in this table.
     *
     *    An integer giving the number of rows in this table.
     *
     *    A string giving the name of the column by which the table is
     *    currently ordered.
     *
     *    A boolean indicating whether the ordering is currently ascending
     *    (true) or descending (false).
     */
    function getTableMetadata(self) {
        return self.callRemote("getTableMetadata");
    },

    /**
     * Set up the tabular structure of this ScrollTable.
     *
     * @type columnNames: C{Array} of C{String}
     * @param columnNames: Names of the columns visible in this ScrollTable.
     *
     * @type columnTypes: C{Array} of C{String}
     * @param columnTypes: Names of the types of the columns visible in this
     * ScrollTable.
     *
     * @type rowCount: C{Integer}
     * @param rowCount: The total number of rows in the model.
     *
     * @type currentSort: C{String}
     * @param currentSort: The name of the column by which the model is
     * ordered.
     *
     * @type isAscendingNow: C{Boolean}
     * @param isAscendingNow: Whether the sort is ascending.
     *
     * @return: C{undefined}
     */
    function setTableMetadata(self, columnNames, columnTypes, rowCount, currentSort, isAscendingNow) {
        self.columnNames = columnNames;
        self.columnTypes = columnTypes;

        if(self.actions && 0 < self.actions.length) {
            self.columnNames.push("actions");
        }

        self.resetColumns();
        self._setSortHeader(currentSort, isAscendingNow);
        self.model.setTotalRowCount(rowCount);
        self.padViewportWithPlaceholderRow(rowCount);
    },

    /**
     * Update internal state associated with displaying column data.
     *
     * Call this whenever the return value of skipColumn might have changed.
     */
    function resetColumns(self) {
        /* set _columnOffsets before calling _getRowHeight() so that
         * _getRowGuineaPig() can call _createRow() */
        self._columnOffsets = self._getColumnOffsets(self.columnNames);
        self._rowHeight = self._getRowHeight();

        while (self._headerRow.firstChild) {
            self._headerRow.removeChild(self._headerRow.firstChild);
        }

        self._headerNodes = self._createRowHeaders(self.columnNames);
        for (var i = 0; i < self._headerNodes.length; ++i) {
            self._headerRow.appendChild(self._headerNodes[i]);
        }
    },

    /**
     * Retrieve a range of row data from the server.
     *
     * @type firstRow: integer
     * @param firstRow: zero-based index of the first message to retrieve.
     *
     * @type lastRow: integer
     * @param lastRow: zero-based index of the message after the last message
     * to retrieve.
     */
    function getRows(self, firstRow, lastRow) {
        return self.callRemote("requestRowRange", firstRow, lastRow);
    },

    /**
     * Retrieve a range of row data from the server and store it locally.
     *
     * @type firstRow: integer
     * @param firstRow: zero-based index of the first message to retrieve.
     *
     * @type lastRow: integer
     * @param lastRow: zero-based index of the message after the last message
     * to retrieve.
     */
    function requestRowRange(self, firstRow, lastRow) {
        return self.getRows(firstRow, lastRow).addCallback(
            function(rows) {
                self._storeRows(firstRow, lastRow, rows);
                return rows;
            });
    },

    function _storeRows(self, firstRow, lastRow, rows) {
        var rowNodes = [];
        for (var i = firstRow; i < rows.length + firstRow; ++i) {
            row = rows[i - firstRow];
            if (i >= self.model.rowCount() || self.model.getRowData(i) == undefined) {
                row.__node__ = self._createRow(i, row);
                self.model.setRowData(i, row);
                rowNodes.push({index: i, node: row.__node__});
            }
        }
        self._addRowsToViewport(rowNodes);
    },

    /**
     * Add C{rows} to the scroll viewport, replacing or splitting any
     * placeholder rows that are in the way.
     *
     * @param rows: array of objects with "index" and "node" members
     */
    function _addRowsToViewport(self, rows) {
        /* this could be made faster, if we have more than one row that falls
         * inside a single placeholder - we only need to split it once instead
         * of once per row, i think */
        var sviewport = self._scrollViewport,
            pmodel = self.placeholderModel,
            placeholder, placeholders, placeholderEntry, above, below;

        var maybeCreatePlaceholder = function(start, stop, replacing) {
            if(start < stop) {
                var obj = self.placeholderModel.createPlaceholder(
                            start, stop, self.makePlaceholderRowElement(
                                            (stop - start) * self._rowHeight));
                sviewport.insertBefore(obj.node, replacing);
                return obj;
            }
        }

        for(var i = 0; i < rows.length; i++) {
            placeholderIndex = pmodel.findPlaceholderIndexForRowIndex(rows[i].index);
            if(placeholderIndex !== null) {
                placeholder = pmodel.getPlaceholderWithIndex(placeholderIndex);

                above = maybeCreatePlaceholder(
                            placeholder.start, rows[i].index, placeholder.node);

                sviewport.insertBefore(rows[i].node, placeholder.node);

                below = maybeCreatePlaceholder(
                            rows[i].index+1, placeholder.stop, placeholder.node);

                sviewport.removeChild(placeholder.node);

                if(above && below) {
                    pmodel.dividePlaceholder(placeholderIndex, above, below);
                } else if(above || below) {
                    pmodel.replacePlaceholder(placeholderIndex, above || below);
                } else {
                    pmodel.removePlaceholder(placeholderIndex);
                }
            } else {
                sviewport.appendChild(rows[i].node);
            }
        }
    },

    /**
     * Remove the indicated row's data from the model and remove its DOM nodes
     * from the document.
     *
     * @type index: integer
     * @param index: The index of the row to remove.
     *
     * @return: The row data for the removed row.
     */
    function removeRow(self, index) {
        var rowData = self.model.removeRow(index);
        rowData.__node__.parentNode.removeChild(rowData.__node__);
        self.placeholderModel.removedRow(index);
        return rowData;
    },

    /**
     * Retrieve a node which is the same height as rows in the table will be.
     */
    function _getRowGuineaPig(self) {
        return MochiKit.DOM.TR(
            {"style": "visibility: hidden",
             "class": "scroll-row",
             "valign": "center"},
            MochiKit.DOM.TD(
                {"class": "scroll-cell"},
                MochiKit.DOM.A({"href": "#"}, "TEST!!!")));
    },

    /**
     * Determine the height of a row in this scrolltable.
     */
    function _getRowHeight(self) {
        var node = self._getRowGuineaPig();
        var rowHeight;

        /*
         * Put the node into the document so that the browser actually figures
         * out how tall it is.  Don't put it into the scrolltable itself or
         * anything clever like that, in case the scrolltable has some style
         * applied to it that would mess things up. (XXX The body could have a
         * style applied to it that could mess things up? -exarkun)
         */
        var tableNode = MochiKit.DOM.TABLE(null, node);
        document.body.appendChild(tableNode);
        rowHeight = Divmod.Runtime.theRuntime.getElementSize(node).h;
        document.body.removeChild(tableNode);

        if (rowHeight == 0) {
            rowHeight = Divmod.Runtime.theRuntime.getElementSize(self._headerRow).h;
        }

        if (rowHeight == 0) {
            rowHeight = 20;
        }

        return rowHeight;
    },

    /**
     * Set the display height of the scroll view DOM node to a height
     * appropriate for displaying the given number of rows, by appending
     * C{rowCount} placeholder rows to it
     *
     * @type rowCount: integer
     * @param rowCount: The number of rows which should fit into the view node.
     */
    function padViewportWithPlaceholderRow(self, rowCount) {
        var row = self.makePlaceholderRowElement(rowCount * self._rowHeight);
        self._scrollViewport.appendChild(row);

        self.placeholderModel.registerInitialPlaceholder(rowCount, row);
    },

    /**
     * This method is responsible for returning the height of the scroll
     * viewport in pixels.  The result is used to calculate the number of
     * rows needed to fill the screen.
     *
     * Under a variety of conditions (for example, a "display: none" style
     * applied to the viewport node), the browser may not report a height for
     * the viewport.  In this case, fall back to the size of the page.  This
     * will result in too many rows being requested, maybe, which is not very
     * harmful.
     */
    function getScrollViewportHeight(self) {
        var height = Divmod.Runtime.theRuntime.getElementSize(
            self._scrollViewport).h;

        /*
         * Firefox returns 0 for the clientHeight of display: none elements, IE
         * seems to return the height of the element before it was made
         * invisible.  There also seem to be some cases where the height will
         * be 0 even though the element has been added to the DOM and is
         * visible, but the browser hasn't gotten around to sizing it
         * (presumably in a different thread :( :( :() yet.  Default to the
         * full window size for these cases.
         */

        if (height == 0 || isNaN(height)) {
            /*
             * Called too early, just give the page height.  at worst we'll end
             * up requesting 5 extra rows or whatever.
             */
            height = Divmod.Runtime.theRuntime.getPageSize().h;
        }
        return height;
    },

    /**
     * Figure out the start and end indexes of rows that should be requested
     *
     * @param scrollingDown: A flag indicating whether we are scrolling down,
     * and so whether the requested rows should be below the current position
     * or not.
     *
     * @return: pair of [startIndex, stopIndex] or null if there isn't a
     * useful row range to request
     */
    function _calculateDesiredRowRange(self, scrollingDown) {
        var scrollViewportHeight = self.getScrollViewportHeight();
        var desiredRowCount = Math.ceil(scrollViewportHeight / self._rowHeight);
        var firstRow = Math.floor(self._scrollViewport.scrollTop / self._rowHeight);
        var requestNeeded = false;
        var i;

        /*
         * Never do less than 1 row of work.  The most likely cause of
         * desiredRowCount being 0 is that the browser screwed up some height
         * calculation.  We'll at least try to get 1 row (and maybe we should
         * actually try to get more than that).
         */
        if (desiredRowCount < 1) {
            desiredRowCount = 1;
        }

        if (scrollingDown) {
            for (i = 0; i < desiredRowCount; i++) {
                if (firstRow >= self.model.rowCount() || self.model.getRowData(firstRow) == undefined) {
                    requestNeeded = true;
                    break;
                }
                firstRow++;
            }
        } else {
            for (i = 0; i < desiredRowCount; i++) {
                var rowIndex = firstRow + desiredRowCount - 1;
                if (rowIndex < 0) {
                    break;
                }
                if (rowIndex >= self.model.rowCount() || self.model.getRowData(rowIndex) == undefined) {
                    requestNeeded = true;
                    break;
                }
                firstRow--;
            }
        }
        if(!requestNeeded) {
            return;
        }
        return [firstRow, firstRow+desiredRowCount];
    },

    /**
     * Retrieve some rows from the server which are likely to be useful given
     * the current state of this ScrollingWidget.  Update the ScrollModel when
     * the results arrive.
     *
     * @param scrollingDown: A flag indicating whether we are scrolling down,
     * and so whether the requested rows should be below the current position
     * or not.
     *
     * @return: A Deferred which fires with an Array of rows retrieve when
     * the update has finished.
     */
    function _getSomeRows(self, scrollingDown) {
        var range = self._calculateDesiredRowRange(scrollingDown);

        /* do we have the rows we need ? */
        if (!range) {
            return Divmod.Defer.succeed([]);
        }

        return self.requestRowRange.apply(self, range);
    },

    /**
     * Convert a Date instance to a human-readable string.
     *
     * @type when: C{Date}
     * @param when: The time to format as a string.
     *
     * @type now: C{Date}
     * @param now: If specified, the date which will be used to determine how
     * much context to provide in the returned string.
     *
     * @rtype: C{String}
     * @return: A string describing the date C{when} with as much information
     * included as is required by context.
     */
    function formatDate(self, date, /* optional */ now) {
        return date.toUTCString();
    },

    /**
     * @param columnName: The name of the column for which this is a value.
     *
     * @param columnType: A string which might indicate the data type of the
     * values in this column (if you have the secret decoder ring).
     *
     * @param columnValue: An object received from the server.
     *
     * @return: The object to put into the DOM for this value.
     */
    function massageColumnValue(self, columnName, columnType, columnValue) {
        if(columnType == 'timestamp') {
            return self.formatDate(new Date(columnValue * 1000));
        }
	if(columnValue ==  null) {
            return '';
	}
        return columnValue;
    },

    /**
     * Make an element which will be displayed for the value of one column in
     * one row.
     *
     * @param colName: The name of the column for which to make an element.
     *
     * @param rowData: An object received from the server.
     *
     * @return: A DOM node.
     */
    function makeCellElement(self, colName, rowData) {
        var attrs = {"class": "scroll-cell"};
        if(self.columnWidths && colName in self.columnWidths) {
            attrs["style"] = "width:" + self.columnWidths[colName];
        }
        var node = MochiKit.DOM.TD(
            attrs,
            /* unfortunately we have to put a link inside each cell - IE
             * doesn't seem to display rows if they are anchors with
             * display: table-row
             */
            MochiKit.DOM.A({"style": "display: block",
                            "href": rowData.__id__},
                self.massageColumnValue(
                    colName,
                    self.columnTypes[colName][0],
                    rowData[colName])));

        if (self.columnTypes[colName][0] == "fragment") {
            Divmod.Runtime.theRuntime.setNodeContent(node.firstChild,
                '<div xmlns="http://www.w3.org/1999/xhtml">' + rowData[colName] + '</div>');
        }
        return node;
    },

    /**
     * Remove all row nodes, including placeholder nodes from the scrolltable
     * viewport node.  Also empty the model.
     */
    function empty(self) {
        var sviewport = self._scrollViewport;

        /*
         * Removing children from the viewport will probably cause it to
         * scroll around a bit.  We care not about any of those events, so
         * ignore them temporarily.
         */
        var onscroll = sviewport.onscroll;
        sviewport.onscroll = undefined;
        while (sviewport.firstChild) {
            sviewport.removeChild(sviewport.firstChild);
        }
        sviewport.onscroll = onscroll;

        /*
         * Make everything as new again.
         */
        self.lastScrollPos = 0;
        self.model.empty();
        self.placeholderModel.empty();
    },

    /**
     * Remove all rows from scrolltable, as well as our cache of
     * fetched/unfetched rows, scroll the table to the top, and
     * refill it.
     */
    function emptyAndRefill(self) {
        self.empty();
        return self.refill();
    },

    /**
     * Request the current size (number of rows) from the server
     */
    function getSize(self) {
        return self.callRemote("requestCurrentSize");
    },

    /**
     * Refill an empty scrolltable by asking for more rows, creating
     * placeholder rows and adding a screenful of fresh rows.
     *
     * @return: Deferred firing with pair of [total size, fetched rows]
     */
    function refill(self) {
        var range = self._calculateDesiredRowRange(true);
        if(range[0] != 0) {
            throw new Error("expected first needed row to have index 0");
        }

        var result = Divmod.Defer.gatherResults(
                        [self.getSize(),
                         self.getRows(0, range[1])]);

        return result.addCallback(
                function(response) {
                    self.padViewportWithPlaceholderRow(response[0]);
                    self.model.setTotalRowCount(response[0]);
                    self._storeRows.apply(self, range.concat([response[1]]));
                    return response;
                });
    },

    /**
     * Tell the server to change the sort key for this table.
     *
     * @type columnName: string
     * @param columnName: The name of the new column by which to sort.
     */
    function resort(self, columnName) {
        var result = self.callRemote("resort", columnName);
        result.addCallback(function(isAscendingNow) {
                self._setSortHeader(columnName, isAscendingNow);
                self.emptyAndRefill();
            });
        return result;
    },

    /**
     * @type rowData: object
     *
     * @return: actions that are enabled for row C{rowData}
     * @rtype: array of L{Mantissa.ScrollTable.Action} instances
     */
    function getActionsForRow(self, rowData) {
        var enabled = [];
        for(var i = 0; i < self.actions.length; i++) {
            if(self.actions[i].enableForRow(rowData)) {
                enabled.push(self.actions[i]);
            }
        }
        return enabled;
    },

    /**
     * Make a node with some event handlers to perform actions on the row
     * specified by C{rowData}.
     *
     * @param rowData: Some data received from the server.
     *
     * @return: A DOM node.
     */
    function _makeActionsCells(self, rowData) {
        var actions = self.getActionsForRow(rowData);
        for(var i = 0; i < actions.length; i++) {
            actions[i] = actions[i].toNode(self, rowData);
        }
        var attrs = {"class": "scroll-cell"};
        if(self.columnWidths && "actions" in self.columnWidths) {
            attrs["style"] = "width:" + self.columnWidths["actions"];
        }
        return MochiKit.DOM.TD(attrs, actions);
    },

    /**
     * Make a DOM node for the given row.
     *
     * @param rowOffset: The index in the scroll model of the row data being
     * rendered.
     *
     * @param rowData: The row data for which to make an element.
     *
     * @return: A DOM node.
     */
    function _createRow(self, rowOffset, rowData) {
        var cells = [];

        for(var colName in rowData) {
            if(!(colName in self._columnOffsets) || self.skipColumn(colName)) {
                continue;
            }
            cells.push([colName, self.makeCellElement(colName, rowData)]);
        }
        if(self.actions && 0 < self.actions.length) {
            cells.push(["actions", self._makeActionsCells(rowData)]);
        }

        cells = cells.sort(
            function(data1, data2) {
                var a = self._columnOffsets[data1[0]];
                var b = self._columnOffsets[data2[0]];

                if (a<b) {
                    return -1;
                }
                if (a>b) {
                    return 1;
                }
                return 0;
            });

        var nodes = [];
        for (var i = 0; i < cells.length; ++i) {
            nodes.push(cells[i][1]);
        }
        return self.makeRowElement(rowOffset, rowData, nodes);
    },

    /**
     * Create a element to represent the given row data in the scrolling
     * widget.
     *
     * @param rowOffset: The index in the scroll model of the row data being
     * rendered.
     *
     * @param rowData: The row data for which to make an element.
     *
     * @param cells: Array of elements which represent the column data for this
     * row.
     *
     * @return: An element
     */
    function makeRowElement(self, rowOffset, rowData, cells) {
        var style = "height: " + self._rowHeight + "px";
        if(rowOffset % 2) {
            style += "; background-color: #F0F0F0";
        }
        return MochiKit.DOM.TR(
            {"class": "scroll-row",
             "style": style,
             "valign": "center"},
            cells);
    },

    /**
     * Make a placeholder row
     *
     * @type height: integer
     * @param height: the height of the placeholder row
     *
     * @rtype: node
     */
    function makePlaceholderRowElement(self, height) {
        return MochiKit.DOM.TR(
                {"class": "placeholder-scroll-row",
                 "style": "height: " + height + "px"},
                MochiKit.DOM.TD({"class": "placeholder-cell"}));
    },

    /**
     * @param name: column name
     * @return: boolean, indicating whether this column should not be rendered
     */
    function skipColumn(self, name) {
        return false;
    },

    /**
     * Return an object the properties of which are named like columns and
     * refer to those columns' display indices.
     */
    function _getColumnOffsets(self, columnNames) {
        var columnOffsets = {};
        for( var i = 0; i < columnNames.length; i++ ) {
            if(self.skipColumn(columnNames[i])) {
                continue;
            }
            columnOffsets[columnNames[i]] = i;
        }
        return columnOffsets;
    },

    /**
     * Return an Array of nodes to be used as column headers.
     *
     * @param columnNames: An Array of strings naming the columns in this
     * table.
     */
    function _createRowHeaders(self, columnNames) {
        var capitalize = function(s) {
            var words = s.split(/ /);
            var capped = "";
            for(var i = 0; i < words.length; i++) {
                capped += words[i].substr(0, 1).toUpperCase();
                capped += words[i].substr(1, words[i].length) + " ";
            }
            return capped;
        }

        var headerNodes = [];
        var sortable, attrs;

        for (var i = 0; i < columnNames.length; i++ ) {
            if(self.skipColumn(columnNames[i])) {
                continue;
            }

            var columnName = columnNames[i];
            var displayName;

            if(self.columnAliases && columnName in self.columnAliases) {
                displayName = self.columnAliases[columnName];
            } else {
                displayName = capitalize(columnName);
            }

            attrs = {"class": "scroll-column-header"};

            if(self.columnWidths && columnName in self.columnWidths) {
                attrs["style"] = "width:" + self.columnWidths[columnName];
            }

            if(columnName == "actions") {
                attrs["class"] = "actions-column-header";
            } else {
                sortable = self.columnTypes[columnName][1];

                if(sortable) {
                    attrs["class"] = "sortable-" + attrs["class"];
                    /*
                    * Bind the current value of columnName instead of just closing
                    * over it, since we're mutating the local variable in a loop.
                    */
                    attrs["onclick"] = (function(whichColumn) {
                            return function() {
                                /* XXX real-time feedback, ugh */
                                self.resort(whichColumn);
                            }
                        })(columnName);
                }
            }

            var headerNode = MochiKit.DOM.TD(attrs, displayName);
            headerNodes.push(headerNode);

        }
        return headerNodes;
    },

    /**
     * Update the view to reflect a new sort state.
     *
     * @param currentSortColumn: The name of the column by which the scrolling
     * widget's rows are now ordered, or null if there isn't a current sort
     * column
     *
     * @param isAscendingNow: A flag indicating whether the sort is currently
     * ascending.
     *
     */
    function _setSortHeader(self, currentSortColumn, isAscendingNow) {
        self.currentSort = currentSortColumn;
        self.ascending = isAscendingNow;

        if(currentSortColumn == null) {
            return;
        }

        /*
         * Remove the sort direction arrow from whichever header has it.
         */
        for (var j = 0; j < self._headerNodes.length; j++) {
            while(1 < self._headerNodes[j].childNodes.length) {
                self._headerNodes[j].removeChild(self._headerNodes[j].lastChild);
            }
        }

        /*
         * Put the appropriate sort direction arrow on whichever header
         * corresponds to the new current sort column.
         */
        var c;
        if(isAscendingNow) {
            c = '\u2191'; // up arrow
        } else {
            c = '\u2193'; // down arrow
        }
        var sortOffset = self._columnOffsets[currentSortColumn];
        var sortHeader = self._headerNodes[sortOffset];
        if (sortHeader != undefined) {
            var sortNode = MochiKit.DOM.SPAN({"class": "sort-arrow"}, c)
            sortHeader.appendChild(sortNode);
        }
    },

    /**
     * Called in response to only user-initiated scroll events.
     */
    function onScroll(self) {
        self.scrolled();
    },

    /**
     * Respond to an event which may have caused to become visible rows for
     * which we do not data locally cached.  Retrieve some data, maybe, if
     * necessary.
     */
    function scrolled(self) {
        var result = Divmod.Defer.Deferred();
        self._scrollDeferreds.push(result);

        var proposedTimeout = 250;
        var scrollingDown = self.lastScrollPos < self._scrollViewport.scrollTop;
        self.lastScrollPos = self._scrollViewport.scrollTop;

        if (self._requestWaiting) {
            self._moreAfterRequest = true;
            return result;
        }

        if (self._rowTimeout !== null) {
            clearTimeout(self._rowTimeout);
        }

        function finishDeferreds(result) {
            var scrollDeferreds = self._scrollDeferreds;
            self._scrollDeferreds = [];
            for (var i = 0; i < scrollDeferreds.length; ++i) {
                scrollDeferreds[i].callback(result);
            }
        }

        self._rowTimeout = setTimeout(
            function () {
                self._rowTimeout = null;
                self._requestWaiting = true;
                try {
                    var rowsDeferred = self._getSomeRows(scrollingDown);
                } catch (err) {
                    finishDeferreds(Divmod.Defer.Failure(err));
                    return;
                }
                rowsDeferred.addBoth(
                    function resetRequestWaiting(passthrough) {
                        self._requestWaiting = false;
                        return passthrough;
                    });
                rowsDeferred.addCallback(
                    function rowsReceived(rows) {
                        self._requestWaiting = false;
                        if (self._moreAfterRequest) {
                            self._moreAfterRequest = false;
                            self.scrolled();
                        } else {
                            finishDeferreds(null);
                        }
                        self.cbRowsFetched(rows.length);
                    });
                rowsDeferred.addErrback(
                    function rowsError(err) {
                        finishDeferreds(err);
                    });
            },
            proposedTimeout);
        return result;
    },

    /**
     * Callback for some event.  Don't implement this.
     */
    function cbRowsFetched(self) {
    }
    );

/**
 * ScrollingWidget subclass which adjusts its height each time the viewport is
 * refilled, setting it to the same height as the total height of all
 * available rows, up until C{self.maxRows}.  Example where maxRows is 3 and
 * row height is 10px:
 *
 * if there is 1 row in the model:
 *
 * |HEADERS HEADERS HEADERS|
 * -------------------------
 * |THE ONLY ROW           | <- 10px, no scrollbar
 *
 * 2 rows in the model:
 *
 * |HEADERS HEADERS HEADERS|
 * -------------------------
 * |THE FIRST ROW          | \__ 10px + 10px = 20px, no scrollbar
 * |THE SECOND ROW         | /
 *
 * 3 or more rows in the model (>= maxRows):
 *
 * |HEADERS HEADERS HEADERS|
 * -------------------------
 * | THE FIRST ROW       | | \
 * | THE SECOND ROW      | |  >- 10px + 10px + 10px = 30px, scrollbar
 * | THE THIRD ROW       | | /
 *          |
 *         \|/
 *  rest of the rows obscured
 */

Mantissa.ScrollTable.FlexHeightScrollingWidget = Mantissa.ScrollTable.ScrollingWidget.subclass('Mantissa.ScrollingWidget.FlexHeightScrollingWidget');
Mantissa.ScrollTable.FlexHeightScrollingWidget.methods(
    /**
     * Override default implementation so we can store C{maxRows} and set the
     * initial height once initialization is complete
     */
    function __init__(self, node, metadata, maxRows/*=undefined*/) {
        Mantissa.ScrollTable.FlexHeightScrollingWidget.upcall(self, "__init__", node, metadata);
        self.maxRows = maxRows;
        self.initializationDeferred.addCallback(
            function(passThrough) {
                self._setScrollViewportHeight();
                return passThrough;
            });
    },

    /**
     * Helper method which sets the height of the scrolltable so that it's
     * tall enough to accomodate (without a scrollbar) any number of rows <=
     * C{self.maxRows}
     */
    function _setScrollViewportHeight(self) {
        var rowCount = self.model.totalRowCount();
        if(self.maxRows) {
            rowCount = Math.min(rowCount, self.maxRows);
        }
        self._scrollViewport.style.height = (rowCount * self._rowHeight) + "px";
    },

    /**
     * Override default implementation to never request less than
     * C{self.maxRows} if we're starting from the zeroth row (e.g. after the
     * scrolltable has been emptied or when we make the initial fetch)
     */
    function _calculateDesiredRowRange(self, scrollingDown) {
        var res = Mantissa.ScrollTable.FlexHeightScrollingWidget.upcall(
                    self, "_calculateDesiredRowRange", scrollingDown);
        if(res && res[0] == 0 && res[1] < self.maxRows-1) {
            res[1] = self.maxRows-1;
        }
        return res;
    },

    /**
     * Override default implementation so that we can adjust the height of the
     * scrolltable after the scrolltable has been refilled
     */
    function refill(self) {
        var D = Mantissa.ScrollTable.FlexHeightScrollingWidget.upcall(self, "refill");
        return D.addCallback(
            function(passThrough) {
                self._setScrollViewportHeight();
                return passThrough;
            });
    })


syntax highlighted by Code2HTML, v. 0.9.1