From f9d480cfe50ca1d7a0f0b5a2b8bb9932962bfbe7 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 27 Apr 2024 17:07:22 +0200 Subject: Adding upstream version 3.38.6. Signed-off-by: Daniel Baumann --- js/ui/appDisplay.js | 2998 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2998 insertions(+) create mode 100644 js/ui/appDisplay.js (limited to 'js/ui/appDisplay.js') 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(); + } +}); -- cgit v1.2.3