8423ba44fe
These traditionally got the various ClutterEvent subtype structs as their argument, so it was not allowed to use ClutterEvent generic getter methods in these vfuncs. These methods used direct access to struct fields instead. This got spoiled with the move to make ClutterEvent opaque types, since these are no longer public structs so GNOME Shell most silently failed to fetch the expected values from event fields. But since they are not ClutterEvents either, the getters could not be used on them. Mutter is changing so that these vmethods all contain an alias to the one and only Clutter.Event type, thus lifting those barriers, and making it possible to use the ClutterEvent methods in these vfuncs. Closes: https://gitlab.gnome.org/GNOME/mutter/-/issues/2950 Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2872>
771 lines
23 KiB
JavaScript
771 lines
23 KiB
JavaScript
import Atk from 'gi://Atk';
|
|
import Clutter from 'gi://Clutter';
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
import Graphene from 'gi://Graphene';
|
|
import Meta from 'gi://Meta';
|
|
import Pango from 'gi://Pango';
|
|
import St from 'gi://St';
|
|
|
|
import * as Main from './main.js';
|
|
import * as MessageTray from './messageTray.js';
|
|
|
|
import * as Util from '../misc/util.js';
|
|
|
|
const MESSAGE_ANIMATION_TIME = 100;
|
|
|
|
const DEFAULT_EXPAND_LINES = 6;
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {boolean} allowMarkup
|
|
* @returns {string}
|
|
*/
|
|
export function _fixMarkup(text, allowMarkup) {
|
|
if (allowMarkup) {
|
|
// Support &, ", ', < and >, escape all other
|
|
// occurrences of '&'.
|
|
let _text = text.replace(/&(?!amp;|quot;|apos;|lt;|gt;)/g, '&');
|
|
|
|
// Support <b>, <i>, and <u>, escape anything else
|
|
// so it displays as raw markup.
|
|
// Ref: https://developer.gnome.org/notification-spec/#markup
|
|
_text = _text.replace(/<(?!\/?[biu]>)/g, '<');
|
|
|
|
try {
|
|
Pango.parse_markup(_text, -1, '');
|
|
return _text;
|
|
} catch (e) {}
|
|
}
|
|
|
|
// !allowMarkup, or invalid markup
|
|
return GLib.markup_escape_text(text, -1);
|
|
}
|
|
|
|
export const URLHighlighter = GObject.registerClass(
|
|
class URLHighlighter extends St.Label {
|
|
_init(text = '', lineWrap, allowMarkup) {
|
|
super._init({
|
|
reactive: true,
|
|
style_class: 'url-highlighter',
|
|
x_expand: true,
|
|
x_align: Clutter.ActorAlign.START,
|
|
});
|
|
this._linkColor = '#ccccff';
|
|
this.connect('style-changed', () => {
|
|
let [hasColor, color] = this.get_theme_node().lookup_color('link-color', false);
|
|
if (hasColor) {
|
|
let linkColor = color.to_string().substr(0, 7);
|
|
if (linkColor != this._linkColor) {
|
|
this._linkColor = linkColor;
|
|
this._highlightUrls();
|
|
}
|
|
}
|
|
});
|
|
this.clutter_text.line_wrap = lineWrap;
|
|
this.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
|
|
|
|
this.setMarkup(text, allowMarkup);
|
|
}
|
|
|
|
vfunc_button_press_event(event) {
|
|
// Don't try to URL highlight when invisible.
|
|
// The MessageTray doesn't actually hide us, so
|
|
// we need to check for paint opacities as well.
|
|
if (!this.visible || this.get_paint_opacity() == 0)
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
// Keep Notification from seeing this and taking
|
|
// a pointer grab, which would block our button-release-event
|
|
// handler, if an URL is clicked
|
|
return this._findUrlAtPos(event) !== -1;
|
|
}
|
|
|
|
vfunc_button_release_event(event) {
|
|
if (!this.visible || this.get_paint_opacity() == 0)
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
const urlId = this._findUrlAtPos(event);
|
|
if (urlId != -1) {
|
|
let url = this._urls[urlId].url;
|
|
if (!url.includes(':'))
|
|
url = `http://${url}`;
|
|
|
|
Gio.app_info_launch_default_for_uri(
|
|
url, global.create_app_launch_context(0, -1));
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
vfunc_motion_event(event) {
|
|
if (!this.visible || this.get_paint_opacity() == 0)
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
const urlId = this._findUrlAtPos(event);
|
|
if (urlId != -1 && !this._cursorChanged) {
|
|
global.display.set_cursor(Meta.Cursor.POINTING_HAND);
|
|
this._cursorChanged = true;
|
|
} else if (urlId == -1) {
|
|
global.display.set_cursor(Meta.Cursor.DEFAULT);
|
|
this._cursorChanged = false;
|
|
}
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
vfunc_leave_event(event) {
|
|
if (!this.visible || this.get_paint_opacity() == 0)
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
if (this._cursorChanged) {
|
|
this._cursorChanged = false;
|
|
global.display.set_cursor(Meta.Cursor.DEFAULT);
|
|
}
|
|
return super.vfunc_leave_event(event);
|
|
}
|
|
|
|
setMarkup(text, allowMarkup) {
|
|
text = text ? _fixMarkup(text, allowMarkup) : '';
|
|
this._text = text;
|
|
|
|
this.clutter_text.set_markup(text);
|
|
/* clutter_text.text contain text without markup */
|
|
this._urls = Util.findUrls(this.clutter_text.text);
|
|
this._highlightUrls();
|
|
}
|
|
|
|
_highlightUrls() {
|
|
// text here contain markup
|
|
let urls = Util.findUrls(this._text);
|
|
let markup = '';
|
|
let pos = 0;
|
|
for (let i = 0; i < urls.length; i++) {
|
|
let url = urls[i];
|
|
let str = this._text.substr(pos, url.pos - pos);
|
|
markup += `${str}<span foreground="${this._linkColor}"><u>${url.url}</u></span>`;
|
|
pos = url.pos + url.url.length;
|
|
}
|
|
markup += this._text.substr(pos);
|
|
this.clutter_text.set_markup(markup);
|
|
}
|
|
|
|
_findUrlAtPos(event) {
|
|
let [x, y] = event.get_coords();
|
|
[, x, y] = this.transform_stage_point(x, y);
|
|
let findPos = -1;
|
|
for (let i = 0; i < this.clutter_text.text.length; i++) {
|
|
let [, px, py, lineHeight] = this.clutter_text.position_to_coords(i);
|
|
if (py > y || py + lineHeight < y || x < px)
|
|
continue;
|
|
findPos = i;
|
|
}
|
|
if (findPos != -1) {
|
|
for (let i = 0; i < this._urls.length; i++) {
|
|
if (findPos >= this._urls[i].pos &&
|
|
this._urls[i].pos + this._urls[i].url.length > findPos)
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
});
|
|
|
|
const ScaleLayout = GObject.registerClass(
|
|
class ScaleLayout extends Clutter.BinLayout {
|
|
_init(params) {
|
|
this._container = null;
|
|
super._init(params);
|
|
}
|
|
|
|
_connectContainer(container) {
|
|
if (this._container == container)
|
|
return;
|
|
|
|
this._container?.disconnectObject(this);
|
|
|
|
this._container = container;
|
|
|
|
if (this._container) {
|
|
this._container.connectObject(
|
|
'notify::scale-x', () => this.layout_changed(),
|
|
'notify::scale-y', () => this.layout_changed(), this);
|
|
}
|
|
}
|
|
|
|
vfunc_get_preferred_width(container, forHeight) {
|
|
this._connectContainer(container);
|
|
|
|
let [min, nat] = super.vfunc_get_preferred_width(container, forHeight);
|
|
return [
|
|
Math.floor(min * container.scale_x),
|
|
Math.floor(nat * container.scale_x),
|
|
];
|
|
}
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
this._connectContainer(container);
|
|
|
|
let [min, nat] = super.vfunc_get_preferred_height(container, forWidth);
|
|
return [
|
|
Math.floor(min * container.scale_y),
|
|
Math.floor(nat * container.scale_y),
|
|
];
|
|
}
|
|
});
|
|
|
|
const LabelExpanderLayout = GObject.registerClass({
|
|
Properties: {
|
|
'expansion': GObject.ParamSpec.double('expansion',
|
|
'Expansion',
|
|
'Expansion of the layout, between 0 (collapsed) ' +
|
|
'and 1 (fully expanded',
|
|
GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
|
|
0, 1, 0),
|
|
},
|
|
}, class LabelExpanderLayout extends Clutter.LayoutManager {
|
|
_init(params) {
|
|
this._expansion = 0;
|
|
this._expandLines = DEFAULT_EXPAND_LINES;
|
|
|
|
super._init(params);
|
|
}
|
|
|
|
get expansion() {
|
|
return this._expansion;
|
|
}
|
|
|
|
set expansion(v) {
|
|
if (v == this._expansion)
|
|
return;
|
|
this._expansion = v;
|
|
this.notify('expansion');
|
|
|
|
let visibleIndex = this._expansion > 0 ? 1 : 0;
|
|
for (let i = 0; this._container && i < this._container.get_n_children(); i++)
|
|
this._container.get_child_at_index(i).visible = i == visibleIndex;
|
|
|
|
this.layout_changed();
|
|
}
|
|
|
|
set expandLines(v) {
|
|
if (v == this._expandLines)
|
|
return;
|
|
this._expandLines = v;
|
|
if (this._expansion > 0)
|
|
this.layout_changed();
|
|
}
|
|
|
|
vfunc_set_container(container) {
|
|
this._container = container;
|
|
}
|
|
|
|
vfunc_get_preferred_width(container, forHeight) {
|
|
let [min, nat] = [0, 0];
|
|
|
|
for (let i = 0; i < container.get_n_children(); i++) {
|
|
if (i > 1)
|
|
break; // we support one unexpanded + one expanded child
|
|
|
|
let child = container.get_child_at_index(i);
|
|
let [childMin, childNat] = child.get_preferred_width(forHeight);
|
|
[min, nat] = [Math.max(min, childMin), Math.max(nat, childNat)];
|
|
}
|
|
|
|
return [min, nat];
|
|
}
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
let [min, nat] = [0, 0];
|
|
|
|
let children = container.get_children();
|
|
if (children[0])
|
|
[min, nat] = children[0].get_preferred_height(forWidth);
|
|
|
|
if (children[1]) {
|
|
let [min2, nat2] = children[1].get_preferred_height(forWidth);
|
|
const [expMin, expNat] = [
|
|
Math.min(min2, min * this._expandLines),
|
|
Math.min(nat2, nat * this._expandLines),
|
|
];
|
|
[min, nat] = [
|
|
min + this._expansion * (expMin - min),
|
|
nat + this._expansion * (expNat - nat),
|
|
];
|
|
}
|
|
|
|
return [min, nat];
|
|
}
|
|
|
|
vfunc_allocate(container, box) {
|
|
for (let i = 0; i < container.get_n_children(); i++) {
|
|
let child = container.get_child_at_index(i);
|
|
|
|
if (child.visible)
|
|
child.allocate(box);
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
export const Message = GObject.registerClass({
|
|
Signals: {
|
|
'close': {},
|
|
'expanded': {},
|
|
'unexpanded': {},
|
|
},
|
|
}, class Message extends St.Button {
|
|
_init(title, body) {
|
|
super._init({
|
|
style_class: 'message',
|
|
accessible_role: Atk.Role.NOTIFICATION,
|
|
can_focus: true,
|
|
x_expand: true,
|
|
y_expand: true,
|
|
});
|
|
|
|
this.expanded = false;
|
|
this._useBodyMarkup = false;
|
|
|
|
let vbox = new St.BoxLayout({
|
|
vertical: true,
|
|
x_expand: true,
|
|
});
|
|
this.set_child(vbox);
|
|
|
|
let hbox = new St.BoxLayout();
|
|
vbox.add_actor(hbox);
|
|
|
|
this._actionBin = new St.Widget({
|
|
layout_manager: new ScaleLayout(),
|
|
visible: false,
|
|
});
|
|
vbox.add_actor(this._actionBin);
|
|
|
|
this._iconBin = new St.Bin({
|
|
style_class: 'message-icon-bin',
|
|
y_expand: true,
|
|
y_align: Clutter.ActorAlign.START,
|
|
visible: false,
|
|
});
|
|
hbox.add_actor(this._iconBin);
|
|
|
|
const contentBox = new St.BoxLayout({
|
|
style_class: 'message-content',
|
|
vertical: true,
|
|
x_expand: true,
|
|
});
|
|
hbox.add_actor(contentBox);
|
|
|
|
this._mediaControls = new St.BoxLayout();
|
|
hbox.add_actor(this._mediaControls);
|
|
|
|
let titleBox = new St.BoxLayout();
|
|
contentBox.add_actor(titleBox);
|
|
|
|
this.titleLabel = new St.Label({ style_class: 'message-title' });
|
|
this.setTitle(title);
|
|
titleBox.add_actor(this.titleLabel);
|
|
|
|
this._secondaryBin = new St.Bin({
|
|
style_class: 'message-secondary-bin',
|
|
x_expand: true, y_expand: true,
|
|
});
|
|
titleBox.add_actor(this._secondaryBin);
|
|
|
|
this._closeButton = new St.Button({
|
|
style_class: 'message-close-button',
|
|
icon_name: 'window-close-symbolic',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
opacity: 0,
|
|
});
|
|
titleBox.add_actor(this._closeButton);
|
|
|
|
this._bodyStack = new St.Widget({ x_expand: true });
|
|
this._bodyStack.layout_manager = new LabelExpanderLayout();
|
|
contentBox.add_actor(this._bodyStack);
|
|
|
|
this.bodyLabel = new URLHighlighter('', false, this._useBodyMarkup);
|
|
this.bodyLabel.add_style_class_name('message-body');
|
|
this._bodyStack.add_actor(this.bodyLabel);
|
|
this.setBody(body);
|
|
|
|
this._closeButton.connect('clicked', this.close.bind(this));
|
|
let actorHoverId = this.connect('notify::hover', this._sync.bind(this));
|
|
this._closeButton.connect('destroy', this.disconnect.bind(this, actorHoverId));
|
|
this.connect('destroy', this._onDestroy.bind(this));
|
|
this._sync();
|
|
}
|
|
|
|
close() {
|
|
this.emit('close');
|
|
}
|
|
|
|
setIcon(actor) {
|
|
this._iconBin.child = actor;
|
|
this._iconBin.visible = actor != null;
|
|
}
|
|
|
|
setSecondaryActor(actor) {
|
|
this._secondaryBin.child = actor;
|
|
}
|
|
|
|
setTitle(text) {
|
|
let title = text ? _fixMarkup(text.replace(/\n/g, ' '), false) : '';
|
|
this.titleLabel.clutter_text.set_markup(title);
|
|
}
|
|
|
|
setBody(text) {
|
|
this._bodyText = text;
|
|
this.bodyLabel.setMarkup(text ? text.replace(/\n/g, ' ') : '',
|
|
this._useBodyMarkup);
|
|
if (this._expandedLabel)
|
|
this._expandedLabel.setMarkup(text, this._useBodyMarkup);
|
|
}
|
|
|
|
setUseBodyMarkup(enable) {
|
|
if (this._useBodyMarkup === enable)
|
|
return;
|
|
this._useBodyMarkup = enable;
|
|
if (this.bodyLabel)
|
|
this.setBody(this._bodyText);
|
|
}
|
|
|
|
setActionArea(actor) {
|
|
if (actor == null) {
|
|
if (this._actionBin.get_n_children() > 0)
|
|
this._actionBin.get_child_at_index(0).destroy();
|
|
return;
|
|
}
|
|
|
|
if (this._actionBin.get_n_children() > 0)
|
|
throw new Error('Message already has an action area');
|
|
|
|
this._actionBin.add_actor(actor);
|
|
this._actionBin.visible = this.expanded;
|
|
}
|
|
|
|
addMediaControl(iconName, callback) {
|
|
const button = new St.Button({
|
|
style_class: 'message-media-control',
|
|
iconName,
|
|
});
|
|
button.connect('clicked', callback);
|
|
this._mediaControls.add_actor(button);
|
|
return button;
|
|
}
|
|
|
|
setExpandedBody(actor) {
|
|
if (actor == null) {
|
|
if (this._bodyStack.get_n_children() > 1)
|
|
this._bodyStack.get_child_at_index(1).destroy();
|
|
return;
|
|
}
|
|
|
|
if (this._bodyStack.get_n_children() > 1)
|
|
throw new Error('Message already has an expanded body actor');
|
|
|
|
this._bodyStack.insert_child_at_index(actor, 1);
|
|
}
|
|
|
|
setExpandedLines(nLines) {
|
|
this._bodyStack.layout_manager.expandLines = nLines;
|
|
}
|
|
|
|
expand(animate) {
|
|
this.expanded = true;
|
|
|
|
this._actionBin.visible = this._actionBin.get_n_children() > 0;
|
|
|
|
if (this._bodyStack.get_n_children() < 2) {
|
|
this._expandedLabel = new URLHighlighter(this._bodyText,
|
|
true, this._useBodyMarkup);
|
|
this.setExpandedBody(this._expandedLabel);
|
|
}
|
|
|
|
if (animate) {
|
|
this._bodyStack.ease_property('@layout.expansion', 1, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration: MessageTray.ANIMATION_TIME,
|
|
});
|
|
|
|
this._actionBin.scale_y = 0;
|
|
this._actionBin.ease({
|
|
scale_y: 1,
|
|
duration: MessageTray.ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
} else {
|
|
this._bodyStack.layout_manager.expansion = 1;
|
|
this._actionBin.scale_y = 1;
|
|
}
|
|
|
|
this.emit('expanded');
|
|
}
|
|
|
|
unexpand(animate) {
|
|
if (animate) {
|
|
this._bodyStack.ease_property('@layout.expansion', 0, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration: MessageTray.ANIMATION_TIME,
|
|
});
|
|
|
|
this._actionBin.ease({
|
|
scale_y: 0,
|
|
duration: MessageTray.ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
this._actionBin.hide();
|
|
this.expanded = false;
|
|
},
|
|
});
|
|
} else {
|
|
this._bodyStack.layout_manager.expansion = 0;
|
|
this._actionBin.scale_y = 0;
|
|
this.expanded = false;
|
|
}
|
|
|
|
this.emit('unexpanded');
|
|
}
|
|
|
|
canClose() {
|
|
return false;
|
|
}
|
|
|
|
_sync() {
|
|
let visible = this.hover && this.canClose();
|
|
this._closeButton.opacity = visible ? 255 : 0;
|
|
this._closeButton.reactive = visible;
|
|
}
|
|
|
|
_onDestroy() {
|
|
}
|
|
|
|
vfunc_key_press_event(event) {
|
|
let keysym = event.get_key_symbol();
|
|
|
|
if (keysym == Clutter.KEY_Delete ||
|
|
keysym == Clutter.KEY_KP_Delete) {
|
|
if (this.canClose()) {
|
|
this.close();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
}
|
|
return super.vfunc_key_press_event(event);
|
|
}
|
|
});
|
|
|
|
export const MessageListSection = GObject.registerClass({
|
|
Properties: {
|
|
'can-clear': GObject.ParamSpec.boolean(
|
|
'can-clear', 'can-clear', 'can-clear',
|
|
GObject.ParamFlags.READABLE,
|
|
false),
|
|
'empty': GObject.ParamSpec.boolean(
|
|
'empty', 'empty', 'empty',
|
|
GObject.ParamFlags.READABLE,
|
|
true),
|
|
},
|
|
Signals: {
|
|
'can-clear-changed': {},
|
|
'empty-changed': {},
|
|
'message-focused': { param_types: [Message.$gtype] },
|
|
},
|
|
}, class MessageListSection extends St.BoxLayout {
|
|
_init() {
|
|
super._init({
|
|
style_class: 'message-list-section',
|
|
clip_to_allocation: true,
|
|
vertical: true,
|
|
x_expand: true,
|
|
});
|
|
|
|
this._list = new St.BoxLayout({
|
|
style_class: 'message-list-section-list',
|
|
vertical: true,
|
|
});
|
|
this.add_actor(this._list);
|
|
|
|
this._list.connect('actor-added', this._sync.bind(this));
|
|
this._list.connect('actor-removed', this._sync.bind(this));
|
|
|
|
Main.sessionMode.connectObject(
|
|
'updated', () => this._sync(), this);
|
|
|
|
this._empty = true;
|
|
this._canClear = false;
|
|
this._sync();
|
|
}
|
|
|
|
get empty() {
|
|
return this._empty;
|
|
}
|
|
|
|
get canClear() {
|
|
return this._canClear;
|
|
}
|
|
|
|
get _messages() {
|
|
return this._list.get_children().map(i => i.child);
|
|
}
|
|
|
|
_onKeyFocusIn(messageActor) {
|
|
this.emit('message-focused', messageActor);
|
|
}
|
|
|
|
get allowed() {
|
|
return true;
|
|
}
|
|
|
|
addMessage(message, animate) {
|
|
this.addMessageAtIndex(message, -1, animate);
|
|
}
|
|
|
|
addMessageAtIndex(message, index, animate) {
|
|
if (this._messages.includes(message))
|
|
throw new Error('Message was already added previously');
|
|
|
|
let listItem = new St.Bin({
|
|
child: message,
|
|
layout_manager: new ScaleLayout(),
|
|
pivot_point: new Graphene.Point({ x: .5, y: .5 }),
|
|
});
|
|
listItem._connectionsIds = [];
|
|
|
|
listItem._connectionsIds.push(message.connect('key-focus-in',
|
|
this._onKeyFocusIn.bind(this)));
|
|
listItem._connectionsIds.push(message.connect('close', () => {
|
|
this.removeMessage(message, true);
|
|
}));
|
|
listItem._connectionsIds.push(message.connect('destroy', () => {
|
|
listItem._connectionsIds.forEach(id => message.disconnect(id));
|
|
listItem.destroy();
|
|
}));
|
|
|
|
this._list.insert_child_at_index(listItem, index);
|
|
|
|
if (animate) {
|
|
listItem.set({ scale_x: 0, scale_y: 0 });
|
|
listItem.ease({
|
|
scale_x: 1,
|
|
scale_y: 1,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
}
|
|
}
|
|
|
|
moveMessage(message, index, animate) {
|
|
if (!this._messages.includes(message))
|
|
throw new Error(`Impossible to move untracked message`);
|
|
|
|
let listItem = message.get_parent();
|
|
|
|
if (!animate) {
|
|
this._list.set_child_at_index(listItem, index);
|
|
return;
|
|
}
|
|
|
|
let onComplete = () => {
|
|
this._list.set_child_at_index(listItem, index);
|
|
listItem.ease({
|
|
scale_x: 1,
|
|
scale_y: 1,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
};
|
|
listItem.ease({
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete,
|
|
});
|
|
}
|
|
|
|
removeMessage(message, animate) {
|
|
const messages = this._messages;
|
|
|
|
if (!messages.includes(message))
|
|
throw new Error(`Impossible to remove untracked message`);
|
|
|
|
let listItem = message.get_parent();
|
|
listItem._connectionsIds.forEach(id => message.disconnect(id));
|
|
|
|
let nextMessage = null;
|
|
|
|
if (message.has_key_focus()) {
|
|
const index = messages.indexOf(message);
|
|
nextMessage =
|
|
messages[index + 1] ||
|
|
messages[index - 1] ||
|
|
this._list;
|
|
}
|
|
|
|
if (animate) {
|
|
listItem.ease({
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
listItem.destroy();
|
|
nextMessage?.grab_key_focus();
|
|
},
|
|
});
|
|
} else {
|
|
listItem.destroy();
|
|
nextMessage?.grab_key_focus();
|
|
}
|
|
}
|
|
|
|
clear() {
|
|
let messages = this._messages.filter(msg => msg.canClose());
|
|
|
|
// If there are few messages, letting them all zoom out looks OK
|
|
if (messages.length < 2) {
|
|
messages.forEach(message => {
|
|
message.close();
|
|
});
|
|
} else {
|
|
// Otherwise we slide them out one by one, and then zoom them
|
|
// out "off-screen" in the end to smoothly shrink the parent
|
|
let delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5);
|
|
for (let i = 0; i < messages.length; i++) {
|
|
let message = messages[i];
|
|
message.get_parent().ease({
|
|
translation_x: this._list.width,
|
|
opacity: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
delay: i * delay,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => message.close(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_shouldShow() {
|
|
return !this.empty;
|
|
}
|
|
|
|
_sync() {
|
|
let messages = this._messages;
|
|
let empty = messages.length == 0;
|
|
|
|
if (this._empty != empty) {
|
|
this._empty = empty;
|
|
this.notify('empty');
|
|
}
|
|
|
|
let canClear = messages.some(m => m.canClose());
|
|
if (this._canClear != canClear) {
|
|
this._canClear = canClear;
|
|
this.notify('can-clear');
|
|
}
|
|
|
|
this.visible = this.allowed && this._shouldShow();
|
|
}
|
|
});
|