summaryrefslogtreecommitdiffstats
path: root/js/ui/dash.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/ui/dash.js')
-rw-r--r--js/ui/dash.js992
1 files changed, 992 insertions, 0 deletions
diff --git a/js/ui/dash.js b/js/ui/dash.js
new file mode 100644
index 0000000..165f8ea
--- /dev/null
+++ b/js/ui/dash.js
@@ -0,0 +1,992 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Dash */
+
+const { Clutter, GLib, GObject, Graphene, Meta, Shell, St } = imports.gi;
+
+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 Overview = imports.ui.overview;
+
+var DASH_ANIMATION_TIME = 200;
+var DASH_ITEM_LABEL_SHOW_TIME = 150;
+var DASH_ITEM_LABEL_HIDE_TIME = 100;
+var DASH_ITEM_HOVER_TIMEOUT = 300;
+
+function getAppFromSource(source) {
+ if (source instanceof AppDisplay.AppIcon)
+ return source.app;
+ else
+ return null;
+}
+
+var DashIcon = GObject.registerClass(
+class DashIcon extends AppDisplay.AppIcon {
+ _init(app) {
+ super._init(app, {
+ setSizeManually: true,
+ showLabel: false,
+ });
+ }
+
+ popupMenu() {
+ super.popupMenu(St.Side.BOTTOM);
+ }
+
+ // Disable scale-n-fade methods used during DND by parent
+ scaleAndFade() {
+ }
+
+ undoScaleAndFade() {
+ }
+
+ handleDragOver() {
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ acceptDrop() {
+ return false;
+ }
+});
+
+// A container like StBin, but taking the child's scale into account
+// when requesting a size
+var DashItemContainer = GObject.registerClass(
+class DashItemContainer extends St.Widget {
+ _init() {
+ super._init({
+ style_class: 'dash-item-container',
+ pivot_point: new Graphene.Point({ x: .5, y: .5 }),
+ layout_manager: new Clutter.BinLayout(),
+ scale_x: 0,
+ scale_y: 0,
+ opacity: 0,
+ x_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._labelText = "";
+ this.label = new St.Label({ style_class: 'dash-label' });
+ this.label.hide();
+ Main.layoutManager.addChrome(this.label);
+ this.label.connectObject('destroy', () => (this.label = null), this);
+ this.label_actor = this.label;
+
+ this.child = null;
+ this.animatingOut = false;
+
+ this.connect('notify::scale-x', () => this.queue_relayout());
+ this.connect('notify::scale-y', () => this.queue_relayout());
+
+ this.connect('destroy', () => {
+ if (this.child != null)
+ this.child.destroy();
+ this.label?.destroy();
+ });
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ let themeNode = this.get_theme_node();
+ forWidth = themeNode.adjust_for_width(forWidth);
+ let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth);
+ return themeNode.adjust_preferred_height(minHeight * this.scale_y,
+ natHeight * this.scale_y);
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ let themeNode = this.get_theme_node();
+ forHeight = themeNode.adjust_for_height(forHeight);
+ let [minWidth, natWidth] = super.vfunc_get_preferred_width(forHeight);
+ return themeNode.adjust_preferred_width(minWidth * this.scale_x,
+ natWidth * this.scale_x);
+ }
+
+ showLabel() {
+ if (!this._labelText)
+ return;
+
+ this.label.set_text(this._labelText);
+ this.label.opacity = 0;
+ this.label.show();
+
+ let [stageX, stageY] = this.get_transformed_position();
+
+ const itemWidth = this.allocation.get_width();
+
+ const labelWidth = this.label.get_width();
+ const xOffset = Math.floor((itemWidth - labelWidth) / 2);
+ const x = Math.clamp(stageX + xOffset, 0, global.stage.width - labelWidth);
+
+ let node = this.label.get_theme_node();
+ const yOffset = node.get_length('-y-offset');
+
+ const y = stageY - this.label.height - yOffset;
+
+ this.label.set_position(x, y);
+ this.label.ease({
+ opacity: 255,
+ duration: DASH_ITEM_LABEL_SHOW_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ setLabelText(text) {
+ this._labelText = text;
+ this.child.accessible_name = text;
+ }
+
+ hideLabel() {
+ this.label.ease({
+ opacity: 0,
+ duration: DASH_ITEM_LABEL_HIDE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.label.hide(),
+ });
+ }
+
+ setChild(actor) {
+ if (this.child == actor)
+ return;
+
+ this.destroy_all_children();
+
+ this.child = actor;
+ this.child.y_expand = true;
+ this.add_actor(this.child);
+ }
+
+ show(animate) {
+ if (this.child == null)
+ return;
+
+ let time = animate ? DASH_ANIMATION_TIME : 0;
+ this.ease({
+ scale_x: 1,
+ scale_y: 1,
+ opacity: 255,
+ duration: time,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ animateOutAndDestroy() {
+ this.label.hide();
+
+ if (this.child == null) {
+ this.destroy();
+ return;
+ }
+
+ this.animatingOut = true;
+ this.ease({
+ scale_x: 0,
+ scale_y: 0,
+ opacity: 0,
+ duration: DASH_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.destroy(),
+ });
+ }
+});
+
+var ShowAppsIcon = GObject.registerClass(
+class ShowAppsIcon extends DashItemContainer {
+ _init() {
+ super._init();
+
+ this.toggleButton = new St.Button({
+ style_class: 'show-apps',
+ track_hover: true,
+ can_focus: true,
+ toggle_mode: true,
+ });
+ this._iconActor = null;
+ this.icon = new IconGrid.BaseIcon(_('Show Applications'), {
+ setSizeManually: true,
+ showLabel: false,
+ createIcon: this._createIcon.bind(this),
+ });
+ this.icon.y_align = Clutter.ActorAlign.CENTER;
+
+ this.toggleButton.add_actor(this.icon);
+ this.toggleButton._delegate = this;
+
+ this.setChild(this.toggleButton);
+ this.setDragApp(null);
+ }
+
+ _createIcon(size) {
+ this._iconActor = new St.Icon({
+ icon_name: 'view-app-grid-symbolic',
+ icon_size: size,
+ style_class: 'show-apps-icon',
+ track_hover: true,
+ });
+ return this._iconActor;
+ }
+
+ _canRemoveApp(app) {
+ if (app == null)
+ return false;
+
+ if (!global.settings.is_writable('favorite-apps'))
+ return false;
+
+ let id = app.get_id();
+ let isFavorite = AppFavorites.getAppFavorites().isFavorite(id);
+ return isFavorite;
+ }
+
+ setDragApp(app) {
+ let canRemove = this._canRemoveApp(app);
+
+ this.toggleButton.set_hover(canRemove);
+ if (this._iconActor)
+ this._iconActor.set_hover(canRemove);
+
+ if (canRemove)
+ this.setLabelText(_('Unpin'));
+ else
+ this.setLabelText(_("Show Applications"));
+ }
+
+ handleDragOver(source, _actor, _x, _y, _time) {
+ if (!this._canRemoveApp(getAppFromSource(source)))
+ return DND.DragMotionResult.NO_DROP;
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source, _actor, _x, _y, _time) {
+ let app = getAppFromSource(source);
+ if (!this._canRemoveApp(app))
+ return false;
+
+ let id = app.get_id();
+
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ AppFavorites.getAppFavorites().removeFavorite(id);
+ return false;
+ });
+
+ return true;
+ }
+});
+
+var DragPlaceholderItem = GObject.registerClass(
+class DragPlaceholderItem extends DashItemContainer {
+ _init() {
+ super._init();
+ this.setChild(new St.Bin({ style_class: 'placeholder' }));
+ }
+});
+
+var EmptyDropTargetItem = GObject.registerClass(
+class EmptyDropTargetItem extends DashItemContainer {
+ _init() {
+ super._init();
+ this.setChild(new St.Bin({ style_class: 'empty-dash-drop-target' }));
+ }
+});
+
+const DashIconsLayout = GObject.registerClass(
+class DashIconsLayout extends Clutter.BoxLayout {
+ _init() {
+ super._init({
+ orientation: Clutter.Orientation.HORIZONTAL,
+ });
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ const [, natWidth] = super.vfunc_get_preferred_width(container, forHeight);
+ return [0, natWidth];
+ }
+});
+
+const baseIconSizes = [16, 22, 24, 32, 48, 64];
+
+var Dash = GObject.registerClass({
+ Signals: { 'icon-size-changed': {} },
+}, class Dash extends St.Widget {
+ _init() {
+ this._maxWidth = -1;
+ this._maxHeight = -1;
+ this.iconSize = 64;
+ this._shownInitially = false;
+
+ this._separator = null;
+ this._dragPlaceholder = null;
+ this._dragPlaceholderPos = -1;
+ this._animatingPlaceholdersCount = 0;
+ this._showLabelTimeoutId = 0;
+ this._resetHoverTimeoutId = 0;
+ this._labelShowing = false;
+
+ super._init({
+ name: 'dash',
+ offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS,
+ layout_manager: new Clutter.BinLayout(),
+ });
+
+ this._dashContainer = new St.BoxLayout({
+ x_align: Clutter.ActorAlign.CENTER,
+ y_expand: true,
+ });
+
+ this._box = new St.Widget({
+ clip_to_allocation: true,
+ layout_manager: new DashIconsLayout(),
+ y_expand: true,
+ });
+ this._box._delegate = this;
+
+ this._dashContainer.add_child(this._box);
+
+ this._showAppsIcon = new ShowAppsIcon();
+ this._showAppsIcon.show(false);
+ this._showAppsIcon.icon.setIconSize(this.iconSize);
+ this._hookUpLabel(this._showAppsIcon);
+ this._dashContainer.add_child(this._showAppsIcon);
+
+ this.showAppsButton = this._showAppsIcon.toggleButton;
+
+ this._background = new St.Widget({
+ style_class: 'dash-background',
+ });
+
+ const sizerBox = new Clutter.Actor();
+ sizerBox.add_constraint(new Clutter.BindConstraint({
+ source: this._showAppsIcon.icon,
+ coordinate: Clutter.BindCoordinate.HEIGHT,
+ }));
+ sizerBox.add_constraint(new Clutter.BindConstraint({
+ source: this._dashContainer,
+ coordinate: Clutter.BindCoordinate.WIDTH,
+ }));
+ this._background.add_child(sizerBox);
+
+ this.add_child(this._background);
+ this.add_child(this._dashContainer);
+
+ this._workId = Main.initializeDeferredWork(this._box, this._redisplay.bind(this));
+
+ this._appSystem = Shell.AppSystem.get_default();
+
+ this._appSystem.connect('installed-changed', () => {
+ AppFavorites.getAppFavorites().reload();
+ this._queueRedisplay();
+ });
+ AppFavorites.getAppFavorites().connect('changed', this._queueRedisplay.bind(this));
+ this._appSystem.connect('app-state-changed', this._queueRedisplay.bind(this));
+
+ Main.overview.connect('item-drag-begin',
+ this._onItemDragBegin.bind(this));
+ Main.overview.connect('item-drag-end',
+ this._onItemDragEnd.bind(this));
+ Main.overview.connect('item-drag-cancelled',
+ this._onItemDragCancelled.bind(this));
+ Main.overview.connect('window-drag-begin',
+ this._onWindowDragBegin.bind(this));
+ Main.overview.connect('window-drag-cancelled',
+ this._onWindowDragEnd.bind(this));
+ Main.overview.connect('window-drag-end',
+ this._onWindowDragEnd.bind(this));
+
+ // Translators: this is the name of the dock/favorites area on
+ // the left of the overview
+ Main.ctrlAltTabManager.addGroup(this, _("Dash"), 'user-bookmarks-symbolic');
+ }
+
+ _onItemDragBegin() {
+ this._dragCancelled = false;
+ this._dragMonitor = {
+ dragMotion: this._onItemDragMotion.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+
+ if (this._box.get_n_children() == 0) {
+ this._emptyDropTarget = new EmptyDropTargetItem();
+ this._box.insert_child_at_index(this._emptyDropTarget, 0);
+ this._emptyDropTarget.show(true);
+ }
+ }
+
+ _onItemDragCancelled() {
+ this._dragCancelled = true;
+ this._endItemDrag();
+ }
+
+ _onItemDragEnd() {
+ if (this._dragCancelled)
+ return;
+
+ this._endItemDrag();
+ }
+
+ _endItemDrag() {
+ this._clearDragPlaceholder();
+ this._clearEmptyDropTarget();
+ this._showAppsIcon.setDragApp(null);
+ DND.removeDragMonitor(this._dragMonitor);
+ }
+
+ _onItemDragMotion(dragEvent) {
+ let app = getAppFromSource(dragEvent.source);
+ if (app == null)
+ return DND.DragMotionResult.CONTINUE;
+
+ let showAppsHovered =
+ this._showAppsIcon.contains(dragEvent.targetActor);
+
+ if (!this._box.contains(dragEvent.targetActor) || showAppsHovered)
+ this._clearDragPlaceholder();
+
+ if (showAppsHovered)
+ this._showAppsIcon.setDragApp(app);
+ else
+ this._showAppsIcon.setDragApp(null);
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _onWindowDragBegin() {
+ this.ease({
+ opacity: 128,
+ duration: Overview.ANIMATION_TIME / 2,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _onWindowDragEnd() {
+ this.ease({
+ opacity: 255,
+ duration: Overview.ANIMATION_TIME / 2,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ });
+ }
+
+ _appIdListToHash(apps) {
+ let ids = {};
+ for (let i = 0; i < apps.length; i++)
+ ids[apps[i].get_id()] = apps[i];
+ return ids;
+ }
+
+ _queueRedisplay() {
+ Main.queueDeferredWork(this._workId);
+ }
+
+ _hookUpLabel(item, appIcon) {
+ item.child.connect('notify::hover', () => {
+ this._syncLabel(item, appIcon);
+ });
+
+ item.child.connect('clicked', () => {
+ this._labelShowing = false;
+ item.hideLabel();
+ });
+
+ Main.overview.connectObject('hiding', () => {
+ this._labelShowing = false;
+ item.hideLabel();
+ }, item.child);
+
+ if (appIcon) {
+ appIcon.connect('sync-tooltip', () => {
+ this._syncLabel(item, appIcon);
+ });
+ }
+ }
+
+ _createAppItem(app) {
+ let appIcon = new DashIcon(app);
+
+ appIcon.connect('menu-state-changed',
+ (o, opened) => {
+ this._itemMenuStateChanged(item, opened);
+ });
+
+ let item = new DashItemContainer();
+ item.setChild(appIcon);
+
+ // Override default AppIcon label_actor, now the
+ // accessible_name is set at DashItemContainer.setLabelText
+ appIcon.label_actor = null;
+ item.setLabelText(app.get_name());
+
+ appIcon.icon.setIconSize(this.iconSize);
+ this._hookUpLabel(item, appIcon);
+
+ return item;
+ }
+
+ _itemMenuStateChanged(item, opened) {
+ // When the menu closes, it calls sync_hover, which means
+ // that the notify::hover handler does everything we need to.
+ if (opened) {
+ if (this._showLabelTimeoutId > 0) {
+ GLib.source_remove(this._showLabelTimeoutId);
+ this._showLabelTimeoutId = 0;
+ }
+
+ item.hideLabel();
+ }
+ }
+
+ _syncLabel(item, appIcon) {
+ let shouldShow = appIcon ? appIcon.shouldShowTooltip() : item.child.get_hover();
+
+ if (shouldShow) {
+ if (this._showLabelTimeoutId == 0) {
+ let timeout = this._labelShowing ? 0 : DASH_ITEM_HOVER_TIMEOUT;
+ this._showLabelTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout,
+ () => {
+ this._labelShowing = true;
+ item.showLabel();
+ this._showLabelTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._showLabelTimeoutId, '[gnome-shell] item.showLabel');
+ if (this._resetHoverTimeoutId > 0) {
+ GLib.source_remove(this._resetHoverTimeoutId);
+ this._resetHoverTimeoutId = 0;
+ }
+ }
+ } else {
+ if (this._showLabelTimeoutId > 0)
+ GLib.source_remove(this._showLabelTimeoutId);
+ this._showLabelTimeoutId = 0;
+ item.hideLabel();
+ if (this._labelShowing) {
+ this._resetHoverTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DASH_ITEM_HOVER_TIMEOUT,
+ () => {
+ this._labelShowing = false;
+ this._resetHoverTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._resetHoverTimeoutId, '[gnome-shell] this._labelShowing');
+ }
+ }
+ }
+
+ _adjustIconSize() {
+ // 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(actor => {
+ return actor.child &&
+ actor.child._delegate &&
+ actor.child._delegate.icon &&
+ !actor.animatingOut;
+ });
+
+ iconChildren.push(this._showAppsIcon);
+
+ if (this._maxWidth === -1 || this._maxHeight === -1)
+ return;
+
+ const themeNode = this.get_theme_node();
+ const maxAllocation = new Clutter.ActorBox({
+ x1: 0,
+ y1: 0,
+ x2: this._maxWidth,
+ y2: 42, /* whatever */
+ });
+ let maxContent = themeNode.get_content_box(maxAllocation);
+ let availWidth = maxContent.x2 - maxContent.x1;
+ let spacing = themeNode.get_length('spacing');
+
+ let firstButton = iconChildren[0].child;
+ let firstIcon = firstButton._delegate.icon;
+
+ // Enforce valid spacings during the size request
+ firstIcon.icon.ensure_style();
+ const [, , iconWidth, iconHeight] = firstIcon.icon.get_preferred_size();
+ const [, , buttonWidth, buttonHeight] = firstButton.get_preferred_size();
+
+ // Subtract icon padding and box spacing from the available width
+ availWidth -= iconChildren.length * (buttonWidth - iconWidth) +
+ (iconChildren.length - 1) * spacing;
+
+ let availHeight = this._maxHeight;
+ availHeight -= this.margin_top + this.margin_bottom;
+ availHeight -= this._background.get_theme_node().get_vertical_padding();
+ availHeight -= themeNode.get_vertical_padding();
+ availHeight -= buttonHeight - iconHeight;
+
+ const maxIconSize = Math.min(availWidth / iconChildren.length, availHeight);
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let iconSizes = baseIconSizes.map(s => s * scaleFactor);
+
+ let newIconSize = baseIconSizes[0];
+ for (let i = 0; i < iconSizes.length; i++) {
+ if (iconSizes[i] <= maxIconSize)
+ newIconSize = baseIconSizes[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].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 transitioning, not visible or when initially filling
+ // the dash
+ if (!Main.overview.visible || Main.overview.animationInProgress ||
+ !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.icon.ease({
+ width: targetWidth,
+ height: targetHeight,
+ duration: DASH_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ if (this._separator) {
+ this._separator.ease({
+ height: this.iconSize,
+ duration: DASH_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ }
+
+ _redisplay() {
+ let favorites = AppFavorites.getAppFavorites().getFavoriteMap();
+
+ let running = this._appSystem.get_running();
+
+ let children = this._box.get_children().filter(actor => {
+ return actor.child &&
+ actor.child._delegate &&
+ actor.child._delegate.app;
+ });
+ // Apps currently in the dash
+ let oldApps = children.map(actor => actor.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) {
+ let oldApp = oldApps.length > oldIndex ? oldApps[oldIndex] : null;
+ let newApp = newApps.length > newIndex ? newApps[newIndex] : null;
+
+ // No change at oldIndex/newIndex
+ if (oldApp == newApp) {
+ oldIndex++;
+ newIndex++;
+ continue;
+ }
+
+ // App removed at oldIndex
+ if (oldApp && !newApps.includes(oldApp)) {
+ removedActors.push(children[oldIndex]);
+ oldIndex++;
+ continue;
+ }
+
+ // App added at newIndex
+ if (newApp && !oldApps.includes(newApp)) {
+ addedItems.push({
+ app: newApp,
+ item: this._createAppItem(newApp),
+ pos: newIndex,
+ });
+ newIndex++;
+ continue;
+ }
+
+ // App moved
+ let nextApp = newApps.length > newIndex + 1
+ ? newApps[newIndex + 1] : null;
+ let insertHere = nextApp && nextApp == oldApp;
+ let alreadyRemoved = removedActors.reduce((result, actor) => {
+ let removedApp = actor.child._delegate.app;
+ return result || removedApp == newApp;
+ }, false);
+
+ if (insertHere || alreadyRemoved) {
+ let newItem = this._createAppItem(newApp);
+ addedItems.push({
+ app: newApp,
+ 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,
+ addedItems[i].pos);
+ }
+
+ for (let i = 0; i < removedActors.length; i++) {
+ let item = removedActors[i];
+
+ // Don't animate item removal when the overview is transitioning
+ // or hidden
+ if (Main.overview.visible && !Main.overview.animationInProgress)
+ item.animateOutAndDestroy();
+ else
+ item.destroy();
+ }
+
+ this._adjustIconSize();
+
+ // Skip animations on first run when adding the initial set
+ // of items, to avoid all items zooming in at once
+
+ let animate = this._shownInitially && Main.overview.visible &&
+ !Main.overview.animationInProgress;
+
+ if (!this._shownInitially)
+ this._shownInitially = true;
+
+ for (let i = 0; i < addedItems.length; i++)
+ addedItems[i].item.show(animate);
+
+ // Update separator
+ const nFavorites = Object.keys(favorites).length;
+ const nIcons = children.length + addedItems.length - removedActors.length;
+ if (nFavorites > 0 && nFavorites < nIcons) {
+ if (!this._separator) {
+ this._separator = new St.Widget({
+ style_class: 'dash-separator',
+ y_align: Clutter.ActorAlign.CENTER,
+ height: this.iconSize,
+ });
+ this._box.add_child(this._separator);
+ }
+ let pos = nFavorites + this._animatingPlaceholdersCount;
+ if (this._dragPlaceholder)
+ pos++;
+ this._box.set_child_at_index(this._separator, pos);
+ } else if (this._separator) {
+ this._separator.destroy();
+ this._separator = null;
+ }
+
+ // Workaround for https://bugzilla.gnome.org/show_bug.cgi?id=692744
+ // Without it, StBoxLayout may use a stale size cache
+ this._box.queue_relayout();
+ }
+
+ _clearDragPlaceholder() {
+ if (this._dragPlaceholder) {
+ this._animatingPlaceholdersCount++;
+ this._dragPlaceholder.connect('destroy', () => {
+ this._animatingPlaceholdersCount--;
+ });
+ this._dragPlaceholder.animateOutAndDestroy();
+ this._dragPlaceholder = null;
+ }
+ this._dragPlaceholderPos = -1;
+ }
+
+ _clearEmptyDropTarget() {
+ if (this._emptyDropTarget) {
+ this._emptyDropTarget.animateOutAndDestroy();
+ this._emptyDropTarget = null;
+ }
+ }
+
+ handleDragOver(source, actor, x, _y, _time) {
+ let app = getAppFromSource(source);
+
+ // Don't allow favoriting of transient apps
+ if (app == null || app.is_window_backed())
+ return DND.DragMotionResult.NO_DROP;
+
+ if (!global.settings.is_writable('favorite-apps'))
+ 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 boxWidth = this._box.width;
+
+ // 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) {
+ boxWidth -= this._dragPlaceholder.width;
+ numChildren--;
+ }
+
+ // Same with the separator
+ if (this._separator) {
+ boxWidth -= this._separator.width;
+ numChildren--;
+ }
+
+ let pos;
+ if (this._emptyDropTarget)
+ pos = 0; // always insert at the start when dash is empty
+ else if (this.text_direction === Clutter.TextDirection.RTL)
+ pos = numChildren - Math.floor(x * numChildren / boxWidth);
+ else
+ pos = Math.floor(x * numChildren / boxWidth);
+
+ // Put the placeholder after the last favorite if we are not
+ // in the favorites zone
+ if (pos > numFavorites)
+ pos = numFavorites;
+
+ if (pos !== this._dragPlaceholderPos && this._animatingPlaceholdersCount === 0) {
+ this._dragPlaceholderPos = pos;
+
+ // Don't allow positioning before or after self
+ if (favPos != -1 && (pos == favPos || pos == favPos + 1)) {
+ this._clearDragPlaceholder();
+ 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.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,
+ this._dragPlaceholderPos);
+ this._dragPlaceholder.show(fadeIn);
+ }
+
+ 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(source, _actor, _x, _y, _time) {
+ let app = getAppFromSource(source);
+
+ // Don't allow favoriting of transient apps
+ if (app == null || app.is_window_backed())
+ return false;
+
+ if (!global.settings.is_writable('favorite-apps'))
+ 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)
+ continue;
+
+ let childId = children[i].child._delegate.app.get_id();
+ if (childId == id)
+ continue;
+ if (childId in favorites)
+ favPos++;
+ }
+
+ // No drag placeholder means we don't want 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, () => {
+ let appFavorites = AppFavorites.getAppFavorites();
+ if (srcIsFavorite)
+ appFavorites.moveFavoriteToPos(id, favPos);
+ else
+ appFavorites.addFavoriteAtPos(id, favPos);
+ return false;
+ });
+
+ return true;
+ }
+
+ setMaxSize(maxWidth, maxHeight) {
+ if (this._maxWidth === maxWidth &&
+ this._maxHeight === maxHeight)
+ return;
+
+ this._maxWidth = maxWidth;
+ this._maxHeight = maxHeight;
+ this._queueRedisplay();
+ }
+});