/*
################################################################################
ox.ui.js

requires
    jquery-1.4.js
    ox.js
################################################################################
*/

// also see test.js, in demos ...

(function() {

    var oxui = {
            defaultTheme: 'classic',
            elements: {},
            getDimensions: function(orientation) {
                return orientation == 'horizontal' ?
                    ['width', 'height'] : ['height', 'width'];
            },
            getEdges: function(orientation) {
                return orientation == 'horizontal' ?
                    ['left', 'right', 'top', 'bottom'] :
                    ['top', 'bottom', 'left', 'right'];
            },
            getBarSize: function(size) {
                var sizes = {
                    small: 20,
                    medium: 24,
                    large: 28,
                };
                return sizes[size];
            },
            jQueryFunctions: function() {
                var functions = [],
                    $element = $('<div>');
                //delete $element.length;
                Ox.each($element, function(k, v) {
                    if (typeof v == 'function') {
                        functions.push(k);
                    }
                });
                return functions.sort();
            }(),
            path: $('script[src*=ox.ui.js]').attr('src').replace('js/ox.ui.js', ''),
            scrollbarSize: $.browser.mozilla ? 16 : 12,
            symbols: {
                alt: '\u2325',
                apple: '\uF8FF',
                arrow_down: '\u2193',
                arrow_left: '\u2190',
                arrow_right: '\u2192',
                arrow_up: '\u2191',
                backspace: '\u232B',
                backup: '\u2707',
                ballot: '\u2717',
                black_star: '\u2605',
                burn: '\u2622',
                caps_lock: '\u21EA',
                check: '\u2713',
                //clear: '\u2327',
                clear: '\u00D7',
                click: '\uF803',
                close: '\u2715',
                command: '\u2318',
                control: '\u2303',
                cut: '\u2702',
                'delete': '\u2326',
                diamond: '\u25C6',
                edit: '\uF802',
                eject: '\u23CF',
                escape: '\u238B',
                end: '\u2198',
                enter: '\u2324',
                fly: '\u2708',
                gear: '\u2699',
                home: '\u2196',
                info: '\u24D8',
                navigate: '\u2388',
                option: '\u2387',
                page_up: '\u21DE',
                page_down: '\u21DF',
                redo: '\u21BA',
                'return': '\u21A9',
                //select: '\u21D5',
                select: '\u25BE',
                shift: '\u21E7',
                sound: '\u266B',
                space: '\u2423',
                tab: '\u21E5',
                trash: '\u267A',
                triangle_down: '\u25BC',
                triangle_left: '\u25C0',
                triangle_right: '\u25BA',
                triangle_up: '\u25B2',
                undo: '\u21BB',
                voltage: '\u26A1',
                warning: '\u26A0',
                white_star: '\u2606'
            }
        },
        $window, $document, $body;

    $(function() {
        $window = $(window),
        $document = $(document),
        $body = $('body'),
        $elements = {};
        Ox.theme(oxui.defaultTheme);
    });

    /*
    ============================================================================
    Application
    ============================================================================
    */

    /*
    ----------------------------------------------------------------------------
    Ox.App
    ----------------------------------------------------------------------------
    */

    Ox.App = function() {
        /*
        options:
        requestTimeout
        requestType
        requestURL
        */
        return function(options) {

            options = options || {};
            var self = {},
                that = this;

            self.time = +new Date();

            self.options = $.extend({
                id: '',
                init: '',
                name: '',
                requestTimeout: 15000,
                requestType: 'POST',
                requestURL: '',
            }, options);

            function getUserAgent() {
                var userAgent = '';
                $.each(['Chrome', 'Firefox', 'Internet Explorer', 'Opera', 'Safari'], function(i, v) {
                    if (navigator.userAgent.indexOf(v) > -1) {
                        userAgent = v;
                        return false;
                    }
                });
                return userAgent;
            }

            function getUserData() {
                //return {};
                return {
                    navigator: {
                        cookieEnabled: navigator.cookieEnabled,
                        plugins: $.map(navigator.plugins, function(plugin, i) {
                            return plugin.name;
                        }),
                        userAgent: navigator.userAgent
                    },
                    screen: screen,
                    time: (+new Date() - self.time) / 1000,
                    window: {
                        innerHeight: window.innerHeight,
                        innerWidth: window.innerWidth,
                        outerHeight: window.outerHeight,
                        outerWidth: window.outerWidth,
                        screenLeft: window.screenLeft,
                        screenTop: window.screenTop
                    }
                };
            }

            function loadImages(callback) {
                $.getJSON(oxui.path + 'json/ox.ui.images.json', function(data) {
                    var counter = 0,
                        length = data.length;
                    data.forEach(function(src, i) {
                        image = new Image()
                        image.src = oxui.path + src;
                        image.onload = function() {
                            (++counter == length) && callback();
                        }
                    });
                });
            }

            self.change = function(key, value) {
                
            };

            that.launch = function(callback) {
                var time = +new Date(),
                    userAgent = getUserAgent(),
                    userAgents = ['Chrome', 'Firefox', 'Safari'];
                document.title = self.options.name;
                $.ajaxSetup({
                    timeout: self.options.requestTimeout,
                    type: self.options.requestType,
                    url: self.options.requestURL
                });
                userAgents.indexOf(userAgent) > -1 ? start() : stop();
                function start() {
                    loadImages(function() {
                        window.google = function() {};
                        $.getScript('http://maps.google.com/maps/api/js?callback=google&sensor=false', function() {
                            $.getJSON(self.options.config, function(data) {
                                var config = data;
                                that.request(self.options.init, getUserData(), function(data) {
                                    var user = data.data.user;
                                    $(function() {
                                        var $div = $body.find('div');
                                        $body.find('img').remove();
                                        $div.animate({
                                            opacity: 0
                                        }, 1000, function() {
                                            $div.remove();
                                        });
                                        callback({
                                            config: config,
                                            user: user
                                        });
                                    });
                                });
                            });
                        });
                    });
                }
                function stop() {
                    that.request(self.options.init, getUserData(), function() {});
                }
                return that;
            };

            that.options = function() {
                return Ox.getset(self.options, Array.prototype.slice.call(arguments), self.change, that);
            };

            that.request = function(action, data, callback) {
                if (arguments.length == 2) {
                    callback = data;
                    data = {};
                }
                return Ox.Request.send({
                    url: self.options.requestURL,
                    data: {
                        action: action,
                        data: JSON.stringify(data)
                    },
                    callback: callback
                });
            };

            return that;

        };

    }();

    /*
    ----------------------------------------------------------------------------
    Ox.Event
    ----------------------------------------------------------------------------
    */

    Ox.Event = function() {
        var $eventHandler = $('<div>'),
            events = {};
        function addEvent(id, type, event, callback) {
            events[id] = events[id] || {};
            events[id][type] = events[id][type] || {};
            events[id][type][event] = events[id][type][event] || [];
            events[id][type][event].push(callback);
            if (type == 'normal' || Ox.Focus.focused() == id) {
                Ox.print('bind', id, event);
                $eventHandler.bind(event + (id ? '_' + id : ''), callback); // requestStart/requestStop currently have '' as id
            }
        }
        function removeEvent(id, type, event, callback) {
            var focused = type == 'normal' || Ox.Focus.focused() == id,
                toString = (callback || '').toString();
            Ox.print('removeEvent', id, type, event, callback);
            if (events[id] && events[id][type] && (!event || events[id][type][event])) {
                $.each(events[id][type], function(e, fns) {
                    if (!event || event == e) {
                        events[id][type][e] = $.map(events[id][type][e], function(fn, i) {
                            if (!callback || toString == fn.toString()) {
                                focused && $eventHandler.unbind(e + '_' + id, fn);
                                return null;
                            } else {
                                return fn;
                            }
                        });
                    }
                });
                Ox.print(id, type, events)
                if (!callback || (event && events[id][type][event].length == 0)) {
                    delete events[id][type][event];
                }
                if (!event || Ox.length(events[id].normal) == 0) {
                    delete events[id][type];
                }
                if (Ox.length(events[id]) == 0) {
                    delete events[id];
                }
            }
        }
        function isKeyboardEvent(event) { // fixme: currently unused
            return event.substr(0, 4) == 'key_';
        }
        return {
            _print: function() {
                Ox.print(events);
            },
            add: function(id, event, callback) {
                // add keyboard event
                addEvent(id, 'keyboard', event, callback);
            },
            bind: function(id, event, callback) {
                // bind event
                addEvent(id, 'normal', event, callback);
            },
            bindKeyboard: function(id) {
                // bind all keyboard events
                //Ox.print('binding', 'id', id, 'events', events[id], Ox.length(events[id]), 'keyboardevents', events[id]['keyboard'])
                $.each(events[id], function(k, v) {
                    Ox.print('|' + k + '|');
                })
                events[id] && events[id].keyboard && $.each(events[id].keyboard, function(event, callbacks) {
                    $.each(callbacks, function(i, callback) {
                        Ox.print('bind', id, event);
                        $eventHandler.bind(event, callback);
                    });
                });
            },
            changeId: function(oldId, newId) {
                // fixme: would it be better to pass that.id instead of self.options.id?
                // then this renaming wouldn't be necessary
                Ox.print('changeId', oldId, newId, events[oldId]);
                $.each($.extend({}, events[oldId] || {}), function(type, events_) {
                    var bind = type == 'normal' ? 'bind' : 'add',
                        unbind = type == 'normal' ? 'unbind' : 'remove';
                    $.each(events_, function(event, callbacks) {
                        $.each(callbacks, function(i, callback) {
                            Ox.Event[unbind](oldId, event, callback);
                            Ox.Event[bind](newId, event, callback);
                        });
                    });
                });
            },
            remove: function(id, event, callback) {
                // remove keyboard event
                // event and callback are optional
                removeEvent(id, 'keyboard', event, callback);
            },
            unbind: function(id, event, callback) {
                // unbind event
                // event and callback are optional
                removeEvent(id, 'normal', event, callback);
            },
            trigger: function(id, event, data) {
                // trigger event
                // data is optional
                // keyboard handler will call this with '' as id
                Ox.print('trigger', id, event, data || {});
                $eventHandler.trigger(event + (id ? '_' + id : ''), data || {});
            },
            unbindKeyboard: function(id) {
                // unbind all keyboard events
                Ox.print('unbinding', id /*events[id].keyboard*/)
                events[id] && events[id].keyboard && $.each(events[id].keyboard, function(event, callbacks) {
                    $.each(callbacks, function(i, callback) {
                        Ox.print('unbind', event)
                        $eventHandler.unbind(event, callback);
                    });
                });
            }
        }
    }();

    /*
    ----------------------------------------------------------------------------
    Ox.Focus
    ----------------------------------------------------------------------------
    */

    Ox.Focus = function() {
        var stack = [];
        return {
            blur: function(id) {
                var index = stack.indexOf(id);
                if (index == stack.length - 1) {
                    stack.length == 1 ? stack.pop() :
                            stack.splice(stack.length - 2, 0, stack.pop());
                    Ox.Event.unbindKeyboard($elements[id].options('id'));
                    stack.length && Ox.Event.bindKeyboard($elements[stack[stack.length - 1]].options('id'));
                    //$elements[id].removeClass('OxFocus');
                    $('.OxFocus').removeClass('OxFocus'); // fixme: the above is better, and should work
                    stack.length && $elements[stack[stack.length - 1]].addClass('OxFocus');
                    Ox.print('blur', id, stack);
                }
            },
            focus: function(id) {
                var index = stack.indexOf(id);
                if (index == -1 || index < stack.length - 1) {
                    stack.length && Ox.Event.unbindKeyboard($elements[stack[stack.length - 1]].options('id'));
                    index > -1 && stack.splice(index, 1);
                    stack.push(id);
                    Ox.Event.bindKeyboard($elements[id].options('id'));
                    $('.OxFocus').removeClass('OxFocus'); // fixme: see above
                    $elements[id].addClass('OxFocus');
                    Ox.print('focus', id, stack);
                }
            },
            focused: function() {
                return stack[stack.length - 1];
            }
        };
    }();

    /*
    ----------------------------------------------------------------------------
    Ox.History
    ----------------------------------------------------------------------------
    */

    /*
    ----------------------------------------------------------------------------
    Ox.Keyboard
    ----------------------------------------------------------------------------
    */

    (function() {

        var buffer = '',
            bufferTime = 0,
            bufferTimeout = 1000,
            keyNames = function() {
                return {
                    0: 'section',
                    8: 'backspace',
                    9: 'tab',
                    12: 'clear',
                    13: 'enter',
                    16: 'shift',
                    17: 'control',
                    18: 'alt',
                    20: 'capslock',
                    27: 'escape',
                    32: 'space',
                    33: 'pageup',
                    34: 'pagedown',
                    35: 'end',
                    36: 'home',
                    37: 'left',
                    38: 'up',
                    39: 'right',
                    40: 'down',
                    45: 'insert',
                    46: 'delete',
                    47: 'help',
                    48: '0',
                    49: '1',
                    50: '2',
                    51: '3',
                    52: '4',
                    53: '5',
                    54: '6',
                    55: '7',
                    56: '8',
                    57: '9',
                    65: 'a',
                    66: 'b',
                    67: 'c',
                    68: 'd',
                    69: 'e',
                    70: 'f',
                    71: 'g',
                    72: 'h',
                    73: 'i',
                    74: 'j',
                    75: 'k',
                    76: 'l',
                    77: 'm',
                    78: 'n',
                    79: 'o',
                    80: 'p',
                    81: 'q',
                    82: 'r',
                    83: 's',
                    84: 't',
                    85: 'u',
                    86: 'v',
                    87: 'w',
                    88: 'x',
                    89: 'y',
                    90: 'z',
                    91: 'meta.left',
                    92: 'meta.right',
                    93: 'select',
                    96: '0.numpad',
                    97: '1.numpad',
                    98: '2.numpad',
                    99: '3.numpad',
                    100: '4.numpad',
                    101: '5.numpad',
                    102: '6.numpad',
                    103: '7.numpad',
                    104: '8.numpad',
                    105: '9.numpad',
                    106: 'asterisk.numpad',
                    107: 'plus.numpad',
                    109: 'minus.numpad',
                    108: 'enter.numpad',
                    110: 'dot.numpad',
                    111: 'slash.numpad',
                    112: 'f1',
                    113: 'f2',
                    114: 'f3',
                    115: 'f4',
                    116: 'f5',
                    117: 'f6',
                    118: 'f7',
                    119: 'f8',
                    120: 'f9',
                    121: 'f10',
                    122: 'f11',
                    123: 'f12',
                    124: 'f13',
                    125: 'f14',
                    126: 'f15',
                    127: 'f16',
                    144: 'numlock',
                    145: 'scrolllock',
                    186: 'semicolon',
                    187: 'equal',
                    188: 'comma',
                    189: 'minus',
                    190: 'dot',
                    191: 'slash',
                    192: 'backtick',
                    219: 'openbracket',
                    220: 'backslash',
                    221: 'closebracket',
                    222: 'quote'
                    // see dojo, for ex.
                };
            }(),
            modifierNames = {
                altKey: 'alt', // mac: option
                ctrlKey: 'control',
                // metaKey: 'meta', // mac: command
                shiftKey: 'shift'
            };

        $(function() {
            // fixme: how to do this better?
            // in firefox on mac, keypress doesn't fire for up/down
            // if the cursor is at the start/end of an input element
            // on linux, it doesn't seem to fire if the input element has focus
            if ($.browser.mozilla) {
                $document.keypress(keypress);
                $document.keydown(function(event) {
                    var $element = $('input:focus');
                    if ($element.length) {
                        if (
                            (
                                keyNames[event.keyCode] == 'up' &&
                                $element[0].selectionStart + $element[0].selectionEnd == 0
                            ) || (
                                keyNames[event.keyCode] == 'down' &&
                                $element[0].selectionStart == $element.val().length &&
                                $element[0].selectionEnd == $element.val().length
                            )
                        ) {
                            keypress(event);
                        }
                    }
                });
            } else {
                $document.keydown(keypress);
            }
        });
        function keypress(event) {
            var key,
                keys = [],
                //ret = true,
                time;
            $.each(modifierNames, function(k, v) {
                if (event[k]) {
                    keys.push(v);
                }
            });
            // avoid pushing modifier twice
            Ox.print('keys', keys)
            if (keyNames[event.keyCode] && keys.indexOf(keyNames[event.keyCode]) == -1) {
                keys.push(keyNames[event.keyCode]);
            }
            key = keys.join('_');
            if (key.match(/^[\w\d-]$|SPACE/)) {
                time = Ox.getTime();
                if (time - bufferTime > bufferTimeout) {
                    buffer = '';
                }
                buffer += key == 'SPACE' ? ' ' : key;
                bufferTime = time;
            }
            Ox.Event.trigger('', 'key_' + key);
            //return false;
            /*
            $.each(stack, function(i, v) {
                // fixme: we dont get the return value!
                ret = Ox.event.trigger(keyboard + Ox.toCamelCase(key) + '.' + v);
                return ret;
            });
            */
        }

    })();

    /*
    ----------------------------------------------------------------------------
    Ox.Mouse (??)
    ----------------------------------------------------------------------------
    */

    /*
    ----------------------------------------------------------------------------
    Ox.Request
    ----------------------------------------------------------------------------
    */

    Ox.Request = function() {

        //  fixme: do we want, instead of request('find', data, callback),
        //  something like server.find(data, callback)?

        var cache = {},
            pending = {},
            requests = {},
            self = {
                options: {
                    timeout: 15000,
                    type: 'POST',
                    url: 'api'
                }
            };

        return {

            cancel: function() {
                var index;
                if (arguments.length == 0) {
                    requests = {};
                } else if (Ox.isFunction(arguments[0])) {
                    // cancel with function
                    $.each(requests, function(id, req) {
                        if (arguments[0](req)) {
                            delete requests[id];
                        }
                    })
                } else {
                    // cancel by id
                    delete requests[arguments[0]]
                }
            },

            emptyCache: function() {
                cache = {};
            },

            options: function(options) {
                return Ox.getset(self.options, options, $.noop(), this);
            },

            send: function(options) {

                var options = $.extend({
                        age: -1,
                        callback: function() {},
                        id: Ox.uid(),
                        timeout: self.options.timeout,
                        type: self.options.type,
                        url: self.options.url
                    }, options),
                    req = JSON.stringify({
                        url: options.url,
                        data: options.data
                    });

                function callback(data) {
                    delete requests[options.id];
                    Ox.length(requests) == 0 && Ox.Event.trigger('', 'requestStop');
                    options.callback(data); // fixme: doesn't work if callback hasn't been passed
                }

                function debug(request) {
                    var $iframe = $('<iframe>')
                            .css({ // fixme: should go into a class
                                width: 768,
                                height: 384
                            }),
                        $dialog = new Ox.Dialog({
                                title: 'Application Error',
                                buttons: [
                                    {
                                        title: 'Close',
                                        click: function() {
                                            $dialog.close();
                                        }
                                    }
                                ],
                                width: 800,
                                height: 400
                            })
                            .append($iframe)
                            .open(),
                        iframe = $iframe[0].contentDocument || $iframe[0].contentWindow.document;
                    iframe.open();
                    iframe.write(request.responseText);
                    iframe.close();
                }

                function error(request, status, error) {
                    var data;
                    if (arguments.length == 1) {
                        data = arguments[0]
                    } else {
                        try {
                            data = JSON.parse(request.responseText);
                        } catch (err) {
                            data = {
                                status: {
                                    code: request.status,
                                    text: request.statusText
                                }
                            };
                        }
                    }
                    if (data.status.code < 500) {
                        callback(data);
                    } else {
                        var $dialog = new Ox.Dialog({
                                title: 'Application Error',
                                buttons: [
                                    {
                                        title: 'Details',
                                        click: function() {
                                            $dialog.close(function() {
                                                debug(request);
                                            });
                                        }
                                    },
                                    {
                                        title: 'Close',
                                        click: function() {
                                            $dialog.close(function() {
                                                callback(data);
                                            });
                                        }
                                    }
                                ],
                                width: 400,
                                height: 200
                            })
                            .append('Sorry, we have encountered an application error while handling your request. To help us find out what went wrong, you may want to report this error to an administrator. Otherwise, please try again later.')
                            .open();
                            // fixme: change this to Send / Don't Send
                        Ox.print({
                            request: request,
                            status: status,
                            error: error
                        });
                    }
                    pending[options.id] = false;
                }

                function success(data) {
                    pending[options.id] = false;
                    cache[req] = {
                        data: data,
                        time: Ox.getTime()
                    };
                    callback(data);
                }

                if (pending[options.id]) {
                    setTimeout(function() {
                        Ox.Request.send(options);
                    }, 0);
                } else {
                    requests[options.id] = {
                        url: options.url,
                        data: options.data
                    };
                    if (cache[req] && (options.age == -1 || options.age > Ox.getTime() - cache[req].time)) {
                        setTimeout(function() {
                            callback(cache[req].data);
                        }, 0);
                    } else {
                        pending[options.id] = true;
                        $.ajax({
                            data: options.data,
                            dataType: 'json',
                            error: error,
                            success: success,
                            timeout: options.timeout,
                            type: options.type,
                            url: options.url
                        });
                        Ox.print('request', options.data, Ox.length(requests));
                        Ox.length(requests) == 1 && Ox.Event.trigger('', 'requestStart');
                    }
                }

                return options.id;

            },

            requests: function() {
                return Ox.length(requests);
            }

        };
    }();

    /*
    ----------------------------------------------------------------------------
    Ox.Theme
    ----------------------------------------------------------------------------
    */

    Ox.Theme = function() {
        
    };

    /*
    ----------------------------------------------------------------------------
    Ox.URL
    ----------------------------------------------------------------------------
    */

    /*
    ============================================================================
    Core
    ============================================================================
    */

    /*
    ----------------------------------------------------------------------------
    Ox.Container
    ----------------------------------------------------------------------------
    */

    // fixme: wouldn't it be better to let the elements be,
    // rather then $element, $content, and potentially others,
    // 0, 1, 2, etc, so that append would append 0, and appendTo
    // would append (length - 1)?
    Ox.Container = function(options, self) {
        var that = new Ox.Element('div', self)
            .options(options || {})
            .addClass('OxContainer');
        that.$content = new Ox.Element('div', self)
            .options(options || {})
            .addClass('OxContent')
            .appendTo(that);
        return that;
    };

    /*
    ----------------------------------------------------------------------------
    Ox.Element
    ----------------------------------------------------------------------------
    */

    // check out http://ejohn.org/apps/learn/#36 (-#38, making fns work w/o new)

    Ox.Element = function() {

        return function(options, self) {

            // construct
            options = options || {};
            self = self || {};
            var that = this;

            // init
            (function() {
                // allow for Ox.Widget('tagname', self)
                if (typeof options == 'string') {
                    options = {
                        element: options
                    };
                }
                that.ox = Ox.version;
                that.id = Ox.uid();
                that.$element = $('<' + (options.element || 'div') + '/>', {
                    data: {
                        ox: that.id
                    }
                });
                $elements[that.id] = that;
                wrapjQuery();
            })();

            // private
            function wrapjQuery() {
                $.each(oxui.jQueryFunctions, function(i, fn) {
                    that[fn] = function() {
                        var args = arguments,
                            length = args.length,
                            id, ret;
                        $.each(args, function(i, arg) {
                            if (Ox.isUndefined(arg)) {
                                Ox.print('fn', fn, 'undefined argument')
                            }
                            // if an ox object was passed
                            // then pass its $element instead
                            // so we can do oxObj.jqFn(oxObj)
                            if (arg.ox) {
                                args[i] = arg.$element;
                            }
                            /*
                            if (arg.ox) { // fixme: or is this too much magic?
                                if (fn == 'appendTo' && arg.$content) {
                                    args[i] = arg.$content
                                } else {
                                    args[i] = arg.$element;
                                }
                            }
                            */
                        });
                        /*
                        if (fn == 'html' && that.$content) { // fixme: or is this too much magic?
                            $element = that.$content;
                        } else {
                            $element = that.$element;
                        }
                        */
                        // why does this not work? (that?)
                        // ret = that.$element[fn].apply(this, arguments);
                        if (length == 0) {
                            ret = that.$element[fn]();
                        } else if (length == 1) {
                            ret = that.$element[fn](args[0]);
                        } else if (length == 2) {
                            ret = that.$element[fn](args[0], args[1]);
                        } else if (length == 3) {
                            ret = that.$element[fn](args[0], args[1], args[2]);
                        } else if (length == 4) {
                            ret = that.$element[fn](args[0], args[1], args[2], args[3]);
                        }
                        if (fn == 'data') {
                            // Ox.print('data ret', ret, $(ret))
                        }
                        // if the $element of an ox object was returned
                        // then return the ox object instead
                        // so we can do oxObj.jqFn().oxFn()
                        return ret.jquery && $elements[id = ret.data('ox')] ?
                                $elements[id] : ret;
                    }
                });
            }

            // shared
            self.onChange = function() {
                // self.onChange(key, value)
                // is called when an option changes
                // (to be implemented by widget)
            };

            // public

            that.addEvent = function() {
                /*
                adds a keyboard event, to be bound when focused
                addEvent(event, fn) or addEvent({event0: fn0, event1: fn1, ...})
                */
                if (arguments.length == 1) {
                    $.each(arguments[0], function(event, fn) {
                        Ox.Event.add(self.options.id, event, fn);
                    });
                } else {
                    Ox.Event.add(self.options.id, arguments[0], arguments[1]);
                }
                return that;
            };

            that.bindEvent = function() {
                /*
                binds a function to an event triggered by this object
                bindEvent(event, fn) or bindEvent({event0: fn0, event1: fn1, ...})
                */
                if (arguments.length == 1) {
                    $.each(arguments[0], function(event, fn) {
                        Ox.Event.bind(self.options.id, event, fn);
                    });
                } else {
                    Ox.Event.bind(self.options.id, arguments[0], arguments[1]);
                }
                return that;
            }

            that.defaults = function(defaults) {
                /*
                    that.defaults({foo: x})         sets self.defaults
                */
                self.defaults = defaults;
                delete self.options; // fixme: hackish fix for that = Ox.Foo({...}, self).defaults({...}).options({...})
                return that;
            };

            that.gainFocus = function() {
                Ox.Focus.focus(that.id);
                return that;
            };

            that.hasFocus = function() {
                return Ox.Focus.focused() == that.id;
            };

            that.loseFocus = function() {
                Ox.Focus.blur(that.id);
                return that;
            };

            that.options = function() { // fixme: use Ox.getset
                /*
                    that.options()                  returns self.options
                    that.options('foo')             returns self.options.foo
                    that.options('foo', x)          sets self.options.foo,
                                                    returns that
                    that.options({foo: x, bar: y})  sets self.options.foo
                                                    and self.options.bar,
                                                    returns that
                */
                var length = arguments.length,
                    id = self.options && self.options.id,
                    args, ret;
                if (length == 0) {
                    // options()
                    ret = self.options || options; // this is silly. make sure self.options get populated with options
                } else if (length == 1 && typeof arguments[0] == 'string') {
                    // options(str)
                    ret = self.options ? self.options[arguments[0]] : options[arguments[0]];
                } else {
                    // options (str, val) or options({str: val, ...})
                    // translate (str, val) to ({str: val})
                    args = Ox.makeObject.apply(that, arguments);
                    /*
                    options = self.options;
                    */
                    // if options have not been set, extend defaults,
                    // otherwise, extend options
                    self.options = $.extend(self.options || self.defaults, args);
                    $.each(args, function(key, value) {
                        key == 'id' && id && Ox.Event.changeId(id, value);
                        self.onChange(key, value);
                        /*
                        fixme: why does this not work?
                        Ox.print('options', options, key, value)
                        //Ox.print(!options, !options || !options[key], !options || !options[key] || options[key] !== value)
                        if (!options || !options[key] || options[key] !== value) {
                            Ox.print('onChange...')
                            self.onChange(key, value);
                        } else {
                            Ox.print('NO CHANGE');
                        }
                        */
                    });
                    ret = that;
                }
                return ret;
            };

            that.remove = function() {
                that.$element.remove();
                delete $elements[that.ox];
                return that;
            };

            that.removeEvent = function() {
                /*
                removes a keyboard event
                removeEvent(event, fn) or removeEvent({event0: fn0, event1: fn1, ...})
                */
                if (arguments.length == 1) {
                    $.each(arguments[0], function(event, fn) {
                        Ox.Event.remove(self.options.id, event, fn);
                    });
                } else {
                    Ox.Event.remove(self.options.id, arguments[0], arguments[1]);
                }
                return that;
            };

            that.triggerEvent = function() {
                /*
                triggers an event
                triggerEvent(event) or triggerEvent(event, data) or triggerEvent({event0: data, event1: data, ...})
                */
                if (Ox.isObject(arguments[0])) {
                    $.each(arguments[0], function(event, data) {
                        Ox.Event.trigger(self.options.id, event, data);
                    });
                } else {
                    Ox.Event.trigger(self.options.id, arguments[0], arguments[1] || {});
                }
                return that;
            };

            that.unbindEvent = function() {
                /*
                unbinds an event
                unbindEvent(event, fn) or unbindEvent({event0: fn0, event1: fn1, ...})
                */
                if (arguments.length == 1) {
                    $.each(arguments[0], function(event, fn) {
                        Ox.Event.unbind(self.options.id, event, fn);
                    });
                } else {
                    Ox.Event.unbind(self.options.id, arguments[0], arguments[1]);
                }
                return that;
            };

            // return
            return that;

        }

    }();

    Ox._Element = function(element) {
        var that = this;
        that.def = {};
        that.opt = {};
        that.ox = Ox.version;
        that.id = Ox.uid();
        //Ox.print('that.id', that.id)
        that.$element = $('<' + (element || 'div') + '/>')
            //.addClass('OxElement')
            .data('ox', that.id);
        oxui.elements[that.id] = that;
        // Ox.print('oxui.elements', oxui.elements)
        //function setOption() {};
        that.setOption = function() {};
        /*
        */
        that.destroy = function() {
            that.$element.remove();
            delete oxui.elements[that.ox];
        }
        /*
        */
        that.disable = function() {
            
        }
        /*
        */
        that.enable = function() {
            
        }
        /*
        */
        ///*
        that.defaults = function() {
            var length = arguments.length,
                ret;
            if (length == 0) {
                ret = that.def
            } else if (length == 1 && typeof arguments[0] == 'string') {
                ret = that.def[arguments[0]];
            } else {
                // translate ('key', 'value') to {'key': 'value'}
                that.def = $.extend(
                    that.def, Ox.makeObject.apply(that, arguments)
                );
                ret = that;
            }
            return ret;
        }
        //*/
        /*
        Ox.Element.options()
            get options
        Ox.Element.options('foo')
            get options.foo
        Ox.Element.options('foo', 0)
            set options.foo
        Ox.Element.options({foo: 0, bar: 1})
            set options.foo and options.bar
        */
        ///*
        that.options = function() {
            var length = arguments.length,
                args, ret;
            if (length == 0) {
                //Ox.print('getting all options', options);
                ret = that.opt;
            } else if (length == 1 && typeof arguments[0] == 'string') {
                //Ox.print('getting one option', options, arguments[0], options[arguments[0]]);
                ret = that.opt[arguments[0]];
            } else {
                // translate ('key', 'value') to {'key': 'value'}
                args = Ox.makeObject.apply(that, arguments);
                // if options have been set then extend options,
                // otherwise extend defaults
                that.opt = $.extend(Ox.length(that.opt) ?
                    that.opt : that.def, args);
                // that.trigger('OxElement' + that.id + 'SetOptions', args);
                $.each(args, function(k, v) {
                    that.setOption(k, v);
                    //Ox.print('triggering', 'OxElement' + that.id + 'SetOption', {k: v})
                    //that.trigger('OxElement' + that.id + 'SetOption', {k: v});
                })
                ret = that;
            }
            return ret;
        }
        // should become self.publish
        that.publish = function(event, data) {
            Ox.Event.publish(event + that.id, data);
            return that;
        }
        that.subscribe = function(event, callback) {
            Ox.Event.subscribe(event, callback);
            return that;
        }
        //that.setOptions = function() {};
        //*/
        // wrap jquery functions
        // so we can do oxObj.jqFn()
        $.each(oxui.jqueryFunctions, function(i, v) {
            that[v] = function() {
                var args = arguments,
                    length = args.length,
                    $element, id, ret;
                $.each(args, function(i, v) {
                    // if an oxui object was passed
                    // then pass its $element instead
                    // so we can do jqObj.append(oxObj)
                    if (v.ox) {
                        args[i] = v.$element;
                    }
                });
                if (v == 'html' && that.$content) {
                    $element = that.$content;
                } else {
                    $element = that.$element;
                }
                // why does this not work?
                // ret = that.$element[v].apply(this, arguments);
                // maybe because we pass this, and not that.$element[v] ... ?
                // ret = that.$element[v].apply(that.$element[v], arguments);
                // doesn't work either ...
                if (length == 0) {
                    ret = $element[v]();
                } else if (length == 1) {
                    ret = $element[v](args[0]);
                } else if (length == 2) {
                    ret = $element[v](args[0], args[1]);
                } else if (length == 3) {
                    ret = $element[v](args[0], args[1], args[2]);
                } else if (length == 4) {
                    ret = $element[v](args[0], args[1], args[2], args[3]);
                }
                // if the $element of an oxui object was returned
                // then return the oxui object instead
                // so we can do oxObj.jqFn().oxFn()
                //Ox.print('v', v, 'arguments', arguments)
                if (ret.jquery) {
                    //Ox.print('ret', ret, 'ret.data('id')', ret.data('ox'))
                }
                return ret.jquery && oxui.elements[id = ret.data('ox')] ?
                    oxui.elements[id] : ret;
            }
        });
        return that;
    };

    /*
    ----------------------------------------------------------------------------
    Ox.Window
    ----------------------------------------------------------------------------
    */

    Ox.Window = function(options, self) {

        self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    draggable: true,
                    fullscreenable: true, // fixme: silly name
                    height: 225,
                    resizeable: true,
                    scaleable: true,
                    width: 400
                })
                .options(options || {})

        self.center = function() {
            
        };

        self.drag = function() {
            
        };

        self.fullscreen = function() {
            
        };

        self.onChange = function() {
            
        };

        self.reset = function() {
            
        };

        self.resize = function() {
            
        };

        self.scale = function() {
            
        };

        that.close = function() {
            
        };

        that.open = function() {
            
        };

        return that;

    };

    /*
    ----------------------------------------------------------------------------
    Ox.theme()
        get theme
    Ox.theme('foo')
        set theme to 'foo'
    ----------------------------------------------------------------------------
    */

    // fixme: this should be Ox.Theme, and provide Ox.Theme.set(), Ox.Theme.load, etc.
    Ox.theme = function() {
        var length = arguments.length,
            classes = $body.attr('class').split(' '),
            arg, theme;
        $.each(classes, function(i, v) {
            if (Ox.startsWith(v, 'OxTheme')) {
                theme = v.replace('OxTheme', '').toLowerCase();
                if (length == 1) {
                    $body.removeClass(v);
                }
                return false;
            }
        });
        if (length == 1) {
            arg = arguments[0]
            $body.addClass('OxTheme' + Ox.toTitleCase(arg));
            if (theme) {
                $('input[type=image]').each(function() {
                    var $this = $(this);
                    $this.attr({
                        src: $this.attr('src').replace(
                            '/ox.ui.' + theme + '/', '/ox.ui.' + arg + '/'
                        )
                    });
                });
                $('.OxLoadingIcon').each(function() {
                    var $this = $(this);
                    $this.attr({
                        src: $this.attr('src').replace(
                            '/ox.ui.' + theme + '/', '/ox.ui.' + arg + '/'
                        )
                    });
                })
            }
        }
        return theme;
    };

    /*
    ============================================================================
    Bars
    ============================================================================
    */

    Ox.Bar = function(options, self) {
        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    orientation: 'horizontal',
                    size: 'medium'                  // can be int
                })
                .options(options || {})
                .addClass('OxBar Ox' + Ox.toTitleCase(self.options.orientation)),
            dimensions = oxui.getDimensions(self.options.orientation);
        self.options.size = Ox.isString(self.options.size) ?
                oxui.getBarSize(self.options.size) : self.options.size;
        that.css(dimensions[0], '100%')
            .css(dimensions[1], self.options.size + 'px');
        return that;
    };

    /*
    ----------------------------------------------------------------------------
    Ox.Resizebar
    ----------------------------------------------------------------------------
    */

    Ox.Resizebar = function(options, self) {

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    collapsed: false,
                    collapsible: true,
                    edge: 'left',
                    elements: [],
                    orientation: 'horizontal',
                    parent: null,
                    resizable: true,
                    resize: [],
                    size: 0
                })
                .options(options || {}) // fixme: options function should be able to handle undefined, no need for || {}
                .addClass('OxResizebar Ox' + Ox.toTitleCase(self.options.orientation))
                /*
                .attr({
                    draggable: 'true'
                })
                .bind('dragstart', function(e) {
                    // e.originalEvent.dataTransfer.setDragImage($('<div>')[0], 0, 0);
                })
                .bind('drag', function(e) {
                    Ox.print('dragging', e)
                })
                */
                .mousedown(dragStart)
                .dblclick(toggle)
                .append($('<div>').addClass('OxSpace'))
                .append($('<div>').addClass('OxLine'))
                .append($('<div>').addClass('OxSpace'));

        $.extend(self, {
            clientXY: self.options.orientation == 'horizontal' ? 'clientY' : 'clientX',
            dimensions: oxui.getDimensions(self.options.orientation), // fixme: should orientation be the opposite orientation here?
            edges: oxui.getEdges(self.options.orientation),
            ids: $.map(self.options.elements, function(v, i) {
                return v.options('id');
            }),
            length: self.options.resize.length,
            startPos: 0,
            startSize: 0
        });

        function drag(e) {
            var d = e[self.clientXY] - self.startPos, i;
            self.options.size = Ox.limit(self.startSize + d, self.options.resize[0], self.options.resize[self.length - 1]);
            Ox.print('sS', self.startSize, 'd', d, 's.o.s', self.options.size)
            $.each(self.options.resize, function(i, v) {
                if (self.options.size > v - 8 && self.options.size < v + 8) {
                    self.options.size = v;
                    return false;
                }
            });
            that.css(self.edges[2], self.options.size + 'px');
            self.options.elements[0].css(self.dimensions[1], self.options.size + 'px');
            self.options.elements[1].css(self.edges[2], (self.options.size + 1) + 'px');
            Ox.Event.trigger(self.ids[0], 'resize', self.options.size);
            Ox.Event.trigger(self.ids[1], 'resize', self.options.elements[1][self.dimensions[1]]());
        }

        function dragStart(e) {
            self.startPos = e[self.clientXY];
            self.startSize = self.options.size;
            Ox.print('startSize', self.startSize)
            $window.mousemove(drag);
            $window.one('mouseup', dragStop);
        }

        function dragStop() {
            $window.unbind('mousemove');
        }

        function toggle() {
            Ox.print('toggle');
            if (Ox.isUndefined(self.options.position)) {
                self.options.position = parseInt(self.options.parent.css(self.options.edge)) +
                        (self.options.collapsed  ? self.options.size : 0);
            }
            var size = self.options.position -
                        (self.options.collapsed ? 0 : self.options.size),
                animate = {};
            Ox.print('s.o.e', self.options.edge);
            animate[self.options.edge] = size;
            self.options.parent.animate(animate, 200, function() {
                var i = (self.options.edge == 'left' || self.options.edge == 'top') ? 1 : 0;
                Ox.Event.trigger(self.ids[i], 'resize', self.options.elements[i][self.dimensions[1]]());
                self.options.collapsed = !self.options.collapsed;
            });
        }

        return that;

    };

    /*
    ----------------------------------------------------------------------------
    Ox.Tabbar
    ----------------------------------------------------------------------------
    */

    Ox.Tabbar = function(options, self) {

        var self = self || {},
            that = new Ox.Bar({
                    size: 20
                }, self)
                .defaults({
                    selected: 0,
                    tabs: []
                })
                .options(options || {})
                .addClass('OxTabbar');

        Ox.ButtonGroup({
            buttons: self.options.tabs,
            group: true,
            selectable: true,
            selected: self.options.selected,
            size: 'medium',
            style: 'tab',
        }).appendTo(that);

        return that;

    };

    // fixme: no need for this
    Ox.Toolbar = function(options, self) {
        var self = self || {},
            that = new Ox.Bar({
                size: oxui.getBarSize(options.size)
            }, self);
        return that;
    };

    /*
    ============================================================================
    Ox.Dialog
    ============================================================================
    */

    Ox.Dialog = function(options, self) {

        // fixme: dialog should be derived from a generic draggable
        // fixme: pass button elements directly
        // fixme: buttons should have a close attribute, or the dialog a close id
        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    title: '',
                    buttons: [],
                    height: 216,
                    minHeight: 144,
                    minWidth: 256,
                    padding: 16,
                    width: 384
                })
                .options(options || {})
                .addClass('OxDialog')
                .addEvent({
                    key_escape: function() {
                        that.close();
                    }
                });

        if (!Ox.isArray(self.options.buttons[0])) {
            self.options.buttons = [[], self.options.buttons];
        }

        that.$titlebar = new Ox.Bar({
                size: 'medium'
            })
            .addClass('OxTitleBar')
            .mousedown(drag)
            .dblclick(center)
            .appendTo(that);

        that.$title = new Ox.Element()
            .addClass('OxTitle')
            .html(self.options.title)
            .appendTo(that.$titlebar);

        // fixme: should the following be a container?
        that.$content = new Ox.Element()
            .addClass('OxContent')
            .css({
                padding: self.options.padding + 'px',
                overflow: 'auto'
            })
            .appendTo(that);

        that.$buttonsbar = new Ox.Bar({})
            .addClass('OxButtonsBar')
            .appendTo(that);

        that.$buttons = [];
        $.each(self.options.buttons[0], function(i, button) {
            that.$buttons[i] = new Ox.Button({
                    disabled: button.disabled || false,
                    size: 'medium',
                    title: button.title
                })
                .addClass('OxLeft')
                .click(button.click) // fixme: rather use event?
                .appendTo(that.$buttonsbar);
        });

        that.$resize = new Ox.Element()
            .addClass('OxResize')
            .mousedown(resize)
            .dblclick(reset)
            .appendTo(that.$buttonsbar);

        $.each(self.options.buttons[1].reverse(), function(i, button) {
            that.$buttons[that.$buttons.length] = new Ox.Button({
                    disabled: button.disabled || false,
                    id: button.id,
                    size: 'medium',
                    title: button.title
                })
                .addClass('OxRight')
                .click(button.click) // fixme: rather use event?
                .appendTo(that.$buttonsbar);
        });

        that.$buttons[0].focus();

        that.$layer = new Ox.Element() // fixme: Layer widget that would handle click?
            .addClass('OxLayer')
            .mousedown(mousedownLayer)
            .mouseup(mouseupLayer);

        function center() {
            var documentHeight = $document.height();
            that.css({
                left: 0,
                top: Math.max(parseInt(-documentHeight / 10), self.options.height - documentHeight + 40) + 'px',
                right: 0,
                bottom: 0,
                margin: 'auto'
            });
        }

        function drag(event) {
            var bodyWidth = $body.width(),
                bodyHeight = $document.height(),
                elementWidth = that.width(),
                offset = that.offset(),
                x = event.clientX,
                y = event.clientY;
            $window.mousemove(function(event) {
                that.css({
                    margin: 0
                });
                var left = Ox.limit(
                        offset.left - x + event.clientX,
                        24 - elementWidth, bodyWidth - 24
                        //0, documentWidth - elementWidth
                    ),
                    top = Ox.limit(
                        offset.top - y + event.clientY,
                        24, bodyHeight - 24
                        //24, documentHeight - elementHeight
                    );
                that.css({
                    left: left + 'px',
                    top: top + 'px'
                });
            });
            $window.one('mouseup', function() {
                $window.unbind('mousemove');
            });
        }

        function getButtonById(id) {
            var ret = null
            $.each(that.$buttons, function(i, button) {
                if (button.options('id') == id) {
                    ret = button;
                    return false;
                }
            });
            return ret;
        }

        function mousedownLayer() {
            that.$layer.stop().animate({
                opacity: 0.5
            }, 0);
        }

        function mouseupLayer() {
            that.$layer.stop().animate({
                opacity: 0
            }, 0);
        }

        function reset() {
            that/*.css({
                    left: Math.max(that.offset().left, 24 - that.width())
                })*/
                .width(self.options.width)
                .height(self.options.height);
            that.$content.height(self.options.height - 48 - 2 * self.options.padding); // fixme: this should happen automatically
        }

        function resize(event) {
            var documentWidth = $document.width(),
                documentHeight = $document.height(),
                elementWidth = that.width(),
                elementHeight = that.height(),
                offset = that.offset(),
                x = event.clientX,
                y = event.clientY;
            $window.mousemove(function(event) {
                that.css({
                    left: offset.left,
                    top: offset.top,
                    margin: 0
                });
                var width = Ox.limit(
                        elementWidth - x + event.clientX,
                        self.options.minWidth, Math.min(documentWidth, documentWidth - offset.left)
                    ),
                    height = Ox.limit(
                        elementHeight - y + event.clientY,
                        self.options.minHeight, documentHeight - offset.top
                    );
                that.width(width);
                that.height(height);
                that.$content.height(height - 48 - 2 * self.options.padding); // fixme: this should happen automatically
            });
            $window.one('mouseup', function() {
                $window.unbind('mousemove');
            });
        }

        self.onChange = function(key, value) {
            if (key == 'height' || key == 'width') {
                that.animate({
                    height: self.options.height + 'px',
                    width: self.options.width + 'px'
                }, 250);
                that.$content.height(self.options.height - 48 - 2 * self.options.padding); // fixme: this should happen automatically
            } else if (key == 'title') {
                that.$title.animate({
                    opacity: 0
                }, 250, function() {
                    that.$title.html(value).animate({
                        opacity: 1
                    }, 250);
                });
            }
        }

        that.append = function($element) {
            that.$content.append($element);
            return that;
        }

        that.close = function(callback) {
            callback = callback || function() {};
            that.animate({
                opacity: 0
            }, 200, function() {
                that.remove();
                that.$layer.remove();
                callback();
            });
            $window.unbind('mouseup', mouseupLayer)
            return that;
        }

        that.disable = function() {
            // to be used on submit of form, like login
            that.$layer.addClass('OxFront');
            return that;
        };

        that.disableButton = function(id) {
            getButtonById(id).options({
                disabled: true
            });
            return that;
        };

        that.enable = function() {
            that.$layer.removeClass('OxFront');
            return that;
        };

        that.enableButton = function(id) {
            getButtonById(id).options({
                disabled: false
            });
            return that;
        };

        that.open = function() {
            that.$layer.appendTo($body);
            that.css({
                opacity: 0
            }).appendTo($body).animate({
                opacity: 1
            }, 200);
            center();
            reset();
            // fixme: reenable, but implement preview-style dialog
            //that.gainFocus();
            $window.bind('mouseup', mouseupLayer)
            return that;
        };

        return that;

    }

    /*
    ============================================================================
    Forms
    ============================================================================
    */

    /*
    ----------------------------------------------------------------------------
    Ox.Filter
    ----------------------------------------------------------------------------
    */

    Ox.Filter = function(options, self) {

        var self = self || {}
            that = new Ox.Element()
                .defaults({
                    
                })
                .options(options || {});

        return that;

    };

    /*
    ----------------------------------------------------------------------------
    Ox.Form
    ----------------------------------------------------------------------------
    */

    Ox.Form = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    error: '',
                    id: '',
                    items: [],
                    submit: null
                })
                .options(options || {}); // fixme: the || {} can be done once, in the options function

        $.extend(self, {
            $items: [],
            $messages: [],
            formIsValid: false,
            itemIds: [],
            itemIsValid: []
        });

        // fixme: form isn't necessarily empty/invalid
        $.map(self.options.items, function(item, i) {
            self.itemIds[i] = item.id || item.element.options('id');
            self.itemIsValid[i] = false;
        });

        $.each(self.options.items, function(i, item) {
            var id = item.element.options('id');
            that.append(self.$items[i] = new Ox.FormItem(item))
                .append(self.$messages[i] = new Ox.Element().addClass('OxFormMessage'));
            // fixme: use widget.bindEvent()
            Ox.Event.bind(id, 'validate', function(event, data) {
                validate(i, data.valid);
            });
            Ox.Event.bind(id, 'blur', function(event, data) {
                validate(i, data.valid);
                if (data.valid) {
                    self.$messages[i].html('').hide();
                } else {
                    self.$messages[i].html(data.message).show();
                }
            });
            Ox.Event.bind(id, 'submit', function(event, data) {
                self.formIsValid && that.submit();
            });
        });

        function getItemPositionById(id) {
            return self.itemIds.indexOf(id);
        }

        function setMessage(id, message) {
            self.$messages[getItemPositionById(id)].html(message)[message !== '' ? 'show' : 'hide']();
        }

        function submitCallback(data) {
            $.each(data, function(i, v) {
                setMessage(v.id, v.message);
            });
        }

        function validate(pos, valid) {
            Ox.print('validate', pos, valid)
            self.itemIsValid[pos] = valid;
            if (Ox.every(self.itemIsValid) != self.formIsValid) {
                self.formIsValid = !self.formIsValid;
                that.triggerEvent('validate', {
                    valid: self.formIsValid
                });
            }
        }

        that.submit = function() {
            self.options.submit(that.values(), submitCallback);
        };

        that.values = function() { // fixme: can this be private?
            var values = {};
            if (arguments.length == 0) {
                $.each(self.$items, function(i, $item) {
                    values[self.itemIds[i]] = self.$items[i].value();
                });
                return values;
            } else {
                $.each(arguments[0], function(key, value) {
                    
                });
                return that;
            }
        };

        return that;

    };

    Ox.FormItem = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    element: null,
                    error: '',
                })
                .options(options || {})
                .addClass('OxFormItem')
                .append(self.options.element);

        that.value = function() {
            return self.options.element.$input.val();
        };

        return that;

    }

    /*
    ----------------------------------------------------------------------------
    Form Elements
    ----------------------------------------------------------------------------
    */

    Ox.Button = function(options, self) {
        /*
        events:
            click       non-selectable button was clicked
            deselect    selectable button was deselected
            select      selectable button was selected
        */
        var self = self || {},
            that = new Ox.Element('input', self)
                .defaults({
                    disabled: false,
                    group: false,
                    id: '',
                    overlap: 'none',
                    selectable: false,
                    selected: false,
                    size: 'medium',
                    // fixme: 'default' or ''?
                    style: 'default', // can be default, checkbox, symbol, or tab
                    title: '',
                    tooltip: '',
                    type: 'text',
                    width: 'auto'
                })
                .options(options || {})
                .attr({
                    disabled: self.options.disabled ? 'disabled' : '',
                    type: self.options.type == 'text' ? 'button' : 'image'
                })
                .addClass('OxButton Ox' + Ox.toTitleCase(self.options.size) +
                    (self.options.disabled ? ' OxDisabled': '') +
                    (self.options.selected ? ' OxSelected': '') +
                    (self.options.style != 'default' ? ' Ox' + Ox.toTitleCase(self.options.style) : '') +
                    (self.options.overlap != 'none' ? ' OxOverlap' + Ox.toTitleCase(self.options.overlap) : ''))
                .css(self.options.width == 'auto' ? {} : {
                    width: (self.options.width - 14) + 'px'
                })
                .mousedown(mousedown)
                .click(click);

        $.extend(self, Ox.isArray(self.options.title) ? {
            selectedTitle: Ox.setPropertyOnce(self.options.title, 'selected'),
            titles: self.options.title
        } : {
            selectedTitle: 0,
            titles: [{
                id: '',
                title: self.options.title
            }]
        });

        setTitle(self.titles[self.selectedTitle].title);

        if (self.options.tooltip) {
            self.tooltips = Ox.isArray(self.options.tooltip) ? self.options.tooltip : [self.options.tooltip];
            self.$tooltip = new Ox.Tooltip({
                    title: self.tooltips[self.selectedTitle]
                });
            that.mouseenter(mouseenter)
                .mouseleave(mouseleave);
        }

        function click() {
            var data = self.titles[self.selectedTitle];
            if (!self.options.selectable) {
                that.triggerEvent('click', data);
            } else {
                if (self.options.group) {
                    that.triggerEvent('select', data);
                } else {
                    that.toggleSelected();
                }
            }
            if (self.titles.length == 2) {
                that.toggleTitle();
            }
        }

        function mousedown(event) {
            if (self.options.type == 'image' && $.browser.safari) {
                // keep image from being draggable
                event.preventDefault();
            }
        }

        function mouseenter(event) {
            self.$tooltip.show(event.clientX, event.clientY);
        }

        function mouseleave(event) {
            self.$tooltip.hide();
        }

        function setTitle(title) {
            self.title = title;
            if (self.options.type == 'image') {
                that.attr({
                    src: oxui.path + 'png/ox.ui.' + Ox.theme() +
                        '/symbol' + Ox.toTitleCase(title) + '.png'
                });
            } else {
                that.val(title);
            }
        }

        self.onChange = function(key, value) {
            if (key == 'disabled') {
                that.attr({
                        disabled: value ? 'disabled' : ''
                    })
                    .toggleClass('OxDisabled');
            } else if (key == 'selected') {
                if (value != that.hasClass('OxSelected')) { // fixme: neccessary?
                    that.toggleClass('OxSelected');
                }
                that.triggerEvent('change');
            } else if (key == 'title') {
                setTitle(value);
            } else if (key == 'width') {
                that.$element.css({
                    width: (value - 14) + 'px'
                });
            }
        }

        that.toggleDisabled = function() {
            that.options({
                enabled: !self.options.disabled
            });
        }

        that.toggleSelected = function() {
            that.options({
                selected: !self.options.selected
            });
        }

        that.toggleTitle = function() {
            self.selectedTitle = 1 - self.selectedTitle;
            setTitle(self.titles[self.selectedTitle].title);
            self.$tooltip && self.$tooltip.options({
                title: self.tooltips[self.selectedTitle]
            });
        }

        return that;

    };

    Ox.ButtonGroup = function(options, self) {

        /*
        Ox.ButtonGroup
        options:
            buttons     array of buttons
            max         integer, maximum number of selected buttons, 0 for all
            min         integer, minimum number of selected buttons, 0 for none
            selectable  if true, buttons are selectable
            type        string, 'image' or 'text'
        events:
            change  {id, value}     selection within a group changed
        */

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    buttons: [],
                    max: 1,
                    min: 1,
                    selectable: false,
                    size: 'medium',
                    style: '',
                    type: 'text',
                })
                .options(options || {})
                .addClass('OxButtonGroup');

        if (self.options.selectable) {
            self.optionGroup = new Ox.OptionGroup(
                self.options.buttons,
                self.options.min,
                self.options.max,
                'selected'
            );
            self.options.buttons = self.optionGroup.init();
        }

        self.$buttons = [];
        $.each(self.options.buttons, function(position, button) {
            var id = self.options.id + Ox.toTitleCase(button.id)
            self.$buttons[position] = Ox.Button({
                    disabled: button.disabled,
                    group: true,
                    id: id,
                    selectable: self.options.selectable,
                    selected: button.selected,
                    size: self.options.size,
                    style: self.options.style,
                    title: button.title,
                    type: self.options.type
                })
                .bindEvent('select', function() {
                    selectButton(position);
                })
                .appendTo(that);
        });

        function selectButton(pos) {
            var toggled = self.optionGroup.toggle(pos);
            if (toggled.length) {
                $.each(toggled, function(i, pos) {
                    self.$buttons[pos].toggleSelected();
                });
                that.triggerEvent('change', {
                    selected: $.map(self.optionGroup.selected(), function(v, i) {
                        return self.options.buttons[v].id;
                    })
                });
            }
        }

        return that;
    };

    Ox.Checkbox = function(options, self) {

        /***
        Ox.Checkbox
            Checkbox Form Element
        Options:
            disabled        boolean, if true, checkbox is disabled
            id              element id
            group           boolean, if true, checkbox is part of a group
            checked         boolean, if true, checkbox is checked
            title           string, text on label
            width           integer, width in px
        Methods:
            toggleChecked   function()
                            toggles checked property
                            returns that
        Events:
            change          triggered when checked property changes
                            passes {checked, id, title}
        ***/

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    disabled: false,
                    id: '',
                    group: false,
                    checked: false,
                    title: '',
                    width: 'auto'
                })
                .options(options || {})
                .addClass('OxCheckbox')
                .attr(self.options.disabled ? {
                    disabled: 'disabled'
                } : {});

        if (self.options.title) {
            self.options.width != 'auto' && that.css({
                    width: self.options.width + 'px'
                });
            self.$title = new Ox.Label({
                    disabled: self.options.disabled,
                    id: self.options.id + 'Label',
                    overlap: 'left',
                    title: self.options.title,
                    width: self.options.width - 16
                })
                .css({
                    float: 'right'
                })
                .click(clickTitle)
                .appendTo(that);
        }

        self.$button = new Ox.Button({
                disabled: self.options.disabled,
                id: self.options.id + 'Button',
                title: [
                    {id: 'none', title: 'none', selected: !self.options.checked},
                    {id: 'check', title: 'check', selected: self.options.checked}
                ],
                type: 'image'
            })
            .addClass('OxCheckbox')
            .click(clickButton)
            .appendTo(that);

        function clickButton() {
            self.options.checked = !self.options.checked;
            // click will have toggled the button,
            // if it is part of a group, we have to revert that
            self.options.group && that.toggleChecked();
            that.triggerEvent('change', {
                checked: self.options.checked,
                id: self.options.id,
                title: self.options.title
            });
        }

        function clickTitle() {
            !self.options.disabled && self.$button.trigger('click');
        }

        self.onChange = function(key, value) {
            if (key == 'checked') {
                that.toggleChecked();
            }
        };

        that.toggleChecked = function() {
            self.$button.toggleTitle();
            return that;
        }

        return that;

    };

    Ox.CheckboxGroup = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    checkboxes: [],
                    max: 1,
                    min: 1,
                    width: 256
                })
                .options(options || {})
                .addClass('OxCheckboxGroup');

        self.optionGroup = new Ox.OptionGroup(
            self.options.checkboxes,
            self.options.min,
            self.options.max);
        self.options.checkboxes = self.optionGroup.init();

        $.extend(self, {
            $checkboxes: [],
            checkboxWidth: $.map(Ox.divideInt(
                self.options.width + (self.options.checkboxes.length - 1) * 6,
                self.options.checkboxes.length
            ), function(v, i) {
                return v + (i < self.options.checkboxes.length - 1 ? 10 : 0);
            })
        });
        $.each(self.options.checkboxes, function(position, checkbox) {
            var id = self.options.id + Ox.toTitleCase(checkbox.id)
            self.$checkboxes[position] = new Ox.Checkbox($.extend(checkbox, {
                    group: true,
                    id: id,
                    width: self.checkboxWidth[position]
                }))
                .bindEvent('change', function() {
                    change(position);
                })
                .appendTo(that);
        });

        function change(pos) {
            var toggled = self.optionGroup.toggle(pos);
            //Ox.print('change', pos, 'toggled', toggled)
            if (toggled.length) {
                $.each(toggled, function(i, pos) {
                    self.$checkboxes[pos].toggleChecked();
                });
                that.triggerEvent('change', {
                    checked: $.map(self.optionGroup.checked(), function(v, i) {
                        return self.options.checkboxes[v].id;
                    })
                });
            }
        }

        return that;

    };

    Ox.Input = function(options, self) {

        /*
        options:
            arrows                      boolearn, if true, and type is 'float' or 'integer', display arrows
            arrowStep                   number, step when clicking arrows
            autocomplete                array of possible values, or
                                        function(key, value, callback), returns one or more values
            autocompleteReplace         boolean, if true, value is replaced
            autocompleteReplaceCorrect  boolean, if true, only valid values can be entered
            autocompleteSelect          boolean, if true, menu is displayed
            autocompleteSelectHighlight boolean, if true, value in menu is highlighted
            autocompleteSelectSubmit    boolean, if true, submit input on menu selection
            autocorrect                 string ('email', 'float', 'integer', 'phone', 'url'), or
                                        regexp(value), or
                                        function(key, value, blur, callback), returns value
            auto validate               --remote validation--
            clear                       boolean, if true, has clear button
            disabled                    boolean, if true, is disabled
            height                      integer, px (for type='textarea' and type='range' with orientation='horizontal')
            id                          string, element id
            key                         string, to be passed to autocomplete and autovalidate functions
            max                         number, max value if type is 'integer' or 'float'
            min                         number, min value if type is 'integer' or 'float'
            name                        string, will be displayed by autovalidate function ('invalid ' + name)
            overlap                     string, '', 'left' or 'right', will cause padding and negative margin
            picker
            //rangeOptions
                arrows              boolean, if true, display arrows
                //arrowStep           number, step when clicking arrows
                //arrowSymbols        array of two strings
                max                 number, maximum value
                min                 number, minimum value
                orientation         'horizontal' or 'vertical'
                step                number, step
                thumbValue          boolean, if true, value is displayed on thumb, or
                                    array of strings per value, or
                                    function(value), returns string
                thumbSize           integer, px
                trackGradient       string, css gradient for track
                trackImage          string, image url, or
                                    array of image urls
                //trackStep           number, 0 for 'scroll here', positive for step
                trackValues         boolean
            serialize
            textAlign                   'left', 'center' or 'right'
            type                        'float', 'integer', 'password', 'text'
            value                       string
            width                       integer, px
        methods:
        events:
            change
            submit
        */

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    arrows: false,
                    arrowStep: 1,
                    autocomplete: null,
                    autocompleteReplace: false,
                    autocompleteReplaceCorrect: false,
                    autocompleteSelect: false,
                    autocompleteSelectHighlight: false,
                    autocompleteSelectSubmit: false,
                    autovalidate: null,
                    clear: false,
                    key: '',
                    min: 0,
                    max: 100,
                    label: '',
                    labelWidth: 64,
                    overlap: 'none',
                    placeholder: '',
                    serialize: null,
                    textAlign: 'left',
                    type: 'text',
                    value: '',
                    width: 128
                })
                .options(options)
                .addClass('OxInput OxMedium')
                .addEvent($.extend(self.options.type == 'textarea' ? {} : {
                    key_enter: submit
                }, {
                    key_escape: cancel
                }));

        if (
            Ox.isArray(self.options.autocomplete) &&
            self.options.autocompleteReplace &&
            self.options.autocompleteReplaceCorrect &&
            self.options.value === ''
        ) {
            self.options.value = self.options.autocomplete[0]
        }

        // fixme: set to min, not 0
        if (self.options.type == 'float') {
            $.extend(self.options, {
                autovalidate: 'float',
                textAlign: 'right',
                value: self.options.value || '0.0'
            });
        } else if (self.options.type == 'integer') {
            $.extend(self.options, {
                autovalidate: 'integer',
                textAlign: 'right',
                value: self.options.value || '0'
            });
        }

        if (self.options.label) {
            self.$label = new Ox.Label({
                overlap: 'right',
                textAlign: 'right',
                title: self.options.label,
                width: self.options.labelWidth
            })
            .css({
                float: 'left', // fixme: use css rule
            })
            .click(function() {
                that.focus();
            })
            .appendTo(that);
        }

        if (self.options.arrows) {
            self.arrows = [];
            self.arrows[0] = [
                new Ox.Button({
                        overlap: 'right',
                        title: 'previous',
                        type: 'image'
                    })
                    .css({
                        float: 'left'
                    })
                    .click(function() {
                        clickArrow(0);
                    })
                    .appendTo(that),
                new Ox.Button({
                        overlap: 'left',
                        title: 'next',
                        type: 'image'
                    })
                    .css({
                        float: 'right'
                    })
                    .click(function() {
                        clickArrow(1);
                    })
                    .appendTo(that)
            ]
        }

        $.extend(self, {
            bindKeyboard: self.options.autocomplete || self.options.autovalidate,
            hasPasswordPlaceholder: self.options.type == 'password' && self.options.placeholder,
            inputWidth: getInputWidth()
        });

        if (self.options.clear) {
            self.$button = new Ox.Button({
                    overlap: 'left',
                    title: 'clear',
                    type: 'image'
                })
                .css({
                    float: 'right' // fixme: use css rule
                })
                .click(clear)
                .appendTo(that);
        }

        self.$input = $('<input>')
            .addClass('OxInput OxMedium')
            .attr({
                disabled: self.options.disabled ? 'disabled' : '',
                type: self.options.type == 'password' ? 'password' : 'text'
            })
            .css({
                width: self.inputWidth + 'px',
                textAlign: self.options.textAlign
            })
            .val(self.options.value)
            .blur(blur)
            .change(change)
            .focus(focus)
            .appendTo(that.$element);

        if (self.hasPasswordPlaceholder) {
            self.$input.hide();
            self.$placeholder = $('<input>')
                .addClass('OxInput OxMedium OxPlaceholder')
                .attr({
                    type: 'text'
                })
                .css({
                    //float: 'left',
                    width: self.inputWidth + 'px'
                })
                .val(self.options.placeholder)
                .focus(focus)
                .appendTo(that.$element);
        }

        if (self.options.autocomplete && self.options.autocompleteSelect) {
            self.$autocompleteMenu = new Ox.Menu({
                    element: self.$input,
                    id: self.options.id + 'Menu', // fixme: we do this in other places ... are we doing it the same way? var name?,
                    offset: {
                        left: 4,
                        top: 0
                    },
                    size: self.options.size
                })
                .bindEvent('click', clickMenu);
            if (self.options.autocompleteReplace) {
                self.$autocompleteMenu.bindEvent({
                    deselect: deselectMenu,
                    select: selectMenu,
                });
            }
        }

        self.options.placeholder && setPlaceholder();

        function autocomplete(oldValue, oldCursor) {

            if (self.options.value || self.options.autocompleteReplaceCorrect) {
                Ox.isFunction(self.options.autocomplete) ?
                    (self.options.key ? self.options.autocomplete(
                        self.options.key,
                        self.options.value,
                        autocompleteCallback
                    ) : self.options.autocomplete(
                        self.options.value,
                        autocompleteCallback
                    )) : autocompleteCallback(autocompleteFunction(self.options.value));
            }
            if (!self.options.value) {
                self.options.autocompleteSelect && self.$autocompleteMenu.hideMenu();
            }

            function autocompleteFunction() {
                var values = Ox.find(self.options.autocomplete, self.options.value);
                return self.options.autocompleteReplace ? values[0] :
                        $.merge(values[0], values[1]);
            }

            function autocompleteCallback(values) {

                Ox.print('autocompleteCallback', values[0], self.options.value, self.options.value.length, oldValue, oldCursor)

                var length = self.options.value.length,
                    deleted = length <= oldValue.length - (oldCursor[1] - oldCursor[0]),
                    newValue = values[0] ?
                            ((self.options.autocompleteReplaceCorrect || !deleted) ?
                            values[0] : self.options.value) :
                            (self.options.autocompleteReplaceCorrect ? oldValue : self.options.value),
                    newLength = newValue.length,
                    pos = cursor(),
                    selected = -1,
                    selectEnd = length == 0 || (values[0] && values[0].length),
                    value;

                Ox.print('selectEnd', selectEnd)

                if (self.options.autocompleteReplace) {
                    self.options.value = newValue;
                    self.$input.val(self.options.value);
                    if (selectEnd) {
                        cursor(length, newLength);
                    } else if (self.options.autocompleteReplaceCorrect) {
                        cursor(oldCursor);
                    } else {
                        cursor(pos);
                    }
                    selected = 0;
                }

                if (self.options.autocompleteSelect) {
                    value = self.options.value.toLowerCase();
                    if (values.length) {
                        self.oldCursor = cursor();
                        self.oldValue = self.options.value;
                        self.$autocompleteMenu.options({
                            items: $.map(values, function(v, i) {
                                if (value == v.toLowerCase()) {
                                    selected = i;
                                }
                                return {
                                    id: v.toLowerCase().replace(/ /g, '_'), // fixme: need function to do lowercase, underscores etc?
                                    title: self.options.autocompleteSelectHighlight ? v.replace(
                                        new RegExp('(' + value + ')', 'ig'),
                                        '<span class="OxHighlight">$1</span>'
                                    ) : v
                                };
                            }),
                            selected: selected
                        }).showMenu();
                    } else {
                        self.$autocompleteMenu.hideMenu();
                    }
                }

                that.triggerEvent('autocomplete', {
                    value: newValue
                });

            }

        }

        function autovalidate() {

            var blur, oldCursor, oldValue;

            if (arguments.length == 1) {
                blur = arguments[0];
            } else {
                blur = false;
                oldValue = arguments[0];
                oldCursor = arguments[1];
            }

            Ox.isFunction(self.options.autovalidate) ?
                (self.options.key ? self.options.autovalidate(
                    self.options.key,
                    self.options.value,
                    blur,
                    autovalidateCallback
                ) : self.options.autovalidate(
                    self.options.value,
                    blur,
                    autovalidateCallback
                )) : Ox.isRegExp(self.options.autovalidate) ?
                    autovalidateCallback(autovalidateFunction(self.options.value)) :
                    autovalidateTypeFunction(self.options.type, self.options.value);

            function autovalidateFunction(value) {
                var regexp = new RegExp(self.options.autovalidate);
                return $.map(value.toLowerCase().split(''), function(v, i) {
                    if (regexp(v)) {
                        return v;
                    } else {
                        return null;
                    }
                }).join('');
            }

            function autovalidateTypeFunction(type, value) {
                var cursor,
                    regexp = type == 'float' ? /[\d\.]/ : /\d/;
                if (type == 'float') {
                    if (value.indexOf('.') != value.lastIndexOf('.')) {
                        value = oldValue;
                    } else {
                        if (self.autovalidateFloatFlag) {
                            if (Ox.endsWith(value, '.')) {
                                value = value.substr(0, value.length - 1);
                            }
                            self.autovalidateFloatFlag = false;
                        }
                        while (Ox.startsWith(value, '.')) {
                            if (Ox.startsWith(value, '..')) {
                                value = value.substr(1);
                            } else {
                                value = '0' + value;
                            }
                        }
                        if (Ox.endsWith(value, '.')) {
                            value += '0';
                            cursor = [value.length - 1, value.length];
                            self.autovalidateFloatFlag = true;
                        }
                    }
                }
                value = $.map(value.split(''), function(v, i) {
                    if (regexp(v)) {
                        return v;
                    } else {
                        return null;
                    }
                }).join('');
                if (type == 'integer') {
                    while (value.length > 1 && Ox.startsWith(value, '0')) {
                        value = value.substr(1);
                    }
                }
                if (value === '') {
                    value = type == 'float' ? '0.0' : '0';
                    cursor = [0, value.length];
                } else if (value > self.options.max) {
                    value = oldValue;
                }
                autovalidateCallback(value, cursor);
            }

            function autovalidateCallback(newValue, newCursor) {
                Ox.print('autovalidateCallback', newValue, oldCursor)
                self.options.value = newValue;
                self.$input.val(self.options.value);
                !blur && cursor(
                    newCursor || (oldCursor[1] + newValue.length - oldValue.length)
                );
                that.triggerEvent('autovalidate', {
                    value: self.options.value
                });
            }

        }

        /*

        function autovalidate(blur) {
            Ox.print('autovalidate', self.options.value, blur || false)
            self.autocorrectBlur = blur || false;
            self.autocorrectCursor = cursor();
            Ox.isFunction(self.options.autocorrect) ?
                (self.options.key ? self.options.autocorrect(
                    self.options.key,
                    self.options.value,
                    self.autocorrectBlur,
                    autocorrectCallback
                ) : self.options.autocorrect(
                    self.options.value,
                    self.autocorrectBlur,
                    autocorrectCallback
                )) : autocorrectCallback(autocorrect(self.options.value));
        }

        function autovalidateFunction(value) {
            var length = value.length;
            return $.map(value.toLowerCase().split(''), function(v, i) {
                if (new RegExp(self.options.autocorrect)(v)) {
                    return v;
                } else {
                    return null;
                }
            }).join('');
        }

        */

        function blur() {
            Ox.print('blur')
            that.loseFocus();
            //that.removeClass('OxFocus');
            self.options.value = self.$input.val();
            self.options.autovalidate && autovalidate(true);
            self.options.placeholder && setPlaceholder();
            if (self.bindKeyboard) {
                $document.unbind('keydown', keypress);
                $document.unbind('keypress', keypress);
            }
        }

        function cancel() {
            self.$input.blur();
        }

        function change() {
            self.options.value = self.$input.val();
            that.triggerEvent('change', {
                value: self.options.value
            });
        }

        function clear() {
            // fixme: set to min, not zero
            // fixme: make this work for password
            var value = '';
            if (self.options.type == 'float') {
                value = '0.0';
            } else if (self.options.type == 'integer') {
                value = '0'
            }
            self.$input.val(value);
            cursor(0, value.length);
        }

        function clickArrow(i) {
            self.options.value = Ox.limit(
                parseFloat(self.options.value) + (i == 0 ? -1 : 1) * self.options.arrowStep,
                self.options.min,
                self.options.max
            );
            self.$input.val(self.options.value);//.focus();
        }

        function clickMenu(event, data) {
            Ox.print('clickMenu', data);
            self.options.value = data.title;
            self.$input.val(self.options.value).focus();
            that.gainFocus();
            self.options.autocompleteSelectSubmit && submit();
        }

        function cursor(start, end) {
            /*
            cursor()                returns [start, end]
            cursor(start)           sets start
            cursor([start, end])    sets start and end
            cursor(start, end)      sets start and end
            */
            var isArray = Ox.isArray(start);
            if (arguments.length == 0) {
                return [self.$input[0].selectionStart, self.$input[0].selectionEnd];
            } else {
                end = isArray ? start[1] : (end ? end : start);
                start = isArray ? start[0] : start;
                self.$input[0].setSelectionRange(start, end);
            }
        }

        function deselectMenu() {
            self.options.value = self.oldValue;
            self.$input.val(self.options.value);
            cursor(self.oldCursor);
        }

        function focus() {
            Ox.print('focus()')
            if (
                that.hasClass('OxFocus') || // fixme: this is just a workaround, since for some reason, focus() gets called twice on focus
                (self.$autocompleteMenu && self.$autocompleteMenu.is(':visible')) ||
                (self.hasPasswordPlaceholder && self.$input.is(':visible'))
            ) {
                return;
            }
            that.gainFocus();
            self.options.placeholder && setPlaceholder();
            if (self.bindKeyboard) {
                Ox.print('binding...')
                // fixme: different in webkit and firefox (?), see keyboard handler, need generic function
                $document.keydown(keypress);
                $document.keypress(keypress);
                self.options.autocompleteSelect && setTimeout(autocomplete, 0); // fixme: why is the timeout needed?
            }
        }

        function getInputWidth() {
            return self.options.width - 14 -
                (self.options.arrows ? 32 : 0) -
                (self.options.clear ? 16 : 0) -
                (self.options.label ? self.options.labelWidth : 0);
        }

        function keypress(event) {
            var oldCursor = cursor(),
                oldValue = self.options.value,
                newValue = oldValue.substr(0, oldCursor[0] - 1),
                hasDeletedSelectedEnd = (event.keyCode == 8 || event.keyCode == 46) &&
                    oldCursor[0] < oldCursor[1] && oldCursor[1] == oldValue.length;
            Ox.print('keypress', event.keyCode)
            if (event.keyCode != 9 && event.keyCode != 13 && event.keyCode != 27) { // fixme: can't 13 and 27 return false?
                setTimeout(function() { // wait for val to be set
                    var value = self.$input.val();
                    if (self.options.autocompleteReplaceCorrect && hasDeletedSelectedEnd) {
                        Ox.print(value, '->', newValue);
                        value = newValue; // value.substr(0, value.length - 1);
                        self.$input.val(value);
                    }
                    if (value != self.options.value) {
                        self.options.value = value;
                        self.options.autocomplete && autocomplete(oldValue, oldCursor);
                        self.options.autovalidate && autovalidate(oldValue, oldCursor);
                    }
                }, 0);
            }
            if ((event.keyCode == 38 || event.keyCode == 40) && self.options.autocompleteSelect && self.$autocompleteMenu.is(':visible')) {
                return false;
            }
        }

        function selectMenu(event, data) {
            var pos = cursor();
            Ox.print('selectMenu', pos)
            self.options.value = data.title
            self.$input.val(self.options.value);
            cursor(pos[0], self.options.value.length)
        }

        function setPlaceholder() {
            if (self.options.placeholder) {
                if (that.hasClass('OxFocus')) {
                    if (self.options.value === '') {
                        if (self.options.type == 'password') {
                            self.$placeholder.hide();
                            self.$input.show().focus();
                        } else {
                            self.$input
                                .removeClass('OxPlaceholder')
                                .val('');
                        }
                    }
                } else {
                    if (self.options.value === '') {
                        if (self.options.type == 'password') {
                            self.$input.hide();
                            self.$placeholder.show();
                        } else {
                            self.$input
                                .addClass('OxPlaceholder')
                                .val(self.options.placeholder)
                        }
                    } else {
                        self.$input
                            .removeClass('OxPlaceholder')
                            .val(self.options.value)
                    }
                }
            }
        }

        function setWidth() {
            
        }

        function submit() {
            self.$input.blur();
            that.triggerEvent('submit', {
                value: self.options.value
            });
        }

        self.onChange = function(key, value) {
            var inputWidth, val;
            if (key == 'placeholder') {
                setPlaceholder();
            } else if (key == 'value') {
                val = self.$input.val();
                self.$input.val(value);
                setPlaceholder();
            } else if (key == 'width') {
                inputWidth = getInputWidth();
                self.$input.css({
                    width: inputWidth + 'px'
                });
                self.hasPasswordPlaceholder && self.$placeholder.css({
                    width: inputWidth + 'px'
                });
            }
        };

        that.focus = function() {
            self.$input.focus();
            cursor(0, self.$input.val().length);
        };

        return that;

    };

    Ox.AutocorrectIntFunction = function(min, max, pad, year) {
        var pad = pad || false,
            year = year || false,
            maxLength = max.toString().length,
            ret = null,
            values = [];
            $.each(Ox.range(min, max + 1), function(i, v) {
                values.push(v + '');
                pad && v.toString().length < maxLength && values.push(Ox.pad(v, maxLength));
            });
        return function(value, blur, callback) {
            var results;
            if (year && value == '1') {
                value = '1900';
            } else {
                results = Ox.find(values, value);
                value = results[0].length == 1 && results[0][0].length < maxLength ?
                    (pad ? Ox.pad(results[0][0], maxLength) : results[0][0]) :
                    (results[0].length ? results[0][0] : null);
            }
            callback(value);
        };
    };

    Ox.InputGroup = function(options, self) {

        /***
        Ox.InputGroup
        Options:
        Methods:
        Events:
        ***/

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    id: '',
                    inputs: [],
                    separators: [],
                    width: 0
                })
                .options(options || {})
                .addClass('OxInputGroup')
                .click(click);

        if (self.options.width) {
            setWidths();
        } else {
            self.options.width = getWidth();
        }
        that.css({
            width: self.options.width + 'px'
        });

        $.extend(self, {
            //$input: [],
            $separator: []
        });

        $.each(self.options.separators, function(i, v) {
            self.$separator[i] = new Ox.Label({
                textAlign: 'center',
                title: v.title,
                width: v.width + 32
            })
            .addClass('OxSeparator')
            .css({
                marginLeft: (self.options.inputs[i].options('width') - (i == 0 ? 16 : 32)) + 'px'
            })
            .appendTo(that);
        });

        $.each(self.options.inputs, function(i, $input) {
            $input.options({
                    id: self.options.id + Ox.toTitleCase($input.options('id')),
                    parent: that
                })
                .css({
                    marginLeft: -Ox.sum($.map(self.options.inputs, function(v_, i_) {
                        return i_ > i ? self.options.inputs[i_ - 1].options('width') +
                            self.options.separators[i_ - 1].width : (i_ == i ? 16 : 0);
                    })) + 'px'
                })
                .bindEvent({
                    change: change,
                    submit: change
                })
                .appendTo(that);
        });

        function change(event, data) {
            Ox.print('InputGroup change')
            // fixme: would be good to pass a value here
            that.triggerEvent('change');
        }

        function click(event) {
            if ($(event.target).hasClass('OxSeparator')) {
                self.options.inputs[0].focus();
            }
        }

        function getWidth() {
            return Ox.sum($.map(self.options.inputs, function(v, i) {
                return v.options('width');
            })) + Ox.sum($.map(self.options.separators, function(v, i) {
                return v.width;
            }));
        }

        function setWidths() {
            var length = self.options.inputs.length,
                inputWidths = Ox.divideInt(
                    self.options.width - Ox.sum($.map(self.options.separators, function(v, i) {
                        return v.width;
                    })), length
                );
            $.each(self.options.inputs, function(i, v) {
                v.options({
                    width: inputWidths[1]
                });
            });
        }

        // fixme: is this used?
        that.getInputById = function(id) {
            var input = null;
            $.each(self.options.inputs, function(i, v) {
                Ox.print(v, v.options('id'), id)
                if (v.options('id') == self.options.id + Ox.toTitleCase(id)) {
                    input = v;
                    return false;
                }
            });
            return input;
        };

        return that;

    };

    Ox.ColorInput = function(options, self) {

        var self = $.extend(self || {}, {
                options: $.extend({
                    id: '',
                    value: '0, 0, 0'
                }, options)
            }),
            that;

        self.values = self.options.value.split(', ');
        self.$inputs = [];
        $.each(['red', 'green', 'blue'], function(i, v) {
            self.$inputs[i] = new Ox.Input({
                id: v,
                max: 255,
                type: 'integer',
                value: self.values[i],
                width: 36
            })
            .bindEvent('autovalidate', change);
        });
        self.$inputs[3] = new Ox.Label({
                id: 'color',
                width: 36
            })
            .css({
                background: 'rgb(' + self.options.value + ')'
            });
        self.$inputs[4] = new Ox.ColorPicker({
                id: 'picker'
            })
            .bindEvent('change', function(event, data) {
                Ox.print('change function called');
                self.options.value = data.value;
                self.values = data.value.split(', ');
                $.each(Ox.range(3), function(i) {
                    self.$inputs[i].options({
                        value: self.values[i]
                    });
                });
            })
            .options({
                width: 16 // this is just a hack to make the InputGroup layout work
            });

        that = new Ox.InputGroup({
                id: self.options.id,
                inputs: self.$inputs,
                separators: [
                    {title: ',', width: 8},
                    {title: ',', width: 8},
                    {title: '', width: 8},
                    {title: '', width: 8}
                ],
                value: self.options.value // fixme: it'd be nicer if this would be taken care of by passing self
            }, self)
            .bindEvent('change', change);

        function change() {
            self.options.value = $.map(self.$inputs, function(v, i) {
                return v.options('value');
            }).join(', ');
            self.$inputs[3].css({
                background: 'rgb(' + self.options.value + ')'
            });
        }

        return that;

    };

    Ox.DateInput = function(options, self) {

        var self = $.extend(self || {}, {
                options: $.extend({
                    format: 'short',
                    value: Ox.formatDate(new Date(), '%F'),
                    weekday: false,
                    width: {
                        day: 32,
                        month: options.format == 'long' ? 80 : (options.format == 'medium' ? 40 : 32),
                        weekday: options.format == 'long' ? 80 : 40,
                        year: 48
                    }
                }, options)
            }),
            that;

        $.extend(self, {
            date: new Date(self.options.value.replace(/-/g, '/')),
            formats: {
                day: '%d',
                month: self.options.format == 'short' ? '%m' :
                        (self.options.format == 'medium' ? '%b' : '%B'),
                weekday: self.options.format == 'long' ? '%A' : '%a',
                year: '%Y'
            },
            months: self.options.format == 'long' ? Ox.MONTHS : $.map(Ox.MONTHS, function(v, i) {
                return v.substr(0, 3);
            }),
            weekdays: self.options.format == 'long' ? Ox.WEEKDAYS : $.map(Ox.WEEKDAYS, function(v, i) {
                return v.substr(0, 3);
            })
        });

        self.$input = $.extend(self.options.weekday ? {
            weekday: new Ox.Input({
                    autocomplete: self.weekdays,
                    autocompleteReplace: true,
                    autocompleteReplaceCorrect: true,
                    id: 'weekday',
                    value: Ox.formatDate(self.date, self.formats.weekday),
                    width: self.options.width.weekday
                })
                .bindEvent('autocomplete', changeWeekday),
        } : {}, {
            day: new Ox.Input({
                    autocomplete: $.map(Ox.range(1, Ox.getDaysInMonth(
                        parseInt(Ox.formatDate(self.date, '%Y'), 10),
                        parseInt(Ox.formatDate(self.date, '%m'), 10)
                    ) + 1), function(v, i) {
                        return self.options.format == 'short' ? Ox.pad(v, 2) : v.toString();
                    }),
                    autocompleteReplace: true,
                    autocompleteReplaceCorrect: true,
                    id: 'day',
                    value: Ox.formatDate(self.date, self.formats.day),
                    textAlign: 'right',
                    width: self.options.width.day
                })
                .bindEvent('autocomplete', changeDay),
            month: new Ox.Input({
                    autocomplete: self.options.format == 'short' ? $.map(Ox.range(1, 13), function(v, i) {
                        return Ox.pad(v, 2);
                    }) : self.months,
                    autocompleteReplace: true,
                    autocompleteReplaceCorrect: true,
                    id: 'month',
                    value: Ox.formatDate(self.date, self.formats.month),
                    textAlign: self.options.format == 'short' ? 'right' : 'left',
                    width: self.options.width.month
                })
                .bindEvent('autocomplete', changeMonthOrYear),
            year: new Ox.Input({
                    autocomplete: $.map($.merge(Ox.range(1900, 3000), Ox.range(1000, 1900)), function(v, i) {
                        return v.toString();
                    }),
                    autocompleteReplace: true,
                    autocompleteReplaceCorrect: true,
                    id: 'year',
                    value: Ox.formatDate(self.date, self.formats.year),
                    textAlign: 'right',
                    width: self.options.width.year
                })
                .bindEvent('autocomplete', changeMonthOrYear)
        });

        that = new Ox.InputGroup($.extend(self.options, {
            id: self.options.id,
            inputs: $.merge(self.options.weekday ? [
                self.$input.weekday
            ] : [], self.options.format == 'short' ? [
                self.$input.year, self.$input.month, self.$input.day
            ] : [
                self.$input.month, self.$input.day, self.$input.year
            ]),
            separators: $.merge(self.options.weekday ? [
                {title: self.options.format == 'short' ? '' : ',', width: 8},
            ] : [], self.options.format == 'short' ? [
                {title: '-', width: 8}, {title: '-', width: 8}
            ] : [
                {title: '', width: 8}, {title: ',', width: 8}
            ]),
            width: 0
        }), self);

        Ox.print('SELF', self)

        function changeDay() {
            self.options.weekday && self.$input.weekday.options({
                value: Ox.formatDate(new Date([
                    self.$input.month.options('value'),
                    self.$input.day.options('value'),
                    self.$input.year.options('value')
                ].join(' ')), self.formats.weekday)
            });
            setValue();
        }

        function changeMonthOrYear() {
            var day = self.$input.day.options('value'),
                month = self.$input.month.options('value'),
                year = self.$input.year.options('value'),
                days = Ox.getDaysInMonth(year, self.options.format == 'short' ? parseInt(month, 10) : month);
            day = day <= days ? day : days;
            Ox.print(year, month, 'day days', day, days)
            self.options.weekday && self.$input.weekday.options({
                value: Ox.formatDate(new Date([month, day, year].join(' ')), self.formats.weekday)
            });
            self.$input.day.options({
                autocomplete: $.map(Ox.range(1, days + 1), function(v, i) {
                    return self.options.format == 'short' ? Ox.pad(v, 2) : v.toString();
                }),
                value: self.options.format == 'short' ? Ox.pad(day, 2) : day.toString()
            });
            setValue();
        }

        function changeWeekday() {
            var date = getDateInWeek(
                    self.$input.weekday.options('value'),
                    self.$input.month.options('value'),
                    self.$input.day.options('value'),
                    self.$input.year.options('value')
                );
            self.$input.month.options({value: date.month});
            self.$input.day.options({
                autocomplete: $.map(Ox.range(1, Ox.getDaysInMonth(date.year, date.month) + 1), function(v, i) {
                        return self.options.format == 'short' ? Ox.pad(v, 2) : v.toString();
                    }),
                value: date.day
            });
            self.$input.year.options({value: date.year});
            setValue();
        }

        function getDateInWeek(weekday, month, day, year) {
            Ox.print([month, day, year].join(' '))
            var date = new Date([month, day, year].join(' '));
            date = Ox.getDateInWeek(date, weekday);
            return {
                day: Ox.formatDate(date, self.formats.day),
                month: Ox.formatDate(date, self.formats.month),
                year: Ox.formatDate(date, self.formats.year)
            };
        }

        function setValue() {
            self.options.value = Ox.formatDate(new Date(self.options.format == 'short' ? [
                self.$input.year.options('value'),
                self.$input.month.options('value'),
                self.$input.day.options('value')
            ].join('/') : [
                self.$input.month.options('value'),
                self.$input.day.options('value'),
                self.$input.year.options('value')
            ].join(' ')), '%F');
        }

        /*
        function normalize() {
            var year = that.getInputById('year').options('value'),
                month = that.getInputById('month').options('value'),
                day = that.getInputById('day').options('value')
            return {
                year: year,
                month: self.options.format == 'short' ? month :
                        Ox.pad((format == 'medium' ? Ox.WEEKDAYS.map(function(v, i) {
                            return v.substr(0, 3);
                        }) : Ox.WEEKDAYS).indexOf(month), 2),
                day: Ox.pad(day, 2)
            }
        }
        */

        /*
        that.serialize = function() {
            var normal = normalize();
            return [normal.year, normal.month, normal.day].join('-');
        }
        */

        return that;

    };

    Ox.DateTimeInput = function(options, self) {

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    ampm: false,
                    format: 'short',
                    seconds: false,
                    value: Ox.formatDate(new Date(), '%F %T'),
                    weekday: false
                })
                .options(options || {});

        self.values = self.options.value.split(' ');
        Ox.print(self.values)

        that = new Ox.InputGroup({
                inputs: [
                    new Ox.DateInput({
                        format: self.options.format,
                        id: 'date',
                        value: self.values[0],
                        weekday: self.options.weekday
                    }),
                    new Ox.TimeInput({
                        ampm: self.options.ampm,
                        id: 'time',
                        value: self.values[1],
                        seconds: self.options.seconds
                    })
                ],
                separators: [
                    {title: '', width: 8}
                ],
                value: self.options.value
            })
            .bindEvent('change', setValue);

        function setValue() {
            self.options.value = [
                self.options('inputs')[0].options('value'),
                self.options('inputs')[1].options('value')
            ].join(' ');
        }

        return that;

    };

    Ox.PlaceInput = function(options, self) {

        var self = $.extend(self || {}, {
                options: $.extend({
                    id: '',
                    value: 'United States'
                }, options)
            }),
            that;

        that = new Ox.FormElementGroup({
                id: self.options.id,
                elements: [
                    new Ox.Input({
                        id: 'input',
                        value: self.options.value
                    }),
                    new Ox.PlacePicker({
                        id: 'picker',
                        overlap: 'left',
                        value: self.options.value
                    })
                ],
                float: 'right'
            }, self)
            .bindEvent('change', change);

        function change() {
            
        }

        return that;

    };

    Ox.TimeInput = function(options, self) {

        // fixme: seconds get set even if options.seconds is false
        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    ampm: false,
                    seconds: false,
                    milliseconds: false,
                    value: Ox.formatDate(new Date(), '%T'),
                })
                .options(options || {});

        if (self.options.milliseconds) {
            self.options.seconds = true;
            if (self.options.value.indexOf('.') == -1) {
                self.options.value += '.000';
            }
        }

        self.date = getDate();
        self.values = getValues();

        self.$input = {
            hours: Ox.Input({
                autocomplete: $.map(self.options.ampm ? Ox.range(1, 13) : Ox.range(0, 24), function(v) {
                    return Ox.pad(v, 2);
                }),
                autocompleteReplace: true,
                autocompleteReplaceCorrect: true,
                id: 'hours',
                textAlign: 'right',
                value: self.values.hours,
                width: 32
            }),
            minutes: Ox.Input({
                autocomplete: $.map(Ox.range(0, 60), function(v) {
                    return Ox.pad(v, 2);
                }),
                autocompleteReplace: true,
                autocompleteReplaceCorrect: true,
                id: 'minutes',
                textAlign: 'right',
                value: self.values.minutes,
                width: 32
            }),
            seconds: Ox.Input({
                autocomplete: $.map(Ox.range(0, 60), function(v) {
                    return Ox.pad(v, 2);
                }),
                autocompleteReplace: true,
                autocompleteReplaceCorrect: true,
                id: 'seconds',
                textAlign: 'right',
                value: self.values.seconds,
                width: 32
            }),
            milliseconds: Ox.Input({
                autocomplete: $.map(Ox.range(0, 1000), function(v) {
                    return Ox.pad(v, 3);
                }),
                autocompleteReplace: true,
                autocompleteReplaceCorrect: true,
                id: 'milliseconds',
                textAlign: 'right',
                value: self.values.milliseconds,
                width: 40
            }),
            ampm: Ox.Input({
                autocomplete: ['AM', 'PM'],
                autocompleteReplace: true,
                autocompleteReplaceCorrect: true,
                id: 'ampm',
                value: self.values.ampm,
                width: 32
            })
        };

        that = new Ox.InputGroup($.extend(self.options, {
                inputs: $.merge($.merge($.merge([
                    self.$input.hours,
                    self.$input.minutes,
                ], self.options.seconds ? [
                    self.$input.seconds
                ] : []), self.options.milliseconds ? [
                    self.$input.milliseconds
                ] : []), self.options.ampm ? [
                    self.$input.ampm
                ] : []),
                separators: $.merge($.merge($.merge([
                    {title: ':', width: 8},
                ], self.options.seconds ? [
                    {title: ':', width: 8}
                ] : []), self.options.milliseconds ? [
                    {title: '.', width: 8}
                ] : []), self.options.ampm ? [
                    {title: '', width: 8}
                ] : []),
                //width: self.options.width || 128
            }), self)
            .bindEvent('change', setValue);

        setValue();

        function getDate() {
            return new Date('1970/01/01 ' + (
                self.options.milliseconds ?
                self.options.value.substr(0, self.options.value.length - 4) :
                self.options.value
            ));
        }

        function getValues() {
            self.date = getDate();
            return {
                ampm: Ox.formatDate(self.date, '%p'),
                hours: Ox.formatDate(self.date, self.options.ampm ? '%I' : '%H'),
                milliseconds: self.options.milliseconds ? self.options.value.substr(-3) : '000',
                minutes: Ox.formatDate(self.date, '%M'),
                seconds: Ox.formatDate(self.date, '%S')
            };
        }

        function setValue() {
            self.options.value = Ox.formatDate(new Date('1970/01/01 ' + [
                self.$input.hours.options('value'),
                self.$input.minutes.options('value'),
                self.options.seconds ? self.$input.seconds.options('value') : '00'
            ].join(':') + (self.options.ampm ? ' ' + self.$input.ampm.options('value') : '')),
                (self.options.seconds? '%T' : '%H:%M')) +
                (self.options.milliseconds ? '.' + self.$input.milliseconds.options('value') : '');
            Ox.print('SETVALUE', self.options.value);
        }

        function setValues() {
            self.values = getValues();
            $.each(self.$input, function(k, v) {
                self.$input[k].options({
                    value: self.values[k]
                });
            });
        }

        self.onChange = function(key, value) {
            if (key == 'value') {
                setValues();
            }
        }

        return that;

    };

    Ox.Label = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    disabled: false,
                    id: '',
                    overlap: 'none',
                    textAlign: 'left',
                    title: '',
                    width: 'auto'
                })
                .options(options)
                .addClass(
                    'OxLabel' + (self.options.disabled ? ' OxDisabled' : '') +
                    (self.options.overlap != 'none' ?
                    ' OxOverlap' + Ox.toTitleCase(self.options.overlap) : '')
                )
                .css($.extend(self.options.width == 'auto' ? {} : {
                    width: (self.options.width - 14) + 'px'
                }, {
                    textAlign: self.options.textAlign
                }))
                .html(self.options.title);

        return that;

    };

    Ox.OptionGroup = function(items, min, max, property) {

        /*
            to be used by ButtonGroup, CheckboxGroup, Select and Menu
        */

        var property = property || 'checked'
            length = items.length,
            max = max == -1 ? length : max;

        function getLastBefore(pos) {
            // returns the position of the last checked item before position pos
            var last = -1;
            Ox.print(items, items.length, length, $.merge(
                pos > 0 ? Ox.range(pos - 1, -1, -1) : [],
                pos < items.length - 1 ? Ox.range(items.length - 1, pos, -1) : []
            ))
            // fixme: why is length not == items.length here?
            $.each($.merge(
                pos > 0 ? Ox.range(pos - 1, -1, -1) : [],
                pos < items.length - 1 ? Ox.range(items.length - 1, pos, -1) : []
            ), function(i, v) {
                Ox.print(pos, v)
                if (items[v][property]) {
                    last = v;
                    return false;
                }
            });
            return last;
        }

        function getNumber() {
            // returns the number of checked items
            var num = 0;
            $.each(items, function(i, item) {
                if (item[property]) {
                    num++;
                }
            })
            return num;
        }

        this[property] = function() {
            // returns an array with the positions of all checked item
            var checked = [];
            $.each(items, function(i, item) {
                if (item[property]) {
                    checked.push(i);
                }
            })
            return checked;
        };

        this.init = function() {
            var num = getNumber(),
                count = 0;
            //if (num < min || num > max) {
                $.each(items, function(i, item) {
                    if (Ox.isUndefined(item[property])) {
                        item[property] = false;
                    }
                    if (item[property]) {
                        count++;
                        if (count > max) {
                            item[property] = false;
                        }
                    } else {
                        if (num < min) {
                            item[property] = true;
                            num++;
                        }
                    }
                });
            //}
            return items;
        };

        this.toggle = function(pos) {
            var last,
                num = getNumber(),
                toggled = [];
            if (!items[pos][property]) { // check
                if (num >= max) {
                    last = getLastBefore(pos);
                    items[last][property] = false;
                    toggled.push(last);
                }
                if (!items[pos][property]) {
                    items[pos][property] = true;
                    toggled.push(pos);
                }
            } else { // uncheck
                if (num > min) {
                    items[pos][property] = false;
                    toggled.push(pos);
                }
            }
            return toggled;
        }

        return this;

    }

    Ox.Range = function(options, self) {

        /***
        Ox.Range
        Options:
            arrows          boolean         if true, show arrows
            arrowStep       number          step when clicking arrows
            arrowSymbols    array           arrow symbols, like ['minus', 'plus']
            max             number          maximum value
            min             number          minimum value
            orientation     string          'horizontal' or 'vertical'
            step            number          step between values
            size             number          width or height, in px
            thumbSize       number          minimum width or height of thumb, in px
            thumbValue      boolean         if true, display value on thumb
            trackGradient   array           colors
            trackImages     string or array one or multiple track background image URLs
            trackStep       number          0 (scroll here) or step when clicking track
            value           number          initial value
            valueNames      array           value names to display on thumb
        ***/

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    arrows: false,
                    arrowStep: 1,
                    arrowSymbols: ['previous', 'next'],
                    max: 100,
                    min: 0,
                    orientation: 'horizontal',
                    step: 1,
                    size: 128,
                    thumbSize: 16,
                    thumbValue: false,
                    trackColors: [],
                    trackImages: [],
                    trackStep: 0,
                    value: 0,
                    valueNames: null,
                })
                .options($.extend(options, {
                    arrowStep: options.arrowStep ?
                        options.arrowStep : options.step,
                    trackImages: $.makeArray(options.trackImages || [])
                }))
                .addClass('OxRange')
                .css({
                    width: self.options.size + 'px'
                });

        $.extend(self, {
            trackColors: self.options.trackColors.length,
            trackImages: self.options.trackImages.length,
            trackSize: self.options.size - self.options.arrows * 32,
            values: (self.options.max - self.options.min + self.options.step) /
                    self.options.step
        });
        $.extend(self, {
            thumbSize: Math.max(self.trackSize / self.values, self.options.thumbSize),
            trackImageWidths: self.trackImages == 1 ? [self.trackSize - 16] :
                    Ox.divideInt(self.trackSize - 2, self.trackImages)
        });
        $.extend(self, {
            trackColorsStart: self.thumbSize / 2 / self.options.size,
            trackColorsStep: (self.options.size - self.thumbSize) / (self.trackColors - 1) / self.options.size
        });

        if (self.options.arrows) {
            self.$arrows = [];
            $.each(Ox.range(0, 2), function(i) {
                self.$arrows[i] = Ox.Button({
                    overlap: i == 0 ? 'right' : 'left',
                    title: self.options.arrowSymbols[i],
                    type: 'image'
                })
                .addClass('OxArrow')
                .mousedown(function(e) {
                    clickArrow(e, i);
                })
                .appendTo(that.$element);
            });
        }

        self.$track = $('<div>')
            .addClass('OxTrack')
            .css($.extend({
                width: (self.trackSize - 2) + 'px'
            }, self.trackImages == 1 ? {
                background: 'rgb(0, 0, 0)'
            } : {}))
            .mousedown(clickTrack)
            .appendTo(that.$element);

        self.trackColors && setTrackColors();

        if (self.trackImages) {
            self.$trackImages = $('<div>')
                .css({
                    width: self.trackSize + 'px',
                    marginRight: (-self.trackSize - 1) + 'px'
                })
                .appendTo(self.$track);
            $.each(self.options.trackImages, function(i, v) {
                Ox.print(self.trackImageWidths[i])
                $('<img>')
                    .attr({
                        src: v
                    })
                    .addClass(i == 0 ? 'OxFirstChild' : '')
                    .addClass(i == self.trackImages - 1 ? 'OxLastChild' : '')
                    .css({
                        width: self.trackImageWidths[i] + 'px'
                    })
                    .mousedown(function(e) {
                        e.preventDefault(); // prevent drag
                    })
                    .appendTo(self.$trackImages);
                //left += self.trackImageWidths[i];
            });
        }

        self.$thumb = Ox.Button({
                id: self.options.id + 'Thumb',
                title: self.options.thumbValue ? (self.options.valueNames ?
                        self.options.valueNames[self.options.value] :
                        self.options.value) : '',
                width: self.thumbSize
            })
            .addClass('OxThumb')
            /*
            .css({
                border: '1px solid rgb(255, 255, 255)',
                background: 'rgba(0, 0, 0, 0)'
            })
            */
            .appendTo(self.$track);

        setThumb();

        function clickArrow(e, i) {
            // fixme: shift doesn't work, see menu scrolling
            var interval,
                timeout = setTimeout(function() {
                    interval = setInterval(function() {
                        setValue(self.options.value + self.options.arrowStep * (i == 0 ? -1 : 1));
                    }, 50);
                }, 500);
            setValue(self.options.value + self.options.arrowStep * (i == 0 ? -1 : 1) * (e.shiftKey ? 2 : 1), true);
            $window.one('mouseup', function() {
                clearInterval(interval);
                clearTimeout(timeout);
            });
        }

        function clickTrack(e) {
            //Ox.Focus.focus();
            var isThumb = $(e.target).hasClass('OxThumb'),
                left = self.$track.offset().left,
                offset = isThumb ? e.clientX - self.$thumb.offset().left - 8 /*self.thumbSize / 2*/ : 0;
            setValue(val(e), !isThumb);
            $window.mousemove(function(e) {
                setValue(val(e));
            });
            $window.one('mouseup', function() {
                $window.unbind('mousemove');
            });
            function val(e) {
                return getVal(e.clientX - left - offset);
            }
        }

        function getPx(val) {
            var pxPerVal = (self.trackSize - self.thumbSize) /
                        (self.options.max - self.options.min);
            return Math.ceil((val - self.options.min) * pxPerVal);
        }

        /*
        function getTime(oldValue, newValue) {
            return self.animationTime * Math.abs(oldValue - newValue) / (self.options.max - self.options.min);
        }
        */

        function getVal(px) {
            var px = self.trackSize / self.values >= 16 ? px : px - 8,
                valPerPx = (self.options.max - self.options.min) /
                        (self.trackSize - self.thumbSize);
            return Ox.limit(self.options.min +
                    Math.floor(px * valPerPx / self.options.step) * self.options.step,
                    self.options.min, self.options.max);
        }

        function setThumb(animate) {
            self.$thumb.stop().animate({
                marginLeft: (getPx(self.options.value) - 1) + 'px',
                //width: self.thumbSize + 'px'
            }, animate ? 200 : 0, function() {
                if (self.options.thumbValue) {
                    self.$thumb.options({
                        title: self.options.valueNames ?
                                self.options.valueNames[self.options.value] :
                                self.options.value
                    });
                }
            });
        }

        function setTrackColors() {
            self.$track.css({
                backgroundImage: $.browser.mozilla ?
                    ('-moz-linear-gradient(left, ' +
                    self.options.trackColors[0] + ' 0%, ' + $.map(self.options.trackColors, function(v, i) {
                        return v + ' ' + ((self.trackColorsStart + self.trackColorsStep * i) * 100) + '%';
                    }).join(', ') + ', ' + self.options.trackColors[self.trackColors - 1] + ' 100%)') :
                    ('-webkit-gradient(linear, left top, right top, color-stop(0, ' +
                    self.options.trackColors[0] + '), ' + $.map(self.options.trackColors, function(v, i) {
                        return 'color-stop(' + (self.trackColorsStart + self.trackColorsStep * i) + ', ' + v + ')';
                    }).join(', ') + ', color-stop(1, ' + self.options.trackColors[self.trackColors - 1] + '))')
            });
        }

        function setValue(value, animate) {
            var value = Ox.limit(value, self.options.min, self.options.max);
            if (value != self.options.value) {
                //time = getTime(self.options.value, value);
                self.options.value = value;
                setThumb(animate);
                that.triggerEvent('change', {
                    value: value
                });
            }
        }

        self.onChange = function(key, value) {
            if (key == 'trackColors') {
                setTrackColors();
            } else if (key == 'value') {
                setThumb();
            }
        }

        return that;

    };

    Ox.Select = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self) // fixme: do we use 'div', or {}, or '', by default?
                .defaults({
                    id: '',
                    items: [],
                    max: 1,
                    min: 1,
                    overlap: 'none', // can be none, left or right
                    selectable: true,
                    size: 'medium',
                    title: '',
                    type: 'text', // can be 'text' or 'image'
                    width: 'auto'
                })
                .options(options)
                .addClass(
                    'OxSelect Ox' + Ox.toTitleCase(self.options.size) +
                    (self.options.overlap == 'none' ? '' : ' OxOverlap' +
                    Ox.toTitleCase(self.options.overlap))
                )
                .css(self.options.width == 'auto' ? {} : {
                    width: self.options.width + 'px'
                })
                .addEvent({
                    key_escape: loseFocus,
                    key_down: showMenu
                });

        $.extend(self, {
            buttonId: self.options.id + 'Button',
            groupId: self.options.id + 'Group',
            menuId: self.options.id + 'Menu'
        });

        if (self.options.selectable) {
            self.optionGroup = new Ox.OptionGroup(
                self.options.items,
                self.options.min,
                self.options.max
            );
            self.options.items = self.optionGroup.init();
            self.checked = self.optionGroup.checked();
        }

        if (self.options.type == 'text') {
            self.$title = $('<div>')
                .addClass('OxTitle')
                .css({
                    width: (self.options.width - 22) + 'px'
                })
                .html(
                    self.options.title ? self.options.title :
                    self.options.items[self.checked[0]].title
                )
                .click(showMenu)
                .appendTo(that.$element);
        }

        self.$button = new Ox.Button({
                id: self.buttonId,
                style: 'symbol',
                title: 'select',
                type: 'image'
            })
            .bindEvent('click', showMenu)
            .appendTo(that);

        self.$menu = new Ox.Menu({
                element: self.$title || self.$button,
                id: self.menuId,
                items: [self.options.selectable ? {
                    group: self.groupId,
                    items: self.options.items,
                    max: self.options.max,
                    min: self.options.min
                } : self.options.items],
                side: 'bottom',
                size: self.options.size
            })
            .bindEvent({
                change: changeMenu,
                click: clickMenu,
                hide: hideMenu
            });

        function clickMenu(event, data) {
            
        }

        function changeMenu(event, data) {
            Ox.print('clickMenu: ', self.options.id, data)
            self.checked = self.optionGroup.checked();
            self.$title && self.$title.html(
                self.options.title ? self.options.title :
                        data.checked[0].title
            );
            that.triggerEvent('change', {
                selected: data.checked
            });
        }

        function hideMenu() {
            that.removeClass('OxSelected');
            self.$button.removeClass('OxSelected');
        }

        function loseFocus() {
            that.loseFocus();
        }

        function showMenu() {
            that.gainFocus();
            that.addClass('OxSelected');
            self.$menu.showMenu();
        }

        self.onChange = function(key, value) {
            
        };

        that.selected = function() {
            return $.map(/*self.checked*/self.optionGroup.checked(), function(v, i) {
                return {
                    id: self.options.items[i].id,
                    title: self.options.items[i].title
                };
            });
        };

        that.selectItem = function(id) {
            Ox.print('selectItem', id, Ox.getObjectById(self.options.items, id).title)
            self.options.type == 'text' && self.$title.html(
                Ox.getObjectById(self.options.items, id).title[0] // fixme: title should not have become an array
            );
            self.$menu.checkItem(id);
            self.checked = self.optionGroup.checked();
        };

        /*
        that.width = function(val) {
            // fixme: silly hack, and won't work for css() ... remove!
            that.$element.width(val + 16);
            that.$button.width(val);
            //that.$symbol.width(val);
            return that;
        };
        */

        return that;

    }

    Ox.FormElementGroup = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    id: '',
                    elements: [],
                    float: 'left',
                    separators: [],
                    width: 0
                })
                .options(options || {})
                .addClass('OxInputGroup');

        $.each(self.options.float == 'left' ? self.options.elements : self.options.elements.reverse(), function(i, $element) {
            $element.options({
                    id: self.options.id + Ox.toTitleCase($element.options('id')),
                    parent: that
                })
                .css({
                    float: self.options.float // fixme: make this a class
                })
                .appendTo(that);
        });

        /*
        if (self.options.width) {
            setWidths();
        } else {
            self.options.width = getWidth();
        }
        that.css({
            width: self.options.width + 'px'
        });
        */

        function getWidth() {
            
        }

        function setWidth() {
            
        }

        self.onChange = function(key, value) {
            if (key == 'trackColors') {
                
            }
        }

        return that;

    }

    Ox.Picker = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    element: null,
                    elementHeight: 128,
                    elementWidth: 256,
                    id: '',
                    overlap: 'none'
                })
                .options(options || {});

        self.$selectButton = new Ox.Button({
                overlap: self.options.overlap,
                title: 'select',
                type: 'image'
            })
            .click(showMenu)
            .appendTo(that);

        self.$menu = new Ox.Element('div')
            .addClass('OxPicker')
            .css({
                width: self.options.elementWidth + 'px',
                height: (self.options.elementHeight + 24) + 'px'
            });

        self.options.element
            .css({
                width: self.options.elementWidth + 'px',
                height: self.options.elementHeight + 'px'
            })
            .appendTo(self.$menu);

        self.$bar = new Ox.Bar({
                orientation: 'horizontal',
                size: 24
            })
            .appendTo(self.$menu);

        that.$label = new Ox.Label({
                width: self.options.elementWidth - 60
            })
            .appendTo(self.$bar);

        self.$doneButton = new Ox.Button({
                title: 'Done',
                width: 48
            })
            .click(hideMenu)
            .appendTo(self.$bar);

        self.$layer = $('<div>')
            .addClass('OxLayer')
            .click(hideMenu);

        function hideMenu() {
            self.$menu.detach();
            self.$layer.detach();
            self.$selectButton
                .removeClass('OxSelected')
                .css({
                    MozBorderRadius: '8px',
                    WebkitBorderRadius: '8px'
                });
            that.triggerEvent('hide');
        };

        function showMenu() {
            var offset = that.offset(),
                left = offset.left,
                top = offset.top + 15;
            self.$selectButton
                .addClass('OxSelected')
                .css({
                    MozBorderRadius: '8px 8px 0 0',
                    WebkitBorderRadius: '8px 8px 0 0'
                });
            self.$layer.appendTo($body);
            self.$menu
                .css({
                    left: left + 'px',
                    top: top + 'px'
                })
                .appendTo($body);
            that.triggerEvent('show');
        };

        return that;

    };

    Ox.ColorPicker = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    id: '',
                    value: '0, 0, 0'
                })
                .options(options || {});

        Ox.print(self)
        self.$ranges = [];
        self.rgb = ['red', 'green', 'blue'];
        self.values = self.options.value.split(', ');

        $.each(Ox.range(3), function(i) {
            self.$ranges[i] = new Ox.Range({
                    arrows: true,
                    id: self.options.id + Ox.toTitleCase(self.rgb[i]),
                    max: 255,
                    size: 328, // 256 + 16 + 40 + 16
                    thumbSize: 40,
                    thumbValue: true,
                    trackColors: getColors(i),
                    value: self.values[i]
                })
                .css({
                    position: 'absolute',
                    top: (i * 15) + 'px'
                })
                .bindEvent('change', function(event, data) {
                    change(i, data.value);
                })
                .appendTo(that);
            // fixme: make self.$ranges[i].children() work
            if (i == 0) {
                self.$ranges[i].$element.children('input.OxOverlapRight').css({
                    MozBorderRadius: 0,
                    WebkitBorderRadius: 0
                });
                self.$ranges[i].$element.children('input.OxOverlapLeft').css({
                    MozBorderRadius: '0 8px 0 0',
                    WebkitBorderRadius: '0 8px 0 0'
                });
            } else {
                self.$ranges[i].$element.children('input').css({
                    MozBorderRadius: 0,
                    WebkitBorderRadius: 0
                });
            }
        });

        that = new Ox.Picker({
            element: that,
            elementHeight: 46,
            elementWidth: 328,
            id: self.options.id
        });

        function change(index, value) {
            self.values[index] = value;
            self.options.value = self.values.join(', ');
            that.$label.css({
                background: 'rgb(' + self.options.value + ')'
            });
            $.each(Ox.range(3), function(i) {
                if (i != index) {
                    self.$ranges[i].options({
                        trackColors: getColors(i)
                    });
                }
            });
            that.triggerEvent('change', {
                value: self.options.value
            });
        }

        function getColors(index) {
            return [
                'rgb(' + $.map(Ox.range(3), function(v) {
                    return v == index ? 0 : self.values[v];
                }).join(', ') + ')',
                'rgb(' + $.map(Ox.range(3), function(v) {
                    return v == index ? 255 : self.values[v];
                }).join(', ') + ')'
            ]
        }

        return that;

    };

    Ox.PlacePicker = function(options, self) {

        var self = $.extend(self || {}, {
                options: $.extend({
                    id: '',
                    value: 'United States'
                }, options)
            }),
            that;

        self.$element = new Ox.Element('div')
            .css({
                width: '256px',
                height: '192px'
            })
            .append(
                self.$topBar = new Ox.Bar({
                        size: 16
                    })
                    .css({
                        MozBorderRadius: '0 8px 0 0',
                        WebkitBorderRadius: '0 8px 0 0'
                    })
                    .append(
                        self.$input = new Ox.Input({
                                clear: true,
                                id: self.options.id + 'Input',
                                placeholder: 'Find',
                                width: 256
                            })
                            .bindEvent('submit', findPlace)
                    )
            )
            .append(
                self.$container = new Ox.Element('div')
                    .css({
                        width: '256px',
                        height: '160px'
                    })
            )
            .append(
                self.$bottomBar = new Ox.Bar({
                        size: 16
                    })
                    .append(
                        self.$range = new Ox.Range({
                                arrows: true,
                                id: self.options.id + 'Range',
                                max: 22,
                                size: 256,
                                thumbSize: 32,
                                thumbValue: true
                            })
                            .bindEvent('change', changeZoom)
                    )
            );

        self.$input.$element.children('input[type=text]').css({
            width: '230px',
            paddingLeft: '2px',
            MozBorderRadius: '0 8px 8px 0',
            WebkitBorderRadius: '0 8px 8px 0'
        });
        self.$input.$element.children('input[type=image]').css({
            MozBorderRadius: '0 8px 0 0',
            WebkitBorderRadius: '0 8px 0 0'
        });
        self.$range.$element.children('input').css({
            MozBorderRadius: 0,
            WebkitBorderRadius: 0
        });

        that = new Ox.Picker({
                element: self.$element,
                elementHeight: 192,
                elementWidth: 256,
                id: self.options.id,
                overlap: self.options.overlap,
                value: self.options.value
            }, self)
            .bindEvent('show', showPicker);

        that.$label.bind('click', clickLabel)

        self.map = false;

        function changeZoom(event, data) {
            Ox.print('changeZoom')
            self.$map.zoom(data.value);
        }

        function clickLabel() {
            var name = that.$label.html();
            if (name) {
                self.$input.options({
                    value: name
                })
                .triggerEvent('submit', {
                    value: name
                });
            }
        }

        function findPlace(event, data) {
            Ox.print('findPlace', data);
            self.$map.find(data.value, function(location) {
                if (location) {
                    that.$label.html(location.name.formatted);
                }
            })
        }

        function onSelect(event, data) {
            that.$label.html(data.name.formatted);
        }

        function onZoom(event, data) {
            self.$range.options({
                value: data.value
            });
        }

        function showPicker() {
            if (!self.map) {
                self.$map = new Ox.Map({
                        id: self.options.id + 'Map',
                        places: [self.options.value]
                    })
                    .css({
                        width: '256px',
                        height: '160px'
                    })
                    .bindEvent({
                        select: onSelect,
                        zoom: onZoom
                    })
                    .appendTo(self.$container);
                self.map = true;
            }
        }

        return that;

    };

    /*
    ----------------------------------------------------------------------------
    delete below
    ----------------------------------------------------------------------------
    */

    Ox.Input_ = function(options, self) {

        /*
        options:
            clear                   boolean, clear button, or not
            disabled                boolean, disabled, or not
            height                  height (px), if type is 'textarea'
            id
            label                   string, or
                                    array [{ id, title, checked }] (selectable label) or
                                    array [{ id, label: [{ id, title, checked }], width }] (multiple selectable labels)
                                    label and placeholder are mutually exclusive
            labelWidth              integer (px)
            placeholder             string, or
                                    array [{ id, title, checked }] (selectable placeholder)
                                    label and placeholder are mutually exclusive
            separator               string, or
                                    array of strings
                                    to separate multiple values
            separatorWidth          integer (px), or
                                    array of integers
            serialize               function
            size                    'large', 'medium' or 'small'
            type                    'password', 'select' or 'text'
            unit                    string, or
                                    array [{ id, title, checked }] (selectable unit)
            unitWidth               integer (px)
            value                   string, or
                                    array [{ id, value, width }] (multiple values)
            width                   integer (px)
        methods:
        events:
        */

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    autocomplete: null,
                    autocorrect: null,
                    autosuggest: null,
                    autosuggestHighlight: false,
                    autosuggestSubmit: false,
                    autovalidate: null,
                    autovalidateName: 'Value',
                    clear: false,
                    disabled: false,
                    height: 128,
                    id: '',
                    key: '',
                    label: '',
                    labelWidth: 64,
                    placeholder: '',
                    separator: '',
                    separatorWidth: 16,
                    serialize: null,
                    size: 'medium',
                    type: 'text',
                    unit: '',
                    unitWidth: 64,
                    value: '',
                    width: 128
                })
                .options(options || {})
                .addClass('OxInput Ox' + Ox.toTitleCase(self.options.size))
                .css({
                    width: self.options.width + 'px'
                });

        $.extend(self, {
            clearWidth: 16,
            hasMultipleKeys: Ox.isArray(self.options.label) && 'label' in self.options.label[0],
            hasMultipleValues: Ox.isArray(self.options.value) &&
                    (self.options.type != 'select' || 'items' in self.options.value[0]),
            hasSelectableKeys: Ox.isArray(self.options.label) || Ox.isArray(self.options.placeholder),
            hasSelectableUnits: Ox.isArray(self.options.unit),
            keyName: self.options.label ? 'label' : (self.options.placeholder ? 'placeholder' : ''),
            placeholderWidth: 16,
            selectedKey: [0], // fixme: only set on demand?
            selectedValue: 0,
            selectedUnit: 0,
            /* valid: autovalidateCall(true) */
        });

        $.each(['autocomplete', 'autocorrect', 'autosuggest', 'autovalidate'], function(i, v) {
            //if (!Ox.isFunction(self.options[v])) {
                self.options[v] = {
                    '': self.options[v]
                };
            //}
        });

        if (self.keyName && !self.hasMultipleKeys) {
            self.options[self.keyName] = [$.extend({
                id: '',
                label: self.options[self.keyName],
            }, self.keyName == 'label' ? {
                id: '',
                width: self.options.labelWidth
            } : {})];
            if (!self.hasSelectableKeys) {
                self.options[self.keyName][0].label = [{
                    id: '',
                    title: self.options[self.keyName][0].label
                }];
            }
        }
        if (self.hasSelectableKeys) {
            $.each(self.options[self.keyName], function(keyPos, key) {
                if (key.width) {
                    self.options.labelWidth = (keyPos == 0 ? 0 : self.options.labelWidth) + key.width;
                }
                self.selectedKey[keyPos] = 0;
                $.each(key, function(valuePos, value) {
                    if (value.checked) {
                        self.selectedKey[keyPos] = valuePos;
                        return false;
                    }
                });
            });
        }
        self.valueWidth = self.options.width -
                (self.options.label ? self.options.labelWidth : 0) -
                ((self.options.placeholder && self.options.placeholder[0].label.length > 1) ? self.placeholderWidth : 0) -
                (self.options.unit ? self.options.unitWidth : 0) -
                (self.options.clear ? self.clearWidth : 0);
        /*
        if (self.hasMultipleValues) {
            self.valueWidth -= Ox.isArray(self.options.separatorWidth) ?
                    Ox.sum(self.options.separatorWidth) :
                    (self.options.value.length - 1) * self.options.separatorWidth;
        }
        */
        Ox.print('self.hasMulVal', self.hasMultipleValues);
        Ox.print('self.options.value', self.options.value)
        if (!self.hasMultipleValues) {
            if (self.options.type == 'select') {
                self.options.value = [{
                    id: '',
                    items: self.options.value,
                    width: self.valueWidth
                }];
            } else if (self.options.type == 'range') {
                self.options.value = [$.extend({
                    id: '',
                    size: self.valueWidth
                }, self.options.value)];
            } else {
                self.options.value = [{
                    id: '',
                    value: self.options.value,
                    width: self.valueWidth
                }]
            }
        }
        Ox.print('self.options.value', self.options.value)
        self.values = self.options.value.length;
        Ox.print(self.options.id, 'self.values', self.values)

        if (Ox.isString(self.options.separator)) {
            self.options.separator = $.map(new Array(self.values - 1), function(v, i) {
                return self.options.separator;
            });
        }
        if (Ox.isNumber(self.options.separatorWidth)) {
            self.options.separatorWidth = $.map(new Array(self.values - 1), function(v, i) {
                return self.options.separatorWidth;
            });
        }

        if (self.options.unit) {
            if (self.hasSelectableUnits) {
                $.each(self.options.unit, function(pos, unit) {
                    if (unit.checked) {
                        self.selectedUnit = pos;
                        return false;
                    }
                });
            } else {
                self.options.unit = [{
                    id: '',
                    title: self.options.unit
                }];
            }
        }

        Ox.print('self', self);

        if (self.keyName) {
            that.$key = [];
            $.each(self.options[self.keyName], function(keyPos, key) {
                Ox.print('keyPos key', keyPos, key)
                if (self.keyName == 'label' && key.label.length == 1) {
                    that.$key[keyPos] = new Ox.Label({
                            overlap: 'right',
                            title: key.label[0].title,
                            width: self.options.labelWidth
                        })
                        .css({
                            float: 'left'
                        })
                        .click(function() {
                            that.$input[0].focus();
                        })
                        .appendTo(that);
                } else if (key.label.length > 1) {
                    Ox.print('key.length > 1')
                    self.selectKeyId = self.options.id + Ox.toTitleCase(self.keyName) +
                            (self.options[self.keyName].length == 1 ? '' : keyPos);
                    Ox.print('three', self.selectedKey, keyPos, self.selectedKey[keyPos]);
                    that.$key[keyPos] = new Ox.Select({
                            id: self.selectKeyId,
                            items: $.map(key.label, function(value, valuePos) {
                                return {
                                    checked: valuePos == self.selectedKey[keyPos],
                                    id: value.id,
                                    group: self.selectKeyId, // fixme: same id, works here, but should be different
                                    title: value.title
                                };
                            }),
                            overlap: 'right',
                            type: self.options.label ? 'text' : 'image',
                            width: self.options.label ? (self.options.label.length == 1 ? self.options.labelWidth : key.width) : self.placeholderWidth
                        })
                        .css({
                            float: 'left'
                        })
                        .appendTo(that);
                    that.bindEvent('change_' + self.selectKeyId, changeKey);
                }
            });
        }

        if (self.options.clear) {
            that.$clear = new Ox.Button({
                    overlap: 'left',
                    type: 'image',
                    value: 'clear'
                })
                .css({
                    float: 'right'
                })
                .click(clear)
                .appendTo(that);
        }

        if (self.options.unit.length == 1) {
            that.$unit = new Ox.Label({
                    overlap: 'left',
                    title: self.options.unit[0].title,
                    width: self.options.unitWidth
                })
                .css({
                    float: 'right'
                })
                .click(function() {
                    that.$input[0].focus();
                })
                .appendTo(that);
        } else if (self.options.unit.length > 1) {
            self.selectUnitId = self.options.id + 'Unit';
            that.$unit = new Ox.Select({
                    id: self.selectUnitId,
                    items: $.map(self.options.unit, function(unit, i) {
                        Ox.print('unit', unit)
                        return {
                            checked: i == 0,
                            id: unit.id,
                            group: self.selectUnitId, // fixme: same id, works here, but should be different
                            title: unit.title
                        };
                    }),
                    overlap: 'left',
                    size: self.options.size,
                    width: self.options.unitWidth
                })
                .css({
                    float: 'right'
                })
                .appendTo(that);
        }

        if (self.values) {
            that.$separator = [];
            $.each(self.options.value, function(i, v) {
                if (i < self.values - 1) {
                    that.$separator[i] = new Ox.Label({
                            textAlign: 'center',
                            title: self.options.separator[i],
                            width: self.options.separatorWidth[i] + 32
                        })
                        .css({
                            float: 'left',
                            marginLeft: (v.width - (i == 0 ? 16 : 32)) + 'px'
                        })
                        .click(function() {
                            that.$input[0].focus();
                        })
                        .appendTo(that);
                }
            });
        }
        that.$input = [];
        //self.margin = 0;
        $.each(self.options.value, function(i, v) {
            Ox.print('o k i', self.options, self.keyName, i);
            var id = self.keyName ? $.map(self.selectedKey, function(v, i) {
                    return self.options[self.keyName][i].id;
                }).join('.') : '';
            //self.margin -= (i == 0 ? 16 : self.options.value[i - 1].width)
            Ox.print('v:', v, 'id:', id)
            if (self.options.type == 'select') {
                that.$input[i] = new Ox.Select({
                        id: v.id,
                        items: v.items,
                        width: v.width
                    }).
                    css({
                        float: 'left'
                    });
            } else if (self.options.type == 'range') {
                that.$input[i] = new Ox.Range(v)
                    .css({
                        float: 'left'
                    });
            } else {
                that.$input[i] = new Ox.InputElement({
                    autocomplete: self.options.autocomplete[id],
                    autocorrect: self.options.autocorrect[id],
                    autosuggest: self.options.autosuggest[id],
                    autosuggestHighlight: self.options.autosuggestHighlight,
                    autosuggestSubmit: self.options.autosuggestSubmit,
                    autovalidate: self.options.autovalidate[id],
                    autovalidateName: self.options.autovalidateName,
                    disabled: self.options.disabled,
                    height: self.options.height,
                    id: self.options.id + 'Input' + Ox.toTitleCase(v.id),
                    key: self.hasSelectableKeys ? self.options[self.keyName][0].label[self.selectedKey[0]].id : '',
                    parent: that,
                    placeholder: self.options.placeholder ? self.options.placeholder[0].label[0].title : '',
                    size: self.options.size,
                    type: self.options.type,
                    value: v.value,
                    width: v.width
                });
            }
            that.$input[i]
                .css($.extend({}, self.options.value.length > 1 ? {
                    float: 'left',
                    marginLeft: -Ox.sum($.map(self.options.value, function(v_, i_) {
                        return i_ > i ? self.options.value[i_ - 1].width + self.options.separatorWidth[i_ - 1] : (i_ == i ? 16 : 0);
                    }))
                } : {}))
                .appendTo(that);
        });

        //width(self.options.width);

        function changeKey(event, data) {
            Ox.print('changeKey', data);
            if (data) { // fixme: necessary?
                self.key = {
                    id: data.id,
                    title: data.value // fixme: should be data.title
                };
                that.$input[0].options({
                    key: data.id
                });
            }
            if (self.options.label) {
                //that.$label.html(self.option.title);
                that.$input[0].focus();
                //autocompleteCall();
            } else {
                that.$input[0].options({
                    placeholder: data.value // fixme: should be data.title
                });
                /*
                if (that.$input.hasClass('OxPlaceholder')) {
                    that.$input.val(self.key.title);
                    //that.$input.focus();
                } else {
                    that.$input.focus();
                    self.options.autosuggest && autosuggestCall();
                }
                */
            }
        }

        function changeUnit() {
            that.$input[0].focus();
        }

        function clear() {
            $.each(that.$input, function(i, v) {
                v.val('');
            });
            that.$input[0].focus();
        }

        function height(value) {
            var stop = 8 / value;
            if (self.options.type == 'textarea') {
                that.$element
                    .height(value)
                    .css({
                        background: '-moz-linear-gradient(top, rgb(224, 224, 224), rgb(208, 208, 208) ' + (stop * 100) + '%, rgb(208, 208, 208) ' + (100 - stop * 100) + '%, rgb(192, 192, 192))'
                    })
                    .css({
                        background: '-webkit-gradient(linear, left top, left bottom, from(rgb(224, 224, 224)), color-stop(' + stop + ', rgb(208, 208, 208)), color-stop(' + (1 - stop) + ', rgb(208, 208, 208)), to(rgb(192, 192, 192)))'
                    });
                that.$input
                    .height(value)
                    .css({
                        background: '-moz-linear-gradient(top, rgb(224, 224, 224), rgb(240, 240, 240) ' + (stop * 100) + '%, rgb(240, 240, 240) ' + (100 - stop * 100) + '%, rgb(255, 255, 255))'
                    })
                    .css({
                        background: '-webkit-gradient(linear, left top, left bottom, from(rgb(224, 224, 224)), color-stop(' + stop + ', rgb(240, 240, 240)), color-stop(' + (1 - stop) + ', rgb(240, 240, 240)), to(rgb(255, 255, 255)))'
                    });
            }
        }

        function selectUnit() {
            self.$selectUnitMenu.show();
        }

        function submit() {
            Ox.print('submit')
            var value = that.$input.val();
            that.$input.blur();
            that.triggerEvent('submit', self.options.key ? {
                key: self.options.key,
                value: value
            } : value);
        }

        function width(value) {
            that.$element.width(value);
            that.$input.width(
                value - (self.options.type == 'textarea' ? 0 : 12) -
                        (self.options.label ? self.options.labelWidth : 0) -
                        (self.options.placeholder.length > 1 ? 16 : 0) -
                        (self.options.unit ? self.options.unitWidth : 0) -
                        (self.options.clear ? 16 : 0)
            );
        }

        self.onChange = function(key, value) {
            if (key == 'height') {
                height(value);
            } else if (key == 'width') {
                width(value);
            }
        };

        that.changeLabel = function(id) {
            that.$key.html(Ox.getObjectById(self.options.label, id).title);
            self.selectMenu.checkItem(id);
        };

        return that;

    }

    Ox.InputElement_ = function(options, self) {

        var self = self || {},
            that = new Ox.Element(
                    options.type == 'textarea' ? 'textarea' : 'input', self
                )
                .defaults({
                    autocomplete: null,
                    autocorrect: null,
                    autosuggest: null,
                    autosuggestHighlight: false,
                    autosuggestSubmit: false,
                    autovalidate: null,
                    disabled: false,
                    height: 128,
                    id: '',
                    key: '',
                    parent: null,
                    placeholder: '',
                    size: 'medium',
                    type: 'text',
                    value: '',
                    width: 128
                })
                .options(options || {})
                .addClass('OxInput Ox' + Ox.toTitleCase(self.options.size) + (
                    (self.options.placeholder && self.options.value === '') ?
                    ' OxPlaceholder' : ''
                ))
                .attr(self.options.type == 'textarea' ? {} : {
                    type: self.options.type
                })
                .css({
                    float: 'left',
                    width: (self.options.width - 14) + 'px'
                })
                .val(
                    (self.options.placeholder && self.options.value === '') ?
                    self.options.placeholder : self.options.value
                )
                .blur(blur)
                .change(change)
                .focus(focus);

        Ox.print('InputElement self.options', self.options)

        self.bindKeyboard = self.options.autocomplete || self.options.autocorrect ||
                        self.options.autosuggest || self.options.autovalidate;

        if (self.options.autosuggest) {
            self.autosuggestId = self.options.id + 'Menu'; // fixme: we do this in other places ... are we doing it the same way? var name?
            self.$autosuggestMenu = new Ox.Menu({
                element: that.$element,
                id: self.autosuggestId,
                offset: {
                    left: 4,
                    top: 0
                },
                size: self.options.size
            });
            that.bindEvent('click_' + self.autosuggestId, clickMenu);
        }

        that.bindEvent($.extend(self.options.type == 'textarea' ? {} : {
            key_enter: submit
        }, {
            key_escape: cancel
        }));

        function autocomplete(value) {
            var value = value.toLowerCase(),
                ret = '';
            if (value !== '') {
                $.each(self.options.autocomplete, function(i, v) {
                    if (v.toLowerCase().indexOf(value) == 0) {
                        ret = v;
                        return false;
                    }
                });
            }
            return ret;
        }

        function autocompleteCall() {
            var value = that.$element.val();
            Ox.isFunction(self.options.autocomplete) ?
                self.options.autocomplete(self.options.key ? {
                    key: self.options.key,
                    value: value
                } : value, autocompleteCallback) :
                autocompleteCallback(autocomplete(value));
        }

        function autocompleteCallback(value) {
            var pos = cursor()[0];
            if (value) {
                that.$element.val(value);
                cursor(pos, value.length);
            }
        }

        function autocorrect(value) {
            var length = value.length;
            return $.map(value.toLowerCase().split(''), function(v, i) {
                if (new RegExp(self.options.autocorrect)(v)) {
                    return v
                } else {
                    return null;
                }
            }).join('');
        }

        function autocorrectCall(blur) {
            var blur = blur || false,
                value = that.$element.val(),
                pos = cursor()[0];
            Ox.isFunction(self.options.autocorrect) ?
                self.options.autocorrect(value, blur, autocorrectCallback) :
                autocorrectCallback(autocorrect(value), blue);
        }

        function autocorrectCallback(value, blur) {
            var length = that.$element.val().length;
            that.$element.val(self.options.value);
            !blur && cursor(pos + value.length - length);
        }

        function autosuggest(value) {
            var value = value.toLowerCase(),
                values = [[], []];
            if (value !== '') {
                $.each(self.options.key ? self.options.autosuggest[self.options.key] : self.options.autosuggest, function(i, v) {
                    //Ox.print('v...', v)
                    var index = v.toLowerCase().indexOf(value);
                    index > -1 && values[index == 0 ? 0 : 1].push(v);
                });
            }
            return $.merge(values[0], values[1]);
        }

        function autosuggestCall() {
            var value = that.$element.val();
            Ox.isFunction(self.options.autosuggest) ?
                self.options.autosuggest(self.options.key ? {
                    key: self.options.key,
                    value: value
                } : value, autosuggestCallback) :
                autosuggestCallback(autosuggest(value));
        }

        function autosuggestCallback(values) {
            var values = values || [],
                selected = values.length == 1 ? 0 : -1,
                value = that.$element.val().toLowerCase();
            //Ox.print('values', values);
            if (values.length) {
                values = $.map(values, function(v, i) {
                    if (value == v.toLowerCase()) {
                        selected = i;
                    }
                    return {
                        id: v.toLowerCase().replace(/ /g, '_'), // fixme: need function to do lowercase, underscores etc?
                        title: self.options.autosuggestHighlight ? v.replace(
                            new RegExp('(' + value + ')', 'ig'),
                            '<span class="OxHighlight">$1</span>'
                        ) : v
                    };
                });
                // self.selectMenu && self.selectMenu.hideMenu(); // fixme: need event
                self.$autosuggestMenu.options({
                    items: values,
                    selected: selected
                }).showMenu();
            } else {
                self.$autosuggestMenu.hideMenu();
            }
        }

        function autovalidate(value) {
            return {
                valid: self.options.autovalidate(value) != null,
                message: 'Invalid ' + self.options.name
            };
        }

        function autovalidateCall(blur) {
            var blur = blur || false,
                value = that.$element.val();
            if (value !== '') {
                Ox.isFunction(self.options.autovalidate) ?
                    self.options.autovalidate(value, autovalidateCallback) :
                    autovalidateCallback(autovalidate(value), blur);
            } else {
                autovalidateCallback({
                    blur: blur,
                    valid: false,
                    message: 'Empty ' + self.options.name
                });
            }
        }

        function autovalidateCallback(data, blur) {
            if (data.valid != self.valid) {
                self.valid = data.valid;
                that.triggerEvent('validate', $.extend(data, {
                    blur: blur
                }));
            }
        }

        function blur() {
            if (!self.options.autosuggest || self.$autosuggestMenu.is(':hidden')) {
                Ox.print('losing focus...')
                that.loseFocus();
                self.options.parent.removeClass('OxFocus');
                self.options.autocorrect && autocorrectCall(true);
                // self.options.autosuggest && self.$autosuggestMenu.hideMenu();
                self.options.autovalidate && autovalidateCall(true);
                if (self.options.placeholder && that.$element.val() === '') {
                    that.$element.addClass('OxPlaceholder').val(self.options.placeholder);
                }
            }
            if (self.bindKeyboard) {
                $document.unbind('keydown', keypress);
                $document.unbind('keypress', keypress);
            }
        }

        function cancel() {
            that.$element.blur();
        }

        function change() {
            
        }

        function clear() {
            that.$element.val('').focus();
        }

        function clickMenu(event, data) {
            Ox.print('clickMenu', data);
            that.$element.val(data.title);
            //self.$autosuggestMenu.hideMenu();
            self.options.autosuggestSubmit && submit();
        }

        function cursor(start, end) {
            /*
            cursor()                returns [start, end]
            cursor(start)           sets start
            cursor([start, end])    sets start and end
            cursor(start, end)      sets start and end
            */
            var isArray = Ox.isArray(start);
            if (arguments.length == 0) {
                return [that.$element[0].selectionStart, that.$element[0].selectionEnd];
            } else {
                start = isArray ? start[0] : start;
                end = isArray ? start[1] : (end ? end : start);
                that.$element[0].setSelectionRange(start, end);
            }
        }

        function focus() {
            var val = that.$element.val();
            that.gainFocus();
            self.options.parent.addClass('OxFocus');
            if (that.$element.hasClass('OxPlaceholder')) {
                that.$element.val('').removeClass('OxPlaceholder');
            }
            if (self.bindKeyboard) {
                // fixme: different in webkit and firefox (?), see keyboard handler, need generic function
                $document.keydown(keypress);
                $document.keypress(keypress);
                Ox.print('calling autosuggest...')
                self.options.autosuggest && setTimeout(autosuggestCall, 0); // fixme: why is the timeout needed?
            }
        }

        function keypress(event) {
            Ox.print('keyCode', event.keyCode)
            if (event.keyCode != 9 && event.keyCode != 13 && event.keyCode != 27) { // fixme: can't 13 and 27 return false?
                setTimeout(function() { // fixme: document what this timeout is for
                    var value = that.$element.val();
                    if (value != self.options.value) {
                        self.options.value = value;
                        self.options.autocomplete && autocompleteCall();
                        self.options.autocorrect && autocorrectCall();
                        self.options.autosuggest && autosuggestCall();
                        self.options.autovalidate && autovalidateCall();
                    }
                }, 25);
            }
        }

        function submit() {
            
        }

        self.onChange = function(key, value) {
            if (key == 'placeholder') {
                that.$element.hasClass('OxPlaceholder') && that.$element.val(value);
            } else if (key == 'value') {
                if (self.options.placeholder) {
                    if (value === '') {
                        that.$element.addClass('OxPlaceholder').val(self.options.placeholder);
                    } else {
                        that.$element.removeClass('OxPlaceholder');
                    }
                }
                change(); // fixme: keypress too
            }
        }

        return that;

    }

    Ox.Range_ = function(options, self) {

        /*
            init
        */
        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    animate: false,
                    arrows: false,
                    arrowStep: 1,
                    arrowSymbols: ['previous', 'next'],
                    max: 100,
                    min: 0,
                    orientation: 'horizontal',
                    step: 1,
                    size: 128,
                    thumbSize: 16,
                    thumbValue: false,
                    trackImages: [],
                    trackStep: 0,
                    value: 0,
                    valueNames: null
                })
                .options($.extend(options, {
                    arrowStep: options.arrowStep ?
                        options.arrowStep : options.step,
                    trackImages: $.makeArray(options.trackImages || [])
                }))
                .addClass('OxRange')
                .css({
                    width: self.options.size + 'px'
                });

        // fixme: self. ... ?
        var trackImages = self.options.trackImages.length,
            values = (self.options.max - self.options.min + self.options.step) /
                    self.options.step;

        /*
            construct
        */
        that.$element
            .css({
                width: self.options.size + 'px'
            });
        if (self.options.arrows) {
            var $arrowDec = Ox.Button({
                    style: 'symbol',
                    type: 'image',
                    value: self.options.arrowSymbols[0]
                })
                .addClass('OxArrow')
                .mousedown(mousedownArrow)
                .click(clickArrowDec)
                .appendTo(that.$element);
        }
        var $track = new Ox.Element()
                .addClass('OxTrack')
                .mousedown(clickTrack)
                .appendTo(that.$element);

        if (trackImages) {
            var width = parseFloat(screen.width / trackImages),
                $image = $('<canvas>')
                    .attr({
                        width: width * trackImages,
                        height: 14
                    })
                    .addClass('OxImage')
                    .appendTo($track.$element),
                c = $image[0].getContext('2d');
                c.mozImageSmoothingEnabled = false; // we may want to remove this later
            $.each(self.options.trackImages, function(i, v) {
                var left = 0;
                $('<img/>')
                    .attr({
                        src: v
                    })
                    .load(function() {
                        c.drawImage(this, left, 0, self.trackImageWidth[i], 14);
                    });
                left += self.trackImageWidth[i];
            });
        }
        var $thumb = Ox.Button({})
            .addClass('OxThumb')
            .appendTo($track);
            Ox.print('----')
        if (self.options.arrows) {
            var $arrowInc = Ox.Button({
                    style: 'symbol',
                    type: 'image',
                    value: self.options.arrowSymbols[1]
                })
                .addClass('OxArrow')
                .mousedown(mousedownArrow)
                .click(clickArrowInc)
                .appendTo(that.$element);
        }
        var rangeWidth, trackWidth, imageWidth, thumbWidth;
        setWidth(self.options.size);

        /*
            private functions
        */

        function clickArrowDec() {
            that.removeClass('OxActive');
            setValue(self.options.value - self.options.arrowStep, 200)
        }
        function clickArrowInc() {
            that.removeClass('OxActive');
            setValue(self.options.value + self.options.arrowStep, 200);
        }
        function clickTrack(e) {
            //Ox.Focus.focus();
            var left = $track.offset().left,
                offset = $(e.target).hasClass('OxThumb') ?
                    e.clientX - $thumb.offset().left - thumbWidth / 2 - 2 : 0;
            function val(e) {
                return getVal(e.clientX - left - offset);
            }
            setValue(val(e), 200);
            $window.mousemove(function(e) {
                setValue(val(e));
            });
            $window.one('mouseup', function() {
                $window.unbind('mousemove');
            });
        }
        function getPx(val) {
            var pxPerVal = (trackWidth - thumbWidth - 2) /
                        (self.options.max - self.options.min);
            return Math.ceil((val - self.options.min) * pxPerVal + 1);
        }
        function getVal(px) {
            var px = trackWidth / values >= 16 ? px : px - 8,
                valPerPx = (self.options.max - self.options.min) /
                        (trackWidth - thumbWidth);
            return Ox.limit(self.options.min +
                    Math.floor(px * valPerPx / self.options.step) * self.options.step,
                    self.options.min, self.options.max);
        }
        function mousedownArrow() {
            that.addClass('OxActive');
        }
        function setThumb(animate) {
            var animate = typeof animate != 'undefined' ? animate : 0;
            $thumb.animate({
                marginLeft: (getPx(self.options.value) - 2) + 'px',
                width: thumbWidth + 'px'
            }, self.options.animate ? animate : 0, function() {
                if (self.options.thumbValue) {
                    $thumb.options({
                        value: self.options.valueNames ?
                                self.options.valueNames[self.options.value] :
                                self.options.value
                    });
                }
            });
        }
        function setValue(val, animate) {
            val = Ox.limit(val, self.options.min, self.options.max);
            if (val != self.options.value) {
                that.options({
                    value: val
                });
                setThumb(animate);
                that.triggerEvent('change', { value: val });
            }
        }
        function setWidth(width) {
            trackWidth = width - self.options.arrows * 32;
            thumbWidth = Math.max(trackWidth / values - 2, self.options.thumbSize - 2);
            that.$element.css({
                width: (width - 2) + 'px'
            });
            $track.css({
                width: (trackWidth - 2) + 'px'
            });
            if (trackImages) {
                $image.css({
                    width: (trackWidth - 2) + 'px'
                });
            }
            $thumb.css({
                width: (thumbWidth - 2) + 'px',
                padding: 0
            });
            setThumb();
        }

        /*
            shared functions
        */

        self.onChange = function(key, value) {
            
        }

        return that;

    };

    /*
    ============================================================================
    Lists
    ============================================================================
    */

    Ox.IconList = function(options, self) {

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    id: '',
                    item: function() {},
                    keys: [],
                    orientation: 'both',
                    request: function() {},
                    size: 128,
                    sort: [],
                })
                .options(options || {});

        $.extend(self, {
            itemHeight: self.options.size * 1.5,
            itemWidth: self.options.size
        });

        that.$element = new Ox.List({
                construct: constructItem,
                id: self.options.id,
                itemHeight: self.itemHeight,
                itemWidth: self.itemWidth,
                keys: self.options.keys,
                orientation: self.options.orientation,
                keys: self.options.keys,
                request: self.options.request,
                size: self.options.size,
                sort: self.options.sort,
                type: 'icon',
                unique: self.options.unique
            }/*, self*/)
            .addClass('OxIconList Ox' + Ox.toTitleCase(self.options.orientation))
            .click(click)
            .dblclick(dblclick)
            .scroll(scroll);

        updateKeys();

        function click() {
            
        }

        function constructItem(data) {
            var data = self.options.item(data, self.options.sort, self.options.size),
                ratio = data.width / data.height;
            return new Ox.IconItem($.extend(data, {
                height: self.options.size / (ratio <= 1 ? 1 : ratio),
                size: self.options.size,
                width: self.options.size * (ratio >= 1 ? 1 : ratio)
            }));
        }

        function dblclick() {
            
        }

        function scroll() {
            
        }

        function updateKeys() {
            self.options.keys = Ox.unique($.merge(self.options.keys, [self.options.sort[0].key]));
            that.$element.options({
                keys: self.options.keys
            });
        }

        self.onChange = function(key, value) {
            if (key == 'request') {
                that.$element.options(key, value);
            }
        }

        that.sortList = function(key, operator) {
            self.options.sort = [{
                key: key,
                operator: operator
            }];
            updateKeys();
            that.$element.sortList(key, operator);
        }

        return that;

    };

    Ox.IconItem = function(options, self) {

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    height: 128,
                    id: '',
                    info: '',
                    size: 128,
                    title: '',
                    width: 128,
                    url: ''
                })
                .options(options || {})

        $.extend(self, {
            fontSize: self.options.size == 64 ? 7 : 9,
            height: self.options.size * 1.5,
            lineLength: self.options.size == 64 ? 17 : 23,
            lines: self.options.size == 64 ? 4 : 5,
            url: oxui.path + '/png/ox.ui/transparent.png',
            width: self.options.size
        });
        self.title = formatText(self.options.title, self.lines - 1, self.lineLength);
        self.info = formatText(self.options.info, 5 - self.title.split('<br/>').length, self.lineLength);

        that.css({
            width: self.width + 'px',
            height: self.height + 'px'
        });
        that.$icon = $('<div>')
            .addClass('OxIcon')
            .css({
                top: self.options.size == 64 ? -72 : -124,
                width: (self.options.size + 4) + 'px',
                height: (self.options.size + 4) + 'px'
            });
        that.$iconImage = $('<img>')
            .addClass('OxLoading OxTarget')
            .attr({
                src: self.url
            })
            .css({
                width: self.options.width + 'px',
                height: self.options.height + 'px'
            })
            .mousedown(mousedown)
            .mouseenter(mouseenter)
            .mouseleave(mouseleave)
            .one('load', load);
        that.$textBox = $('<div>')
            .addClass('OxText')
            .css({
                top: (self.options.size / 2) + 'px',
                width: (self.options.size + 4) + 'px',
                height: (self.options.size == 64 ? 38 : 58) + 'px'
            })
        that.$text = $('<div>')
            .addClass('OxTarget')
            .css({
                fontSize: self.fontSize + 'px'
            })
            .html(
                self.title + '<br/><span class="OxInfo">' + self.info + '</span>'
            )
            .mouseenter(mouseenter)
            .mouseleave(mouseleave);
        that.$reflection = $('<div>')
            .addClass('OxReflection')
            .css({
                top: self.options.size + 'px',
                width: (self.options.size + 4) + 'px',
                height: (self.options.size / 2) + 'px'
            });
        that.$reflectionImage = $('<img>')
            .addClass('OxLoading')
            .attr({
                src: self.url
            })
            .css({
                width: self.options.width + 'px',
                height: self.options.height + 'px',
                // firefox is 1px off when centering images with odd width and scaleY(-1)
                paddingLeft: ($.browser.mozilla && self.options.width % 2 ? 1 : 0) + 'px'
            });
        that.$gradient = $('<div>')
            .css({
                //top: (-self.options.size / 2) + 'px',
                width: self.options.width + 'px',
                height: (self.options.size / 2) + 'px'
            });

        that.append(
            that.$reflection.append(
                that.$reflectionImage
            ).append(
                that.$gradient
            )
        ).append(
            that.$textBox.append(
                that.$text
            )
        ).append(
            that.$icon.append(
                that.$iconImage
            )
        );

        function formatText(text, maxLines, maxLength) {
            var lines = Ox.wordwrap(text, maxLength, '<br/>', true, false).split('<br/>');
            return $.map(lines, function(line, i) {
                if (i < maxLines - 1) {
                    return line;
                } else if (i == maxLines - 1) {
                    return lines.length == maxLines ? line : Ox.truncate($.map(lines, function(line, i) {
                        return i < maxLines - 1 ? null : line;
                    }).join(' '), maxLength, '...', 'center');
                } else {
                    return null;
                }
            }).join('<br/>');
        }

        function load() {
            that.$iconImage.attr({
                    src: self.options.url
                })
                .one('load', function() {
                    that.$iconImage.removeClass('OxLoading');
                    that.$reflectionImage.removeClass('OxLoading');
                });
            that.$reflectionImage.attr({
                src: self.options.url
            });
        }

        function mousedown(e) {
            e.preventDefault();
        }

        function mouseenter() {
            that.addClass('OxHover');
        }

        function mouseleave() {
            that.removeClass('OxHover');
        }

        return that;

    };

    Ox.List = function(options, self) {

        var self = self || {},
            that = new Ox.Container({}, self)
                .defaults({
                    construct: function() {},
                    itemHeight: 16,
                    itemWidth: 16,
                    keys: [],
                    orientation: 'vertical',
                    pageLength: 100,
                    request: function() {}, // (sort:, range:, callback:), without parameter returns {items, size etc.}
                    //rowLength: 1,
                    sort: [],
                    type: 'text',
                    unique: ''
                })
                .options(options || {})
                .mousedown(mousedown)
                .scroll(scroll);

        $.extend(self, {
            $items: [],
            $pages: [],
            clickTimeout: 0,
            ids: {},
            itemMargin: self.options.type == 'text' ? 0 : 8, // 2 x 4 px margin ... fixme: the 2x should be computed later
            keyboardEvents: {
                key_alt_control_a: invertSelection,
                key_control_a: selectAll,
                key_control_shift_a: selectNone,
                key_end: scrollToFirst,
                key_enter: open,
                key_home: scrollToLast,
                key_pagedown: scrollPageDown,
                key_pageup: scrollPageUp,
                key_section: preview, // fixme: firefox gets keyCode 0 when pressing space
                key_space: preview
            },
            listMargin: self.options.type == 'text' ? 0 : 8, // 2 x 4 px padding
            page: 0,
            preview: false,
            requests: [],
            scrollTimeout: 0,
            selected: []
        });
        self.keyboardEvents['key_' + (self.options.orientation == 'vertical' ? 'up' : 'left')] = selectPrevious;
        self.keyboardEvents['key_' + (self.options.orientation == 'vertical' ? 'down' : 'right')] = selectNext;
        self.keyboardEvents['key_' + (self.options.orientation == 'vertical' ? 'shift_up' : 'shift_left')] = addPreviousToSelection;
        self.keyboardEvents['key_' + (self.options.orientation == 'vertical' ? 'shift_down' : 'shift_right')] = addNextToSelection;
        if (self.options.orientation == 'both') {
            $.extend(self.keyboardEvents, {
                key_down: selectBelow,
                key_up: selectAbove,
                key_shift_down: addBelowToSelection,
                key_shift_up: addAboveToSelection
            });
            self.pageLengthByRowLength = [
                0, 60, 60, 60, 60, 60, 60, 63, 64, 63, 60, 66, 60, 65, 70, 60, 64, 68, 72, 76, 60
            ];
        }

        updateQuery();
        Ox.print('s.o', self.options)
        that.addEvent(self.keyboardEvents);
        $window.resize(resize);

        function addAboveToSelection() {
            var pos = getAbove();
            if (pos > -1) {
                addToSelection(pos);
                scrollTo(pos);
            }
        }

        function addAllToSelection(pos) {
            var arr,
                len = self.$items.length;
            if (!isSelected(pos)) {
                if (self.selected.length == 0) {
                    addToSelection(pos);
                } else {
                    if (Ox.min(self.selected) < pos) {
                         var arr = [pos];
                         for (var i = pos - 1; i >= 0; i--) {
                             if (isSelected(i)) {
                                 $.each(arr, function(i, v) {
                                     addToSelection(v);
                                 });
                                 break;
                             }
                             arr.push(i);
                         }
                    }
                    if (Ox.max(self.selected) > pos) {
                        var arr = [pos];
                        for (var i = pos + 1; i < len; i++) {
                            if (isSelected(i)) {
                                $.each(arr, function(i, v) {
                                    addToSelection(v);
                                });
                                break;
                            }
                            arr.push(i);
                        }
                    }
                }
            }
        }

        function addBelowToSelection() {
            var pos = getBelow();
            if (pos > -1) {
                addToSelection(pos);
                scrollTo(pos);
            }
        }

        function addNextToSelection() {
            var pos = getNext();
            if (pos > -1) {
                addToSelection(pos);
                scrollTo(pos);
            }
        }

        function addPreviousToSelection() {
            var pos = getPrevious();
            if (pos > -1) {
                addToSelection(pos);
                scrollTo(pos);
            }
        }

        function addToSelection(pos) {
            if (!isSelected(pos)) {
                self.selected.push(pos);
                if (!Ox.isUndefined(self.$items[pos])) {
                    self.$items[pos].addClass('OxSelected');
                }
                Ox.print('addToSelection')
                triggerSelectEvent();
            } else {
                // allow for 'cursor navigation' if orientation == 'both'
                self.selected.splice(self.selected.indexOf(pos), 1);
                self.selected.push(pos);
                Ox.print('self.selected', self.selected)
            }
        }

        function clear() {
            $.each(self.requests, function(i, v) {
                Ox.print('Ox.Request.cancel', v);
                Ox.Request.cancel(v);
            });
            $.extend(self, {
                $items: [],
                $pages: [],
                page: 0,
                requests: []
            });
        }

        function deselect(pos) {
            if (isSelected(pos)) {
                self.selected.splice(self.selected.indexOf(pos), 1);
                if (!Ox.isUndefined(self.$items[pos])) {
                    self.$items[pos].removeClass('OxSelected');
                }
                triggerSelectEvent();
            }
        }

        function findItem(e) {
            var $element = $(e.target),
                $item = null;
            while (!$element.hasClass('OxTarget') && !$element.hasClass('OxPage') && !$element.is('body')) {
                $element = $element.parent();
            }
            if ($element.hasClass('OxTarget')) {
                while (!$element.hasClass('OxItem') && !$element.hasClass('OxPage') && !$element.is('body')) {
                    $element = $element.parent();
                }
                if ($element.hasClass('OxItem')) {
                    $item = $element;
                }
            }
            return $item;
        }

        function getAbove() {
            var pos = -1;
            if (self.selected.length) {
                pos = self.selected[self.selected.length - 1] - self.rowLength
                if (pos < 0) {
                    pos = -1;
                }
            }
            return pos;
        }

        function getBelow() {
            var pos = -1;
            if (self.selected.length) {
                pos = self.selected[self.selected.length - 1] + self.rowLength;
                if (pos >= self.$items.length) {
                    pos = -1;
                }
            }
            return pos;
        }

        function getHeight() {
            return that.height() - (that.$content.width() > that.width() ? oxui.scrollbarSize : 0);
        }

        function getNext() {
            var pos = -1;
            if (self.selected.length) {
                pos = (self.options.orientation == 'both' ?
                    self.selected[self.selected.length - 1] :
                    Ox.max(self.selected)) + 1;
                if (pos == self.$items.length) {
                    pos = -1;
                }
            }
            return pos;
        }

        function getPage() {
            return Math.max(
                Math.floor(self.options.orientation == 'horizontal' ?
                (that.scrollLeft() - self.listMargin / 2) / self.pageWidth :
                (that.scrollTop() - self.listMargin / 2) / self.pageHeight
            ), 0);
        }

        function getPositions() {
            Ox.print('getPositions', $.map(self.selected, function(v, i) {
                return self.ids[v];
            }));
            // fixme: optimize: send non-selected ids if more than half of the items are selected
            if (self.selected.length /*&& self.selected.length < self.listLength*/) {
                self.requests.push(self.options.request({
                    callback: getPositionsCallback,
                    ids: $.map(self.selected, function(v, i) {
                        return self.ids[v];
                    }),
                    sort: self.options.sort
                }));
            } else {
                getPositionsCallback();
            }
        }

        function getPositionsCallback(result) {
            Ox.print('getPositionsCallback', result)
            if (result) {
                $.extend(self, {
                    ids: {},
                    selected: []
                });
                $.each(result.data.positions, function(id, pos) {
                    Ox.print('id', id, 'pos', pos)
                    self.selected.push(pos);
                });
            }
            load();
        }

        function getPrevious() {
            var pos = -1;
            if (self.selected.length) {
                pos = (self.options.orientation == 'both' ?
                    self.selected[self.selected.length - 1] :
                    Ox.min(self.selected)) - 1;
            }
            return pos;
        }

        function getRow(pos) {
            return Math.floor(pos / self.rowLength);
        }

        function getRowLength() {
            return Math.floor((getWidth() - self.listMargin) / (self.options.itemWidth + self.itemMargin));
        }

        function getSelectedIds() {
            // fixme: is carring self.ids around the best way?
            return $.map(self.selected, function(v, i) {
                return self.ids[v];
            });
        }

        function getWidth() {
            return that.width() - (that.$content.height() > that.height() ? oxui.scrollbarSize : 0);
        }

        function invertSelection() {
            $.each(Ox.range(self.listLength), function(i, v) {
                toggleSelection(v);
            });
        }

        function isSelected(pos) {
            return self.selected.indexOf(pos) > -1;
        }

        function load() {
            that.scrollTop(0);
            that.$content.empty();
            loadPages(self.page);
        }

        function loadPage(page, callback) {
            if (page < 0 || page >= self.pages) {
                !Ox.isUndefined(callback) && callback();
                return;
            }
            Ox.print('loadPage', page);
            var keys = $.inArray('id', self.options.keys) > -1 ? self.options.keys :
                        $.merge(self.options.keys, ['id']),
                offset = page * self.pageLength,
                range = [offset, offset + (page < self.pages - 1 ?
                        self.pageLength : self.listLength % self.pageLength)];
            if (Ox.isUndefined(self.$pages[page])) {
                self.requests.push(self.options.request({
                    callback: function(result) {
                        self.$pages[page] = new Ox.ListPage()
                            .css({
                                width: self.pageWidth + 'px'
                            });
                        if (self.options.orientation == 'horizontal') {

                        } else {
                            self.$pages[page].css({
                                top: (page * self.pageHeight + self.listMargin / 2) + 'px'
                            });
                        }
                        $.each(result.data.items, function(i, v) {
                            var pos = offset + i;
                            self.$items[pos] = new Ox.ListItem({
                                construct: self.options.construct,
                                data: v,
                                id: v[self.options.unique],
                                position: pos
                            });
                            self.ids[pos] = v[self.options.unique];
                            if (isSelected(pos)) {
                                self.$items[pos].addClass('OxSelected');
                            }
                            self.$items[pos].appendTo(self.$pages[page]);
                        });
                        if (self.options.type == 'text' && page == 0) {
                            var height = that.height() - (that.width() < that.$content.width() ? oxui.scrollbarSize : 0),
                                visibleItems = Math.ceil(height / self.options.itemHeight);
                            if (result.data.items.length < visibleItems) {
                                self.$pages[page].height(height).css({
                                    overflow: 'hidden'
                                });
                                $.each(Ox.range(result.data.items.length, visibleItems), function(i, v) {
                                    new Ox.ListItem({
                                        construct: self.options.construct,
                                        data: {},
                                        id: '',
                                        position: v
                                    }).appendTo(self.$pages[page]);
                                });
                            }
                        }
                        self.$pages[page].appendTo(that.$content);
                        !Ox.isUndefined(callback) && callback();
                    },
                    keys: keys,
                    range: range,
                    sort: self.options.sort
                }));
            } else {
                self.$pages[page].appendTo(that.$content);
            }
        }

        function loadPages(page, callback) {
            var counter = 0,
                fn = function() {
                    counter++;
                    counter == 2 && !Ox.isUndefined(callback) && callback();
                };
            loadPage(page, function() {
                loadPage(page - 1, fn);
                loadPage(page + 1, fn);
            });
        }

        function mousedown(e) {
            Ox.print('click')
            var $item = findItem(e),
                pos,
                deselectTimeout = false;
                selectTimeout = false;
            that.gainFocus();
            if ($item) {
                if (!self.clickTimeout) {
                    // click
                    pos = $item.data('position');
                    if (e.metaKey) {
                        if (!isSelected(pos)) {
                            addToSelection(pos);
                        } else {
                            deselectTimeout = true;
                        }
                    } else if (e.shiftKey) {
                        addAllToSelection(pos);
                    } else if (!isSelected(pos)) {
                        select(pos);
                    } else {
                        selectTimeout = true;
                    }
                    self.clickTimeout = setTimeout(function() {
                        self.clickTimeout = 0;
                        if (deselectTimeout) {
                            deselect(pos);
                        } else if (selectTimeout) {
                            select(pos);
                        }
                    }, 250);
                } else {
                    // dblclick
                    clearTimeout(self.clickTimeout);
                    self.clickTimeout = 0;
                    open();
                }
            } else {
                selectNone();
            }
        }

        function open() {
            that.triggerEvent('open', {
                ids: getSelectedIds()
            });
        }

        function preview() {
            self.preview = !self.preview;
            if (self.preview) {
                that.triggerEvent('openpreview', {
                    ids: getSelectedIds()
                });
            } else {
                that.triggerEvent('closepreview');
            }
        }

        function resize() {
            if (self.options.orientation == 'both') {
                var rowLength = getRowLength(),
                    pageLength = self.pageLengthByRowLength[rowLength];
                if (pageLength != self.pageLength) {
                    updateQuery();
                } else if (rowLength != self.rowLength) {
                    self.rowLength = rowLength;
                    self.pageWidth = (self.options.itemWidth + self.itemMargin) * self.rowLength;
                    $.each(self.$pages, function(i, $page) {
                        $page.css({
                            width: self.pageWidth + 'px'
                        });
                    });
                }
            }
        }

        function scroll() {
            var page = self.page;
            self.scrollTimeout && clearTimeout(self.scrollTimeout);
            self.scrollTimeout = setTimeout(function() {
                self.scrollTimeout = 0;
                self.page = getPage();
                if (self.page != page) {
                    Ox.print('page', page, '-->', self.page);
                }
                if (self.page == page - 1) {
                    unloadPage(self.page + 2);
                    loadPage(self.page - 1);
                } else if (self.page == page + 1) {
                    unloadPage(self.page - 2);
                    loadPage(self.page + 1);
                } else if (self.page == page - 2) {
                    unloadPage(self.page + 3);
                    unloadPage(self.page + 2);
                    loadPage(self.page);
                    loadPage(self.page - 1);
                } else if (self.page == page + 2) {
                    unloadPage(self.page - 3);
                    unloadPage(self.page - 2);
                    loadPage(self.page);
                    loadPage(self.page + 1);
                } else if (self.page != page) {
                    unloadPages(page);
                    loadPages(self.page);
                }
            }, 250);
        }

        function scrollPageDown() {
            that.scrollBy(getHeight());
        }

        function scrollPageUp() {
            that.scrollBy(-getHeight());
        }

        function scrollTo(pos) {
            var itemHeight = self.options.itemHeight + self.itemMargin,
                itemWidth = self.options.itemWidth + self.itemMargin,
                positions = [],
                scroll,
                size;
            if (self.options.orientation == 'horizontal') {
                positions[0] = pos * itemWidth;
                positions[1] = positions[0] + itemWidth;
                scroll = that.scrollLeft();
                size = getWidth();
            } else {
                positions[0] = (self.options.orientation == 'vertical' ? pos : getRow(pos)) * itemHeight;
                positions[1] = positions[0] + itemHeight + (self.options.orientation == 'vertical' ? 0 : self.itemMargin);
                scroll = that.scrollTop();
                size = getHeight();
                if (positions[0] < scroll) {
                    that.animate({
                        scrollTop: positions[0] + 'px'
                    }, 0);
                } else if (positions[1] > scroll + size) {
                    that.animate({
                        scrollTop: (positions[1] - size) + 'px'
                    }, 0);
                }
            }
        }

        function scrollToFirst() {
            that.scrollTop(0);
        }

        function scrollToLast() {
            that.scrollTop(self.listHeight);
        }

        function select(pos) {
            if (!isSelected(pos) || self.selected.length > 1) {
                selectNone();
                addToSelection(pos);
            }
        }

        function selectAbove() {
            var pos = getAbove();
            if (pos > -1) {
                select(pos);
                scrollTo(pos);
            }
        }

        function selectAll() {
            $.each(Ox.range(self.listLength), function(i, v) {
                Ox.print('adding', v);
                addToSelection(v);
            });
        }

        function selectBelow() {
            var pos = getBelow();
            if (pos > -1) {
                select(pos);
                scrollTo(pos);
            }
        }

        function selectNext() {
            var pos = getNext();
            if (pos > -1) {
                select(pos);
                scrollTo(pos);
            }
        }

        function selectNone() {
            $.each(self.$items, function(i, v) {
                deselect(i);
            });
        }

        function selectPrevious() {
            var pos = getPrevious();
            if (pos > -1) {
                select(pos);
                scrollTo(pos);
            }
        }

        function selectQuery(str) {
            $.each(self.$items, function(i, v) {
                if (Ox.toLatin(v.title).toUpperCase().indexOf(str) == 0) {
                    select(i);
                    scrollTo(i);
                    return false;
                }
            });
        }

        function toggleSelection(pos) {
            if (!isSelected(pos)) {
                addToSelection(pos);
            } else {
                deselect(pos);
            }
        }

        function triggerSelectEvent() {
            var ids = getSelectedIds();
            setTimeout(function() {
                var ids_ = getSelectedIds();
                Ox.print('ids', ids, 'ids after 100 msec', ids_)
                if (ids.length == ids_.length && (ids.length == 0 || ids[0] == ids_[0])) {
                    that.triggerEvent('select', {
                        ids: ids
                    });
                    self.preview && that.triggerEvent('openpreview', {
                        ids: ids
                    });
                } else {
                    Ox.print('select event not triggered after timeout');
                }
            }, 100);
        }

        function unloadPage(page) {
            if (page < 0 || page >= self.pages) {
                return;
            }
            Ox.print('unloadPage', page)
            Ox.print('self.$pages', self.$pages)
            Ox.print('page not undefined', !Ox.isUndefined(self.$pages[page]))
            if (!Ox.isUndefined(self.$pages[page])) {
                self.$pages[page].remove();
                delete self.$pages[page];
            }
        }

        function unloadPages(page) {
            unloadPage(page);
            unloadPage(page - 1);
            unloadPage(page + 1)
        }

        function updateQuery() {
            clear();
            self.requests.push(self.options.request({
                callback: function(result) {
                    var keys = {};
                    that.triggerEvent('load', result.data);
                    self.rowLength = getRowLength();
                    self.pageLength = self.options.orientation == 'both' ?
                            self.pageLengthByRowLength[self.rowLength] :
                            self.options.pageLength;
                    $.extend(self, {
                        listHeight: Math.ceil(result.data.items * (self.options.itemHeight + self.itemMargin) / self.rowLength), // fixme: should be listSize
                        listLength: result.data.items,
                        pages: Math.ceil(result.data.items / self.pageLength),
                        pageWidth: self.options.orientation == 'vertical' ? 0 :
                                (self.options.itemWidth + self.itemMargin) * self.rowLength,
                        pageHeight: self.options.orientation == 'horizontal' ? 0 :
                                Math.ceil(self.pageLength * (self.options.itemHeight + self.itemMargin) / self.rowLength)
                    });
                    Ox.print('list self', self);
                    that.$content.css({
                        height: self.listHeight + 'px'
                    });
                    getPositions();
                }
            }));
        }

        function updateSort() {
            if (self.listLength > 1) {
                clear();
                getPositions();
            }
        }

        self.onChange = function(key, value) {
            Ox.print('list onChange', key, value);
            if (key == 'request') {
                updateQuery();
            }
        };

        that.clearCache = function() { // used by TextList resizeColumn
            self.$pages = [];
            return that;
        };

        that.reload = function() {
            clear();
            that.clearCache();
            that.$content.empty();
            loadPages(self.page);
            return that;
        };

        that.sortList = function(key, operator) {
            if (key != self.options.sort[0].key || operator != self.options.sort[0].operator) {
                self.options.sort[0] = {
                    key: key,
                    operator: operator
                }
                that.triggerEvent('sort', self.options.sort[0]);
                updateSort();
            }
            return that;
        }

        return that;

    };

    Ox.ListItem = function(options, self) {

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    construct: function() {},
                    data: {},
                    id: '',
                    position: 0
                })
                .options(options || {});

        $.each(self.options.data, function(k, v) {
            self.options.data[k] = $.isArray(v) ? v.join(', ') : v;
        });

        that.$element = self.options.construct(self.options.data)
            .addClass('OxItem')
            .attr({
                id: self.options.id
            })
            .data('position', self.options.position);

        return that;

    };

    Ox.ListPage = function(options, self) {
        var self = self || {},
            that = new Ox.Element({}, self)
                .addClass('OxPage');
        return that;
    };

    Ox.TextList = function(options, self) {

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    columns: [],
                    columnsMovable: false,
                    columnsRemovable: false,
                    columnWidth: [40, 800],
                    id: '',
                    request: function() {}, // {sort, range, keys, callback}
                    sort: []
                })
                .options(options || {})
                .addClass('OxTextList');

        $.each(self.options.columns, function(i, v) { // fixme: can this go into a generic ox.js function?
            if (Ox.isUndefined(v.unique)) {
                v.unique = false;
            }
            if (Ox.isUndefined(v.visible)) {
                v.visible = false;
            }
            if (v.unique) {
                self.unique = v.id;
            }
        });

        $.extend(self, {
            columnPositions: [],
            itemHeight: 16,
            page: 0,
            pageLength: 100,
            scrollLeft: 0,
            selectedColumn: getColumnIndexById(self.options.sort[0].key),
            visibleColumns: $.map(self.options.columns, function(v, i) {
                return v.visible ? v : null;
            })
        });
        $.extend(self, {
            columnWidths: $.map(self.visibleColumns, function(v, i) {
                return v.width;
            }),
            pageHeight: self.options.pageLength * self.itemHeight
        });

        // Head

        that.$bar = new Ox.Bar({
            orientation: 'horizontal',
            size: 16
        }).appendTo(that);
        that.$head = new Ox.Container()
            .addClass('OxHead')
            .appendTo(that.$bar);
        that.$head.$content.addClass('OxTitles');
        constructHead();
        if (self.options.columnsRemovable) {
            that.$select = new Ox.Select({
                    id: self.options.id + 'SelectColumns',
                    items: $.map(self.options.columns, function(v, i) {
                        return {
                            checked: v.visible,
                            disabled: v.removable === false,
                            id: v.id,
                            title: v.title
                        }
                    }),
                    max: -1,
                    min: 1,
                    type: 'image'
                })
                .bindEvent('change', changeColumns)
                .appendTo(that.$bar.$element);
        }

        // Body

        that.$body = new Ox.List({
                construct: constructItem,
                id: self.options.id,
                itemHeight: 16,
                itemWidth: getItemWidth(),
                keys: $.map(self.visibleColumns, function(v, i) {
                    return v.id;
                }),
                orientation: 'vertical',
                request: self.options.request,
                sort: self.options.sort,
                type: 'text',
                unique: self.unique
            })
            .addClass('OxBody')
            .scroll(function() {
                var scrollLeft = $(this).scrollLeft();
                if (scrollLeft != self.scrollLeft) {
                    self.scrollLeft = scrollLeft;
                    that.$head.scrollLeft(scrollLeft);
                }
            })
            .appendTo(that);
        that.$body.$content.css({
            width: getItemWidth() + 'px'
        });

        Ox.print('s.vC', self.visibleColumns)

        function addColumn(id) {
            Ox.print('addColumn', id);
            var column,
                index = 0;
            $.each(self.options.columns, function(i, v) {
                if (v.visible) {
                    index++;
                } else if (v.id == id) {
                    column = v;
                    return false;
                }
            });
            column.visible = true;
            self.visibleColumns.splice(index, 0, column);
            self.columnWidths.splice(index, 0, column.width);
            that.$head.$content.empty();
            constructHead();
            that.$body.options({
                keys: $.map(self.visibleColumns, function(v, i) {
                    return v.id;
                })
            });
            that.$body.reload();
        }

        function changeColumns(event, data) {
            var add,
                ids = [];
            $.each(data.selected, function(i, column) {
                var index = getColumnIndexById(column.id);
                if (!self.options.columns[index].visible) {
                    addColumn(column.id);
                    add = true;
                    return false;
                }
                ids.push(column.id);
            });
            if (!add) {
                $.each(self.visibleColumns, function(i, column) {
                    if (ids.indexOf(column.id) == -1) {
                        removeColumn(column.id);
                        return false;
                    }
                });
            }
        }

        function clickColumn(id) {
            Ox.print('clickColumn', id);
            var i = getColumnIndexById(id),
                isSelected = self.options.sort[0].key == self.options.columns[i].id;
            that.sortList(
                self.options.columns[i].id, isSelected ?
                (self.options.sort[0].operator === '' ? '-' : '') :
                self.options.columns[i].operator
            );
        }

        function constructHead() {
            var offset = 0;
            that.$titles = [];
            self.columnOffsets = [];
            $.each(self.visibleColumns, function(i, v) {
                var $order, $resize, $left, $center, $right, timeout = 0;
                offset += self.columnWidths[i];
                self.columnOffsets[i] = offset - self.columnWidths[i] / 2;
                that.$titles[i] = $('<div>')
                    .addClass('OxTitle OxColumn' + Ox.toTitleCase(v.id))
                    .css({
                        width: (self.columnWidths[i] - 9) + 'px',
                        textAlign: v.align
                    })
                    .html(v.title)
                    .mousedown(function(e) {
                        timeout = setTimeout(function() {
                            self.options.columnsMovable && dragColumn(v.id, e);
                            timeout = 0;
                        }, 250);
                    })
                    .mouseup(function() {
                        if (timeout) {
                            clearTimeout(timeout);
                            timeout = 0;
                            clickColumn(v.id);
                        }
                    })
                    .appendTo(that.$head.$content.$element);
                $order = $('<div>')
                    .addClass('OxOrder')
                    .html(oxui.symbols['triangle_' + (
                        v.operator === '' ? 'up' : 'down'
                    )])
                    .click(function() {
                        $(this).prev().trigger('click')
                    })
                    .appendTo(that.$head.$content.$element);
                $resize = $('<div>')
                    .addClass('OxResize')
                    .mousedown(function(e) {
                        var startWidth = self.columnWidths[i],
                            startX = e.clientX;
                        $window.mousemove(function(e) {
                            var x = e.clientX,
                                width = Ox.limit(
                                    startWidth - startX + x,
                                    self.options.columnWidth[0],
                                    self.options.columnWidth[1]
                                );
                                resizeColumn(v.id, width);
                        });
                        $window.one('mouseup', function() {
                            $window.unbind('mousemove');
                        });
                    })
                    .dblclick(function() {
                        resizeColumn(v.id, v.width);
                    })
                    .appendTo(that.$head.$content.$element);
                $left = $('<div>').addClass('OxLeft').appendTo($resize);
                $center = $('<div>').addClass('OxCenter').appendTo($resize);
                $right = $('<div>').addClass('OxRight').appendTo($resize);
            });
            that.$head.$content.css({
                width: (Ox.sum(self.columnWidths) + 2) + 'px'
            });
            Ox.print('s.sC', self.selectedColumn)
            Ox.print('s.cO', self.columnOffsets)
            if (getColumnPositionById(self.options.columns[self.selectedColumn].id) > -1) { // fixme: save in var
                toggleSelected(self.options.columns[self.selectedColumn].id);
                that.$titles[getColumnPositionById(self.options.columns[self.selectedColumn].id)].css({
                    width: (self.options.columns[self.selectedColumn].width - 25) + 'px'
                });
            }
        }

        function constructItem(data) {
            var $item = $('<div>')
                    .addClass('OxTarget')
                    .css({
                        width: getItemWidth() + 'px'
                    });
            $.each(self.visibleColumns, function(i, v) {
                var $cell = $('<div>')
                        .addClass('OxCell OxColumn' + Ox.toTitleCase(v.id))
                        .css({
                            width: (self.columnWidths[i] - 9) + 'px',
                            textAlign: v.align
                        })
                        .html(!$.isEmptyObject(data) ? data[v.id] : '')
                        .appendTo($item);
            });
            return $item;
        }

        function dragColumn(id, e) {
            var startX = e.clientX,
                startPos = getColumnPositionById(id),
                pos = startPos,
                stopPos = startPos,
                offsets = $.map(self.visibleColumns, function(v, i) {
                    return self.columnOffsets[i] - self.columnOffsets[startPos]
                });
            $('.OxColumn' + Ox.toTitleCase(id)).css({
                opacity: 0.25
            });
            that.$titles[startPos].addClass('OxDrag').css({ // fixme: why does the class not work?
                cursor: 'move'
            });
            Ox.print('offsets', offsets)
            $window.mousemove(function(e) {
                var d = e.clientX - startX;
                $.each(offsets, function(i, v) {
                    if (d < 0 && d < v) {
                        stopPos = i;
                        return false;
                    } else if (d > 0 && d > v) {
                        stopPos = i;
                    }
                });
                if (stopPos != pos) {
                    pos = stopPos;
                    moveColumn(id, pos);
                }
            });
            $window.one('mouseup', function() {
                dropColumn(id, pos);
                $window.unbind('mousemove');
            });
        }

        function dropColumn(id, pos) {
            Ox.print('dropColumn', id, pos)
            var startPos = getColumnPositionById(id),
                stopPos = pos,
                $title = that.$titles.splice(startPos, 1)[0],
                column = self.visibleColumns.splice(startPos, 1)[0],
                width = self.columnWidths.splice(startPos, 1)[0];
            self.visibleColumns.splice(stopPos, 0, column);
            self.columnWidths.splice(stopPos, 0, width);
            that.$head.$content.empty();
            constructHead();
            Ox.print('s.vC', self.visibleColumns)
            $('.OxColumn' + Ox.toTitleCase(id)).css({
                opacity: 1
            });
            that.$titles[stopPos].removeClass('OxDrag').css({
                cursor: 'pointer'
            });
            that.$body.clearCache();
        }

        function getColumnIndexById(id) {
            var pos = -1;
            $.each(self.options.columns, function(i, v) {
                if (v.id == id) {
                    pos = i;
                    return false;
                }
            });
            return pos;
        }

        function getColumnPositionById(id) {
            var pos = -1;
            $.each(self.visibleColumns, function(i, v) {
                if (v.id == id) {
                    pos = i;
                    return false;
                }
            });
            return pos;
        }

        function getItemWidth() {
            return Math.max(Ox.sum(self.columnWidths), that.$element.width() - oxui.scrollbarSize);
            //return Ox.sum(self.columnWidths)
        }

        function moveColumn(id, pos) {
            // fixme: column head should be one element, not three
            Ox.print('moveColumn', id, pos)
            var startPos = getColumnPositionById(id),
                stopPos = pos,
                startClassName = '.OxColumn' + Ox.toTitleCase(id),
                stopClassName = '.OxColumn' + Ox.toTitleCase(self.visibleColumns[stopPos].id),
                insert = startPos < stopPos ? 'insertAfter' : 'insertBefore'
                $column = $('.OxTitle' + startClassName),
                $order = $column.next(),
                $resize = $order.next();
            Ox.print(startClassName, insert, stopClassName)
            $column.detach()[insert](insert == 'insertAfter' ? $('.OxTitle' + stopClassName).next().next() : $('.OxTitle' + stopClassName));
            $order.detach().insertAfter($column);
            $resize.detach().insertAfter($order);
            $.each(that.$body.find('.OxItem'), function(i, v) {
                var $v = $(v);
                $v.children(startClassName).detach()[insert]($v.children(stopClassName));
            });
            column = self.visibleColumns.splice(startPos, 1)[0],
            width = self.columnWidths.splice(startPos, 1)[0];
            self.visibleColumns.splice(stopPos, 0, column);
            self.columnWidths.splice(stopPos, 0, width);
        }

        function removeColumn(id) {
            Ox.print('removeColumn', id);
            var className = '.OxColumn' + Ox.toTitleCase(id),
                index = getColumnIndexById(id),
                itemWidth,
                position = getColumnPositionById(id),
                $column = $('.OxTitle' + className),
                $order = $column.next(),
                $resize = $order.next();
            self.options.columns[index].visible = false;
            self.visibleColumns.splice(position, 1);
            self.columnWidths.splice(position, 1);
            that.$head.$content.empty();
            constructHead();
            itemWidth = getItemWidth();
            $.each(that.$body.find('.OxItem'), function(i, v) {
                var $v = $(v);
                $v.children(className).remove();
                $v.css({
                    width: itemWidth + 'px'
                });
            });
            that.$body.$content.css({
                width: itemWidth + 'px'
            });
            that.$body.options({
                keys: $.map(self.visibleColumns, function(v, i) {
                    return v.id;
                })
            });
            that.$body.clearCache();
        }

        function resize() {
            
        }

        function resizeColumn(id, width) {
            var i = getColumnIndexById(id),
                pos = getColumnPositionById(id);
            self.columnWidths[pos] = width;
            that.$head.$content.css({
                width: (Ox.sum(self.columnWidths) + 2) + 'px'
            });
            that.$titles[pos].css({
                width: (width - 9 - (i == self.selectedColumn ? 16 : 0)) + 'px'
            });
            that.$body.$content.find('.OxItem').css({ // fixme: can we avoid this lookup?
                width: getItemWidth() + 'px'
            });
            that.$body.$content.css({
                width: getItemWidth() + 'px' // fixme: check if scrollbar visible, and listen to resize/toggle event
            });
            $('.OxCell.OxColumn' + Ox.toTitleCase(self.options.columns[i].id)).css({
                width: (width - 9) + 'px'
            });
            that.$body.clearCache();
        }

        function toggleSelected(id) {
            var pos = getColumnPositionById(id);
            if (pos > -1) {
                updateOrder(id);
                pos > 0 && that.$titles[pos].prev().children().eq(2).toggleClass('OxSelected');
                that.$titles[pos].toggleClass('OxSelected');
                that.$titles[pos].next().toggleClass('OxSelected');
                that.$titles[pos].next().next().children().eq(0).toggleClass('OxSelected');
                that.$titles[pos].css({
                    width: (
                        that.$titles[pos].width() + (that.$titles[pos].hasClass('OxSelected') ? -16 : 16)
                    ) + 'px'
                });
            }
        }

        function updateOrder(id) {
            var pos = getColumnPositionById(id);
            Ox.print(id, pos)
            that.$titles[pos].next().html(oxui.symbols[
                'triangle_' +  (self.options.sort[0].operator === '' ? 'up' : 'down')
            ]);
        }

        self.onChange = function(key, value) {
            if (key == 'request') {
                that.$body.options(key, value);
            }
        };

        that.closePreview = function() {
            self.preview = false;
        };

        that.resizeColumn = function(id, width) {
            resizeColumn(id, width);
            return that;
        }

        that.sortList = function(key, operator) {
            var isSelected = key == self.options.sort[0].key;
            self.options.sort = [
                {
                    key: key,
                    operator: operator
                }
            ];
            if (isSelected) {
                updateOrder(self.options.columns[self.selectedColumn].id);
            } else {
                toggleSelected(self.options.columns[self.selectedColumn].id);
                self.selectedColumn = getColumnIndexById(key);
                toggleSelected(self.options.columns[self.selectedColumn].id);
            }
            that.$body.sortList(self.options.sort[0].key, self.options.sort[0].operator);
            return that;
        };

        return that;

    };

    /*
    ============================================================================
    Maps
    ============================================================================
    */

    Ox.Map = function(options, self) {

        var self = self || {}
            that = new Ox.Element('div', self)
                .defaults({
                    places: [],
                    type: 'satellite'
                })
                .options(options || {});

        init();

        function canContain(outerBounds, innerBounds) {
            var outerSpan = outerBounds.toSpan(),
                innerSpan = innerBounds.toSpan();
            return outerSpan.lat() > innerSpan.lat() &&
                    outerSpan.lng() > innerSpan.lng();
        }

        function click(event) {
            Ox.print('event', event);
            getLocationByLatLng(event.latLng, self.map.getBounds(), function(location) {
                self.marker && self.marker.remove();
                self.polygon && self.polygon.remove();
                if (location) {
                    self.marker = location.marker.add();
                    self.polygon = location.polygon.add();
                    that.triggerEvent('select', location)
                }
            })
        }

        function getLocationByLatLng(latlng, bounds, callback) {
            Ox.print('ll b', latlng, bounds)
            var callback = arguments.length == 3 ? callback : bounds,
                bounds = arguments.length == 3 ? bounds : null;
            self.geocoder.geocode({
                latLng: latlng
            }, function(results, status) {
                Ox.print('results', results)
                var length = results.length;
                if (status == google.maps.GeocoderStatus.OK) {
                    if (status != google.maps.GeocoderStatus.ZERO_RESULTS) {
                        if (bounds) {
                            $.each(results.reverse(), function(i, result) {
                                if (
                                    i == length - 1 ||
                                    canContain(bounds, result.geometry.bounds || result.geometry.viewport)
                                ) {
                                    callback(new Location(results[i]));
                                    return false;
                                }
                            });
                        } else {
                            callback(new Location(results[0]));
                        }
                    } else {
                        callback(null);
                    }
                } else {
                    Ox.print('geocode failed:', status);
                    callback(null);
                }
            });
        }

        function getLocationByName(name, callback) {
            self.geocoder.geocode({
                address: name
            }, function(results, status) {
                if (status == google.maps.GeocoderStatus.OK) {
                    if (status != google.maps.GeocoderStatus.ZERO_RESULTS) {
                        callback(new Location(results[0]))
                    } else {
                        callback(null);
                    }
                } else {
                    Ox.print('geocode failed:', status);
                    callback(null);
                }
            });
        }

        function init() {
            var counter = 0,
                length = self.options.places.length;
            $.extend(self, {
                geocoder: new google.maps.Geocoder(),
                locations: [],
                selectedMarker: null
            });
            $.each(self.options.places, function(i, place) {
                if (Ox.isString(place)) {
                    self.options.places[i] = {
                        name: place
                    };
                } else if (Ox.isArray(place)) {
                    self.options.places[i] = {
                        name: '',
                        point: place
                    };
                }
                'point' in self.options.places[i] ?
                        getLocationByPoint(self.options.places[i].point, callback) :
                        getLocationByName(self.options.places[i].name, callback);
            });
            function callback(location) {
                if (location) {
                    Ox.print('counter', counter, location)
                    self.locations.push(location);
                    self.bounds = counter == 0 ? location.rectangle.bounds :
                            self.bounds.union(location.rectangle.bounds);
                }
                if (counter++ == length - 1) {
                    loadMap();
                }
            }
        }

        function loadMap() {
            Ox.print('loadMap');
            $.extend(self, {
                map: new google.maps.Map(that.$element[0], {
                    center: self.bounds.getCenter(),
                    disableDefaultUI: true,
                    mapTypeId: google.maps.MapTypeId[self.options.type.toUpperCase()],
                    zoom: 0
                })
            });
            self.map.fitBounds(self.bounds)
            google.maps.event.addListener(self.map, 'click', click);
            google.maps.event.addListener(self.map, 'zoom_changed', zoom);
            $.each(self.locations, function(i, location) {
                location.marker.add();
            });
            resize();
        };

        function resize() {
            google.maps.event.trigger(self.map, 'resize');
        }

        function zoom() {
            that.triggerEvent('zoom', {
                value: self.map.getZoom()
            });
        }

        function Location(geocode) {
            Ox.print('geocode', geocode);
            var bounds = geocode.geometry.bounds || geocode.geometry.viewport,
                area = [
                    [bounds.getSouthWest().lat(), bounds.getSouthWest().lng()],
                    [bounds.getNorthEast().lat(), bounds.getNorthEast().lng()]
                ],
                location = {
                    geocode: geocode,
                    name: {
                        formatted: geocode.formatted_address,
                        long: $.map(geocode.address_components, function(v) {
                            return v.long_name;
                        }).join(', ')
                    },
                    rectangle: new Rectangle(area),
                };
            Ox.print('area', area)
            return $.extend(location, {
                marker: new Marker(location),
                polygon: new Polygon(location)
            });
        }

        function Marker(location) {
            var listeners = {},
                marker = new google.maps.Marker({
                    icon: icon('red'),
                    position: location.rectangle.center,
                    title: location.name.formatted
                }),
                selected = false;
            function click() {
                selected = !selected;
                marker.setOptions({
                    icon: icon(selected ? 'blue' : 'red')
                });
                location.polygon[selected ? 'add' : 'remove']();
            }
            function dblclick() {
                click();
                self.map.fitBounds(location.rectangle.bounds);
            }
            function icon(color) {
                return 'http://dev.pan.do:8000' + oxui.path + 'png/ox.ui/marker' + Ox.toTitleCase(color) + '.png'
            }
            return {
                add: function() {
                    marker.setMap(self.map);
                    listeners = {
                        click: google.maps.event.addListener(marker, 'click', click),
                        dblclick: google.maps.event.addListener(marker, 'dblclick', dblclick),
                    };
                    return this;
                },
                deselect: function() {
                    
                },
                remove: function() {
                    marker.setMap(null);
                    $.each(listeners, function(i, listener) {
                        google.maps.event.removeListener(listener);
                    });
                    return this;
                },
                select: function() {
                    
                }
            };
        }

        function Polygon(location) {
            var listeners = {},
                points = [
                    location.rectangle.latlng.sw,
                    location.rectangle.latlng.nw,
                    location.rectangle.latlng.ne,
                    location.rectangle.latlng.se,
                    location.rectangle.latlng.sw
                ],
                polygon = new google.maps.Polygon({
                    paths: points
                }),
                selected = false;
            setOptions();
            function click() {
                selected = !selected;
                setOptions();
            }
            function setOptions() {
                var color = selected ? '#8080FF' : '#FFFFFF';
                polygon.setOptions({
                    clickable: true,
                    fillColor: color,
                    fillOpacity: selected ? 0.1 : 0,
                    strokeColor: color,
                    strokeOpacity: 1,
                    strokeWeight: 2
                });
            }
            return {
                add: function() {
                    polygon.setMap(self.map);
                    listeners.click = google.maps.event.addListener(polygon, 'click', click);
                    return this;
                },
                deselect: function() {
                    selected = false;
                    setOptions();
                },
                remove: function() {
                    polygon.setMap(null);
                    google.maps.event.removeListener(listeners.click);
                    return this;
                },
                select: function() {
                    selected = true;
                    setOptions();
                }
            };
        }

        function Rectangle(area) {
            var latlng = {
                    sw: new google.maps.LatLng(area[0][0], area[0][1]),
                    ne: new google.maps.LatLng(area[1][0], area[1][1])
                },
                bounds = new google.maps.LatLngBounds(latlng.sw, latlng.ne),
                lat = {},
                lng = {};
            latlng.mc = bounds.getCenter();
            $.each(latlng, function(k, v) {
                lat[k] = v.lat();
                lng[k] = v.lng();
            });
            $.extend(latlng, {
                sc: new google.maps.LatLng(lat.sw, lng.mc),
                se: new google.maps.LatLng(lat.sw, lng.ne),
                mw: new google.maps.LatLng(lat.mc, lng.sw),
                mw: new google.maps.LatLng(lat.mc, lng.ne),
                nw: new google.maps.LatLng(lat.ne, lng.sw),
                nc: new google.maps.LatLng(lat.ne, lng.mc),
            });
            return {
                area: area,
                bounds: bounds,
                canContain: function(rectangle) {
                    var outerSpan = this.bounds.toSpan(),
                        innerSpan = rectangle.bounds.toSpan();
                    return outerSpan.lat() > innerSpan.lat() &&
                            outerSpan.lng() > innerSpan.lng();
                },
                center: latlng.mc,
                contains: function(rectangle) {
                    return this.bounds.contains(rectangle.bounds.getSouthWest()) &&
                            this.bounds.contains(rectangle.bounds.getNorthEast());
                },
                latlng: latlng
            };
        }

        self.onChange = function(key, value) {
            if (key == 'type') {
                
            }
        };

        that.find = function(name, callback) {
            getLocationByName(name, function(location) {
                if (location) {
                    self.polygon && self.polygon.remove();
                    self.polygon = location.polygon.add();
                    self.bounds = location.rectangle.bounds;
                    self.map.fitBounds(self.bounds);
                }
                callback(location);
            });
        };

        that.zoom = function(value) {
            self.map.setZoom(value);
        };

        return that;

    };

    Ox.MapImage = function(options, self) {

        /*
        options:
            height      image height (px)
            places      array of either names (''), points ([0, 0]),
                        or objects ({name, point, highlight})
            type        map type ('hybrid', 'roadmap', 'satellite', 'terrain')
            width       image width (px)
        */

        var self = self || {},
            that = new Ox.Element('img', self)
                .defaults({
                    height: 360,
                    markerColorHighlight: 'yellow',
                    markerColorNormal: 'blue',
                    places: [],
                    type: 'satellite',
                    width: 640
                })
                .options(options || {})

        $.extend(self, {
            markers: {
                highlight: [],
                normal: []
            },
            src: 'http://maps.google.com/maps/api/staticmap?sensor=false' +
                    '&size=' + self.options.width + 'x' + self.options.height +
                    '&maptype=' + self.options.type
        });

        if (self.options.places.length) {
            $.each(self.options.places, function(i, place) {
                if (Ox.isString(place)) {
                    self.markers.normal.push(place);
                } else if (Ox.isArray(place)) {
                    self.markers.normal.push(place.join(','));
                } else {
                    self.markers[place.highlight ? 'highlight' : 'normal']
                        .push('point' in place ? place.point.join(',') : place.name)
                }
            });
            $.each(self.markers, function(k, markers) {
                if (markers.length) {
                    self.src += '&markers=icon:' + 'http://dev.pan.do:8000' + oxui.path + 'png/ox.ui/marker' +
                            Ox.toTitleCase(self.options['markerColor' + Ox.toTitleCase(k)]) + '.png|' +
                            markers.join('|')
                }
            });
        } else {
            self.src += '&center=0,0&zoom=2'
        }

        that.attr({
            src: self.src
        });

        self.onChange = function(key, value) {
            
        };

        return that;

    };

    /*
    ============================================================================
    Menus
    ============================================================================
    */

    Ox.MainMenu = function(options, self) {

        /*  options:
         *      extras
         *      menus
         *      size
         */

        var self = self || {},
            that = new Ox.Bar({}, self)
                .defaults({
                    extras: [],
                    menus: [],
                    size: 'medium'
                })
                .options(options || {})
                .addClass('OxMainMenu Ox' + Ox.toTitleCase(self.options.size)) // fixme: bar should accept small/medium/large ... like toolbar
                .click(click)
                .mousemove(mousemove);

        self.focused = false;
        self.selected = -1;
        that.menus = [];
        that.titles = [];
        that.layer = $('<div>').addClass('OxLayer');

        $.each(self.options.menus, function(position, menu) {
            that.titles[position] = $('<div>')
                .addClass('OxTitle')
                .html(menu.title)
                .data('position', position)
                .appendTo(that.$element);
            that.menus[position] = new Ox.Menu($.extend(menu, {
                    element: that.titles[position],
                    mainmenu: that,
                    size: self.options.size
                }))
                .bindEvent({
                    hide: onHideMenu
                });
        });

        if (self.options.extras.length) {
            that.extras = $('<div>')
                .addClass('OxExtras')
                .appendTo(that.$element);
            $.each(self.options.extras, function(position, extra) {
                extra.css({
                    float: 'left' // fixme: need class!
                }).appendTo(that.extras);
            });
        }

        function click(event) {
            var $target = $(event.target),
                position = typeof $target.data('position') != 'undefined' ?
                        $target.data('position') : -1;
            clickTitle(position);
        }

        function clickTitle(position) {
            var selected = self.selected;
            if (self.selected > -1) {
                that.menus[self.selected].hideMenu();
            }
            if (position > -1) {
                if (position != selected) {
                    self.focused = true;
                    self.selected = position;
                    that.titles[self.selected].addClass('OxSelected');
                    that.menus[self.selected].showMenu();
                }
            }
        }

        function mousemove(event) {
            var $target = $(event.target),
                focused,
                position = typeof $target.data('position') != 'undefined' ?
                        $target.data('position') : -1;
            if (self.focused && position != self.selected) {
                if (position > -1) {
                    clickTitle(position);
                } else {
                    focused = self.focused;
                    that.menus[self.selected].hideMenu();
                    self.focused = focused;
                }
            }
        }

        function onHideMenu() {
            if (self.selected > -1) {
                that.titles[self.selected].removeClass('OxSelected');
                self.selected = -1;
            }
            self.focused = false;
        }

        self.onChange = function(key, value) {

        };

        that.addMenuAfter = function(id) {
            
        };

        that.addMenuBefore = function(id) {
            
        };

        that.checkItem = function(id) {
            var ids = id.split('_'),
                itemId = ids.pop(),
                menuId = ids.join('_');
            that.getMenu(menuId).checkItem(itemId);
        };

        that.disableItem = function(id) {
            that.getItem(id).options({
                disabled: true
            });
        };

        that.enableItem = function(id) {
            that.getItem(id).options({
                disabled: false
            });
        };

        that.getItem = function(id) {
            var ids = id.split('_'),
                item;
            if (ids.length == 1) {
                $.each(that.menus, function(i, menu) {
                    item = menu.getItem(id);
                    return !item;
                });
            } else {
                item = that.getMenu(ids.shift()).getItem(ids.join('_'));
            }
            Ox.print('getItem', id, item);
            return item;
        };

        that.getMenu = function(id) {
            var ids = id.split('_'),
                menu;
            if (ids.length == 1) {
                $.each(that.menus, function(i, v) {
                    if (v.options('id') == id) {
                        menu = v;
                        return false;
                    }
                });
            } else {
                menu = that.getMenu(ids.shift()).getSubmenu(ids.join('_'));
            }
            Ox.print('getMenu', id, menu);
            return menu;
        };

        that.removeMenu = function() {
            
        };

        that.selectNextMenu = function() {
            if (self.selected < self.options.menus.length - 1) {
                clickTitle(self.selected + 1);
            }
        };

        that.selectPreviousMenu = function() {
            if (self.selected) {
                clickTitle(self.selected - 1);
            }
        };

        that.uncheckItem = function(id) {
            that.getItem(id).options({
                checked: false
            });
        };

        return that;

    };

    Ox.Menu = function(options, self) {

        /*

        options:
            element         the element the menu is attached to
            id              the menu id
            items           array of menu items
            mainmenu        the main menu this menu is part of, if any
            offset          offset of the menu, in px
            parent          the supermenu, if any
            selected        the position of the selected item
            side            open to 'bottom' or 'right'
            size            'large', 'medium' or 'small'

        methods:

        events:
            change_groupId  {id, value}     checked item of a group has changed
            click_itemId                    item not belonging to a group was clicked
            click_menuId    {id, value}     item not belonging to a group was clicked
            deselect_menuId {id, value}     item was deselected                             not needed, not implemented
            hide_menuId                     menu was hidden
            select_menuId   {id, value}     item was selected

        */

        var self = self || {},
            that = new Ox.Element({}, self)
                .defaults({
                    element: null,
                    id: '',
                    items: [],
                    mainmenu: null,
                    offset: {
                        left: 0,
                        top: 0
                    },
                    parent: null,
                    selected: -1,
                    side: 'bottom',
                    size: 'medium',
                })
                .options(options || {})
                .addClass(
                    'OxMenu Ox' + Ox.toTitleCase(self.options.side) +
                    ' Ox' + Ox.toTitleCase(self.options.size)
                )
                .click(click)
                .mouseenter(mouseenter)
                .mouseleave(mouseleave)
                .mousemove(mousemove)
                .addEvent({
                    key_up: selectPreviousItem,
                    key_down: selectNextItem,
                    key_left: selectSupermenu,
                    key_right: selectSubmenu,
                    key_escape: hideMenu,
                    key_enter: clickSelectedItem
                }),
            itemHeight = self.options.size == 'small' ? 12 : (self.options.size == 'medium' ? 16 : 20),
            // menuHeight,
            scrollSpeed = 1,
            $item; // fixme: used?
            // fixme: attach all private vars to self

        // construct
        that.items = [];
        that.submenus = {};
        that.$scrollbars = [];
        that.$top = $('<div>')
            .addClass('OxTop')
            .appendTo(that.$element);
        that.$scrollbars.up = constructScrollbar('up')
            .appendTo(that.$element);
        that.$container = $('<div>')
            .addClass('OxContainer')
            .appendTo(that.$element);
        that.$content = $('<table>')
            .addClass('OxContent')
            .appendTo(that.$container);
        constructItems(self.options.items);
        that.$scrollbars.down = constructScrollbar('down')
            .appendTo(that.$element);
        that.$bottom = $('<div>')
            .addClass('OxBottom')
            .appendTo(that.$element);
        that.$layer = $('<div>')
            .addClass(self.options.mainmenu ? 'OxMainMenuLayer' : 'OxLayer')
            .click(click);

        function click(event) {
            var item,
                position,
                $target = $(event.target),
                $parent = $target.parent();
            // necessary for highlight
            if ($parent.is('.OxCell')) {
                $target = $parent;
                $parent = $target.parent();
            }
            if ($target.is('.OxCell')) {
                position = $parent.data('position');
                item = that.items[position];
                if (!item.options('disabled')) {
                    clickItem(position);
                } else {
                    that.hideMenu();
                }
            } else {
                that.hideMenu();
            }
        }

        function clickItem(position) {
            var item = that.items[position],
                toggled;
            that.hideMenu();
            if (!item.options('items').length) {
                if (that.options('parent')) {
                    that.options('parent').hideMenu().triggerEvent('click');
                }
                if (item.options('checked') !== null) {
                    if (item.options('group')) {
                        Ox.print('has group', item.options('group'))
                        toggled = self.optionGroups[item.options('group')].toggle(position);
                        Ox.print('toggled', toggled)
                        if (toggled.length) {
                            $.each(toggled, function(i, pos) {
                                that.items[pos].toggleChecked();
                            });
                            Ox.print('--triggering change event--');
                            (self.options.mainmenu || that).triggerEvent('change', {
                                id: item.options('group'),
                                checked: $.map(self.optionGroups[item.options('group')].checked(), function(v, i) {
                                    return {
                                        id: that.items[v].options('id'),
                                        title: Ox.stripTags(that.items[v].options('title')[0])
                                    };
                                })
                            });
                        }
                    } else {
                        item.toggleChecked();
                        (self.options.mainmenu || that).triggerEvent('change', {
                            checked: item.options('checked'),
                            id: item.options('id'),
                            title: Ox.stripTags(item.options('title')[0])
                        });
                    }
                } else {
                    (self.options.mainmenu || that).triggerEvent('click', {
                        id: item.options('id'),
                        title: Ox.stripTags(item.options('title')[0])
                    });
                }
                if (item.options('title').length == 2) {
                    item.toggleTitle();
                }
            }
        }

        function clickSelectedItem() {
            // called on key.enter
            if (self.options.selected > -1) {
                clickItem(self.options.selected);
            } else {
                that.hideMenu();
            }
        }

        function constructItems(items) {

            that.$content.empty();
            scrollMenuUp();

            self.optionGroups = {};
            $.each(items, function(i, item) {
                if (item.group) {
                    items[i] = $.map(item.items, function(v, i) {
                        return $.extend(v, {
                            group: item.group
                        });
                    });
                    self.optionGroups[item.group] = new Ox.OptionGroup(
                        items[i],
                        'min' in item ? item.min : 1,
                        'max' in item ? item.max : 1
                    );
                }
            });
            items = Ox.flatten(items);

            that.items = [];
            $.each(items, function(i, item) {
                var position;
                if (item.id) {
                    that.items.push(new Ox.MenuItem($.extend(item, {
                        menu: that,
                        position: position = that.items.length
                    })).data('position', position).appendTo(that.$content)); // fixme: jquery bug when passing {position: position}? does not return the object?;
                    if (item.items) {
                        that.submenus[item.id] = new Ox.Menu({
                            element: that.items[position],
                            id: Ox.toCamelCase(self.options.id + '/' + item.id),
                            items: item.items,
                            mainmenu: self.options.mainmenu,
                            offset: {
                                left: 0,
                                top: -4
                            },
                            parent: that,
                            side: 'right',
                            size: self.options.size,
                        });
                    }
                } else {
                    that.$content.append(constructSpace());
                    that.$content.append(constructLine());
                    that.$content.append(constructSpace());
                }
            });

            if (!that.is(':hidden')) {
                that.hideMenu();
                that.showMenu();
            }

        }

        function constructLine() {
            return $('<tr>').append(
                $('<td>', {
                    'class': 'OxLine',
                    colspan: 5
                })
            );
        }

        function constructScrollbar(direction) {
            var interval,
                speed = direction == 'up' ? -1 : 1;
            return $('<div/>', {
                'class': 'OxScrollbar Ox' + Ox.toTitleCase(direction),
                html: oxui.symbols['triangle_' + direction],
                click: function() { // fixme: do we need to listen to click event?
                    return false;
                },
                mousedown: function() {
                    scrollSpeed = 2;
                    return false;
                },
                mouseenter: function() {
                    var $otherScrollbar = that.$scrollbars[direction == 'up' ? 'down' : 'up'];
                    $(this).addClass('OxSelected');
                    if ($otherScrollbar.is(':hidden')) {
                        $otherScrollbar.show();
                        that.$container.height(that.$container.height() - itemHeight);
                        if (direction == 'down') {
                            that.$content.css({
                                top: -itemHeight + 'px'
                            });
                        }
                    }
                    scrollMenu(speed);
                    interval = setInterval(function() {
                        scrollMenu(speed);
                    }, 100);
                },
                mouseleave: function() {
                    $(this).removeClass('OxSelected');
                    clearInterval(interval);
                },
                mouseup: function() {
                    scrollSpeed = 1;
                    return false;
                }
            });
        }

        function constructSpace() {
            return $('<tr>').append(
                $('<td>', {
                    'class': 'OxSpace',
                    colspan: 5
                })
            );
        }

        function getElement(id) {
            // fixme: needed?
            return $('#' + Ox.toCamelCase(options.id + '/' + id));
        }

        function getItemPositionById(id) {
            var position;
            $.each(that.items, function(i, v) {
                if (v.options('id') == id) {
                    position = i;
                    return false;
                }
            });
            return position;
        }

        function hideMenu() {
            // called on key_escape
            that.hideMenu();
        }

        function isFirstEnabledItem() {
            var ret = true;
            $.each(that.items, function(i, item) {
                if (i < self.options.selected && !item.options('disabled')) {
                    return ret = false;
                }
            });
            return ret;
        }

        function isLastEnabledItem() {
            var ret = true;
            $.each(that.items, function(i, item) {
                if (i > self.options.selected && !item.options('disabled')) {
                    return ret = false;
                }
            });
            return ret;
        }

        function mouseenter() {
            that.gainFocus();
        }

        function mouseleave() {
            if (self.options.selected > -1 && !that.items[self.options.selected].options('items').length) {
                selectItem(-1);
            }
        }

        function mousemove(event) {
            var item,
                position,
                $target = $(event.target);
                $parent = $target.parent();
            if ($parent.is('.OxCell')) {
                $target = $parent;
                $parent = $target.parent();
            }
            if ($target.is('.OxCell')) {
                position = $parent.data('position');
                item = that.items[position];
                if (!item.options('disabled') && position != self.options.selected) {
                    selectItem(position);
                }
            } else {
                mouseleave();
            }
        }

        function scrollMenu(speed) {
            var containerHeight = that.$container.height(),
                contentHeight = that.$content.height(),
                top = parseInt(that.$content.css('top')) || 0,
                min = containerHeight - contentHeight + itemHeight,
                max = 0;
            top += speed * scrollSpeed * -itemHeight;
            if (top <= min) {
                top = min;
                that.$scrollbars.down.hide().trigger('mouseleave');
                that.$container.height(containerHeight + itemHeight);
                that.items[that.items.length - 1].trigger('mouseover');
            } else if (top >= max - itemHeight) {
                top = max;
                that.$scrollbars.up.hide().trigger('mouseleave');
                that.$container.height(containerHeight + itemHeight);
                that.items[0].trigger('mouseover');
            }
            that.$content.css({
                top: top + 'px'
            });
        }

        function scrollMenuUp() {
            if (that.$scrollbars.up.is(':visible')) {
                that.$content.css({
                    top: '0px'
                });
                that.$scrollbars.up.hide();
                if (that.$scrollbars.down.is(':hidden')) {
                    that.$scrollbars.down.show();
                } else {
                    that.$container.height(that.$container.height() + itemHeight);
                }
            }
        }

        function selectItem(position) {
            var item;
            if (self.options.selected > -1) {
                //Ox.print('s.o.s', self.options.selected, that.items)
                item = that.items[self.options.selected]
                item.removeClass('OxSelected');
                /* disabled
                that.triggerEvent('deselect', {
                    id: item.options('id'),
                    title: Ox.stripTags(item.options('title')[0])
                });
                */
            }
            if (position > -1) {
                item = that.items[position];
                $.each(that.submenus, function(id, submenu) {
                    if (!submenu.is(':hidden')) {
                        submenu.hideMenu();
                        return false;
                    }
                });
                item.options('items').length && that.submenus[item.options('id')].showMenu(); // fixme: do we want to switch to this style?
                item.addClass('OxSelected');
                /* disabled
                that.triggerEvent('select', {
                    id: item.options('id'),
                    title: Ox.stripTags(item.options('title')[0])
                });
                */
            }
            self.options.selected = position;
        }

        function selectNextItem() {
            var offset,
                selected = self.options.selected;
            Ox.print('sNI', selected)
            if (!isLastEnabledItem()) {
                if (selected == -1) {
                    scrollMenuUp();
                } else {
                    that.items[selected].removeClass('OxSelected');
                }
                do {
                    selected++;
                } while (that.items[selected].options('disabled'))
                selectItem(selected);
                offset = that.items[selected].offset().top + itemHeight -
                        that.$container.offset().top - that.$container.height();
                if (offset > 0) {
                    if (that.$scrollbars.up.is(':hidden')) {
                        that.$scrollbars.up.show();
                        that.$container.height(that.$container.height() - itemHeight);
                        offset += itemHeight;
                    }
                    if (selected == that.items.length - 1) {
                        that.$scrollbars.down.hide();
                        that.$container.height(that.$container.height() + itemHeight);
                    } else {
                        that.$content.css({
                            top: ((parseInt(that.$content.css('top')) || 0) - offset) + 'px'
                        });
                    }
                }
            }
        }

        function selectPreviousItem() {
            var offset,
                selected = self.options.selected;
            Ox.print('sPI', selected)
            if (selected > - 1) {
                if (!isFirstEnabledItem()) {
                    that.items[selected].removeClass('OxSelected');
                    do {
                        selected--;
                    } while (that.items[selected].options('disabled'))
                    selectItem(selected);
                }
                offset = that.items[selected].offset().top - that.$container.offset().top;
                if (offset < 0) {
                    if (that.$scrollbars.down.is(':hidden')) {
                        that.$scrollbars.down.show();
                        that.$container.height(that.$container.height() - itemHeight);
                    }
                    if (selected == 0) {
                        that.$scrollbars.up.hide();
                        that.$container.height(that.$container.height() + itemHeight);
                    }
                    that.$content.css({
                        top: ((parseInt(that.$content.css('top')) || 0) - offset) + 'px'
                    });
                }
            }
        }

        function selectSubmenu() {
            Ox.print('selectSubmenu', self.options.selected)
            if (self.options.selected > -1) {
                var submenu = that.submenus[that.items[self.options.selected].options('id')];
                Ox.print('submenu', submenu, that.submenus);
                if (submenu && submenu.hasEnabledItems()) {
                    submenu.gainFocus();
                    submenu.selectFirstItem();
                } else if (self.options.mainmenu) {
                    self.options.mainmenu.selectNextMenu();
                }
            } else if (self.options.mainmenu) {
                self.options.mainmenu.selectNextMenu();
            }
        }

        function selectSupermenu() {
            Ox.print('selectSupermenu', self.options.selected)
            if (self.options.parent) {
                self.options.selected > -1 && that.items[self.options.selected].trigger('mouseleave');
                scrollMenuUp();
                self.options.parent.gainFocus();
            } else if (self.options.mainmenu) {
                self.options.mainmenu.selectPreviousMenu();
            }
        }

        self.onChange = function(key, value) {
            if (key == 'items') {
                constructItems(value);
            } else if (key == 'selected') {
                that.$content.find('.OxSelected').removeClass('OxSelected');
                selectItem(value);
            }
        }

        that.addItem = function(item, position) {
            
        };

        that.addItemAfter = function(item, id) {

        };

        that.addItemBefore = function(item, id) {

        };

        that.checkItem = function(id) {
            var item = that.getItem(id);
            if (item.options('group')) {
                var position = getItemPositionById(id),
                    toggled = self.optionGroups[item.options('group')].toggle(position);
                if (toggled.length) {
                    $.each(toggled, function(i, pos) {
                        that.items[pos].toggleChecked();
                    });
                }
            } else {
                item.options({
                    checked: true
                });
            }
        };

        that.getItem = function(id) {
            //Ox.print('id', id)
            var ids = id.split('_'),
                item;
            if (ids.length == 1) {
                $.each(that.items, function(i, v) {
                    if (v.options('id') == id) {
                        item = v;
                        return false;
                    }
                });
                if (!item) {
                    $.each(that.submenus, function(k, submenu) {
                        item = submenu.getItem(id);
                        return !item;
                    });
                }
            } else {
                item = that.submenus[ids.shift()].getItem(ids.join('_'));
            }
            return item;
        };

        that.getSubmenu = function(id) {
            var ids = id.split('_'),
                submenu;
            if (ids.length == 1) {
                submenu = that.submenus[id];
            } else {
                submenu = that.submenus[ids.shift()].getSubmenu(ids.join('_'));
            }
            Ox.print('getSubmenu', id, submenu);
            return submenu;
        }

        that.hasEnabledItems = function() {
            var ret = false;
            $.each(that.items, function(i, item) {
                if (!item.options('disabled')) {
                    return ret = true;
                }
            });
            return ret;
        };

        that.hideMenu = function() {
            if (that.is(':hidden')) {
                return;
            }
            $.each(that.submenus, function(i, submenu) {
                if (submenu.is(':visible')) {
                    submenu.hideMenu();
                    return false;
                }
            });
            selectItem(-1);
            scrollMenuUp();
            that.$scrollbars.up.is(':visible') && that.$scrollbars.up.hide();
            that.$scrollbars.down.is(':visible') && that.$scrollbars.down.hide();
            //that.$scrollbars.down.hide();
            if (self.options.parent) {
                //self.options.element.removeClass('OxSelected');
                self.options.parent.options({
                    selected: -1
                });
            }
            that.hide()
                .loseFocus()
                .triggerEvent('hide');
            that.$layer.hide();
            return that;
        };

        that.removeItem = function() {

        };

        that.selectFirstItem = function() {
            selectNextItem();
        };

        that.showMenu = function() {
            if (!that.is(':hidden')) {
                return;
            }
            if (!self.options.parent && !that.$layer.parent().length) {
                that.$layer.appendTo($body);
            }
            that.parent().length == 0 && that.appendTo($body);
            that.css({
                left: '-1000px',
                top: '-1000px',
            }).show();
            var offset = self.options.element.offset(),
                width = self.options.element.outerWidth(),
                height = self.options.element.outerHeight(),
                left = Ox.limit(
                    offset.left + self.options.offset.left + (self.options.side == 'bottom' ? 0 : width),
                    0, $window.width() - that.width()
                ),
                top = offset.top + self.options.offset.top + (self.options.side == 'bottom' ? height : 0),
                menuHeight = that.$content.outerHeight(); // fixme: why is outerHeight 0 when hidden?
                menuMaxHeight = Math.floor($window.height() - top - 16);
            if (self.options.parent) {
                if (menuHeight > menuMaxHeight) {
                    top = Ox.limit(top - menuHeight + menuMaxHeight, self.options.parent.offset().top, top);
                    menuMaxHeight = Math.floor($window.height() - top - 16);
                }
            }
            that.css({
                left: left + 'px',
                top: top + 'px'
            });
            if (menuHeight > menuMaxHeight) {
                that.$container.height(menuMaxHeight - itemHeight - 8); // margin
                that.$scrollbars.down.show();
            } else {
                that.$container.height(menuHeight);
            }
            if (!self.options.parent) {
                that.gainFocus();
            }
            that.$layer.show();
            return that;
            //that.triggerEvent('show');
        };

        that.toggleMenu = function() {
            that.is(':hidden') ? that.showMenu() : that.hideMenu();
        };

        return that;

    };

    Ox.MenuItem = function(options, self) {

        var self = self || {},
            that = new Ox.Element('tr', self)
                .defaults({
                    bind: [], // fixme: what's this?
                    checked: null,
                    disabled: false,
                    group: '',
                    icon: '',
                    id: '',
                    items: [],
                    keyboard: '',
                    menu: null, // fixme: is passing the menu to 100s of menu items really memory-neutral?
                    position: 0,
                    title: [],
                })
                .options($.extend(options, {
                    keyboard: parseKeyboard(options.keyboard || self.defaults.keyboard),
                    title: Ox.makeArray(options.title || self.defaults.title)
                }))
                .addClass('OxItem' + (self.options.disabled ? ' OxDisabled' : ''))
                .attr({
                    id: Ox.toCamelCase(self.options.menu.options('id') + '/' + self.options.id)
                })
                .data('group', self.options.group); // fixme: why?

        if (self.options.group && self.options.checked === null) {
            self.options.checked = false;
        }

        // construct
        that.append(
                that.$status = $('<td>', {
                    'class': 'OxCell OxStatus',
                    html: self.options.checked ? oxui.symbols.check : ''
                })
            )
            .append(
                that.$icon = $('<td>', {
                    'class': 'OxCell OxIcon'
                })
                .append(self.options.icon ?
                    $('<img>', {
                        src: self.options.icon
                    }) : null
                )
            )
            .append(
                that.$title = $('<td>', {
                    'class': 'OxCell OxTitle',
                    html: self.options.title[0]
                })
            )
            .append(
                $('<td>', {
                    'class': 'OxCell OxModifiers',
                    html: $.map(self.options.keyboard.modifiers, function(modifier) {
                        return oxui.symbols[modifier];
                    }).join('')
                })
            )
            .append(
                $('<td>', {
                    'class': 'OxCell Ox' + (self.options.items.length ? 'Submenu' : 'Key'),
                    html: self.options.items.length ? oxui.symbols.triangle_right :
                            oxui.symbols[self.options.keyboard.key] ||
                            self.options.keyboard.key.toUpperCase()
                })
            );

        function parseKeyboard(str) {
            var modifiers = str.split(' '),
                key = modifiers.pop();
            return {
                modifiers: modifiers,
                key: key
            };
        }

        self.onChange = function(key, value) {
            if (key == 'checked') {
                that.$status.html(value ? oxui.symbols.check : '')
            } else if (key == 'disabled') {
                that.toggleClass('OxDisabled'); // fixme: this will only work if onChange is only invoked on actual change
            } else if (key == 'title') {
                self.options.title = Ox.makeArray(value);
                that.$title.html(self.options.title[0]);
            }
        }

        that.toggle = function() {
            // toggle id and title
        };

        that.toggleChecked = function() {
            that.options({
                checked: !self.options.checked
            });
        };

        that.toggleDisabled = function() {

        };

        that.toggleTitle = function() {
            Ox.print('s.o.t', self.options.title)
            that.options({
                title: self.options.title.reverse()
            });
        };

        return that;

    };

    /*
    ============================================================================
    Panels
    ============================================================================
    */

    /*
    ----------------------------------------------------------------------------
    Ox.CollapsePanel
    ----------------------------------------------------------------------------
    */

    Ox.CollapsePanel = function(options, self) {
        var self = self || {},
            that = new Ox.Panel({}, self)
                .defaults({
                    collapsed: false,
                    size: 20,
                    title: ''
                })
                .options(options)
                .addClass('OxCollapsePanel'),
            title = self.options.collapsed ?
                [{id: 'expand', title: 'expand'}, {id: 'collapse', title: 'collapse'}] :
                [{id: 'collapse', title: 'collapse'}, {id: 'expand', title: 'expand'}];
            $titlebar = new Ox.Bar({
                    orientation: 'horizontal',
                    size: self.options.size,
                })
                .dblclick(dblclickTitlebar)
                .appendTo(that),
            $switch = new Ox.Button({
                    id: self.options.id + 'Switch',
                    style: 'symbol',
                    title: title,
                    type: 'image',
                })
                .click(toggleCollapsed)
                .appendTo($titlebar),
            $title = new Ox.Element()
                .addClass('OxTitle')
                .html(self.options.title/*.toUpperCase()*/)
                .appendTo($titlebar);
        that.$content = new Ox.Element()
            .addClass('OxContent')
            .appendTo(that);
        // fixme: doesn't work, content still empty
        // need to hide it if collapsed
        if (self.options.collapsed) {
            that.$content.css({
                marginTop: -that.$content.height() + 'px'
            });
        }
        function dblclickTitlebar(e) {
            if (!$(e.target).hasClass('OxButton')) {
                $switch.trigger('click');
            }
        }
        function toggleCollapsed() {
            var marginTop;
            self.options.collapsed = !self.options.collapsed;
            marginTop = self.options.collapsed ? -that.$content.height() : 0;
            that.$content.animate({
                marginTop: marginTop + 'px'
            }, 200);
        }
        self.onChange = function(key, value) {
            if (key == 'collapsed') {

            } else if (key == 'title') {
                $title.html(self.options.title);
            }
        };
        return that;
    };

    /*
    ----------------------------------------------------------------------------
    Ox.Panel
    ----------------------------------------------------------------------------
    */

    Ox.Panel = function(options, self) {
        var self = self || {},
            that = new Ox.Element({}, self)
                .addClass('OxPanel');
        return that;
    };

    /*
    ----------------------------------------------------------------------------
    Ox.SplitPanel
    options:
        elements: [{            array of one, two or three elements
            collapsible: false, collapsible or not (only for outer elements)
            collapsed: false,   collapsed or not (only for collapsible elements)
            element: {},        OxElement (if any element is resizable or
                                collapsible, all OxElements must have an id)
            resizable: false,   resizable or not (only for outer elements)
            resize: [],         array of sizes (only for resizable elements,
                                first value is min, last value is max,
                                other values are 'snappy' points in between)
            size: 0             size in px (one element must have no size)
        }],
        orientation: ''         'horizontal' or 'vertical'
    methods:
        isCollapsed(id)         element is collapsed or not
        resize(id, size)        resize element to size px
        toggle(id)              collapse or expand element
    events:
        resize
        toggle
    ----------------------------------------------------------------------------
    */

    Ox.SplitPanel = function(options, self) {

        var self = self || {},
            that = new Ox.Element({}, self) // fixme: Container
                .defaults({
                    elements: [],
                    orientation: 'horizontal'
                })
                .options(options || {})
                .addClass('OxSplitPanel');

        $.extend(self, {
            dimensions: oxui.getDimensions(self.options.orientation),
            edges: oxui.getEdges(self.options.orientation),
            length: self.options.elements.length
        });

        that.$elements = [];

        $.each(self.options.elements, function(i, v) {
            self.options.elements[i] = $.extend({
                collapsible: false,
                collapsed: false,
                resizable: false,
                resize: [],
                size: 'auto'
            }, v);
            that.$elements[i] = v.element
                .css(self.edges[2], 0)
                .css(self.edges[3], 0);
        });

        setSizes();

        $.each(self.options.elements, function(i, v) {
            //that.append(element)
            //Ox.print('V: ', v, that.$elements[i])
            that.$elements[i].appendTo(that.$element); // fixme: that.$content
            if (v.collapsible || v.resizable) {
                Ox.print('v.size', v.size)
                var $resizebar = new Ox.Resizebar({
                        collapsible: v.collapsible,
                        edge: self.edges[i == 0 ? 0 : 1],
                        elements: i < 2 ?
                                [that.$elements[0], that.$elements[1]] :
                                [that.$elements[1], that.$elements[2]],
                        id: v.element.options('id'),
                        orientation: self.options.orientation == 'horizontal' ? 'vertical' : 'horizontal',
                        parent: that, // fixme: that.$content
                        resizable: v.resizable,
                        resize: v.resize,
                        size: v.size
                    })
                    .css(self.edges[i == 0 ? 0 : 1], v.size);
                $resizebar[i == 0 ? 'insertAfter' : 'insertBefore'](that.$elements[i]);
            }
        });

        function getPositionById(id) {
            var position = -1;
            $.each(self.options.elements, function(i, element) {
                if (element.element.options('id') == id) {
                    position = i;
                    return false;
                }
            });
            Ox.print('getPositionById', id, position);
            return position;
        }

        function getSize(element) {
            return element.size + element.resizable;
        }

        function setSizes() {
            $.each(self.options.elements, function(i, v) {
                v.size != 'auto' && that.$elements[i].css(self.dimensions[0], v.size + 'px');
                if (i == 0) {
                    that.$elements[i].css(self.edges[0], 0);
                    v.size != 'auto' && that.$elements[i].css(
                        self.edges[1], (getSize(self.options.elements[1]) + (length == 3 ? getSize(self.options.elements[2]) : 0)) + 'px'
                    );
                } else if (i == 1) {
                    self.options.elements[0].size != 'auto' && that.$elements[i].css(
                        self.edges[0], getSize(self.options.elements[0]) + 'px'
                    );
                    (self.options.elements[0].size != 'auto' || v.size != 'auto') && that.$elements[i].css(
                        self.edges[1], (self.length == 3 ? getSize(self.options.elements[2]) : 0) + 'px'
                    );
                } else {
                    that.$elements[i].css(self.edges[1], 0);
                    v.size != 'auto' && that.$elements[i].css(
                        self.edges[0], (getSize(self.options.elements[0]) + getSize(self.options.elements[1])) + 'px'
                    );
                }
            });
        }

        that.isCollapsed = function(id) {
            return self.options.elements[getPositionById(id)].collapsed;
        };

        that.replace = function(id, element) {
            // one can pass pos instead of id
            var pos = Ox.isNumber(id) ? id : getPositionById(id);
            Ox.print('element', self.options.elements[pos].element, element)
            self.options.elements[pos].element.replaceWith(element.$element.$element || element.$element);
            self.options.elements[pos].element = element;
            that.$elements[pos] = element
                .css(self.edges[2], 0)
                .css(self.edges[3], 0);
            setSizes();
            Ox.print(self.options.elements[pos])
        };

        that.resize = function(id, size) {
            // one can pass pos instead of id
            var pos = Ox.isNumber(id) ? id : getPositionById(id);
            // Ox.print('pos', pos, self.options.elements, $.map(self.options.elements, function(v, i) { return v.element.options('id'); }))
            Ox.print('pos', pos, 'size', size)
            self.options.elements[pos].size = size;
            setSizes();
            return that;
        };

        that.toggle = function(id) {
            Ox.print('toggle', id);
            /*
            // something like this is needed to load in collapsed state
            if (Ox.isUndefined(self.options.position)) {
                self.options.position = parseInt(self.options.parent.css(self.options.edge)) +
                        (self.options.collapsed  ? self.options.size : 0);
            }
            var size = self.options.position -
                        (self.options.collapsed ? 0 : self.options.size),
                animate = {};
            Ox.print('s.o.e', self.options.edge);
            */
            var pos = getPositionById(id),
                size = self.options.elements[pos].collapsed ? 0 : self.options.elements[pos].size,
                animate = {};
            animate[self.edges[pos == 0 ? 0 : 1]] = size;
            self.options.parent.animate(animate, 200, function() {
                var i = (self.options.edge == 'left' || self.options.edge == 'top') ? 1 : 0;
                that.triggerEvent('resize', self.options.elements[i][self.dimensions[1]]());
                self.options.elements[pos].collapsed = !self.options.elements[pos].collapsed;
            });
        };

        return that;

    };

    Ox.TabPanel = function(options, self) {
        
    };

    /*
    ============================================================================
    Requests
    ============================================================================
    */

    /*
    ----------------------------------------------------------------------------
    Ox.LoadingIcon
    ----------------------------------------------------------------------------
    */

    Ox.LoadingIcon = function(options, self) {
        var self = self || {},
            that = new Ox.Element('img', self)
                .defaults({
                    size: 'medium'
                })
                .options(options || {})
                .attr({
                    src: oxui.path + '/png/ox.ui.' + Ox.theme() + '/loading.png' // fixme: oxui.themePath needed?
                })
                .addClass(
                    'OxLoadingIcon Ox' + Ox.toTitleCase(self.options.size)
                );
        self.deg = 0;
        self.interval = 0;
        self.isRunning = false;
        function clear() {
            clearInterval(self.interval);
            self.deg = 0;
            self.interval = 0;
            update();
        }
        function update() {
            that.css({
                MozTransform: 'rotate(' + self.deg + 'deg)',
                WebkitTransform: 'rotate(' + self.deg + 'deg)'
            });
        }
        that.start = function() {
            self.isRunning = true;
            clear();
            that.animate({
                opacity: 1
            }, 250);
            self.interval = setInterval(function() {
                self.deg = (self.deg + 30) % 360;
                update();
            }, 83);
            return that;
        };
        that.stop = function() {
            that.animate({
                opacity: 0
            }, 250, function() {
                !self.isRunning && clear();
                self.isRunning = false;
            });
            return that;
        }
        return that;
    }

    /*
    ----------------------------------------------------------------------------
    Ox.Progressbar
    ----------------------------------------------------------------------------
    */

    /*
    ============================================================================
    Miscellaneous
    ============================================================================
    */

    /*
    ----------------------------------------------------------------------------
    Ox.Tooltip
    ----------------------------------------------------------------------------
    */

    Ox.Tooltip = function(options, self) {

        var self = self || {},
            that = new Ox.Element('div', self)
                .defaults({
                    title: ''
                })
                .options(options || {})
                .addClass('OxTooltip')
                .html(self.options.title);

        self.onChange = function(key, value) {
            if (key == 'title') {
                that.html(value);
            }
        };

        that.hide = function() {
            that.animate({
                opacity: 0
            }, 0, function() {
                that.remove();
            });
            return that;
        };

        that.show = function(x, y) {
            var left, top, width, height;
            $('.OxTooltip').remove(); // fixme: don't use dom
            that.appendTo($body);
            width = that.width();
            height = that.height();
            left = Ox.limit(x - width / 2, 0, $document.width() - width);
            top = y > $document.height() - height - 16 ? y - 32 : y + 16;
            that.css({
                    left: left + 'px',
                    top: top + 'px'
                })
                .animate({
                    opacity: 1
                }, 0);
            return that;
        };

        return that;

    };

})();