diff options
Diffstat (limited to 'extensions/47/vertical-workspaces/lib/appDisplay.js')
-rw-r--r-- | extensions/47/vertical-workspaces/lib/appDisplay.js | 2014 |
1 files changed, 2014 insertions, 0 deletions
diff --git a/extensions/47/vertical-workspaces/lib/appDisplay.js b/extensions/47/vertical-workspaces/lib/appDisplay.js new file mode 100644 index 0000000..d94f7df --- /dev/null +++ b/extensions/47/vertical-workspaces/lib/appDisplay.js @@ -0,0 +1,2014 @@ +/** + * V-Shell (Vertical Workspaces) + * appDisplay.js + * + * @author GdH <G-dH@github.com> + * @copyright 2022 - 2024 + * @license GPL-3.0 + * + */ + +'use strict'; + +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Graphene from 'gi://Graphene'; +import Meta from 'gi://Meta'; +import Pango from 'gi://Pango'; +import Shell from 'gi://Shell'; +import St from 'gi://St'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as AppDisplay from 'resource:///org/gnome/shell/ui/appDisplay.js'; +import * as DND from 'resource:///org/gnome/shell/ui/dnd.js'; +import * as PageIndicators from 'resource:///org/gnome/shell/ui/pageIndicators.js'; + +import { IconSize } from './iconGrid.js'; + +let Me; +let opt; +// gettext +let _; + +let _appDisplay; +let _timeouts; + +const APP_ICON_TITLE_EXPAND_TIME = 200; +const APP_ICON_TITLE_COLLAPSE_TIME = 100; + +const shellVersion46 = !Clutter.Container; // Container has been removed in 46 + +function _getCategories(info) { + let categoriesStr = info.get_categories(); + if (!categoriesStr) + return []; + return categoriesStr.split(';'); +} + +function _listsIntersect(a, b) { + for (let itemA of a) { + if (b.includes(itemA)) + return true; + } + return false; +} + +export const AppDisplayModule = class { + constructor(me) { + Me = me; + opt = Me.opt; + _ = Me.gettext; + + _appDisplay = Main.overview._overview.controls._appDisplay; + + this._firstActivation = true; + this.moduleEnabled = false; + this._overrides = null; + + this._appSystemStateConId = 0; + this._appGridLayoutConId = 0; + this._origAppViewItemAcceptDrop = null; + this._updateFolderIcons = 0; + } + + cleanGlobals() { + Me = null; + opt = null; + _ = null; + _appDisplay = null; + } + + update(reset) { + this._removeTimeouts(); + this.moduleEnabled = opt.get('appDisplayModule'); + const conflict = false; + + reset = reset || !this.moduleEnabled || conflict; + + // don't touch the original code if module disabled + if (reset && !this._firstActivation) { + this._disableModule(); + this.moduleEnabled = false; + } else if (!reset) { + this._firstActivation = false; + this._activateModule(); + } + if (reset && this._firstActivation) { + this.moduleEnabled = false; + console.debug(' AppDisplayModule - Keeping untouched'); + } + } + + _activateModule() { + Me.Modules.iconGridModule.update(); + + if (!this._overrides) + this._overrides = new Me.Util.Overrides(); + + _timeouts = {}; + + this._applyOverrides(); + this._updateAppDisplay(); + _appDisplay.add_style_class_name('app-display-46'); + + console.debug(' AppDisplayModule - Activated'); + } + + _disableModule() { + Me.Modules.iconGridModule.update(true); + + if (this._overrides) + this._overrides.removeAll(); + this._overrides = null; + + const reset = true; + this._updateAppDisplay(reset); + this._restoreOverviewGroup(); + + _appDisplay.remove_style_class_name('app-display-46'); + + console.debug(' AppDisplayModule - Disabled'); + } + + _removeTimeouts() { + if (_timeouts) { + Object.values(_timeouts).forEach(t => { + if (t) + GLib.source_remove(t); + }); + _timeouts = null; + } + } + + _applyOverrides() { + // Common/appDisplay + // this._overrides.addOverride('BaseAppViewCommon', AppDisplay.BaseAppView.prototype, BaseAppViewCommon); + // instead of overriding inaccessible BaseAppView class, we override its subclasses - AppDisplay and FolderView + this._overrides.addOverride('BaseAppViewCommonApp', AppDisplay.AppDisplay.prototype, BaseAppViewCommon); + this._overrides.addOverride('AppDisplay', AppDisplay.AppDisplay.prototype, AppDisplayCommon); + this._overrides.addOverride('AppViewItem', AppDisplay.AppViewItem.prototype, AppViewItemCommon); + this._overrides.addOverride('AppGridCommon', AppDisplay.AppGrid.prototype, AppGridCommon); + this._overrides.addOverride('AppIcon', AppDisplay.AppIcon.prototype, AppIcon); + if (opt.ORIENTATION) { + this._overrides.removeOverride('AppGridLayoutHorizontal'); + this._overrides.addOverride('AppGridLayoutVertical', _appDisplay._appGridLayout, BaseAppViewGridLayoutVertical); + } else { + this._overrides.removeOverride('AppGridLayoutVertical'); + this._overrides.addOverride('AppGridLayoutHorizontal', _appDisplay._appGridLayout, BaseAppViewGridLayoutHorizontal); + } + + // Custom folders + this._overrides.addOverride('BaseAppViewCommonFolder', AppDisplay.FolderView.prototype, BaseAppViewCommon); + this._overrides.addOverride('FolderView', AppDisplay.FolderView.prototype, FolderView); + this._overrides.addOverride('AppFolderDialog', AppDisplay.AppFolderDialog.prototype, AppFolderDialog); + this._overrides.addOverride('FolderIcon', AppDisplay.FolderIcon.prototype, FolderIcon); + + // Prevent changing grid page size when showing/hiding _pageIndicators + this._overrides.addOverride('PageIndicators', PageIndicators.PageIndicators.prototype, PageIndicatorsCommon); + } + + _updateAppDisplay(reset) { + const orientation = reset ? Clutter.Orientation.HORIZONTAL : opt.ORIENTATION; + BaseAppViewCommon._adaptForOrientation.bind(_appDisplay)(orientation); + + this._updateFavoritesConnection(reset); + + _appDisplay.visible = true; + if (reset) { + _appDisplay._grid.layoutManager.fixedIconSize = -1; + _appDisplay._grid.layoutManager.allow_incomplete_pages = true; + _appDisplay._grid._currentMode = -1; + _appDisplay._grid.setGridModes(); + _appDisplay._grid.set_style(''); + _appDisplay._prevPageArrow.set_scale(1, 1); + _appDisplay._nextPageArrow.set_scale(1, 1); + if (this._appGridLayoutConId) { + global.settings.disconnect(this._appGridLayoutConId); + this._appGridLayoutConId = 0; + } + this._repopulateAppDisplay(reset); + } else { + _appDisplay._grid._currentMode = -1; + // update grid on layout reset + if (!this._appGridLayoutConId) + this._appGridLayoutConId = global.settings.connect('changed::app-picker-layout', this._updateLayout.bind(this)); + + // avoid resetting appDisplay before startup animation + // x11 shell restart skips startup animation + if (!Main.layoutManager._startingUp) { + this._repopulateAppDisplay(); + } else if (Main.layoutManager._startingUp && Meta.is_restart()) { + _timeouts.three = GLib.idle_add(GLib.PRIORITY_LOW, () => { + this._repopulateAppDisplay(); + _timeouts.three = 0; + return GLib.SOURCE_REMOVE; + }); + } + } + } + + _updateFavoritesConnection(reset) { + if (!reset) { + if (!this._appSystemStateConId && opt.APP_GRID_INCLUDE_DASH >= 3) { + this._appSystemStateConId = Shell.AppSystem.get_default().connect( + 'app-state-changed', + () => { + this._updateFolderIcons = true; + _appDisplay._redisplay(); + } + ); + } + } else if (this._appSystemStateConId) { + Shell.AppSystem.get_default().disconnect(this._appSystemStateConId); + this._appSystemStateConId = 0; + } + } + + _restoreOverviewGroup() { + Main.overview.dash.showAppsButton.checked = false; + Main.layoutManager.overviewGroup.opacity = 255; + Main.layoutManager.overviewGroup.scale_x = 1; + Main.layoutManager.overviewGroup.scale_y = 1; + Main.layoutManager.overviewGroup.hide(); + _appDisplay.translation_x = 0; + _appDisplay.translation_y = 0; + _appDisplay.visible = true; + _appDisplay.opacity = 255; + } + + _updateLayout(settings, key) { + // Reset the app grid only if the user layout has been completely removed + if (!settings.get_value(key).deep_unpack().length) { + this._repopulateAppDisplay(); + } + } + + _repopulateAppDisplay(reset = false, callback) { + // Remove all icons so they can be re-created with the current configuration + // Updating appGrid content while rebasing extensions when session is locked makes no sense (relevant for GS version < 46) + if (!Main.sessionMode.isLocked) + AppDisplayCommon.removeAllItems.bind(_appDisplay)(); + + // appDisplay disabled + if (reset) { + _appDisplay._redisplay(); + return; + } + + _appDisplay._readyToRedisplay = true; + _appDisplay._redisplay(); + + // Setting OffscreenRedirect should improve performance when opacity transitions are used + _appDisplay.offscreen_redirect = Clutter.OffscreenRedirect.ALWAYS; + + if (opt.APP_GRID_PERFORMANCE) + this._realizeAppDisplay(callback); + else if (callback) + callback(); + } + + _realizeAppDisplay(callback) { + // Workaround - silently realize appDisplay + // The realization takes some time and affects animations during the first use + // If we do it invisibly before the user needs the app grid, it can improve the user's experience + _appDisplay.opacity = 1; + + this._exposeAppGrid(); + _appDisplay._redisplay(); + this._exposeAppFolders(); + + // Let the main loop process our changes before we continue + _timeouts.updateAppGrid = GLib.idle_add(GLib.PRIORITY_LOW, () => { + this._restoreAppGrid(); + Me._resetInProgress = false; + + if (callback) + callback(); + + _timeouts.updateAppGrid = 0; + return GLib.SOURCE_REMOVE; + }); + } + + _exposeAppGrid() { + const overviewGroup = Main.layoutManager.overviewGroup; + if (!overviewGroup.visible) { + // scale down the overviewGroup so it don't cover uiGroup + overviewGroup.scale_y = 0.001; + // make it invisible to the eye, but visible for the renderer + overviewGroup.opacity = 1; + // if overview is hidden, show it + overviewGroup.visible = true; + } + } + + _restoreAppGrid() { + if (opt.APP_GRID_PERFORMANCE) + this._hideAppFolders(); + + const overviewGroup = Main.layoutManager.overviewGroup; + if (!Main.overview._shown) + overviewGroup.hide(); + overviewGroup.scale_y = 1; + overviewGroup.opacity = 255; + _appDisplay.opacity = 0; + _appDisplay.visible = false; + } + + _exposeAppFolders() { + _appDisplay._folderIcons.forEach(d => { + d._ensureFolderDialog(); + d._dialog.scale_y = 0.0001; + d._dialog.show(); + d._dialog._updateFolderSize(); + }); + } + + _hideAppFolders() { + _appDisplay._folderIcons.forEach(d => { + if (d._dialog) { + d._dialog.hide(); + d._dialog.scale_y = 1; + } + }); + } +}; + +function _getViewFromIcon(icon) { + icon = icon._sourceItem ? icon._sourceItem : icon; + for (let parent = icon.get_parent(); parent; parent = parent.get_parent()) { + if (parent instanceof AppDisplay.AppDisplay || parent instanceof AppDisplay.FolderView) { + return parent; + } + } + return null; +} + +const AppDisplayCommon = { + _ensureDefaultFolders() { + // disable creation of default folders if user deleted them + }, + + removeAllItems() { + this._orderedItems.slice().forEach(item => { + if (item._dialog) + Main.layoutManager.overviewGroup.remove_child(item._dialog); + + this._removeItem(item); + item.destroy(); + }); + this._folderIcons = []; + }, + + // apps load adapted for custom sorting and including dash items + _loadApps() { + let appIcons = []; + const runningApps = Shell.AppSystem.get_default().get_running().map(a => a.id); + + this._appInfoList = Shell.AppSystem.get_default().get_installed().filter(appInfo => { + try { + appInfo.get_id(); // catch invalid file encodings + } catch (e) { + return false; + } + + const appIsRunning = runningApps.includes(appInfo.get_id()); + const appIsFavorite = this._appFavorites.isFavorite(appInfo.get_id()); + const excludeApp = (opt.APP_GRID_EXCLUDE_RUNNING && appIsRunning) || (opt.APP_GRID_EXCLUDE_FAVORITES && appIsFavorite); + + return this._parentalControlsManager.shouldShowApp(appInfo) && !excludeApp; + }); + + let apps = this._appInfoList.map(app => app.get_id()); + + let appSys = Shell.AppSystem.get_default(); + + const appsInsideFolders = new Set(); + this._folderIcons = []; + if (!opt.APP_GRID_USAGE) { + let folders = this._folderSettings.get_strv('folder-children'); + folders.forEach(id => { + let path = `${this._folderSettings.path}folders/${id}/`; + let icon = this._items.get(id); + if (!icon) { + icon = new AppDisplay.FolderIcon(id, path, this); + icon.connect('apps-changed', () => { + this._redisplay(); + this._savePages(); + }); + icon.connect('notify::pressed', () => { + if (icon.pressed) + this.updateDragFocus(icon); + }); + } else if (this._updateFolderIcons && opt.APP_GRID_EXCLUDE_RUNNING) { + // if any app changed its running state, update folder icon + icon.icon.update(); + } + + // remove empty folder icons + if (!icon.visible) { + icon.destroy(); + return; + } + + appIcons.push(icon); + this._folderIcons.push(icon); + + icon.getAppIds().forEach(appId => appsInsideFolders.add(appId)); + }); + } + + // reset request to update active icon + this._updateFolderIcons = false; + + // Allow dragging of the icon only if the Dash would accept a drop to + // change favorite-apps. There are no other possible drop targets from + // the app picker, so there's no other need for a drag to start, + // at least on single-monitor setups. + // This also disables drag-to-launch on multi-monitor setups, + // but we hope that is not used much. + const isDraggable = + global.settings.is_writable('favorite-apps') || + global.settings.is_writable('app-picker-layout'); + + apps.forEach(appId => { + if (!opt.APP_GRID_USAGE && appsInsideFolders.has(appId)) + return; + + let icon = this._items.get(appId); + if (!icon) { + let app = appSys.lookup_app(appId); + icon = new AppDisplay.AppIcon(app, { isDraggable }); + icon.connect('notify::pressed', () => { + if (icon.pressed) + this.updateDragFocus(icon); + }); + } + + appIcons.push(icon); + }); + + // At last, if there's a placeholder available, add it + if (this._placeholder) + appIcons.push(this._placeholder); + + return appIcons; + }, + + _onDragBegin(overview, source) { + // let sourceId; + // support active preview icons + if (source._sourceItem) { + // sourceId = source._sourceFolder._id; + source = source._sourceItem; + } /* else { + sourceId = source.id; + }*/ + // Prevent switching page when an item on another page is selected + // by removing the focus from all icons + // This is an upstream bug + // this.selectApp(sourceId); + this.grab_key_focus(); + + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + dragDrop: this._onDragDrop.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + + this._appGridLayout.showPageIndicators(); + this._dragFocus = null; + this._swipeTracker.enabled = false; + + // When dragging from a folder dialog, the dragged app icon doesn't + // exist in AppDisplay. We work around that by adding a placeholder + // icon that is either destroyed on cancel, or becomes the effective + // new icon when dropped. + if (/* AppDisplay.*/_getViewFromIcon(source) instanceof AppDisplay.FolderView || + (opt.APP_GRID_EXCLUDE_FAVORITES && this._appFavorites.isFavorite(source.id))) + this._ensurePlaceholder(source); + }, + + _ensurePlaceholder(source) { + if (this._placeholder) + return; + + if (source._sourceItem) + source = source._sourceItem; + + const appSys = Shell.AppSystem.get_default(); + const app = appSys.lookup_app(source.id); + + const isDraggable = + global.settings.is_writable('favorite-apps') || + global.settings.is_writable('app-picker-layout'); + + this._placeholder = new AppDisplay.AppIcon(app, { isDraggable }); + this._placeholder.connect('notify::pressed', () => { + if (this._placeholder?.pressed) + this.updateDragFocus(this._placeholder); + }); + this._placeholder.scaleAndFade(); + this._redisplay(); + }, + + // accept source from active folder preview + acceptDrop(source) { + if (opt.APP_GRID_USAGE) + return false; + if (source._sourceItem) + source = source._sourceItem; + if (!this._acceptDropCommon(source)) + return false; + + this._savePages(); + + const view = /* AppDisplay.*/_getViewFromIcon(source); + if (view instanceof AppDisplay.FolderView) + view.removeApp(source.app); + + if (this._currentDialog) + this._currentDialog.popdown(); + + if (opt.APP_GRID_EXCLUDE_FAVORITES && this._appFavorites.isFavorite(source.id)) + this._appFavorites.removeFavorite(source.id); + return true; + }, + + _savePages() { + // Skip saving pages when search app grid mode is active + // and the grid is showing search results + if (Main.overview._overview.controls._origAppGridContent) + return; + + const pages = []; + + for (let i = 0; i < this._grid.nPages; i++) { + const pageItems = + this._grid.getItemsAtPage(i).filter(c => c.visible); + const pageData = {}; + + pageItems.forEach((item, index) => { + pageData[item.id] = { + position: GLib.Variant.new_int32(index), + }; + }); + pages.push(pageData); + } + + this._pageManager.pages = pages; + }, +}; + +const BaseAppViewCommon = { + after__init() { + // Only folders can run this init + this._isFolder = true; + + this._adaptForOrientation(opt.ORIENTATION, true); + + // Because the original class prototype is not exported, we need to inject every instance + const overrides = new Me.Util.Overrides(); + if (opt.ORIENTATION) { + overrides.addOverride('FolderGridLayoutVertical', this._appGridLayout, BaseAppViewGridLayoutVertical); + this._pageIndicators.set_style('margin-right: 12px;'); + } else { + overrides.addOverride('FolderGridLayoutHorizontal', this._appGridLayout, BaseAppViewGridLayoutHorizontal); + this._pageIndicators.set_style('margin-bottom: 12px;'); + } + }, + + _adaptForOrientation(orientation, folder) { + const vertical = !!orientation; + + this._grid.layoutManager.fixedIconSize = folder ? opt.APP_GRID_FOLDER_ICON_SIZE : opt.APP_GRID_ICON_SIZE; + this._grid.layoutManager._orientation = orientation; + this._orientation = orientation; + this._swipeTracker.orientation = orientation; + this._swipeTracker._reset(); + + this._adjustment = vertical + ? this._scrollView.get_vscroll_bar().adjustment + : this._scrollView.get_hscroll_bar().adjustment; + + this._prevPageArrow.pivot_point = new Graphene.Point({ x: 0.5, y: 0.5 }); + this._prevPageArrow.rotation_angle_z = vertical ? 90 : 0; + + this._nextPageArrow.pivot_point = new Graphene.Point({ x: 0.5, y: 0.5 }); + this._nextPageArrow.rotation_angle_z = vertical ? 90 : 0; + + const pageIndicators = this._pageIndicators; + pageIndicators.vertical = vertical; + this._box.vertical = !vertical; + pageIndicators.x_expand = !vertical; + pageIndicators.y_align = vertical ? Clutter.ActorAlign.CENTER : Clutter.ActorAlign.START; + pageIndicators.x_align = vertical ? Clutter.ActorAlign.START : Clutter.ActorAlign.CENTER; + + this._grid.layoutManager.allow_incomplete_pages = folder ? false : opt.APP_GRID_ALLOW_INCOMPLETE_PAGES; + const spacing = folder ? opt.APP_GRID_FOLDER_SPACING : opt.APP_GRID_SPACING; + this._grid.set_style(`column-spacing: ${spacing}px; row-spacing: ${spacing}px;`); + + if (vertical) { + this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.EXTERNAL); + if (!this._scrollConId) { + this._scrollConId = this._adjustment.connect('notify::value', adj => { + const value = adj.value / adj.page_size; + this._pageIndicators.setCurrentPosition(value); + }); + } + pageIndicators.remove_style_class_name('page-indicators-horizontal'); + pageIndicators.add_style_class_name('page-indicators-vertical'); + this._prevPageIndicator.add_style_class_name('prev-page-indicator'); + this._nextPageIndicator.add_style_class_name('next-page-indicator'); + this._nextPageArrow.translationY = 0; + this._prevPageArrow.translationY = 0; + this._nextPageIndicator.translationX = 0; + this._prevPageIndicator.translationX = 0; + } else { + this._scrollView.set_policy(St.PolicyType.EXTERNAL, St.PolicyType.NEVER); + if (this._scrollConId) { + this._adjustment.disconnect(this._scrollConId); + this._scrollConId = 0; + } + pageIndicators.remove_style_class_name('page-indicators-vertical'); + pageIndicators.add_style_class_name('page-indicators-horizontal'); + this._prevPageIndicator.remove_style_class_name('prev-page-indicator'); + this._nextPageIndicator.remove_style_class_name('next-page-indicator'); + this._nextPageArrow.translationX = 0; + this._prevPageArrow.translationX = 0; + this._nextPageIndicator.translationY = 0; + this._prevPageIndicator.translationY = 0; + } + + const scale = opt.APP_GRID_SHOW_PAGE_ARROWS ? 1 : 0; + this._prevPageArrow.set_scale(scale, scale); + this._nextPageArrow.set_scale(scale, scale); + }, + + _sortItemsByName(items) { + items.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + }, + + _updateItemPositions(icons, allowIncompletePages = false) { + // Avoid recursion when relocating icons + this._grid.layoutManager._skipRelocateSurplusItems = true; + + const { itemsPerPage } = this._grid; + + icons.slice().forEach((icon, index) => { + const [currentPage, currentPosition] = this._grid.layoutManager.getItemPosition(icon); + + let page, position; + if (allowIncompletePages) { + [page, position] = this._getItemPosition(icon); + } else { + page = Math.floor(index / itemsPerPage); + position = index % itemsPerPage; + } + + if (currentPage !== page || currentPosition !== position) { + this._moveItem(icon, page, position); + } + }); + + this._grid.layoutManager._skipRelocateSurplusItems = false; + // Disable animating the icons to their new positions + // since it can cause glitches when the app grid search mode is active + // and many icons are repositioning at once + this._grid.layoutManager._shouldEaseItems = false; + }, + + // Adds sorting options + _redisplay() { + // different options for main app grid and app folders + const thisIsFolder = this instanceof AppDisplay.FolderView; + const thisIsAppDisplay = !thisIsFolder; + + // When an app was dragged from a folder and dropped to the main grid + // folders (if exist) need to be redisplayed even if we temporary block it for the appDisplay + this._folderIcons?.forEach(icon => { + icon.view._redisplay(); + }); + + // Avoid unwanted updates + if (thisIsAppDisplay && !this._readyToRedisplay) + return; + + const oldApps = this._orderedItems.slice(); + const oldAppIds = oldApps.map(icon => icon.id); + + const newApps = this._loadApps(); + const newAppIds = newApps.map(icon => icon.id); + + const addedApps = newApps.filter(icon => !oldAppIds.includes(icon.id)); + const removedApps = oldApps.filter(icon => !newAppIds.includes(icon.id)); + + // Don't update folder without dialog if its content didn't change + if (!addedApps.length && !removedApps.length && thisIsFolder && !this.get_parent()) + return; + + // Remove old app icons + removedApps.forEach(icon => { + this._removeItem(icon); + icon.destroy(); + }); + + // For the main app grid only + let allowIncompletePages = thisIsAppDisplay && opt.APP_GRID_ALLOW_INCOMPLETE_PAGES; + + const customOrder = !((opt.APP_GRID_ORDER && thisIsAppDisplay) || (opt.APP_FOLDER_ORDER && thisIsFolder)); + if (!customOrder) { + allowIncompletePages = false; + + // Sort by name + this._sortItemsByName(newApps); + + // Sort by usage + if ((opt.APP_GRID_USAGE && thisIsAppDisplay) || + (opt.APP_FOLDER_USAGE && thisIsFolder)) { + newApps.sort((a, b) => Shell.AppUsage.get_default().compare(a.app?.id, b.app?.id)); + } + + // Sort favorites first + if (!opt.APP_GRID_EXCLUDE_FAVORITES && opt.APP_GRID_DASH_FIRST) { + const fav = Object.keys(this._appFavorites._favorites); + newApps.sort((a, b) => { + let aFav = fav.indexOf(a.id); + if (aFav < 0) + aFav = 999; + let bFav = fav.indexOf(b.id); + if (bFav < 0) + bFav = 999; + return bFav < aFav; + }); + } + + // Sort running first + if (!opt.APP_GRID_EXCLUDE_RUNNING && opt.APP_GRID_DASH_FIRST) { + newApps.sort((a, b) => a.app?.get_state() !== Shell.AppState.RUNNING && b.app?.get_state() === Shell.AppState.RUNNING); + } + + // Sort folders first + if (thisIsAppDisplay && opt.APP_GRID_FOLDERS_FIRST) + newApps.sort((a, b) => b._folder && !a._folder); + + // Sort folders last + else if (thisIsAppDisplay && opt.APP_GRID_FOLDERS_LAST) + newApps.sort((a, b) => a._folder && !b._folder); + } else { + // Sort items according to the custom order stored in pageManager + newApps.sort(this._compareItems.bind(this)); + } + + // Add new app icons to the grid + newApps.forEach(icon => { + const [page, position] = this._grid.getItemPosition(icon); + if (page === -1 && position === -1) + this._addItem(icon, -1, -1); + }); + // When a placeholder icon was added to the custom sorted grid during DND from a folder + // update its initial position on the page + if (customOrder) + newApps.sort(this._compareItems.bind(this)); + + this._orderedItems = newApps; + + // Update icon positions if needed + this._updateItemPositions(this._orderedItems, allowIncompletePages); + + // Relocate items with invalid positions + if (thisIsAppDisplay) { + const nPages = this._grid.layoutManager.nPages; + for (let pageIndex = 0; pageIndex < nPages; pageIndex++) + this._grid.layoutManager._relocateSurplusItems(pageIndex); + } + + this.emit('view-loaded'); + }, + + _canAccept(source) { + return source instanceof AppDisplay.AppViewItem; + }, + + // this method is replacing BaseAppVew.acceptDrop which can't be overridden directly + _acceptDropCommon(source) { + const dropTarget = this._dropTarget; + delete this._dropTarget; + if (!this._canAccept(source)) + return false; + + if (dropTarget === this._prevPageIndicator || + dropTarget === this._nextPageIndicator) { + let increment; + increment = dropTarget === this._prevPageIndicator ? -1 : 1; + const { currentPage, nPages } = this._grid; + const page = Math.min(currentPage + increment, nPages); + const position = page < nPages ? -1 : 0; + + this._moveItem(source, page, position); + this.goToPage(page); + } else if (this._delayedMoveData) { + // Dropped before the icon was moved + const { page, position } = this._delayedMoveData; + try { + this._moveItem(source, page, position); + } catch (e) { + console.warn(`Warning:${e}`); + } + this._removeDelayedMove(); + } + + return true; + }, + + // support active preview icons + _onDragMotion(dragEvent) { + if (!(dragEvent.source instanceof AppDisplay.AppViewItem)) + return DND.DragMotionResult.CONTINUE; + + if (dragEvent.source._sourceItem) + dragEvent.source = dragEvent.source._sourceItem; + + const appIcon = dragEvent.source; + + if (appIcon instanceof AppDisplay.AppViewItem) { + if (!this._dragMaybeSwitchPageImmediately(dragEvent)) { + // Two ways of switching pages during DND: + // 1) When "bumping" the cursor against the monitor edge, we switch + // page immediately. + // 2) When hovering over the next-page indicator for a certain time, + // we also switch page. + + const { targetActor } = dragEvent; + + if (targetActor === this._prevPageIndicator || + targetActor === this._nextPageIndicator) + this._maybeSetupDragPageSwitchInitialTimeout(dragEvent); + else + this._resetDragPageSwitch(); + } + } + + const thisIsFolder = this instanceof AppDisplay.FolderView; + const thisIsAppDisplay = !thisIsFolder; + + // Prevent reorganizing the main app grid icons when an app folder is open and when sorting is not custom + // For some reason in V-Shell the drag motion events propagate from folder to main grid, which is not a problem in default code - so test the open dialog + if (!this._currentDialog && (!opt.APP_GRID_ORDER && thisIsAppDisplay) || (!opt.APP_FOLDER_ORDER && thisIsFolder)) + this._maybeMoveItem(dragEvent); + + return DND.DragMotionResult.CONTINUE; + }, +}; + +const BaseAppViewGridLayoutHorizontal = { + _getIndicatorsWidth(box) { + const [width, height] = box.get_size(); + const arrows = [ + this._nextPageArrow, + this._previousPageArrow, + ]; + + let minArrowsWidth; + + minArrowsWidth = arrows.reduce( + (previousWidth, accessory) => { + const [min] = accessory.get_preferred_width(height); + return Math.max(previousWidth, min); + }, 0); + + minArrowsWidth = opt.APP_GRID_SHOW_PAGE_ARROWS ? minArrowsWidth : 0; + + const indicatorWidth = !this._grid._isFolder + ? minArrowsWidth + ((width - minArrowsWidth) * (1 - opt.APP_GRID_PAGE_WIDTH_SCALE)) / 2 + : minArrowsWidth + 6; + + return Math.round(indicatorWidth); + }, + + vfunc_allocate(container, box) { + const ltr = container.get_text_direction() !== Clutter.TextDirection.RTL; + const indicatorsWidth = this._getIndicatorsWidth(box); + + const pageIndicatorsHeight = 20; // _appDisplay._pageIndicators.height is unstable, 20 is determined by the style + const availHeight = box.get_height() - pageIndicatorsHeight; + const vPadding = Math.round((availHeight - availHeight * opt.APP_GRID_PAGE_HEIGHT_SCALE) / 2); + this._grid.indicatorsPadding = new Clutter.Margin({ + left: indicatorsWidth, + right: indicatorsWidth, + top: vPadding + pageIndicatorsHeight, + bottom: vPadding, + }); + + this._scrollView.allocate(box); + + const leftBox = box.copy(); + leftBox.x2 = leftBox.x1 + indicatorsWidth; + + const rightBox = box.copy(); + rightBox.x1 = rightBox.x2 - indicatorsWidth; + + this._previousPageIndicator.allocate(ltr ? leftBox : rightBox); + this._previousPageArrow.allocate_align_fill(ltr ? leftBox : rightBox, + 0.5, 0.5, false, false); + this._nextPageIndicator.allocate(ltr ? rightBox : leftBox); + this._nextPageArrow.allocate_align_fill(ltr ? rightBox : leftBox, + 0.5, 0.5, false, false); + + this._pageWidth = box.get_width(); + + // Center page arrow buttons + this._previousPageArrow.translationY = pageIndicatorsHeight / 2; + this._nextPageArrow.translationY = pageIndicatorsHeight / 2; + // Reset page indicators vertical position + this._nextPageIndicator.translationY = 0; + this._previousPageIndicator.translationY = 0; + }, +}; + +const BaseAppViewGridLayoutVertical = { + _getIndicatorsHeight(box) { + const [width, height] = box.get_size(); + const arrows = [ + this._nextPageArrow, + this._previousPageArrow, + ]; + + let minArrowsHeight; + + minArrowsHeight = arrows.reduce( + (previousHeight, accessory) => { + const [min] = accessory.get_preferred_height(width); + return Math.max(previousHeight, min); + }, 0); + + minArrowsHeight = opt.APP_GRID_SHOW_PAGE_ARROWS ? minArrowsHeight : 0; + + const indicatorHeight = !this._grid._isFolder + ? minArrowsHeight + ((height - minArrowsHeight) * (1 - opt.APP_GRID_PAGE_HEIGHT_SCALE)) / 2 + : minArrowsHeight + 6; + + return Math.round(indicatorHeight); + }, + + _syncPageIndicators() { + if (!this._container) + return; + + const { value } = this._pageIndicatorsAdjustment; + + const { top, bottom } = this._grid.indicatorsPadding; + const topIndicatorOffset = -top * (1 - value); + const bottomIndicatorOffset = bottom * (1 - value); + + this._previousPageIndicator.translationY = + topIndicatorOffset; + this._nextPageIndicator.translationY = + bottomIndicatorOffset; + + const leftArrowOffset = -top * value; + const rightArrowOffset = bottom * value; + + this._previousPageArrow.translationY = + leftArrowOffset; + this._nextPageArrow.translationY = + rightArrowOffset; + + // Page icons + this._translatePreviousPageIcons(value); + this._translateNextPageIcons(value); + + if (this._grid.nPages > 0) { + this._grid.getItemsAtPage(this._currentPage).forEach(icon => { + icon.translationY = 0; + }); + } + }, + + _translatePreviousPageIcons(value) { + if (this._currentPage === 0) + return; + + const pageHeight = this._grid.layoutManager._pageHeight; + const previousPage = this._currentPage - 1; + const icons = this._grid.getItemsAtPage(previousPage).filter(i => i.visible); + if (icons.length === 0) + return; + + const { top } = this._grid.indicatorsPadding; + const { rowSpacing } = this._grid.layoutManager; + const endIcon = icons[icons.length - 1]; + let iconOffset; + + const currentPageOffset = pageHeight * this._currentPage; + iconOffset = currentPageOffset - endIcon.allocation.y1 - endIcon.width + top - rowSpacing; + + for (const icon of icons) + icon.translationY = iconOffset * value; + }, + + _translateNextPageIcons(value) { + if (this._currentPage >= this._grid.nPages - 1) + return; + + const nextPage = this._currentPage + 1; + const icons = this._grid.getItemsAtPage(nextPage).filter(i => i.visible); + if (icons.length === 0) + return; + + const { bottom } = this._grid.indicatorsPadding; + const { rowSpacing } = this._grid.layoutManager; + let iconOffset; + + const pageOffset = this._pageHeight * nextPage; + iconOffset = pageOffset - icons[0].allocation.y1 - bottom + rowSpacing; + + for (const icon of icons) + icon.translationY = iconOffset * value; + }, + + vfunc_allocate(container, box) { + const indicatorsHeight = this._getIndicatorsHeight(box); + + const pageIndicatorsWidth = 20; // _appDisplay._pageIndicators.width is not stable, 20 is determined by the style + const availWidth = box.get_width() - pageIndicatorsWidth; + const hPadding = Math.round((availWidth - availWidth * opt.APP_GRID_PAGE_WIDTH_SCALE) / 2); + + this._grid.indicatorsPadding = new Clutter.Margin({ + top: indicatorsHeight, + bottom: indicatorsHeight, + left: hPadding + pageIndicatorsWidth, + right: hPadding, + }); + + this._scrollView.allocate(box); + + const topBox = box.copy(); + topBox.y2 = topBox.y1 + indicatorsHeight; + + const bottomBox = box.copy(); + bottomBox.y1 = bottomBox.y2 - indicatorsHeight; + + this._previousPageIndicator.allocate(topBox); + this._previousPageArrow.allocate_align_fill(topBox, + 0.5, 0.5, false, false); + this._nextPageIndicator.allocate(bottomBox); + this._nextPageArrow.allocate_align_fill(bottomBox, + 0.5, 0.5, false, false); + + this._pageHeight = box.get_height(); + + // Center page arrow buttons + this._previousPageArrow.translationX = pageIndicatorsWidth / 2; + this._nextPageArrow.translationX = pageIndicatorsWidth / 2; + // Reset page indicators vertical position + this._nextPageIndicator.translationX = 0; + this._previousPageIndicator.translationX = 0; + }, +}; + +const AppGridCommon = { + _updatePadding() { + const { rowSpacing, columnSpacing } = this.layoutManager; + + const padding = this._indicatorsPadding.copy(); + + padding.left += rowSpacing; + padding.right += rowSpacing; + padding.top += columnSpacing; + padding.bottom += columnSpacing; + + this.layoutManager.pagePadding = padding; + }, +}; + +const FolderIcon = { + after__init() { + this.button_mask = St.ButtonMask.ONE | St.ButtonMask.TWO; + if (shellVersion46) + this.add_style_class_name('app-folder-46'); + else + this.add_style_class_name('app-folder-45'); + }, + + open() { + // Prevent switching page when an item on another page is selected + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + // Select folder icon to prevent switching page to the one with currently selected icon + this._parentView._selectAppInternal(this._id); + // Remove key focus from the selected icon to prevent switching page after dropping the removed folder icon on another page of the main grid + this._parentView.grab_key_focus(); + this._ensureFolderDialog(); + this._dialog.popup(); + }); + }, + + vfunc_clicked() { + this.open(); + }, + + _canAccept(source) { + if (!(source instanceof AppDisplay.AppIcon)) + return false; + + const view = _getViewFromIcon(source); + if (!view /* || !(view instanceof AppDisplay.AppDisplay)*/) + return false; + + // Disable this test to allow the user to cancel the current DND by dropping the icon on its original source + /* if (this._folder.get_strv('apps').includes(source.id)) + return false;*/ + + return true; + }, + + acceptDrop(source) { + if (source._sourceItem) + source = source._sourceItem; + + const accepted = AppViewItemCommon.acceptDrop.bind(this)(source); + + if (!accepted) + return false; + + // If the icon is already in the folder (user dropped it back on the same folder), skip re-adding it + if (this._folder.get_strv('apps').includes(source.id)) + return true; + + this._onDragEnd(); + + this.view.addApp(source.app); + + return true; + }, +}; + +const FolderView = { + _createGrid() { + let grid = new FolderGrid(); + grid._view = this; + return grid; + }, + + createFolderIcon(size) { + const layout = new Clutter.GridLayout({ + row_homogeneous: true, + column_homogeneous: true, + }); + + let icon = new St.Widget({ + layout_manager: layout, + x_align: Clutter.ActorAlign.CENTER, + style: `width: ${size}px; height: ${size}px;`, + }); + + const numItems = this._orderedItems.length; + // decide what number of icons switch to 3x3 grid + // APP_GRID_FOLDER_ICON_GRID: 3 -> more than 4 + // : 4 -> more than 8 + const threshold = opt.APP_GRID_FOLDER_ICON_GRID % 3 ? 8 : 4; + const gridSize = opt.APP_GRID_FOLDER_ICON_GRID > 2 && numItems > threshold ? 3 : 2; + const FOLDER_SUBICON_FRACTION = gridSize === 2 ? 0.4 : 0.27; + + let subSize = Math.floor(FOLDER_SUBICON_FRACTION * size); + let rtl = icon.get_text_direction() === Clutter.TextDirection.RTL; + for (let i = 0; i < gridSize * gridSize; i++) { + const style = `width: ${subSize}px; height: ${subSize}px;`; + let bin = new St.Bin({ style, reactive: true }); + bin.pivot_point = new Graphene.Point({ x: 0.5, y: 0.5 }); + if (i < numItems) { + if (!opt.APP_GRID_ACTIVE_PREVIEW) { + bin.child = this._orderedItems[i].app.create_icon_texture(subSize); + } else { + const app = this._orderedItems[i].app; + const child = new AppDisplay.AppIcon(app, { + setSizeManually: true, + showLabel: false, + }); + + child._sourceItem = this._orderedItems[i]; + child._sourceFolder = this; + child.icon.style_class = ''; + child.set_style_class_name(''); + child.icon.set_style('margin: 0; padding: 0;'); + child._dot.set_style('margin-bottom: 1px;'); + child.icon.setIconSize(subSize); + child._canAccept = () => false; + + bin.child = child; + + bin.connect('enter-event', () => { + bin.ease({ + duration: 100, + translation_y: -3, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + bin.connect('leave-event', () => { + bin.ease({ + duration: 100, + translation_y: 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + } + } + + layout.attach(bin, rtl ? (i + 1) % gridSize : i % gridSize, Math.floor(i / gridSize), 1, 1); + } + + return icon; + }, + + _loadApps() { + this._apps = []; + const excludedApps = this._folder.get_strv('excluded-apps'); + const appSys = Shell.AppSystem.get_default(); + const addAppId = appId => { + if (excludedApps.includes(appId)) + return; + + if (opt.APP_GRID_EXCLUDE_FAVORITES && this._appFavorites.isFavorite(appId)) + return; + + const app = appSys.lookup_app(appId); + if (!app) + return; + + if (opt.APP_GRID_EXCLUDE_RUNNING) { + const runningApps = Shell.AppSystem.get_default().get_running().map(a => a.id); + if (runningApps.includes(appId)) + return; + } + + if (!this._parentalControlsManager.shouldShowApp(app.get_app_info())) + return; + + if (this._apps.indexOf(app) !== -1) + return; + + this._apps.push(app); + }; + + const folderApps = this._folder.get_strv('apps'); + folderApps.forEach(addAppId); + + const folderCategories = this._folder.get_strv('categories'); + const appInfos = this._parentView.getAppInfos(); + appInfos.forEach(appInfo => { + let appCategories = /* AppDisplay.*/_getCategories(appInfo); + if (!_listsIntersect(folderCategories, appCategories)) + return; + + addAppId(appInfo.get_id()); + }); + + let items = []; + this._apps.forEach(app => { + let icon = this._items.get(app.get_id()); + if (!icon) + icon = new AppDisplay.AppIcon(app); + + items.push(icon); + }); + + return items; + }, + + acceptDrop(source) { + /* if (!BaseAppViewCommon.acceptDrop.bind(this)(source)) + return false;*/ + if (opt.APP_FOLDER_ORDER) + return false; + if (source._sourceItem) + source = source._sourceItem; + + if (!this._acceptDropCommon(source)) + return false; + + const folderApps = this._orderedItems.map(item => item.id); + this._folder.set_strv('apps', folderApps); + + return true; + }, +}; + +const FolderGrid = GObject.registerClass({ + // Registered name should be unique + GTypeName: `FolderGrid${Math.floor(Math.random() * 1000)}`, +}, class FolderGrid extends AppDisplay.AppGrid { + _init() { + super._init({ + allow_incomplete_pages: false, + // For adaptive size (0), set the numbers high enough to fit all the icons + // to avoid splitting the icons to pages upon creating the grid + columns_per_page: 20, + rows_per_page: 20, + page_halign: Clutter.ActorAlign.CENTER, + page_valign: Clutter.ActorAlign.CENTER, + }); + this.layoutManager._isFolder = true; + this._isFolder = true; + const spacing = opt.APP_GRID_FOLDER_SPACING; + this.set_style(`column-spacing: ${spacing}px; row-spacing: ${spacing}px;`); + this.layoutManager.fixedIconSize = opt.APP_GRID_FOLDER_ICON_SIZE; + + this.setGridModes([ + { + columns: 20, + rows: 20, + }, + ]); + } + + _updatePadding() { + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + const padding = this._indicatorsPadding.copy(); + const pageIndicatorSize = opt.ORIENTATION + ? this._view._pageIndicators.get_preferred_width(1000)[1] / scaleFactor + : this._view._pageIndicators.get_preferred_height(1000)[1] / scaleFactor; + Math.round(Math.min(...this._view._pageIndicators.get_size()));// / scaleFactor);// ~28; + padding.left = opt.ORIENTATION ? pageIndicatorSize : 0; + padding.right = 0; + padding.top = opt.ORIENTATION ? 0 : pageIndicatorSize; + padding.bottom = 0; + this.layoutManager.pagePadding = padding; + } +}); + + +const FOLDER_DIALOG_ANIMATION_TIME = 200; // AppDisplay.FOLDER_DIALOG_ANIMATION_TIME +const AppFolderDialog = { + // injection to _init() + after__init() { + // GS 46 changed the aligning to CENTER which restricts max folder dialog size + this._viewBox.set({ + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.FILL, + }); + + // delegate this dialog to the FolderIcon._view + // so its _createFolderIcon function can update the dialog if folder content changed + this._view._dialog = this; + + // right click into the folder popup should close it + this.child.reactive = true; + const clickAction = new Clutter.ClickAction(); + clickAction.connect('clicked', act => { + if (act.get_button() === Clutter.BUTTON_PRIMARY) + return Clutter.EVENT_STOP; + const [x, y] = clickAction.get_coords(); + const actor = global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y); + // if it's not entry for editing folder title + if (actor !== this._entry) + this.popdown(); + return Clutter.EVENT_STOP; + }); + + this.child.add_action(clickAction); + }, + + after__addFolderNameEntry() { + // edit-folder-button class has been replaced with icon-button class which is not transparent in 46 + this._editButton.add_style_class_name('edit-folder-button'); + + // Edit button + this._removeButton = new St.Button({ + style_class: 'icon-button edit-folder-button', + button_mask: St.ButtonMask.ONE, + toggle_mode: false, + reactive: true, + can_focus: true, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER, + child: new St.Icon({ + icon_name: 'user-trash-symbolic', + icon_size: 16, + }), + }); + + this._removeButton.connect('clicked', () => { + if (Date.now() - this._removeButton._lastClick < Clutter.Settings.get_default().double_click_time) { + // Close dialog to avoid crashes + this._isOpen = false; + this._grabHelper.ungrab({ actor: this }); + this.emit('open-state-changed', false); + this.hide(); + this._popdownCallbacks.forEach(func => func()); + this._popdownCallbacks = []; + _appDisplay.ease({ + opacity: 255, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + // Reset all keys to delete the relocatable schema + this._view._deletingFolder = true; // Upstream property + let keys = this._folder.settings_schema.list_keys(); + for (const key of keys) + this._folder.reset(key); + + let settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' }); + let folders = settings.get_strv('folder-children'); + folders.splice(folders.indexOf(this._view._id), 1); + + // remove all abandoned folders (usually my own garbage and unwanted default folders...) + /* const appFolders = _appDisplay._folderIcons.map(icon => icon._id); + folders.forEach(folder => { + if (!appFolders.includes(folder)) { + folders.splice(folders.indexOf(folder._id), 1); + } + });*/ + settings.set_strv('folder-children', folders); + + this._view._deletingFolder = false; + return; + } + this._removeButton._lastClick = Date.now(); + }); + + this._entryBox.add_child(this._removeButton); + this._entryBox.set_child_at_index(this._removeButton, 0); + + this._closeButton = new St.Button({ + style_class: 'icon-button edit-folder-button', + button_mask: St.ButtonMask.ONE, + toggle_mode: false, + reactive: true, + can_focus: true, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER, + child: new St.Icon({ + icon_name: 'window-close-symbolic', + icon_size: 16, + }), + }); + + this._closeButton.connect('clicked', () => { + this.popdown(); + }); + + this._entryBox.add_child(this._closeButton); + }, + + popup() { + if (this._isOpen) + return; + + this._isOpen = this._grabHelper.grab({ + actor: this, + focus: this._editButton, + onUngrab: () => this.popdown(), + }); + + if (!this._isOpen) + return; + + this.get_parent().set_child_above_sibling(this, null); + + // _zoomAndFadeIn() is called from the dialog's allocate() + this._needsZoomAndFade = true; + + this.show(); + // force update folder size + this._folderAreaBox = null; + this._updateFolderSize(); + + this.emit('open-state-changed', true); + }, + + _setupPopdownTimeout() { + if (this._popdownTimeoutId > 0) + return; + + // This timeout is handled in the original code and removed in _onDestroy() + // All dialogs are destroyed on extension disable() + this._popdownTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + this._popdownTimeoutId = 0; + // Following line fixes upstream bug + // https://gitlab.gnome.org/GNOME/gnome-shell/-/issues/6164 + this._view._onDragEnd(); + this.popdown(); + return GLib.SOURCE_REMOVE; + }); + }, + + vfunc_allocate(box) { + this._updateFolderSize(); + + // super.allocate(box) + St.Bin.prototype.vfunc_allocate.bind(this)(box); + + // Override any attempt to resize the folder dialog, that happens when some child gets wild + // Re-allocate the child only if necessary, because it terminates grid animations + if (this._width && this._height && (this._width !== this.child.width || this._height !== this.child.height)) + this._allocateChild(); + + // We can only start zooming after receiving an allocation + if (this._needsZoomAndFade) + this._zoomAndFadeIn(); + }, + + _allocateChild() { + const childBox = new Clutter.ActorBox(); + childBox.set_size(this._width, this._height); + this.child.allocate(childBox); + }, + + // Note that the appDisplay may be off-screen so its coordinates may be shifted + // However, for _updateFolderSize() it doesn't matter + // and when _zoomAndFadeIn() is called, appDisplay is on the right place + _getFolderAreaBox() { + const appDisplay = this._source._parentView; + const folderAreaBox = appDisplay.get_allocation_box().copy(); + const searchEntryHeight = opt.SHOW_SEARCH_ENTRY ? Main.overview._overview.controls._searchEntryBin.height : 0; + folderAreaBox.y1 -= searchEntryHeight; + + // _zoomAndFadeIn() needs an absolute position within a multi-monitor workspace + const monitorGeometry = global.display.get_monitor_geometry(global.display.get_primary_monitor()); + folderAreaBox.x1 += monitorGeometry.x; + folderAreaBox.x2 += monitorGeometry.x; + folderAreaBox.y1 += monitorGeometry.y; + folderAreaBox.y2 += monitorGeometry.y; + + return folderAreaBox; + }, + + _updateFolderSize() { + const view = this._view; + const nItems = view._orderedItems.length; + const [firstItem] = view._grid.layoutManager._container; + if (!firstItem) + return; + + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + const margin = 18; // see stylesheet .app-folder-dialog-container; + + const folderAreaBox = this._getFolderAreaBox(); + + const maxDialogWidth = folderAreaBox.get_width() / scaleFactor; + const maxDialogHeight = folderAreaBox.get_height() / scaleFactor; + + // We can't build folder if the available space is not available + if (!isFinite(maxDialogWidth) || !isFinite(maxDialogHeight) || !maxDialogWidth || !maxDialogHeight) + return; + + // We don't need to recalculate grid if nothing changed + if ( + this._folderAreaBox?.get_width() === folderAreaBox.get_width() && + this._folderAreaBox?.get_height() === folderAreaBox.get_height() && + nItems === this._nItems + ) + return; + + const layoutManager = view._grid.layoutManager; + const spacing = opt.APP_GRID_FOLDER_SPACING; + const padding = 40; + + const titleBoxHeight = + Math.round(this._entryBox.get_preferred_height(-1)[1] / scaleFactor); // ~75 + const minDialogWidth = Math.max(640, + Math.round(this._entryBox.get_preferred_width(-1)[1] / scaleFactor + 2 * margin)); + const navigationArrowsSize = // padding + one arrow width is sufficient for both arrows + Math.round(view._nextPageArrow.get_preferred_width(-1)[1] / scaleFactor); + const pageIndicatorSize = + Math.round(Math.min(...view._pageIndicators.get_size()) / scaleFactor);// ~28; + const horizontalNavigation = opt.ORIENTATION ? pageIndicatorSize : navigationArrowsSize; // either add padding or arrows + const verticalNavigation = opt.ORIENTATION ? navigationArrowsSize : pageIndicatorSize; + + // Horizontal size + const baseWidth = horizontalNavigation + 3 * padding + 2 * margin; + const maxGridPageWidth = maxDialogWidth - baseWidth; + // Vertical size + const baseHeight = titleBoxHeight + verticalNavigation + 2 * padding + 2 * margin; + const maxGridPageHeight = maxDialogHeight - baseHeight; + + // Will be updated to the actual value later + let itemPadding = 55; + const minItemSize = 48 + itemPadding; + + let columns = opt.APP_GRID_FOLDER_COLUMNS; + let rows = opt.APP_GRID_FOLDER_ROWS; + const maxColumns = columns ? columns : 100; + const maxRows = rows ? rows : 100; + + // Find best icon size + let iconSize = opt.APP_GRID_FOLDER_ICON_SIZE < 0 ? opt.APP_GRID_FOLDER_ICON_SIZE_DEFAULT : opt.APP_GRID_FOLDER_ICON_SIZE; + if (opt.APP_GRID_FOLDER_ICON_SIZE === -1) { + let maxIconSize; + if (columns) { + const maxItemWidth = (maxGridPageWidth - (columns - 1) * opt.APP_GRID_FOLDER_SPACING) / columns; + maxIconSize = maxItemWidth - itemPadding; + } + if (rows) { + const maxItemHeight = (maxGridPageHeight - (rows - 1) * spacing) / rows; + maxIconSize = Math.min(maxItemHeight - itemPadding, maxIconSize); + } + + if (maxIconSize) { + // We only need sizes from the default to the smallest + let iconSizes = Object.values(IconSize).sort((a, b) => b - a); + iconSizes = iconSizes.slice(iconSizes.indexOf(iconSize)); + for (const size of iconSizes) { + iconSize = size; + if (iconSize <= maxIconSize) + break; + } + } + } + + if ((!columns && !rows) || opt.APP_GRID_FOLDER_ICON_SIZE !== -1) { + columns = Math.ceil(Math.sqrt(nItems)); + rows = columns; + if (columns * (columns - 1) >= nItems) { + rows = columns - 1; + } else if ((columns + 1) * (columns - 1) >= nItems) { + rows = columns - 1; + columns += 1; + } + } else if (columns && !rows) { + rows = Math.ceil(nItems / columns); + } else if (rows && !columns) { + columns = Math.ceil(nItems / rows); + } + + columns = Math.clamp(columns, 1, maxColumns); + columns = Math.min(nItems, columns); + rows = Math.clamp(rows, 1, maxRows); + + let itemSize = iconSize + itemPadding; + // First run sets the grid before we can read the real icon size + // so we estimate the size from default properties + // and correct it in the second run + if (this.realized) { + firstItem.icon.setIconSize(iconSize); + // Item height is inconsistent because it depends on its label height + const [, firstItemWidth] = firstItem.get_preferred_width(-1); + const realSize = firstItemWidth / scaleFactor; + itemSize = realSize; + itemPadding = realSize - iconSize; + } + + const gridWidth = columns * (itemSize + spacing); + let width = gridWidth + baseWidth; + const gridHeight = rows * (itemSize + spacing); + let height = gridHeight + baseHeight; + + // Folder must fit the appDisplay area plus searchEntryBin if visible + // reduce columns/rows if needed + while (height > maxDialogHeight && rows > 1) { + height -= itemSize + spacing; + rows -= 1; + } + + while (width > maxDialogWidth && columns > 1) { + width -= itemSize + spacing; + columns -= 1; + } + + // Try to compensate for the previous reduction if there is a space + while ((nItems > columns * rows) && ((width + (itemSize + spacing)) <= maxDialogWidth) && (columns < maxColumns)) { + width += itemSize + spacing; + columns += 1; + } + + // remove columns that cannot be displayed + if (((columns * minItemSize + (columns - 1) * spacing)) > maxDialogWidth) + columns = Math.floor(maxDialogWidth / (minItemSize + spacing)); + + while ((nItems > columns * rows) && ((height + (itemSize + spacing)) <= maxDialogHeight) && (rows < maxRows)) { + height += itemSize + spacing; + rows += 1; + } + // remove rows that cannot be displayed + if ((((rows * minItemSize + (rows - 1) * spacing))) > maxDialogHeight) + rows = Math.floor(maxDialogWidth / (minItemSize + spacing)); + + // remove size for rows that are empty + const rowsNeeded = Math.ceil(nItems / columns); + if (rows > rowsNeeded) { + height -= (rows - rowsNeeded) * (itemSize + spacing); + rows -= rows - rowsNeeded; + } + + // Remove space reserved for page controls and indicator if not used + if (rows * columns >= nItems) { + width -= horizontalNavigation; + height -= verticalNavigation; + } + + width = Math.clamp(width, minDialogWidth, maxDialogWidth); + height = Math.min(height, maxDialogHeight); + + layoutManager.columns_per_page = columns; + layoutManager.rows_per_page = rows; + + layoutManager.fixedIconSize = iconSize; + + + // Store data for further use + this._width = width * scaleFactor; + this._height = height * scaleFactor; + this._folderAreaBox = folderAreaBox; + this._nItems = nItems; + + // Set fixed dialog size to prevent size instability + this.child.set_size(this._width, this._height); + this._viewBox.set_style(`width: ${this._width - 2 * margin}px; height: ${this._height - 2 * margin}px;`); + this._viewBox.set_size(this._width - 2 * margin, this._height - 2 * margin); + + view._redisplay(); + }, + + _zoomAndFadeIn() { + let [sourceX, sourceY] = + this._source.get_transformed_position(); + let [dialogX, dialogY] = + this.child.get_transformed_position(); + + const sourceCenterX = sourceX + this._source.width / 2; + const sourceCenterY = sourceY + this._source.height / 2; + + // this. covers the whole screen + let dialogTargetX = dialogX; + let dialogTargetY = dialogY; + + const appDisplay = this._source._parentView; + + const folderAreaBox = this._getFolderAreaBox(); + + let folderAreaX = folderAreaBox.x1; + let folderAreaY = folderAreaBox.y1; + const folderAreaWidth = folderAreaBox.get_width(); + const folderAreaHeight = folderAreaBox.get_height(); + const folder = this.child; + + if (opt.APP_GRID_FOLDER_CENTER) { + dialogTargetX = folderAreaX + folderAreaWidth / 2 - folder.width / 2; + dialogTargetY = folderAreaY + (folderAreaHeight / 2 - folder.height / 2) / 2; + } else { + const { pagePadding } = appDisplay._grid.layoutManager; + const hPadding = (pagePadding.left + pagePadding.right) / 2; + const vPadding = (pagePadding.top + pagePadding.bottom) / 2; + const minX = Math.min(folderAreaX + hPadding, folderAreaX + (folderAreaWidth - folder.width) / 2); + const maxX = Math.max(folderAreaX + folderAreaWidth - hPadding - folder.width, folderAreaX + folderAreaWidth / 2 - folder.width / 2); + const minY = Math.min(folderAreaY + vPadding, folderAreaY + (folderAreaHeight - folder.height) / 2); + const maxY = Math.max(folderAreaY + folderAreaHeight - vPadding - folder.height, folderAreaY + folderAreaHeight / 2 - folder.height / 2); + + dialogTargetX = sourceCenterX - folder.width / 2; + dialogTargetX = Math.clamp(dialogTargetX, minX, maxX); + dialogTargetY = sourceCenterY - folder.height / 2; + dialogTargetY = Math.clamp(dialogTargetY, minY, maxY); + + // keep the dialog in the appDisplay area + dialogTargetX = Math.clamp( + dialogTargetX, + folderAreaX, + folderAreaX + folderAreaWidth - folder.width + ); + + dialogTargetY = Math.clamp( + dialogTargetY, + folderAreaY, + folderAreaY + folderAreaHeight - folder.height + ); + } + + const dialogOffsetX = Math.round(dialogTargetX - dialogX); + const dialogOffsetY = Math.round(dialogTargetY - dialogY); + + this.child.set({ + translation_x: sourceX - dialogX, + translation_y: sourceY - dialogY, + scale_x: this._source.width / this.child.width, + scale_y: this._source.height / this.child.height, + opacity: 0, + }); + + this.child.ease({ + translation_x: dialogOffsetX, + translation_y: dialogOffsetY, + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + appDisplay.ease({ + opacity: 0, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + if (opt.SHOW_SEARCH_ENTRY) { + Main.overview.searchEntry.ease({ + opacity: 0, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + this._needsZoomAndFade = false; + + if (this._sourceMappedId === 0) { + this._sourceMappedId = this._source.connect( + 'notify::mapped', this._zoomAndFadeOut.bind(this)); + } + }, + + _zoomAndFadeOut() { + if (!this._isOpen) + return; + + if (!this._source.mapped) { + this.hide(); + return; + } + + // if the dialog was shown silently, skip animation + if (this.scale_y < 1) { + this._needsZoomAndFade = false; + this.hide(); + this._popdownCallbacks.forEach(func => func()); + this._popdownCallbacks = []; + return; + } + + let [sourceX, sourceY] = + this._source.get_transformed_position(); + let [dialogX, dialogY] = + this.child.get_transformed_position(); + + this.child.ease({ + translation_x: sourceX - dialogX + this.child.translation_x, + translation_y: sourceY - dialogY + this.child.translation_y, + scale_x: this._source.width / this.child.width, + scale_y: this._source.height / this.child.height, + opacity: 0, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + onComplete: () => { + this.child.set({ + translation_x: 0, + translation_y: 0, + scale_x: 1, + scale_y: 1, + opacity: 255, + }); + this.hide(); + + this._popdownCallbacks.forEach(func => func()); + this._popdownCallbacks = []; + }, + }); + + const appDisplay = this._source._parentView; + appDisplay.ease({ + opacity: 255, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + + if (opt.SHOW_SEARCH_ENTRY) { + Main.overview.searchEntry.ease({ + opacity: 255, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + } + + this._needsZoomAndFade = false; + }, + + _setLighterBackground(lighter) { + let opacity = 255; + if (this._isOpen) + opacity = lighter ? 20 : 0; + + _appDisplay.ease({ + opacity, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }, + + vfunc_key_press_event(event) { + if (global.focus_manager.navigate_from_event(event)) + return Clutter.EVENT_STOP; + return Clutter.EVENT_PROPAGATE; + }, + + _showFolderLabel() { + if (this._editButton.checked) + this._editButton.checked = false; + + this._maybeUpdateFolderName(); + this._switchActor(this._entry, this._folderNameLabel); + // This line has been added in 47 to fix focus after editing the folder name + this.navigate_focus(this, St.DirectionType.TAB_FORWARD, false); + }, +}; + +const AppIcon = { + after__init() { + // update the app label behavior + this._updateMultiline(); + }, + + // avoid accepting by placeholder when dragging active preview + // and also by icon if usage sorting is used + _canAccept(source) { + if (source._sourceItem) + source = source._sourceItem; + + // Folders in folder are not supported + if (!(_getViewFromIcon(this) instanceof AppDisplay.AppDisplay) || !this.opacity) + return false; + + const view = /* AppDisplay.*/_getViewFromIcon(source); + return source !== this && + (source instanceof this.constructor) && + // Include drops from folders + // (view instanceof AppDisplay.AppDisplay && + (view && + !opt.APP_GRID_USAGE); + }, +}; + +const AppViewItemCommon = { + _updateMultiline() { + const { label } = this.icon; + if (label) + label.opacity = 255; + if (!this._expandTitleOnHover || !this.icon.label) + return; + + const { clutterText } = label; + + const isHighlighted = this.has_key_focus() || this.hover || this._forcedHighlight; + + if (opt.APP_GRID_NAMES_MODE === 2 && this._expandTitleOnHover) { // !_expandTitleOnHover indicates search result icon + label.opacity = isHighlighted || !this.app ? 255 : 0; + } + if (isHighlighted) + this.get_parent()?.set_child_above_sibling(this, null); + + if (!opt.APP_GRID_NAMES_MODE) { + const layout = clutterText.get_layout(); + if (!layout.is_wrapped() && !layout.is_ellipsized()) + return; + } + + label.remove_transition('allocation'); + + const id = label.connect('notify::allocation', () => { + label.restore_easing_state(); + label.disconnect(id); + }); + + const expand = opt.APP_GRID_NAMES_MODE === 1 || this._forcedHighlight || this.hover || this.has_key_focus(); + + label.save_easing_state(); + label.set_easing_duration(expand + ? APP_ICON_TITLE_EXPAND_TIME + : APP_ICON_TITLE_COLLAPSE_TIME); + clutterText.set({ + line_wrap: expand, + line_wrap_mode: expand ? Pango.WrapMode.WORD_CHAR : Pango.WrapMode.NONE, + ellipsize: expand ? Pango.EllipsizeMode.NONE : Pango.EllipsizeMode.END, + }); + }, + + // support active preview icons + acceptDrop(source, _actor, x) { + if (opt.APP_GRID_USAGE) + return DND.DragMotionResult.NO_DROP; + + this._setHoveringByDnd(false); + + if (!this._canAccept(source)) + return false; + + if (this._withinLeeways(x)) + return false; + + // added - remove app from the source folder after dnd to other folder + let view = /* AppDisplay.*/_getViewFromIcon(source); + if (view instanceof AppDisplay.FolderView) + view.removeApp(source.app); + + return true; + }, + +}; + +const PageIndicatorsCommon = { + after_setNPages() { + this.visible = true; + this.opacity = this._nPages > 1 ? 255 : 0; + }, +}; |