/* * Taskbar: A taskbar extension for the Gnome panel. * Copyright (C) 2016 Zorin OS * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * * Credits: * This file is based on code from the Dash to Dock extension by micheleg. * Some code was also adapted from the upstream Gnome Shell source code. */ const Clutter = imports.gi.Clutter; const GLib = imports.gi.GLib; const Gtk = imports.gi.Gtk; const Lang = imports.lang; const Main = imports.ui.main; const Mainloop = imports.mainloop; const PopupMenu = imports.ui.popupMenu; const Signals = imports.signals; const St = imports.gi.St; const Tweener = imports.ui.tweener; const Workspace = imports.ui.workspace; const Me = imports.misc.extensionUtils.getCurrentExtension(); const Taskbar = Me.imports.taskbar; let THUMBNAIL_WIDTH = 350; let THUMBNAIL_HEIGHT = 200; const thumbnailPreviewMenu = new Lang.Class({ Name: 'thumbnailPreviewMenu', Extends: PopupMenu.PopupMenu, _init: function(source) { let side = Taskbar.getPosition(); this.parent(source.actor, 0.5, side); // We want to keep the item hovered while the menu is up this.blockSourceEvents = false; this._source = source; this._app = this._source.app; this.shouldOpen = true; this.shouldClose = false; this.actor.add_style_class_name('app-well-menu'); this.actor.set_style("max-width: " + (Main.layoutManager.primaryMonitor.width - 22) + "px;"); this.actor.hide(); // Chain our visibility and lifecycle to that of the source source.actor.connect('notify::mapped', Lang.bind(this, function () { if (!source.actor.mapped) this.close(); })); source.actor.connect('destroy', Lang.bind(this, this.destroy)); Main.uiGroup.add_actor(this.actor); source.actor.connect('enter-event', Lang.bind(this, this._onEnter)); source.actor.connect('leave-event', Lang.bind(this, this._onLeave)); this.actor.connect('enter-event', Lang.bind(this, this._onMenuEnter)); this.actor.connect('leave-event', Lang.bind(this, this._onMenuLeave)); // Change the initialized side where required. this._arrowSide = side; this._boxPointer._arrowSide = side; this._boxPointer._userArrowSide = side; this._previewBox = new thumbnailPreviewList(this._app, THUMBNAIL_HEIGHT); this.addMenuItem(this._previewBox); }, requestCloseMenu: function() { // The "~0" argument makes the animation display. this.close(~0); }, _redisplay: function() { this._previewBox._shownInitially = false; this._previewBox._redisplay(); }, popup: function() { let windows = Taskbar.getAppInterestingWindows(this._app); if (windows.length > 0) { this._redisplay(); this.open(); this._source.emit('sync-tooltip'); } }, _onMenuEnter: function () { this.shouldOpen = true; this.shouldClose = false; Mainloop.timeout_add(Taskbar.DASH_ITEM_HOVER_TIMEOUT, Lang.bind(this, this.hoverOpen)); }, _onMenuLeave: function () { this.shouldOpen = false; this.shouldClose = true; Mainloop.timeout_add(Taskbar.DASH_ITEM_HOVER_TIMEOUT, Lang.bind(this, this.hoverClose)); }, _onEnter: function () { this.shouldOpen = true; this.shouldClose = false; Mainloop.timeout_add(Taskbar.DASH_ITEM_HOVER_TIMEOUT, Lang.bind(this, this.hoverOpen)); }, _onLeave: function () { this.shouldClose = true; this.shouldOpen = false; Mainloop.timeout_add(0, Lang.bind(this, this.hoverClose)); }, hoverOpen: function () { if (this.shouldOpen && !this.isOpen) { this.popup(); } }, hoverClose: function () { if (this.shouldClose) { this.close(~0); } }, close: function(animate) { if (this.isOpen) this.emit('open-state-changed', false); if (this._activeMenuItem) this._activeMenuItem.setActive(false); if (this._boxPointer.actor.visible) { this._boxPointer.hide(animate, Lang.bind(this, function() { this.emit('menu-closed'); })); } this.isOpen = false; } }); const thumbnailPreview = new Lang.Class({ Name: 'thumbnailPreview', Extends: PopupMenu.PopupBaseMenuItem, _init: function(window) { this.window = window; this.parent({reactive: true}); this._workId = Main.initializeDeferredWork(this.actor, Lang.bind(this, this._onResize)); this._closeButtonId = Main.initializeDeferredWork(this.actor, Lang.bind(this, this._repositionCloseButton)); this.scale = 0; this.preview = this.getThumbnail(); this.actor.remove_child(this._ornamentLabel); this.actor._delegate = this; this.animatingOut = false; this._windowBox = new St.BoxLayout({ style_class: 'window-box', x_expand: true, vertical: true }); this._previewBin = new St.Bin(); this._previewBin.set_size(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); this._closeButton = new St.Button({ style_class: 'window-close', accessible_name: "Close window" }); this._closeButton.opacity = 0; this._closeButton.connect('clicked', Lang.bind(this, this._closeWindow)); this.overlayGroup = new Clutter.Actor({layout_manager: new Clutter.BinLayout()}); this.overlayGroup.add_actor(this._previewBin); this.overlayGroup.add_actor(this._closeButton); this._title = new St.Label({ text: window.title }); this._titleBin = new St.Bin({ child: this._title, x_align: St.Align.MIDDLE, width: THUMBNAIL_WIDTH }); this._titleBin.add_style_class_name("preview-window-title"); this.window.connect('notify::title', Lang.bind(this, function() { this._title.set_text(this.window.title); })); this._windowBin = new St.Bin({ child: this.overlayGroup, x_align: St.Align.MIDDLE, width: THUMBNAIL_WIDTH, height: THUMBNAIL_HEIGHT }); this._windowBox.add_child(this._windowBin); if (this.preview) this._previewBin.set_child(this.preview); this._windowBox.add_child(this._titleBin); this.actor.add_child(this._windowBox); this._queueRepositionCloseButton(); this.actor.connect('enter-event', Lang.bind(this, this._onEnter)); this.actor.connect('leave-event', Lang.bind(this, this._onLeave)); this.actor.connect('key-focus-in', Lang.bind(this, this._onEnter)); this.actor.connect('key-focus-out', Lang.bind(this, this._onLeave)); }, _onEnter: function() { this._showCloseButton(); return Clutter.EVENT_PROPAGATE; }, _onLeave: function() { if (!this._previewBin.has_pointer && !this._closeButton.has_pointer) this._hideCloseButton(); return Clutter.EVENT_PROPAGATE; }, _idleToggleCloseButton: function() { this._idleToggleCloseId = 0; if (!this._previewBin.has_pointer && !this._closeButton.has_pointer) this._hideCloseButton(); return GLib.SOURCE_REMOVE; }, _showCloseButton: function() { if (this._windowCanClose()) { this._closeButton.show(); Tweener.addTween(this._closeButton, { opacity: 255, time: Workspace.CLOSE_BUTTON_FADE_TIME, transition: 'easeOutQuad' }); } }, _windowCanClose: function() { return this.window.can_close() && !this._hasAttachedDialogs(); }, _hasAttachedDialogs: function() { // count trasient windows let n = 0; this.window.foreach_transient(function() {n++;}); return n > 0; }, _hideCloseButton: function() { Tweener.addTween(this._closeButton, { opacity: 0, time: Workspace.CLOSE_BUTTON_FADE_TIME, transition: 'easeInQuad' }); }, getThumbnail: function() { let thumbnail = null; let mutterWindow = this.window.get_compositor_private(); if (mutterWindow) { let windowTexture = mutterWindow.get_texture(); let [width, height] = windowTexture.get_size(); this.scale = Math.min(1.0, THUMBNAIL_WIDTH / width, THUMBNAIL_HEIGHT / height); thumbnail = new Clutter.Clone ({ source: windowTexture, reactive: true, width: width * this.scale, height: height * this.scale }); this._resizeId = mutterWindow.meta_window.connect('size-changed', Lang.bind(this, this._queueResize)); this._destroyId = mutterWindow.connect('destroy', Lang.bind(this, function() { thumbnail.destroy(); this._destroyId = 0; this.animateOutAndDestroy(); })); } return thumbnail; }, _queueResize: function () { Main.queueDeferredWork(this._workId); }, _onResize: function() { let [width, height] = this.preview.get_source().get_size(); this.scale = Math.min(1.0, THUMBNAIL_WIDTH / width, THUMBNAIL_HEIGHT / height); this.preview.set_size(width * this.scale, height * this.scale); this._queueRepositionCloseButton(); }, _queueRepositionCloseButton: function () { Main.queueDeferredWork(this._closeButtonId); }, _repositionCloseButton: function() { let rect = this.window.get_compositor_private().meta_window.get_frame_rect(); let cloneWidth = Math.floor(rect.width) * this.scale; let cloneHeight = Math.floor(rect.height) * this.scale; let cloneX = (THUMBNAIL_WIDTH - cloneWidth) / 2 ; let cloneY = (THUMBNAIL_HEIGHT - cloneHeight) / 2; let buttonX; if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { buttonX = cloneX - (this._closeButton.width / 2); buttonX = Math.max(buttonX, 0); } else { buttonX = cloneX + (cloneWidth - (this._closeButton.width / 2)); buttonX = Math.min(buttonX, THUMBNAIL_WIDTH - this._closeButton.width); } let buttonY = cloneY - (this._closeButton.height / 2); buttonY = Math.max(buttonY, 0); this._closeButton.set_position(Math.floor(buttonX), Math.floor(buttonY)); }, _closeWindow: function() { this.window.delete(global.get_current_time()); }, show: function(animate) { let fullWidth = this.actor.get_width(); this.actor.opacity = 0; this.actor.set_width(0); let time = animate ? Taskbar.DASH_ANIMATION_TIME : 0; Tweener.addTween(this.actor, { opacity: 255, width: fullWidth, time: time, transition: 'easeInOutQuad' }); }, animateOutAndDestroy: function() { this.animatingOut = true; this._hideCloseButton(); Tweener.addTween(this.actor, { width: 0, opacity: 0, time: Taskbar.DASH_ANIMATION_TIME, transition: 'easeOutQuad', onComplete: Lang.bind(this, function() { this.destroy(); }) }); }, activate: function() { Main.activateWindow(this.window); this._getTopMenu().close(~0); } }); const thumbnailPreviewList = new Lang.Class({ Name: 'thumbnailPreviewList', Extends: PopupMenu.PopupMenuSection, _init: function(app) { this.parent(); this._ensurePreviewVisibilityTimeoutId = 0; this.actor = new St.ScrollView({ name: 'thumbnailPreviewScrollview', hscrollbar_policy: Gtk.PolicyType.NEVER, vscrollbar_policy: Gtk.PolicyType.NEVER, enable_mouse_scrolling: true }); this.actor.connect('scroll-event', Lang.bind(this, this._onScrollEvent )); this.box.set_vertical(false); this.box.set_name("thumbnailPreviewList"); this.actor.add_actor(this.box); this.actor._delegate = this; this._shownInitially = false; this.app = app; this._redisplayId = Main.initializeDeferredWork(this.actor, Lang.bind(this, this._redisplay)); this._scrollbarId = Main.initializeDeferredWork(this.actor, Lang.bind(this, this._showHideScrollbar)); if (this._stateChangedId > 0) { this.app.disconnect(this._stateChangedId); this._stateChangedId = 0; } this._stateChangedId = this.app.connect('windows-changed', Lang.bind(this, this._queueRedisplay)); }, _needsScrollbar: function() { let topMenu = this._getTopMenu(); let [topMinWidth, topNaturalWidth] = topMenu.actor.get_preferred_width(-1); let topThemeNode = topMenu.actor.get_theme_node(); let topMaxWidth = topThemeNode.get_max_width(); return topMaxWidth >= 0 && topNaturalWidth >= topMaxWidth; }, _showHideScrollbar: function() { let needsScrollbar = this._needsScrollbar(); // St.ScrollView always requests space vertically for a possible horizontal // scrollbar if in AUTOMATIC mode. This looks bad when we *don't* need it, // so turn off the scrollbar when that's true. Dynamic changes in whether // we need it aren't handled properly. this.actor.hscrollbar_policy = needsScrollbar ? Gtk.PolicyType.AUTOMATIC : Gtk.PolicyType.NEVER; if (needsScrollbar) this.actor.add_style_pseudo_class('scrolled'); else this.actor.remove_style_pseudo_class('scrolled'); }, _queueScrollbar: function () { Main.queueDeferredWork(this._scrollbarId); }, _queueRedisplay: function () { Main.queueDeferredWork(this._redisplayId); }, _onScrollEvent: function(actor, event) { // Event coordinates are relative to the stage but can be transformed // as the actor will only receive events within his bounds. let stage_x, stage_y, ok, event_x, event_y, actor_w, actor_h; [stage_x, stage_y] = event.get_coords(); [ok, event_x, event_y] = actor.transform_stage_point(stage_x, stage_y); [actor_w, actor_h] = actor.get_size(); // If the scroll event is within a 1px margin from // the relevant edge of the actor, let the event propagate. if (event_y >= actor_h - 2) return Clutter.EVENT_PROPAGATE; // reset timeout to avid conflicts with the mousehover event if (this._ensurePreviewVisibilityTimeoutId>0) { Mainloop.source_remove(this._ensurePreviewVisibilityTimeoutId); this._ensurePreviewVisibilityTimeoutId = 0; } // Skip to avoid double events mouse if (event.is_pointer_emulated()) return Clutter.EVENT_STOP; let adjustment, delta; adjustment = this.actor.get_hscroll_bar().get_adjustment(); let increment = adjustment.step_increment; switch ( event.get_scroll_direction() ) { case Clutter.ScrollDirection.UP: delta = -increment; break; case Clutter.ScrollDirection.DOWN: delta = +increment; break; case Clutter.ScrollDirection.SMOOTH: let [dx, dy] = event.get_scroll_delta(); delta = dy*increment; delta += dx*increment; break; } adjustment.set_value(adjustment.get_value() + delta); return Clutter.EVENT_STOP; }, _createPreviewItem: function(window) { let preview = new thumbnailPreview(window); preview.actor.connect('notify::hover', Lang.bind(this, function() { if (preview.actor.hover){ this._ensurePreviewVisibilityTimeoutId = Mainloop.timeout_add(100, Lang.bind(this, function(){ Taskbar.ensureActorVisibleInScrollView(this.actor, preview.actor); this._ensurePreviewVisibilityTimeoutId = 0; return GLib.SOURCE_REMOVE; })); } else { if (this._ensurePreviewVisibilityTimeoutId>0) { Mainloop.source_remove(this._ensurePreviewVisibilityTimeoutId); this._ensurePreviewVisibilityTimeoutId = 0; } } })); preview.actor.connect('key-focus-in', Lang.bind(this, function(actor) { let [x_shift, y_shift] = Taskbar.ensureActorVisibleInScrollView(this.actor, actor); })); return preview; }, _redisplay: function () { let windows = Taskbar.getAppInterestingWindows(this.app).sort(this.sortWindowsCompareFunction); let children = this.box.get_children().filter(function(actor) { return actor._delegate.window && actor._delegate.preview; }); // Apps currently in the taskbar let oldWin = children.map(function(actor) { return actor._delegate.window; }); // Apps supposed to be in the taskbar let newWin = windows; let addedItems = []; let removedActors = []; let newIndex = 0; let oldIndex = 0; while (newIndex < newWin.length || oldIndex < oldWin.length) { // No change at oldIndex/newIndex if (oldWin[oldIndex] == newWin[newIndex]) { oldIndex++; newIndex++; continue; } // Window removed at oldIndex if (oldWin[oldIndex] && newWin.indexOf(oldWin[oldIndex]) == -1) { removedActors.push(children[oldIndex]); oldIndex++; continue; } // Window added at newIndex if (newWin[newIndex] && oldWin.indexOf(newWin[newIndex]) == -1) { addedItems.push({ item: this._createPreviewItem(newWin[newIndex]), pos: newIndex }); newIndex++; continue; } // Window moved let insertHere = newWin[newIndex + 1] && newWin[newIndex + 1] == oldWin[oldIndex]; let alreadyRemoved = removedActors.reduce(function(result, actor) { let removedWin = actor.window; return result || removedWin == newWin[newIndex]; }, false); if (insertHere || alreadyRemoved) { addedItems.push({ item: this._createPreviewItem(newWin[newIndex]), pos: newIndex + removedActors.length }); newIndex++; } else { removedActors.push(children[oldIndex]); oldIndex++; } } for (let i = 0; i < addedItems.length; i++) this.addMenuItem(addedItems[i].item, addedItems[i].pos); for (let i = 0; i < removedActors.length; i++) { let item = removedActors[i]; item._delegate.animateOutAndDestroy(); } // Skip animations on first run when adding the initial set // of items, to avoid all items zooming in at once let animate = this._shownInitially; if (!this._shownInitially) this._shownInitially = true; for (let i = 0; i < addedItems.length; i++) { addedItems[i].item.show(animate); } this._queueScrollbar(); // Workaround for https://bugzilla.gnome.org/show_bug.cgi?id=692744 // Without it, StBoxLayout may use a stale size cache this.box.queue_relayout(); if (windows.length < 1) { this._getTopMenu().close(~0); } }, isAnimatingOut: function() { return this.actor.get_children().reduce(function(result, actor) { return result || actor.animatingOut; }, false); }, sortWindowsCompareFunction: function(windowA, windowB) { return windowA.get_stable_sequence() > windowB.get_stable_sequence(); } });