/* DING: Desktop Icons New Generation for GNOME Shell * * Gtk4 Port Copyright (C) 2022 - 2025 Sundeep Mediratta (smedius@gmail.com) * Copyright (C) 2019 Sergio Costas (rastersoft@gmail.com) * Based on code original (C) Carlos Soriano * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import {GObject, Gtk, Gdk, GLib, Gio, Graphene, Gsk, Adw} from '../dependencies/gi.js'; import {_} from '../dependencies/gettext.js'; export {DesktopGrid}; // eslint-disable-next-line no-unused-vars const DisplayGrid = class { constructor(params) { const { desktopManager, desktopName, desktopDescription, asDesktop, hidden = false, desktopIndex = 0, } = params; this._destroying = false; this._desktopManager = desktopManager; this._mainapp = desktopManager.mainApp; this._dragManager = desktopManager.dragManager; this.Prefs = this._desktopManager.Prefs; this.DesktopIconsUtil = this._desktopManager.DesktopIconsUtil; this.DBusUtils = this._desktopManager.DBusUtils; this.Enums = this._desktopManager.Enums; this.elementSpacing = this.Enums.GRID_ELEMENT_SPACING; this.gridPadding = this.Enums.GRID_PADDING; this._desktopName = desktopName; this._desktopIndex = desktopIndex; this._asDesktop = asDesktop; this._desktopDescription = desktopDescription; this._hidden = hidden; this._using_X11 = this.DesktopIconsUtil.usingX11(); this.directoryOpenTimer = null; this.windowGlobalRectangle = new Gdk.Rectangle(); this._updateWindowGeometry(); this._updateUnscaledHeightWidthMargins(); this._createGrids(); this._window = new Gtk.ApplicationWindow( { application: desktopManager.mainApp, 'title': desktopName, } ); this._window.update_property( [Gtk.AccessibleProperty.LABEL], [_('Desktop Icons')] ); if (this._asDesktop) { this._window.set_decorated(false); this._window.set_deletable(false); this._window.set_resizable(false); // Transparent Background only if this is working as a desktop this._window.set_name('desktopwindow'); this._window .set_default_size(this._windowWidth, this._windowHeight); this._window .set_size_request(this._windowWidth, this._windowHeight); this._mappedPromise = new Promise(resolve => (this._resolveMapped = resolve)); if (!this._using_X11) { // Wayland Compositer hang on some high resolution // requires all windows be maximized to map and display // initially. this._window.maximize(); } this._window.connect('map', () => { if (!this._resolveMapped) return; this._resolveMapped(true); this._resolveMapped = null; // Maximize however creates an error where the window can // be moved by the user by dragging down on top panel. // So we unmaximize all windows after they are mapped // as maximization is not needed anymore. this._window.unmaximize(); }); } else { // Opaque black test window this._window.set_name('testwindow'); } // Remove any other css classes, even if applied by other apps later this._window.set_css_classes(['background']); this._window.connect('notify::css_classes', () => { this._window.set_css_classes(['background']); }); this._window.connect( 'close-request', () => { if (this._destroying) return false; if (this._asDesktop) { // Do not destroy window when closing if the instance // is working as desktop return true; } else { // Exit if this instance is working as an // stand-alone window this._desktopManager.terminateProgram(); return false; } } ); // New: one fixed root that contains both layers this._rootFixed = new Gtk.Fixed(); this._rootFixed.set_size_request(this._windowWidth, this._windowHeight); this._container = new Gtk.Fixed(); this._containerContext = this._container.get_style_context(); this._container.set_size_request(this._windowWidth, this._windowHeight); this._containerContext.add_class('unhighlightdroptarget'); // icon grid goes in rootFixed this._rootFixed.put(this._container, 0, 0); this._overlay = new Gtk.Overlay(); this._overlay.set_hexpand(true); this._overlay.set_vexpand(true); this._overlay.set_child(this._rootFixed); this._window.set_child(this._overlay); this.gridGlobalRectangle = new Gdk.Rectangle(); this._selectedList = null; this._setGridStatus(); this._updateGridRectangle(); } ensureMapped() { // show/present only here after the window is fully set up to // and to avoit commiting content too early so that the shell // errors on commiting first frame before acknowleding ack from wayland // compositor. this._window.set_visible(!this._hidden); this._window.present(); return this._mappedPromise; } ensureAllocationComplete() { if (this._allocPromise) return this._allocPromise; const w = this._container; this._allocPromise = new Promise(resolve => { let tickId = 0; let stableFrames = 0; const cleanup = () => { if (tickId) w.remove_tick_callback(tickId); this._allocPromise = null; }; const isAllocated = () => { const aw = w.get_allocated_width(); const ah = w.get_allocated_height(); return aw > 0 && ah > 0; }; if (isAllocated()) { this._overlay.queue_draw(); resolve(); cleanup(); return; } tickId = w.add_tick_callback(() => { if (isAllocated()) stableFrames++; else stableFrames = 0; if (stableFrames >= 2) { cleanup(); this._overlay.queue_draw(); resolve(); return GLib.SOURCE_REMOVE; } return GLib.SOURCE_CONTINUE; }); }); return this._allocPromise; } setErrorState() { this._window.set_name('errorstate'); } unsetErrorState() { if (this._asDesktop) this._window.set_name('desktopwindow'); else this._window.set_name('testwindow'); } hide() { this._window.hide(); this._hidden = true; } show() { this._window.present(); this._hidden = false; } queue_draw() { this._container.queue_draw(); this._overlay.queue_draw(); this._window.queue_draw(); } // Establish and update window geometry, establish and update // grid for the desktop icons updateGridDescription(desktopDescription) { this._desktopDescription = desktopDescription; } _updateWindowGeometry() { this._zoom = this._desktopDescription.zoom; this._x = this._desktopDescription.x; this._y = this._desktopDescription.y; this._monitor = this._desktopDescription.monitorIndex; this._sizer = this._zoom; if (this._asDesktop) { if (this._using_X11) this._sizer = Math.ceil(this._zoom); else if (this.Prefs.fractionalScaling) this._sizer = 1; } this._windowWidth = Math.floor(this._desktopDescription.width / this._sizer); this._windowHeight = Math.floor(this._desktopDescription.height / this._sizer); this.windowGlobalRectangle.x = this._x; this.windowGlobalRectangle.y = this._y; this.windowGlobalRectangle.width = this._windowWidth; this.windowGlobalRectangle.height = this._windowHeight; } resizeWindow() { this._updateWindowGeometry(); this._desktopName = `@!${this._x},${this._y};BDHF`; this._window.set_title(this._desktopName); this._window.set_default_size(this._windowWidth, this._windowHeight); this._window.set_size_request(this._windowWidth, this._windowHeight); this.scale = this._window.get_scale_factor(); } _updateUnscaledHeightWidthMargins() { this._marginLeftHiddenObject = false; this._marginRightHiddenObject = false; this._marginTopHiddenObject = false; this._marginBottomHiddenObject = false; this._marginTop = this._desktopDescription.marginTop + this.gridPadding; if (this._marginTop > 1000) { this._marginTopHiddenObject = true; this._marginTop -= 1000; } this._marginBottom = this._desktopDescription.marginBottom + this.gridPadding; if (this._marginBottom > 1000) { this._marginBottomHiddenObject = true; this._marginBottom -= 1000; } this._marginLeft = this._desktopDescription.marginLeft + this.gridPadding; if (this._marginLeft > 1000) { this._marginLeftHiddenObject = true; this._marginLeft -= 1000; } this._marginRight = this._desktopDescription.marginRight + this.gridPadding; if (this._marginRight > 1000) { this._marginRightHiddenObject = true; this._marginRight -= 1000; } this._width = this._desktopDescription.width - this._marginLeft - this._marginRight; this._height = this._desktopDescription.height - this._marginTop - this._marginBottom; } _createGrids() { this._width = Math.floor(this._width / this._sizer); this._height = Math.floor(this._height / this._sizer); this._marginTop = Math.floor(this._marginTop / this._sizer); this._marginBottom = Math.floor(this._marginBottom / this._sizer); this._marginLeft = Math.floor(this._marginLeft / this._sizer); this._marginRight = Math.floor(this._marginRight / this._sizer); this._maxColumns = Math.floor( this._width / (this.Prefs.DesiredWidth + 4 * this.elementSpacing) ); this._maxRows = Math.floor( this._height / (this.Prefs.DesiredHeight + 4 * this.elementSpacing) ); this._elementWidth = Math.floor(this._width / this._maxColumns); this._elementHeight = Math.floor(this._height / this._maxRows); } _updateGridRectangle() { this.gridGlobalRectangle.x = this._x + this._marginLeft; this.gridGlobalRectangle.y = this._y + this._marginTop; this.gridGlobalRectangle.width = this._width; this.gridGlobalRectangle.height = this._height; } _sizeContainer(widget) { widget.margin_top = this._marginTop; widget.margin_bottom = this._marginBottom; const leftToRight = widget.get_direction() === Gtk.TextDirection.LTR; if (leftToRight) { widget.margin_start = this._marginLeft; widget.margin_end = this._marginRight; } else { widget.margin_start = this._marginRight; widget.margin_end = this._marginLeft; } } _setGridStatus() { this._fileItems = new Map(); this._gridStatus = new Map(); for (let y = 0; y < this._maxRows; y++) { for (let x = 0; x < this._maxColumns; x++) this._gridStatus.set(y * this._maxColumns + x, new Set()); } } resizeGrid() { this._updateUnscaledHeightWidthMargins(); this._createGrids(); // Ensure event targets cover the full window even when no icons/widgets this._container.set_size_request(this._windowWidth, this._windowHeight); this._rootFixed.set_size_request(this._windowWidth, this._windowHeight); this._sizeContainer(this._container); this._updateGridRectangle(); this._setGridStatus(); } destroy() { this._destroying = true; this._window.destroy(); } recomputeGridPosition(column, row) { if (column > this._maxColumns) return [this._x, this._y]; if (row > this._maxRows) return [this._x, this._y]; const [localX, localY] = this._getLocalCoordinatesForGrid(column, row); const [newGlobalX, newGlobalY] = this.coordinatesLocalToGlobal(localX, localY); return [newGlobalX, newGlobalY]; } // Compute correct position for pop up menus relative to // margins to prevent going under/over margins getIntelligentPosition(gdkRectangle) { if (!this._marginLeftHiddenObject && !this._marginRightHiddenObject && !this._marginTopHiddenObject && !this._marginBottomHiddenObject) return null; var clickLocation = 'center'; if (this._marginLeft > 0 && (gdkRectangle.x < (this._marginLeft * 2)) ) clickLocation = 'left'; if (this._marginRight > 0 && ( gdkRectangle.x + gdkRectangle.width > (this._windowWidth - this._marginRight * 2.5) ) ) clickLocation = 'right'; if (this._marginBottom > 0 && ( gdkRectangle.y + gdkRectangle.height > (this._windowHeight - this._marginBottom * 2) ) ) { switch (clickLocation) { case 'left': clickLocation = 'bottomLeft'; break; case 'right': clickLocation = 'bottomRight'; break; default: clickLocation = 'bottom'; } } if (this._marginTop > 0 && ( gdkRectangle.y < (this._marginTop * 2) ) ) { switch (clickLocation) { case 'left': clickLocation = 'topLeft'; break; case 'right': clickLocation = 'topRight'; break; default: clickLocation = 'top'; } } var returnvalue; switch (clickLocation) { case 'left': if (this._marginLeftHiddenObject) returnvalue = Gtk.PositionType.RIGHT; else returnvalue = null; break; case 'right': if (this._marginRightHiddenObject) returnvalue = Gtk.PositionType.LEFT; else returnvalue = null; break; case 'top': if (this._marginTopHiddenObject) returnvalue = Gtk.PositionType.BOTTOM; else returnvalue = null; break; case 'bottom': if (this._marginBottomHiddenObject) returnvalue = Gtk.PositionType.TOP; else returnvalue = null; break; case 'center': returnvalue = null; break; case 'bottomRight': if (this._marginBottomHiddenObject && this._marginRightHiddenObject) { returnvalue = Gtk.PositionType.LEFT; break; } if (this._marginBottomHiddenObject) { returnvalue = Gtk.PositionType.TOP; break; } if (this._marginRightHiddenObject) { returnvalue = Gtk.PositionType.LEFT; break; } break; case 'bottomLeft': if (this._marginBottomHiddenObject && this._marginLeftHiddenObject) { returnvalue = Gtk.PositionType.RIGHT; break; } if (this._marginBottomHiddenObject) { returnvalue = Gtk.PositionType.TOP; break; } if (this._marginLeftHiddenObject) { returnvalue = Gtk.PositionType.RIGHT; break; } break; case 'topRight': if (this._marginTopHiddenObject && this._marginRightHiddenObject) { returnvalue = Gtk.PositionType.LEFT; break; } if (this._marginTopHiddenObject) { returnvalue = Gtk.PositionType.BOTTOM; break; } if (this._marginRightHiddenObject) { returnvalue = Gtk.PositionType.LEFT; break; } break; case 'topLeft': if (this._marginTopHiddenObject && this._marginLeftHiddenObject) { returnvalue = Gtk.PositionType.RIGHT; break; } if (this._marginTopHiddenObject) { returnvalue = Gtk.PositionType.BOTTOM; break; } if (this._marginLeftHiddenObject) { returnvalue = Gtk.PositionType.RIGHT; break; } break; default: returnvalue = null; } return returnvalue; } // Functions for computing postion/Geometry _getColumnRowFromLocal(x, y) { // Returns the column, row of the grid that holds the local x, y let placeX = Math.floor(x / this._elementWidth); let placeY = Math.floor(y / this._elementHeight); placeX = this.DesktopIconsUtil.clamp(placeX, 0, this._maxColumns - 1); placeY = this.DesktopIconsUtil.clamp(placeY, 0, this._maxRows - 1); return [placeX, placeY]; } _getGridLocalCoordinates(x, y) { // Returns the local grid coordinates of top left rectangle // vertex of the grid that has local x,y const [column, row] = this._getColumnRowFromLocal(x, y); return this._getLocalCoordinatesForGrid(column, row); } _getLocalCoordinatesForGrid(column, row) { const localX = Math.floor(this._width * column / this._maxColumns); const localY = Math.floor(this._height * row / this._maxRows); return [localX, localY]; } getDistance(x) { // Returns the distance to the middle point of this grid from X // return Math.pow(x - (this._x + this._windowWidth * this._zoom / 2), 2) + Math.pow(x - (this._y + this._windowHeight * this._zoom / 2), 2); } _coordinatesGlobalToLocal(X, Y, widget = null) { const [windowX, windowY] = this._coordinatesGlobalToWindow(X, Y); const sourcePoint = new Graphene.Point({x: windowX, y: windowY}); if (!widget) widget = this._container; const [found, targetPoint] = this._window.compute_point(widget, sourcePoint); if (!found) return [0, 0]; return [targetPoint.x, targetPoint.y]; } _coordinatesGlobalToWindow(X, Y) { X -= this._x; Y -= this._y; return [X, Y]; } _coordinatesWidgetToWidget(x, y, widget1, widget2) { const sourcePoint = new Graphene.Point({x, y}); const [found, targetPoint] = widget1.compute_point(widget2, sourcePoint); if (!found) return [0, 0]; return [targetPoint.x, targetPoint.y]; } coordinatesLocalToWindow(x, y, widget = null) { if (!widget) widget = this._container; const sourcePoint = new Graphene.Point({x, y}); const [found, targetPoint] = widget.compute_point(this._window, sourcePoint); if (!found) return [0, 0]; return [targetPoint.x, targetPoint.y]; } coordinatesLocalToGlobal(x, y, widget = null) { const [X, Y] = this.coordinatesLocalToWindow(x, y, widget); return [X + this._x, Y + this._y]; } coordinatesBelongToThisGrid(X, Y) { const checkRectangle = new Gdk.Rectangle( { x: X, y: Y, width: 1, height: 1, } ); return this.gridGlobalRectangle.intersect(checkRectangle)[0]; } coordinatesBelongToThisGridWindow(X, Y) { const checkRectangle = new Gdk.Rectangle( { x: X, y: Y, width: 1, height: 1, } ); return this.windowGlobalRectangle.intersect(checkRectangle)[0]; } getGlobaltoLocalRectangle(gdkRectangle) { const [X, Y] = this._coordinatesGlobalToLocal(gdkRectangle.x, gdkRectangle.y); return new Gdk.Rectangle( { x: X, y: Y, width: gdkRectangle.width, height: gdkRectangle.height, } ); } getCoordinatesOfGridContaining(X, Y, globalCoordinates = false) { // returns the local or global coordinates if requested, // of the local grid rectangle top left vertex that contains x, y if (this.coordinatesBelongToThisGrid(X, Y)) { const [x, y] = this._coordinatesGlobalToLocal(X, Y); if (globalCoordinates) { const a = this._elementWidth * Math.floor((x / this._elementWidth) + 0.5); const b = this._elementHeight * Math.floor((y / this._elementHeight) + 0.5); return this.coordinatesLocalToGlobal(a, b); } else { return this._getGridLocalCoordinates(x, y); } } else { return null; } } // Functions to query and set grid use by Icons and files _fileAtColumnRow(column, row) { // only works for grid placement of icons, // with free placements there maybe multiple fileItems per grid const setOfFileItemsOnGridNumber = this._gridStatus.get(row * this._maxColumns + column); if (!this.Prefs.freePositionIcons && setOfFileItemsOnGridNumber.size) { for (const fileItem of setOfFileItemsOnGridNumber.keys()) return fileItem; } return null; } _fileAt(x, y) { if (!this.Prefs.freePositionIcons) { const [column, row] = this._getColumnRowFromLocal(x, y); return this._fileAtColumnRow(column, row); } const widgetAtPointer = this._container.pick(x, y, Gtk.PickFlags.GTK_PICK_DEFAULT); if (widgetAtPointer === this._container) return null; let fileItemFound = null; for (const fileItem of this._fileItems.keys()) { const [widgetX, widgetY] = this._coordinatesWidgetToWidget( x, y, this._container, fileItem.container ); if (widgetX === 0 && widgetY === 0) continue; const localWidget = fileItem.container.pick( widgetX, widgetY, Gtk.PickFlags.GTK_PICK_DEFAULT ); if (localWidget === widgetAtPointer) { fileItemFound = fileItem; break; } } return fileItemFound; } isAvailable() { // Returns true if there is an available slot in the grid let isFree = false; for (const [, setOfFileItemsOnGridNumber] of this._gridStatus.entries() ) { if (!setOfFileItemsOnGridNumber.size) { isFree = true; break; } } return isFree; } _setUseColumnRowOverlappingThis(fileItem, column, row, X, Y) { this._setGridUse(column, row, fileItem); const Xr = X + this._elementWidth - 2; const Yr = Y + this._elementHeight - 2; const [xr, yr] = this._coordinatesGlobalToLocal(Xr, Yr); const [bottomRightColumn, bottomRightRow] = this._getColumnRowFromLocal(xr, yr); if (bottomRightColumn !== column && bottomRightRow !== row) { this._setGridUse(bottomRightColumn, bottomRightRow, fileItem); this._setGridUse(column, bottomRightRow, fileItem); this._setGridUse(bottomRightColumn, row, fileItem); return; } if (bottomRightColumn === column && bottomRightRow !== row) { this._setGridUse(column, bottomRightRow, fileItem); return; } if (bottomRightColumn !== column && bottomRightRow === row) this._setGridUse(bottomRightColumn, row, fileItem); } _isEmptyAt(column, row) { // returns if grid at column row has a file or not const setOfFileItemsOnGridNumber = this._gridStatus.get(row * this._maxColumns + column); return setOfFileItemsOnGridNumber.size === 0; } _gridInUse(x, y) { // returns if the local grid containing local coordinates // x, y has a file assigned. const [placeX, placeY] = this._getColumnRowFromLocal(x, y); return !this._isEmptyAt(placeX, placeY); } _setGridUse(column, row, fileItem) { const setOfFileItemsOnGridNumber = this._gridStatus.get(row * this._maxColumns + column); setOfFileItemsOnGridNumber.add(fileItem); } _getEmptyPlaceClosestTo(x, y, coordinatesAction, reverseHorizontal) { // returns the column row of empty grid available at global X, Y let cornerInversion = this.Prefs.StartCorner; if (reverseHorizontal) cornerInversion[0] = !cornerInversion[0]; const [placeX, placeY] = this._getColumnRowFromLocal(x, y); if (this._isEmptyAt(placeX, placeY) && coordinatesAction !== this.Enums.StoredCoordinates.ASSIGN) return [placeX, placeY]; let found = false; let resColumn = null; let resRow = null; let minDistance = Infinity; let column, row; for (let tmpColumn = 0; tmpColumn < this._maxColumns; tmpColumn++) { if (cornerInversion[0]) column = this._maxColumns - tmpColumn - 1; else column = tmpColumn; for (let tmpRow = 0; tmpRow < this._maxRows; tmpRow++) { if (cornerInversion[1]) row = this._maxRows - tmpRow - 1; else row = tmpRow; if (!this._isEmptyAt(column, row)) continue; let proposedX = column * this._elementWidth; let proposedY = row * this._elementHeight; if (coordinatesAction === this.Enums.StoredCoordinates.ASSIGN) return [column, row]; let distance = this.DesktopIconsUtil.distanceBetweenPoints( proposedX, proposedY, x, y ); if (distance < minDistance) { found = true; minDistance = distance; resColumn = column; resRow = row; } } } if (!found) throw new Error('No available space on the monitor'); return [resColumn, resRow]; } // Finally the actual code that places and removes icons on the desktop _addFileItemToGrid(fileItem, column, row, coordinatesAction) { if (this._destroying) return; let [localX, localY] = this._getLocalCoordinatesForGrid(column, row); localX += this.elementSpacing; localY += this.elementSpacing; this._container.put(fileItem.container, localX, localY); this._setGridUse(column, row, fileItem); fileItem.column = column; fileItem.row = row; this._fileItems.set(fileItem, [localX, localY]); const [X, Y] = this.coordinatesLocalToGlobal(localX, localY); fileItem.setCoordinates( X, Y, this._elementWidth - 2 * this.elementSpacing, this._elementHeight - 2 * this.elementSpacing, this.elementSpacing, this ); /* If this file is new in the Desktop and hasn't yet * fixed coordinates, store the new position to ensure * that the next time it will be shown in the same position. * Also store the new position if it has been moved by the user, * and not triggered by a screen change. */ if ((fileItem.savedCoordinates === null) || (coordinatesAction === this.Enums.StoredCoordinates.OVERWRITE)) { const [normalizedX, normalizedY] = this.getNormalizedCoordinates(localX, localY); const array = [X, Y, normalizedX, normalizedY, this._monitor]; fileItem.writeSavedCoordinates(array); } } removeItem(fileItem) { if (this._fileItems.has(fileItem)) this._fileItems.delete(fileItem); this._gridStatus.forEach( setOfFileItemsOnGridNumber => setOfFileItemsOnGridNumber.delete(fileItem) ); this._container.remove(fileItem.container); } _placeIntoPosition( fileItem, X, Y, x, y, emptycolumn, emptyrow, coordinatesAction ) { // For sanpping to grid if (fileItem.savedCoordinates == null || (fileItem.savedCoordinates[0] === 0 && fileItem.savedCoordinates[1] === 0) || !this.Prefs.freePositionIcons || this.Prefs.keepArranged || this.Prefs.keepStacked ) { this._addFileItemToGrid( fileItem, emptycolumn, emptyrow, coordinatesAction ); return; } if (this._destroying) return; // For free placement // Make sure the icon lands inside the grid and does not protrude out const [currentColumn, currentRow] = this._getColumnRowFromLocal(x, y); let translocated = false; if (currentColumn === this._maxColumns - 1 && x + this._elementWidth > this._width ) { x = this._width - this._elementWidth; translocated = true; } if (currentRow === this._maxRows - 1 && y + this._elementHeight > this._height ) { y = this._height - this._elementHeight; translocated = true; } if (x < 0) { x = 0; translocated = true; } if (y < 0) { y = 0; translocated = true; } // recompute global coordinates from the translocatedd local coordinates if (translocated) [X, Y] = this.coordinatesLocalToGlobal(x, y); this._container.put(fileItem.container, x, y); this._fileItems.set(fileItem, [x, y]); fileItem.setCoordinates(X, Y, this._elementWidth - 2 * this.elementSpacing, this._elementHeight - 2 * this.elementSpacing, this.elementSpacing, this); // set column row being used for all four vertices this._setUseColumnRowOverlappingThis( fileItem, currentColumn, currentRow, X, Y ); /* If this file is new in the Desktop and hasn't yet * fixed coordinates, store the new position to ensure * that the next time it will be shown in the same position. * Also store the new position if it has been moved by the user, * and not triggered by a screen change. */ if ((fileItem.savedCoordinates === null) || (coordinatesAction === this.Enums.StoredCoordinates.OVERWRITE) ) { const [normalizedX, normalizedY] = this.getNormalizedCoordinates(x, y); const array = [X, Y, normalizedX, normalizedY, this._monitor]; fileItem.writeSavedCoordinates(array); fileItem.column = null; fileItem.row = null; } } addFileItemCloseTo(fileItem, X, Y, coordinatesAction) { const addVolumesOpposite = this.Prefs.AddVolumesOpposite; const [x, y] = this._coordinatesGlobalToLocal(X, Y); const [column, row] = this._getEmptyPlaceClosestTo( x, y, coordinatesAction, fileItem.isDrive && addVolumesOpposite ); this._placeIntoPosition( fileItem, X, Y, x, y, column, row, coordinatesAction ); } makeTopLayerOnGrid(fileItem) { if (!this.Prefs.freePositionIcons) return; const [x, y] = this._fileItems.get(fileItem); this._container.remove(fileItem.container); this._container.put(fileItem.container, x, y); } getNormalizedCoordinates(x, y) { return [x / this.normalizedWidth, y / this.normalizedHeight]; } setNormalizedCoordinates(x, y) { const newGlobalX = x * this.normalizedWidth; const newGlobalY = y * this.normalizedHeight; return [newGlobalX, newGlobalY]; } get normalizedWidth() { return this._width; } get normalizedHeight() { return this._height; } get monitorIndex() { return this._monitor; } get index() { return this._desktopIndex; } get name() { return this._desktopName; } }; const GridOverlay = GObject.registerClass( class GridOverlay extends Gtk.Widget { constructor(grid) { super({can_target: false}); this._grid = grid; } vfunc_snapshot(snapshot) { const w = this.get_allocated_width(); const h = this.get_allocated_height(); if (w <= 0 || h <= 0) return; this._grid._doDrawOnGrid(snapshot); // Ensure this widget contributes a node so the frame overwrites stale content. const rect = new Graphene.Rect(); rect.init(0, 0, 1, 1); snapshot.append_color( new Gdk.RGBA({red: 0, green: 0, blue: 0, alpha: 0.001}), rect ); } }); const DrawGrid = class extends DisplayGrid { constructor(params) { super(params); this._drawArea = new GridOverlay(this); this._drawArea.set_size_request(this._windowWidth, this._windowHeight); this._sizeContainer(this._drawArea); this._overlay.add_overlay(this._drawArea); this._drawArea.set_can_target(false); this._drawArea.set_visible(true); } resizeWindow() { super.resizeWindow(); this._drawArea.set_size_request(this._windowWidth, this._windowHeight); } resizeGrid() { super.resizeGrid(); this._drawArea.set_size_request(this._windowWidth, this._windowHeight); this._sizeContainer(this._drawArea); } // Functions for drawing on the grid highLightGridAt(x, y) { const globalCoordinates = false; const selected = this.getCoordinatesOfGridContaining(x, y, globalCoordinates); this._selectedList = [selected]; this.updateOverlay(); } unHighLightGrids() { this._selectedList = null; this.updateOverlay(); } updateOverlay() { this._drawArea.queue_draw(); } _overlayHasContent() { const hasRubberBand = this._dragManager.rubberBand && this._dragManager.selectionRectangle; const hasDropRects = (this._selectedList?.length ?? 0) > 0; return hasRubberBand || hasDropRects; } _doDrawOnGrid(snapshot) { this._doDrawRubberBand(snapshot); this._doDrawDropRectangles(snapshot); } _doDrawRubberBand(snapshot) { if (!this._dragManager.rubberBand || !this._dragManager.selectionRectangle || !this.gridGlobalRectangle .intersect(this._dragManager.selectionRectangle)[0] ) return; const [xInit, yInit] = this._coordinatesGlobalToLocal( this._dragManager.x1, this._dragManager.y1 ); const [xFin, yFin] = this._coordinatesGlobalToLocal( this._dragManager.x2, this._dragManager.y2 ); const width = xFin - xInit; const height = yFin - yInit; const fillColor = new Gdk.RGBA({ red: this.Prefs.selectColor.red, green: this.Prefs.selectColor.green, blue: this.Prefs.selectColor.blue, alpha: 0.15, }); const outlineColor = new Gdk.RGBA({ red: this.Prefs.selectColor.red, green: this.Prefs.selectColor.green, blue: this.Prefs.selectColor.blue, alpha: 1.0, }); this._roundedRectangleDraw( xInit, yInit, width, height, snapshot, fillColor, outlineColor ); } _doDrawDropRectangles(snapshot) { if (!this.Prefs.showDropPlace || this._selectedList === null) return; const fillColor = new Gdk.RGBA({ red: 1.0 - this.Prefs.selectColor.red, green: 1.0 - this.Prefs.selectColor.green, blue: 1.0 - this.Prefs.selectColor.blue, alpha: 0.4, }); const outlineColor = new Gdk.RGBA({ red: 1.0 - this.Prefs.selectColor.red, green: 1.0 - this.Prefs.selectColor.green, blue: 1.0 - this.Prefs.selectColor.blue, alpha: 1.0, }); for (const [x, y] of this._selectedList) { this._rectangleDraw( x, y, this._elementWidth, this._elementHeight, snapshot, fillColor, outlineColor ); } } _rectangleDraw(x, y, width, height, snapshot, fillColor, outlineColor) { const rect = new Graphene.Rect(); rect.init(x + 0.5, y + 0.5, width, height); snapshot.append_color(fillColor, rect); const rr = new Gsk.RoundedRect(); const zero = new Graphene.Size(); zero.init(0, 0); rr.init(rect, zero, zero, zero, zero); snapshot.append_border( rr, [0.5, 0.5, 0.5, 0.5], [outlineColor, outlineColor, outlineColor, outlineColor] ); } _roundedRectangleDraw(x, y, width, height, snapshot, fillColor, outlineColor) { const cornerRadius = 5; const isSquare = width === height; const tooLarge = cornerRadius * 2 > Math.min(width, height); const useSquareCorners = cornerRadius <= 0 || isSquare || tooLarge; const radius = useSquareCorners ? 0 : Math.min(cornerRadius, width / 2, height / 2); const rect = new Graphene.Rect(); rect.init(x, y, width, height); const size = new Graphene.Size(); size.init(radius, radius); const rr = new Gsk.RoundedRect(); rr.init(rect, size, size, size, size); if (radius > 0) { snapshot.push_rounded_clip(rr); snapshot.append_color(fillColor, rect); snapshot.pop(); } else { snapshot.append_color(fillColor, rect); } snapshot.append_border( rr, [1.0, 1.0, 1.0, 1.0], [outlineColor, outlineColor, outlineColor, outlineColor] ); } }; const ControlGrid = class extends DrawGrid { constructor(params) { super(params); this._addDragControllers(); } _addDragControllers() { // Bubble-phase controller: delivers key events to DesktopManager for actions this._eventKey = Gtk.EventControllerKey.new(); this._eventKey.set_propagation_phase(Gtk.PropagationPhase.BUBBLE); this._window.add_controller(this._eventKey); // Capture-phase controller: only caches modifier state, does not invoke actions this._eventKeyState = Gtk.EventControllerKey.new(); this._eventKeyState.set_propagation_phase(Gtk.PropagationPhase.CAPTURE); this._window.add_controller(this._eventKeyState); this._eventKey.connect( 'key-pressed', this._onKeyPress.bind(this) ); this._eventKeyState.connect( 'key-pressed', this._onModifierUpdate.bind(this) ); this._eventKeyState.connect( 'key-released', this._onModifierClear.bind(this) ); this._eventMotion = Gtk.EventControllerMotion.new(); this._eventMotion.set_propagation_phase(Gtk.PropagationPhase.BUBBLE); this._container.add_controller(this._eventMotion); this._eventMotion.connect( 'motion', (actor, x, y) => { if (!this._dragManager.rubberBand) return false; const [X, Y] = this.coordinatesLocalToGlobal(x, y); this._dragManager.onMotion(X, Y); return false; } ); this._buttonClick = new Gtk.GestureClick({button: 0}); this._buttonClick.set_propagation_phase(Gtk.PropagationPhase.BUBBLE); this._container.add_controller(this._buttonClick); this._buttonLongClick = new Gtk.GestureLongPress({button: 0}); this._buttonLongClick.set_propagation_phase(Gtk.PropagationPhase.BUBBLE); this._container.add_controller(this._buttonLongClick); this._buttonClick.set_exclusive(true); this._buttonLongClick.set_exclusive(true); this._buttonClick.group(this._buttonLongClick); this._longHandled = false; this._buttonLongClick.connect('pressed', (actor, x, y) => { this._longHandled = true; this._doGestureLongPress(actor, x, y); }); this._buttonLongClick.connect('cancelled', _actor => { this._longHandled = false; }); this._buttonClick.connect('pressed', (actor, nPress, x, y) => { this._doGesturePress(actor, nPress, x, y); }); this._buttonClick.connect('released', (actor, nPress, x, y) => { if (this._longHandled) this._longHandled = false; this._doGestureRelease(actor, nPress, x, y, this); }); this._setDropDestination(this._container); this._setDragSource(this._container); } _onKeyPress(actor, keyval, keycode, state) { this._desktopManager.onKeyPress( keyval, keycode, state, this ); } _onModifierUpdate(_actor, _keyval, _keycode, state) { this._desktopManager.updateModifierState(state); } _onModifierClear() { this._desktopManager.clearModifierState(); } _doGesturePress(actor, nPress, x, y) { if (this._desktopManager.closePopUps()) return; const button = actor.get_current_button(); const state = this._buttonClick.get_current_event_state(); const isCtrl = (state & Gdk.ModifierType.CONTROL_MASK) !== 0; const isShift = (state & Gdk.ModifierType.SHIFT_MASK) !== 0; const [X, Y] = this.coordinatesLocalToGlobal(x, y); const clickItem = this._fileAt(x, y); if (clickItem && this._clickItemClickable(clickItem, X, Y)) { clickItem ._onPressButton(actor, nPress, X, Y, x, y, isShift, isCtrl); return; } this._desktopManager .onPressButton(X, Y, x, y, button, isShift, isCtrl, this); } async _doGestureRelease(actor, nPress, x, y, grid) { const button = actor.get_current_button(); const state = this._buttonClick.get_current_event_state(); const isCtrl = (state & Gdk.ModifierType.CONTROL_MASK) !== 0; const isShift = (state & Gdk.ModifierType.SHIFT_MASK) !== 0; const [X, Y] = this.coordinatesLocalToGlobal(x, y); const clickItem = this._fileAt(x, y); const clickItemClickable = this._clickItemClickable(clickItem, X, Y); if (clickItemClickable && !this._dragManager.rubberBand) { clickItem._onReleaseButton( actor, nPress, X, Y, x, y, isShift, isCtrl); return; } this._dragManager.onReleaseButton(this); await this._desktopManager .onReleaseButton(X, Y, x, y, button, isShift, isCtrl, grid) .catch(logError); } _doGestureLongPress(actor, x, y) { const button = actor.get_current_button(); const state = this._buttonClick.get_current_event_state(); const isCtrl = (state & Gdk.ModifierType.CONTROL_MASK) !== 0; const isShift = (state & Gdk.ModifierType.SHIFT_MASK) !== 0; const [X, Y] = this.coordinatesLocalToGlobal(x, y); const clickItem = this._fileAt(x, y); const clickItemClickable = this._clickItemClickable(clickItem, X, Y); if (clickItemClickable) { clickItem ._onLongPressButton(actor, X, Y, x, y, isShift, isCtrl); return; } this._desktopManager .onLongPressButton(X, Y, x, y, button, isShift, isCtrl, this); } _clickItemClickable(clickedItem, X, Y) { if (!clickedItem) return false; const clickRectangle = new Gdk.Rectangle({x: X, y: Y, width: 1, height: 1}); return clickRectangle.intersect(clickedItem.iconRectangle)[0] || clickRectangle.intersect(clickedItem.labelRectangle)[0]; } _setDropDestination(widget) { this.gridDropController = new Gtk.DropTargetAsync(); this.gridDropController.set_actions( Gdk.DragAction.MOVE | Gdk.DragAction.COPY | Gdk.DragAction.ASK ); const desktopAcceptFormats = Gdk.ContentFormats.new(this.Enums.DndTargetInfo.MIME_TYPES); const fileItemAcceptFormats = Gdk.ContentFormats.new([ this.Enums.DndTargetInfo.GNOME_ICON_LIST, this.Enums.DndTargetInfo.URI_LIST, ]); const desktopMoveIconsFormat = Gdk.ContentFormats.new([this.Enums.DndTargetInfo.DING_ICON_LIST]); const textDropFormat = Gdk.ContentFormats.new([this.Enums.DndTargetInfo.TEXT_PLAIN]); const oldNautilusDropFormat = Gdk.ContentFormats.new([this.Enums.DndTargetInfo.GNOME_ICON_LIST]); this.gridDropController.set_formats(desktopAcceptFormats); let acceptFormat = null; let dropData = null; this.gridDropController.connect( 'accept', (actor, drop) => { if (drop.get_formats().match(desktopAcceptFormats)) return true; else return false; } ); this.gridDropController.connect( 'drag-enter', (actor, drop) => { this.localDrag = true; drop.status( Gdk.DragAction.COPY | Gdk.DragAction.MOVE | Gdk.DragAction.LINK, Gdk.DragAction.MOVE ); return Gdk.DragAction.MOVE; } ); this.gridDropController.connect( 'drag-motion', (actor, drop, x, y) => { let desktopDropZone = false; let fileItemDropZone = false; const fileItem = this._fileAt(x, y); const [X, Y] = this.coordinatesLocalToGlobal(x, y); const dropRectangle = new Gdk.Rectangle({x: X, y: Y, width: 1, height: 1}); const desktopMove = drop.get_formats().match(desktopMoveIconsFormat); const filesMove = drop.get_formats().match(fileItemAcceptFormats); if (fileItem) { if (!this.Prefs.freePositionIcons) fileItemDropZone = true; else if (dropRectangle .intersect(fileItem.iconRectangle)[0] || dropRectangle .intersect(fileItem.labelRectangle)[0]) fileItemDropZone = true; if (desktopMove && fileItem._hasToRouteDragToGrid()) fileItemDropZone = false; } desktopDropZone = !fileItemDropZone; this.receiveMotion(x, y, false); if (fileItemDropZone && !fileItem.dropCapable) return false; if (fileItemDropZone && fileItem.dropCapable) { if (!filesMove) return false; if (fileItem._fileExtra !== this.Enums.FileType.EXTERNAL_DRIVE) return Gdk.DragAction.MOVE; if (fileItem._fileExtra === this.Enums.FileType.EXTERNAL_DRIVE) return Gdk.DragAction.COPY; } if (desktopDropZone) { if (desktopMove) { if (this.Prefs.keepArranged || this.Prefs.keepStacked) { if (this.Prefs.sortSpecialFolders) return false; else if (this._desktopManager .getCurrentSelection() ?.filter(f => !f.isSpecial).length >= 1) return false; } } return Gdk.DragAction.MOVE; } return false; }); this.gridDropController.connect('drag-leave', () => { this.localDrag = false; this._receiveLeave(); }); this.gridDropController.connect('drop', (actor, drop, x, y) => { const event = { 'parentWindow': this._window, 'timestamp': Gdk.CURRENT_TIME, }; let desktopDropZone = false; let fileItemDropZone = false; const fileItem = this._fileAt(x, y); const [X, Y] = this.coordinatesLocalToGlobal(x, y); const dropRectangle = new Gdk.Rectangle({x: X, y: Y, width: 1, height: 1}); const desktopMove = drop.get_formats().match(desktopMoveIconsFormat); const filesMove = drop.get_formats().match(fileItemAcceptFormats); const oldNautilusMove = drop.get_formats().match(oldNautilusDropFormat); let readFormat = Gdk.FileList.$gtype; if (fileItem) { if (!this.Prefs.freePositionIcons) fileItemDropZone = true; else if (dropRectangle.intersect(fileItem.iconRectangle)[0] || dropRectangle.intersect(fileItem.labelRectangle)[0]) fileItemDropZone = true; if (desktopMove && fileItem._hasToRouteDragToGrid()) fileItemDropZone = false; } desktopDropZone = !fileItemDropZone; const textDrop = drop.get_formats().match(textDropFormat) && !desktopMove && !filesMove; if (textDrop) { acceptFormat = this.Enums.DndTargetInfo.TEXT_PLAIN; readFormat = String.$gtype; } if (desktopMove) acceptFormat = this.Enums.DndTargetInfo.DING_ICON_LIST; if (filesMove && !desktopMove) { if (oldNautilusMove) { acceptFormat = this.Enums.DndTargetInfo.GNOME_ICON_LIST; readFormat = String.$gtype; } else { acceptFormat = this.Enums.DndTargetInfo.URI_LIST; readFormat = String.$gtype; } } let gdkDropAction = drop.get_actions(); if (!Gdk.DragAction.is_unique(gdkDropAction)) { if (this._using_X11 && (gdkDropAction >= (Gdk.DragAction.COPY | Gdk.DragAction.MOVE))) gdkDropAction = Gdk.DragAction.MOVE; else if (gdkDropAction > (Gdk.DragAction.COPY | Gdk.DragAction.MOVE)) gdkDropAction = Gdk.DragAction.ASK; } let gdkReturnAction = Gdk.DragAction.COPY; if (desktopMove && desktopDropZone && (gdkDropAction === Gdk.DragAction.MOVE) ) { let [xOrigin, yOrigin] = this._dragManager.dragItem.getCoordinates() .slice(0, 3); this._dragManager.doMoveWithDragAndDrop(xOrigin, yOrigin, X, Y); this._receiveLeave(); drop.finish(gdkDropAction); return true; } try { drop.read_value_async( readFormat, GLib.PRIORITY_DEFAULT, null, async (dropactor, result) => { dropData = dropactor.read_value_finish(result); if (!dropData || !acceptFormat) { drop.finish(0); this._receiveLeave(); return false; } if (dropData && textDrop) { gdkReturnAction = Gdk.DragAction.COPY; this._dragManager.onTextDrop(dropData, [X, Y]); drop.finish(gdkReturnAction); this._receiveLeave(); return true; } gdkReturnAction = await this._completeDrop( X, Y, x, y, drop, dropData, gdkDropAction, fileItem, acceptFormat, fileItemDropZone, desktopDropZone, desktopMove, filesMove, textDrop, event ).catch(e => console.error(e)); if (gdkReturnAction) { drop.finish(gdkReturnAction); this._receiveLeave(); return true; } else { drop.finish(0); this._receiveLeave(); return false; } } ); } catch (e) { console.error(e); drop.finish(0); this._receiveLeave(); } return false; }); widget.add_controller(this.gridDropController); this.gridDropControllerMotion = new Gtk.DropControllerMotion(); this.gridDropControllerMotion.connect( 'motion', (actor, x, y) => { if (!this.gridDropControllerMotion.is_pointer) { const fileItem = this._fileAt(x, y); const [X, Y] = this.coordinatesLocalToGlobal(x, y); const pointerRectangle = new Gdk.Rectangle({x: X, y: Y, width: 1, height: 1}); if (fileItem && fileItem.dropCapable) { this._dragManager.unHighLightDropTarget(); if (!this.Prefs.freePositionIcons) fileItem.highLightDropTarget(); else if ( pointerRectangle .intersect(fileItem.iconRectangle)[0] || pointerRectangle .intersect(fileItem.labelRectangle)[0]) fileItem.highLightDropTarget(); } if (fileItem && (fileItem.isDirectory || fileItem.isDrive)) this._startSpringLoadedTimer(fileItem); } else { this._dragManager.unHighLightDropTarget(); this._stopSpringLoadedTimer(); } }); widget.add_controller(this.gridDropControllerMotion); } async _completeDrop( X, Y, x, y, drop, dropData, gdkDropAction, fileItem, acceptFormat, fileItemDropZone, desktopDropZone, desktopMove, filesMove, textDrop, event ) { let returnAction = Gdk.DragAction.COPY; const localDrop = !!drop.get_drag(); if (fileItemDropZone && (desktopMove || filesMove)) { returnAction = await fileItem.receiveDrop( X, Y, x, y, dropData, acceptFormat, gdkDropAction, localDrop, event, this._dragManager.dragItem ).catch(e => console.error(e)); return returnAction; } if (desktopDropZone && (desktopMove || filesMove)) { returnAction = await this._receiveDrop( x, y, dropData, acceptFormat, gdkDropAction, localDrop, event, this._dragManager.dragItem ).catch(e => console.error(e)); return returnAction; } // Finally if all above does not work, catchall- return false; } _setDragSource(widget) { const widgetDragController = Gtk.DragSource.new(); let clickItem; widgetDragController.set_actions( Gdk.DragAction.MOVE | Gdk.DragAction.COPY | Gdk.DragAction.ASK); widgetDragController.connect( 'prepare', // eslint-disable-next-line consistent-return (actor, x, y) => { const draggedItem = this._fileAt(x, y); if (draggedItem && !this._dragManager.rubberBand) { clickItem = draggedItem; const [a, b] = this._coordinatesWidgetToWidget( x, y, this._container, clickItem._icon ) .map(f => Math.floor(Math.max(f))); this._dragManager.localDragOffset = [a, b]; const dragIcon = this._createStackedDragIcon(clickItem); widgetDragController.set_icon(dragIcon, a, b); clickItem.dragSourceOffset = [a, b]; this._loadDragData(); if (this.contentProvider) return this.contentProvider; } } ); widgetDragController.connect('drag-begin', () => { this._dragManager.onReleaseButton(this); this._dragManager.onDragBegin(clickItem); }); widgetDragController.connect( 'drag-cancel', async (actor, drag, reason) => { if (reason === Gdk.DragCancelReason.NO_TARGET || reason === Gdk.DragCancelReason.ERROR) { const gnomedropDetected = await this._dragManager.gnomeShellDrag ?.completeGnomeShellDrop() .catch(e => console.error(e)); if (gnomedropDetected) return true; else return false; } else { return false; } } ); widgetDragController.connect('drag-end', () => { this._dragManager.onDragEnd(); this._dragManager.selected(clickItem, this.Enums.Selection.RELEASE); }); widget.add_controller(widgetDragController); } _loadDragData() { this.contentProvider = null; const textCoder = new TextEncoder(); const uriList = this._dragManager.fillDragDataGet( this.Enums.DndTargetInfo.DING_ICON_LIST); if (!uriList) return; const encodedUriList = textCoder.encode(uriList); const dingContentProvider = Gdk.ContentProvider.new_for_bytes( this.Enums.DndTargetInfo.DING_ICON_LIST, encodedUriList ); if (this._desktopManager.checkIfSpecialFilesAreSelected()) { this.contentProvider = dingContentProvider; return; } const gnomeUriList = this._dragManager.fillDragDataGet( this.Enums.DndTargetInfo.GNOME_ICON_LIST); if (!gnomeUriList) return; const gnomeContentProvider = Gdk.ContentProvider.new_for_bytes( this.Enums.DndTargetInfo.GNOME_ICON_LIST, textCoder.encode(gnomeUriList) ); const textPathList = this._dragManager.fillDragDataGet( this.Enums.DndTargetInfo.TEXT_PLAIN ); if (!textPathList) return; const encodedPathList = textCoder.encode(textPathList); const textUriListContentProvider = Gdk.ContentProvider.new_for_bytes( this.Enums.DndTargetInfo.URI_LIST, encodedUriList ); const textListContentProvider = Gdk.ContentProvider.new_for_bytes( this.Enums.DndTargetInfo.TEXT_PLAIN, encodedPathList ); const textUtf8ListContentProvider = Gdk.ContentProvider.new_for_bytes( this.Enums.DndTargetInfo.TEXT_PLAIN_UTF8, encodedPathList ); this.contentProvider = Gdk.ContentProvider.new_union([ dingContentProvider, gnomeContentProvider, textUriListContentProvider, textListContentProvider, textUtf8ListContentProvider, ]); } // The following code is translated from Nautilus C to Javascript // to form the similar stack of items _createStackedDragIcon(draggedItem) { const selectionArray = this._desktopManager.getCurrentSelection(); selectionArray.sort( // eslint-disable-next-line no-nested-ternary (a, b) => a.uri === draggedItem.uri ? -1 : b.uri === draggedItem.uri ? 1 : 0 ); const dragIconArray = selectionArray.map(f => f._icon.get_paintable()); const numberOfIcons = dragIconArray.length; const dragIcon = Gtk.Snapshot.new(); /* A wide shadow for the pile of icons gives a sense of floating. */ const stackShadow = { color: {red: 0, green: 0, blue: 0, alpha: 0.15}, dx: 0, dy: 2, radius: 10, }; /* A slight shadow swhich makes each icon in the stack look separate. */ const iconShadow = { color: {red: 0, green: 0, blue: 0, alpha: 0.30}, dx: 0, dy: 1, radius: 1, }; let xOffset = numberOfIcons % 2 === 1 ? 6 : -6; let yOffset; switch (numberOfIcons) { case 1: yOffset = 0; break; case 2: yOffset = 10; break; case 3: yOffset = 6; break; default: yOffset = 4; } dragIcon.translate( new Graphene.Point( { x: 10 + (xOffset / 2), y: yOffset * numberOfIcons, } ) ); const shadow = new Gsk.Shadow(stackShadow); dragIcon.push_shadow([shadow]); dragIconArray.reverse().forEach( paintableWidget => { const w = paintableWidget.get_intrinsic_width(); const h = paintableWidget.get_intrinsic_height(); const X = Math.floor((this.Prefs.IconSize - w) / 2); const Y = Math.floor((this.Prefs.IconSize - h) / 2); dragIcon.translate( new Graphene.Point( { x: -xOffset, y: -yOffset, } ) ); xOffset = -xOffset; dragIcon.translate(new Graphene.Point({x: X, y: Y})); dragIcon.push_shadow([new Gsk.Shadow(iconShadow)]); paintableWidget.snapshot(dragIcon, w, h); dragIcon.pop(); dragIcon.translate(new Graphene.Point({x: -X, y: -Y})); } ); dragIcon.pop(); return dragIcon.to_paintable(null); } _receiveLeave() { this._stopSpringLoadedTimer(); this._window.queue_draw(); this._dragManager.onDragLeave(); } receiveLeave() { this._receiveLeave(); } receiveMotion(x, y, global) { let X; let Y; if (!global) { x = this._elementWidth * Math.floor(x / this._elementWidth); y = this._elementHeight * Math.floor(y / this._elementHeight); [X, Y] = this.coordinatesLocalToGlobal(x, y); } this._dragManager.onDragMotion(X, Y); } async _receiveDrop( x, y, selection, info, gdkDropAction, localDrop, event, dragItem ) { x = this._elementWidth * Math.floor(x / this._elementWidth); y = this._elementHeight * Math.floor(y / this._elementHeight); const [X, Y] = this.coordinatesLocalToGlobal(x, y); const returnAction = await this._dragManager .onDragDataReceived( X, Y, x, y, selection, info, gdkDropAction, localDrop, event, dragItem ) .catch(e => console.error(e)); return returnAction; } refreshDrag(selectedList, ox, oy) { if (!this.Prefs.showDropPlace) return; if (selectedList === null) { this._selectedList = null; this.updateOverlay(); return; } let newSelectedList = []; for (let [x, y] of selectedList) { x += this._elementWidth / 2; y += this._elementHeight / 2; x += ox; y += oy; const r = this.getCoordinatesOfGridContaining(x, y); if (r && !isNaN(r[0]) && !isNaN(r[1]) && (!this._gridInUse(r[0], r[1]) || this._fileAt(r[0], r[1])?.isSelected) ) newSelectedList.push(r); } if (newSelectedList.length === 0) { if (this._selectedList !== null) { this._selectedList = null; this.updateOverlay(); } return; } if (this._selectedList !== null) { if ((newSelectedList[0][0] === this._selectedList[0][0]) && (newSelectedList[0][1] === this._selectedList[0][1]) ) return; } this._selectedList = newSelectedList; this.updateOverlay(); } _startSpringLoadedTimer(fileItem) { if (!this.Prefs.openFolderOnDndHover || this.directoryOpenTimer) return; if (this._dragManager.dragItem?.uri === fileItem.uri) return; this.directoryOpenTimer = GLib.timeout_add( GLib.PRIORITY_DEFAULT, this.Enums.DND_HOVER_TIMEOUT, () => { const context = Gdk.Display.get_default() .get_app_launch_context(); context.set_timestamp(Gdk.CURRENT_TIME); try { Gio.AppInfo.launch_default_for_uri( fileItem.uri, context ); } catch (e) { console.error(e, `Error opening ${fileItem.uri}` + ` in GNOME Files: ${e.message}`); } this.directoryOpenTimer = 0; return GLib.SOURCE_REMOVE; } ); } _stopSpringLoadedTimer() { if (this.directoryOpenTimer) GLib.Source.remove(this.directoryOpenTimer); this.directoryOpenTimer = 0; } }; /* A Picture that can translate itself at paint time (render-only) */ const OffsetPicture = GObject.registerClass({ Properties: { 'tx': GObject.ParamSpec.double('tx', 'tx', 'translate x', GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY, -1e6, 1e6, 0.0), 'ty': GObject.ParamSpec.double('ty', 'ty', 'translate y', GObject.ParamFlags.READWRITE | GObject.ParamFlags.EXPLICIT_NOTIFY, -1e6, 1e6, 0.0), 'scale': GObject.ParamSpec.double('scale', '', '', GObject.ParamFlags.READWRITE, 0.5, 2.0, 1.0), 'pivot-x': GObject.ParamSpec.double('pivot-x', '', '', GObject.ParamFlags.READWRITE, -1e6, 1e6, 0), 'pivot-y': GObject.ParamSpec.double('pivot-y', '', '', GObject.ParamFlags.READWRITE, -1e6, 1e6, 0), }, }, class OffsetPicture extends Gtk.Picture { constructor(props = {}) { super( Object.assign({ hexpand: false, vexpand: false, halign: Gtk.Align.START, valign: Gtk.Align.START, can_target: false, }, props) ); this._tx = 0.0; this._ty = 0.0; this._scale = 1.0; this._pivot_x = 0.0; this._pivot_y = 0.0; } get tx() { return this._tx; } set tx(v) { v = Number(v); if (v !== this._tx) { this._tx = v; this.notify('tx'); this.queue_draw(); } } get ty() { return this._ty; } set ty(v) { v = Number(v); if (v !== this._ty) { this._ty = v; this.notify('ty'); this.queue_draw(); } } get scale() { return this._scale; } set scale(v) { v = Number(v); if (v !== this._scale) { this._scale = v; this.notify('scale'); this.queue_draw(); } } get pivot_x() { return this._pivot_x; } set pivot_x(v) { v = Number(v); if (v !== this._pivot_x) { this._pivot_x = v; this.notify('pivot-x'); this.queue_draw(); } } get pivot_y() { return this._pivot_y; } set pivot_y(v) { v = Number(v); if (v !== this._pivot_y) { this._pivot_y = v; this.notify('pivot-y'); this.queue_draw(); } } // eslint-disable-next-line no-unused-vars vfunc_snapshot(snapshot) { const a = this.get_allocation(); if (a.width <= 0 || a.height <= 0) return; snapshot.save(); try { const rect = new Graphene.Rect(); rect.init(0, 0, a.width, a.height); snapshot.push_clip(rect); try { snapshot.translate( new Graphene.Point({x: this._tx, y: this._ty})); snapshot.translate( new Graphene.Point({x: this.pivot_x, y: this.pivot_y})); snapshot.scale(this.scale, this.scale); snapshot.translate( new Graphene.Point({x: -this.pivot_x, y: -this.pivot_y})); super.vfunc_snapshot(snapshot); } finally { snapshot.pop(); } } finally { snapshot.restore(); } } }); // Adds an auxiliary fixed layer that can sit above/below the icon grid. const WidgetGrid = class extends ControlGrid { constructor(params) { super(params); this._selectedWidget = null; // instanceId this._draggedWidget = null; // instanceId this.widgetGridEnabled = false; this._gridSize = this.Enums.WIDGET_GRID_SIZE; this._widgetContainer = new Gtk.Fixed(); this._rootFixed.put(this._widgetContainer, 0, 0); this.resizeGrid(); this._widgetContainer.set_name('widget-container'); this._widgetContainerOnTop = true; this.lowerWidgetContainer(); this._longPressActive = false; const drag = new Gtk.GestureDrag(); drag.set_propagation_phase(Gtk.PropagationPhase.CAPTURE); this._widgetContainer.add_controller(drag); // Click gesture: used only to track selection + click radius const click = new Gtk.GestureClick({button: 0}); click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE); this._widgetContainer.add_controller(click); const contextClick = new Gtk.GestureClick({button: 3}); contextClick.set_propagation_phase(Gtk.PropagationPhase.CAPTURE); this._widgetContainer.add_controller(contextClick); const longPress = new Gtk.GestureLongPress(); longPress.set_propagation_phase(Gtk.PropagationPhase.CAPTURE); this._widgetContainer.add_controller(longPress); longPress.group(drag); const settings = Gtk.Settings.get_default(); if (settings) { const longPressTime = settings.gtk_long_press_time; // ms const doubleClickTime = settings.gtk_double_click_time; // ms if (longPressTime && doubleClickTime) { let factor = doubleClickTime / longPressTime; longPress.set_delay_factor(factor); } } drag.connect('drag-begin', this._onWidgetDragBegin.bind(this)); drag.connect('drag-update', this._onWidgetDragUpdate.bind(this)); drag.connect('drag-end', this._onWidgetDragEnd.bind(this)); click.connect('pressed', this._onClick.bind(this)); click.connect('released', this._onClickRelease.bind(this)); contextClick.connect('pressed', this._onWidgetContextMenu.bind(this)); longPress.connect('pressed', this._onWidgetLongPress.bind(this)); longPress .connect('cancelled', this._onWidgetLongPressCancelled.bind(this)); } get widgetContainer() { return this._widgetContainer; } isWidgetContainerOnTop() { return this._widgetContainerOnTop; } raiseWidgetContainer() { this._setWidgetContainerLayer(true); } lowerWidgetContainer() { this._setWidgetContainerLayer(false); } setWidgetContainerOnTop(onTop = true) { this._setWidgetContainerLayer(onTop); } toggleWidgetLayer() { this.setWidgetContainerOnTop(!this._widgetContainerOnTop); } resizeWindow() { super.resizeWindow(); this._widgetContainer.set_size_request( this._width, this._height ); this._sizeContainer(this._widgetContainer); } resizeGrid() { super.resizeGrid(); this._widgetContainer.set_size_request( this._width, this._height ); this._sizeContainer(this._widgetContainer); } _setWidgetContainerLayer(onTop) { if (onTop === this._widgetContainerOnTop) return; this._widgetContainerOnTop = onTop; if (onTop) { // Widgets above icons (edit mode) // Draw order: icons (bottom), widgets (top) // Reorder without unparenting: // place widgetContainer after container in _rootFixed this._widgetContainer.insert_after(this._rootFixed, this._container); this._widgetContainer.add_css_class('widgets-on-top'); // Input: widget layer active, icons inert this._container.set_can_target(false); this._widgetContainer.set_can_target(true); this._desktopManager.unselectAll(); this._mainapp.activate_action('textEntryOff', null); this._mainapp.set_accels_for_action( 'app.lowerWidgetLayer', ['Escape'] ); } else { // Icons above widgets (normal mode) // Draw order: widgets (bottom), icons (top) // Reorder the other way: container after widgetContainer this._container.insert_after(this._rootFixed, this._widgetContainer); this._widgetContainer.remove_css_class('widgets-on-top'); // Input: icons active, widget layer background only this._container.set_can_target(true); this._widgetContainer.set_can_target(false); this._desktopManager.widgetManager?.clearSelectedInstance(); this._mainapp.set_accels_for_action('app.lowerWidgetLayer', []); this._mainapp.activate_action('textEntryOn', null); } this._desktopManager.widgetManager ?.handleWidgetContainerLayerChange(this.monitorIndex, this._widgetContainerOnTop); } _onWidgetContextMenu(gesture, _nPress, x, y) { if (!this._widgetContainerOnTop) return; if (this._findWidgetAt(x, y)) return; gesture.set_state(Gtk.EventSequenceState.CLAIMED); const menu = new Gio.Menu(); menu.append(_('Back to Desktop'), 'app.lowerWidgetLayer'); menu.append(_('Toggle Widget Grid'), 'app.toggleWidgetGrid'); menu.append(_('Add Widget...'), 'app.addWidget'); const popover = Gtk.PopoverMenu.new_from_model(menu); popover.set_parent(this._widgetContainer); popover.set_pointing_to(new Gdk.Rectangle({x, y, width: 1, height: 1})); popover.set_has_arrow(false); popover.popup(); popover.connect('closed', () => { GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { popover.unparent(); return GLib.SOURCE_REMOVE; }); }); } _onKeyPress(actor, keyval, keycode, state) { if (this._widgetContainerOnTop) return true; return super._onKeyPress(actor, keyval, keycode, state); } _onWidgetLongPress(gesture, x, y) { this._longPressActive = true; this._onWidgetDragBegin(gesture, x, y); } _onWidgetLongPressCancelled(_gesture) { this._longPressActive = false; } _onWidgetDragBegin(gesture, startX, startY) { this._dragStartX = startX; this._dragStartY = startY; this._draggedWidget = this._findWidgetAt(startX, startY); this._dragPointerOffsetX = 0; this._dragPointerOffsetY = 0; if (!this._draggedWidget || this._isWidgetChromeActor(this._draggedWidget)) { this._longPressActive = false; gesture.set_state(Gtk.EventSequenceState.DENIED); return; } // Require a long-press before we actually claim the drag. // This lets normal short clicks go through to the WebView / Gtk.Button. if (!this._longPressActive) { // Don’t drag, let the sequence fall through to children. this._draggedWidget = null; return; } gesture.set_state(Gtk.EventSequenceState.CLAIMED); const instanceId = this._draggedWidget.widgetInstanceId; const frame = this._desktopManager.widgetManager.getInstanceFrame(instanceId); if (frame) { this._dragPointerOffsetX = startX - frame.x; this._dragPointerOffsetY = startY - frame.y; } if (this._selectedWidget === instanceId) this._desktopManager.widgetManager.hideSelectionChromeDuringDrag(); this._setWidgetDraggingState(true); } _findWidgetAt(lx, ly) { const picked = this._widgetContainer.pick(lx, ly, Gtk.PickFlags.DEFAULT); return this._widgetFromPickedActor(picked); } _widgetFromPickedActor(picked) { if (!picked) return null; // We only want to return a direct child in widgetContainer, let w = picked; while (w && w !== this._widgetContainer) { if (w.get_parent() === this._widgetContainer) return w; w = w.get_parent(); } return null; } _onWidgetDragUpdate(gesture, offsetX, offsetY) { if (!this._draggedWidget) return; const lx = this._dragStartX + offsetX; const ly = this._dragStartY + offsetY; const instanceId = this._draggedWidget.widgetInstanceId; const [offX, offY] = this._getWidgetOffsets(instanceId); let newLocalX = lx - offX; let newLocalY = ly - offY; if (this.widgetGridEnabled) [newLocalX, newLocalY] = this._getWidgetSnappedPosition(newLocalX, newLocalY); this._widgetContainer.move(this._draggedWidget, newLocalX, newLocalY); } _getWidgetSnappedPosition(lx, ly) { let newLocalX = Math.round(lx / this._gridSize) * this._gridSize; let newLocalY = Math.round(ly / this._gridSize) * this._gridSize; newLocalX = Math.max(0, Math.min(newLocalX, this._width - this._gridSize)); newLocalY = Math.max(0, Math.min(newLocalY, this._height - this._gridSize)); return [newLocalX, newLocalY]; } _onWidgetDragEnd(gesture, offsetX, offsetY) { if (!this._draggedWidget) return; const lx = this._dragStartX + offsetX; const ly = this._dragStartY + offsetY; const instanceId = this._draggedWidget.widgetInstanceId; const [offX, offY] = this._getWidgetOffsets(instanceId); let newLocalX = lx - offX; let newLocalY = ly - offY; if (this.widgetGridEnabled) [newLocalX, newLocalY] = this._getWidgetSnappedPosition(newLocalX, newLocalY); this._desktopManager.widgetManager.setInstanceFrame( instanceId, newLocalX, newLocalY ); if (this._selectedWidget === instanceId) { this._desktopManager.widgetManager .updateSelectionChromePositionFor(instanceId); } this._setWidgetDraggingState(false); this._draggedWidget = null; this._dragPointerOffsetX = null; this._dragPointerOffsetY = null; this._longPressActive = false; } _setWidgetDraggingState(isDragging) { if (!this._draggedWidget) return; const ctx = this._draggedWidget.get_style_context(); if (isDragging) ctx.add_class('dragging'); else ctx.remove_class('dragging'); } _getWidgetOffsets(instanceId) { const inst = this._desktopManager.widgetManager.getInstance(instanceId); const fallbackOffsetX = inst ? inst.width / 2 : 0; const fallbackOffsetY = inst ? inst.height / 2 : 0; const offsetX = typeof this._dragPointerOffsetX === 'number' ? this._dragPointerOffsetX : fallbackOffsetX; const offsetY = typeof this._dragPointerOffsetY === 'number' ? this._dragPointerOffsetY : fallbackOffsetY; return [offsetX, offsetY]; } _onClick(gesture, nPress, x, y) { const widget = this._findWidgetAt(x, y); if (!widget) { this._selectedWidget = null; this._desktopManager.widgetManager.selectInstance(null); return; } if (this._isWidgetChromeActor(widget)) { this._selectedWidget = null; return; } const instanceId = widget.widgetInstanceId; if (!instanceId) { this._selectedWidget = null; return; } this._selectedWidget = instanceId; this._desktopManager.widgetManager.selectInstance(instanceId); this.click = [x, y]; } _onClickRelease(gesture, _nPress, x, y) { if (!this._selectedWidget) return; const [clickX, clickY] = this.click ?? [x, y]; const dx = x - clickX; const dy = y - clickY; const dist = dx * dx + dy * dy; const radius = 4 * 4; const isClick = dist <= radius; this.click = null; if (!isClick) return; // At this point we’ve done all our selection work in _onClick or // _onWidgetLongPress. For a real click, we now DENY the sequence // so that the underlying actor (HTML WebView or Gtk.Button add // widget) sees a normal click. gesture.set_state(Gtk.EventSequenceState.DENIED); } _isWidgetChromeActor(actor) { return actor.get_name?.() === 'ding-widget-close-button'; } _doDrawOnGrid(snapshot) { super._doDrawOnGrid(snapshot); this._doDrawGridRectangles(snapshot); } _doDrawGridRectangles(snapshot) { const enabled = this.widgetGridEnabled; if (enabled) { const width = this._drawArea.get_allocated_width(); const height = this._drawArea.get_allocated_height(); const gridColor = new Gdk.RGBA({red: 0.3, green: 0.3, blue: 0.3, alpha: 0.18}); for (let x = 0; x < width; x += this._gridSize) { const rect = new Graphene.Rect(); rect.init(x + 0.5, 0, 1, height); snapshot.append_color(gridColor, rect); } for (let y = 0; y < height; y += this._gridSize) { const rect = new Graphene.Rect(); rect.init(0, y + 0.5, width, 1); snapshot.append_color(gridColor, rect); } } } }; const DesktopGrid = class extends WidgetGrid { constructor(params) { super(params); this._snapshotPic = new OffsetPicture(); this._oldMargins = null; this._animationInProgress = false; this._freezeDesktop = false; this._pendingMargins = null; this._newMargins = null; this._tweenDelta = null; this._reverse = 0.33; // single tuning knob for spring snappiness // in ms this._duration = Math.max(350, this.Enums.TRANSITIONDURATION ?? 0); this._setupAnimations(); } destroy() { if (this._relayoutCoalesceSource) { GLib.source_remove(this._relayoutCoalesceSource); this._relayoutCoalesceSource = 0; } super.destroy(); } _setupAnimations() { this._setupSpringAnimation(); this._setupOffsetAnimation(); } _captureSnapshotPaintable(widget) { return new Promise(resolve => { const width = widget.get_width(); const height = widget.get_height(); const size = new Graphene.Size({width, height}); try { const snap = Gtk.Snapshot.new(); widget.vfunc_snapshot(snap); resolve(snap.to_paintable(size)); } catch (e) { logError(e); GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { try { const snap = Gtk.Snapshot.new(); widget.vfunc_snapshot(snap); resolve(snap.to_paintable(size)); } catch (ee) { logError(ee); const gdkpic = Gtk.WidgetPaintable.new(widget).get_current_image(); resolve(gdkpic); } return GLib.SOURCE_REMOVE; }); } }); } async displaySnapshot() { if (this._freezeDesktop) return; this._freezeDesktop = true; const snapshot = await this._captureSnapshotPaintable(this._window); this._resetAll(); this._snapshotPic.set_paintable(snapshot); this._oldMargins = this._getCurrentMargins(); this._overlay.add_overlay(this._snapshotPic); this._snapshotPic.opacity = 1; this._overlay.queue_draw(); this._rootFixed.opacity = 0; this._rootFixed.queue_draw(); } _getCurrentMargins() { const margin = { left: this._marginLeft ?? 0, top: this._marginTop ?? 0, right: this._marginRight ?? 0, bottom: this._marginBottom ?? 0, }; const contentRectangle = this._computeContentRectangle(margin); margin.contentRectangle = contentRectangle; return margin; } _computeContentRectangle(margins) { const contentRectangle = new Gdk.Rectangle({ x: margins.left, y: margins.top, width: this._windowWidth - margins.left - margins.right, height: this._windowHeight - margins.top - margins.bottom, }); return contentRectangle; } _setLiveOffset(dx, dy) { this._snapshotPic.tx = Math.round(dx); this._snapshotPic.ty = Math.round(dy); } _setLiveTransform(scale, pivotx, pivoty) { this._snapshotPic.scale = Number(scale); this._snapshotPic.pivot_x = Math.round(pivotx); this._snapshotPic.pivot_y = Math.round(pivoty); } _resetLiveTransform() { this._setLiveTransform(1.0, 0, 0); } _resetLiveOffset() { this._setLiveOffset(0, 0); } _resetAll() { this._resetLiveOffset(); this._resetLiveTransform(); } _clearOverlay(widget) { if (widget?.get_parent() === this._overlay) this._overlay.remove_overlay(widget); } _displayLive() { this._rootFixed.opacity = 1.0; this._snapshotPic.opacity = 0; this._rootFixed.queue_draw(); this._resetAll(); this._clearOverlay(this._snapshotPic); this._animationInProgress = false; this._freezeDesktop = false; } _computeTweenDelta(Old, New) { const sameShape = Old.contentRectangle.width === New.contentRectangle.width && Old.contentRectangle.height === New.contentRectangle.height; if (sameShape) { // If the content rectangles are the same shape, we can just tween // the top left corner of the content rectangle as the anchor // for pixel perfect alignment of the content rectangle. const anchor = 'topleft'; const dx = Old.left - New.left; const dy = Old.top - New.top; const pivotx = Old.contentRectangle.x; const pivoty = Old.contentRectangle.y; return {sameShape, anchor, dx, dy, pivotx, pivoty}; } // If the content rectangles are not the same shape, or if the // or both axis changed size, then we cannot just tween the // top left corner of the content rectangle as the anchor. // Instead, we need to tween the center, to account for the // difference in aspect ratio. const ocx = Old.contentRectangle.x + Old.contentRectangle.width / 2; const ocy = Old.contentRectangle.y + Old.contentRectangle.height / 2; const ncx = New.contentRectangle.x + New.contentRectangle.width / 2; const ncy = New.contentRectangle.y + New.contentRectangle.height / 2; const anchor = 'center'; const dx = ocx - ncx; const dy = ocy - ncy; const pivotx = ncx; const pivoty = ncy; return {sameShape, anchor, dx, dy, pivotx, pivoty}; } requestAnimatedRelayout() { if (this._relayoutCoalesceSource) { GLib.source_remove(this._relayoutCoalesceSource); this._relayoutCoalesceSource = 0; } // coalesce multiple relayouts within this time const relayoutBurstMs = 100; this._pendingMargins = this._getCurrentMargins(); this._relayoutCoalesceSource = GLib.timeout_add( GLib.PRIORITY_DEFAULT, relayoutBurstMs, () => { this._playRelayoutTransition(this._pendingMargins); this._relayoutCoalesceSource = 0; this._pendingMargins = null; return GLib.SOURCE_REMOVE; } ); } _setupSpringAnimation() { const dampingRatio = 0.58; // < 1 => underdamped (dip then settle) const stiffness = 250 + Math.round((1 - this._reverse) * 350); const mass = 1.0; const springParams = Adw.SpringParams.new(dampingRatio, mass, stiffness); const springTarget = Adw.CallbackAnimationTarget.new(v => { const s = Number(v); // animates around 1.0 due to initial_velocity this._setLiveTransform( s, this._tweenDelta.pivotx, this._tweenDelta.pivoty ); }); this._springAnimation = new Adw.SpringAnimation({ widget: this._overlay, value_from: 1.0, value_to: 1.0, spring_params: springParams, initial_velocity: -3.0, // negative => dip “away”, then return epsilon: 0.001, clamp: false, target: springTarget, }); } _setupOffsetAnimation() { const target = Adw.CallbackAnimationTarget.new(value => { const t = Number(value); // 0.0 to 1.0 const x = Math.round(-this._tweenDelta.dx * t); const y = Math.round(-this._tweenDelta.dy * t); this._setLiveOffset(x, y); this._snapshotPic.opacity = 1 - t; // Fade in the NEW live layers only near the end if (t > 0.8) this._rootFixed.opacity = t; }); this._offsetAnim = new Adw.TimedAnimation({ widget: this._overlay, value_from: 0.0, value_to: 1.0, duration: this._duration, easing: Adw.Easing.EASE_OUT_CUBIC, target, }); this._offsetAnim.connect('done', () => { this._setLiveOffset(-this._tweenDelta.dx, -this._tweenDelta.dy); // Ensure we end exactly at identity scale if (this._moveAway) { this._setLiveTransform( 1.0, this._tweenDelta.pivotx, this._tweenDelta.pivoty ); } this._displayLive(); }); } _playRelayoutTransition(pendingMargins = null) { if (!this.animationsEnabled || !this._freezeDesktop) { this._displayLive(); return; } if (this._animationInProgress) { this._offsetAnim.pause(); this._springAnimation.pause(); } this._animationInProgress = true; this._newMargins = pendingMargins ?? this._getCurrentMargins(); this._tweenDelta = this._computeTweenDelta(this._oldMargins, this._newMargins); const noshift = this._tweenDelta.dx === 0 && this._tweenDelta.dy === 0; this._moveAway = !this._tweenDelta.sameShape; if (noshift && !this._moveAway) { // No visible change, so just end the animation this._displayLive(); return; } // Initialize transform for the OLD snapshot we are animating // - translation starts at the old position // - scale is 1.0 (no depth change yet) // - pivot is from tweenDelta (center for shape change, topleft otherwise) this._setLiveOffset(0, 0); this._setLiveTransform(1.0, this._tweenDelta.pivotx, this._tweenDelta.pivoty ); this._offsetAnim.play(); if (this._moveAway) this._springAnimation.play(); } get animationsEnabled() { return this.Prefs.globalAnimations; } };