summaryrefslogtreecommitdiffstats
path: root/js/ui/appDisplay.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/ui/appDisplay.js')
-rw-r--r--js/ui/appDisplay.js2998
1 files changed, 2998 insertions, 0 deletions
diff --git a/js/ui/appDisplay.js b/js/ui/appDisplay.js
new file mode 100644
index 0000000..399ab54
--- /dev/null
+++ b/js/ui/appDisplay.js
@@ -0,0 +1,2998 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported AppDisplay, AppSearchProvider */
+
+const { Clutter, Gio, GLib, GObject, Graphene, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const AppFavorites = imports.ui.appFavorites;
+const DND = imports.ui.dnd;
+const GrabHelper = imports.ui.grabHelper;
+const IconGrid = imports.ui.iconGrid;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const PageIndicators = imports.ui.pageIndicators;
+const ParentalControlsManager = imports.misc.parentalControlsManager;
+const PopupMenu = imports.ui.popupMenu;
+const Search = imports.ui.search;
+const SwipeTracker = imports.ui.swipeTracker;
+const Params = imports.misc.params;
+const SystemActions = imports.misc.systemActions;
+
+var MENU_POPUP_TIMEOUT = 600;
+var POPDOWN_DIALOG_TIMEOUT = 500;
+
+var FOLDER_SUBICON_FRACTION = .4;
+
+var VIEWS_SWITCH_TIME = 400;
+var VIEWS_SWITCH_ANIMATION_DELAY = 100;
+
+var SCROLL_TIMEOUT_TIME = 150;
+
+var APP_ICON_SCALE_IN_TIME = 500;
+var APP_ICON_SCALE_IN_DELAY = 700;
+
+const FOLDER_DIALOG_ANIMATION_TIME = 200;
+
+const OVERSHOOT_THRESHOLD = 20;
+const OVERSHOOT_TIMEOUT = 1000;
+
+const DELAYED_MOVE_TIMEOUT = 200;
+
+const DIALOG_SHADE_NORMAL = Clutter.Color.from_pixel(0x000000cc);
+const DIALOG_SHADE_HIGHLIGHT = Clutter.Color.from_pixel(0x00000055);
+
+let discreteGpuAvailable = false;
+
+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;
+}
+
+function _getFolderName(folder) {
+ let name = folder.get_string('name');
+
+ if (folder.get_boolean('translate')) {
+ let translated = Shell.util_get_translated_folder_name(name);
+ if (translated !== null)
+ return translated;
+ }
+
+ return name;
+}
+
+function _getViewFromIcon(icon) {
+ for (let parent = icon.get_parent(); parent; parent = parent.get_parent()) {
+ if (parent instanceof BaseAppView)
+ return parent;
+ }
+ return null;
+}
+
+function _findBestFolderName(apps) {
+ let appInfos = apps.map(app => app.get_app_info());
+
+ let categoryCounter = {};
+ let commonCategories = [];
+
+ appInfos.reduce((categories, appInfo) => {
+ for (let category of _getCategories(appInfo)) {
+ if (!(category in categoryCounter))
+ categoryCounter[category] = 0;
+
+ categoryCounter[category] += 1;
+
+ // If a category is present in all apps, its counter will
+ // reach appInfos.length
+ if (category.length > 0 &&
+ categoryCounter[category] == appInfos.length)
+ categories.push(category);
+ }
+ return categories;
+ }, commonCategories);
+
+ for (let category of commonCategories) {
+ const directory = '%s.directory'.format(category);
+ const translated = Shell.util_get_translated_folder_name(directory);
+ if (translated !== null)
+ return translated;
+ }
+
+ return null;
+}
+
+var BaseAppView = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'use-pagination': GObject.ParamSpec.boolean(
+ 'use-pagination', 'use-pagination', 'use-pagination',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ false),
+ },
+ Signals: {
+ 'view-loaded': {},
+ },
+}, class BaseAppView extends St.Widget {
+ _init(params = {}, orientation = Clutter.Orientation.VERTICAL) {
+ super._init(params);
+
+ this._grid = this._createGrid();
+ this._grid._delegate = this;
+ // Standard hack for ClutterBinLayout
+ this._grid.x_expand = true;
+ this._grid.connect('pages-changed', () => {
+ this._adjustment.value = 0;
+ this.goToPage(this._grid.currentPage);
+ this._pageIndicators.setNPages(this._grid.nPages);
+ this._pageIndicators.setCurrentPosition(this._grid.currentPage);
+ });
+
+ const vertical = orientation === Clutter.Orientation.VERTICAL;
+
+ // Scroll View
+ this._scrollView = new St.ScrollView({
+ clip_to_allocation: true,
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ });
+ this._scrollView.set_policy(
+ vertical ? St.PolicyType.NEVER : St.PolicyType.EXTERNAL,
+ vertical ? St.PolicyType.EXTERNAL : St.PolicyType.NEVER);
+
+ this._canScroll = true; // limiting scrolling speed
+ this._scrollTimeoutId = 0;
+ this._scrollView.connect('scroll-event', this._onScroll.bind(this));
+
+ this._scrollView.add_actor(this._grid);
+
+ const scroll = vertical ? this._scrollView.vscroll : this._scrollView.hscroll;
+ this._adjustment = scroll.adjustment;
+ this._adjustment.connect('notify::value', adj => {
+ this._pageIndicators.setCurrentPosition(adj.value / adj.page_size);
+ });
+
+ // Page Indicators
+ if (vertical)
+ this._pageIndicators = new PageIndicators.AnimatedPageIndicators();
+ else
+ this._pageIndicators = new PageIndicators.PageIndicators(orientation);
+
+ this._pageIndicators.y_expand = vertical;
+ this._pageIndicators.connect('page-activated',
+ (indicators, pageIndex) => {
+ this.goToPage(pageIndex);
+ });
+ this._pageIndicators.connect('scroll-event', (actor, event) => {
+ this._scrollView.event(event, false);
+ });
+
+ // Swipe
+ this._swipeTracker = new SwipeTracker.SwipeTracker(this._scrollView,
+ Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP);
+ this._swipeTracker.orientation = orientation;
+ this._swipeTracker.connect('begin', this._swipeBegin.bind(this));
+ this._swipeTracker.connect('update', this._swipeUpdate.bind(this));
+ this._swipeTracker.connect('end', this._swipeEnd.bind(this));
+
+ this._availWidth = 0;
+ this._availHeight = 0;
+ this._orientation = orientation;
+
+ this._items = new Map();
+ this._orderedItems = [];
+
+ this._animateLaterId = 0;
+ this._viewLoadedHandlerId = 0;
+ this._viewIsReady = false;
+
+ // Filter the apps through the user’s parental controls.
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ this._appFilterChangedId =
+ this._parentalControlsManager.connect('app-filter-changed', () => {
+ this._redisplay();
+ });
+
+ // Drag n' Drop
+ this._lastOvershoot = -1;
+ this._lastOvershootTimeoutId = 0;
+ this._delayedMoveData = null;
+
+ this._dragBeginId = 0;
+ this._dragEndId = 0;
+ this._dragCancelledId = 0;
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._appFilterChangedId > 0) {
+ this._parentalControlsManager.disconnect(this._appFilterChangedId);
+ this._appFilterChangedId = 0;
+ }
+
+ if (this._swipeTracker) {
+ this._swipeTracker.destroy();
+ delete this._swipeTracker;
+ }
+
+ this._removeDelayedMove();
+ this._disconnectDnD();
+ }
+
+ _createGrid() {
+ return new IconGrid.IconGrid({ allow_incomplete_pages: true });
+ }
+
+ _onScroll(actor, event) {
+ if (this._swipeTracker.canHandleScrollEvent(event))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this._canScroll)
+ return Clutter.EVENT_STOP;
+
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+ const vertical = this._orientation === Clutter.Orientation.VERTICAL;
+
+ let nextPage = this._grid.currentPage;
+ switch (event.get_scroll_direction()) {
+ case Clutter.ScrollDirection.UP:
+ nextPage -= 1;
+ break;
+
+ case Clutter.ScrollDirection.DOWN:
+ nextPage += 1;
+ break;
+
+ case Clutter.ScrollDirection.LEFT:
+ if (vertical)
+ return Clutter.EVENT_STOP;
+ nextPage += rtl ? 1 : -1;
+ break;
+
+ case Clutter.ScrollDirection.RIGHT:
+ if (vertical)
+ return Clutter.EVENT_STOP;
+ nextPage += rtl ? -1 : 1;
+ break;
+
+ default:
+ return Clutter.EVENT_STOP;
+ }
+
+ this.goToPage(nextPage);
+
+ this._canScroll = false;
+ this._scrollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ SCROLL_TIMEOUT_TIME, () => {
+ this._canScroll = true;
+ this._scrollTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _swipeBegin(tracker, monitor) {
+ if (monitor !== Main.layoutManager.primaryIndex)
+ return;
+
+ const adjustment = this._adjustment;
+ adjustment.remove_transition('value');
+
+ const progress = adjustment.value / adjustment.page_size;
+ const points = Array.from({ length: this._grid.nPages }, (v, i) => i);
+ const size = tracker.orientation === Clutter.Orientation.VERTICAL
+ ? this._scrollView.height : this._scrollView.width;
+
+ tracker.confirmSwipe(size, points, progress, Math.round(progress));
+ }
+
+ _swipeUpdate(tracker, progress) {
+ const adjustment = this._adjustment;
+ adjustment.value = progress * adjustment.page_size;
+ }
+
+ _swipeEnd(tracker, duration, endProgress) {
+ const adjustment = this._adjustment;
+ const value = endProgress * adjustment.page_size;
+
+ adjustment.ease(value, {
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ duration,
+ onComplete: () => this.goToPage(endProgress, false),
+ });
+ }
+
+ _connectDnD() {
+ this._dragBeginId =
+ Main.overview.connect('item-drag-begin', this._onDragBegin.bind(this));
+ this._dragEndId =
+ Main.overview.connect('item-drag-end', this._onDragEnd.bind(this));
+ this._dragCancelledId =
+ Main.overview.connect('item-drag-cancelled', this._onDragCancelled.bind(this));
+ }
+
+ _disconnectDnD() {
+ if (this._dragBeginId > 0) {
+ Main.overview.disconnect(this._dragBeginId);
+ this._dragBeginId = 0;
+ }
+
+ if (this._dragEndId > 0) {
+ Main.overview.disconnect(this._dragEndId);
+ this._dragEndId = 0;
+ }
+
+ if (this._dragCancelledId > 0) {
+ Main.overview.disconnect(this._dragCancelledId);
+ this._dragCancelledId = 0;
+ }
+
+ if (this._dragMonitor) {
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+ }
+
+ _maybeMoveItem(dragEvent) {
+ const [success, x, y] =
+ this._grid.transform_stage_point(dragEvent.x, dragEvent.y);
+
+ if (!success)
+ return;
+
+ const { source } = dragEvent;
+ const [page, position, dragLocation] =
+ this._getDropTarget(x, y, source);
+ const item = position !== -1
+ ? this._grid.getItemAt(page, position) : null;
+
+
+ // Dragging over invalid parts of the grid cancels the timeout
+ if (item === source ||
+ dragLocation === IconGrid.DragLocation.INVALID ||
+ dragLocation === IconGrid.DragLocation.ON_ICON) {
+ this._removeDelayedMove();
+ return;
+ }
+
+ if (!this._delayedMoveData ||
+ this._delayedMoveData.page !== page ||
+ this._delayedMoveData.position !== position) {
+ // Update the item with a small delay
+ this._removeDelayedMove();
+ this._delayedMoveData = {
+ page,
+ position,
+ source,
+ destroyId: source.connect('destroy', () => this._removeDelayedMove()),
+ timeoutId: GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ DELAYED_MOVE_TIMEOUT, () => {
+ this._moveItem(source, page, position);
+ this._delayedMoveData.timeoutId = 0;
+ this._removeDelayedMove();
+ return GLib.SOURCE_REMOVE;
+ }),
+ };
+ }
+ }
+
+ _removeDelayedMove() {
+ if (!this._delayedMoveData)
+ return;
+
+ const { source, destroyId, timeoutId } = this._delayedMoveData;
+
+ if (timeoutId > 0)
+ GLib.source_remove(timeoutId);
+
+ if (destroyId > 0)
+ source.disconnect(destroyId);
+
+ this._delayedMoveData = null;
+ }
+
+ _resetOvershoot() {
+ if (this._lastOvershootTimeoutId)
+ GLib.source_remove(this._lastOvershootTimeoutId);
+ this._lastOvershootTimeoutId = 0;
+ this._lastOvershoot = -1;
+ }
+
+ _handleDragOvershoot(dragEvent) {
+ const [gridX, gridY] = this.get_transformed_position();
+ const [gridWidth, gridHeight] = this.get_transformed_size();
+
+ const vertical = this._orientation === Clutter.Orientation.VERTICAL;
+ const gridStart = vertical ? gridY : gridX;
+ const gridEnd = vertical
+ ? gridY + gridHeight - OVERSHOOT_THRESHOLD
+ : gridX + gridWidth - OVERSHOOT_THRESHOLD;
+
+ // Already animating
+ if (this._adjustment.get_transition('value') !== null)
+ return;
+
+ // Within the grid boundaries
+ const dragPosition = vertical ? dragEvent.y : dragEvent.x;
+ if (dragPosition > gridStart && dragPosition < gridEnd) {
+ // Check whether we moved out the area of the last switch
+ if (Math.abs(this._lastOvershoot - dragPosition) > OVERSHOOT_THRESHOLD)
+ this._resetOvershoot();
+
+ return;
+ }
+
+ // Still in the area of the previous page switch
+ if (this._lastOvershoot >= 0)
+ return;
+
+ const currentPosition = this._adjustment.value;
+ const maxPosition = this._adjustment.upper - this._adjustment.page_size;
+
+ if (dragPosition <= gridStart && currentPosition > 0)
+ this.goToPage(this._grid.currentPage - 1);
+ else if (dragPosition >= gridEnd && currentPosition < maxPosition)
+ this.goToPage(this._grid.currentPage + 1);
+ else
+ return; // don't go beyond first/last page
+
+ this._lastOvershoot = dragPosition;
+
+ if (this._lastOvershootTimeoutId > 0)
+ GLib.source_remove(this._lastOvershootTimeoutId);
+
+ this._lastOvershootTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, OVERSHOOT_TIMEOUT, () => {
+ this._resetOvershoot();
+ this._handleDragOvershoot(dragEvent);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._lastOvershootTimeoutId,
+ '[gnome-shell] this._lastOvershootTimeoutId');
+ }
+
+ _onDragBegin() {
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ }
+
+ _onDragMotion(dragEvent) {
+ if (!(dragEvent.source instanceof AppViewItem))
+ return DND.DragMotionResult.CONTINUE;
+
+ const appIcon = dragEvent.source;
+
+ // Handle the drag overshoot. When dragging to above the
+ // icon grid, move to the page above; when dragging below,
+ // move to the page below.
+ if (appIcon instanceof AppViewItem)
+ this._handleDragOvershoot(dragEvent);
+
+ this._maybeMoveItem(dragEvent);
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _onDragEnd() {
+ if (this._dragMonitor) {
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+
+ this._resetOvershoot();
+ }
+
+ _onDragCancelled() {
+ // At this point, the positions aren't stored yet, thus _redisplay()
+ // will move all items to their original positions
+ this._redisplay();
+ }
+
+ _canAccept(source) {
+ return source instanceof AppViewItem;
+ }
+
+ handleDragOver(source) {
+ if (!this._canAccept(source))
+ return DND.DragMotionResult.NO_DROP;
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source) {
+ if (!this._canAccept(source))
+ return false;
+
+ // Dropped before the icon was moved
+ if (this._delayedMoveData) {
+ const { page, position } = this._delayedMoveData;
+
+ this._moveItem(source, page, position);
+ this._removeDelayedMove();
+ }
+
+ return true;
+ }
+
+ _findBestPageToAppend(startPage = 1) {
+ for (let i = startPage; i < this._grid.nPages; i++) {
+ const pageItems =
+ this._grid.getItemsAtPage(i).filter(c => c.visible);
+
+ if (pageItems.length < this._grid.itemsPerPage)
+ return i;
+ }
+
+ return -1;
+ }
+
+ _getLinearPosition(page, position) {
+ let itemIndex = 0;
+
+ if (this._grid.nPages > 0) {
+ const realPage = page === -1 ? this._grid.nPages - 1 : page;
+
+ itemIndex = position === -1
+ ? this._grid.getItemsAtPage(realPage).filter(c => c.visible).length - 1
+ : position;
+
+ for (let i = 0; i < realPage; i++) {
+ const pageItems = this._grid.getItemsAtPage(i).filter(c => c.visible);
+ itemIndex += pageItems.length;
+ }
+ }
+
+ return itemIndex;
+ }
+
+ _addItem(item, page, position) {
+ // Append icons to the first page with empty slot, starting from
+ // the second page
+ if (this._grid.nPages > 1 && page === -1 && position === -1)
+ page = this._findBestPageToAppend();
+
+ const itemIndex = this._getLinearPosition(page, position);
+
+ this._orderedItems.splice(itemIndex, 0, item);
+ this._items.set(item.id, item);
+ this._grid.addItem(item, page, position);
+ }
+
+ _removeItem(item) {
+ const iconIndex = this._orderedItems.indexOf(item);
+
+ this._orderedItems.splice(iconIndex, 1);
+ this._items.delete(item.id);
+ this._grid.removeItem(item);
+ }
+
+ _getItemPosition(item) {
+ const { itemsPerPage } = this._grid;
+
+ let iconIndex = this._orderedItems.indexOf(item);
+ if (iconIndex === -1)
+ iconIndex = this._orderedItems.length - 1;
+
+ const page = Math.floor(iconIndex / itemsPerPage);
+ const position = iconIndex % itemsPerPage;
+
+ return [page, position];
+ }
+
+ _redisplay() {
+ let oldApps = this._orderedItems.slice();
+ let oldAppIds = oldApps.map(icon => icon.id);
+
+ let newApps = this._loadApps().sort(this._compareItems.bind(this));
+ let newAppIds = newApps.map(icon => icon.id);
+
+ let addedApps = newApps.filter(icon => !oldAppIds.includes(icon.id));
+ let removedApps = oldApps.filter(icon => !newAppIds.includes(icon.id));
+
+ // Remove old app icons
+ removedApps.forEach(icon => {
+ this._removeItem(icon);
+ icon.destroy();
+ });
+
+ // Add new app icons, or move existing ones
+ newApps.forEach(icon => {
+ const [page, position] = this._getItemPosition(icon);
+ if (addedApps.includes(icon))
+ this._addItem(icon, page, position);
+ else if (page !== -1 && position !== -1)
+ this._moveItem(icon, page, position);
+ });
+
+ this._viewIsReady = true;
+ this.emit('view-loaded');
+ }
+
+ getAllItems() {
+ return this._orderedItems;
+ }
+
+ _compareItems(a, b) {
+ return a.name.localeCompare(b.name);
+ }
+
+ _selectAppInternal(id) {
+ if (this._items.has(id))
+ this._items.get(id).navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ else
+ log('No such application %s'.format(id));
+ }
+
+ selectApp(id) {
+ if (this._items.has(id)) {
+ let item = this._items.get(id);
+
+ if (item.mapped) {
+ this._selectAppInternal(id);
+ } else {
+ // Need to wait until the view is mapped
+ let signalId = item.connect('notify::mapped', actor => {
+ if (actor.mapped) {
+ actor.disconnect(signalId);
+ this._selectAppInternal(id);
+ }
+ });
+ }
+ } else {
+ // Need to wait until the view is built
+ let signalId = this.connect('view-loaded', () => {
+ this.disconnect(signalId);
+ this.selectApp(id);
+ });
+ }
+ }
+
+ _doSpringAnimation(animationDirection) {
+ this._grid.opacity = 255;
+ this._grid.animateSpring(
+ animationDirection,
+ Main.overview.dash.showAppsButton);
+ }
+
+ _clearAnimateLater() {
+ if (this._animateLaterId) {
+ Meta.later_remove(this._animateLaterId);
+ this._animateLaterId = 0;
+ }
+ if (this._viewLoadedHandlerId) {
+ this.disconnect(this._viewLoadedHandlerId);
+ this._viewLoadedHandlerId = 0;
+ }
+ }
+
+ animate(animationDirection, onComplete) {
+ if (onComplete) {
+ let animationDoneId = this._grid.connect('animation-done', () => {
+ this._grid.disconnect(animationDoneId);
+ onComplete();
+ });
+ }
+
+ this._clearAnimateLater();
+ this._grid.opacity = 255;
+
+ if (animationDirection == IconGrid.AnimationDirection.IN) {
+ const doSpringAnimationLater = laterType => {
+ this._animateLaterId = Meta.later_add(laterType,
+ () => {
+ this._animateLaterId = 0;
+ this._doSpringAnimation(animationDirection);
+ return GLib.SOURCE_REMOVE;
+ });
+ };
+
+ if (this._viewIsReady) {
+ this._grid.opacity = 0;
+ doSpringAnimationLater(Meta.LaterType.IDLE);
+ } else {
+ this._viewLoadedHandlerId = this.connect('view-loaded',
+ () => {
+ this._clearAnimateLater();
+ this._grid.opacity = 255;
+ doSpringAnimationLater(Meta.LaterType.BEFORE_REDRAW);
+ });
+ }
+ } else {
+ this._doSpringAnimation(animationDirection);
+ }
+ }
+
+ _getDropTarget(x, y, source) {
+ const { currentPage } = this._grid;
+
+ let [item, dragLocation] = this._grid.getDropTarget(x, y);
+
+ const [sourcePage, sourcePosition] = this._grid.getItemPosition(source);
+ const targetPage = currentPage;
+ let targetPosition = item
+ ? this._grid.getItemPosition(item)[1] : -1;
+
+ // In case we're hovering over the edge of an item but the
+ // reflow will happen in the opposite direction (the drag
+ // can't "naturally push the item away"), we instead set the
+ // drop target to the adjacent item that can be pushed away
+ // in the reflow-direction.
+ //
+ // We must avoid doing that if we're hovering over the first
+ // or last column though, in that case there is no adjacent
+ // icon we could push away.
+ if (dragLocation === IconGrid.DragLocation.START_EDGE &&
+ targetPosition > sourcePosition &&
+ targetPage === sourcePage) {
+ const nColumns = this._grid.layout_manager.columns_per_page;
+ const targetColumn = targetPosition % nColumns;
+
+ if (targetColumn > 0) {
+ targetPosition -= 1;
+ dragLocation = IconGrid.DragLocation.END_EDGE;
+ }
+ } else if (dragLocation === IconGrid.DragLocation.END_EDGE &&
+ (targetPosition < sourcePosition ||
+ targetPage !== sourcePage)) {
+ const nColumns = this._grid.layout_manager.columns_per_page;
+ const targetColumn = targetPosition % nColumns;
+
+ if (targetColumn < nColumns - 1) {
+ targetPosition += 1;
+ dragLocation = IconGrid.DragLocation.START_EDGE;
+ }
+ }
+
+ // Append to the page if dragging over empty area
+ if (dragLocation === IconGrid.DragLocation.EMPTY_SPACE) {
+ const pageItems =
+ this._grid.getItemsAtPage(currentPage).filter(c => c.visible);
+
+ targetPosition = pageItems.length;
+ }
+
+ return [targetPage, targetPosition, dragLocation];
+ }
+
+ _moveItem(item, newPage, newPosition) {
+ const [page, position] = this._grid.getItemPosition(item);
+ if (page === newPage && position === newPosition)
+ return;
+
+ // Update the _orderedItems array
+ let index = this._orderedItems.indexOf(item);
+ this._orderedItems.splice(index, 1);
+
+ index = this._getLinearPosition(newPage, newPosition);
+ this._orderedItems.splice(index, 0, item);
+
+ this._grid.moveItem(item, newPage, newPosition);
+ }
+
+ vfunc_allocate(box) {
+ const width = box.get_width();
+ const height = box.get_height();
+
+ this.adaptToSize(width, height);
+
+ super.vfunc_allocate(box);
+ }
+
+ vfunc_map() {
+ this._swipeTracker.enabled = true;
+ this._connectDnD();
+ super.vfunc_map();
+ }
+
+ vfunc_unmap() {
+ this._swipeTracker.enabled = false;
+ this._clearAnimateLater();
+ this._disconnectDnD();
+ super.vfunc_unmap();
+ }
+
+ animateSwitch(animationDirection) {
+ this.remove_all_transitions();
+ this._grid.remove_all_transitions();
+
+ let params = {
+ duration: VIEWS_SWITCH_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ };
+ if (animationDirection == IconGrid.AnimationDirection.IN) {
+ this.show();
+ params.opacity = 255;
+ params.delay = VIEWS_SWITCH_ANIMATION_DELAY;
+ } else {
+ params.opacity = 0;
+ params.delay = 0;
+ params.onComplete = () => this.hide();
+ }
+
+ this._grid.ease(params);
+ }
+
+ goToPage(pageNumber, animate = true) {
+ pageNumber = Math.clamp(pageNumber, 0, this._grid.nPages - 1);
+
+ if (this._grid.currentPage === pageNumber)
+ return;
+
+ this._grid.goToPage(pageNumber, animate);
+ }
+
+ adaptToSize(width, height) {
+ let box = new Clutter.ActorBox({
+ x2: width,
+ y2: height,
+ });
+ box = this._scrollView.get_theme_node().get_content_box(box);
+ box = this._grid.get_theme_node().get_content_box(box);
+
+ const availWidth = box.get_width();
+ const availHeight = box.get_height();
+
+ this._grid.adaptToSize(availWidth, availHeight);
+
+ this._availWidth = availWidth;
+ this._availHeight = availHeight;
+ }
+});
+
+var PageManager = GObject.registerClass({
+ Signals: { 'layout-changed': {} },
+}, class PageManager extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._updatingPages = false;
+ this._loadPages();
+
+ global.settings.connect('changed::app-picker-layout',
+ this._loadPages.bind(this));
+
+ }
+
+ _loadPages() {
+ const layout = global.settings.get_value('app-picker-layout');
+ this._pages = layout.recursiveUnpack();
+ if (!this._updatingPages)
+ this.emit('layout-changed');
+ }
+
+ getAppPosition(appId) {
+ let position = -1;
+ let page = -1;
+
+ for (let pageIndex = 0; pageIndex < this._pages.length; pageIndex++) {
+ const pageData = this._pages[pageIndex];
+
+ if (appId in pageData) {
+ page = pageIndex;
+ position = pageData[appId].position;
+ break;
+ }
+ }
+
+ return [page, position];
+ }
+
+ set pages(p) {
+ const packedPages = [];
+
+ // Pack the icon properties as a GVariant
+ for (const page of p) {
+ const pageData = {};
+ for (const [appId, properties] of Object.entries(page))
+ pageData[appId] = new GLib.Variant('a{sv}', properties);
+ packedPages.push(pageData);
+ }
+
+ this._updatingPages = true;
+
+ const variant = new GLib.Variant('aa{sv}', packedPages);
+ global.settings.set_value('app-picker-layout', variant);
+
+ this._updatingPages = false;
+ }
+
+ get pages() {
+ return this._pages;
+ }
+});
+
+var AppDisplay = GObject.registerClass(
+class AppDisplay extends BaseAppView {
+ _init() {
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+
+ this._pageManager = new PageManager();
+ this._pageManager.connect('layout-changed', () => this._redisplay());
+
+ this._scrollView.add_style_class_name('all-apps');
+
+ this._stack = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+ this.add_actor(this._stack);
+ this._stack.add_actor(this._scrollView);
+
+ this.add_actor(this._pageIndicators);
+
+ this._folderIcons = [];
+
+ this._currentDialog = null;
+ this._displayingDialog = false;
+ this._currentDialogDestroyId = 0;
+
+ this._placeholder = null;
+
+ Main.overview.connect('hidden', () => this.goToPage(0));
+
+ this._redisplayWorkId = Main.initializeDeferredWork(this, this._redisplay.bind(this));
+
+ Shell.AppSystem.get_default().connect('installed-changed', () => {
+ this._viewIsReady = false;
+ Main.queueDeferredWork(this._redisplayWorkId);
+ });
+ this._folderSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' });
+ this._folderSettings.connect('changed::folder-children', () => {
+ this._viewIsReady = false;
+ Main.queueDeferredWork(this._redisplayWorkId);
+ });
+
+ this._switcherooNotifyId = global.connect('notify::switcheroo-control',
+ () => this._updateDiscreteGpuAvailable());
+ this._updateDiscreteGpuAvailable();
+ }
+
+ _updateDiscreteGpuAvailable() {
+ this._switcherooProxy = global.get_switcheroo_control();
+ if (this._switcherooProxy) {
+ let prop = this._switcherooProxy.get_cached_property('HasDualGpu');
+ discreteGpuAvailable = prop ? prop.unpack() : false;
+ } else {
+ discreteGpuAvailable = false;
+ }
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._scrollTimeoutId !== 0) {
+ GLib.source_remove(this._scrollTimeoutId);
+ this._scrollTimeoutId = 0;
+ }
+ }
+
+ vfunc_map() {
+ this._keyPressEventId =
+ global.stage.connect('key-press-event',
+ this._onKeyPressEvent.bind(this));
+ super.vfunc_map();
+ }
+
+ vfunc_unmap() {
+ if (this._keyPressEventId) {
+ global.stage.disconnect(this._keyPressEventId);
+ this._keyPressEventId = 0;
+ }
+ super.vfunc_unmap();
+ }
+
+ _redisplay() {
+ this._folderIcons.forEach(icon => {
+ icon.view._redisplay();
+ });
+
+ super._redisplay();
+ }
+
+ _savePages() {
+ 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;
+ }
+
+ _ensurePlaceholder(source) {
+ if (this._placeholder)
+ return;
+
+ 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 AppIcon(app, { isDraggable });
+ this._placeholder.scaleAndFade();
+ this._redisplay();
+ }
+
+ _removePlaceholder() {
+ if (this._placeholder) {
+ this._placeholder.undoScaleAndFade();
+ this._placeholder = null;
+ this._redisplay();
+ }
+ }
+
+ getAppInfos() {
+ return this._appInfoList;
+ }
+
+ _getItemPosition(item) {
+ if (item === this._placeholder) {
+ let [page, position] = this._grid.getItemPosition(item);
+
+ if (page === -1)
+ page = this._findBestPageToAppend(this._grid.currentPage);
+
+ return [page, position];
+ }
+
+ return this._pageManager.getAppPosition(item.id);
+ }
+
+ _compareItems(a, b) {
+ const [aPage, aPosition] = this._getItemPosition(a);
+ const [bPage, bPosition] = this._getItemPosition(b);
+
+ if (aPage === -1 && bPage === -1)
+ return a.name.localeCompare(b.name);
+ else if (aPage === -1)
+ return 1;
+ else if (bPage === -1)
+ return -1;
+
+ if (aPage !== bPage)
+ return aPage - bPage;
+
+ return aPosition - bPosition;
+ }
+
+ _loadApps() {
+ let appIcons = [];
+ this._appInfoList = Shell.AppSystem.get_default().get_installed().filter(appInfo => {
+ try {
+ appInfo.get_id(); // catch invalid file encodings
+ } catch (e) {
+ return false;
+ }
+ return this._parentalControlsManager.shouldShowApp(appInfo);
+ });
+
+ let apps = this._appInfoList.map(app => app.get_id());
+
+ let appSys = Shell.AppSystem.get_default();
+
+ const appsInsideFolders = new Set();
+ this._folderIcons = [];
+
+ let folders = this._folderSettings.get_strv('folder-children');
+ folders.forEach(id => {
+ let path = '%sfolders/%s/'.format(this._folderSettings.path, id);
+ let icon = this._items.get(id);
+ if (!icon) {
+ icon = new FolderIcon(id, path, this);
+ icon.connect('apps-changed', () => {
+ this._redisplay();
+ this._savePages();
+ });
+ }
+
+ // Don't try to display empty folders
+ if (!icon.visible) {
+ icon.destroy();
+ return;
+ }
+
+ appIcons.push(icon);
+ this._folderIcons.push(icon);
+
+ icon.getAppIds().forEach(appId => appsInsideFolders.add(appId));
+ });
+
+ // 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 (appsInsideFolders.has(appId))
+ return;
+
+ let icon = this._items.get(appId);
+ if (!icon) {
+ let app = appSys.lookup_app(appId);
+
+ icon = new AppIcon(app, { isDraggable });
+ }
+
+ appIcons.push(icon);
+ });
+
+ // At last, if there's a placeholder available, add it
+ if (this._placeholder)
+ appIcons.push(this._placeholder);
+
+ return appIcons;
+ }
+
+ // Overridden from BaseAppView
+ animate(animationDirection, onComplete) {
+ this._scrollView.reactive = false;
+ this._swipeTracker.enabled = false;
+ let completionFunc = () => {
+ this._scrollView.reactive = true;
+ this._swipeTracker.enabled = this.mapped;
+ if (onComplete)
+ onComplete();
+ };
+
+ if (animationDirection == IconGrid.AnimationDirection.OUT &&
+ this._displayingDialog && this._currentDialog) {
+ this._currentDialog.popdown();
+ } else {
+ super.animate(animationDirection, completionFunc);
+ if (animationDirection == IconGrid.AnimationDirection.OUT)
+ this._pageIndicators.animateIndicators(animationDirection);
+ }
+ }
+
+ animateSwitch(animationDirection) {
+ super.animateSwitch(animationDirection);
+
+ if (this._currentDialog && this._displayingDialog &&
+ animationDirection == IconGrid.AnimationDirection.OUT) {
+ this._currentDialog.ease({
+ opacity: 0,
+ duration: VIEWS_SWITCH_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => (this.opacity = 255),
+ });
+ }
+
+ if (animationDirection == IconGrid.AnimationDirection.OUT)
+ this._pageIndicators.animateIndicators(animationDirection);
+ }
+
+ goToPage(pageNumber, animate = true) {
+ pageNumber = Math.clamp(pageNumber, 0, this._grid.nPages - 1);
+
+ if (this._grid.currentPage === pageNumber &&
+ this._displayingDialog &&
+ this._currentDialog)
+ return;
+ if (this._displayingDialog && this._currentDialog)
+ this._currentDialog.popdown();
+
+ super.goToPage(pageNumber, animate);
+ }
+
+ _onScroll(actor, event) {
+ if (this._displayingDialog || !this._scrollView.reactive)
+ return Clutter.EVENT_STOP;
+
+ return super._onScroll(actor, event);
+ }
+
+ _onKeyPressEvent(actor, event) {
+ if (this._displayingDialog)
+ return Clutter.EVENT_STOP;
+
+ if (event.get_key_symbol() === Clutter.KEY_Page_Up) {
+ this.goToPage(this._grid.currentPage - 1);
+ return Clutter.EVENT_STOP;
+ } else if (event.get_key_symbol() === Clutter.KEY_Page_Down) {
+ this.goToPage(this._grid.currentPage + 1);
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ addFolderDialog(dialog) {
+ Main.layoutManager.overviewGroup.add_child(dialog);
+ dialog.connect('open-state-changed', (o, isOpen) => {
+ if (this._currentDialog) {
+ this._currentDialog.disconnect(this._currentDialogDestroyId);
+ this._currentDialogDestroyId = 0;
+ }
+
+ this._currentDialog = null;
+
+ if (isOpen) {
+ this._currentDialog = dialog;
+ this._currentDialogDestroyId = dialog.connect('destroy', () => {
+ this._currentDialog = null;
+ this._currentDialogDestroyId = 0;
+ });
+ }
+ this._displayingDialog = isOpen;
+ });
+ }
+
+ _maybeMoveItem(dragEvent) {
+ const clonedEvent = {
+ ...dragEvent,
+ source: this._placeholder ? this._placeholder : dragEvent.source,
+ };
+
+ super._maybeMoveItem(clonedEvent);
+ }
+
+ _onDragBegin(overview, source) {
+ super._onDragBegin(overview, source);
+
+ // 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 (_getViewFromIcon(source) instanceof FolderView)
+ this._ensurePlaceholder(source);
+ }
+
+ _onDragMotion(dragEvent) {
+ if (this._currentDialog)
+ return DND.DragMotionResult.CONTINUE;
+
+ return super._onDragMotion(dragEvent);
+ }
+
+ _onDragEnd() {
+ super._onDragEnd();
+ this._removePlaceholder();
+ }
+
+ _onDragCancelled(overview, source) {
+ const view = _getViewFromIcon(source);
+
+ if (view instanceof FolderView)
+ return;
+
+ super._onDragCancelled(overview, source);
+ }
+
+ acceptDrop(source) {
+ if (!super.acceptDrop(source))
+ return false;
+
+ this._savePages();
+
+ let view = _getViewFromIcon(source);
+ if (view instanceof FolderView)
+ view.removeApp(source.app);
+
+ if (this._currentDialog)
+ this._currentDialog.popdown();
+
+ return true;
+ }
+
+ createFolder(apps) {
+ let newFolderId = GLib.uuid_string_random();
+
+ let folders = this._folderSettings.get_strv('folder-children');
+ folders.push(newFolderId);
+ this._folderSettings.set_strv('folder-children', folders);
+
+ // Create the new folder
+ let newFolderPath = this._folderSettings.path.concat('folders/', newFolderId, '/');
+ let newFolderSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.app-folders.folder',
+ path: newFolderPath,
+ });
+ if (!newFolderSettings) {
+ log('Error creating new folder');
+ return false;
+ }
+
+ // The hovered AppIcon always passes its own id as the first
+ // one, and this is where we want the folder to be created
+ let [folderPage, folderPosition] =
+ this._grid.getItemPosition(this._items.get(apps[0]));
+
+ // Adjust the final position
+ folderPosition -= apps.reduce((counter, appId) => {
+ const [page, position] =
+ this._grid.getItemPosition(this._items.get(appId));
+ if (page === folderPage && position < folderPosition)
+ counter++;
+ return counter;
+ }, 0);
+
+ let appItems = apps.map(id => this._items.get(id).app);
+ let folderName = _findBestFolderName(appItems);
+ if (!folderName)
+ folderName = _("Unnamed Folder");
+
+ newFolderSettings.delay();
+ newFolderSettings.set_string('name', folderName);
+ newFolderSettings.set_strv('apps', apps);
+ newFolderSettings.apply();
+
+ this._redisplay();
+
+ // Move the folder to where the icon target icon was
+ const folderItem = this._items.get(newFolderId);
+ this._moveItem(folderItem, folderPage, folderPosition);
+ this._savePages();
+
+ return true;
+ }
+});
+
+var AppSearchProvider = class AppSearchProvider {
+ constructor() {
+ this._appSys = Shell.AppSystem.get_default();
+ this.id = 'applications';
+ this.isRemoteProvider = false;
+ this.canLaunchSearch = false;
+
+ this._systemActions = new SystemActions.getDefault();
+
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ }
+
+ getResultMetas(apps, callback) {
+ let metas = [];
+ for (let id of apps) {
+ if (id.endsWith('.desktop')) {
+ let app = this._appSys.lookup_app(id);
+
+ metas.push({
+ id: app.get_id(),
+ name: app.get_name(),
+ createIcon: size => app.create_icon_texture(size),
+ });
+ } else {
+ let name = this._systemActions.getName(id);
+ let iconName = this._systemActions.getIconName(id);
+
+ let createIcon = size => new St.Icon({ icon_name: iconName,
+ width: size,
+ height: size,
+ style_class: 'system-action-icon' });
+
+ metas.push({ id, name, createIcon });
+ }
+ }
+
+ callback(metas);
+ }
+
+ filterResults(results, maxNumber) {
+ return results.slice(0, maxNumber);
+ }
+
+ getInitialResultSet(terms, callback, _cancellable) {
+ // Defer until the parental controls manager is initialised, so the
+ // results can be filtered correctly.
+ if (!this._parentalControlsManager.initialized) {
+ let initializedId = this._parentalControlsManager.connect('app-filter-changed', () => {
+ if (this._parentalControlsManager.initialized) {
+ this._parentalControlsManager.disconnect(initializedId);
+ this.getInitialResultSet(terms, callback, _cancellable);
+ }
+ });
+ return;
+ }
+
+ let query = terms.join(' ');
+ let groups = Shell.AppSystem.search(query);
+ let usage = Shell.AppUsage.get_default();
+ let results = [];
+
+ groups.forEach(group => {
+ group = group.filter(appID => {
+ const app = this._appSys.lookup_app(appID);
+ return app && this._parentalControlsManager.shouldShowApp(app.app_info);
+ });
+ results = results.concat(group.sort(
+ (a, b) => usage.compare(a, b)));
+ });
+
+ results = results.concat(this._systemActions.getMatchingActions(terms));
+
+ callback(results);
+ }
+
+ getSubsearchResultSet(previousResults, terms, callback, cancellable) {
+ this.getInitialResultSet(terms, callback, cancellable);
+ }
+
+ createResultObject(resultMeta) {
+ if (resultMeta.id.endsWith('.desktop'))
+ return new AppIcon(this._appSys.lookup_app(resultMeta['id']));
+ else
+ return new SystemActionIcon(this, resultMeta);
+ }
+};
+
+var AppViewItem = GObject.registerClass(
+class AppViewItem extends St.Button {
+ _init(params = {}, isDraggable = true) {
+ super._init({
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ reactive: true,
+ button_mask: St.ButtonMask.ONE | St.ButtonMask.TWO,
+ can_focus: true,
+ ...params,
+ });
+
+ this._delegate = this;
+
+ if (isDraggable) {
+ this._draggable = DND.makeDraggable(this);
+ this._draggable.connect('drag-begin', this._onDragBegin.bind(this));
+ this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this));
+ this._draggable.connect('drag-end', this._onDragEnd.bind(this));
+ }
+
+ this._otherIconIsHovering = false;
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._dragMonitor) {
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+
+ if (this._draggable) {
+ if (this._dragging)
+ Main.overview.endItemDrag(this);
+ this._draggable = null;
+ }
+ }
+
+ _onDragBegin() {
+ this._dragging = true;
+ this.scaleAndFade();
+ Main.overview.beginItemDrag(this);
+ }
+
+ _onDragCancelled() {
+ this._dragging = false;
+ Main.overview.cancelledItemDrag(this);
+ }
+
+ _onDragEnd() {
+ this._dragging = false;
+ this.undoScaleAndFade();
+ Main.overview.endItemDrag(this);
+ }
+
+ scaleIn() {
+ this.scale_x = 0;
+ this.scale_y = 0;
+
+ this.ease({
+ scale_x: 1,
+ scale_y: 1,
+ duration: APP_ICON_SCALE_IN_TIME,
+ delay: APP_ICON_SCALE_IN_DELAY,
+ mode: Clutter.AnimationMode.EASE_OUT_QUINT,
+ });
+ }
+
+ scaleAndFade() {
+ this.reactive = false;
+ this.ease({
+ scale_x: 0.5,
+ scale_y: 0.5,
+ opacity: 0,
+ });
+ }
+
+ undoScaleAndFade() {
+ this.reactive = true;
+ this.ease({
+ scale_x: 1.0,
+ scale_y: 1.0,
+ opacity: 255,
+ });
+ }
+
+ _canAccept(source) {
+ return source !== this;
+ }
+
+ _setHoveringByDnd(hovering) {
+ if (this._otherIconIsHovering === hovering)
+ return;
+
+ this._otherIconIsHovering = hovering;
+
+ if (hovering) {
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ } else {
+ DND.removeDragMonitor(this._dragMonitor);
+ }
+ }
+
+ _onDragMotion(dragEvent) {
+ if (!this.contains(dragEvent.targetActor))
+ this._setHoveringByDnd(false);
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _withinLeeways(x) {
+ return x < IconGrid.LEFT_DIVIDER_LEEWAY ||
+ x > this.width - IconGrid.RIGHT_DIVIDER_LEEWAY;
+ }
+
+ handleDragOver(source, _actor, x) {
+ if (source === this)
+ return DND.DragMotionResult.NO_DROP;
+
+ if (!this._canAccept(source))
+ return DND.DragMotionResult.CONTINUE;
+
+ if (this._withinLeeways(x)) {
+ this._setHoveringByDnd(false);
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ this._setHoveringByDnd(true);
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source, _actor, x) {
+ this._setHoveringByDnd(false);
+
+ if (!this._canAccept(source))
+ return false;
+
+ if (this._withinLeeways(x))
+ return false;
+
+ return true;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get name() {
+ return this._name;
+ }
+});
+
+var FolderGrid = GObject.registerClass(
+class FolderGrid extends IconGrid.IconGrid {
+ _init() {
+ super._init({
+ allow_incomplete_pages: false,
+ orientation: Clutter.Orientation.HORIZONTAL,
+ columns_per_page: 3,
+ rows_per_page: 3,
+ page_halign: Clutter.ActorAlign.CENTER,
+ page_valign: Clutter.ActorAlign.CENTER,
+ });
+ }
+
+ adaptToSize(width, height) {
+ this.layout_manager.adaptToSize(width, height);
+ }
+});
+
+var FolderView = GObject.registerClass(
+class FolderView extends BaseAppView {
+ _init(folder, id, parentView) {
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ }, Clutter.Orientation.HORIZONTAL);
+
+ // If it not expand, the parent doesn't take into account its preferred_width when allocating
+ // the second time it allocates, so we apply the "Standard hack for ClutterBinLayout"
+ this._grid.x_expand = true;
+ this._id = id;
+ this._folder = folder;
+ this._parentView = parentView;
+ this._grid._delegate = this;
+
+ const box = new St.BoxLayout({
+ vertical: true,
+ reactive: true,
+ x_expand: true,
+ y_expand: true,
+ });
+ box.add_child(this._scrollView);
+ box.add_child(this._pageIndicators);
+ this.add_child(box);
+
+ let action = new Clutter.PanAction({ interpolate: true });
+ action.connect('pan', this._onPan.bind(this));
+ this._scrollView.add_action(action);
+
+ this._deletingFolder = false;
+ this._appIds = [];
+ this._redisplay();
+ }
+
+ _createGrid() {
+ return new FolderGrid();
+ }
+
+ _getFolderApps() {
+ const appIds = [];
+ const excludedApps = this._folder.get_strv('excluded-apps');
+ const appSys = Shell.AppSystem.get_default();
+ const addAppId = appId => {
+ if (excludedApps.includes(appId))
+ return;
+
+ const app = appSys.lookup_app(appId);
+ if (!app)
+ return;
+
+ if (!this._parentalControlsManager.shouldShowApp(app.get_app_info()))
+ return;
+
+ if (appIds.indexOf(appId) !== -1)
+ return;
+
+ appIds.push(appId);
+ };
+
+ 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 = _getCategories(appInfo);
+ if (!_listsIntersect(folderCategories, appCategories))
+ return;
+
+ addAppId(appInfo.get_id());
+ });
+
+ return appIds;
+ }
+
+ _getItemPosition(item) {
+ const appIndex = this._appIds.indexOf(item.id);
+
+ if (appIndex === -1)
+ return [-1, -1];
+
+ const { itemsPerPage } = this._grid;
+ return [Math.floor(appIndex / itemsPerPage), appIndex % itemsPerPage];
+ }
+
+ _compareItems(a, b) {
+ const aPosition = this._appIds.indexOf(a.id);
+ const bPosition = this._appIds.indexOf(b.id);
+
+ if (aPosition === -1 && bPosition === -1)
+ return a.name.localeCompare(b.name);
+ else if (aPosition === -1)
+ return 1;
+ else if (bPosition === -1)
+ return -1;
+
+ return aPosition - bPosition;
+ }
+
+ // Overridden from BaseAppView
+ animate(animationDirection) {
+ this._grid.animatePulse(animationDirection);
+ }
+
+ createFolderIcon(size) {
+ let layout = new Clutter.GridLayout();
+ let icon = new St.Widget({
+ layout_manager: layout,
+ style_class: 'app-folder-icon',
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ layout.hookup_style(icon);
+ let subSize = Math.floor(FOLDER_SUBICON_FRACTION * size);
+
+ let numItems = this._orderedItems.length;
+ let rtl = icon.get_text_direction() == Clutter.TextDirection.RTL;
+ for (let i = 0; i < 4; i++) {
+ const style = 'width: %dpx; height: %dpx;'.format(subSize, subSize);
+ let bin = new St.Bin({ style });
+ if (i < numItems)
+ bin.child = this._orderedItems[i].app.create_icon_texture(subSize);
+ layout.attach(bin, rtl ? (i + 1) % 2 : i % 2, Math.floor(i / 2), 1, 1);
+ }
+
+ return icon;
+ }
+
+ _onPan(action) {
+ let [dist_, dx_, dy] = action.get_motion_delta(0);
+ let adjustment = this._scrollView.vscroll.adjustment;
+ adjustment.value -= (dy / this._scrollView.height) * adjustment.page_size;
+ return false;
+ }
+
+ adaptToSize(width, height) {
+ const [, indicatorHeight] = this._pageIndicators.get_preferred_height(-1);
+ height -= indicatorHeight;
+
+ super.adaptToSize(width, height);
+ }
+
+ _loadApps() {
+ let apps = [];
+ let appSys = Shell.AppSystem.get_default();
+
+ this._appIds.forEach(appId => {
+ const app = appSys.lookup_app(appId);
+
+ let icon = this._items.get(appId);
+ if (!icon)
+ icon = new AppIcon(app);
+
+ apps.push(icon);
+ });
+
+ return apps;
+ }
+
+ _redisplay() {
+ // Keep the app ids list cached
+ this._appIds = this._getFolderApps();
+
+ super._redisplay();
+ }
+
+ acceptDrop(source) {
+ if (!super.acceptDrop(source))
+ return false;
+
+ const folderApps = this._orderedItems.map(item => item.id);
+ this._folder.set_strv('apps', folderApps);
+
+ return true;
+ }
+
+ addApp(app) {
+ let folderApps = this._folder.get_strv('apps');
+ folderApps.push(app.id);
+
+ this._folder.set_strv('apps', folderApps);
+
+ // Also remove from 'excluded-apps' if the app id is listed
+ // there. This is only possible on categories-based folders.
+ let excludedApps = this._folder.get_strv('excluded-apps');
+ let index = excludedApps.indexOf(app.id);
+ if (index >= 0) {
+ excludedApps.splice(index, 1);
+ this._folder.set_strv('excluded-apps', excludedApps);
+ }
+ }
+
+ removeApp(app) {
+ let folderApps = this._folder.get_strv('apps');
+ let index = folderApps.indexOf(app.id);
+ if (index >= 0)
+ folderApps.splice(index, 1);
+
+ // Remove the folder if this is the last app icon; otherwise,
+ // just remove the icon
+ if (folderApps.length == 0) {
+ this._deletingFolder = true;
+
+ // Resetting all keys deletes the relocatable schema
+ 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._id), 1);
+ settings.set_strv('folder-children', folders);
+
+ this._deletingFolder = false;
+ } else {
+ // If this is a categories-based folder, also add it to
+ // the list of excluded apps
+ const categories = this._folder.get_strv('categories');
+ if (categories.length > 0) {
+ const excludedApps = this._folder.get_strv('excluded-apps');
+ excludedApps.push(app.id);
+ this._folder.set_strv('excluded-apps', excludedApps);
+ }
+
+ this._folder.set_strv('apps', folderApps);
+ }
+ }
+
+ get deletingFolder() {
+ return this._deletingFolder;
+ }
+});
+
+var FolderIcon = GObject.registerClass({
+ Signals: {
+ 'apps-changed': {},
+ },
+}, class FolderIcon extends AppViewItem {
+ _init(id, path, parentView) {
+ super._init({
+ style_class: 'app-well-app app-folder',
+ button_mask: St.ButtonMask.ONE,
+ toggle_mode: true,
+ can_focus: true,
+ }, global.settings.is_writable('app-picker-layout'));
+ this._id = id;
+ this._name = '';
+ this._parentView = parentView;
+
+ this._folder = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders.folder',
+ path });
+
+ this.icon = new IconGrid.BaseIcon('', {
+ createIcon: this._createIcon.bind(this),
+ setSizeManually: true,
+ });
+ this.set_child(this.icon);
+ this.label_actor = this.icon.label;
+
+ this.view = new FolderView(this._folder, id, parentView);
+
+ this._folderChangedId = this._folder.connect(
+ 'changed', this._sync.bind(this));
+ this._sync();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._dialog)
+ this._dialog.destroy();
+ else
+ this.view.destroy();
+
+ if (this._folderChangedId) {
+ this._folder.disconnect(this._folderChangedId);
+ delete this._folderChangedId;
+ }
+ }
+
+ vfunc_clicked() {
+ this.open();
+ }
+
+ vfunc_unmap() {
+ if (this._dialog)
+ this._dialog.popdown();
+
+ super.vfunc_unmap();
+ }
+
+ open() {
+ this._ensureFolderDialog();
+ this.view._scrollView.vscroll.adjustment.value = 0;
+ this._dialog.popup();
+ }
+
+ getAppIds() {
+ return this.view.getAllItems().map(item => item.id);
+ }
+
+ _setHoveringByDnd(hovering) {
+ if (this._otherIconIsHovering == hovering)
+ return;
+
+ super._setHoveringByDnd(hovering);
+
+ if (hovering)
+ this.add_style_pseudo_class('drop');
+ else
+ this.remove_style_pseudo_class('drop');
+ }
+
+ _onDragMotion(dragEvent) {
+ if (!this._canAccept(dragEvent.source))
+ this._setHoveringByDnd(false);
+
+ return super._onDragMotion(dragEvent);
+ }
+
+ getDragActor() {
+ const iconParams = {
+ createIcon: this._createIcon.bind(this),
+ showLabel: this.icon.label !== null,
+ setSizeManually: false,
+ };
+
+ const icon = new IconGrid.BaseIcon(this.name, iconParams);
+ icon.style_class = this.style_class;
+
+ return icon;
+ }
+
+ getDragActorSource() {
+ return this;
+ }
+
+ _canAccept(source) {
+ if (!(source instanceof AppIcon))
+ return false;
+
+ let view = _getViewFromIcon(source);
+ if (!view || !(view instanceof AppDisplay))
+ return false;
+
+ if (this._folder.get_strv('apps').includes(source.id))
+ return false;
+
+ return true;
+ }
+
+ acceptDrop(source) {
+ const accepted = super.acceptDrop(source);
+
+ if (!accepted)
+ return false;
+
+ this.view.addApp(source.app);
+
+ return true;
+ }
+
+ _updateName() {
+ let name = _getFolderName(this._folder);
+ if (this.name == name)
+ return;
+
+ this._name = name;
+ this.icon.label.text = this.name;
+ }
+
+ _sync() {
+ if (this.view.deletingFolder)
+ return;
+
+ this.emit('apps-changed');
+ this._updateName();
+ this.visible = this.view.getAllItems().length > 0;
+ this.icon.update();
+ }
+
+ _createIcon(iconSize) {
+ return this.view.createFolderIcon(iconSize, this);
+ }
+
+ _ensureFolderDialog() {
+ if (this._dialog)
+ return;
+ if (!this._dialog) {
+ this._dialog = new AppFolderDialog(this, this._folder,
+ this._parentView);
+ this._parentView.addFolderDialog(this._dialog);
+ this._dialog.connect('open-state-changed', (popup, isOpen) => {
+ const duration = FOLDER_DIALOG_ANIMATION_TIME / 2;
+ const mode = isOpen
+ ? Clutter.AnimationMode.EASE_OUT_QUAD
+ : Clutter.AnimationMode.EASE_IN_QUAD;
+
+ this.ease({
+ opacity: isOpen ? 0 : 255,
+ duration,
+ mode,
+ delay: isOpen ? 0 : FOLDER_DIALOG_ANIMATION_TIME - duration,
+ });
+
+ if (!isOpen)
+ this.checked = false;
+ });
+ }
+ }
+});
+
+var AppFolderDialog = GObject.registerClass({
+ Signals: {
+ 'open-state-changed': { param_types: [GObject.TYPE_BOOLEAN] },
+ },
+}, class AppFolderDialog extends St.Bin {
+ _init(source, folder, appDisplay) {
+ super._init({
+ visible: false,
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ });
+
+ this.add_constraint(new Layout.MonitorConstraint({
+ primary: true,
+ work_area: true,
+ }));
+
+ const clickAction = new Clutter.ClickAction();
+ clickAction.connect('clicked', () => {
+ const [x, y] = clickAction.get_coords();
+ const actor =
+ global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y);
+
+ if (actor === this)
+ this.popdown();
+ });
+ this.add_action(clickAction);
+
+ this._source = source;
+ this._folder = folder;
+ this._view = source.view;
+ this._appDisplay = appDisplay;
+ this._delegate = this;
+
+ this._isOpen = false;
+
+ this._viewBox = new St.BoxLayout({
+ style_class: 'app-folder-dialog',
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.FILL,
+ y_align: Clutter.ActorAlign.FILL,
+ vertical: true,
+ });
+
+ this.child = new St.Bin({
+ style_class: 'app-folder-dialog-container',
+ child: this._viewBox,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._addFolderNameEntry();
+ this._viewBox.add_child(this._view);
+
+ global.focus_manager.add_group(this);
+
+ this._grabHelper = new GrabHelper.GrabHelper(this, {
+ actionMode: Shell.ActionMode.POPUP,
+ });
+ this._grabHelper.addActor(Main.layoutManager.overviewGroup);
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._dragMonitor = null;
+ this._sourceMappedId = 0;
+ this._popdownTimeoutId = 0;
+ this._needsZoomAndFade = false;
+
+ this._popdownCallbacks = [];
+ }
+
+ _addFolderNameEntry() {
+ this._entryBox = new St.BoxLayout({
+ style_class: 'folder-name-container',
+ });
+ this._viewBox.add_child(this._entryBox);
+
+ // Empty actor to center the title
+ let ghostButton = new Clutter.Actor();
+ this._entryBox.add_child(ghostButton);
+
+ let stack = new Shell.Stack({
+ x_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this._entryBox.add_child(stack);
+
+ // Folder name label
+ this._folderNameLabel = new St.Label({
+ style_class: 'folder-name-label',
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ stack.add_child(this._folderNameLabel);
+
+ // Folder name entry
+ this._entry = new St.Entry({
+ style_class: 'folder-name-entry',
+ opacity: 0,
+ reactive: false,
+ });
+ this._entry.clutter_text.set({
+ x_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._entry.clutter_text.connect('activate', () => {
+ this._showFolderLabel();
+ });
+
+ stack.add_child(this._entry);
+
+ // Edit button
+ this._editButton = new St.Button({
+ style_class: 'edit-folder-button',
+ button_mask: St.ButtonMask.ONE,
+ toggle_mode: true,
+ reactive: true,
+ can_focus: true,
+ x_align: Clutter.ActorAlign.END,
+ y_align: Clutter.ActorAlign.CENTER,
+ child: new St.Icon({
+ icon_name: 'document-edit-symbolic',
+ icon_size: 16,
+ }),
+ });
+
+ this._editButton.connect('notify::checked', () => {
+ if (this._editButton.checked)
+ this._showFolderEntry();
+ else
+ this._showFolderLabel();
+ });
+
+ this._entryBox.add_child(this._editButton);
+
+ ghostButton.add_constraint(new Clutter.BindConstraint({
+ source: this._editButton,
+ coordinate: Clutter.BindCoordinate.SIZE,
+ }));
+
+ this._folder.connect('changed::name', () => this._syncFolderName());
+ this._syncFolderName();
+ }
+
+ _syncFolderName() {
+ let newName = _getFolderName(this._folder);
+
+ this._folderNameLabel.text = newName;
+ this._entry.text = newName;
+ }
+
+ _switchActor(from, to) {
+ to.reactive = true;
+ to.ease({
+ opacity: 255,
+ duration: 300,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ from.ease({
+ opacity: 0,
+ duration: 300,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ from.reactive = false;
+ },
+ });
+ }
+
+ _showFolderLabel() {
+ if (this._editButton.checked)
+ this._editButton.checked = false;
+
+ this._maybeUpdateFolderName();
+ this._switchActor(this._entry, this._folderNameLabel);
+ }
+
+ _showFolderEntry() {
+ this._switchActor(this._folderNameLabel, this._entry);
+
+ this._entry.clutter_text.set_selection(0, -1);
+ this._entry.clutter_text.grab_key_focus();
+ }
+
+ _maybeUpdateFolderName() {
+ let folderName = _getFolderName(this._folder);
+ let newFolderName = this._entry.text.trim();
+
+ if (newFolderName.length === 0 || newFolderName === folderName)
+ return;
+
+ this._folder.set_string('name', newFolderName);
+ this._folder.set_boolean('translate', false);
+ }
+
+ _zoomAndFadeIn() {
+ let [sourceX, sourceY] =
+ this._source.get_transformed_position();
+ let [dialogX, dialogY] =
+ this.child.get_transformed_position();
+
+ 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.ease({
+ background_color: DIALOG_SHADE_NORMAL,
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ this.child.ease({
+ translation_x: 0,
+ translation_y: 0,
+ scale_x: 1,
+ scale_y: 1,
+ opacity: 255,
+ 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;
+ }
+
+ let [sourceX, sourceY] =
+ this._source.get_transformed_position();
+ let [dialogX, dialogY] =
+ this.child.get_transformed_position();
+
+ this.ease({
+ background_color: Clutter.Color.from_pixel(0x00000000),
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this.child.ease({
+ 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,
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_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 = [];
+ },
+ });
+
+ this._needsZoomAndFade = false;
+ }
+
+ _removeDragMonitor() {
+ if (!this._dragMonitor)
+ return;
+
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+
+ _removePopdownTimeout() {
+ if (this._popdownTimeoutId === 0)
+ return;
+
+ GLib.source_remove(this._popdownTimeoutId);
+ this._popdownTimeoutId = 0;
+ }
+
+ _onDestroy() {
+ if (this._isOpen) {
+ this._isOpen = false;
+ this._grabHelper.ungrab({ actor: this });
+ this._grabHelper = null;
+ }
+
+ if (this._sourceMappedId) {
+ this._source.disconnect(this._sourceMappedId);
+ this._sourceMappedId = 0;
+ }
+
+ this._removePopdownTimeout();
+ this._removeDragMonitor();
+ }
+
+ vfunc_allocate(box) {
+ super.vfunc_allocate(box);
+
+ // We can only start zooming after receiving an allocation
+ if (this._needsZoomAndFade)
+ this._zoomAndFadeIn();
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ if (global.stage.get_key_focus() != this)
+ return Clutter.EVENT_PROPAGATE;
+
+ // Since we need to only grab focus on one item child when the user
+ // actually press a key we don't use navigate_focus when opening
+ // the popup.
+ // Instead of that, grab the focus on the AppFolderPopup actor
+ // and actually moves the focus to a child only when the user
+ // actually press a key.
+ // It should work with just grab_key_focus on the AppFolderPopup
+ // actor, but since the arrow keys are not wrapping_around the focus
+ // is not grabbed by a child when the widget that has the current focus
+ // is the same that is requesting focus, so to make it works with arrow
+ // keys we need to connect to the key-press-event and navigate_focus
+ // when that happens using TAB_FORWARD or TAB_BACKWARD instead of arrow
+ // keys
+
+ // Use TAB_FORWARD for down key and right key
+ // and TAB_BACKWARD for up key and left key on ltr
+ // languages
+ let direction;
+ let isLtr = Clutter.get_default_text_direction() == Clutter.TextDirection.LTR;
+ switch (keyEvent.keyval) {
+ case Clutter.KEY_Down:
+ direction = St.DirectionType.TAB_FORWARD;
+ break;
+ case Clutter.KEY_Right:
+ direction = isLtr
+ ? St.DirectionType.TAB_FORWARD
+ : St.DirectionType.TAB_BACKWARD;
+ break;
+ case Clutter.KEY_Up:
+ direction = St.DirectionType.TAB_BACKWARD;
+ break;
+ case Clutter.KEY_Left:
+ direction = isLtr
+ ? St.DirectionType.TAB_BACKWARD
+ : St.DirectionType.TAB_FORWARD;
+ break;
+ default:
+ return Clutter.EVENT_PROPAGATE;
+ }
+ return this.navigate_focus(null, direction, false);
+ }
+
+ _setLighterBackground(lighter) {
+ const backgroundColor = lighter
+ ? DIALOG_SHADE_HIGHLIGHT
+ : DIALOG_SHADE_NORMAL;
+
+ this.ease({
+ backgroundColor,
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _withinDialog(x, y) {
+ const childExtents = this.child.get_transformed_extents();
+ return childExtents.contains_point(new Graphene.Point({ x, y }));
+ }
+
+ _setupDragMonitor() {
+ if (this._dragMonitor)
+ return;
+
+ this._dragMonitor = {
+ dragMotion: dragEvent => {
+ const withinDialog =
+ this._withinDialog(dragEvent.x, dragEvent.y);
+
+ this._setLighterBackground(!withinDialog);
+
+ if (withinDialog) {
+ this._removePopdownTimeout();
+ this._removeDragMonitor();
+ }
+ return DND.DragMotionResult.CONTINUE;
+ },
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ }
+
+ _setupPopdownTimeout() {
+ if (this._popdownTimeoutId > 0)
+ return;
+
+ this._popdownTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, POPDOWN_DIALOG_TIMEOUT, () => {
+ this._popdownTimeoutId = 0;
+ this.popdown();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ handleDragOver(source, actor, x, y) {
+ if (this._withinDialog(x, y)) {
+ this._setLighterBackground(false);
+ this._removePopdownTimeout();
+ this._removeDragMonitor();
+ } else {
+ this._setupPopdownTimeout();
+ this._setupDragMonitor();
+ }
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source) {
+ const appId = source.id;
+
+ this.popdown(() => {
+ this._view.removeApp(source);
+ this._appDisplay.selectApp(appId);
+ });
+
+ return true;
+ }
+
+ toggle() {
+ if (this._isOpen)
+ this.popdown();
+ else
+ this.popup();
+ }
+
+ popup() {
+ if (this._isOpen)
+ return;
+
+ this._isOpen = this._grabHelper.grab({
+ actor: this,
+ onUngrab: () => this.popdown(),
+ });
+
+ if (!this._isOpen)
+ return;
+
+ this.get_parent().set_child_above_sibling(this, null);
+
+ this._needsZoomAndFade = true;
+ this.show();
+
+ this.emit('open-state-changed', true);
+ }
+
+ popdown(callback) {
+ // Either call the callback right away, or wait for the zoom out
+ // animation to finish
+ if (callback) {
+ if (this.visible)
+ this._popdownCallbacks.push(callback);
+ else
+ callback();
+ }
+
+ if (!this._isOpen)
+ return;
+
+ this._zoomAndFadeOut();
+ this._showFolderLabel();
+
+ this._isOpen = false;
+ this._grabHelper.ungrab({ actor: this });
+ this.emit('open-state-changed', false);
+ }
+});
+
+var AppIcon = GObject.registerClass({
+ Signals: {
+ 'menu-state-changed': { param_types: [GObject.TYPE_BOOLEAN] },
+ 'sync-tooltip': {},
+ },
+}, class AppIcon extends AppViewItem {
+ _init(app, iconParams = {}) {
+ // Get the isDraggable property without passing it on to the BaseIcon:
+ const appIconParams = Params.parse(iconParams, { isDraggable: true }, true);
+ const isDraggable = appIconParams['isDraggable'];
+ delete iconParams['isDraggable'];
+
+ super._init({ style_class: 'app-well-app' }, isDraggable);
+
+ this.app = app;
+ this._id = app.get_id();
+ this._name = app.get_name();
+
+ this._iconContainer = new St.Widget({ layout_manager: new Clutter.BinLayout(),
+ x_expand: true, y_expand: true });
+
+ this.set_child(this._iconContainer);
+
+ this._folderPreviewId = 0;
+
+ iconParams['createIcon'] = this._createIcon.bind(this);
+ iconParams['setSizeManually'] = true;
+ this.icon = new IconGrid.BaseIcon(app.get_name(), iconParams);
+ this._iconContainer.add_child(this.icon);
+
+ this._dot = new St.Widget({
+ style_class: 'app-well-app-running-dot',
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.END,
+ });
+ this._iconContainer.add_child(this._dot);
+
+ this.label_actor = this.icon.label;
+
+ this.connect('popup-menu', this._onKeyboardPopupMenu.bind(this));
+
+ this._menu = null;
+ this._menuManager = new PopupMenu.PopupMenuManager(this);
+
+ this._menuTimeoutId = 0;
+ this._stateChangedId = this.app.connect('notify::state', () => {
+ this._updateRunningStyle();
+ });
+ this._updateRunningStyle();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._folderPreviewId > 0) {
+ GLib.source_remove(this._folderPreviewId);
+ this._folderPreviewId = 0;
+ }
+ if (this._stateChangedId > 0)
+ this.app.disconnect(this._stateChangedId);
+
+ this._stateChangedId = 0;
+ this._removeMenuTimeout();
+ }
+
+ _onDragBegin() {
+ this._removeMenuTimeout();
+ super._onDragBegin();
+ }
+
+ _createIcon(iconSize) {
+ return this.app.create_icon_texture(iconSize);
+ }
+
+ _removeMenuTimeout() {
+ if (this._menuTimeoutId > 0) {
+ GLib.source_remove(this._menuTimeoutId);
+ this._menuTimeoutId = 0;
+ }
+ }
+
+ _updateRunningStyle() {
+ if (this.app.state != Shell.AppState.STOPPED)
+ this._dot.show();
+ else
+ this._dot.hide();
+ }
+
+ _setPopupTimeout() {
+ this._removeMenuTimeout();
+ this._menuTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, MENU_POPUP_TIMEOUT, () => {
+ this._menuTimeoutId = 0;
+ this.popupMenu();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._menuTimeoutId, '[gnome-shell] this.popupMenu');
+ }
+
+ vfunc_leave_event(crossingEvent) {
+ const ret = super.vfunc_leave_event(crossingEvent);
+
+ this.fake_release();
+ this._removeMenuTimeout();
+ return ret;
+ }
+
+ vfunc_button_press_event(buttonEvent) {
+ const ret = super.vfunc_button_press_event(buttonEvent);
+ if (buttonEvent.button == 1) {
+ this._setPopupTimeout();
+ } else if (buttonEvent.button == 3) {
+ this.popupMenu();
+ return Clutter.EVENT_STOP;
+ }
+ return ret;
+ }
+
+ vfunc_touch_event(touchEvent) {
+ const ret = super.vfunc_touch_event(touchEvent);
+ if (touchEvent.type == Clutter.EventType.TOUCH_BEGIN)
+ this._setPopupTimeout();
+
+ return ret;
+ }
+
+ vfunc_clicked(button) {
+ this._removeMenuTimeout();
+ this.activate(button);
+ }
+
+ _onKeyboardPopupMenu() {
+ this.popupMenu();
+ this._menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ }
+
+ getId() {
+ return this.app.get_id();
+ }
+
+ popupMenu() {
+ this._removeMenuTimeout();
+ this.fake_release();
+
+ if (this._draggable)
+ this._draggable.fakeRelease();
+
+ if (!this._menu) {
+ this._menu = new AppIconMenu(this);
+ this._menu.connect('activate-window', (menu, window) => {
+ this.activateWindow(window);
+ });
+ this._menu.connect('open-state-changed', (menu, isPoppedUp) => {
+ if (!isPoppedUp)
+ this._onMenuPoppedDown();
+ });
+ let id = Main.overview.connect('hiding', () => {
+ this._menu.close();
+ });
+ this.connect('destroy', () => {
+ Main.overview.disconnect(id);
+ });
+
+ this._menuManager.addMenu(this._menu);
+ }
+
+ this.emit('menu-state-changed', true);
+
+ this.set_hover(true);
+ this._menu.popup();
+ this._menuManager.ignoreRelease();
+ this.emit('sync-tooltip');
+
+ return false;
+ }
+
+ activateWindow(metaWindow) {
+ if (metaWindow)
+ Main.activateWindow(metaWindow);
+ else
+ Main.overview.hide();
+ }
+
+ _onMenuPoppedDown() {
+ this.sync_hover();
+ this.emit('menu-state-changed', false);
+ }
+
+ activate(button) {
+ let event = Clutter.get_current_event();
+ let modifiers = event ? event.get_state() : 0;
+ let isMiddleButton = button && button == Clutter.BUTTON_MIDDLE;
+ let isCtrlPressed = (modifiers & Clutter.ModifierType.CONTROL_MASK) != 0;
+ let openNewWindow = this.app.can_open_new_window() &&
+ this.app.state == Shell.AppState.RUNNING &&
+ (isCtrlPressed || isMiddleButton);
+
+ if (this.app.state == Shell.AppState.STOPPED || openNewWindow)
+ this.animateLaunch();
+
+ if (openNewWindow)
+ this.app.open_new_window(-1);
+ else
+ this.app.activate();
+
+ Main.overview.hide();
+ }
+
+ animateLaunch() {
+ this.icon.animateZoomOut();
+ }
+
+ animateLaunchAtPos(x, y) {
+ this.icon.animateZoomOutAtPos(x, y);
+ }
+
+ shellWorkspaceLaunch(params) {
+ let { stack } = new Error();
+ log('shellWorkspaceLaunch is deprecated, use app.open_new_window() instead\n%s'.format(stack));
+
+ params = Params.parse(params, { workspace: -1,
+ timestamp: 0 });
+
+ this.app.open_new_window(params.workspace);
+ }
+
+ getDragActor() {
+ return this.app.create_icon_texture(Main.overview.dash.iconSize);
+ }
+
+ // Returns the original actor that should align with the actor
+ // we show as the item is being dragged.
+ getDragActorSource() {
+ return this.icon.icon;
+ }
+
+ shouldShowTooltip() {
+ return this.hover && (!this._menu || !this._menu.isOpen);
+ }
+
+ _showFolderPreview() {
+ this.icon.label.opacity = 0;
+ this.icon.icon.ease({
+ scale_x: FOLDER_SUBICON_FRACTION,
+ scale_y: FOLDER_SUBICON_FRACTION,
+ });
+ }
+
+ _hideFolderPreview() {
+ this.icon.label.opacity = 255;
+ this.icon.icon.ease({
+ scale_x: 1.0,
+ scale_y: 1.0,
+ });
+ }
+
+ _canAccept(source) {
+ let view = _getViewFromIcon(source);
+
+ return source != this &&
+ (source instanceof this.constructor) &&
+ (view instanceof AppDisplay);
+ }
+
+ _setHoveringByDnd(hovering) {
+ if (this._otherIconIsHovering == hovering)
+ return;
+
+ super._setHoveringByDnd(hovering);
+
+ if (hovering) {
+ if (this._folderPreviewId > 0)
+ return;
+
+ this._folderPreviewId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
+ this.add_style_pseudo_class('drop');
+ this._showFolderPreview();
+ this._folderPreviewId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ } else {
+ if (this._folderPreviewId > 0) {
+ GLib.source_remove(this._folderPreviewId);
+ this._folderPreviewId = 0;
+ }
+ this._hideFolderPreview();
+ this.remove_style_pseudo_class('drop');
+ }
+ }
+
+ acceptDrop(source, actor, x) {
+ const accepted = super.acceptDrop(source, actor, x);
+ if (!accepted)
+ return false;
+
+ let view = _getViewFromIcon(this);
+ let apps = [this.id, source.id];
+
+ return view.createFolder(apps);
+ }
+});
+
+var AppIconMenu = class AppIconMenu extends PopupMenu.PopupMenu {
+ constructor(source) {
+ let side = St.Side.LEFT;
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
+ side = St.Side.RIGHT;
+
+ super(source, 0.5, side);
+
+ // We want to keep the item hovered while the menu is up
+ this.blockSourceEvents = true;
+
+ this._source = source;
+
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+
+ this.actor.add_style_class_name('app-well-menu');
+
+ // Chain our visibility and lifecycle to that of the source
+ this._sourceMappedId = source.connect('notify::mapped', () => {
+ if (!source.mapped)
+ this.close();
+ });
+ source.connect('destroy', () => {
+ source.disconnect(this._sourceMappedId);
+ this.destroy();
+ });
+
+ Main.uiGroup.add_actor(this.actor);
+ }
+
+ _rebuildMenu() {
+ this.removeAll();
+
+ let windows = this._source.app.get_windows().filter(
+ w => !w.skip_taskbar);
+
+ if (windows.length > 0) {
+ this.addMenuItem(
+ /* Translators: This is the heading of a list of open windows */
+ new PopupMenu.PopupSeparatorMenuItem(_('Open Windows')));
+ }
+
+ windows.forEach(window => {
+ let title = window.title
+ ? window.title : this._source.app.get_name();
+ let item = this._appendMenuItem(title);
+ item.connect('activate', () => {
+ this.emit('activate-window', window);
+ });
+ });
+
+ if (!this._source.app.is_window_backed()) {
+ this._appendSeparator();
+
+ let appInfo = this._source.app.get_app_info();
+ let actions = appInfo.list_actions();
+ if (this._source.app.can_open_new_window() &&
+ !actions.includes('new-window')) {
+ this._newWindowMenuItem = this._appendMenuItem(_("New Window"));
+ this._newWindowMenuItem.connect('activate', () => {
+ this._source.animateLaunch();
+ this._source.app.open_new_window(-1);
+ this.emit('activate-window', null);
+ });
+ this._appendSeparator();
+ }
+
+ if (discreteGpuAvailable &&
+ this._source.app.state == Shell.AppState.STOPPED) {
+ const appPrefersNonDefaultGPU = appInfo.get_boolean('PrefersNonDefaultGPU');
+ const gpuPref = appPrefersNonDefaultGPU
+ ? Shell.AppLaunchGpu.DEFAULT
+ : Shell.AppLaunchGpu.DISCRETE;
+ this._onGpuMenuItem = this._appendMenuItem(appPrefersNonDefaultGPU
+ ? _('Launch using Integrated Graphics Card')
+ : _('Launch using Discrete Graphics Card'));
+ this._onGpuMenuItem.connect('activate', () => {
+ this._source.animateLaunch();
+ this._source.app.launch(0, -1, gpuPref);
+ this.emit('activate-window', null);
+ });
+ }
+
+ for (let i = 0; i < actions.length; i++) {
+ let action = actions[i];
+ let item = this._appendMenuItem(appInfo.get_action_name(action));
+ item.connect('activate', (emitter, event) => {
+ if (action == 'new-window')
+ this._source.animateLaunch();
+
+ this._source.app.launch_action(action, event.get_time(), -1);
+ this.emit('activate-window', null);
+ });
+ }
+
+ let canFavorite = global.settings.is_writable('favorite-apps') &&
+ this._parentalControlsManager.shouldShowApp(this._source.app.app_info);
+
+ if (canFavorite) {
+ this._appendSeparator();
+
+ let isFavorite = AppFavorites.getAppFavorites().isFavorite(this._source.app.get_id());
+
+ if (isFavorite) {
+ let item = this._appendMenuItem(_("Remove from Favorites"));
+ item.connect('activate', () => {
+ let favs = AppFavorites.getAppFavorites();
+ favs.removeFavorite(this._source.app.get_id());
+ });
+ } else {
+ let item = this._appendMenuItem(_("Add to Favorites"));
+ item.connect('activate', () => {
+ let favs = AppFavorites.getAppFavorites();
+ favs.addFavorite(this._source.app.get_id());
+ });
+ }
+ }
+
+ if (Shell.AppSystem.get_default().lookup_app('org.gnome.Software.desktop')) {
+ this._appendSeparator();
+ let item = this._appendMenuItem(_("Show Details"));
+ item.connect('activate', async () => {
+ let id = this._source.app.get_id();
+ let args = GLib.Variant.new('(ss)', [id, '']);
+ const bus = await Gio.DBus.get(Gio.BusType.SESSION, null);
+ bus.call(
+ 'org.gnome.Software',
+ '/org/gnome/Software',
+ 'org.gtk.Actions', 'Activate',
+ new GLib.Variant.new(
+ '(sava{sv})', ['details', [args], null]),
+ null, 0, -1, null);
+ Main.overview.hide();
+ });
+ }
+ }
+ }
+
+ _appendSeparator() {
+ let separator = new PopupMenu.PopupSeparatorMenuItem();
+ this.addMenuItem(separator);
+ }
+
+ _appendMenuItem(labelText) {
+ // FIXME: app-well-menu-item style
+ let item = new PopupMenu.PopupMenuItem(labelText);
+ this.addMenuItem(item);
+ return item;
+ }
+
+ popup(_activatingButton) {
+ this._rebuildMenu();
+ this.open();
+ }
+};
+Signals.addSignalMethods(AppIconMenu.prototype);
+
+var SystemActionIcon = GObject.registerClass(
+class SystemActionIcon extends Search.GridSearchResult {
+ activate() {
+ SystemActions.getDefault().activateAction(this.metaInfo['id']);
+ Main.overview.hide();
+ }
+});