Index: djblets/media/js/datagrid.js
===================================================================
--- djblets/media/js/datagrid.js	(revision 11917)
+++ djblets/media/js/datagrid.js	(working copy)
@@ -29,7 +29,7 @@ jQuery.fn.datagrid = function() {
     });
 
     /* Make the columns unselectable. */
-    $("th", this).unselectable();
+    $.ui.disableSelection($("th", this));
 
     /* Make the columns draggable. */
     $("th", this).not(".edit-columns").draggable({
Index: djblets/media/js/jquery.gravy.js
===================================================================
--- djblets/media/js/jquery.gravy.js	(revision 11917)
+++ djblets/media/js/jquery.gravy.js	(working copy)
@@ -2,26 +2,746 @@
 
 jQuery.fn.extend({
     /*
-     * Makes an element unselectable.
+     * Sets one or more elements' visibility based on the specified value.
+     *
+     * @param {bool} visible The visibility state.
+     *
+     * @return {jQuery} This jQuery.
+     */
+    setVisible: function(visible) {
+        return $(this).each(function() {
+            if (visible) {
+                $(this).show();
+            } else {
+                $(this).hide();
+            }
+        });
+    },
+
+    /*
+     * Sets the position of an element.
+     *
+     * @param {int}    left     The new left position.
+     * @param {int}    top      The new top position.
+     * @param {string} posType  The optional position type.
+     *
+     * @return {jQuery} This jQuery.
+     */
+    move: function(left, top, posType) {
+        return $(this).each(function() {
+            $(this).css({
+                left: left,
+                top: top
+            });
+
+            if (posType) {
+                $(this).css("position",
+                    (posType == "fixed" && $.browser.msie &&
+                     $.browser.version == 6)
+                    ? "absolute" : posType);
+            }
+        });
+    }
+});
+
+$.fn.getExtents = function(types, sides) {
+    var val = 0;
+
+    this.each(function() {
+        var self = $(this);
+
+        for (var t = 0; t < types.length; t++) {
+            var type = types.charAt(t);
+
+            for (var s = 0; s < sides.length; s++) {
+                var side = sides.charAt(s);
+                var prop;
+
+                if (type == "b") {
+                    type = "border";
+                } else if (type == "m") {
+                    type = "margin";
+                } else if (type == "p") {
+                    type = "padding";
+                }
+
+                if (side == "l" || side == "left") {
+                    side = "Left";
+                } else if (side == "r" || side == "right") {
+                    side = "Right";
+                } else if (side == "t" || side == "top") {
+                    side = "Top";
+                } else if (side == "b" || side == "bottom") {
+                    side = "Bottom";
+                }
+
+                prop = type + side;
+
+                if (type == "border") {
+                    prop += "Width";
+                }
+
+                var i = parseInt(self.css(prop));
+
+                if (!isNaN(i)) {
+                    val += i;
+                }
+            }
+        }
+    });
+
+    return val;
+}
+
+
+/*
+ * Auto-sizes a text area to make room for the contained content.
+ */
+$.widget("ui.autoSizeTextArea", {
+    _init: function() {
+        var self = this;
+
+        this._proxyEl = $("<pre/>")
+            .appendTo("body")
+            .move(-10000, -10000, "absolute");
+
+        if ($.browser.msie) {
+            //this._proxyEl.css("white-space", "pre-wrap"); // CSS 3
+        } else if ($.browser.mozilla) {
+            this._proxyEl.css("white-space", "-moz-pre-wrap"); // Mozilla, 1999+
+        } else if ($.browser.opera) {
+            // Opera 4-6
+            this._proxyEl.css("white-space", "-pre-wrap");
+
+            // Opera 7
+            this._proxyEl.css({
+                "white-space": "-o-pre-wrap",
+                "word-wrap": "break-word"
+            });
+        }
+
+        this.element.css("overflow", "hidden");
+
+        if (this.options.growOnKeyUp) {
+            this.element
+                .keyup(function() {
+                    self.autoSize();
+                })
+                .triggerHandler("keyup");
+        }
+    },
+
+    /*
+     * Auto-sizes a text area to match the content.
+     *
+     * This works by setting a proxy element to match the exact width of
+     * our text area and then filling it with text. The proxy element will
+     * grow to accommodate the content. We then set the text area to the
+     * resulting width.
      */
-    unselectable: function() {
-        this
-            .attr("unselectable", "on")
+    autoSize: function() {
+        this._proxyEl
+            .width(this.element.width())
+            .move(-10000, -10000)
+             .html(this.element.val()
+                .htmlEncode()
+                .replace(/\n/g, "<br />&nbsp;"));
+
+        this.element
+            .height(Math.max(this.options.minHeight,
+                             this._proxyEl.outerHeight()))
+            .triggerHandler("resize");
+    },
+
+    setMinHeight: function(minHeight) {
+        this.options.minHeight = minHeight;
+    }
+});
+
+$.extend($.ui.autoSizeTextArea, {
+    defaults: {
+        growOnKeyUp: true,
+        minHeight: 100
+    }
+});
+
+
+$.widget("ui.inlineEditor", {
+    _init: function() {
+        if ($.data(this.element[0], "inlineEditor")) {
+            return;
+        }
+
+        /* Constants */
+        var self = this;
+
+        /* State */
+        this._initialValue = null;
+        this._editing = false;
+
+        /* Elements */
+        this._form = $("<form/>")
+            .addClass("inline-editor-form " + this.options.cls)
+            .css("display", "inline")
+            .insertBefore(this.element)
+            .hide();
+
+        if (this.options.multiline) {
+            this._field = $("<textarea/>")
+                .appendTo(this._form)
+                .autoSizeTextArea();
+        } else {
+            this._field = $('<input type="text"/>')
+                .appendTo(this._form);
+        }
+
+        this._field.keypress(function(e) {
+            switch (e.keyCode) {
+                case 10:
+                case $.keyCode.ENTER:
+                    /* Enter */
+                    if (!self.options.forceOpen &&
+                        (!self.options.multiline || e.ctrlKey)) {
+                        self.save();
+                    }
+
+                    if (!self.options.multiline) {
+                        e.preventDefault();
+                    }
+                    break;
+
+                case $.keyCode.ESCAPE:
+                    /* Escape */
+                    if (!self.options.forceOpen) {
+                        self.cancel();
+                    }
+                    break;
+
+                default:
+                    return;
+            }
+        });
+
+        this._buttons = null;
+
+        if (this.options.showButtons) {
+            this._buttons = $("<div/>")
+                .addClass("buttons")
+                .appendTo(this._form);
+
+            if (!this.options.multiline) {
+                this._buttons.css("display", "inline");
+            }
+
+            var saveButton =
+                $('<input type="button"/>')
+                .val("OK")
+                .addClass("save")
+                .appendTo(this._buttons)
+                .click(function() { self.save(); });
+
+            var cancelButton =
+                $('<input type="button"/>')
+                .val("Cancel")
+                .addClass("cancel")
+                .appendTo(this._buttons)
+                .click(function() { self.cancel(); });
+        }
+
+        this._editIcon = null;
+
+        if (this.options.showEditIcon) {
+            this._editIcon =
+                $('<img src="' + this.options.editIconPath + '"/>')
+                .addClass("editicon")
+                .click(function() {
+                    self.startEdit();
+                });
+
+            if (this.options.multiline) {
+                this._editIcon.appendTo(
+                    $("label[for=" + this.element[0].id + "]"));
+            } else {
+                this._editIcon.insertAfter(this.element);
+            }
+        }
+
+        if (!this.options.useEditIconOnly) {
+            this.element.click(function() {
+                self.startEdit();
+            });
+        }
+
+        $(window).resize(function() {
+            self._fitWidthToParent();
+        });
+
+        if (this.options.forceOpen) {
+            self.startEdit();
+        }
+    },
+
+    /*
+     * Puts the editor into edit mode.
+     *
+     * This triggers the "beginEdit" signal.
+     */
+    startEdit: function() {
+        this._initialValue = this.element.text();
+        this._editing = true;
+
+        var value = this._normalizeText(this._initialValue).htmlDecode();
+
+        if (this.options.multiline && $.browser.msie) {
+            this._field.text(value);
+        } else {
+            this._field.val(value);
+        }
+
+        this.showEditor();
+        this.element.triggerHandler("beginEdit");
+    },
+
+    /*
+     * Saves the contents of the editor.
+     */
+    save: function() {
+        var value = this.value();
+        var encodedValue = value.htmlEncode();
+
+        var dirty = (this._normalizeText(this._initialValue) != encodedValue);
+
+        if (dirty) {
+            this.element.html($.isFunction(this.options.formatResult)
+                              ? this.options.formatResult(encodedValue)
+                              : encodedValue);
+        }
+
+        this.hideEditor();
+
+        if (dirty || this.options.notifyUnchangedCompletion) {
+            this.element.triggerHandler("complete",
+                                        [value, this._initialValue]);
+        }
+    },
+
+    cancel: function() {
+        this.hideEditor();
+        this.element.triggerHandler("cancel", [this._initialValue]);
+    },
+
+    field: function() {
+        return this._field;
+    },
+
+    value: function() {
+        return this._field.val();
+    },
+
+    showEditor: function() {
+        var self = this;
+
+        if (this._editIcon) {
+            if (this.options.multiline) {
+                this._editIcon.fadeOut();
+            } else {
+                this._editIcon.hide();
+            }
+        }
+
+        this.element.hide();
+        this._form.show();
+
+        if (this.options.multiline) {
+            var elHeight = this.element.height();
+            var newHeight = elHeight + this.options.extraHeight;
+
+            this._fitWidthToParent();
+
+            // TODO: Set autosize min height
+            this._field
+                .autoSizeTextArea("setMinHeight", newHeight)
+                .css("overflow", "hidden")
+                .height(elHeight)
+                .animate({
+                    height: newHeight
+                }, 350);
+        }
+
+        /* Execute this after the animation, if we performed one. */
+        this._field.queue(function() {
+            if (self.options.multiline) {
+                self._field.css("overflow", "auto");
+            }
+
+            self._fitWidthToParent();
+            self._field.focus();
+
+            if (!self.options.multiline) {
+                self._field[0].select();
+            }
+
+            self._field.dequeue();
+        });
+    },
+
+    hideEditor: function() {
+        var self = this;
+
+        if (self.options.forceOpen) {
+            return;
+        }
+
+        this._field.blur();
+
+        if (this._editIcon) {
+            if (this.options.multiline) {
+                this._editIcon.fadeIn();
+            } else {
+                this._editIcon.show();
+            }
+        }
+
+        if (this.options.multiline && this._editing) {
+            this._field
+                .css("overflow", "hidden")
+                .animate({
+                    height: this.element.height()
+                }, 350);
+        }
+
+        this._field.queue(function() {
+            self.element.show();
+            self._form.hide();
+            self._field.dequeue();
+        });
+
+        this._editing = false;
+    },
+
+    dirty: function() {
+        return this._editing &&
+               this._normalizeText(this._initialValue) !=
+               this.value().htmlEncode();
+    },
+
+    _fitWidthToParent: function() {
+        if (!this._editing) {
+            return;
+        }
+
+        var formParent = this._form.parent();
+
+        /*
+         * First make the field really small so it will fit on the
+         * first line, then figure out the offset and use it calculate
+         * the desired width.
+         */
+        this._field
+            .css("min-width", this.element.width())
+            .width(0)
+            .width(formParent.width() -
+                   (this._form.offset().left - formParent.offset().left) -
+                   this._field.getExtents("bp", "lr") -
+                   (this.options.multiline || !this._buttons
+                    ? 0 : this._buttons.outerWidth()));
+    },
+
+    _normalizeText: function(str) {
+        if (this.options.stripTags) {
+            /*
+             * Turn <br>s back into newlines before stripping out all
+             * other tags. Without this, we lose multi-line data when
+             * editing an old comment.
+             */
+            str = str.replace(/\<br\>/g, '\n');
+            str = str.stripTags().strip();
+        }
+
+        if (!this.options.multiline) {
+            str = str.replace(/\s{2,}/g, " ");
+        }
+
+        return str;
+    }
+});
+
+$.ui.inlineEditor.getter = "dirty field value";
+
+$.extend($.ui.inlineEditor, {
+    defaults: {
+        cls: "",
+        extraHeight: 100,
+        forceOpen: false,
+        formatResult: null,
+        multiline: false,
+        notifyUnchangedCompletion: false,
+        showButtons: true,
+        showEditIcon: true,
+        stripTags: false,
+        useEditIconOnly: false
+    }
+});
+
+/* Allows quickly looking for dirty inline editors. Those dirty things. */
+$.expr[':'].inlineEditorDirty = function(a) {
+    return $(a).inlineEditor("dirty");
+};
+
+
+jQuery.fn.positionToSide = function(el, options) {
+    options = jQuery.extend({
+        side: 'b',
+        distance: 0
+    }, options);
+
+    var offset = $(el).offset();
+    var thisWidth = this.width();
+    var thisHeight = this.height();
+    var elWidth = el.width();
+    var elHeight = el.height();
+
+    var scrollLeft = $(document).scrollLeft();
+    var scrollTop = $(document).scrollTop();
+    var scrollWidth = $(window).width();
+    var scrollHeight = $(window).height();
+
+    return $(this).each(function() {
+        var bestLeft = null;
+        var bestTop = null;
+
+        for (var i = 0; i < options.side.length; i++) {
+            var side = options.side[i];
+            var left = 0;
+            var top = 0;
+
+            if (side == "t") {
+                left = offset.left;
+                top = offset.top - thisHeight - options.distance;
+            } else if (side == "b") {
+                left = offset.left;
+                top = offset.top + elHeight + options.distance;
+            } else if (side == "l") {
+                left = offset.left - thisWidth - options.distance;
+                top = offset.top;
+            } else if (side == "r") {
+                left = offset.left + elWidth + options.distance;
+                top = offset.top;
+            } else {
+                continue;
+            }
+
+            if (left >= scrollLeft &&
+                left + thisWidth - scrollLeft < scrollWidth &&
+                top >= scrollTop &&
+                top + thisHeight - scrollTop < scrollHeight) {
+                bestLeft = left;
+                bestTop = top;
+                break;
+            } else if (bestLeft == null) {
+                bestLeft = left;
+                bestTop = top;
+            }
+        }
+
+        $(this).move(bestLeft, bestTop, "absolute");
+    });
+};
+
+
+jQuery.fn.delay = function(msec) {
+    return $(this).each(function() {
+        var self = $(this);
+        self.queue(function() {
+            window.setTimeout(function() { self.dequeue(); }, msec);
+        });
+    });
+}
+
+
+$.widget("ui.modalBox", {
+    _init: function() {
+        var self = this;
+
+        if (this.options.fadeBackground) {
+            this.bgbox = $("<div/>")
+                .addClass("modalbox-bg")
+                .appendTo("body")
+                .css({
+                    "background-color": "#000",
+                    opacity: 0,
+                    zIndex: 11000
+                })
+                .move(0, 0, "fixed")
+                .width("100%")
+                .height("100%")
+                .keydown(function() { return false; });
+        }
+
+        this.box = $("<div/>")
+            .appendTo("body")
+            .addClass("modalbox")
+            .move(0, 0, "absolute")
+            .css({zIndex: 11001})
+            .keydown(function() { return false; });
+
+        this.inner = $("<div/>")
+            .appendTo(this.box)
+            .addClass("modalbox-inner")
             .css({
-                '-moz-user-select': 'none',
-                '-khtml-user-select': 'none'
-            })
-            .bind('selectstart', function(evt) {
-                evt.stopPropagation();
-                evt.preventDefault();
+                position: "relative",
+                width: "100%",
+                height: "100%"
+            });
+
+        if (this.options.title) {
+            this.titleBox = $("<h1/>")
+                .appendTo(this.inner)
+                .addClass("modalbox-title")
+                .text(this.options.title);
+        }
+
+        this.element
+            .appendTo(this.inner)
+            .addClass("modalbox-contents")
+            .bind("DOMSubtreeModified", function() {
+                self.resize();
+            });
+
+        this._buttons = $("<div/>")
+            .appendTo(this.inner)
+            .addClass("modalbox-buttons")
+            .click(function(e) {
+                /* Check here so that buttons can call stopPropagation(). */
+                if (e.target.tagName == "INPUT") {
+                    self.element.modalBox("destroy");
+                }
+            });
+
+        $.each(this.options.buttons, function() {
+            $(this).appendTo(self._buttons);
+        });
+
+        if (this.options.fadeBackground) {
+            this.bgbox.fadeTo(800, 0.7);
+        }
+
+        $(window).bind("resize.modalbox", function() {
+            self.resize();
+        });
+
+        this.resize();
+    },
+
+    destroy: function() {
+        var self = this;
+
+        this.element
+            .removeData("modalBox")
+            .unbind("resize.modalbox")
+            .css("position", "static");
+
+        if (this.options.fadeBackground) {
+            this.bgbox.fadeOut(350, function() {
+                self.bgbox.remove();
             });
+        }
+
+        if (!this.options.discardOnClose) {
+            this.element.appendTo("body");
+        }
+
+        this.box.remove();
+    },
 
-        return this;
+    buttons: function() {
+        return this._buttons;
+    },
+
+    resize: function() {
+        var marginHoriz = $("body").getExtents("m", "lr");
+        var marginVert  = $("body").getExtents("m", "tb");
+        var winWidth    = $(window).width()  - marginHoriz;
+        var winHeight   = $(window).height() - marginVert;
+
+        if ($.browser.msie && $.browser.version == 6) {
+            /* Width/height of 100% don't really work right in IE6. */
+            this.bgbox.width($(window).width());
+            this.bgbox.height($(window).height());
+        }
+
+        if (this.options.stretchX) {
+            this.box.width(winWidth -
+                           this.box.getExtents("bmp", "lr") -
+                           marginHoriz);
+        }
+
+        if (this.options.stretchY) {
+            this.box.height(winHeight -
+                            this.box.getExtents("bmp", "tb") -
+                            marginVert);
+
+            this.element.height(this._buttons.position().top -
+                                this.element.position().top -
+                                this.element.getExtents("m", "tb"));
+        } else {
+            this.box.height(this.element.position().top +
+                            this.element.outerHeight(true) +
+                            this._buttons.outerHeight(true));
+        }
+
+        this.box.move((winWidth  - this.box.outerWidth())  / 2,
+                      (winHeight - this.box.outerHeight()) / 2,
+                      "fixed");
+
+        this.element.triggerHandler("resize");
     }
 });
 
+$.ui.modalBox.getter = "buttons";
+
+$.extend($.ui.modalBox, {
+    defaults: {
+        buttons: [$('<input type="button" value="Close"/>')],
+        discardOnClose: true,
+        fadeBackground: true,
+        stretchX: false,
+        stretchY: false,
+        title: null
+    }
+});
+
+
+jQuery.tooltip = function(el, options) {
+    options = jQuery.extend({
+        side: 'b'
+    }, options);
+
+    var self = $("<div></div>")
+        .appendTo("body")
+        .addClass("tooltip")
+        .hide();
+
+    el.hover(
+        function() {
+            if (self.html() != "") {
+                self
+                    .positionToSide(el, {
+                        side: options.side,
+                        distance: 10
+                    })
+                    .show();
+            }
+        },
+        function() {
+            self.hide();
+        });
 
-jQuery.extend(String, {
+    return self;
+};
+
+
+jQuery.extend(String.prototype, {
     strip: function() {
         return this.replace(/^\s+/, '').replace(/\s+$/, '');
     },
@@ -52,9 +772,29 @@ jQuery.extend(String, {
         str = str.replace(/&gt;/g, ">");
 
         return str;
+    },
+
+    truncate: function(numChars) {
+        numChars = numChars || 100;
+
+        var str = this.toString();
+
+        if (this.length > numChars) {
+            str = this.substring(0, numChars - 3); // minus length of "..."
+            i = str.lastIndexOf(".");
+
+            if (i != -1) {
+                str = str.substring(0, i + 1);
+            }
+
+            str += "...";
+        }
+
+        return str;
     }
 });
 
+
 var queues = {};
 
 /*
@@ -105,3 +845,5 @@ $.funcQueue = function(name) {
 }
 
 })(jQuery);
+
+// vim: set et:
Index: djblets/util/templatetags/djblets_js.py
===================================================================
--- djblets/util/templatetags/djblets_js.py	(revision 11917)
+++ djblets/util/templatetags/djblets_js.py	(working copy)
@@ -47,6 +47,12 @@ def form_dialog_fields(form):
         else:
             s += "label: '%s', " % field.label_tag(field.label + ":")
 
+            if field.field.required:
+                s += "required: true, "
+
+            if field.field.help_text:
+                s += "help_text: '%s', " % field.field.help_text
+
         s += "widget: '%s' }," % unicode(field)
 
     # Chop off the last ','
