diff options
Diffstat (limited to 'js/ui/magnifier.js')
-rw-r--r-- | js/ui/magnifier.js | 2093 |
1 files changed, 2093 insertions, 0 deletions
diff --git a/js/ui/magnifier.js b/js/ui/magnifier.js new file mode 100644 index 0000000..bd69047 --- /dev/null +++ b/js/ui/magnifier.js @@ -0,0 +1,2093 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Magnifier */ + +const { + Atspi, Clutter, GDesktopEnums, Gio, GLib, GObject, Meta, Shell, St, +} = imports.gi; +const Signals = imports.misc.signals; + +const Background = imports.ui.background; +const FocusCaretTracker = imports.ui.focusCaretTracker; +const Main = imports.ui.main; +const Params = imports.misc.params; +const PointerWatcher = imports.ui.pointerWatcher; + +var CROSSHAIRS_CLIP_SIZE = [100, 100]; +var NO_CHANGE = 0.0; + +var POINTER_REST_TIME = 1000; // milliseconds + +// Settings +const MAGNIFIER_SCHEMA = 'org.gnome.desktop.a11y.magnifier'; +const SCREEN_POSITION_KEY = 'screen-position'; +const MAG_FACTOR_KEY = 'mag-factor'; +const INVERT_LIGHTNESS_KEY = 'invert-lightness'; +const COLOR_SATURATION_KEY = 'color-saturation'; +const BRIGHT_RED_KEY = 'brightness-red'; +const BRIGHT_GREEN_KEY = 'brightness-green'; +const BRIGHT_BLUE_KEY = 'brightness-blue'; +const CONTRAST_RED_KEY = 'contrast-red'; +const CONTRAST_GREEN_KEY = 'contrast-green'; +const CONTRAST_BLUE_KEY = 'contrast-blue'; +const LENS_MODE_KEY = 'lens-mode'; +const CLAMP_MODE_KEY = 'scroll-at-edges'; +const MOUSE_TRACKING_KEY = 'mouse-tracking'; +const FOCUS_TRACKING_KEY = 'focus-tracking'; +const CARET_TRACKING_KEY = 'caret-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'; + +var MouseSpriteContent = GObject.registerClass({ + Implements: [Clutter.Content], +}, class MouseSpriteContent extends GObject.Object { + _init() { + super._init(); + this._texture = null; + } + + vfunc_get_preferred_size() { + if (!this._texture) + return [false, 0, 0]; + + return [true, this._texture.get_width(), this._texture.get_height()]; + } + + vfunc_paint_content(actor, node, _paintContext) { + if (!this._texture) + return; + + let color = Clutter.Color.get_static(Clutter.StaticColor.WHITE); + let [minFilter, magFilter] = actor.get_content_scaling_filters(); + let textureNode = new Clutter.TextureNode(this._texture, + color, minFilter, magFilter); + textureNode.set_name('MouseSpriteContent'); + node.add_child(textureNode); + + textureNode.add_rectangle(actor.get_content_box()); + } + + get texture() { + return this._texture; + } + + set texture(coglTexture) { + if (this._texture == coglTexture) + return; + + let oldTexture = this._texture; + this._texture = coglTexture; + this.invalidate(); + + if (!oldTexture || !coglTexture || + oldTexture.get_width() != coglTexture.get_width() || + oldTexture.get_height() != coglTexture.get_height()) + this.invalidate_size(); + } +}); + +var Magnifier = class Magnifier extends Signals.EventEmitter { + constructor() { + super(); + + // Magnifier is a manager of ZoomRegions. + this._zoomRegions = []; + + // Create small clutter tree for the magnified mouse. + let cursorTracker = Meta.CursorTracker.get_for_display(global.display); + this._cursorTracker = cursorTracker; + + this._mouseSprite = new Clutter.Actor({ request_mode: Clutter.RequestMode.CONTENT_SIZE }); + this._mouseSprite.content = new MouseSpriteContent(); + + this._cursorRoot = new Clutter.Actor(); + this._cursorRoot.add_actor(this._mouseSprite); + + // Create the first ZoomRegion and initialize it according to the + // magnification settings. + + [this.xMouse, this.yMouse] = global.get_pointer(); + + let aZoomRegion = new ZoomRegion(this, this._cursorRoot); + this._zoomRegions.push(aZoomRegion); + this._settingsInit(aZoomRegion); + aZoomRegion.scrollContentsTo(this.xMouse, this.yMouse); + + St.Settings.get().connect('notify::magnifier-active', () => { + this.setActive(St.Settings.get().magnifier_active); + }); + + this.setActive(St.Settings.get().magnifier_active); + } + + /** + * showSystemCursor: + * Show the system mouse pointer. + */ + showSystemCursor() { + const seat = Clutter.get_default_backend().get_default_seat(); + + if (seat.is_unfocus_inhibited()) + seat.uninhibit_unfocus(); + + if (this._cursorVisibilityChangedId) { + this._cursorTracker.disconnect(this._cursorVisibilityChangedId); + delete this._cursorVisibilityChangedId; + + this._cursorTracker.set_pointer_visible(true); + } + } + + /** + * hideSystemCursor: + * Hide the system mouse pointer. + */ + hideSystemCursor() { + const seat = Clutter.get_default_backend().get_default_seat(); + + if (!seat.is_unfocus_inhibited()) + seat.inhibit_unfocus(); + + if (!this._cursorVisibilityChangedId) { + this._cursorTracker.set_pointer_visible(false); + this._cursorVisibilityChangedId = this._cursorTracker.connect('visibility-changed', () => { + if (this._cursorTracker.get_pointer_visible()) + this._cursorTracker.set_pointer_visible(false); + }); + } + } + + /** + * setActive: + * Show/hide all the zoom regions. + * @param {bool} activate: Boolean to activate or de-activate the magnifier. + */ + setActive(activate) { + let isActive = this.isActive(); + + this._zoomRegions.forEach(zoomRegion => { + zoomRegion.setActive(activate); + }); + + if (isActive === activate) + return; + + if (activate) { + this._updateMouseSprite(); + this._cursorTracker.connectObject( + 'cursor-changed', this._updateMouseSprite.bind(this), this); + Meta.disable_unredirect_for_display(global.display); + this.startTrackingMouse(); + } else { + this._cursorTracker.disconnectObject(this); + this._mouseSprite.content.texture = null; + Meta.enable_unredirect_for_display(global.display); + this.stopTrackingMouse(); + } + + if (this._crossHairs) + this._crossHairs.setEnabled(activate); + + // Make sure system mouse pointer is shown when all zoom regions are + // invisible. + if (!activate) + this.showSystemCursor(); + + // Notify interested parties of this change + this.emit('active-changed', activate); + } + + /** + * isActive: + * @returns {bool} Whether the magnifier is active. + */ + isActive() { + // 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() { + if (!this._pointerWatch) { + let interval = 1000 / 60; + this._pointerWatch = PointerWatcher.getPointerWatcher().addWatch(interval, this.scrollToMousePos.bind(this)); + + this.scrollToMousePos(); + } + } + + /** + * stopTrackingMouse: + * Turn off mouse tracking, if not already doing so. + */ + stopTrackingMouse() { + if (this._pointerWatch) + this._pointerWatch.remove(); + + this._pointerWatch = null; + } + + /** + * isTrackingMouse: + * @returns {bool} whether the magnifier is currently tracking the mouse + */ + isTrackingMouse() { + return !!this._mouseTrackingId; + } + + /** + * scrollToMousePos: + * Position all zoom regions' ROI relative to the current location of the + * system pointer. + */ + scrollToMousePos(...args) { + const [xMouse, yMouse] = args.length ? args : global.get_pointer(); + + if (xMouse === this.xMouse && yMouse === this.yMouse) + return; + + this.xMouse = xMouse; + this.yMouse = yMouse; + + let sysMouseOverAny = false; + this._zoomRegions.forEach(zoomRegion => { + if (zoomRegion.scrollToMousePos()) + sysMouseOverAny = true; + }); + if (sysMouseOverAny) + this.hideSystemCursor(); + else + this.showSystemCursor(); + } + + /** + * createZoomRegion: + * Create a ZoomRegion instance with the given properties. + * @param {number} 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. + * @param {number} yMagFactor: + * The power to set the vertical magnification of the ZoomRegion. + * @param {{x: number, y: number, width: number, height: number}} roi: + * The reg Object that defines the region to magnify, given in + * unmagnified coordinates. + * @param {{x: number, y: number, width: number, height: number}} viewPort: + * Object that defines the position of the ZoomRegion on screen. + * @returns {ZoomRegion} the newly created ZoomRegion. + */ + createZoomRegion(xMagFactor, yMagFactor, roi, viewPort) { + let zoomRegion = new ZoomRegion(this, this._cursorRoot); + zoomRegion.setViewPort(viewPort); + + // We ignore the redundant width/height on the ROI + let fixedROI = Object.create(roi); + fixedROI.width = viewPort.width / xMagFactor; + fixedROI.height = viewPort.height / yMagFactor; + zoomRegion.setROI(fixedROI); + + zoomRegion.addCrosshairs(this._crossHairs); + return zoomRegion; + } + + /** + * addZoomRegion: + * Append the given ZoomRegion to the list of currently defined ZoomRegions + * for this Magnifier instance. + * @param {ZoomRegion} zoomRegion: The zoomRegion to add. + */ + addZoomRegion(zoomRegion) { + if (zoomRegion) { + this._zoomRegions.push(zoomRegion); + if (!this.isTrackingMouse()) + this.startTrackingMouse(); + } + } + + /** + * getZoomRegions: + * Return a list of ZoomRegion's for this Magnifier. + * @returns {number[]} The Magnifier's zoom region list. + */ + getZoomRegions() { + return this._zoomRegions; + } + + /** + * clearAllZoomRegions: + * Remove all the zoom regions from this Magnfier's ZoomRegion list. + */ + clearAllZoomRegions() { + for (let i = 0; i < this._zoomRegions.length; i++) + this._zoomRegions[i].setActive(false); + + this._zoomRegions.length = 0; + this.stopTrackingMouse(); + this.showSystemCursor(); + } + + /** + * addCrosshairs: + * Add and show a cross hair centered on the magnified mouse. + */ + addCrosshairs() { + 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_double(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(zoomRegion => { + zoomRegion.addCrosshairs(theCrossHairs); + }); + } + + /** + * setCrosshairsVisible: + * Show or hide the cross hair. + * @param {bool} visible: Flag that indicates show (true) or hide (false). + */ + setCrosshairsVisible(visible) { + if (visible) { + if (!this._crossHairs) + this.addCrosshairs(); + this._crossHairs.show(); + } else { + // eslint-disable-next-line no-lonely-if + if (this._crossHairs) + this._crossHairs.hide(); + } + } + + /** + * setCrosshairsColor: + * Set the color of the crosshairs for all ZoomRegions. + * @param {string} color: The color as a string, e.g. '#ff0000ff' or 'red'. + */ + setCrosshairsColor(color) { + if (this._crossHairs) { + let [res_, clutterColor] = Clutter.Color.from_string(color); + this._crossHairs.setColor(clutterColor); + } + } + + /** + * getCrosshairsColor: + * Get the color of the crosshairs. + * @returns {string} The color as a string, e.g. '#0000ffff' or 'blue'. + */ + getCrosshairsColor() { + if (this._crossHairs) { + let clutterColor = this._crossHairs.getColor(); + return clutterColor.to_string(); + } else { + return '#00000000'; + } + } + + /** + * setCrosshairsThickness: + * Set the crosshairs thickness for all ZoomRegions. + * @param {number} thickness: The width of the vertical and + * horizontal lines of the crosshairs. + */ + setCrosshairsThickness(thickness) { + if (this._crossHairs) + this._crossHairs.setThickness(thickness); + } + + /** + * getCrosshairsThickness: + * Get the crosshairs thickness. + * @returns {number} The width of the vertical and horizontal + * lines of the crosshairs. + */ + getCrosshairsThickness() { + if (this._crossHairs) + return this._crossHairs.getThickness(); + else + return 0; + } + + /** + * setCrosshairsOpacity: + * @param {number} opacity: Value between 0.0 (transparent) + * and 1.0 (fully opaque). + */ + setCrosshairsOpacity(opacity) { + if (this._crossHairs) + this._crossHairs.setOpacity(opacity * 255); + } + + /** + * getCrosshairsOpacity: + * @returns {number} Value between 0.0 (transparent) and 1.0 (fully opaque). + */ + getCrosshairsOpacity() { + if (this._crossHairs) + return this._crossHairs.getOpacity() / 255.0; + else + return 0.0; + } + + /** + * setCrosshairsLength: + * Set the crosshairs length for all ZoomRegions. + * @param {number} length: The length of the vertical and horizontal + * lines making up the crosshairs. + */ + setCrosshairsLength(length) { + if (this._crossHairs) { + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._crossHairs.setLength(length / scaleFactor); + } + } + + /** + * getCrosshairsLength: + * Get the crosshairs length. + * @returns {number} The length of the vertical and horizontal + * lines making up the crosshairs. + */ + getCrosshairsLength() { + if (this._crossHairs) + return this._crossHairs.getLength(); + else + return 0; + } + + /** + * setCrosshairsClip: + * Set whether the crosshairs are clipped at their intersection. + * @param {bool} clip: Flag to indicate whether to clip the crosshairs. + */ + setCrosshairsClip(clip) { + if (!this._crossHairs) + return; + + // Setting no clipping on crosshairs means a zero sized clip rectangle. + this._crossHairs.setClip(clip ? CROSSHAIRS_CLIP_SIZE : [0, 0]); + } + + /** + * getCrosshairsClip: + * Get whether the crosshairs are clipped by the mouse image. + * @returns {bool} Whether the crosshairs are clipped. + */ + getCrosshairsClip() { + if (this._crossHairs) { + let [clipWidth, clipHeight] = this._crossHairs.getClip(); + return clipWidth > 0 && clipHeight > 0; + } else { + return false; + } + } + + // Private methods // + + _updateMouseSprite() { + this._updateSpriteTexture(); + let [xHot, yHot] = this._cursorTracker.get_hot(); + this._mouseSprite.set({ + translation_x: -xHot, + translation_y: -yHot, + }); + } + + _updateSpriteTexture() { + let sprite = this._cursorTracker.get_sprite(); + + if (sprite) { + this._mouseSprite.content.texture = sprite; + this._mouseSprite.show(); + } else { + this._mouseSprite.hide(); + } + } + + _settingsInit(zoomRegion) { + this._settings = new Gio.Settings({ schema_id: MAGNIFIER_SCHEMA }); + + this._settings.connect(`changed::${SCREEN_POSITION_KEY}`, + this._updateScreenPosition.bind(this)); + this._settings.connect(`changed::${MAG_FACTOR_KEY}`, + this._updateMagFactor.bind(this)); + this._settings.connect(`changed::${LENS_MODE_KEY}`, + this._updateLensMode.bind(this)); + this._settings.connect(`changed::${CLAMP_MODE_KEY}`, + this._updateClampMode.bind(this)); + this._settings.connect(`changed::${MOUSE_TRACKING_KEY}`, + this._updateMouseTrackingMode.bind(this)); + this._settings.connect(`changed::${FOCUS_TRACKING_KEY}`, + this._updateFocusTrackingMode.bind(this)); + this._settings.connect(`changed::${CARET_TRACKING_KEY}`, + this._updateCaretTrackingMode.bind(this)); + + this._settings.connect(`changed::${INVERT_LIGHTNESS_KEY}`, + this._updateInvertLightness.bind(this)); + this._settings.connect(`changed::${COLOR_SATURATION_KEY}`, + this._updateColorSaturation.bind(this)); + + this._settings.connect(`changed::${BRIGHT_RED_KEY}`, + this._updateBrightness.bind(this)); + this._settings.connect(`changed::${BRIGHT_GREEN_KEY}`, + this._updateBrightness.bind(this)); + this._settings.connect(`changed::${BRIGHT_BLUE_KEY}`, + this._updateBrightness.bind(this)); + + this._settings.connect(`changed::${CONTRAST_RED_KEY}`, + this._updateContrast.bind(this)); + this._settings.connect(`changed::${CONTRAST_GREEN_KEY}`, + this._updateContrast.bind(this)); + this._settings.connect(`changed::${CONTRAST_BLUE_KEY}`, + this._updateContrast.bind(this)); + + this._settings.connect(`changed::${SHOW_CROSS_HAIRS_KEY}`, () => { + this.setCrosshairsVisible(this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_THICKNESS_KEY}`, () => { + this.setCrosshairsThickness(this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_COLOR_KEY}`, () => { + this.setCrosshairsColor(this._settings.get_string(CROSS_HAIRS_COLOR_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_OPACITY_KEY}`, () => { + this.setCrosshairsOpacity(this._settings.get_double(CROSS_HAIRS_OPACITY_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_LENGTH_KEY}`, () => { + this.setCrosshairsLength(this._settings.get_int(CROSS_HAIRS_LENGTH_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_CLIP_KEY}`, () => { + this.setCrosshairsClip(this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY)); + }); + + 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); + + aPref = this._settings.get_enum(FOCUS_TRACKING_KEY); + if (aPref) + zoomRegion.setFocusTrackingMode(aPref); + + aPref = this._settings.get_enum(CARET_TRACKING_KEY); + if (aPref) + zoomRegion.setCaretTrackingMode(aPref); + + aPref = this._settings.get_boolean(INVERT_LIGHTNESS_KEY); + if (aPref) + zoomRegion.setInvertLightness(aPref); + + aPref = this._settings.get_double(COLOR_SATURATION_KEY); + if (aPref) + zoomRegion.setColorSaturation(aPref); + + let bc = {}; + bc.r = this._settings.get_double(BRIGHT_RED_KEY); + bc.g = this._settings.get_double(BRIGHT_GREEN_KEY); + bc.b = this._settings.get_double(BRIGHT_BLUE_KEY); + zoomRegion.setBrightness(bc); + + bc.r = this._settings.get_double(CONTRAST_RED_KEY); + bc.g = this._settings.get_double(CONTRAST_GREEN_KEY); + bc.b = this._settings.get_double(CONTRAST_BLUE_KEY); + zoomRegion.setContrast(bc); + } + + let showCrosshairs = this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY); + this.addCrosshairs(); + this.setCrosshairsVisible(showCrosshairs); + } + + _updateScreenPosition() { + // 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 != GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN) + this._updateLensMode(); + } + } + + _updateMagFactor() { + // 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() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) + this._zoomRegions[0].setLensMode(this._settings.get_boolean(LENS_MODE_KEY)); + } + + _updateClampMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setClampScrollingAtEdges( + !this._settings.get_boolean(CLAMP_MODE_KEY)); + } + } + + _updateMouseTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setMouseTrackingMode( + this._settings.get_enum(MOUSE_TRACKING_KEY)); + } + } + + _updateFocusTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setFocusTrackingMode( + this._settings.get_enum(FOCUS_TRACKING_KEY)); + } + } + + _updateCaretTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setCaretTrackingMode( + this._settings.get_enum(CARET_TRACKING_KEY)); + } + } + + _updateInvertLightness() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setInvertLightness( + this._settings.get_boolean(INVERT_LIGHTNESS_KEY)); + } + } + + _updateColorSaturation() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setColorSaturation( + this._settings.get_double(COLOR_SATURATION_KEY)); + } + } + + _updateBrightness() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let brightness = {}; + brightness.r = this._settings.get_double(BRIGHT_RED_KEY); + brightness.g = this._settings.get_double(BRIGHT_GREEN_KEY); + brightness.b = this._settings.get_double(BRIGHT_BLUE_KEY); + this._zoomRegions[0].setBrightness(brightness); + } + } + + _updateContrast() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let contrast = {}; + contrast.r = this._settings.get_double(CONTRAST_RED_KEY); + contrast.g = this._settings.get_double(CONTRAST_GREEN_KEY); + contrast.b = this._settings.get_double(CONTRAST_BLUE_KEY); + this._zoomRegions[0].setContrast(contrast); + } + } +}; + +var ZoomRegion = class ZoomRegion { + constructor(magnifier, mouseSourceActor) { + this._magnifier = magnifier; + this._focusCaretTracker = new FocusCaretTracker.FocusCaretTracker(); + + this._mouseTrackingMode = GDesktopEnums.MagnifierMouseTrackingMode.NONE; + this._focusTrackingMode = GDesktopEnums.MagnifierFocusTrackingMode.NONE; + this._caretTrackingMode = GDesktopEnums.MagnifierCaretTrackingMode.NONE; + this._clampScrollingAtEdges = false; + this._lensMode = false; + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + this._invertLightness = false; + this._colorSaturation = 1.0; + this._brightness = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE }; + this._contrast = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE }; + + this._magView = null; + this._background = null; + this._uiGroupClone = null; + this._mouseSourceActor = mouseSourceActor; + this._mouseActor = null; + this._crossHairs = null; + this._crossHairsActor = null; + + this._viewPortX = 0; + this._viewPortY = 0; + this._viewPortWidth = global.screen_width; + this._viewPortHeight = global.screen_height; + this._xCenter = this._viewPortWidth / 2; + this._yCenter = this._viewPortHeight / 2; + this._xMagFactor = 1; + this._yMagFactor = 1; + this._followingCursor = false; + this._xFocus = 0; + this._yFocus = 0; + this._xCaret = 0; + this._yCaret = 0; + + this._pointerIdleMonitor = global.backend.get_core_idle_monitor(); + this._scrollContentsTimerId = 0; + } + + _connectSignals() { + if (this._signalConnections) + return; + + this._signalConnections = []; + let id = Main.layoutManager.connect('monitors-changed', + this._monitorsChanged.bind(this)); + this._signalConnections.push([Main.layoutManager, id]); + + id = this._focusCaretTracker.connect('caret-moved', this._updateCaret.bind(this)); + this._signalConnections.push([this._focusCaretTracker, id]); + + id = this._focusCaretTracker.connect('focus-changed', this._updateFocus.bind(this)); + this._signalConnections.push([this._focusCaretTracker, id]); + } + + _disconnectSignals() { + for (let [obj, id] of this._signalConnections) + obj.disconnect(id); + + delete this._signalConnections; + } + + _updateScreenPosition() { + if (this._screenPosition == GDesktopEnums.MagnifierScreenPosition.NONE) { + this._setViewPort({ + x: this._viewPortX, + y: this._viewPortY, + width: this._viewPortWidth, + height: this._viewPortHeight, + }); + } else { + this.setScreenPosition(this._screenPosition); + } + } + + _convertExtentsToScreenSpace(accessible, extents) { + const toplevelWindowTypes = new Set([ + Atspi.Role.FRAME, + Atspi.Role.DIALOG, + Atspi.Role.WINDOW, + ]); + + try { + let app = null; + let parentWindow = null; + let iter = accessible; + while (iter) { + if (iter.get_role() === Atspi.Role.APPLICATION) { + app = iter; + /* This is the last Accessible we are interested in */ + break; + } else if (toplevelWindowTypes.has(iter.get_role())) { + parentWindow = iter; + } + iter = iter.get_parent(); + } + + /* We don't want to translate our own events to the focus window. + * They are also already scaled by clutter before being sent, so + * we don't need to do that here either. */ + if (app && app.get_name() === 'gnome-shell') + return extents; + + /* Only events from the focused widget of the focused window. Some + * widgets seem to claim to have focus when the window does not so + * check both. */ + const windowActive = parentWindow && + parentWindow.get_state_set().contains(Atspi.StateType.ACTIVE); + const accessibleFocused = + accessible.get_state_set().contains(Atspi.StateType.FOCUSED); + if (!windowActive || !accessibleFocused) + return null; + } catch (e) { + throw new Error(`Failed to validate parent window: ${e}`); + } + + const { focusWindow } = global.display; + if (!focusWindow) + return null; + + let windowRect = focusWindow.get_frame_rect(); + if (!focusWindow.is_client_decorated()) + windowRect = focusWindow.frame_rect_to_client_rect(windowRect); + + const scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + const screenSpaceExtents = new Atspi.Rect({ + x: windowRect.x + (scaleFactor * extents.x), + y: windowRect.y + (scaleFactor * extents.y), + width: scaleFactor * extents.width, + height: scaleFactor * extents.height, + }); + + return screenSpaceExtents; + } + + _updateFocus(caller, event) { + let component = event.source.get_component_iface(); + if (!component || event.detail1 != 1) + return; + let extents; + try { + extents = component.get_extents(Atspi.CoordType.WINDOW); + extents = this._convertExtentsToScreenSpace(event.source, extents); + if (!extents) + return; + } catch (e) { + log(`Failed to read extents of focused component: ${e.message}`); + return; + } + + const [xFocus, yFocus] = [ + extents.x + (extents.width / 2), + extents.y + (extents.height / 2), + ]; + + if (this._xFocus !== xFocus || this._yFocus !== yFocus) { + [this._xFocus, this._yFocus] = [xFocus, yFocus]; + this._centerFromFocusPosition(); + } + } + + _updateCaret(caller, event) { + let text = event.source.get_text_iface(); + if (!text) + return; + let extents; + try { + extents = text.get_character_extents(text.get_caret_offset(), + Atspi.CoordType.WINDOW); + extents = this._convertExtentsToScreenSpace(text, extents); + if (!extents) + return; + } catch (e) { + log(`Failed to read extents of text caret: ${e.message}`); + return; + } + + const [xCaret, yCaret] = [extents.x, extents.y]; + + // Ignore event(s) if the caret size is none (0x0). This happens a lot if + // the cursor offset can't be translated into a location. This is a work + // around. + if (extents.width === 0 && extents.height === 0) + return; + + if (this._xCaret !== xCaret || this._yCaret !== yCaret) { + [this._xCaret, this._yCaret] = [xCaret, yCaret]; + this._centerFromCaretPosition(); + } + } + + /** + * setActive: + * @param {bool} activate: Boolean to show/hide the ZoomRegion. + */ + setActive(activate) { + if (activate == this.isActive()) + return; + + if (activate) { + this._createActors(); + if (this._isMouseOverRegion()) + this._magnifier.hideSystemCursor(); + this._updateScreenPosition(); + this._updateMagViewGeometry(); + this._updateCloneGeometry(); + this._updateMousePosition(); + this._connectSignals(); + } else { + Main.uiGroup.set_opacity(255); + this._disconnectSignals(); + this._destroyActors(); + } + + this._syncCaretTracking(); + this._syncFocusTracking(); + } + + /** + * isActive: + * @returns {bool} Whether this ZoomRegion is active + */ + isActive() { + return this._magView != null; + } + + /** + * setMagFactor: + * @param {number} 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. + * @param {number} yMagFactor: The power to set the vertical + * magnification factor to of the magnified view. + */ + setMagFactor(xMagFactor, yMagFactor) { + this._changeROI({ + xMagFactor, + yMagFactor, + redoCursorTracking: this._followingCursor, + animate: true, + }); + } + + /** + * getMagFactor: + * @returns {number[]} 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() { + return [this._xMagFactor, this._yMagFactor]; + } + + /** + * setMouseTrackingMode + * @param {GDesktopEnums.MagnifierMouseTrackingMode} mode: the new mode + */ + setMouseTrackingMode(mode) { + if (mode >= GDesktopEnums.MagnifierMouseTrackingMode.NONE && + mode <= GDesktopEnums.MagnifierMouseTrackingMode.PUSH) + this._mouseTrackingMode = mode; + } + + /** + * getMouseTrackingMode + * @returns {GDesktopEnums.MagnifierMouseTrackingMode} the current mode + */ + getMouseTrackingMode() { + return this._mouseTrackingMode; + } + + /** + * setFocusTrackingMode + * @param {GDesktopEnums.MagnifierFocusTrackingMode} mode: the new mode + */ + setFocusTrackingMode(mode) { + this._focusTrackingMode = mode; + this._syncFocusTracking(); + } + + /** + * setCaretTrackingMode + * @param {GDesktopEnums.MagnifierCaretTrackingMode} mode: the new mode + */ + setCaretTrackingMode(mode) { + this._caretTrackingMode = mode; + this._syncCaretTracking(); + } + + _syncFocusTracking() { + let enabled = this._focusTrackingMode != GDesktopEnums.MagnifierFocusTrackingMode.NONE && + this.isActive(); + + if (enabled) + this._focusCaretTracker.registerFocusListener(); + else + this._focusCaretTracker.deregisterFocusListener(); + } + + _syncCaretTracking() { + let enabled = this._caretTrackingMode != GDesktopEnums.MagnifierCaretTrackingMode.NONE && + this.isActive(); + + if (enabled) + this._focusCaretTracker.registerCaretListener(); + else + this._focusCaretTracker.deregisterCaretListener(); + } + + /** + * setViewPort + * Sets the position and size of the ZoomRegion on screen. + * @param {{x: number, y: number, width: number, height: number}} viewPort: + * Object defining the position and size of the view port. + * The values are in stage coordinate space. + */ + setViewPort(viewPort) { + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.NONE; + } + + /** + * setROI + * Sets the "region of interest" that the ZoomRegion is magnifying. + * @param {{x: number, y: number, width: number, height: number}} roi: + * Object that defines the region of the screen to magnify. + * The values are in screen (unmagnified) coordinate space. + */ + setROI(roi) { + if (roi.width <= 0 || roi.height <= 0) + return; + + this._followingCursor = false; + this._changeROI({ + xMagFactor: this._viewPortWidth / roi.width, + yMagFactor: this._viewPortHeight / roi.height, + xCenter: roi.x + roi.width / 2, + yCenter: roi.y + roi.height / 2, + }); + } + + /** + * 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. + * @returns {number[]} an array, [x, y, width, height], representing + * the bounding rectangle of what is shown in the magnified view. + */ + getROI() { + let roiWidth = this._viewPortWidth / this._xMagFactor; + let roiHeight = this._viewPortHeight / this._yMagFactor; + + return [ + this._xCenter - roiWidth / 2, + this._yCenter - roiHeight / 2, + roiWidth, roiHeight, + ]; + } + + /** + * setLensMode: + * Turn lens mode on/off. In full screen mode, lens mode does nothing since + * a lens the size of the screen is pointless. + * @param {bool} lensMode: Whether lensMode should be active + */ + setLensMode(lensMode) { + this._lensMode = lensMode; + if (!this._lensMode) + this.setScreenPosition(this._screenPosition); + } + + /** + * isLensMode: + * Is lens mode on or off? + * @returns {bool} The lens mode state. + */ + isLensMode() { + return this._lensMode; + } + + /** + * setClampScrollingAtEdges: + * Stop vs. allow scrolling of the magnified contents when it scroll beyond + * the edges of the screen. + * @param {bool} clamp: Boolean to turn on/off clamping. + */ + setClampScrollingAtEdges(clamp) { + this._clampScrollingAtEdges = clamp; + if (clamp) + this._changeROI(); + } + + /** + * setTopHalf: + * Magnifier view occupies the top half of the screen. + */ + setTopHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height / 2; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.TOP_HALF; + } + + /** + * setBottomHalf: + * Magnifier view occupies the bottom half of the screen. + */ + setBottomHalf() { + 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 = GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF; + } + + /** + * setLeftHalf: + * Magnifier view occupies the left half of the screen. + */ + setLeftHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width / 2; + viewPort.height = global.screen_height; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.LEFT_HALF; + } + + /** + * setRightHalf: + * Magnifier view occupies the right half of the screen. + */ + setRightHalf() { + 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 = GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF; + } + + /** + * setFullScreenMode: + * Set the ZoomRegion to full-screen mode. + * Note: disallows lens mode. + */ + setFullScreenMode() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height; + this.setViewPort(viewPort); + + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + } + + /** + * setScreenPosition: + * Positions the zoom region to one of the enumerated positions on the + * screen. + * @param {GDesktopEnums.MagnifierScreenPosition} inPosition: the position + */ + setScreenPosition(inPosition) { + switch (inPosition) { + case GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN: + this.setFullScreenMode(); + break; + case GDesktopEnums.MagnifierScreenPosition.TOP_HALF: + this.setTopHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF: + this.setBottomHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.LEFT_HALF: + this.setLeftHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF: + this.setRightHalf(); + break; + } + } + + /** + * getScreenPosition: + * Tell the outside world what the current mode is -- magnifiying the + * top half, bottom half, etc. + * @returns {GDesktopEnums.MagnifierScreenPosition}: the current position. + */ + getScreenPosition() { + return this._screenPosition; + } + + _clearScrollContentsTimer() { + if (this._scrollContentsTimerId !== 0) { + GLib.source_remove(this._scrollContentsTimerId); + this._scrollContentsTimerId = 0; + } + } + + /** + * scrollToMousePos: + * Set the region of interest based on the position of the system pointer. + * @returns {bool}: Whether the system mouse pointer is over the + * magnified view. + */ + scrollToMousePos() { + this._followingCursor = true; + if (this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE) + this._changeROI({ redoCursorTracking: true }); + else + this._updateMousePosition(); + + this._clearScrollContentsTimer(); + this._scrollContentsTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POINTER_REST_TIME, () => { + this._followingCursor = false; + if (this._xDelayed !== null && this._yDelayed !== null) { + this._scrollContentsToDelayed(this._xDelayed, this._yDelayed); + this._xDelayed = null; + this._yDelayed = null; + } + + this._scrollContentsTimerId = 0; + + return GLib.SOURCE_REMOVE; + }); + + // Determine whether the system mouse pointer is over this zoom region. + return this._isMouseOverRegion(); + } + + _scrollContentsToDelayed(x, y) { + if (this._followingCursor) { + this._xDelayed = x; + this._yDelayed = y; + } else { + this.scrollContentsTo(x, y); + } + } + + /** + * scrollContentsTo: + * Shift the contents of the magnified view such it is centered on the given + * coordinate. + * @param {number} x: The x-coord of the point to center on. + * @param {number} y: The y-coord of the point to center on. + */ + scrollContentsTo(x, y) { + if (x < 0 || x > global.screen_width || + y < 0 || y > global.screen_height) + return; + + this._clearScrollContentsTimer(); + + this._followingCursor = false; + this._changeROI({ + xCenter: x, + yCenter: y, + animate: true, + }); + } + + /** + * addCrosshairs: + * Add crosshairs centered on the magnified mouse. + * @param {Crosshairs} crossHairs: Crosshairs instance + */ + addCrosshairs(crossHairs) { + this._crossHairs = crossHairs; + + // If the crossHairs is not already within a larger container, add it + // to this zoom region. Otherwise, add a clone. + if (crossHairs && this.isActive()) + this._crossHairsActor = crossHairs.addToZoomRegion(this, this._mouseActor); + } + + /** + * setInvertLightness: + * Set whether to invert the lightness of the magnified view. + * @param {bool} flag: whether brightness should be inverted + */ + setInvertLightness(flag) { + this._invertLightness = flag; + if (this._magShaderEffects) + this._magShaderEffects.setInvertLightness(this._invertLightness); + } + + /** + * getInvertLightness: + * Retrieve whether the lightness is inverted. + * @returns {bool} whether brightness should be inverted + */ + getInvertLightness() { + return this._invertLightness; + } + + /** + * setColorSaturation: + * Set the color saturation of the magnified view. + * @param {number} saturation: A value from 0.0 to 1.0 that defines + * the color saturation, with 0.0 defining no color (grayscale), + * and 1.0 defining full color. + */ + setColorSaturation(saturation) { + this._colorSaturation = saturation; + if (this._magShaderEffects) + this._magShaderEffects.setColorSaturation(this._colorSaturation); + } + + /** + * getColorSaturation: + * Retrieve the color saturation of the magnified view. + * @returns {number} the color saturation + */ + getColorSaturation() { + return this._colorSaturation; + } + + /** + * setBrightness: + * Alter the brightness of the magnified view. + * @param {Object} brightness: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * brightness (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed brightness, respectively. + * + * {number} brightness.r - the red component + * {number} brightness.g - the green component + * {number} brightness.b - the blue component + */ + setBrightness(brightness) { + this._brightness.r = brightness.r; + this._brightness.g = brightness.g; + this._brightness.b = brightness.b; + if (this._magShaderEffects) + this._magShaderEffects.setBrightness(this._brightness); + } + + /** + * setContrast: + * Alter the contrast of the magnified view. + * @param {Object} contrast: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * contrast (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed contrast, respectively. + * + * {number} contrast.r - the red component + * {number} contrast.g - the green component + * {number} contrast.b - the blue component + */ + setContrast(contrast) { + this._contrast.r = contrast.r; + this._contrast.g = contrast.g; + this._contrast.b = contrast.b; + if (this._magShaderEffects) + this._magShaderEffects.setContrast(this._contrast); + } + + /** + * getContrast: + * Retrieve the contrast of the magnified view. + * @returns {{r: number, g: number, b: number}}: Object containing + * the contrast for the red, green, and blue channels. + */ + getContrast() { + let contrast = {}; + contrast.r = this._contrast.r; + contrast.g = this._contrast.g; + contrast.b = this._contrast.b; + return contrast; + } + + // Private methods // + + _createActors() { + // The root actor for the zoom region + this._magView = new St.Bin({ style_class: 'magnifier-zoom-region' }); + global.stage.add_actor(this._magView); + + // hide the magnified region from CLUTTER_PICK_ALL + Shell.util_set_hidden_from_pick(this._magView, true); + + // Add a group to clip the contents of the magnified view. + let mainGroup = new Clutter.Actor({ clip_to_allocation: true }); + this._magView.set_child(mainGroup); + + // Add a background for when the magnified uiGroup is scrolled + // out of view (don't want to see desktop showing through). + this._background = new Background.SystemBackground(); + mainGroup.add_actor(this._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, + clip_to_allocation: true, + }); + mainGroup.add_actor(this._uiGroupClone); + + // Add either the given mouseSourceActor to the ZoomRegion, or a clone of + // it. + if (this._mouseSourceActor.get_parent() != null) + this._mouseActor = new Clutter.Clone({ source: this._mouseSourceActor }); + else + this._mouseActor = this._mouseSourceActor; + mainGroup.add_actor(this._mouseActor); + + if (this._crossHairs) + this._crossHairsActor = this._crossHairs.addToZoomRegion(this, this._mouseActor); + else + this._crossHairsActor = null; + + // Contrast and brightness effects. + this._magShaderEffects = new MagShaderEffects(mainGroup); + this._magShaderEffects.setColorSaturation(this._colorSaturation); + this._magShaderEffects.setInvertLightness(this._invertLightness); + this._magShaderEffects.setBrightness(this._brightness); + this._magShaderEffects.setContrast(this._contrast); + } + + _destroyActors() { + if (this._mouseActor == this._mouseSourceActor) + this._mouseActor.get_parent().remove_actor(this._mouseActor); + if (this._crossHairs) + this._crossHairs.removeFromParent(this._crossHairsActor); + + this._magShaderEffects.destroyEffects(); + this._magShaderEffects = null; + this._magView.destroy(); + this._magView = null; + this._background = null; + this._uiGroupClone = null; + this._mouseActor = null; + this._crossHairsActor = null; + } + + _setViewPort(viewPort, fromROIUpdate) { + // Sets the position of the zoom region on the screen + + let width = Math.round(Math.min(viewPort.width, global.screen_width)); + let height = Math.round(Math.min(viewPort.height, global.screen_height)); + let x = Math.max(viewPort.x, 0); + let y = Math.max(viewPort.y, 0); + + x = Math.round(Math.min(x, global.screen_width - width)); + y = Math.round(Math.min(y, global.screen_height - height)); + + this._viewPortX = x; + this._viewPortY = y; + this._viewPortWidth = width; + this._viewPortHeight = height; + + this._updateMagViewGeometry(); + + if (!fromROIUpdate) + this._changeROI({ redoCursorTracking: this._followingCursor }); // will update mouse + + if (this.isActive() && this._isMouseOverRegion()) + this._magnifier.hideSystemCursor(); + + const uiGroupIsOccluded = this.isActive() && this._isFullScreen(); + Main.uiGroup.set_opacity(uiGroupIsOccluded ? 0 : 255); + } + + _changeROI(params) { + // Updates the area we are viewing; the magnification factors + // and center can be set explicitly, or we can recompute + // the position based on the mouse cursor position + + params = Params.parse(params, { + xMagFactor: this._xMagFactor, + yMagFactor: this._yMagFactor, + xCenter: this._xCenter, + yCenter: this._yCenter, + redoCursorTracking: false, + animate: false, + }); + + if (params.xMagFactor <= 0) + params.xMagFactor = this._xMagFactor; + if (params.yMagFactor <= 0) + params.yMagFactor = this._yMagFactor; + + this._xMagFactor = params.xMagFactor; + this._yMagFactor = params.yMagFactor; + + if (params.redoCursorTracking && + this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE) { + // This depends on this.xMagFactor/yMagFactor already being updated + [params.xCenter, params.yCenter] = this._centerFromMousePosition(); + } + + if (this._clampScrollingAtEdges) { + let roiWidth = this._viewPortWidth / this._xMagFactor; + let roiHeight = this._viewPortHeight / this._yMagFactor; + + params.xCenter = Math.min(params.xCenter, global.screen_width - roiWidth / 2); + params.xCenter = Math.max(params.xCenter, roiWidth / 2); + params.yCenter = Math.min(params.yCenter, global.screen_height - roiHeight / 2); + params.yCenter = Math.max(params.yCenter, roiHeight / 2); + } + + this._xCenter = params.xCenter; + this._yCenter = params.yCenter; + + // 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._isFullScreen()) { + this._setViewPort({ + x: this._xCenter - this._viewPortWidth / 2, + y: this._yCenter - this._viewPortHeight / 2, + width: this._viewPortWidth, + height: this._viewPortHeight, + }, true); + } + + this._updateCloneGeometry(params.animate); + } + + _isMouseOverRegion() { + // Return whether the system mouse sprite is over this ZoomRegion. If the + // mouse's position is not given, then it is fetched. + let mouseIsOver = false; + if (this.isActive()) { + let xMouse = this._magnifier.xMouse; + let yMouse = this._magnifier.yMouse; + + mouseIsOver = + xMouse >= this._viewPortX && xMouse < (this._viewPortX + this._viewPortWidth) && + yMouse >= this._viewPortY && yMouse < (this._viewPortY + this._viewPortHeight); + } + return mouseIsOver; + } + + _isFullScreen() { + // Does the magnified view occupy the whole screen? Note that this + // doesn't necessarily imply + // this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + + if (this._viewPortX != 0 || this._viewPortY != 0) + return false; + if (this._viewPortWidth != global.screen_width || + this._viewPortHeight != global.screen_height) + return false; + return true; + } + + _centerFromMousePosition() { + // Determines where the center should be given the current cursor + // position and mouse tracking mode + + let xMouse = this._magnifier.xMouse; + let yMouse = this._magnifier.yMouse; + + if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PROPORTIONAL) + return this._centerFromPointProportional(xMouse, yMouse); + else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PUSH) + return this._centerFromPointPush(xMouse, yMouse); + else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.CENTERED) + return this._centerFromPointCentered(xMouse, yMouse); + + return null; // Should never be hit + } + + _centerFromCaretPosition() { + let xCaret = this._xCaret; + let yCaret = this._yCaret; + + if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PROPORTIONAL) + [xCaret, yCaret] = this._centerFromPointProportional(xCaret, yCaret); + else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PUSH) + [xCaret, yCaret] = this._centerFromPointPush(xCaret, yCaret); + else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.CENTERED) + [xCaret, yCaret] = this._centerFromPointCentered(xCaret, yCaret); + + this._scrollContentsToDelayed(xCaret, yCaret); + } + + _centerFromFocusPosition() { + let xFocus = this._xFocus; + let yFocus = this._yFocus; + + if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PROPORTIONAL) + [xFocus, yFocus] = this._centerFromPointProportional(xFocus, yFocus); + else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PUSH) + [xFocus, yFocus] = this._centerFromPointPush(xFocus, yFocus); + else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.CENTERED) + [xFocus, yFocus] = this._centerFromPointCentered(xFocus, yFocus); + + this._scrollContentsToDelayed(xFocus, yFocus); + } + + _centerFromPointPush(xPoint, yPoint) { + let [xRoi, yRoi, widthRoi, heightRoi] = this.getROI(); + let [cursorWidth, cursorHeight] = this._mouseSourceActor.get_size(); + let xPos = xRoi + widthRoi / 2; + let yPos = yRoi + heightRoi / 2; + let xRoiRight = xRoi + widthRoi - cursorWidth; + let yRoiBottom = yRoi + heightRoi - cursorHeight; + + if (xPoint < xRoi) + xPos -= xRoi - xPoint; + else if (xPoint > xRoiRight) + xPos += xPoint - xRoiRight; + + if (yPoint < yRoi) + yPos -= yRoi - yPoint; + else if (yPoint > yRoiBottom) + yPos += yPoint - yRoiBottom; + + return [xPos, yPos]; + } + + _centerFromPointProportional(xPoint, yPoint) { + let [xRoi_, yRoi_, widthRoi, heightRoi] = this.getROI(); + let halfScreenWidth = global.screen_width / 2; + let halfScreenHeight = global.screen_height / 2; + // We want to pad with a constant distance after zooming, so divide + // by the magnification factor. + let unscaledPadding = Math.min(this._viewPortWidth, this._viewPortHeight) / 5; + let xPadding = unscaledPadding / this._xMagFactor; + let yPadding = unscaledPadding / this._yMagFactor; + let xProportion = (xPoint - halfScreenWidth) / halfScreenWidth; // -1 ... 1 + let yProportion = (yPoint - halfScreenHeight) / halfScreenHeight; // -1 ... 1 + let xPos = xPoint - xProportion * (widthRoi / 2 - xPadding); + let yPos = yPoint - yProportion * (heightRoi / 2 - yPadding); + + return [xPos, yPos]; + } + + _centerFromPointCentered(xPoint, yPoint) { + return [xPoint, yPoint]; + } + + _screenToViewPort(screenX, screenY) { + // Converts coordinates relative to the (unmagnified) screen to coordinates + // relative to the origin of this._magView + return [ + this._viewPortWidth / 2 + (screenX - this._xCenter) * this._xMagFactor, + this._viewPortHeight / 2 + (screenY - this._yCenter) * this._yMagFactor, + ]; + } + + _updateMagViewGeometry() { + if (!this.isActive()) + return; + + if (this._isFullScreen()) + this._magView.add_style_class_name('full-screen'); + else + this._magView.remove_style_class_name('full-screen'); + + this._magView.set_size(this._viewPortWidth, this._viewPortHeight); + this._magView.set_position(this._viewPortX, this._viewPortY); + } + + _updateCloneGeometry(animate = false) { + if (!this.isActive()) + return; + + let [x, y] = this._screenToViewPort(0, 0); + this._uiGroupClone.ease({ + x: Math.round(x), + y: Math.round(y), + scale_x: this._xMagFactor, + scale_y: this._yMagFactor, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + + let [mouseX, mouseY] = this._getMousePosition(); + this._mouseActor.ease({ + x: mouseX, + y: mouseY, + scale_x: this._xMagFactor, + scale_y: this._yMagFactor, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + + if (this._crossHairsActor) { + let [crossX, crossY] = this._getCrossHairsPosition(); + this._crossHairsActor.ease({ + x: crossX, + y: crossY, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + } + } + + _updateMousePosition() { + let [xMagMouse, yMagMouse] = this._getMousePosition(); + this._mouseActor.set_position(xMagMouse, yMagMouse); + + if (this._crossHairsActor) { + let [crossX, crossY] = this._getCrossHairsPosition(); + this._crossHairsActor.set_position(crossX, crossY); + } + } + + _getMousePosition() { + let [xMagMouse, yMagMouse] = this._screenToViewPort( + this._magnifier.xMouse, this._magnifier.yMouse); + return [Math.round(xMagMouse), Math.round(yMagMouse)]; + } + + _getCrossHairsPosition() { + let [xMagMouse, yMagMouse] = this._getMousePosition(); + let [groupWidth, groupHeight] = this._crossHairsActor.get_size(); + + return [xMagMouse - groupWidth / 2, yMagMouse - groupHeight / 2]; + } + + _monitorsChanged() { + this._background.set_size(global.screen_width, global.screen_height); + this._updateScreenPosition(); + } +}; + +var Crosshairs = GObject.registerClass( +class Crosshairs extends Clutter.Actor { + _init() { + // 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; + + super._init({ + clip_to_allocation: false, + width: groupWidth, + height: groupHeight, + }); + this._horizLeftHair = new Clutter.Actor(); + this._horizRightHair = new Clutter.Actor(); + this._vertTopHair = new Clutter.Actor(); + this._vertBottomHair = new Clutter.Actor(); + this.add_actor(this._horizLeftHair); + this.add_actor(this._horizRightHair); + this.add_actor(this._vertTopHair); + this.add_actor(this._vertBottomHair); + this._clipSize = [0, 0]; + this._clones = []; + this.reCenter(); + this._monitorsChangedId = 0; + } + + _monitorsChanged() { + this.set_size(global.screen_width * 3, global.screen_height * 3); + this.reCenter(); + } + + setEnabled(enabled) { + if (enabled && this._monitorsChangedId === 0) { + this._monitorsChangedId = Main.layoutManager.connect( + 'monitors-changed', this._monitorsChanged.bind(this)); + } else if (!enabled && this._monitorsChangedId !== 0) { + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; + } + } + + /** + * 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. + * @param {ZoomRegion} zoomRegion: The container to add the crosshairs + * group to. + * @param {Clutter.Actor} magnifiedMouse: The mouse actor for the + * zoom region -- used to position the crosshairs and properly + * layer them below the mouse. + * @returns {Clutter.Actor} The crosshairs actor, or its clone. + */ + addToZoomRegion(zoomRegion, magnifiedMouse) { + let crosshairsActor = null; + if (zoomRegion && magnifiedMouse) { + let container = magnifiedMouse.get_parent(); + if (container) { + crosshairsActor = this; + if (this.get_parent() != null) { + crosshairsActor = new Clutter.Clone({ source: this }); + this._clones.push(crosshairsActor); + + // Clones don't share visibility. + this.bind_property('visible', crosshairsActor, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } + + container.add_actor(crosshairsActor); + container.set_child_above_sibling(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: + * @param {Clutter.Actor} childActor: the actor returned from + * addToZoomRegion + * Remove the crosshairs actor from its parent container, or destroy the + * child actor if it was just a clone of the crosshairs actor. + */ + removeFromParent(childActor) { + if (childActor == this) + childActor.get_parent().remove_actor(childActor); + else + childActor.destroy(); + } + + /** + * setColor: + * Set the color of the crosshairs. + * @param {Clutter.Color} clutterColor: The color + */ + setColor(clutterColor) { + this._horizLeftHair.background_color = clutterColor; + this._horizRightHair.background_color = clutterColor; + this._vertTopHair.background_color = clutterColor; + this._vertBottomHair.background_color = clutterColor; + } + + /** + * getColor: + * Get the color of the crosshairs. + * @returns {ClutterColor} the crosshairs color + */ + getColor() { + return this._horizLeftHair.get_color(); + } + + /** + * setThickness: + * Set the width of the vertical and horizontal lines of the crosshairs. + * @param {number} thickness: the new thickness value + */ + setThickness(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. + * @returns {number} The thickness of the crosshairs. + */ + getThickness() { + return this._horizLeftHair.get_height(); + } + + /** + * setOpacity: + * Set how opaque the crosshairs are. + * @param {number} opacity: Value between 0 (fully transparent) + * and 255 (full opaque). + */ + setOpacity(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); + } + + /** + * setLength: + * Set the length of the vertical and horizontal lines in the crosshairs. + * @param {number} length: The length of the crosshairs. + */ + setLength(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. + * @returns {number} The length of the crosshairs. + */ + getLength() { + return this._horizLeftHair.get_width(); + } + + /** + * setClip: + * Set the width and height of the rectangle that clips the crosshairs at + * their intersection + * @param {number[]} size: Array of [width, height] defining the size + * of the clip rectangle. + */ + setClip(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(); + } + } + + /** + * 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. + * @param {number[]=} clipSize: If present, the clip's [width, height]. + */ + reCenter(clipSize) { + let [groupWidth, groupHeight] = this.get_size(); + let leftLength = this._horizLeftHair.get_width(); + let topLength = this._vertTopHair.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]; + + let left = groupWidth / 2 - clipWidth / 2 - leftLength - thickness / 2; + let right = groupWidth / 2 + clipWidth / 2 + thickness / 2; + let top = groupHeight / 2 - clipHeight / 2 - topLength - thickness / 2; + let bottom = groupHeight / 2 + clipHeight / 2 + 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); + } +}); + +var MagShaderEffects = class MagShaderEffects { + constructor(uiGroupClone) { + this._inverse = new Shell.InvertLightnessEffect(); + this._brightnessContrast = new Clutter.BrightnessContrastEffect(); + this._colorDesaturation = new Clutter.DesaturateEffect(); + this._inverse.set_enabled(false); + this._brightnessContrast.set_enabled(false); + this._colorDesaturation.set_enabled(false); + + this._magView = uiGroupClone; + this._magView.add_effect(this._inverse); + this._magView.add_effect(this._brightnessContrast); + this._magView.add_effect(this._colorDesaturation); + } + + /** + * destroyEffects: + * Remove contrast and brightness effects from the magnified view, and + * lose the reference to the actor they were applied to. Don't use this + * object after calling this. + */ + destroyEffects() { + this._magView.clear_effects(); + this._colorDesaturation = null; + this._brightnessContrast = null; + this._inverse = null; + this._magView = null; + } + + /** + * setInvertLightness: + * Enable/disable invert lightness effect. + * @param {bool} invertFlag: Enabled flag. + */ + setInvertLightness(invertFlag) { + this._inverse.set_enabled(invertFlag); + } + + setColorSaturation(factor) { + this._colorDesaturation.set_factor(1.0 - factor); + this._colorDesaturation.set_enabled(factor !== 1.0); + } + + /** + * setBrightness: + * Set the brightness of the magnified view. + * @param {Object} brightness: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * brightness (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed brightness, respectively. + * + * {number} brightness.r - the red component + * {number} brightness.g - the green component + * {number} brightness.b - the blue component + */ + setBrightness(brightness) { + let bRed = brightness.r; + let bGreen = brightness.g; + let bBlue = brightness.b; + this._brightnessContrast.set_brightness_full(bRed, bGreen, bBlue); + + // Enable the effect if the brightness OR contrast change are such that + // it modifies the brightness and/or contrast. + let [cRed, cGreen, cBlue] = this._brightnessContrast.get_contrast(); + this._brightnessContrast.set_enabled( + bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE || + cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE); + } + + /** + * Set the contrast of the magnified view. + * @param {Object} contrast: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * contrast (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed contrast, respectively. + * + * {number} contrast.r - the red component + * {number} contrast.g - the green component + * {number} contrast.b - the blue component + */ + setContrast(contrast) { + let cRed = contrast.r; + let cGreen = contrast.g; + let cBlue = contrast.b; + + this._brightnessContrast.set_contrast_full(cRed, cGreen, cBlue); + + // Enable the effect if the contrast OR brightness change are such that + // it modifies the brightness and/or contrast. + // should be able to use Clutter.color_equal(), but that complains of + // a null first argument. + let [bRed, bGreen, bBlue] = this._brightnessContrast.get_brightness(); + this._brightnessContrast.set_enabled( + cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE || + bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE); + } +}; |