/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ const Clutter = imports.gi.Clutter; const Signals = imports.signals; const Lang = imports.lang; const Meta = imports.gi.Meta; const Shell = imports.gi.Shell; const St = imports.gi.St; const Gettext = imports.gettext.domain('gnome-shell'); const _ = Gettext.gettext; const AppDisplay = imports.ui.appDisplay; const AppFavorites = imports.ui.appFavorites; const DND = imports.ui.dnd; const IconGrid = imports.ui.iconGrid; const Main = imports.ui.main; const Tweener = imports.ui.tweener; const Workspace = imports.ui.workspace; const DASH_ANIMATION_TIME = 0.2; // A container like StBin, but taking the child's scale into account // when requesting a size function DashItemContainer() { this._init(); } DashItemContainer.prototype = { _init: function() { this.actor = new Shell.GenericContainer({ style_class: 'dash-item-container' }); this.actor.connect('get-preferred-width', Lang.bind(this, this._getPreferredWidth)); this.actor.connect('get-preferred-height', Lang.bind(this, this._getPreferredHeight)); this.actor.connect('allocate', Lang.bind(this, this._allocate)); this.actor._delegate = this; this.child = null; this._childScale = 1; this._childOpacity = 255; }, _allocate: function(actor, box, flags) { if (this.child == null) return; let availWidth = box.x2 - box.x1; let availHeight = box.y2 - box.y1; let [minChildWidth, minChildHeight, natChildWidth, natChildHeight] = this.child.get_preferred_size(); let [childScaleX, childScaleY] = this.child.get_scale(); let childWidth = Math.min(natChildWidth * childScaleX, availWidth); let childHeight = Math.min(natChildHeight * childScaleY, availHeight); let childBox = new Clutter.ActorBox(); childBox.x1 = (availWidth - childWidth) / 2; childBox.y1 = (availHeight - childHeight) / 2; childBox.x2 = childBox.x1 + childWidth; childBox.y2 = childBox.y1 + childHeight; this.child.allocate(childBox, flags); }, _getPreferredHeight: function(actor, forWidth, alloc) { alloc.min_size = 0; alloc.natural_size = 0; if (this.child == null) return; let [minHeight, natHeight] = this.child.get_preferred_height(forWidth); alloc.min_size += minHeight * this.child.scale_y; alloc.natural_size += natHeight * this.child.scale_y; }, _getPreferredWidth: function(actor, forHeight, alloc) { alloc.min_size = 0; alloc.natural_size = 0; if (this.child == null) return; let [minWidth, natWidth] = this.child.get_preferred_width(forHeight); alloc.min_size = minWidth * this.child.scale_y; alloc.natural_size = natWidth * this.child.scale_y; }, setChild: function(actor) { if (this.child == actor) return; this.actor.destroy_children(); this.child = actor; this.actor.add_actor(this.child); }, animateIn: function() { if (this.child == null) return; this.childScale = 0; this.childOpacity = 0; Tweener.addTween(this, { childScale: 1.0, childOpacity: 255, time: DASH_ANIMATION_TIME, transition: 'easeOutQuad' }); }, animateOutAndDestroy: function() { if (this.child == null) { this.actor.destroy(); return; } this.childScale = 1.0; Tweener.addTween(this, { childScale: 0.0, childOpacity: 0, time: DASH_ANIMATION_TIME, transition: 'easeOutQuad', onComplete: Lang.bind(this, function() { this.actor.destroy(); }) }); }, set childScale(scale) { this._childScale = scale; if (this.child == null) return; this.child.set_scale_with_gravity(scale, scale, Clutter.Gravity.CENTER); this.actor.queue_relayout(); }, get childScale() { return this._childScale; }, set childOpacity(opacity) { this._childOpacity = opacity; if (this.child == null) return; this.child.set_opacity(opacity); this.actor.queue_redraw(); }, get childOpacity() { return this._childOpacity; } }; function RemoveFavoriteIcon() { this._init(); } RemoveFavoriteIcon.prototype = { __proto__: DashItemContainer.prototype, _init: function() { DashItemContainer.prototype._init.call(this); this._iconBin = new St.Bin({ style_class: 'remove-favorite' }); this._iconActor = null; this.icon = new IconGrid.BaseIcon(_("Remove"), { setSizeManually: true, showLabel: false, createIcon: Lang.bind(this, this._createIcon) }); this._iconBin.set_child(this.icon.actor); this._iconBin._delegate = this; this.setChild(this._iconBin); this.hiding = false; }, animateOutAndDestroy: function() { DashItemContainer.prototype.animateOutAndDestroy.call(this); this.hiding = true; }, _createIcon: function(size) { this._iconActor = new St.Icon({ icon_name: 'user-trash', style_class: 'remove-favorite-icon', icon_size: size }); return this._iconActor; }, setHover: function(hovered) { this._iconBin.set_hover(hovered); if (this._iconActor) this._iconActor.set_hover(hovered); }, // Rely on the dragged item being a favorite handleDragOver: function(source, actor, x, y, time) { return DND.DragMotionResult.MOVE_DROP; }, acceptDrop: function(source, actor, x, y, time) { let app = null; if (source instanceof AppDisplay.AppWellIcon) { let appSystem = Shell.AppSystem.get_default(); app = appSystem.get_app(source.getId()); } else if (source.metaWindow) { let tracker = Shell.WindowTracker.get_default(); app = tracker.get_window_app(source.metaWindow); } let id = app.get_id(); Meta.later_add(Meta.LaterType.BEFORE_REDRAW, Lang.bind(this, function () { AppFavorites.getAppFavorites().removeFavorite(id); return false; })); return true; } }; function DragPlaceholderItem() { this._init(); } DragPlaceholderItem.prototype = { __proto__: DashItemContainer.prototype, _init: function() { DashItemContainer.prototype._init.call(this); this.setChild(new St.Bin({ style_class: 'dash-placeholder' })); } }; function Dash() { this._init(); } Dash.prototype = { _init : function() { this._maxHeight = -1; this._iconSize = 64; this._shownInitially = false; this._dragPlaceholder = null; this._dragPlaceholderPos = -1; this._animatingPlaceholdersCount = 0; this._favRemoveTarget = null; this._box = new St.BoxLayout({ name: 'dash', vertical: true, clip_to_allocation: true }); this._box._delegate = this; this.actor = new St.Bin({ y_align: St.Align.START, child: this._box }); this.actor.connect('notify::height', Lang.bind(this, function() { if (this._maxHeight != this.actor.height) this._queueRedisplay(); this._maxHeight = this.actor.height; })); this._workId = Main.initializeDeferredWork(this._box, Lang.bind(this, this._redisplay)); this._tracker = Shell.WindowTracker.get_default(); this._appSystem = Shell.AppSystem.get_default(); this._appSystem.connect('installed-changed', Lang.bind(this, this._queueRedisplay)); AppFavorites.getAppFavorites().connect('changed', Lang.bind(this, this._queueRedisplay)); this._tracker.connect('app-state-changed', Lang.bind(this, this._queueRedisplay)); }, show: function() { this._itemDragBeginId = Main.overview.connect('item-drag-begin', Lang.bind(this, this._onDragBegin)); this._itemDragEndId = Main.overview.connect('item-drag-end', Lang.bind(this, this._onDragEnd)); this._windowDragBeginId = Main.overview.connect('window-drag-begin', Lang.bind(this, this._onDragBegin)); this._windowDragEndId = Main.overview.connect('window-drag-end', Lang.bind(this, this._onDragEnd)); }, hide: function() { Main.overview.disconnect(this._itemDragBeginId); Main.overview.disconnect(this._itemDragEndId); Main.overview.disconnect(this._windowDragBeginId); Main.overview.disconnect(this._windowDragEndId); }, _onDragBegin: function() { this._dragMonitor = { dragMotion: Lang.bind(this, this._onDragMotion) }; DND.addDragMonitor(this._dragMonitor); }, _onDragEnd: function() { this._clearDragPlaceholder(); if (this._favRemoveTarget) { this._favRemoveTarget.actor.hide(); this._adjustIconSize(); this._favRemoveTarget.actor.show(); this._favRemoveTarget.animateOutAndDestroy(); this._favRemoveTarget.actor.connect('destroy', Lang.bind(this, function() { this._favRemoveTarget = null; })); } DND.removeMonitor(this._dragMonitor); }, _onDragMotion: function(dragEvent) { let app = null; if (dragEvent.source instanceof AppDisplay.AppWellIcon) app = this._appSystem.get_app(dragEvent.source.getId()); else if (dragEvent.source.metaWindow) app = this._tracker.get_window_app(dragEvent.source.metaWindow); else return DND.DragMotionResult.CONTINUE; let id = app.get_id(); let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); let srcIsFavorite = (id in favorites); if (srcIsFavorite && this._favRemoveTarget == null) { this._favRemoveTarget = new RemoveFavoriteIcon(); this._favRemoveTarget.icon.setIconSize(this._iconSize); this._box.add(this._favRemoveTarget.actor); this._adjustIconSize(); this._favRemoveTarget.animateIn(); } let favRemoveHovered = false; if (this._favRemoveTarget) favRemoveHovered = this._favRemoveTarget.actor.contains(dragEvent.targetActor); if (!this._box.contains(dragEvent.targetActor) || favRemoveHovered) this._clearDragPlaceholder(); if (this._favRemoveTarget) this._favRemoveTarget.setHover(favRemoveHovered); return DND.DragMotionResult.CONTINUE; }, _appIdListToHash: function(apps) { let ids = {}; for (let i = 0; i < apps.length; i++) ids[apps[i].get_id()] = apps[i]; return ids; }, _queueRedisplay: function () { Main.queueDeferredWork(this._workId); }, _createAppItem: function(app) { let display = new AppDisplay.AppWellIcon(app, { setSizeManually: true, showLabel: false }); display._draggable.connect('drag-begin', Lang.bind(this, function() { display.actor.opacity = 50; })); display._draggable.connect('drag-end', Lang.bind(this, function() { display.actor.opacity = 255; })); let item = new DashItemContainer(); item.setChild(display.actor); display.icon.setIconSize(this._iconSize); return item; }, _adjustIconSize: function() { let children = this._box.get_children(); if (children.length == 0) { this._box.add_style_pseudo_class('empty'); return; } this._box.remove_style_pseudo_class('empty'); if (this._maxHeight == -1) return; let iconChildren = children.filter(function(actor) { return actor.visible && actor._delegate.child && actor._delegate.child._delegate && actor._delegate.child._delegate.icon; }); // Compute the amount of extra space (or missing space) we have // per icon with the current icon size let [minHeight, natHeight] = this.actor.get_preferred_height(-1); let diff = (this._maxHeight - natHeight) / iconChildren.length; let iconSizes = [ 16, 22, 24, 32, 48, 64 ]; let newIconSize = 16; for (let i = 0; i < iconSizes.length; i++) { if (iconSizes[i] < this._iconSize + diff) newIconSize = iconSizes[i]; } if (newIconSize == this._iconSize) return; let oldIconSize = this._iconSize; this._iconSize = newIconSize; let scale = oldIconSize / newIconSize; for (let i = 0; i < iconChildren.length; i++) { let icon = iconChildren[i]._delegate.child._delegate.icon; // Set the new size immediately, to keep the icons' sizes // in sync with this._iconSize icon.setIconSize(this._iconSize); // Don't animate the icon size change when the overview // is not visible or when initially filling the dash if (!Main.overview.visible || !this._shownInitially) continue; let [targetWidth, targetHeight] = icon.icon.get_size(); // Scale the icon's texture to the previous size and // tween to the new size icon.icon.set_size(icon.icon.width * scale, icon.icon.height * scale); Tweener.addTween(icon.icon, { width: targetWidth, height: targetHeight, time: DASH_ANIMATION_TIME, transition: 'easeOutQuad' }); } }, _redisplay: function () { let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); /* hardcode here pending some design about how exactly desktop contexts behave */ let contextId = ''; let running = this._tracker.get_running_apps(contextId); let children = this._box.get_children().filter(function(actor) { return actor._delegate.child && actor._delegate.child._delegate && actor._delegate.child._delegate.app; }); // Apps currently in the dash let oldApps = children.map(function(actor) { return actor._delegate.child._delegate.app; }); // Apps supposed to be in the dash let newApps = []; for (let id in favorites) newApps.push(favorites[id]); for (let i = 0; i < running.length; i++) { let app = running[i]; if (app.get_id() in favorites) continue; newApps.push(app); } // Figure out the actual changes to the list of items; we iterate // over both the list of items currently in the dash and the list // of items expected there, and collect additions and removals. // Moves are both an addition and a removal, where the order of // the operations depends on whether we encounter the position // where the item has been added first or the one from where it // was removed. // There is an assumption that only one item is moved at a given // time; when moving several items at once, everything will still // end up at the right position, but there might be additional // additions/removals (e.g. it might remove all the launchers // and add them back in the new order even if a smaller set of // additions and removals is possible). // If above assumptions turns out to be a problem, we might need // to use a more sophisticated algorithm, e.g. Longest Common // Subsequence as used by diff. let addedItems = []; let removedActors = []; let newIndex = 0; let oldIndex = 0; while (newIndex < newApps.length || oldIndex < oldApps.length) { // No change at oldIndex/newIndex if (oldApps[oldIndex] == newApps[newIndex]) { oldIndex++; newIndex++; continue; } // App removed at oldIndex if (oldApps[oldIndex] && newApps.indexOf(oldApps[oldIndex]) == -1) { removedActors.push(children[oldIndex]); oldIndex++; continue; } // App added at newIndex if (newApps[newIndex] && oldApps.indexOf(newApps[newIndex]) == -1) { addedItems.push({ app: newApps[newIndex], item: this._createAppItem(newApps[newIndex]), pos: newIndex }); newIndex++; continue; } // App moved let insertHere = newApps[newIndex + 1] && newApps[newIndex + 1] == oldApps[oldIndex]; let alreadyRemoved = removedActors.reduce(function(result, actor) { let removedApp = actor._delegate.child._delegate.app; return result || removedApp == newApps[newIndex]; }, false); if (insertHere || alreadyRemoved) { let newItem = this._createAppItem(newApps[newIndex]); addedItems.push({ app: newApps[newIndex], item: newItem, pos: newIndex + removedActors.length }); newIndex++; } else { removedActors.push(children[oldIndex]); oldIndex++; } } for (let i = 0; i < addedItems.length; i++) this._box.insert_actor(addedItems[i].item.actor, addedItems[i].pos); // Hide removed actors to not take them into account // when adjusting the icon size ... for (let i = 0; i < removedActors.length; i++) removedActors[i].hide(); // ... and do the same for the remove target if necessary if (this._favRemoveTarget && this._favRemoveTarget.hiding) this._favRemoveTarget.actor.hide(); this._adjustIconSize(); if (this._favRemoveTarget && this._favRemoveTarget.hiding) this._favRemoveTarget.actor.show(); // Skip animations on first run when adding the initial set // of items, to avoid all items zooming in at once if (!this._shownInitially) { this._shownInitially = true; return; } for (let i = 0; i < removedActors.length; i++) { removedActors[i].show(); let item = removedActors[i]._delegate; // Don't animate item removal when the overview is hidden if (Main.overview.visible) item.animateOutAndDestroy(); else item.actor.destroy(); } // Don't animate item addition when the overview is hidden if (!Main.overview.visible) return; for (let i = 0; i < addedItems.length; i++) addedItems[i].item.animateIn(); }, _clearDragPlaceholder: function() { if (this._dragPlaceholder) { this._dragPlaceholder.animateOutAndDestroy(); this._dragPlaceholder = null; this._dragPlaceholderPos = -1; } }, handleDragOver : function(source, actor, x, y, time) { let app = null; if (source instanceof AppDisplay.AppWellIcon) app = this._appSystem.get_app(source.getId()); else if (source.metaWindow) app = this._tracker.get_window_app(source.metaWindow); // Don't allow favoriting of transient apps if (app == null || app.is_transient()) return DND.DragMotionResult.NO_DROP; let favorites = AppFavorites.getAppFavorites().getFavorites(); let numFavorites = favorites.length; let favPos = favorites.indexOf(app); let children = this._box.get_children(); let numChildren = children.length; let boxHeight = this._box.height; // Keep the placeholder out of the index calculation; assuming that // the remove target has the same size as "normal" items, we don't // need to do the same adjustment there. if (this._dragPlaceholder) { boxHeight -= this._dragPlaceholder.actor.height; numChildren--; } let pos = Math.round(y * numChildren / boxHeight); if (pos != this._dragPlaceholderPos && pos <= numFavorites) { if (this._animatingPlaceholdersCount > 0) { let appChildren = children.filter(function(actor) { return actor._delegate && actor._delegate.child && actor._delegate.child._delegate && actor._delegate.child._delegate.app; }); this._dragPlaceholderPos = children.indexOf(appChildren[pos]); } else { this._dragPlaceholderPos = pos; } // Don't allow positioning before or after self if (favPos != -1 && (pos == favPos || pos == favPos + 1)) { if (this._dragPlaceholder) { this._dragPlaceholder.animateOutAndDestroy(); this._animatingPlaceholdersCount++; this._dragPlaceholder.actor.connect('destroy', Lang.bind(this, function() { this._animatingPlaceholdersCount--; })); } this._dragPlaceholder = null; return DND.DragMotionResult.CONTINUE; } // If the placeholder already exists, we just move // it, but if we are adding it, expand its size in // an animation let fadeIn; if (this._dragPlaceholder) { this._dragPlaceholder.actor.destroy(); fadeIn = false; } else { fadeIn = true; } this._dragPlaceholder = new DragPlaceholderItem(); this._box.insert_actor(this._dragPlaceholder.actor, this._dragPlaceholderPos); if (fadeIn) this._dragPlaceholder.animateIn(); } let srcIsFavorite = (favPos != -1); if (srcIsFavorite) return DND.DragMotionResult.MOVE_DROP; return DND.DragMotionResult.COPY_DROP; }, // Draggable target interface acceptDrop : function(source, actor, x, y, time) { let app = null; if (source instanceof AppDisplay.AppWellIcon) { app = this._appSystem.get_app(source.getId()); } else if (source.metaWindow) { app = this._tracker.get_window_app(source.metaWindow); } // Don't allow favoriting of transient apps if (app == null || app.is_transient()) { return false; } let id = app.get_id(); let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); let srcIsFavorite = (id in favorites); let favPos = 0; let children = this._box.get_children(); for (let i = 0; i < this._dragPlaceholderPos; i++) { if (this._dragPlaceholder && children[i] == this._dragPlaceholder.actor) continue; let childId = children[i]._delegate.child._delegate.app.get_id(); if (childId == id) continue; if (childId in favorites) favPos++; } Meta.later_add(Meta.LaterType.BEFORE_REDRAW, Lang.bind(this, function () { let appFavorites = AppFavorites.getAppFavorites(); if (srcIsFavorite) appFavorites.moveFavoriteToPos(id, favPos); else appFavorites.addFavoriteAtPos(id, favPos); return false; })); return true; } }; Signals.addSignalMethods(Dash.prototype);