diff --git a/data/theme/gnome-shell.css b/data/theme/gnome-shell.css index 71a5084a2..67d917bae 100644 --- a/data/theme/gnome-shell.css +++ b/data/theme/gnome-shell.css @@ -472,6 +472,27 @@ StTooltip { color: #cccccc; } +/* Message Tray */ +#message-tray { + background-gradient-direction: vertical; + background-gradient-start: rgba(0,0,0,0.01); + background-gradient-end: rgba(0,0,0,0.95); + height: 28px; +} + +#notification { + border-radius: 5px; + background: rgba(0,0,0,0.9); + color: white; + padding: 2px 10px; + spacing: 10px; +} + +#summary-mode { + spacing: 10px; + padding: 2px 4px; +} + /* App Switcher */ .switcher-list { background: rgba(0,0,0,0.8); diff --git a/js/ui/Makefile.am b/js/ui/Makefile.am index 5ef241b57..4eb388348 100644 --- a/js/ui/Makefile.am +++ b/js/ui/Makefile.am @@ -16,6 +16,8 @@ dist_jsui_DATA = \ link.js \ lookingGlass.js \ main.js \ + messageTray.js \ + notificationDaemon.js \ overview.js \ panel.js \ placeDisplay.js \ diff --git a/js/ui/main.js b/js/ui/main.js index f30644d46..f8c95b364 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -15,11 +15,13 @@ const St = imports.gi.St; const Chrome = imports.ui.chrome; const Environment = imports.ui.environment; const ExtensionSystem = imports.ui.extensionSystem; +const MessageTray = imports.ui.messageTray; const Overview = imports.ui.overview; const Panel = imports.ui.panel; const PlaceDisplay = imports.ui.placeDisplay; const RunDialog = imports.ui.runDialog; const LookingGlass = imports.ui.lookingGlass; +const NotificationDaemon = imports.ui.notificationDaemon; const ShellDBus = imports.ui.shellDBus; const Sidebar = imports.ui.sidebar; const WindowManager = imports.ui.windowManager; @@ -35,6 +37,8 @@ let overview = null; let runDialog = null; let lookingGlass = null; let wm = null; +let notificationDaemon = null; +let messageTray = null; let recorder = null; let shellDBusService = null; let modalCount = 0; @@ -113,6 +117,8 @@ function start() { panel = new Panel.Panel(); sidebar = new Sidebar.Sidebar(); wm = new WindowManager.WindowManager(); + notificationDaemon = new NotificationDaemon.NotificationDaemon(); + messageTray = new MessageTray.MessageTray(); _startDate = new Date(); diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js new file mode 100644 index 000000000..60dbebc0a --- /dev/null +++ b/js/ui/messageTray.js @@ -0,0 +1,239 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ + +const Clutter = imports.gi.Clutter; +const Lang = imports.lang; +const Mainloop = imports.mainloop; +const St = imports.gi.St; +const Signals = imports.signals; +const Tweener = imports.ui.tweener; + +const Main = imports.ui.main; + +const ANIMATION_TIME = 0.2; +const NOTIFICATION_TIMEOUT = 4; + +const MESSAGE_TRAY_TIMEOUT = 0.2; + +const ICON_SIZE = 24; + +function Notification(icon, text) { + this._init(icon, text); +} + +Notification.prototype = { + _init: function(icon, text) { + this.icon = icon; + this.text = text; + } +} + +function NotificationBox() { + this._init(); +} + +NotificationBox.prototype = { + _init: function() { + this.actor = new St.BoxLayout({ name: 'notification' }); + + this._iconBox = new St.Bin(); + this.actor.add(this._iconBox); + + this._text = new St.Label(); + this.actor.add(this._text, { expand: true, x_fill: false, y_fill: false, y_align: St.Align.MIDDLE }); + }, + + setContent: function(notification) { + this._iconBox.child = notification.icon; + + // Support , , and , escape anything else + // so it displays as raw markup. + let markup = notification.text.replace(/<(\/?[^biu]>|[^>\/][^>])/g, "<$1"); + this._text.clutter_text.set_markup(markup); + } +}; + +function Source(id, createIcon) { + this._init(id, createIcon); +} + +Source.prototype = { + _init: function(id, createIcon) { + this.id = id; + if (createIcon) + this.createIcon = createIcon; + }, + + // This can be overridden by a subclass, or by the createIcon + // parameter to _init() + createIcon: function(size) { + throw new Error('no implementation of createIcon in ' + this); + }, + + notify: function(text) { + Main.messageTray.showNotification(new Notification(this.createIcon(ICON_SIZE), text)); + }, + + clicked: function() { + this.emit('clicked'); + }, + + destroy: function() { + this.emit('destroy'); + } +}; +Signals.addSignalMethods(Source.prototype); + +function MessageTray() { + this._init(); +} + +MessageTray.prototype = { + _init: function() { + this.actor = new St.BoxLayout({ name: 'message-tray', + reactive: true }); + + let primary = global.get_primary_monitor(); + this.actor.x = 0; + this.actor.y = primary.height - 1; + + this.actor.width = primary.width; + + this._summaryBin = new St.Bin({ x_align: St.Align.END }); + this.actor.add(this._summaryBin, { expand: true }); + this._summaryBin.hide(); + + this._notificationBox = new NotificationBox(); + this._notificationQueue = []; + this.actor.add(this._notificationBox.actor); + this._notificationBox.actor.hide(); + + Main.chrome.addActor(this.actor, { affectsStruts: false }); + + this.actor.connect('enter-event', + Lang.bind(this, this._onMessageTrayEntered)); + this.actor.connect('leave-event', + Lang.bind(this, this._onMessageTrayLeft)); + this._isShowing = false; + this.actor.show(); + + this._summary = new St.BoxLayout({ name: 'summary-mode' }); + this._summaryBin.child = this._summary; + + this._sources = {}; + this._icons = {}; + }, + + contains: function(source) { + return this._sources.hasOwnProperty(source.id); + }, + + add: function(source) { + if (this.contains(source)) { + log('Trying to re-add source ' + source.id); + return; + } + + let iconBox = new St.Bin({ reactive: true }); + iconBox.child = source.createIcon(ICON_SIZE); + this._summary.insert_actor(iconBox, 0); + this._icons[source.id] = iconBox; + this._sources[source.id] = source; + + iconBox.connect('button-release-event', Lang.bind(this, + function () { + source.clicked(); + })); + + source.connect('destroy', Lang.bind(this, + function () { + this.remove(source); + })); + }, + + remove: function(source) { + if (!this.contains(source)) + return; + + this._summary.remove_actor(this._icons[source.id]); + delete this._icons[source.id]; + delete this._sources[source.id]; + }, + + getSource: function(id) { + return this._sources[id]; + }, + + _onMessageTrayEntered: function() { + // Don't hide the message tray after a timeout if the user has moved the mouse over it. + // We might have a timeout in place if the user moved the mouse away from the message tray for a very short period of time + // or if we are showing a notification. + if (this._hideTimeoutId > 0) + Mainloop.source_remove(this._hideTimeoutId); + + if (this._isShowing) + return; + + // If the message tray was not already showing, we'll show it in the summary mode. + this._summaryBin.show(); + this._show(); + }, + + _onMessageTrayLeft: function() { + if (!this._isShowing) + return; + + // We wait just a little before hiding the message tray in case the user will quickly move the mouse back over it. + this._hideTimeoutId = Mainloop.timeout_add(MESSAGE_TRAY_TIMEOUT * 1000, Lang.bind(this, this._hide)); + }, + + _show: function() { + this._isShowing = true; + let primary = global.get_primary_monitor(); + Tweener.addTween(this.actor, + { y: primary.height - this.actor.height, + time: ANIMATION_TIME, + transition: "easeOutQuad" + }); + }, + + _hide: function() { + this._hideTimeoutId = 0; + + let primary = global.get_primary_monitor(); + + Tweener.addTween(this.actor, + { y: primary.height - 1, + time: ANIMATION_TIME, + transition: "easeOutQuad", + onComplete: this._hideComplete, + onCompleteScope: this + }); + return false; + }, + + _hideComplete: function() { + this._isShowing = false; + this._summaryBin.hide(); + this._notificationBox.actor.hide(); + if (this._notificationQueue.length > 0) + this.showNotification(this._notificationQueue.shift()); + }, + + showNotification: function(notification) { + if (this._isShowing) { + this._notificationQueue.push(notification); + return; + } + + this._notificationBox.setContent(notification); + + this._notificationBox.actor.x = Math.round((this.actor.width - this._notificationBox.actor.width) / 2); + this._notificationBox.actor.show(); + + // Because we set up the timeout before we do the animation, we add ANIMATION_TIME to NOTIFICATION_TIMEOUT, so that + // NOTIFICATION_TIMEOUT represents the time the notifiation is fully shown. + this._hideTimeoutId = Mainloop.timeout_add((NOTIFICATION_TIMEOUT + ANIMATION_TIME) * 1000, Lang.bind(this, this._hide)); + + this._show(); + } +}; diff --git a/js/ui/notificationDaemon.js b/js/ui/notificationDaemon.js new file mode 100644 index 000000000..0190d66a4 --- /dev/null +++ b/js/ui/notificationDaemon.js @@ -0,0 +1,218 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ + +const DBus = imports.dbus; +const GLib = imports.gi.GLib; +const Lang = imports.lang; +const Shell = imports.gi.Shell; +const Mainloop = imports.mainloop; + +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const Params = imports.misc.params; + +let nextNotificationId = 1; + +const NotificationDaemonIface = { + name: 'org.freedesktop.Notifications', + methods: [{ name: 'Notify', + inSignature: 'susssasa{sv}i', + outSignature: 'u' + }, + { name: 'CloseNotification', + inSignature: 'u', + outSignature: '' + }, + { name: 'GetCapabilities', + inSignature: '', + outSignature: 'as' + }, + { name: 'GetServerInformation', + inSignature: '', + outSignature: 'ssss' + }], + signals: [{ name: 'NotificationClosed', + inSignature: 'uu' }, + { name: 'ActionInvoked', + inSignature: 'us' }] +}; + +const NotificationClosedReason = { + EXPIRED: 1, + DISMISSED: 2, + APP_CLOSED: 3, + UNDEFINED: 4 +}; + +const Urgency = { + LOW: 0, + NORMAL: 1, + CRITICAL: 2 +}; + +function NotificationDaemon() { + this._init(); +} + +NotificationDaemon.prototype = { + _init: function() { + DBus.session.exportObject('/org/freedesktop/Notifications', this); + + this._everAcquiredName = false; + DBus.session.acquire_name('org.freedesktop.Notifications', + // We pass MANY_INSTANCES so that if + // notification-daemon is running, we'll + // get queued behind it and then get the + // name after killing it below + DBus.MANY_INSTANCES, + Lang.bind(this, this._acquiredName), + Lang.bind(this, this._lostName)); + }, + + _acquiredName: function() { + this._everAcquiredName = true; + }, + + _lostName: function() { + if (this._everAcquiredName) + log('Lost name org.freedesktop.Notifications!'); + else if (GLib.getenv('GNOME_SHELL_NO_REPLACE')) + log('Failed to acquire org.freedesktop.Notifications'); + else { + log('Failed to acquire org.freedesktop.Notifications; trying again'); + + // kill the notification-daemon. pkill is more portable + // than killall, but on Linux at least it won't match if + // you pass more than 15 characters of the process name... + // However, if you use the "-f" flag to match the entire + // command line, it will work, but we have to be careful + // in that case that we don't match "gedit + // notification-daemon.c" or whatever... + let p = new Shell.Process({ args: ['pkill', '-f', + '^([^ ]*/)?(notification-daemon|notify-osd)$']}); + p.run(); + } + }, + + _sourceId: function(id) { + return 'notification-' + id; + }, + + Notify: function(appName, replacesId, icon, summary, body, + actions, hints, timeout) { + let id, source = null; + + if (replacesId != 0) { + id = replacesId; + source = Main.messageTray.getSource(this._sourceId(id)); + // source may be null if the current source was destroyed + // right as the client sent the new notification + } + + if (source == null) { + id = nextNotificationId++; + + source = new Source(this._sourceId(id), icon, hints); + Main.messageTray.add(source); + + source.connect('clicked', Lang.bind(this, + function() { + source.destroy(); + this._emitNotificationClosed(id, NotificationClosedReason.DISMISSED); + })); + } + + summary = GLib.markup_escape_text(summary, -1); + if (body) + source.notify('' + summary + ': ' + body); + else + source.notify('' + summary + ''); + return id; + }, + + CloseNotification: function(id) { + let source = Main.messageTray.getSource(this._sourceId(id)); + if (source) + source.destroy(); + this._emitNotificationClosed(id, NotificationClosedReason.APP_CLOSED); + }, + + GetCapabilities: function() { + return [ + // 'actions', + 'body', + // 'body-hyperlinks', + // 'body-images', + 'body-markup', + // 'icon-multi', + 'icon-static' + // 'sound', + ]; + }, + + GetServerInformation: function() { + return [ + 'GNOME Shell', + 'GNOME', + '0.1', // FIXME, get this from somewhere + '1.0' + ]; + }, + + _emitNotificationClosed: function(id, reason) { + DBus.session.emit_signal('/org/freedesktop/Notifications', + 'org.freedesktop.Notifications', + 'NotificationClosed', 'uu', + [id, reason]); + } +}; + +DBus.conformExport(NotificationDaemon.prototype, NotificationDaemonIface); + +function Source(sourceId, icon, hints) { + this._init(sourceId, icon, hints); +} + +Source.prototype = { + __proto__: MessageTray.Source.prototype, + + _init: function(sourceId, icon, hints) { + MessageTray.Source.prototype._init.call(this, sourceId); + + hints = Params.parse(hints, { urgency: Urgency.NORMAL }, true); + + this._icon = icon; + this._iconData = hints.icon_data; + this._urgency = hints.urgency; + }, + + createIcon: function(size) { + let textureCache = Shell.TextureCache.get_default(); + + if (this._icon) { + if (this._icon.substr(0, 7) == 'file://') + return textureCache.load_uri_async(this._icon, size, size); + else if (this._icon[0] == '/') { + let uri = GLib.filename_to_uri(this._icon, null); + return textureCache.load_uri_async(uri, size, size); + } else + return textureCache.load_icon_name(this._icon, size); + } else if (this._iconData) { + let [width, height, rowStride, hasAlpha, + bitsPerSample, nChannels, data] = this._iconData; + return textureCache.load_from_raw(data, data.length, hasAlpha, + width, height, rowStride, size); + } else { + let stockIcon; + switch (this._urgency) { + case Urgency.LOW: + case Urgency.NORMAL: + stockIcon = 'gtk-dialog-info'; + break; + case Urgency.CRITICAL: + stockIcon = 'gtk-dialog-error'; + break; + } + return textureCache.load_icon_name(stockIcon, size); + } + } +}; diff --git a/src/gnome-shell.in b/src/gnome-shell.in old mode 100644 new mode 100755 index 41354ad0a..254cdee4a --- a/src/gnome-shell.in +++ b/src/gnome-shell.in @@ -357,8 +357,9 @@ try: shell = None if options.xephyr: xephyr = start_xephyr() - # This makes us not grab the org.gnome.Panel name - os.environ['GNOME_SHELL_NO_REPLACE_PANEL'] = '1' + # This makes us not grab the org.gnome.Panel or + # org.freedesktop.Notifications D-Bus names + os.environ['GNOME_SHELL_NO_REPLACE'] = '1' shell = start_shell() else: xephyr = None diff --git a/src/shell-global.c b/src/shell-global.c index 7fd37c998..3e422b122 100644 --- a/src/shell-global.c +++ b/src/shell-global.c @@ -751,7 +751,7 @@ shell_global_grab_dbus_service (ShellGlobal *global) * unless a special environment variable is passed. The environment * variable is used by the gnome-shell (no --replace) launcher in * Xephyr */ - if (!g_getenv ("GNOME_SHELL_NO_REPLACE_PANEL")) + if (!g_getenv ("GNOME_SHELL_NO_REPLACE")) { if (!dbus_g_proxy_call (bus, "RequestName", &error, G_TYPE_STRING, "org.gnome.Panel", G_TYPE_UINT,