// -*- 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; 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 }), 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_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.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.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(_("Remove from Favorites")); 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.BoxLayout { _init() { this._maxWidth = -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, x_align: Clutter.ActorAlign.CENTER, }); this._box = new St.Widget({ clip_to_allocation: true, layout_manager: new DashIconsLayout(), x_expand: true, x_align: Clutter.ActorAlign.CENTER, }); this._box._delegate = this; this.add_child(this._box); this._showAppsIcon = new ShowAppsIcon(); this._showAppsIcon.show(false); this._showAppsIcon.icon.setIconSize(this.iconSize); this._hookUpLabel(this._showAppsIcon); this.add_child(this._showAppsIcon); this.showAppsButton = this._showAppsIcon.toggleButton; this.connect('notify::width', () => { if (this._maxWidth !== this.width) this._queueRedisplay(); this._maxWidth = this.width; }); 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._onDragBegin.bind(this)); Main.overview.connect('item-drag-end', this._onDragEnd.bind(this)); Main.overview.connect('item-drag-cancelled', this._onDragCancelled.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'); } _onDragBegin() { this._dragCancelled = false; this._dragMonitor = { dragMotion: this._onDragMotion.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); } } _onDragCancelled() { this._dragCancelled = true; this._endDrag(); } _onDragEnd() { if (this._dragCancelled) return; this._endDrag(); } _endDrag() { this._clearDragPlaceholder(); this._clearEmptyDropTarget(); this._showAppsIcon.setDragApp(null); DND.removeDragMonitor(this._dragMonitor); } _onDragMotion(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; } _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(); }); let id = Main.overview.connect('hiding', () => { this._labelShowing = false; item.hideLabel(); }); item.child.connect('destroy', () => { Main.overview.disconnect(id); }); 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) 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(); let [, iconWidth] = firstIcon.icon.get_preferred_width(-1); let [, buttonWidth] = firstButton.get_preferred_width(-1); // Subtract icon padding and box spacing from the available width availWidth -= iconChildren.length * (buttonWidth - iconWidth) + (iconChildren.length - 1) * spacing; let availSize = availWidth / iconChildren.length; 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] <= availSize) 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; if (nFavorites > 0) { 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; 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.animateOutAndDestroy(); this._dragPlaceholder.connect('destroy', () => { this._animatingPlaceholdersCount--; }); 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 = Math.floor(x * numChildren / boxWidth); else pos = 0; // always insert at the top when dash is empty // 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; } });