// -*- mode: js; js-indent-level: 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 Mainloop = imports.mainloop; 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; const DASH_ITEM_LABEL_SHOW_TIME = 0.15; const DASH_ITEM_LABEL_HIDE_TIME = 0.1; const DASH_ITEM_HOVER_TIMEOUT = 300; // A container like StBin, but taking the child's scale into account // when requesting a size const DashItemContainer = new Lang.Class({ Name: 'DashItemContainer', _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._label = null; this.child = null; this._childScale = 1; this._childOpacity = 255; this.animatingOut = false; }, _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; }, showLabel: function() { if (this._label == null) return; this._label.opacity = 0; this._label.show(); let [stageX, stageY] = this.actor.get_transformed_position(); let itemHeight = this.actor.allocation.y2 - this.actor.allocation.y1; let labelHeight = this._label.get_height(); let yOffset = Math.floor((itemHeight - labelHeight) / 2) let y = stageY + yOffset; let node = this._label.get_theme_node(); let xOffset = node.get_length('-x-offset'); let x; if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) x = stageX - this._label.get_width() - xOffset; else x = stageX + this.actor.get_width() + xOffset; this._label.set_position(x, y); Tweener.addTween(this._label, { opacity: 255, time: DASH_ITEM_LABEL_SHOW_TIME, transition: 'easeOutQuad', }); }, setLabelText: function(text) { if (this._label == null) this._label = new St.Label({ style_class: 'dash-label'}); this._label.set_text(text); Main.layoutManager.addChrome(this._label); this._label.hide(); }, hideLabel: function () { this._label.opacity = 255; Tweener.addTween(this._label, { opacity: 0, time: DASH_ITEM_LABEL_HIDE_TIME, transition: 'easeOutQuad', onComplete: Lang.bind(this, function() { this._label.hide(); }) }); }, setChild: function(actor) { if (this.child == actor) return; this.actor.destroy_all_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.animatingOut = true; 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; } }); const RemoveFavoriteIcon = new Lang.Class({ Name: 'RemoveFavoriteIcon', Extends: DashItemContainer, _init: function() { this.parent(); 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); }, _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.lookup_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; } }); const DragPlaceholderItem = new Lang.Class({ Name: 'DragPlaceholderItem', Extends: DashItemContainer, _init: function() { this.parent(); this.setChild(new St.Bin({ style_class: 'placeholder' })); } }); const Dash = new Lang.Class({ Name: 'Dash', _init : function() { this._maxHeight = -1; this.iconSize = 64; this._shownInitially = false; this._dragPlaceholder = null; this._dragPlaceholderPos = -1; this._animatingPlaceholdersCount = 0; this._favRemoveTarget = null; this._showLabelTimeoutId = 0; this._resetHoverTimeoutId = 0; this._labelShowing = false; 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._appSystem.connect('app-state-changed', Lang.bind(this, this._queueRedisplay)); Main.overview.connect('item-drag-begin', Lang.bind(this, this._onDragBegin)); Main.overview.connect('item-drag-end', Lang.bind(this, this._onDragEnd)); Main.overview.connect('item-drag-cancelled', Lang.bind(this, this._onDragCancelled)); Main.overview.connect('window-drag-begin', Lang.bind(this, this._onDragBegin)); Main.overview.connect('window-drag-cancelled', Lang.bind(this, this._onDragCancelled)); Main.overview.connect('window-drag-end', Lang.bind(this, this._onDragEnd)); }, _onDragBegin: function() { this._dragCancelled = false; this._dragMonitor = { dragMotion: Lang.bind(this, this._onDragMotion) }; DND.addDragMonitor(this._dragMonitor); }, _onDragCancelled: function() { this._dragCancelled = true; this._endDrag(); }, _onDragEnd: function() { if (this._dragCancelled) return; this._endDrag(); }, _endDrag: function() { this._clearDragPlaceholder(); if (this._favRemoveTarget) { this._favRemoveTarget.animateOutAndDestroy(); this._favRemoveTarget.actor.connect('destroy', Lang.bind(this, function() { this._favRemoveTarget = null; })); this._adjustIconSize(); } DND.removeDragMonitor(this._dragMonitor); }, _onDragMotion: function(dragEvent) { let app = null; if (dragEvent.source instanceof AppDisplay.AppWellIcon) app = this._appSystem.lookup_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 && dragEvent.source.actor && this.actor.contains (dragEvent.source.actor) && 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); item.setLabelText(app.get_name()); display.icon.setIconSize(this.iconSize); display.actor.connect('notify::hover', Lang.bind(this, function() { this._onHover(item, display) })); return item; }, _onHover: function (item, display) { if (display.actor.get_hover() && !display.isMenuUp) { if (this._showLabelTimeoutId == 0) { let timeout = this._labelShowing ? 0 : DASH_ITEM_HOVER_TIMEOUT; this._showLabelTimeoutId = Mainloop.timeout_add(timeout, Lang.bind(this, function() { this._labelShowing = true; item.showLabel(); return false; })); if (this._resetHoverTimeoutId > 0) { Mainloop.source_remove(this._resetHoverTimeoutId); this._resetHoverTimeoutId = 0; } } } else { if (this._showLabelTimeoutId > 0) Mainloop.source_remove(this._showLabelTimeoutId); this._showLabelTimeoutId = 0; item.hideLabel(); if (this._labelShowing) { this._resetHoverTimeoutId = Mainloop.timeout_add(DASH_ITEM_HOVER_TIMEOUT, Lang.bind(this, function() { this._labelShowing = false; return false; })); } } }, _adjustIconSize: function() { // For the icon size, we only consider children which are "proper" // icons (i.e. ignoring drag placeholders) and which are not // animating out (which means they will be destroyed at the end of // the animation) let iconChildren = this._box.get_children().filter(function(actor) { return actor._delegate.child && actor._delegate.child._delegate && actor._delegate.child._delegate.icon && !actor._delegate.animatingOut; }); if (iconChildren.length == 0) { this._box.add_style_pseudo_class('empty'); return; } this._box.remove_style_pseudo_class('empty'); if (this._maxHeight == -1) return; let themeNode = this._box.get_theme_node(); let maxAllocation = new Clutter.ActorBox({ x1: 0, y1: 0, x2: 42 /* whatever */, y2: this._maxHeight }); let maxContent = themeNode.get_content_box(maxAllocation); let availHeight = maxContent.y2 - maxContent.y1; let spacing = themeNode.get_length('spacing'); let firstIcon = iconChildren[0]._delegate.child._delegate.icon; let minHeight, natHeight; // Enforce the current icon size during the size request if // the icon is animating if (firstIcon._animating) { let [currentWidth, currentHeight] = firstIcon.icon.get_size(); firstIcon.icon.set_size(this.iconSize, this.iconSize); [minHeight, natHeight] = iconChildren[0].get_preferred_height(-1); firstIcon.icon.set_size(currentWidth, currentHeight); } else { [minHeight, natHeight] = iconChildren[0].get_preferred_height(-1); } // Subtract icon padding and box spacing from the available height availHeight -= iconChildren.length * (natHeight - this.iconSize) + (iconChildren.length - 1) * spacing; let availSize = availHeight / iconChildren.length; let iconSizes = [ 16, 22, 24, 32, 48, 64 ]; let newIconSize = 16; for (let i = 0; i < iconSizes.length; i++) { if (iconSizes[i] < availSize) newIconSize = iconSizes[i]; } if (newIconSize == this.iconSize) return; let oldIconSize = this.iconSize; this.iconSize = newIconSize; this.emit('icon-size-changed'); 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); icon._animating = true; Tweener.addTween(icon.icon, { width: targetWidth, height: targetHeight, time: DASH_ANIMATION_TIME, transition: 'easeOutQuad', onComplete: function() { icon._animating = false; } }); } }, _redisplay: function () { let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); let running = this._appSystem.get_running(); 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_child_at_index(addedItems[i].item.actor, addedItems[i].pos); for (let i = 0; i < removedActors.length; i++) { 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(); } this._adjustIconSize(); // 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; } // 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.lookup_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_window_backed()) 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._dragPlaceholder.child.set_width (this.iconSize); this._dragPlaceholder.child.set_height (this.iconSize / 2); this._box.insert_child_at_index(this._dragPlaceholder.actor, this._dragPlaceholderPos); if (fadeIn) this._dragPlaceholder.animateIn(); } // Remove the drag placeholder if we are not in the // "favorites zone" if (pos > numFavorites && this._dragPlaceholder) { this._clearDragPlaceholder(); } if (!this._dragPlaceholder) return DND.DragMotionResult.NO_DROP; 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.lookup_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_window_backed()) { 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++; } // No drag placeholder means we don't wan't to favorite the app // and we are dragging it to its original position if (!this._dragPlaceholder) return true; 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);