/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */

const Clutter = imports.gi.Clutter;
const Gio = imports.gi.Gio;
const Shell = imports.gi.Shell;
const St = imports.gi.St;
const Lang = imports.lang;
const Mainloop = imports.mainloop;
const Signals = imports.signals;

const Main = imports.ui.main;
const MagnifierDBus = imports.ui.magnifierDBus;

// Keep enums in sync with GSettings schemas
const MouseTrackingMode = {
    NONE: 0,
    CENTERED: 1,
    PUSH: 2,
    PROPORTIONAL: 3
};

const ScreenPosition = {
    NONE: 0,
    FULL_SCREEN: 1,
    TOP_HALF: 2,
    BOTTOM_HALF: 3,
    LEFT_HALF: 4,
    RIGHT_HALF: 5
};

const MOUSE_POLL_FREQUENCY = 50;
const CROSSHAIRS_CLIP_SIZE = [100, 100];

// Settings
const SHOW_KEY                  = 'show-magnifier';
const SCREEN_POSITION_KEY       = 'screen-position';
const MAG_FACTOR_KEY            = 'mag-factor';
const LENS_MODE_KEY             = 'lens-mode';
const CLAMP_MODE_KEY            = 'scroll-at-edges';
const MOUSE_TRACKING_KEY        = 'mouse-tracking';
const SHOW_CROSS_HAIRS_KEY      = 'show-cross-hairs';
const CROSS_HAIRS_THICKNESS_KEY = 'cross-hairs-thickness';
const CROSS_HAIRS_COLOR_KEY     = 'cross-hairs-color';
const CROSS_HAIRS_OPACITY_KEY   = 'cross-hairs-opacity';
const CROSS_HAIRS_LENGTH_KEY    = 'cross-hairs-length';
const CROSS_HAIRS_CLIP_KEY      = 'cross-hairs-clip';

let magDBusService = null;

function Magnifier() {
    this._init();
}

Magnifier.prototype = {
    _init: function() {
        // Magnifier is a manager of ZoomRegions.
        this._zoomRegions = [];

        // Create small clutter tree for the magnified mouse.
        let xfixesCursor = Shell.XFixesCursor.get_default();
        this._mouseSprite = new Clutter.Texture();
        xfixesCursor.update_texture_image(this._mouseSprite);
        this._cursorRoot = new Clutter.Group();
        this._cursorRoot.add_actor(this._mouseSprite);

        // Create the first ZoomRegion and initialize it according to the
        // magnification settings.
        let [xMouse, yMouse, mask] = global.get_pointer();
        let aZoomRegion = new ZoomRegion(this, this._cursorRoot);
        this._zoomRegions.push(aZoomRegion);
        let showAtLaunch = this._settingsInit(aZoomRegion);
        aZoomRegion.scrollContentsTo(xMouse, yMouse);

        xfixesCursor.connect('cursor-change', Lang.bind(this, this._updateMouseSprite));
        this._xfixesCursor = xfixesCursor;

        // Export to dbus.
        magDBusService = new MagnifierDBus.ShellMagnifier();
        this.setActive(showAtLaunch);
    },

    /**
     * showSystemCursor:
     * Show the system mouse pointer.
     */
    showSystemCursor: function() {
        this._xfixesCursor.show();
    },

    /**
     * hideSystemCursor:
     * Hide the system mouse pointer.
     */
    hideSystemCursor: function() {
        this._xfixesCursor.hide();
    },

    /**
     * setActive:
     * Show/hide all the zoom regions.
     * @activate:   Boolean to activate or de-activate the magnifier.
     */
    setActive: function(activate) {
        this._zoomRegions.forEach (function(zoomRegion, index, array) {
            zoomRegion.setActive(activate);
        });

        if (activate)
            this.startTrackingMouse();
        else
            this.stopTrackingMouse();

        // Make sure system mouse pointer is shown when all zoom regions are
        // invisible.
        if (!activate)
            this._xfixesCursor.show();

        // Notify interested parties of this change
        this.emit('active-changed', activate);
    },

    /**
     * isActive:
     * @return  Whether the magnifier is active (boolean).
     */
    isActive: function() {
        // Sufficient to check one ZoomRegion since Magnifier's active
        // state applies to all of them.
        if (this._zoomRegions.length == 0)
            return false;
        else
            return this._zoomRegions[0].isActive();
    },

    /**
     * startTrackingMouse:
     * Turn on mouse tracking, if not already doing so.
     */
    startTrackingMouse: function() {
        // initialize previous mouse coord to undefined.
        let prevCoord = { x: NaN, y: NaN };
        if (!this._mouseTrackingId)
            this._mouseTrackingId = Mainloop.timeout_add(
                MOUSE_POLL_FREQUENCY,
                Lang.bind(this, this.scrollToMousePos, prevCoord)
            );
    },

    /**
     * stopTrackingMouse:
     * Turn off mouse tracking, if not already doing so.
     */
    stopTrackingMouse: function() {
        if (this._mouseTrackingId)
            Mainloop.source_remove(this._mouseTrackingId);

        this._mouseTrackingId = null;
    },

    /**
     * isTrackingMouse:
     * Is the magnifier tracking the mouse currently?
     */
    isTrackingMouse: function() {
        return !!this._mouseTrackingId;
    },

    /**
     * scrollToMousePos:
     * Position all zoom regions' ROI relative to the current location of the
     * system pointer.
     * @prevCoord:  The previous mouse coordinates.  Used to stop scrolling if
     *              the new position is the same as the last one (optional).
     * @return      true.
     */
    scrollToMousePos: function(prevCoord) {
        let [xMouse, yMouse, mask] = global.get_pointer();

        if (!prevCoord || prevCoord.x != xMouse || prevCoord.y != yMouse) {
            let sysMouseOverAny = false;
            this._zoomRegions.forEach(function(zoomRegion, index, array) {
                if (zoomRegion.scrollToMousePos())
                    sysMouseOverAny = true;
            });
            if (sysMouseOverAny)
                this.hideSystemCursor();
            else
                this.showSystemCursor();

            if (prevCoord) {
                prevCoord.x = xMouse;
                prevCoord.y = yMouse;
            }
        }
        return true;
    },

    /**
     * createZoomRegion:
     * Create a ZoomRegion instance with the given properties.
     * @xMagFactor:     The power to set horizontal magnification of the
     *                  ZoomRegion.  A value of 1.0 means no magnification.  A
     *                  value of 2.0 doubles the size.
     * @yMagFactor:     The power to set the vertical magnification of the
     *                  ZoomRegion.
     * @roi             Object in the form { x, y, width, height } that
     *                  defines the region to magnify.  Given in unmagnified
     *                  coordinates.
     * @viewPort        Object in the form { x, y, width, height } that defines
     *                  the position of the ZoomRegion on screen.
     * @return          The newly created ZoomRegion.
     */
    createZoomRegion: function(xMagFactor, yMagFactor, roi, viewPort) {
        let zoomRegion = new ZoomRegion(this, this._cursorRoot);
        zoomRegion.setMagFactor(xMagFactor, yMagFactor);
        zoomRegion.setViewPort(viewPort);
        zoomRegion.setROI(roi);
        zoomRegion.addCrosshairs(this._crossHairs);
        return zoomRegion;
    },

    /**
     * addZoomRegion:
     * Append the given ZoomRegion to the list of currently defined ZoomRegions
     * for this Magnifier instance.
     * @zoomRegion:     The zoomRegion to add.
     */
    addZoomRegion: function(zoomRegion) {
        if(zoomRegion) {
            this._zoomRegions.push(zoomRegion);
            if (!this.isTrackingMouse())
                this.startTrackingMouse();
        }
    },

    /**
     * getZoomRegions:
     * Return a list of ZoomRegion's for this Magnifier.
     * @return:     The Magnifier's zoom region list (array).
     */
    getZoomRegions: function() {
        return this._zoomRegions;
    },

    /**
     * clearAllZoomRegions:
     * Remove all the zoom regions from this Magnfier's ZoomRegion list.
     */
    clearAllZoomRegions: function() {
        // First ZoomRegion is special since its magnified mouse and crosshairs
        // are the original -- all the others are Clutter.Clone's.  Deal with
        // all but first zoom region.
        for (let i = 1; i < this._zoomRegions.length; i++) {
            this._zoomRegions[i].setActive(false);
            this._zoomRegions[i].removeFromStage();
        }
        this._zoomRegions[0].setActive(false);

        // Detach the (original) magnified mouse and cross hair for later reuse
        // before removing ZoomRegion from the stage.
        this._cursorRoot.get_parent().remove_actor(this._cursorRoot);
        if (this._crossHairs)
            this._crossHairs.removeFromParent();

        this._zoomRegions[0].removeFromStage();
        this._zoomRegions.length = 0;
        this.stopTrackingMouse();
        this.showSystemCursor();
    },

    /**
     * addCrosshairs:
     * Add and show a cross hair centered on the magnified mouse.
     */
    addCrosshairs: function() {
        if (!this._crossHairs)
            this._crossHairs = new Crosshairs();

        let thickness = this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY);
        let color = this._settings.get_string(CROSS_HAIRS_COLOR_KEY);
        let opacity = this._settings.get_int(CROSS_HAIRS_OPACITY_KEY);
        let length = this._settings.get_int(CROSS_HAIRS_LENGTH_KEY);
        let clip = this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY);

        this.setCrosshairsThickness(thickness);
        this.setCrosshairsColor(color);
        this.setCrosshairsOpacity(opacity);
        this.setCrosshairsLength(length);
        this.setCrosshairsClip(clip);

        let theCrossHairs = this._crossHairs;
        this._zoomRegions.forEach (function(zoomRegion, index, array) {
            zoomRegion.addCrosshairs(theCrossHairs);
        });
    },

    /**
     * setCrosshairsVisible:
     * Show or hide the cross hair.
     * @visible    Flag that indicates show (true) or hide (false).
     */
    setCrosshairsVisible: function(visible) {
        if (visible) {
            if (!this._crossHairs)
                this.addCrosshairs();
            this._crossHairs.show();
        }
        else {
            if (this._crossHairs)
                this._crossHairs.hide();
        }
    },

    /**
     * setCrosshairsColor:
     * Set the color of the crosshairs for all ZoomRegions.
     * @color:  The color as a string, e.g. '#ff0000ff' or 'red'.
     */
    setCrosshairsColor: function(color) {
        if (this._crossHairs) {
            let clutterColor = new Clutter.Color();
            clutterColor.from_string(color);
            this._crossHairs.setColor(clutterColor);
        }
    },

    /**
     * getCrosshairsColor:
     * Get the color of the crosshairs.
     * @return: The color as a string, e.g. '#0000ffff' or 'blue'.
     */
    getCrosshairsColor: function() {
        if (this._crossHairs) {
            let clutterColor = this._crossHairs.getColor();
            return clutterColor.to_string();
        }
        else
            return '#00000000';
    },

    /**
     * setCrosshairsThickness:
     * Set the crosshairs thickness for all ZoomRegions.
     * @thickness:  The width of the vertical and horizontal lines of the
     *              crosshairs.
     */
    setCrosshairsThickness: function(thickness) {
        if (this._crossHairs)
            this._crossHairs.setThickness(thickness);
    },

    /**
     * getCrosshairsThickness:
     * Get the crosshairs thickness.
     * @return: The width of the vertical and horizontal lines of the
     *          crosshairs.
     */
    getCrosshairsThickness: function() {
        if (this._crossHairs)
            return this._crossHairs.getThickness();
        else
            return 0;
    },

    /**
     * setCrosshairsOpacity:
     * @opacity:    Value between 0 (transparent) and 255 (fully opaque).
     */
    setCrosshairsOpacity: function(opacity) {
        if (this._crossHairs)
            this._crossHairs.setOpacity(opacity);
    },

    /**
     * getCrosshairsOpacity:
     * @return:     Value between 0 (transparent) and 255 (fully opaque).
     */
    getCrosshairsOpacity: function() {
        if (this._crossHairs)
            return this._crossHairs.getOpacity();
        else
            return 0;
    },

    /**
     * setCrosshairsLength:
     * Set the crosshairs length for all ZoomRegions.
     * @length: The length of the vertical and horizontal lines making up the
     *          crosshairs.
     */
    setCrosshairsLength: function(length) {
        if (this._crossHairs)
            this._crossHairs.setLength(length);
    },

    /**
     * getCrosshairsLength:
     * Get the crosshairs length.
     * @return: The length of the vertical and horizontal lines making up the
     *          crosshairs.
     */
    getCrosshairsLength: function() {
        if (this._crossHairs)
            return this._crossHairs.getLength();
        else
            return 0;
    },

    /**
     * setCrosshairsClip:
     * Set whether the crosshairs are clipped at their intersection.
     * @clip:   Flag to indicate whether to clip the crosshairs.
     */
    setCrosshairsClip: function(clip) {
        if (clip) {
            if (this._crossHairs)
                this._crossHairs.setClip(CROSSHAIRS_CLIP_SIZE);
        }
        else {
            // Setting no clipping on crosshairs means a zero sized clip
            // rectangle.
            if (this._crossHairs)
                this._crossHairs.setClip([0, 0]);
        }
    },

    /**
     * getCrosshairsClip:
     * Get whether the crosshairs are clipped by the mouse image.
     * @return:   Whether the crosshairs are clipped.
     */
     getCrosshairsClip: function() {
        if (this._crossHairs) {
            let [clipWidth, clipHeight] = this._crossHairs.getClip();
            return (clipWidth > 0 && clipHeight > 0);
        }
        else
            return false;
     },

    //// Private methods ////

    _updateMouseSprite: function() {
        this._xfixesCursor.update_texture_image(this._mouseSprite);
        let xHot = this._xfixesCursor.get_hot_x();
        let yHot = this._xfixesCursor.get_hot_y();
        this._mouseSprite.set_anchor_point(xHot, yHot);
    },

    _settingsInit: function(zoomRegion) {
        this._settings = new Gio.Settings({ schema: 'org.gnome.accessibility.magnifier' });

        if (zoomRegion) {
            // Mag factor is accurate to two decimal places.
            let aPref = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2));
            if (aPref != 0.0)
                zoomRegion.setMagFactor(aPref, aPref);

            aPref = this._settings.get_enum(SCREEN_POSITION_KEY);
            if (aPref)
                zoomRegion.setScreenPosition(aPref);

            zoomRegion.setLensMode(this._settings.get_boolean(LENS_MODE_KEY));
            zoomRegion.setClampScrollingAtEdges(!this._settings.get_boolean(CLAMP_MODE_KEY));

            aPref = this._settings.get_enum(MOUSE_TRACKING_KEY);
            if (aPref)
                zoomRegion.setMouseTrackingMode(aPref);
        }

        let showCrosshairs = this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY);
        this.addCrosshairs();
        this.setCrosshairsVisible(showCrosshairs);

        this._settings.connect('changed::' + SHOW_KEY,
                               Lang.bind(this, function() {
            this.setActive(this._settings.get_boolean(SHOW_KEY));
        }));

        this._settings.connect('changed::' + SCREEN_POSITION_KEY,
                               Lang.bind(this, this._updateScreenPosition));
        this._settings.connect('changed::' + MAG_FACTOR_KEY,
                               Lang.bind(this, this._updateMagFactor));
        this._settings.connect('changed::' + LENS_MODE_KEY,
                               Lang.bind(this, this._updateLensMode));
        this._settings.connect('changed::' + CLAMP_MODE_KEY,
                               Lang.bind(this, this._updateClampMode));
        this._settings.connect('changed::' + MOUSE_TRACKING_KEY,
                               Lang.bind(this, this._updateMouseTrackingMode));

        this._settings.connect('changed::' + SHOW_CROSS_HAIRS_KEY,
                               Lang.bind(this, function() {
            this.setCrosshairsVisible(this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY));
        }));

        this._settings.connect('changed::' + CROSS_HAIRS_THICKNESS_KEY,
                               Lang.bind(this, function() {
            this.setCrosshairsThickness(this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY));
        }));

        this._settings.connect('changed::' + CROSS_HAIRS_COLOR_KEY,
                               Lang.bind(this, function() {
            this.setCrosshairsColor(this._settings.get_string(CROSS_HAIRS_COLOR_KEY));
        }));

        this._settings.connect('changed::' + CROSS_HAIRS_OPACITY_KEY,
                               Lang.bind(this, function() {
            this.setCrosshairsOpacity(this._settings.get_int(CROSS_HAIRS_OPACITY_KEY));
        }));

        this._settings.connect('changed::' + CROSS_HAIRS_LENGTH_KEY,
                               Lang.bind(this, function() {
            this.setCrosshairsLength(this._settings.get_int(CROSS_HAIRS_LENGTH_KEY));
        }));

        this._settings.connect('changed::' + CROSS_HAIRS_CLIP_KEY,
                               Lang.bind(this, function() {
            this.setCrosshairsClip(this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY));
        }));

        return this._settings.get_boolean(SHOW_KEY);
   },

    _updateScreenPosition: function() {
        // Applies only to the first zoom region.
        if (this._zoomRegions.length) {
            let position = this._settings.get_enum(SCREEN_POSITION_KEY);
            this._zoomRegions[0].setScreenPosition(position);
            if (position != ScreenPosition.FULL_SCREEN)
                this._updateLensMode();
        }
    },

    _updateMagFactor: function() {
        // Applies only to the first zoom region.
        if (this._zoomRegions.length) {
            // Mag factor is accurate to two decimal places.
            let magFactor = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2));
            this._zoomRegions[0].setMagFactor(magFactor, magFactor);
        }
    },

    _updateLensMode: function() {
        // Applies only to the first zoom region.
        if (this._zoomRegions.length) {
            this._zoomRegions[0].setLensMode(this._settings.get_boolean(LENS_MODE_KEY));
        }
    },

    _updateClampMode: function() {
        // Applies only to the first zoom region.
        if (this._zoomRegions.length) {
            this._zoomRegions[0].setClampScrollingAtEdges(
                !this._settings.get_boolean(CLAMP_MODE_KEY)
            );
        }
    },

    _updateMouseTrackingMode: function() {
        // Applies only to the first zoom region.
        if (this._zoomRegions.length) {
            this._zoomRegions[0].setMouseTrackingMode(
                this._settings.get_enum(MOUSE_TRACKING_KEY)
            );
        }
    }
};
Signals.addSignalMethods(Magnifier.prototype);

function ZoomRegion(magnifier, mouseRoot) {
    this._init(magnifier, mouseRoot);
}

ZoomRegion.prototype = {
    _init: function(magnifier, mouseRoot) {
        this._magnifier = magnifier;

        // The root actor for the zoom region
        this._magView = new St.Bin({ style_class: 'magnifier-zoom-region', x_fill: true, y_fill: true });
        global.stage.add_actor(this._magView);
        this._magView.hide();

        // Append a Clutter.Group to clip the contents of the magnified view.
        this._mainGroup = new Clutter.Group({ clip_to_allocation: true });
        this._magView.set_child(this._mainGroup);

        // Add a background for when the magnified uiGroup is scrolled
        // out of view (don't want to see desktop showing through).
        let background = new Clutter.Rectangle({ color: Main.DEFAULT_BACKGROUND_COLOR });
        this._mainGroup.add_actor(background);

        // Clone the group that contains all of UI on the screen.  This is the
        // chrome, the windows, etc.
        this._uiGroupClone = new Clutter.Clone({ source: Main.uiGroup });
        this._mainGroup.add_actor(this._uiGroupClone);
        Main.uiGroup.set_size(global.screen_width, global.screen_height);
        background.set_size(global.screen_width, global.screen_height);
        this._uiGroupClone.set_size(global.screen_width, global.screen_height);

        // Add either the given mouseRoot to the ZoomRegion, or a clone of
        // it.
        if (mouseRoot.get_parent() != null)
            this._mouseRoot = new Clutter.Clone({ source: mouseRoot });
        else
            this._mouseRoot = mouseRoot;
        this._mainGroup.add_actor(this._mouseRoot);
        this._crossHairs = null;
    },

    /**
     * setActive:
     * @activate:   Boolean to show/hide the ZoomRegion.
     */
    setActive: function(activate) {
        if (activate) {
            this._magView.show();
            if (this.isMouseOverRegion())
                this._magnifier.hideSystemCursor();
            this._updateMousePosition(false /* mouse didn't move */);
        }
        else
            this._magView.hide();
    },

    /**
     * isActive:
     * @return  Whether this ZoomRegion is active (boolean).
     */
    isActive: function() {
        return this._magView.visible;
    },

    /**
     * removeFromStage:
     * Remove the magnified view from the stage.
     */
    removeFromStage: function() {
        global.stage.remove_actor(this._magView);
        this._mouseRoot = null;
        this._uiGroupClone = null;
        this._magView = null;
    },

    /**
     * setMagFactor:
     * @xMagFactor:     The power to set the horizontal magnification factor to
     *                  of the magnified view.  A value of 1.0 means no
     *                  magnification.  A value of 2.0 doubles the size.
     * @yMagFactor:     The power to set the vertical magnification factor to
     *                  of the magnified view.
     */
    setMagFactor: function(xMagFactor, yMagFactor) {
        if (xMagFactor > 0 && yMagFactor > 0) {
            // Changing the mag factor moves the pixels along the axes of
            // magnification.  Set the view back to the point that was at the centre
            // of the region of interest.
            let [x, y, width, height] = this.getROI();
            let xCentre = x + width / 2;
            let yCentre = y + height / 2;
            this._uiGroupClone.set_scale(xMagFactor, yMagFactor);
            this._mouseRoot.set_scale(xMagFactor, yMagFactor);
            this._calcRightBottomStops();
            this._scrollToPosition(xCentre, yCentre);
            this._updateMousePosition(false /* mouse didn't move */);
        }
    },

    /**
     * getMagFactor:
     * @return  an array, [xMagFactor, yMagFactor], containing the horizontal
     *          and vertical magnification powers.  A value of 1.0 means no
     *          magnification.  A value of 2.0 means the contents are doubled
     *          in size, and so on.
     */
    getMagFactor: function() {
        return this._uiGroupClone.get_scale();
    },

    /**
     * setMouseTrackingMode
     * @mode:     One of the enum MouseTrackingMode values.
     */
    setMouseTrackingMode: function(mode) {
        if (mode >= MouseTrackingMode.NONE && mode <= MouseTrackingMode.PROPORTIONAL)
            this._mouseTrackingMode = mode;
    },

    /**
     * getMouseTrackingMode
     * @return:     One of the enum MouseTrackingMode values.
     */
    getMouseTrackingMode: function() {
        return this._mouseTrackingMode;
    },

    /**
     * setViewPort
     * Sets the position and size of the ZoomRegion on screen.
     * @viewPort:   Object defining the position and size of the view port.  It
     *              has the form { x, y, width, height }.  The values are in
     *              stage coordinate space.
     */
    setViewPort: function(viewPort) {
        let [xRoi, yRoi, wRoi, hRoi] = this.getROI();

        // Remove border if the view port is the entire screen.  Otherwise,
        // ensure that the border is there.
        if (viewPort.x == 0 && viewPort.y == 0 && viewPort.width == global.screen_width && viewPort.height == global.screen_height)
            this._magView.add_style_class_name('full-screen');
        else
            this._magView.remove_style_class_name('full-screen');

        this.setSize(viewPort.width, viewPort.height);
        this.setPosition(viewPort.x, viewPort.y);
        if (this._crossHairs)
            this._crossHairs.reCenter();

        this.scrollContentsTo(xRoi + wRoi / 2, yRoi + hRoi / 2);
        if (this.isMouseOverRegion())
            this._magnifier.hideSystemCursor();

        this._screenPosition = ScreenPosition.NONE;
    },

    /**
     * setROI
     * Sets the "region of interest" that the ZoomRegion is magnifying.
     * @roi:    Object that defines the region of the screen to magnify.  It
     *          has the form { x, y, width, height }.  The values are in
     *          screen (unmagnified) coordinate space.
     */
    setROI: function(roi) {
        let xRoiCenter = roi.x + roi.width  / 2;
        let yRoiCenter = roi.y + roi.height / 2;
        this.scrollContentsTo(xRoiCenter, yRoiCenter);
    },

    /**
     * setSize:
     * @width:    The width to set the magnified view to.
     * @height:   The height to set the magnified view to.
     */
    setSize: function(width, height) {
        this._magView.set_size(width, height);
        this._calcRightBottomStops();
    },

    /**
     * getSize:
     * @return  an array, [width, height], that specifies the size of the
     *          magnified view.
     */
    getSize: function() {
        return this._magView.get_size();
    },

    /**
     * setPosition:
     * Position the magnified view at the given coordinates.
     * @x:    The x-coord of the new position.
     * @y:    The y-coord of the new position.
     */
    setPosition: function(x, y) {
        let [width, height] = this._magView.get_size();
        if (this._clampScrollingAtEdges) {
            // Restrict positioning so view doesn't go beyond any edge of the
            // screen.
            if (x < 0)
                x = 0;
            if (x + width > global.screen_width)
                x = global.screen_width - width;
            if (y < 0)
                y = 0;
            if (y + height > global.screen_height)
                y = global.screen_height - height;
        }
        this._magView.set_position(x, y);
    },

    /**
     * getPosition:
     * @return  an array, [x, y], that gives the position of the
     *          magnified view on screen.
     */
    getPosition: function() {
        return this._magView.get_position();
    },

    /**
     * getCenter:
     * @return  an array, [x, y], that is half the width and height of the
     *          magnified view (the center of the magnified view).
     */
    getCenter: function() {
        let [width, height] = this._magView.get_size();
        return [width / 2, height / 2];
    },

    /**
     * isFullScreenMode:
     * Does the magnified view occupy the whole screen?
     */
    isFullScreenMode: function() {
        let [x, y] = this._magView.get_position();
        if (x != 0 || y != 0)
            return false;
        [width, height] = this._magView.get_size();
        if (width != global.screen_width || height != global.screen_height)
            return false;
        return true;
    },

    /**
     * getROI:
     * Retrieves the "region of interest" -- the rectangular bounds of that part
     * of the desktop that the magnified view is showing (x, y, width, height).
     * The bounds are given in non-magnified coordinates.
     * @return  an array, [x, y, width, height], representing the bounding
     *          rectangle of what is shown in the magnified view.
     */
    getROI: function() {
        let [xMagnified, yMagnified] = this._uiGroupClone.get_position();
        let [xMagFactor, yMagFactor] = this.getMagFactor();
        let [width, height] = this.getSize();
        let x = (0 - xMagnified) / xMagFactor;
        let y = (0 - yMagnified) / yMagFactor;
        return [x, y, width / xMagFactor, height / yMagFactor];
    },

    /**
     * setLensMode:
     * Turn lens mode on/off.  In full screen mode, lens mode is alway off since
     * a lens the size of the screen is pointless.
     * @lensMode:   A boolean to set the sense of lens mode.
     */
    setLensMode: function(lensMode) {
        let fullScreen = this.isFullScreenMode();
        this._lensMode = (lensMode && !fullScreen);
        if (!this._lensMode && !fullScreen)
            this.setScreenPosition (this._screenPosition);
    },

    /**
     * isLensMode:
     * Is lens mode on or off?
     * @return  The lens mode state as a boolean.
     */
    isLensMode: function() {
        return this._lensMode;
    },

    /**
     * setClampScrollingAtEdges:
     * Stop vs. allow scrolling of the magnified contents when it scroll beyond
     * the edges of the screen.
     * @clamp:   Boolean to turn on/off clamping.
     */
    setClampScrollingAtEdges: function(clamp) {
        this._clampScrollingAtEdges = clamp;
    },

    /**
     * setTopHalf:
     * Magnifier view occupies the top half of the screen.
     */
    setTopHalf: function() {
        let viewPort = {};
        viewPort.x = 0;
        viewPort.y = 0;
        viewPort.width = global.screen_width;
        viewPort.height = global.screen_height/2;
        this.setViewPort(viewPort);
        this._screenPosition = ScreenPosition.TOP_HALF;
    },

    /**
     * setBottomHalf:
     * Magnifier view occupies the bottom half of the screen.
     */
    setBottomHalf: function() {
        let viewPort = {};
        viewPort.x = 0;
        viewPort.y = global.screen_height/2;
        viewPort.width = global.screen_width;
        viewPort.height = global.screen_height/2;
        this.setViewPort(viewPort);
        this._screenPosition = ScreenPosition.BOTTOM_HALF;
    },

    /**
     * setLeftHalf:
     * Magnifier view occupies the left half of the screen.
     */
    setLeftHalf: function() {
        let viewPort = {};
        viewPort.x = 0;
        viewPort.y = 0;
        viewPort.width = global.screen_width/2;
        viewPort.height = global.screen_height;
        this.setViewPort(viewPort);
        this._screenPosition = ScreenPosition.LEFT_HALF;
    },

    /**
     * setRightHalf:
     * Magnifier view occupies the right half of the screen.
     */
    setRightHalf: function() {
        let viewPort = {};
        viewPort.x = global.screen_width/2;
        viewPort.y = 0;
        viewPort.width = global.screen_width/2;
        viewPort.height = global.screen_height;
        this.setViewPort(viewPort);
        this._screenPosition = ScreenPosition.RIGHT_HALF;
    },

    /**
     * getScreenPosition:
     * Tell the outside world what the current mode is -- magnifiying the
     * top half, bottom half, etc.
     * @return:  the current mode.
     */
    getScreenPosition: function() {
        return this._screenPosition;
    },

    /**
     * scrollToMousePos:
     * Set the region of interest based on the position of the system pointer.
     * @return:     Whether the system mouse pointer is over the magnified view.
     */
    scrollToMousePos: function() {
        let [xMouse, yMouse, mask] = global.get_pointer();

        if (this._mouseTrackingMode == MouseTrackingMode.PROPORTIONAL) {
            this._setROIProportional(xMouse, yMouse);
        }
        else if (this._mouseTrackingMode == MouseTrackingMode.PUSH) {
            this._setROIPush(xMouse, yMouse);
        }
        else if (this._mouseTrackingMode == MouseTrackingMode.CENTERED) {
            this._setROICentered(xMouse, yMouse);
        }
        this._updateMousePosition(true);

        // Determine whether the system mouse pointer is over this zoom region.
        return this.isMouseOverRegion(xMouse, yMouse);
    },

    /**
     * setFullScreenMode:
     * Set the ZoomRegion to full-screen mode.
     * Note:  disallows lens mode.
     */
    setFullScreenMode: function() {
        if (!this.isFullScreenMode()) {
            let viewPort = {};
            viewPort.x = 0;
            viewPort.y = 0;
            viewPort.width = global.screen_width;
            viewPort.height = global.screen_height;
            this.setViewPort(viewPort);
            this.setLensMode(false);
            if (this.isActive())
                this._magnifier.hideSystemCursor();

            this._screenPosition = ScreenPosition.FULL_SCREEN;
        }
    },

    /**
     * setScreenPosition:
     * Positions the zoom region to one of the enumerated positions on the
     * screen.
     * @position:   one of Magnifier.FULL_SCREEN, Magnifier.TOP_HALF,
     *              Magnifier.BOTTOM_HALF,Magnifier.LEFT_HALF, or
     *              Magnifier.RIGHT_HALF.
     */
    setScreenPosition: function(inPosition) {
        switch (inPosition) {
            case ScreenPosition.FULL_SCREEN:
                this.setFullScreenMode();
                break;
            case ScreenPosition.TOP_HALF:
                this.setTopHalf();
                break;
            case ScreenPosition.BOTTOM_HALF:
                this.setBottomHalf();
                break;
            case ScreenPosition.LEFT_HALF:
                this.setLeftHalf();
                break;
            case ScreenPosition.RIGHT_HALF:
                this.setRightHalf();
                break;
        }
    },

    /**
     * scrollContentsTo:
     * Shift the contents of the magnified view such it is centered on the given
     * coordinate.  Also, update the position of the magnified mouse image after
     * the shift.
     * @x:      The x-coord of the point to center on.
     * @y:      The y-coord of the point to center on.
     */
    scrollContentsTo: function(x, y) {
        this._scrollToPosition(x, y);
        this._updateMousePosition(false /* mouse didn't move */);
    },

    /**
     * isMouseOverRegion:
     * Return whether the system mouse sprite is over this ZoomRegion.  If the
     * mouse's position is not given, then it is fetched.
     * @xMouse:     The system mouse's x-coord.  Optional.
     * @yMouse:     The system mouse's y-coord.  Optional.
     * @return:     Boolean:  true if the mouse is over the zoom region; false
     *              otherwise.
     */
    isMouseOverRegion: function(xMouse, yMouse) {
        let mouseIsOver = false;
        if (this.isActive()) {
            if (!xMouse || !yMouse) {
                let [x, y, mask] = global.get_pointer();
                xMouse = x;
                yMouse = y;
            }
            let [x, y] = this.getPosition();
            let [width, height] = this.getSize();
            mouseIsOver = (
                xMouse >= x && xMouse < (x + width) &&
                yMouse >= y && yMouse < (y + height)
            );
        }
        return mouseIsOver;
    },

    /**
     * addCrosshairs:
     * Add crosshairs centered on the magnified mouse.
     * @crossHairs  Clutter.Group that contains the actors for the crosshairs.
     */
    addCrosshairs: function(crossHairs) {
        // If the crossHairs is not already within a larger container, add it
        // to this zoom region.  Otherwise, add a clone.
        if (crossHairs) {
            this._crosshairsActor = crossHairs.addToZoomRegion(this, this._mouseRoot);
            this._crossHairs = crossHairs;
        }
    },

    //// Private methods ////

    _scrollToPosition: function(x, y) {
        // Given the point (x, y) in non-magnified coordinates, scroll the
        // magnified contenst such that the point is at the centre of the
        // magnified view.
        let [xMagFactor, yMagFactor] = this.getMagFactor();
        let xMagnified = x * xMagFactor;
        let yMagnified = y * yMagFactor;

        let [xCenterMagView, yCenterMagView] = this.getCenter();
        let newX = xCenterMagView - xMagnified;
        let newY = yCenterMagView - yMagnified;

        if (this._clampScrollingAtEdges) {
            if (newX > 0)
                newX = 0;
            else if (newX < this._rightStop)
                newX = this._rightStop;
            if (newY > 0)
                newY = 0;
            else if (newY < this._bottomStop)
                newY = this._bottomStop;
            this._uiGroupClone.set_position(newX, newY);
        }
        else
            this._uiGroupClone.set_position(newX, newY);

        // If in lens mode, move the magnified view such that it is centered
        // over the actual mouse. However, in full screen mode, the "lens" is
        // the size of the screen -- pointless to move such a large lens around.
        if (this._lensMode && !this.isFullScreenMode())
            this.setPosition(x - xCenterMagView, y - yCenterMagView);
    },

    _calcRightBottomStops: function() {
        // Calculate the location of the top-left corner of _uiGroupClone
        // when its right and bottom edges are coincident with the right and
        // bottom edges of the _magView.
        let [contentWidth, contentHeight] = this._uiGroupClone.get_size();
        let [viewWidth, viewHeight] = this.getSize();
        let [xMagFactor, yMagFactor] = this.getMagFactor();
        let rightStop = viewWidth - (contentWidth * xMagFactor);
        let bottomStop = viewHeight - (contentHeight * yMagFactor);
        this._rightStop = parseInt(rightStop.toFixed(1));
        this._bottomStop = parseInt(bottomStop.toFixed(1));
    },

    _setROIPush: function(xMouse, yMouse) {
        let [xRoi, yRoi, widthRoi, heightRoi] = this.getROI();
        let [cursorWidth, cursorHeight] = this._mouseRoot.get_size();
        let xPos = xRoi + widthRoi / 2;
        let yPos = yRoi + heightRoi / 2;
        let xRoiRight = xRoi + widthRoi - cursorWidth;
        let yRoiBottom = yRoi + heightRoi - cursorHeight;

        if (xMouse < xRoi)
            xPos -= (xRoi - xMouse);
        else if (xMouse > xRoiRight)
            xPos += (xMouse - xRoiRight);

        if (yMouse < yRoi)
            yPos -= (yRoi - yMouse);
        else if (yMouse > yRoiBottom)
            yPos += (yMouse - yRoiBottom);

        this._scrollToPosition(xPos, yPos);
    },

    _setROIProportional: function(xMouse, yMouse) {
        let [xRoi, yRoi, widthRoi, heightRoi] = this.getROI();
        let halfScreenWidth = global.screen_width / 2;
        let halfScreenHeight = global.screen_height / 2;
        let xProportion = (halfScreenWidth - xMouse) / halfScreenWidth;
        let yProportion = (halfScreenHeight - yMouse) / halfScreenHeight;
        let xPos = xMouse + xProportion * widthRoi / 2;
        let yPos = yMouse + yProportion * heightRoi / 2;

        this._scrollToPosition(xPos, yPos);
    },

    _setROICentered: function(xMouse, yMouse) {
        this._scrollToPosition(xMouse, yMouse);
    },

    _updateMousePosition: function(mouseMoved) {
        let [x, y] = this._uiGroupClone.get_position();
        x = parseInt(x.toFixed(1));
        y = parseInt(y.toFixed(1));
        let [xCenterMagView, yCenterMagView] = this.getCenter();
        let [xMouse, yMouse, mask] = global.get_pointer();
        let [xMagFactor, yMagFactor] = this.getMagFactor();

        let xMagMouse = xMouse * xMagFactor + x;
        let yMagMouse = yMouse * yMagFactor + y;
        if (mouseMoved) {
            if (x == 0)
                xMagMouse = xMouse * xMagFactor;
            else if (x == this._rightStop)
                xMagMouse = (xMouse * xMagFactor) + this._rightStop;
            else if (this._mouseTrackingMode == MouseTrackingMode.CENTERED)
                xMagMouse = xCenterMagView;

            if (y == 0)
                yMagMouse = yMouse * yMagFactor;
            else if (y == this._bottomStop)
                yMagMouse = (yMouse * yMagFactor) + this._bottomStop;
            else if (this._mouseTrackingMode == MouseTrackingMode.CENTERED)
                yMagMouse = yCenterMagView;
        }
        this._mouseRoot.set_position(xMagMouse, yMagMouse);
        this._updateCrosshairsPosition(xMagMouse, yMagMouse);
    },

    _updateCrosshairsPosition: function(x, y) {
        if (this._crosshairsActor) {
            let [groupWidth, groupHeight] = this._crosshairsActor.get_size();
            this._crosshairsActor.set_position(x - groupWidth / 2, y - groupHeight / 2);
        }
    }
};

function Crosshairs() {
    this._init();
}

Crosshairs.prototype = {
    _init: function() {

        // Set the group containing the crosshairs to three times the desktop
        // size in case the crosshairs need to appear to be infinite in
        // length (i.e., extend beyond the edges of the view they appear in).
        let groupWidth = global.screen_width * 3;
        let groupHeight = global.screen_height * 3;

        this._actor = new Clutter.Group({
            clip_to_allocation: false,
            width: groupWidth,
            height: groupHeight
        });
        this._horizLeftHair = new Clutter.Rectangle();
        this._horizRightHair = new Clutter.Rectangle();
        this._vertTopHair = new Clutter.Rectangle();
        this._vertBottomHair = new Clutter.Rectangle();
        this._actor.add_actor(this._horizLeftHair);
        this._actor.add_actor(this._horizRightHair);
        this._actor.add_actor(this._vertTopHair);
        this._actor.add_actor(this._vertBottomHair);
        this._clipSize = [0, 0];
        this._clones = [];
        this.reCenter();
    },

   /**
    * addToZoomRegion
    * Either add the crosshairs actor to the given ZoomRegion, or, if it is
    * already part of some other ZoomRegion, create a clone of the crosshairs
    * actor, and add the clone instead.  Returns either the original or the
    * clone.
    * @zoomRegion:      The container to add the crosshairs group to.
    * @magnifiedMouse:  The mouse actor for the zoom region -- used to
    *                   position the crosshairs and properly layer them below
    *                   the mouse.
    * @return           The crosshairs actor, or its clone.
    */
    addToZoomRegion: function(zoomRegion, magnifiedMouse) {
        let crosshairsActor = null;
        if (zoomRegion && magnifiedMouse) {
            let container = magnifiedMouse.get_parent();
            if (container) {
                crosshairsActor = this._actor;
                if (this._actor.get_parent() != null) {
                    crosshairsActor = new Clutter.Clone({ source: this._actor });
                    this._clones.push(crosshairsActor);
                }
                if (this._actor.visible)
                    crosshairsActor.show();
                else
                    crosshairsActor.hide();

                container.add_actor(crosshairsActor);
                container.raise_child(magnifiedMouse, crosshairsActor);
                let [xMouse, yMouse] = magnifiedMouse.get_position();
                let [crosshairsWidth, crosshairsHeight] = crosshairsActor.get_size();
                crosshairsActor.set_position(xMouse - crosshairsWidth / 2 , yMouse - crosshairsHeight / 2);
            }
        }
        return crosshairsActor;
    },

    /**
     * removeFromParent:
     * Remove the crosshairs actor from its parent container.
     */
    removeFromParent: function() {
        this._actor.get_parent().remove_actor(this._actor);
    },

    /**
     * setColor:
     * Set the color of the crosshairs.
     * @clutterColor:   The color as a Clutter.Color.
     */
    setColor: function(clutterColor) {
        this._horizLeftHair.set_color(clutterColor);
        this._horizRightHair.set_color(clutterColor);
        this._vertTopHair.set_color(clutterColor);
        this._vertBottomHair.set_color(clutterColor);
    },

    /**
     * getColor:
     * Get the color of the crosshairs.
     * @color:  The color as a Clutter.Color.
     */
    getColor: function() {
        let clutterColor = new Clutter.Color();
        this._horizLeftHair.get_color(clutterColor);
        return clutterColor;
    },

    /**
     * setThickness:
     * Set the width of the vertical and horizontal lines of the crosshairs.
     * @thickness
     */
    setThickness: function(thickness) {
        this._horizLeftHair.set_height(thickness);
        this._horizRightHair.set_height(thickness);
        this._vertTopHair.set_width(thickness);
        this._vertBottomHair.set_width(thickness);
        this.reCenter();
    },

    /**
     * getThickness:
     * Get the width of the vertical and horizontal lines of the crosshairs.
     * @return:     The thickness of the crosshairs.
     */
    getThickness: function() {
        return this._horizLeftHair.get_height();
    },

    /**
     * setOpacity:
     * Set how opaque the crosshairs are.
     * @opacity:    Value between 0 (fully transparent) and 255 (full opaque).
     */
    setOpacity: function(opacity) {
        // set_opacity() throws an exception for values outside the range
        // [0, 255].
        if (opacity < 0)
            opacity = 0;
        else if (opacity > 255)
            opacity = 255;

        this._horizLeftHair.set_opacity(opacity);
        this._horizRightHair.set_opacity(opacity);
        this._vertTopHair.set_opacity(opacity);
        this._vertBottomHair.set_opacity(opacity);
    },

    /**
     * getOpacity:
     * Retriev how opaque the crosshairs are.
     * @return: A value between 0 (transparent) and 255 (opaque).
     */
    getOpacity: function() {
        return this._horizLeftHair.get_opacity();
    },

    /**
     * setLength:
     * Set the length of the vertical and horizontal lines in the crosshairs.
     * @length: The length of the crosshairs.
     */
    setLength: function(length) {
        this._horizLeftHair.set_width(length);
        this._horizRightHair.set_width(length);
        this._vertTopHair.set_height(length);
        this._vertBottomHair.set_height(length);
        this.reCenter();
    },

    /**
     * getLength:
     * Get the length of the vertical and horizontal lines in the crosshairs.
     * @return: The length of the crosshairs.
     */
    getLength: function() {
        return this._horizLeftHair.get_width();
    },

    /**
     * setClip:
     * Set the width and height of the rectangle that clips the crosshairs at
     * their intersection
     * @size:   Array of [width, height] defining the size of the clip
     *          rectangle.
     */
    setClip: function(size) {
        if (size) {
            // Take a chunk out of the crosshairs where it intersects the
            // mouse.
            this._clipSize = size;
            this.reCenter();
        }
        else {
            // Restore the missing chunk.
            this._clipSize = [0, 0];
            this.reCenter();
        }
     },

    /**
     * getClip:
     * Get the dimensions of the clip rectangle.
     * @return:   An array of the form [width, height].
     */
    getClip: function() {
        return this._clipSize;
    },

    /**
     * show:
     * Show the crosshairs.
     */
    show: function() {
        this._actor.show();
        // Clones don't share visibility.
        for (let i = 0; i < this._clones.length; i++)
            this._clones[i].show();
    },

    /**
     * hide:
     * Hide the crosshairs.
     */
    hide: function() {
        this._actor.hide();
        // Clones don't share visibility.
        for (let i = 0; i < this._clones.length; i++)
            this._clones[i].hide();
    },

    /**
     * reCenter:
     * Reposition the horizontal and vertical hairs such that they cross at
     * the center of crosshairs group.  If called with the dimensions of
     * the clip rectangle, these are used to update the size of the clip.
     * @clipSize:  Optional.  If present, an array of the form [width, height].
     */
    reCenter: function(clipSize) {
        let [groupWidth, groupHeight] = this._actor.get_size();
        let leftLength = this._horizLeftHair.get_width();
        let rightLength = this._horizRightHair.get_width();
        let topLength = this._vertTopHair.get_height();
        let bottomLength = this._vertBottomHair.get_height();
        let thickness = this._horizLeftHair.get_height();

        // Deal with clip rectangle.
        if (clipSize)
            this._clipSize = clipSize;
        let clipWidth = this._clipSize[0];
        let clipHeight = this._clipSize[1];

        // Note that clip, if present, is not centred on the cross hair
        // intersection, but biased towards the top left.
        let left = groupWidth / 2 - clipWidth * 0.25 - leftLength;
        let right = groupWidth / 2 + clipWidth * 0.75;
        let top = groupHeight / 2 - clipHeight * 0.25 - topLength - thickness / 2;
        let bottom = groupHeight / 2 + clipHeight * 0.75 + thickness / 2;
        this._horizLeftHair.set_position(left, (groupHeight - thickness) / 2);
        this._horizRightHair.set_position(right, (groupHeight - thickness) / 2);
        this._vertTopHair.set_position((groupWidth - thickness) / 2, top);
        this._vertBottomHair.set_position((groupWidth - thickness) / 2, bottom);
    }
};