[gnome-shell] Add animated display of startup notification



commit 7f8f0f2358c29e3002654feadf17055a88c32a7e
Author: Maxim Ermilov <zaspire rambler ru>
Date:   Thu Jun 10 16:07:33 2010 +0400

    Add animated display of startup notification
    
    The shell design says that upon launching an application,
    no X window should have focus, and we should display an
    animated launching indicator.
    
    Implement this by in panel.js, keep track of the last started
    application.  If there isn't currently an X focus, show an animation
    for the last starting application.
    
    https://bugzilla.gnome.org/show_bug.cgi?id=598349

 data/Makefile.am               |    1 +
 data/theme/gnome-shell.css     |    7 ++
 data/theme/process-working.png |  Bin 0 -> 4097 bytes
 js/ui/panel.js                 |  189 +++++++++++++++++++++++++++++++++++++---
 4 files changed, 184 insertions(+), 13 deletions(-)
---
diff --git a/data/Makefile.am b/data/Makefile.am
index f0cc6d4..2dc4199 100644
--- a/data/Makefile.am
+++ b/data/Makefile.am
@@ -30,6 +30,7 @@ dist_theme_DATA =				\
 	theme/mosaic-view-active.svg          \
 	theme/mosaic-view.svg          \
 	theme/move-window-on-new.svg          \
+	theme/process-working.png          \
 	theme/remove-workspace.svg          \
 	theme/scroll-button-down-hover.png	\
 	theme/scroll-button-down.png		\
diff --git a/data/theme/gnome-shell.css b/data/theme/gnome-shell.css
index 67e653e..b3abbe0 100644
--- a/data/theme/gnome-shell.css
+++ b/data/theme/gnome-shell.css
@@ -30,6 +30,13 @@
     color: rgba(0,0,0,0.5);
 }
 
+.label-real-shadow {
+    background-gradient-direction: horizontal;
+    background-gradient-start: rgba(0, 0, 0, 0);
+    background-gradient-end: rgba(0, 0, 0, 255);
+    width: 10px;
+}
+
 StScrollBar
 {
   padding: 0px;
diff --git a/data/theme/process-working.png b/data/theme/process-working.png
new file mode 100644
index 0000000..402615b
Binary files /dev/null and b/data/theme/process-working.png differ
diff --git a/js/ui/panel.js b/js/ui/panel.js
index 25b0153..40d607e 100644
--- a/js/ui/panel.js
+++ b/js/ui/panel.js
@@ -28,6 +28,10 @@ const PANEL_ICON_SIZE = 24;
 
 const HOT_CORNER_ACTIVATION_TIMEOUT = 0.5;
 
+const ANIMATED_ICON_UPDATE_TIMEOUT = 100;
+const SPINNER_UPDATE_TIMEOUT = 130;
+const SPINNER_SPEED = 0.02;
+
 const STANDARD_TRAY_ICON_ORDER = ['keyboard', 'volume', 'bluetooth', 'network', 'battery'];
 const STANDARD_TRAY_ICON_IMPLEMENTATIONS = {
     'bluetooth-applet': 'bluetooth',
@@ -41,6 +45,49 @@ const CLOCK_CUSTOM_FORMAT_KEY = 'clock/custom_format';
 const CLOCK_SHOW_DATE_KEY     = 'clock/show_date';
 const CLOCK_SHOW_SECONDS_KEY  = 'clock/show_seconds';
 
+function AnimatedIcon(name, size) {
+    this._init(name, size);
+}
+
+AnimatedIcon.prototype = {
+    _init: function(name, size) {
+        this.actor = new St.Bin({ visible: false });
+        this.actor.connect('destroy', Lang.bind(this, this._onDestroy));
+        this.actor.connect('notify::visible', Lang.bind(this, function() {
+            if (this.actor.visible) {
+                this._timeoutId = Mainloop.timeout_add(ANIMATED_ICON_UPDATE_TIMEOUT, Lang.bind(this, this._update));
+            } else {
+                if (this._timeoutId)
+                    Mainloop.source_remove(this._timeoutId);
+                this._timeoutId = 0;
+            }
+        }));
+
+        this._timeoutId = 0;
+        this._i = 0;
+        this._animations = St.TextureCache.get_default().load_sliced_image (global.datadir + '/theme/' + name, size, size);
+        this.actor.set_child(this._animations);
+    },
+
+    _update: function() {
+        this._animations.hide_all();
+        this._animations.show();
+        if (this._i && this._i < this._animations.get_n_children())
+            this._animations.get_nth_child(this._i++).show();
+        else {
+            this._i = 1;
+            if (this._animations.get_n_children())
+                this._animations.get_nth_child(0).show();
+        }
+        return true;
+    },
+
+    _onDestroy: function() {
+        if (this._timeoutId)
+            Mainloop.source_remove(this._timeoutId);
+    }
+};
+
 function TextShadower() {
     this._init();
 }
@@ -207,13 +254,22 @@ AppMenuButton.prototype = {
             this.hide();
         }));
 
+        this._updateId = 0;
+        this._animationStep = 0;
+        this._clipWidth = AppDisplay.APPICON_SIZE / 2;
+        this._direction = SPINNER_SPEED;
+
+        this._spinner = new AnimatedIcon('process-working.png', 24);
+        this._container.add_actor(this._spinner.actor);
+        this._spinner.actor.lower_bottom();
+
+        this._shadow = new St.Bin({ style_class: 'label-real-shadow' });
+        this._shadow.hide();
+        this._container.add_actor(this._shadow);
+
         let tracker = Shell.WindowTracker.get_default();
         tracker.connect('notify::focus-app', Lang.bind(this, this._sync));
-        // For now just resync on all running state changes; this is mainly to handle
-        // cases where the focused window's application changes without the focus
-        // changing.  An example case is how we map Firefox based on the window
-        // title which is a dynamic property.
-        tracker.connect('app-state-changed', Lang.bind(this, this._sync));
+        tracker.connect('app-state-changed', Lang.bind(this, this._onAppStateChanged));
 
         this._sync();
     },
@@ -248,6 +304,66 @@ AppMenuButton.prototype = {
                            onCompleteScope: this });
     },
 
+    _stopAnimation: function(animate) {
+        this._label.actor.remove_clip();
+        if (this._updateId) {
+            this._shadow.hide();
+            if (animate) {
+                Tweener.addTween(this._spinner.actor,
+                                 { opacity: 0,
+                                   time: 0.2,
+                                   transition: "easeOutQuad",
+                                   onCompleteScope: this,
+                                   onComplete: function() {
+                                       this._spinner.actor.opacity = 255;
+                                       this._spinner.actor.hide();
+                                   }
+                                 });
+            }
+            Mainloop.source_remove(this._updateId);
+            this._updateId = 0;
+        }
+        if (!animate)
+            this._spinner.actor.hide();
+    },
+
+    stopAnimation: function() {
+        this._direction = SPINNER_SPEED * 3;
+        this._stop = true;
+    },
+
+    _update: function() {
+        this._animationStep += this._direction;
+        if (this._animationStep > 1 && this._stop) {
+            this._animationStep = 1;
+            this._stopAnimation(true);
+            return false;
+        }
+        if (this._animationStep < 0 || this._animationStep > 1) {
+            this._direction = -this._direction;
+            this._animationStep += 2 * this._direction;
+        }
+        this._clipWidth = this._label.actor.width - (this._label.actor.width - AppDisplay.APPICON_SIZE / 2) * (1 - this._animationStep);
+        if (this.actor.get_direction() == St.TextDirection.LTR) {
+            this._label.actor.set_clip(0, 0, this._clipWidth + this._shadow.width, this.actor.height);
+        } else {
+            this._label.actor.set_clip(this._label.actor.width - this._clipWidth, 0, this._clipWidth, this.actor.height);
+        }
+        this._container.queue_relayout();
+        return true;
+    },
+
+    startAnimation: function() {
+        this._direction = SPINNER_SPEED;
+        this._stopAnimation(false);
+        this._animationStep = 0;
+        this._update();
+        this._stop = false;
+        this._updateId = Mainloop.timeout_add(SPINNER_UPDATE_TIMEOUT, Lang.bind(this, this._update));
+        this._spinner.actor.show();
+        this._shadow.show();
+    },
+
     _getContentPreferredWidth: function(actor, forHeight, alloc) {
         let [minSize, naturalSize] = this._iconBox.get_preferred_width(forHeight);
         alloc.min_size = minSize;
@@ -305,6 +421,25 @@ AppMenuButton.prototype = {
             childBox.x1 = Math.max(0, childBox.x2 - naturalWidth);
         }
         this._label.actor.allocate(childBox, flags);
+
+        if (direction == St.TextDirection.LTR) {
+            childBox.x1 = Math.floor(iconWidth / 2) + this._clipWidth + this._shadow.width;
+            childBox.x2 = childBox.x1 + this._spinner.actor.width;
+            childBox.y1 = box.y1;
+            childBox.y2 = box.y2 - 1;
+            this._spinner.actor.allocate(childBox, flags);
+            childBox.x1 = Math.floor(iconWidth / 2) + this._clipWidth + 2;
+            childBox.x2 = childBox.x1 + this._shadow.width;
+            childBox.y1 = box.y1;
+            childBox.y2 = box.y2 - 1;
+            this._shadow.allocate(childBox, flags);
+        } else {
+            childBox.x1 = this._label.actor.width - this._clipWidth - this._spinner.actor.width;
+            childBox.x2 = childBox.x1 + this._spinner.actor.width;
+            childBox.y1 = box.y1;
+            childBox.y2 = box.y2 - 1;
+            this._spinner.actor.allocate(childBox, flags);
+        }
     },
 
     _onQuit: function() {
@@ -313,27 +448,55 @@ AppMenuButton.prototype = {
         this._focusedApp.request_quit();
     },
 
+    _onAppStateChanged: function(tracker, app) {
+        let state = app.state;
+        if (app == this._lastStartedApp
+            && state != Shell.AppState.STARTING) {
+            this._lastStartedApp = null;
+        } else if (state == Shell.AppState.STARTING) {
+            this._lastStartedApp = app;
+        }
+        // For now just resync on all running state changes; this is mainly to handle
+        // cases where the focused window's application changes without the focus
+        // changing.  An example case is how we map OpenOffice.org based on the window
+        // title which is a dynamic property.
+        this._sync();
+    },
+
     _sync: function() {
         let tracker = Shell.WindowTracker.get_default();
 
         let focusedApp = tracker.focus_app;
-        if (focusedApp == this._focusedApp)
-          return;
+        if (focusedApp == this._focusedApp) {
+            if (focusedApp && focusedApp.get_state() != Shell.AppState.STARTING)
+                this.stopAnimation();
+            return;
+        } else {
+            this._stopAnimation();
+        }
 
         if (this._iconBox.child != null)
             this._iconBox.child.destroy();
         this._iconBox.hide();
         this._label.setText('');
+        this.actor.reactive = false;
 
         this._focusedApp = focusedApp;
 
-        if (this._focusedApp != null) {
-            let icon = this._focusedApp.get_faded_icon(AppDisplay.APPICON_SIZE);
-            let appName = this._focusedApp.get_name();
-            this._label.setText(appName);
-            this._quitMenu.label.set_text(_("Quit %s").format(appName));
+        let targetApp = this._focusedApp != null ? this._focusedApp : this._lastStartedApp;
+        if (targetApp != null) {
+            let icon = targetApp.get_faded_icon(AppDisplay.APPICON_SIZE);
+
+            this._label.setText(targetApp.get_name());
+            // TODO - _quit() doesn't really work on apps in state STARTING yet
+            this._quitMenu.label.set_text(_('Quit %s').format(targetApp.get_name()));
+
+            this.actor.reactive = true;
             this._iconBox.set_child(icon);
             this._iconBox.show();
+
+            if (targetApp.get_state() == Shell.AppState.STARTING)
+                this.startAnimation();
         }
 
         this.emit('changed');
@@ -597,7 +760,7 @@ Panel.prototype = {
                                         Lang.bind(this, this._onHotCornerClicked));
 
         // In addition to being triggered by the mouse enter event, the hot corner
-        // can be triggered by clicking on it. This is useful if the user wants to 
+        // can be triggered by clicking on it. This is useful if the user wants to
         // undo the effect of triggering the hot corner once in the hot corner.
         this._hotCorner.connect('enter-event',
                                 Lang.bind(this, this._onHotCornerEntered));



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]