[MessageTray] reimplement the state machine

Previously, every time _updateState was called, it would make some
change, and so it was necessary to very carefully set up all the calls
to it, to ensure it was always called at exactly the right time. Now,
instead, we keep a bunch of state variables like "_notificationState"
and "_pointerInSummary", and potentially multiple timeouts, and
_updateState looks at all of them and figure out what, if anything,
needs to be changed.

By making the rules about what causes changes more explicit, it will
be easier to change those rules in the future as we add new
functionality.

Also, update the rules a bit, so that notifications can appear while
the summary is visible, and the summary only shows after a
notification if the summary has changed.

https://bugzilla.gnome.org/show_bug.cgi?id=609765
This commit is contained in:
Dan Winship 2010-02-11 15:31:12 -05:00
parent 02f67af464
commit e86c821878

View File

@ -19,11 +19,11 @@ const MESSAGE_TRAY_TIMEOUT = 0.2;
const ICON_SIZE = 24; const ICON_SIZE = 24;
const MessageTrayState = { const State = {
HIDDEN: 0, // entire message tray is hidden HIDDEN: 0,
NOTIFICATION: 1, // notifications are visible SHOWING: 1,
SUMMARY: 2, // summary is visible SHOWN: 2,
TRAY_ONLY: 3 // neither notifiations nor summary are visible, only tray HIDING: 3
}; };
function _cleanMarkup(text) { function _cleanMarkup(text) {
@ -360,20 +360,24 @@ MessageTray.prototype = {
this._summaryBin.add(this._summary, { x_align: St.Align.END, this._summaryBin.add(this._summary, { x_align: St.Align.END,
x_fill: false, x_fill: false,
expand: true }); expand: true });
this._summary.connect('enter-event', this._summary.connect('enter-event',
Lang.bind(this, this._showMessageTray)); Lang.bind(this, this._onSummaryEntered));
this._summary.connect('leave-event',
Lang.bind(this, this._onSummaryLeft));
this._summaryBin.opacity = 0; this._summaryBin.opacity = 0;
this.actor.connect('enter-event', this.actor.connect('enter-event', Lang.bind(this, this._onTrayEntered));
Lang.bind(this, function() { this.actor.connect('leave-event', Lang.bind(this, this._onTrayLeft));
if (this._state == MessageTrayState.NOTIFICATION || this._state == MessageTrayState.SUMMARY)
this._showMessageTray(); this._trayState = State.HIDDEN;
})); this._trayLeftTimeoutId = 0;
this._pointerInTray = false;
this._summaryState = State.HIDDEN;
this._summaryTimeoutId = 0;
this._pointerInSummary = false;
this._notificationState = State.HIDDEN;
this._notificationTimeoutId = 0;
this.actor.connect('leave-event',
Lang.bind(this, this._hideMessageTray));
this._state = MessageTrayState.HIDDEN;
this.actor.show(); this.actor.show();
Main.chrome.addActor(this.actor, { affectsStruts: false }); Main.chrome.addActor(this.actor, { affectsStruts: false });
Main.chrome.trackActor(this._notificationBin, { affectsStruts: false }); Main.chrome.trackActor(this._notificationBin, { affectsStruts: false });
@ -409,6 +413,7 @@ MessageTray.prototype = {
let iconBox = new St.Bin({ reactive: true }); let iconBox = new St.Bin({ reactive: true });
iconBox.child = source.createIcon(ICON_SIZE); iconBox.child = source.createIcon(ICON_SIZE);
this._summary.insert_actor(iconBox, 0); this._summary.insert_actor(iconBox, 0);
this._summaryNeedsToBeShown = true;
this._icons[source.id] = iconBox; this._icons[source.id] = iconBox;
this._sources[source.id] = source; this._sources[source.id] = source;
@ -437,14 +442,18 @@ MessageTray.prototype = {
} }
this._notificationQueue = newNotificationQueue; this._notificationQueue = newNotificationQueue;
// Update state if we are showing a notification from the removed source
if (this._state == MessageTrayState.NOTIFICATION &&
this._notification.source == source)
this._updateState();
this._summary.remove_actor(this._icons[source.id]); this._summary.remove_actor(this._icons[source.id]);
this._summaryNeedsToBeShown = true;
delete this._icons[source.id]; delete this._icons[source.id];
delete this._sources[source.id]; delete this._sources[source.id];
if (this._notification && this._notification.source == source) {
if (this._notificationTimeoutId) {
Mainloop.source_remove(this._notificationTimeoutId);
this._notificationTimeoutId = 0;
}
this._updateState();
}
}, },
getSource: function(id) { getSource: function(id) {
@ -453,130 +462,134 @@ MessageTray.prototype = {
_onNotify: function(source, notification) { _onNotify: function(source, notification) {
this._notificationQueue.push(notification); this._notificationQueue.push(notification);
this._updateState();
if (this._state == MessageTrayState.HIDDEN)
this._updateState();
}, },
_showMessageTray: function() { _onSummaryEntered: function() {
// Don't hide the message tray after a timeout if the user has moved this._pointerInSummary = true;
// the mouse over it. this._updateState();
// 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._updateTimeoutId > 0)
Mainloop.source_remove(this._updateTimeoutId);
if (this._state == MessageTrayState.HIDDEN)
this._updateState();
else if (this._state == MessageTrayState.NOTIFICATION) {
if (this._notification.popOut()) {
Tweener.addTween(this._notificationBin,
{ y: this.actor.height - this._notificationBin.height,
time: ANIMATION_TIME,
transition: "easeOutQuad"
});
}
}
}, },
_hideMessageTray: function() { _onSummaryLeft: function() {
if (this._state == MessageTrayState.HIDDEN) this._pointerInSummary = false;
this._updateState();
},
_onTrayEntered: function() {
if (this._trayLeftTimeoutId) {
Mainloop.source_remove(this._trayLeftTimeoutId);
this._trayLeftTimeoutId = 0;
return; return;
// We wait just a little before hiding the message tray in case the
// user will quickly move the mouse back over it.
let timeout = MESSAGE_TRAY_TIMEOUT * 1000;
this._updateTimeoutId = Mainloop.timeout_add(timeout, Lang.bind(this, this._updateState));
},
// As tray, notification box and summary view are all animated separately,
// but dependant on each other's states, it appears less confusing
// handling all transitions in a state machine rather than spread out
// over different event handlers.
//
// State changes are triggered when
// - a notification arrives (see _onNotify())
// - the mouse enters the tray (see _showMessageTray())
// - the mouse leaves the tray (see _hideMessageTray())
// - a timeout expires (usually set up in a previous invocation of this function)
_updateState: function() {
if (this._updateTimeoutId > 0)
Mainloop.source_remove(this._updateTimeoutId);
this._updateTimeoutId = 0;
let timeout = -1;
switch (this._state) {
case MessageTrayState.HIDDEN:
if (this._notificationQueue.length > 0) {
this._showNotification();
this._showTray();
this._state = MessageTrayState.NOTIFICATION;
// 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.
timeout = (ANIMATION_TIME + NOTIFICATION_TIMEOUT) * 1000;
} else {
this._showSummary();
this._showTray();
this._state = MessageTrayState.SUMMARY;
}
break;
case MessageTrayState.NOTIFICATION:
if (this._notificationQueue.length > 0) {
this._hideNotification();
this._state = MessageTrayState.TRAY_ONLY;
timeout = ANIMATION_TIME * 1000;
} else {
this._hideNotification();
this._showSummary();
this._state = MessageTrayState.SUMMARY;
timeout = (ANIMATION_TIME + SUMMARY_TIMEOUT) * 1000;
}
break;
case MessageTrayState.SUMMARY:
if (this._notificationQueue.length > 0) {
this._hideSummary();
this._showNotification();
this._state = MessageTrayState.NOTIFICATION;
timeout = (ANIMATION_TIME + NOTIFICATION_TIMEOUT) * 1000;
} else {
this._hideSummary();
this._hideTray();
this._state = MessageTrayState.HIDDEN;
}
break;
case MessageTrayState.TRAY_ONLY:
this._showNotification();
this._state = MessageTrayState.NOTIFICATION;
timeout = (ANIMATION_TIME + NOTIFICATION_TIMEOUT) * 1000;
break;
} }
if (timeout > -1) this._pointerInTray = true;
this._updateTimeoutId = Mainloop.timeout_add(timeout, Lang.bind(this, this._updateState)); this._updateState();
},
_onTrayLeft: function() {
// We wait just a little before hiding the message tray in case the
// user quickly moves the mouse back into it.
let timeout = MESSAGE_TRAY_TIMEOUT * 1000;
this._trayLeftTimeoutId = Mainloop.timeout_add(timeout, Lang.bind(this, this._onTrayLeftTimeout));
},
_onTrayLeftTimeout: function() {
this._trayLeftTimeoutId = 0;
this._pointerInTray = false;
this._pointerInSummary = false;
this._updateState();
return false;
},
// All of the logic for what happens when occurs here; the various
// event handlers merely update variables such as
// "this._pointerInTray", "this._summaryState", etc, and
// _updateState() figures out what (if anything) needs to be done
// at the present time.
_updateState: function() {
// Notifications
let notificationsPending = this._notificationQueue.length > 0;
let notificationPinned = this._pointerInTray && !this._pointerInSummary;
let notificationExpanded = this._notificationBin.y < 0;
let notificationExpired = this._notificationTimeoutId == 0 && !this._pointerInTray;
if (this._notificationState == State.HIDDEN) {
if (notificationsPending)
this._showNotification();
} else if (this._notificationState == State.SHOWN) {
if (notificationExpired)
this._hideNotification();
else if (notificationPinned && !notificationExpanded)
this._expandNotification();
}
// Summary
let summarySummoned = this._pointerInSummary;
let summaryPinned = this._summaryTimeoutId != 0 || this._pointerInTray || summarySummoned;
let notificationsVisible = (this._notificationState == State.SHOWING ||
this._notificationState == State.SHOWN);
let notificationsDone = !notificationsVisible && !notificationsPending;
if (this._summaryState == State.HIDDEN) {
if (notificationsDone && this._summaryNeedsToBeShown)
this._showSummary(true);
else if (!notificationsVisible && summarySummoned)
this._showSummary(false);
} else if (this._summaryState == State.SHOWN) {
if (!summaryPinned)
this._hideSummary();
}
// Tray itself
let trayIsVisible = (this._trayState == State.SHOWING ||
this._trayState == State.SHOWN);
let trayShouldBeVisible = (!notificationsDone ||
this._summaryState == State.SHOWING ||
this._summaryState == State.SHOWN);
if (!trayIsVisible && trayShouldBeVisible)
this._showTray();
else if (trayIsVisible && !trayShouldBeVisible)
this._hideTray();
},
_tween: function(actor, statevar, value, params) {
let onComplete = params.onComplete;
let onCompleteScope = params.onCompleteScope;
let onCompleteParams = params.onCompleteParams;
params.onComplete = this._tweenComplete;
params.onCompleteScope = this;
params.onCompleteParams = [statevar, value, onComplete, onCompleteScope, onCompleteParams];
Tweener.addTween(actor, params);
let valuing = (value == State.SHOWN) ? State.SHOWING : State.HIDING;
this[statevar] = valuing;
},
_tweenComplete: function(statevar, value, onComplete, onCompleteScope, onCompleteParams) {
this[statevar] = value;
if (onComplete)
onComplete.apply(onCompleteScope, onCompleteParams);
this._updateState();
}, },
_showTray: function() { _showTray: function() {
let primary = global.get_primary_monitor(); let primary = global.get_primary_monitor();
Tweener.addTween(this.actor, this._tween(this.actor, "_trayState", State.SHOWN,
{ y: primary.y + primary.height - this.actor.height, { y: primary.y + primary.height - this.actor.height,
time: ANIMATION_TIME, time: ANIMATION_TIME,
transition: "easeOutQuad" transition: "easeOutQuad"
}); });
}, },
_hideTray: function() { _hideTray: function() {
let primary = global.get_primary_monitor(); let primary = global.get_primary_monitor();
this._tween(this.actor, "_trayState", State.HIDDEN,
Tweener.addTween(this.actor, { y: primary.y + primary.height - 1,
{ y: primary.y + primary.height - 1, time: ANIMATION_TIME,
time: ANIMATION_TIME, transition: "easeOutQuad"
transition: "easeOutQuad" });
});
return false;
}, },
_showNotification: function() { _showNotification: function() {
@ -587,44 +600,94 @@ MessageTray.prototype = {
this._notificationBin.y = this.actor.height; this._notificationBin.y = this.actor.height;
this._notificationBin.show(); this._notificationBin.show();
Tweener.addTween(this._notificationBin, this._tween(this._notificationBin, "_notificationState", State.SHOWN,
{ y: 0, { y: 0,
opacity: 255, opacity: 255,
time: ANIMATION_TIME, time: ANIMATION_TIME,
transition: "easeOutQuad" }); transition: "easeOutQuad",
onComplete: this._showNotificationCompleted,
onCompleteScope: this
});
},
_showNotificationCompleted: function() {
this._notificationTimeoutId =
Mainloop.timeout_add(NOTIFICATION_TIMEOUT * 1000,
Lang.bind(this, this._notificationTimeout));
},
_notificationTimeout: function() {
this._notificationTimeoutId = 0;
this._updateState();
return false;
}, },
_hideNotification: function() { _hideNotification: function() {
this._notification.popIn(); this._notification.popIn();
Tweener.addTween(this._notificationBin, this._tween(this._notificationBin, "_notificationState", State.HIDDEN,
{ y: this.actor.height, { y: this.actor.height,
opacity: 0, opacity: 0,
time: ANIMATION_TIME, time: ANIMATION_TIME,
transition: "easeOutQuad", transition: "easeOutQuad",
onComplete: Lang.bind(this, function() { onComplete: this._hideNotificationCompleted,
this._notificationBin.hide(); onCompleteScope: this
this._notificationBin.child = null; });
this._notification = null;
})});
}, },
_showSummary: function() { _hideNotificationCompleted: function() {
this._notificationBin.hide();
this._notificationBin.child = null;
this._notification = null;
},
_expandNotification: function() {
if (this._notification && this._notification.popOut()) {
this._tween(this._notificationBin, "_notificationState", State.SHOWN,
{ y: this.actor.height - this._notificationBin.height,
time: ANIMATION_TIME,
transition: "easeOutQuad"
});
}
},
_showSummary: function(withTimeout) {
let primary = global.get_primary_monitor(); let primary = global.get_primary_monitor();
this._summaryBin.opacity = 0; this._summaryBin.opacity = 0;
this._summaryBin.y = this.actor.height; this._summaryBin.y = this.actor.height;
Tweener.addTween(this._summaryBin, this._tween(this._summaryBin, "_summaryState", State.SHOWN,
{ y: 0, { y: 0,
opacity: 255, opacity: 255,
time: ANIMATION_TIME, time: ANIMATION_TIME,
transition: "easeOutQuad" }); transition: "easeOutQuad",
onComplete: this._showSummaryCompleted,
onCompleteScope: this,
onCompleteParams: [withTimeout]
});
},
_showSummaryCompleted: function(withTimeout) {
this._summaryNeedsToBeShown = false;
if (withTimeout) {
this._summaryTimeoutId =
Mainloop.timeout_add(SUMMARY_TIMEOUT * 1000,
Lang.bind(this, this._summaryTimeout));
}
},
_summaryTimeout: function() {
this._summaryTimeoutId = 0;
this._updateState();
return false;
}, },
_hideSummary: function() { _hideSummary: function() {
Tweener.addTween(this._summaryBin, this._tween(this._summaryBin, "_summaryState", State.HIDDEN,
{ opacity: 0, { opacity: 0,
time: ANIMATION_TIME, time: ANIMATION_TIME,
transition: "easeOutQuad" transition: "easeOutQuad"
}); });
this._summaryNeedsToBeShown = false;
} }
}; };