
The new design requires that other messages and groups are faded when the user has a group expanded. This introduces a new GLSL shader to provide the desired effect. The new shader is used for the already existing scroll fade and the previous one is removed. The two fades need to work together to ensure that resulting fade looks good. Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3012>
2072 lines
65 KiB
JavaScript
2072 lines
65 KiB
JavaScript
import Atk from 'gi://Atk';
|
|
import Clutter from 'gi://Clutter';
|
|
import Cogl from 'gi://Cogl';
|
|
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 Shell from 'gi://Shell';
|
|
import St from 'gi://St';
|
|
|
|
import * as Main from './main.js';
|
|
import * as MessageTray from './messageTray.js';
|
|
import * as Mpris from './mpris.js';
|
|
|
|
import * as Util from '../misc/util.js';
|
|
import {formatTimeSpan} from '../misc/dateUtils.js';
|
|
|
|
const MAX_NOTIFICATION_BUTTONS = 3;
|
|
const MESSAGE_ANIMATION_TIME = 100;
|
|
|
|
const EXPANDED_GROUP_OVERSHOT_HEIGHT = 50;
|
|
const DEFAULT_EXPAND_LINES = 6;
|
|
|
|
const GROUP_EXPENSION_TIME = 200;
|
|
const MAX_VISIBLE_STACKED_MESSAGES = 3;
|
|
const ADDITIONAL_BOTTOM_MARGIN_EXPANDED_GROUP = 15;
|
|
const WIDTH_OFFSET_STACKED = 6;
|
|
const HEIGHT_OFFSET_STACKED = 10;
|
|
const HEIGHT_OFFSET_REDUCTION_STACKED = 1.4;
|
|
|
|
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().substring(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 ? Util.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.substring(pos, url.pos);
|
|
markup += `${str}<span foreground="${this._linkColor}"><u>${url.url}</u></span>`;
|
|
pos = url.pos + url.url.length;
|
|
}
|
|
markup += this._text.substring(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({
|
|
Properties: {
|
|
'scaling-enabled': GObject.ParamSpec.boolean(
|
|
'scaling-enabled', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
true),
|
|
},
|
|
}, class ScaleLayout extends Clutter.BinLayout {
|
|
_container = null;
|
|
_scalingEnabled = true;
|
|
|
|
get scalingEnabled() {
|
|
return this._scalingEnabled;
|
|
}
|
|
|
|
set scalingEnabled(value) {
|
|
if (this._scalingEnabled === value)
|
|
return;
|
|
|
|
this._scalingEnabled = value;
|
|
this.notify('scaling-enabled');
|
|
this.layout_changed();
|
|
}
|
|
|
|
vfunc_set_container(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) {
|
|
const [min, nat] = super.vfunc_get_preferred_width(container, forHeight);
|
|
|
|
if (this._scalingEnabled) {
|
|
return [
|
|
Math.floor(min * container.scale_x),
|
|
Math.floor(nat * container.scale_x),
|
|
];
|
|
} else {
|
|
return [min, nat];
|
|
}
|
|
}
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
const [min, nat] = super.vfunc_get_preferred_height(container, forWidth);
|
|
|
|
if (this._scalingEnabled) {
|
|
return [
|
|
Math.floor(min * container.scale_y),
|
|
Math.floor(nat * container.scale_y),
|
|
];
|
|
} else {
|
|
return [min, nat];
|
|
}
|
|
}
|
|
});
|
|
|
|
const LabelExpanderLayout = GObject.registerClass({
|
|
Properties: {
|
|
'expansion': GObject.ParamSpec.double(
|
|
'expansion', null, null,
|
|
GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
|
|
0, 1, 0),
|
|
},
|
|
}, class LabelExpanderLayout extends Clutter.BinLayout {
|
|
constructor(params) {
|
|
super(params);
|
|
|
|
this._expansion = 0;
|
|
this._expandLines = DEFAULT_EXPAND_LINES;
|
|
}
|
|
|
|
get expansion() {
|
|
return this._expansion;
|
|
}
|
|
|
|
set expansion(v) {
|
|
if (v === this._expansion)
|
|
return;
|
|
this._expansion = v;
|
|
this.notify('expansion');
|
|
|
|
this.layout_changed();
|
|
}
|
|
|
|
set expandLines(v) {
|
|
if (v === this._expandLines)
|
|
return;
|
|
this._expandLines = v;
|
|
if (this._expansion > 0)
|
|
this.layout_changed();
|
|
}
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
let [min, nat] = [0, 0];
|
|
|
|
const [child] = container;
|
|
|
|
if (child) {
|
|
[min, nat] = child.get_preferred_height(-1);
|
|
|
|
const [, nat2] = child.get_preferred_height(forWidth);
|
|
const expHeight =
|
|
Math.min(nat2, nat * this._expandLines);
|
|
[min, nat] = [
|
|
min + this._expansion * (expHeight - min),
|
|
nat + this._expansion * (expHeight - nat),
|
|
];
|
|
}
|
|
|
|
return [min, nat];
|
|
}
|
|
});
|
|
|
|
export const Source = GObject.registerClass({
|
|
Properties: {
|
|
'title': GObject.ParamSpec.string(
|
|
'title', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
null),
|
|
'icon': GObject.ParamSpec.object(
|
|
'icon', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
Gio.Icon),
|
|
'icon-name': GObject.ParamSpec.string(
|
|
'icon-name', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
null),
|
|
},
|
|
}, class Source extends GObject.Object {
|
|
get iconName() {
|
|
if (this.gicon instanceof Gio.ThemedIcon)
|
|
return this.gicon.iconName;
|
|
else
|
|
return null;
|
|
}
|
|
|
|
set iconName(iconName) {
|
|
this.icon = new Gio.ThemedIcon({name: iconName});
|
|
}
|
|
});
|
|
|
|
const TimeLabel = GObject.registerClass(
|
|
class TimeLabel extends St.Label {
|
|
_init() {
|
|
super._init({
|
|
style_class: 'event-time',
|
|
x_expand: true,
|
|
y_expand: true,
|
|
x_align: Clutter.ActorAlign.START,
|
|
y_align: Clutter.ActorAlign.END,
|
|
visible: false,
|
|
});
|
|
}
|
|
|
|
get datetime() {
|
|
return this._datetime;
|
|
}
|
|
|
|
set datetime(datetime) {
|
|
if (this._datetime?.equal(datetime))
|
|
return;
|
|
|
|
this._datetime = datetime;
|
|
|
|
this.visible = !!this._datetime;
|
|
|
|
if (this.mapped)
|
|
this._updateText();
|
|
}
|
|
|
|
_updateText() {
|
|
if (this._datetime)
|
|
this.text = formatTimeSpan(this._datetime);
|
|
}
|
|
|
|
vfunc_map() {
|
|
this._updateText();
|
|
|
|
super.vfunc_map();
|
|
}
|
|
});
|
|
|
|
const MessageHeader = GObject.registerClass(
|
|
class MessageHeader extends St.BoxLayout {
|
|
constructor(source) {
|
|
super({
|
|
style_class: 'message-header',
|
|
x_expand: true,
|
|
});
|
|
|
|
const sourceIconEffect = new Clutter.DesaturateEffect();
|
|
const sourceIcon = new St.Icon({
|
|
style_class: 'message-source-icon',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
fallback_icon_name: 'application-x-executable-symbolic',
|
|
});
|
|
sourceIcon.add_effect(sourceIconEffect);
|
|
this.add_child(sourceIcon);
|
|
|
|
sourceIcon.connect('style-changed', () => {
|
|
const themeNode = sourceIcon.get_theme_node();
|
|
sourceIconEffect.enabled = themeNode.get_icon_style() === St.IconStyle.SYMBOLIC;
|
|
});
|
|
|
|
const headerContent = new St.BoxLayout({
|
|
style_class: 'message-header-content',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
x_expand: true,
|
|
});
|
|
this.add_child(headerContent);
|
|
|
|
this.expandButton = new St.Button({
|
|
style_class: 'message-expand-button',
|
|
icon_name: 'notification-expand-symbolic',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
|
|
});
|
|
this.add_child(this.expandButton);
|
|
|
|
this.closeButton = new St.Button({
|
|
style_class: 'message-close-button',
|
|
icon_name: 'window-close-symbolic',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
this.add_child(this.closeButton);
|
|
|
|
const sourceTitle = new St.Label({
|
|
style_class: 'message-source-title',
|
|
y_align: Clutter.ActorAlign.END,
|
|
});
|
|
headerContent.add_child(sourceTitle);
|
|
|
|
source.bind_property_full('title',
|
|
sourceTitle,
|
|
'text',
|
|
GObject.BindingFlags.SYNC_CREATE,
|
|
// Translators: this is the string displayed in the header when a message
|
|
// source doesn't have a name
|
|
(bind, value) => [true, value === null || value === '' ? _('Unknown App') : value],
|
|
null);
|
|
source.bind_property('icon',
|
|
sourceIcon,
|
|
'gicon',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
|
|
this.timeLabel = new TimeLabel();
|
|
headerContent.add_child(this.timeLabel);
|
|
}
|
|
});
|
|
|
|
export const Message = GObject.registerClass({
|
|
Properties: {
|
|
'title': GObject.ParamSpec.string(
|
|
'title', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
null),
|
|
'body': GObject.ParamSpec.string(
|
|
'body', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
null),
|
|
'use-body-markup': GObject.ParamSpec.boolean(
|
|
'use-body-markup', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
false),
|
|
'icon': GObject.ParamSpec.object(
|
|
'icon', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
Gio.Icon),
|
|
'datetime': GObject.ParamSpec.boxed(
|
|
'datetime', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
GLib.DateTime),
|
|
},
|
|
Signals: {
|
|
'close': {
|
|
flags: GObject.SignalFlags.RUN_LAST,
|
|
},
|
|
'expanded': {},
|
|
'unexpanded': {},
|
|
},
|
|
}, class Message extends St.Button {
|
|
constructor(source) {
|
|
super({
|
|
style_class: 'message',
|
|
accessible_role: Atk.Role.NOTIFICATION,
|
|
can_focus: true,
|
|
x_expand: true,
|
|
y_expand: false,
|
|
});
|
|
|
|
this.expanded = false;
|
|
this._useBodyMarkup = false;
|
|
|
|
let vbox = new St.BoxLayout({
|
|
orientation: Clutter.Orientation.VERTICAL,
|
|
x_expand: true,
|
|
});
|
|
this.set_child(vbox);
|
|
|
|
this._header = new MessageHeader(source);
|
|
vbox.add_child(this._header);
|
|
|
|
const hbox = new St.BoxLayout({
|
|
style_class: 'message-box',
|
|
});
|
|
vbox.add_child(hbox);
|
|
|
|
this._actionBin = new St.Bin({
|
|
style_class: 'message-action-bin',
|
|
layout_manager: new ScaleLayout(),
|
|
visible: false,
|
|
});
|
|
vbox.add_child(this._actionBin);
|
|
|
|
this._icon = new St.Icon({
|
|
style_class: 'message-icon',
|
|
y_expand: true,
|
|
y_align: Clutter.ActorAlign.START,
|
|
visible: false,
|
|
});
|
|
hbox.add_child(this._icon);
|
|
|
|
this._icon.connect('notify::is-symbolic', () => {
|
|
if (this._icon.is_symbolic)
|
|
this._icon.add_style_class_name('message-themed-icon');
|
|
else
|
|
this._icon.remove_style_class_name('message-themed-icon');
|
|
});
|
|
|
|
const contentBox = new St.BoxLayout({
|
|
style_class: 'message-content',
|
|
orientation: Clutter.Orientation.VERTICAL,
|
|
x_expand: true,
|
|
});
|
|
hbox.add_child(contentBox);
|
|
|
|
this._mediaControls = new St.BoxLayout();
|
|
hbox.add_child(this._mediaControls);
|
|
|
|
this.titleLabel = new St.Label({
|
|
style_class: 'message-title',
|
|
y_align: Clutter.ActorAlign.END,
|
|
});
|
|
contentBox.add_child(this.titleLabel);
|
|
|
|
this._bodyLabel = new URLHighlighter('', true, this._useBodyMarkup);
|
|
this._bodyLabel.add_style_class_name('message-body');
|
|
this._bodyBin = new St.Bin({
|
|
x_expand: true,
|
|
layout_manager: new LabelExpanderLayout(),
|
|
child: this._bodyLabel,
|
|
});
|
|
contentBox.add_child(this._bodyBin);
|
|
|
|
this._header.closeButton.connect('clicked', this.close.bind(this));
|
|
this._header.closeButton.visible = this.canClose();
|
|
|
|
this._header.expandButton.connect('clicked', () => {
|
|
if (this.expanded)
|
|
this.unexpand(true);
|
|
else
|
|
this.expand(true);
|
|
});
|
|
this._bodyLabel.connect('notify::allocation', this._updateExpandButton.bind(this));
|
|
this._updateExpandButton();
|
|
}
|
|
|
|
_updateExpandButton() {
|
|
if (!this._bodyLabel.has_allocation())
|
|
return;
|
|
const layout = this._bodyLabel.clutter_text.get_layout();
|
|
const canExpand = layout.is_ellipsized() || this.expanded || !!this._actionBin.child;
|
|
// Use opacity to not trigger a relayout
|
|
this._header.expandButton.opacity = canExpand ? 255 : 0;
|
|
}
|
|
|
|
close() {
|
|
this.emit('close');
|
|
}
|
|
|
|
set icon(icon) {
|
|
this._icon.gicon = icon;
|
|
|
|
this._icon.visible = !!icon;
|
|
this.notify('icon');
|
|
}
|
|
|
|
get icon() {
|
|
return this._icon.gicon;
|
|
}
|
|
|
|
set datetime(datetime) {
|
|
this._header.timeLabel.datetime = datetime;
|
|
this.notify('datetime');
|
|
}
|
|
|
|
get datetime() {
|
|
return this._header.timeLabel.datetime;
|
|
}
|
|
|
|
set title(text) {
|
|
this._titleText = text;
|
|
const title = text ? Util.fixMarkup(text.replace(/\n/g, ' '), false) : '';
|
|
this.titleLabel.clutter_text.set_markup(title);
|
|
this.notify('title');
|
|
}
|
|
|
|
get title() {
|
|
return this._titleText;
|
|
}
|
|
|
|
set body(text) {
|
|
this._bodyText = text;
|
|
this._bodyLabel.setMarkup(text ? text.replace(/\n/g, ' ') : '',
|
|
this._useBodyMarkup);
|
|
this.notify('body');
|
|
}
|
|
|
|
get body() {
|
|
return this._bodyText;
|
|
}
|
|
|
|
set useBodyMarkup(enable) {
|
|
if (this._useBodyMarkup === enable)
|
|
return;
|
|
this._useBodyMarkup = enable;
|
|
this.body = this._bodyText;
|
|
this.notify('use-body-markup');
|
|
}
|
|
|
|
get useBodyMarkup() {
|
|
return this._useBodyMarkup;
|
|
}
|
|
|
|
setActionArea(actor) {
|
|
this._actionBin.child = actor;
|
|
this._actionBin.visible = actor && this.expanded;
|
|
this._updateExpandButton();
|
|
}
|
|
|
|
addMediaControl(iconName, callback) {
|
|
const button = new St.Button({
|
|
style_class: 'message-media-control',
|
|
iconName,
|
|
});
|
|
button.connect('clicked', callback);
|
|
this._mediaControls.add_child(button);
|
|
return button;
|
|
}
|
|
|
|
expand(animate) {
|
|
this.expanded = true;
|
|
|
|
this._actionBin.visible = !!this._actionBin.child;
|
|
|
|
const duration = animate ? MessageTray.ANIMATION_TIME : 0;
|
|
this._bodyBin.ease_property('@layout.expansion', 1, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration,
|
|
});
|
|
|
|
this._actionBin.scale_y = 0;
|
|
this._actionBin.ease({
|
|
scale_y: 1,
|
|
duration,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
|
|
this._header.expandButton.ease({
|
|
rotation_angle_z: 180,
|
|
duration,
|
|
});
|
|
|
|
this.emit('expanded');
|
|
}
|
|
|
|
unexpand(animate) {
|
|
const duration = animate ? MessageTray.ANIMATION_TIME : 0;
|
|
this._bodyBin.ease_property('@layout.expansion', 0, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration,
|
|
});
|
|
|
|
this._actionBin.ease({
|
|
scale_y: 0,
|
|
duration,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
this._actionBin.hide();
|
|
this.expanded = false;
|
|
},
|
|
});
|
|
|
|
this._header.expandButton.ease({
|
|
rotation_angle_z: 0,
|
|
duration,
|
|
});
|
|
|
|
this.emit('unexpanded');
|
|
}
|
|
|
|
canClose() {
|
|
return false;
|
|
}
|
|
|
|
vfunc_key_press_event(event) {
|
|
let keysym = event.get_key_symbol();
|
|
|
|
if (keysym === Clutter.KEY_Delete ||
|
|
keysym === Clutter.KEY_KP_Delete ||
|
|
keysym === Clutter.KEY_BackSpace) {
|
|
if (this.canClose()) {
|
|
this.close();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
}
|
|
return super.vfunc_key_press_event(event);
|
|
}
|
|
});
|
|
|
|
export const NotificationMessage = GObject.registerClass(
|
|
class NotificationMessage extends Message {
|
|
constructor(notification) {
|
|
super(notification.source);
|
|
|
|
this.notification = notification;
|
|
|
|
notification.connectObject(
|
|
'action-added', (_, action) => this._addAction(action),
|
|
'action-removed', (_, action) => this._removeAction(action),
|
|
'destroy', () => {
|
|
this.notification = null;
|
|
if (!this._closed)
|
|
this.close();
|
|
}, this);
|
|
|
|
notification.bind_property('title',
|
|
this, 'title',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
notification.bind_property('body',
|
|
this, 'body',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
notification.bind_property('use-body-markup',
|
|
this, 'use-body-markup',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
notification.bind_property('datetime',
|
|
this, 'datetime',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
notification.bind_property('gicon',
|
|
this, 'icon',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
|
|
this._actions = new Map();
|
|
this.notification.actions.forEach(action => {
|
|
this._addAction(action);
|
|
});
|
|
}
|
|
|
|
on_close() {
|
|
this._closed = true;
|
|
this.notification?.destroy(MessageTray.NotificationDestroyedReason.DISMISSED);
|
|
}
|
|
|
|
vfunc_clicked() {
|
|
this.notification?.activate();
|
|
}
|
|
|
|
canClose() {
|
|
return true;
|
|
}
|
|
|
|
_addAction(action) {
|
|
if (!this._buttonBox) {
|
|
this._buttonBox = new St.BoxLayout({
|
|
x_expand: true,
|
|
style_class: 'notification-buttons-bin',
|
|
});
|
|
this.setActionArea(this._buttonBox);
|
|
global.focus_manager.add_group(this._buttonBox);
|
|
}
|
|
|
|
if (this._buttonBox.get_n_children() >= MAX_NOTIFICATION_BUTTONS)
|
|
return;
|
|
|
|
const button = new St.Button({
|
|
style_class: 'notification-button',
|
|
x_expand: true,
|
|
label: action.label,
|
|
});
|
|
|
|
button.connect('clicked', () => action.activate());
|
|
|
|
this._actions.set(action, button);
|
|
this._buttonBox.add_child(button);
|
|
}
|
|
|
|
_removeAction(action) {
|
|
this._actions.get(action)?.destroy();
|
|
this._actions.delete(action);
|
|
}
|
|
});
|
|
|
|
const MediaMessage = GObject.registerClass(
|
|
class MediaMessage extends Message {
|
|
constructor(player) {
|
|
super(player.source);
|
|
|
|
this._player = player;
|
|
this.add_style_class_name('media-message');
|
|
|
|
this._prevButton = this.addMediaControl('media-skip-backward-symbolic',
|
|
() => {
|
|
this._player.previous();
|
|
});
|
|
|
|
this._playPauseButton = this.addMediaControl('',
|
|
() => {
|
|
this._player.playPause();
|
|
});
|
|
|
|
this._nextButton = this.addMediaControl('media-skip-forward-symbolic',
|
|
() => {
|
|
this._player.next();
|
|
});
|
|
|
|
this._player.connectObject('changed', this._update.bind(this), this);
|
|
this._update();
|
|
}
|
|
|
|
vfunc_clicked() {
|
|
this._player.raise();
|
|
Main.panel.closeCalendar();
|
|
}
|
|
|
|
_updateNavButton(button, sensitive) {
|
|
button.reactive = sensitive;
|
|
}
|
|
|
|
_update() {
|
|
let icon;
|
|
if (this._player.trackCoverUrl) {
|
|
const file = Gio.File.new_for_uri(this._player.trackCoverUrl);
|
|
icon = new Gio.FileIcon({file});
|
|
} else {
|
|
icon = new Gio.ThemedIcon({name: 'audio-x-generic-symbolic'});
|
|
}
|
|
|
|
this.set({
|
|
title: this._player.trackTitle,
|
|
body: this._player.trackArtists.join(', '),
|
|
icon,
|
|
});
|
|
|
|
let isPlaying = this._player.status === 'Playing';
|
|
let iconName = isPlaying
|
|
? 'media-playback-pause-symbolic'
|
|
: 'media-playback-start-symbolic';
|
|
this._playPauseButton.child.icon_name = iconName;
|
|
|
|
this._updateNavButton(this._prevButton, this._player.canGoPrevious);
|
|
this._updateNavButton(this._nextButton, this._player.canGoNext);
|
|
}
|
|
});
|
|
|
|
const NotificationMessageGroup = GObject.registerClass({
|
|
Properties: {
|
|
'expanded': GObject.ParamSpec.boolean(
|
|
'expanded', null, null,
|
|
GObject.ParamFlags.READABLE,
|
|
false),
|
|
'has-urgent': GObject.ParamSpec.boolean(
|
|
'has-urgent', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
false),
|
|
'focus-child': GObject.ParamSpec.object(
|
|
'focus-child', null, null,
|
|
GObject.ParamFlags.READABLE,
|
|
Message),
|
|
},
|
|
Signals: {
|
|
'notification-added': {},
|
|
'expand-toggle-requested': {},
|
|
},
|
|
}, class NotificationMessageGroup extends St.Widget {
|
|
constructor(source) {
|
|
const action = new Clutter.ClickAction();
|
|
|
|
// A widget that covers stacked messages so that they don't receive events
|
|
const cover = new St.Widget({
|
|
name: 'cover',
|
|
reactive: true,
|
|
});
|
|
|
|
const header = new St.BoxLayout({
|
|
style_class: 'message-group-header',
|
|
x_expand: true,
|
|
visible: false,
|
|
});
|
|
|
|
super({
|
|
style_class: 'message-notification-group',
|
|
x_expand: true,
|
|
layout_manager: new MessageGroupExpanderLayout(cover, header),
|
|
actions: action,
|
|
reactive: true,
|
|
});
|
|
|
|
// The cover is always the second child to prevent interaction
|
|
// with stacked messages when collapsed.
|
|
this._cover = cover;
|
|
// The headerBox will always be the last child
|
|
this._headerBox = header;
|
|
|
|
this.source = source;
|
|
this._expanded = false;
|
|
this._notificationToMessage = new Map();
|
|
this._nUrgent = 0;
|
|
this._focusChild = null;
|
|
|
|
const titleLabel = new St.Label({
|
|
style_class: 'message-group-title',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
|
|
source.bind_property('title',
|
|
titleLabel,
|
|
'text',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
|
|
this._headerBox.add_child(titleLabel);
|
|
|
|
this._unexpandButton = new St.Button({
|
|
style_class: 'message-collapse-button',
|
|
icon_name: 'group-collapse-symbolic',
|
|
x_align: Clutter.ActorAlign.END,
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
x_expand: true,
|
|
y_expand: true,
|
|
});
|
|
|
|
this._unexpandButton.connect('clicked', () => this.emit('expand-toggle-requested'));
|
|
action.connect('clicked', () => this.emit('expand-toggle-requested'));
|
|
|
|
this._headerBox.add_child(this._unexpandButton);
|
|
this.add_child(this._headerBox);
|
|
this.add_child(this._cover);
|
|
|
|
source.connectObject(
|
|
'notification-added', (_, notification) => this._addNotification(notification),
|
|
'notification-removed', (_, notification) => this._removeNotification(notification),
|
|
this);
|
|
|
|
source.notifications.forEach(notification => {
|
|
this._addNotification(notification);
|
|
});
|
|
}
|
|
|
|
get expanded() {
|
|
// Consider this group to be expanded when it has only one message
|
|
return this._expanded || this._notificationToMessage.size === 1;
|
|
}
|
|
|
|
get hasUrgent() {
|
|
return this._nUrgent > 0;
|
|
}
|
|
|
|
_onKeyFocusIn(actor) {
|
|
if (this._focusChild === actor)
|
|
return;
|
|
this._focusChild = actor;
|
|
this.notify('focus-child');
|
|
}
|
|
|
|
get focusChild() {
|
|
return this._focusChild;
|
|
}
|
|
|
|
async expand() {
|
|
if (this._expanded)
|
|
return;
|
|
|
|
this._headerBox.show();
|
|
this._expanded = true;
|
|
this._updateStackedMessagesFade();
|
|
this.notify('expanded');
|
|
this._cover.hide();
|
|
|
|
await new Promise((resolve, _) => {
|
|
this.ease_property('@layout.expansion', 1, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration: GROUP_EXPENSION_TIME,
|
|
onComplete: () => resolve(),
|
|
});
|
|
});
|
|
}
|
|
|
|
async collapse() {
|
|
if (!this._expanded)
|
|
return;
|
|
|
|
this._notificationToMessage.forEach(message => message.unexpand(true));
|
|
|
|
// Give focus to the fully visible message
|
|
if (this.focusChild?.has_key_focus())
|
|
this.get_first_child().child.grab_key_focus();
|
|
|
|
this._expanded = false;
|
|
this.notify('expanded');
|
|
this._cover.show();
|
|
this._updateStackedMessagesFade();
|
|
|
|
await new Promise((resolve, _) => {
|
|
this.ease_property('@layout.expansion', 0, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration: GROUP_EXPENSION_TIME,
|
|
onComplete: () => resolve(),
|
|
});
|
|
});
|
|
|
|
this._headerBox.hide();
|
|
}
|
|
|
|
get expandedHeight() {
|
|
const [min] = this.layoutManager.getExpandedHeight(this, -1);
|
|
return min;
|
|
}
|
|
|
|
// Ensure that the cover is still below the top most message
|
|
_ensureCoverPosition() {
|
|
// If the group doesn't have any messages,
|
|
// don't move the cover before the headerBox
|
|
if (this.get_n_children() > 2)
|
|
this.set_child_at_index(this._cover, 1);
|
|
}
|
|
|
|
_updateStackedMessagesFade() {
|
|
const pseudoClasses = ['second-in-stack', 'lower-in-stack'];
|
|
|
|
// The group doesn't have any messages
|
|
if (this.get_n_children() < 3)
|
|
return;
|
|
|
|
const [top, cover, ...stack] = this.get_children();
|
|
const header = stack.pop();
|
|
|
|
console.assert(cover === this._cover,
|
|
'Cover has expected stack position');
|
|
console.assert(header === this._headerBox,
|
|
'Header has expected stack position');
|
|
|
|
// A message may have moved so we need to remove the classes from all messages
|
|
const messages = [top, ...stack];
|
|
messages.forEach(item => {
|
|
pseudoClasses.forEach(name => {
|
|
item.child.remove_style_pseudo_class(name);
|
|
});
|
|
});
|
|
|
|
if (!this.expanded && stack.length > 0) {
|
|
const stackTop = stack.shift();
|
|
stackTop.child.add_style_pseudo_class(pseudoClasses[0]);
|
|
|
|
// Use the same class for the third message and all messages
|
|
// after that, since they won't be visible anyways
|
|
stack.forEach(item => {
|
|
const message = item.child;
|
|
message.add_style_pseudo_class(pseudoClasses[1]);
|
|
});
|
|
}
|
|
}
|
|
|
|
canClose() {
|
|
return true;
|
|
}
|
|
|
|
_addNotification(notification) {
|
|
const message = new NotificationMessage(notification);
|
|
|
|
this._notificationToMessage.set(notification, message);
|
|
|
|
notification.connectObject(
|
|
'notify::urgency', () => {
|
|
const isUrgent = notification.urgency === MessageTray.Urgency.CRITICAL;
|
|
const oldHasUrgent = this.hasUrgent;
|
|
|
|
if (isUrgent)
|
|
this._nUrgent++;
|
|
else
|
|
this._nUrgent--;
|
|
|
|
const index = isUrgent ? 0 : this._nUrgent;
|
|
this._moveMessage(message, index);
|
|
if (oldHasUrgent !== this.hasUrgent)
|
|
this.notify('has-urgent');
|
|
}, message);
|
|
|
|
const isUrgent = notification.urgency === MessageTray.Urgency.CRITICAL;
|
|
const oldHasUrgent = this.hasUrgent;
|
|
|
|
if (isUrgent)
|
|
this._nUrgent++;
|
|
|
|
const wasExpanded = this.expanded;
|
|
const item = new St.Bin({
|
|
child: message,
|
|
canFocus: false,
|
|
layout_manager: new ScaleLayout(),
|
|
pivot_point: new Graphene.Point({x: .5, y: .5}),
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
});
|
|
|
|
message.connectObject(
|
|
'key-focus-in', this._onKeyFocusIn.bind(this),
|
|
'expanded', () => {
|
|
if (!this.expanded)
|
|
this.emit('expand-toggle-requested');
|
|
},
|
|
'close', () => {
|
|
// If the group is collapsed and one notification is closed, close the entire group
|
|
if (!this.expanded) {
|
|
GObject.signal_stop_emission_by_name(message, 'close');
|
|
this.close();
|
|
}
|
|
},
|
|
'clicked', () => {
|
|
if (!this.expanded) {
|
|
GObject.signal_stop_emission_by_name(message, 'clicked');
|
|
this.emit('expand-toggle-requested');
|
|
}
|
|
}, this);
|
|
|
|
let index = isUrgent ? 0 : this._nUrgent;
|
|
// If we add a child below the top child we need to adjust index to skip the cover child
|
|
if (index > 0)
|
|
index += 1;
|
|
|
|
this.insert_child_at_index(item, index);
|
|
this._ensureCoverPosition();
|
|
this._updateStackedMessagesFade();
|
|
|
|
item.layout_manager.scalingEnabled = this._expanded;
|
|
|
|
// The first message doesn't need to be animated since the entire group is animated
|
|
if (this._notificationToMessage.size > 1) {
|
|
item.ease({
|
|
scale_x: 1,
|
|
scale_y: 1,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
} else {
|
|
item.set_scale(1.0, 1.0);
|
|
}
|
|
|
|
if (wasExpanded !== this.expanded)
|
|
this.notify('expanded');
|
|
|
|
if (oldHasUrgent !== this.hasUrgent)
|
|
this.notify('has-urgent');
|
|
this.emit('notification-added');
|
|
}
|
|
|
|
_removeNotification(notification) {
|
|
const message = this._notificationToMessage.get(notification);
|
|
const item = message.get_parent();
|
|
|
|
if (notification.urgency === MessageTray.Urgency.CRITICAL)
|
|
this._nUrgent--;
|
|
|
|
message.disconnectObject(this);
|
|
|
|
item.layout_manager.scalingEnabled = this._expanded;
|
|
|
|
item.ease({
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
item.destroy();
|
|
this._notificationToMessage.delete(notification);
|
|
this._ensureCoverPosition();
|
|
this._updateStackedMessagesFade();
|
|
|
|
if (this._notificationToMessage.size === 1)
|
|
this.emit('expand-toggle-requested');
|
|
},
|
|
});
|
|
}
|
|
|
|
vfunc_paint(paintContext) {
|
|
// Invert the paint order, so that messages are collapsed with the
|
|
// newest message (the first child) on top of the stack
|
|
for (const child of this.get_children().reverse())
|
|
child.paint(paintContext);
|
|
}
|
|
|
|
vfunc_pick(pickContext) {
|
|
// Invert the pick order, so that messages are collapsed with the
|
|
// newest message (the first child) on top of the stack
|
|
for (const child of this.get_children().reverse())
|
|
child.pick(pickContext);
|
|
}
|
|
|
|
vfunc_map() {
|
|
// Acknowledge all notifications once they are mapped
|
|
this._notificationToMessage.forEach((_, notification) => {
|
|
notification.acknowledged = true;
|
|
});
|
|
super.vfunc_map();
|
|
}
|
|
|
|
vfunc_get_focus_chain() {
|
|
if (this.expanded)
|
|
return this.get_children();
|
|
else
|
|
return [this.get_first_child()];
|
|
}
|
|
|
|
_moveMessage(message, index) {
|
|
if (this.get_child_at_index(index) === message)
|
|
return;
|
|
|
|
const item = message.get_parent();
|
|
item.ease({
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
// If we add a child below the top child we need to adjust index to skip the cover child
|
|
if (index > 0)
|
|
index += 1;
|
|
|
|
this.set_child_at_index(item, index);
|
|
this._ensureCoverPosition();
|
|
this._updateStackedMessagesFade();
|
|
item.ease({
|
|
scale_x: 1,
|
|
scale_y: 1,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
close() {
|
|
// If the group is closed, close all messages in this group
|
|
this._notificationToMessage.forEach(message => {
|
|
message.disconnectObject(this);
|
|
message.close();
|
|
});
|
|
}
|
|
});
|
|
|
|
const MessageGroupExpanderLayout = GObject.registerClass({
|
|
Properties: {
|
|
'expansion': GObject.ParamSpec.double(
|
|
'expansion', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
0, 1, 0),
|
|
},
|
|
}, class MessageGroupExpanderLayout extends Clutter.LayoutManager {
|
|
constructor(cover, header) {
|
|
super();
|
|
|
|
this._cover = cover;
|
|
this._header = header;
|
|
this._expansion = 0;
|
|
}
|
|
|
|
get expansion() {
|
|
return this._expansion;
|
|
}
|
|
|
|
set expansion(v) {
|
|
v = Math.clamp(v, 0, 1);
|
|
|
|
if (v === this._expansion)
|
|
return;
|
|
this._expansion = v;
|
|
this.notify('expansion');
|
|
|
|
this.layout_changed();
|
|
}
|
|
|
|
getExpandedHeight(container, forWidth) {
|
|
let [minExpanded, natExpanded] = [0, 0];
|
|
|
|
container.get_children().forEach(child => {
|
|
// We don't need to measure the cover
|
|
if (child === this._cover)
|
|
return;
|
|
|
|
const [minChild, natChild] = child.get_preferred_height(forWidth);
|
|
minExpanded += minChild;
|
|
natExpanded += natChild;
|
|
});
|
|
|
|
// Add additional spacing after an expanded group
|
|
minExpanded += ADDITIONAL_BOTTOM_MARGIN_EXPANDED_GROUP;
|
|
natExpanded += ADDITIONAL_BOTTOM_MARGIN_EXPANDED_GROUP;
|
|
|
|
return [minExpanded, natExpanded];
|
|
}
|
|
|
|
vfunc_get_preferred_width(container, forHeight) {
|
|
return container.get_children().reduce((acc, child) => {
|
|
// We don't need to measure the cover
|
|
if (child === this._cover)
|
|
return [0, 0];
|
|
|
|
if (!child.visible)
|
|
return acc;
|
|
|
|
const [minChild, natChild] = child.get_preferred_width(forHeight);
|
|
|
|
return [
|
|
Math.max(minChild, acc[0]),
|
|
Math.max(natChild, acc[1]),
|
|
];
|
|
}, [0, 0]);
|
|
}
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
let offset = HEIGHT_OFFSET_STACKED;
|
|
let [min, nat] = [0, 0];
|
|
let visibleCount = MAX_VISIBLE_STACKED_MESSAGES;
|
|
|
|
for (const child of container.get_children()) {
|
|
// We don't need to measure the cover and the header is behind the stacked messages
|
|
if (child === this._cover || child === this._header)
|
|
continue;
|
|
|
|
if (!child.visible)
|
|
continue;
|
|
|
|
// The first message is always fully shown
|
|
if (min === 0 || nat === 0) {
|
|
[min, nat] = child.get_preferred_height(forWidth);
|
|
} else {
|
|
min += offset;
|
|
nat += offset;
|
|
offset /= HEIGHT_OFFSET_REDUCTION_STACKED;
|
|
}
|
|
|
|
visibleCount--;
|
|
|
|
if (visibleCount === 0)
|
|
break;
|
|
}
|
|
|
|
const [minExpanded, natExpanded] = this.getExpandedHeight(container, forWidth);
|
|
|
|
[min, nat] = [
|
|
min + this._expansion * (minExpanded - min),
|
|
nat + this._expansion * (natExpanded - nat),
|
|
];
|
|
|
|
return [min, nat];
|
|
}
|
|
|
|
vfunc_allocate(container, box) {
|
|
const childWidth = box.x2 - box.x1;
|
|
let fullY2 = box.y2;
|
|
|
|
if (this._cover.visible)
|
|
this._cover.allocate(box);
|
|
|
|
if (this._header.visible) {
|
|
const [min, nat_] = this._header.get_preferred_height(childWidth);
|
|
box.y2 = box.y1 + min;
|
|
this._header.allocate(box);
|
|
box.y1 += this._expansion * (box.y2 - box.y1);
|
|
}
|
|
|
|
// The group doesn't have any messages
|
|
if (container.get_n_children() < 3)
|
|
return;
|
|
|
|
let heightOffset = HEIGHT_OFFSET_STACKED;
|
|
const [top, cover, ...stack] = container.get_children();
|
|
const header = stack.pop();
|
|
|
|
console.assert(cover === this._cover,
|
|
'Cover has expected stack position');
|
|
console.assert(header === this._header,
|
|
'Header has expected stack position');
|
|
|
|
if (top) {
|
|
const [min, nat_] = top.get_preferred_height(childWidth);
|
|
// The first message is always fully shown
|
|
box.y2 = box.y1 + min;
|
|
top.allocate(box);
|
|
}
|
|
|
|
stack.forEach(child => {
|
|
const [min, nat_] = child.get_preferred_height(childWidth);
|
|
|
|
// Reduce width of children when collapsed
|
|
const widthOffset = (1.0 - this._expansion) * WIDTH_OFFSET_STACKED;
|
|
box.x1 += widthOffset;
|
|
box.x2 -= widthOffset;
|
|
|
|
// Stack children with a small reveal when collapsed
|
|
box.y2 += heightOffset + this._expansion * (min - heightOffset);
|
|
// Ensure messages are not placed outside the widget
|
|
if (box.y2 > fullY2)
|
|
box.y2 = fullY2;
|
|
else
|
|
heightOffset /= HEIGHT_OFFSET_REDUCTION_STACKED;
|
|
box.y1 = box.y2 - min;
|
|
|
|
child.allocate(box);
|
|
});
|
|
}
|
|
});
|
|
|
|
const MessageViewLayout = GObject.registerClass({
|
|
}, class MessageViewLayout extends Clutter.LayoutManager {
|
|
constructor(overlay) {
|
|
super();
|
|
this._overlay = overlay;
|
|
}
|
|
|
|
vfunc_get_preferred_width(container, forHeight) {
|
|
const [min, nat] = container.get_children().reduce((acc, child) => {
|
|
const [minChild, natChild] = child.get_preferred_width(forHeight);
|
|
|
|
return [
|
|
Math.max(minChild, acc[0]),
|
|
Math.max(natChild, acc[1]),
|
|
];
|
|
}, [0, 0]);
|
|
|
|
return [
|
|
min,
|
|
nat,
|
|
];
|
|
}
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
let [min, nat] = [0, 0];
|
|
|
|
container.get_children().forEach(child => {
|
|
const [minChild, natChild] = child.get_preferred_height(forWidth);
|
|
|
|
min += minChild;
|
|
nat += natChild;
|
|
});
|
|
|
|
return [
|
|
min,
|
|
nat,
|
|
];
|
|
}
|
|
|
|
vfunc_allocate(container, box) {
|
|
if (this._overlay?.visible)
|
|
this._overlay.allocate(box);
|
|
|
|
const width = box.x2 - box.x1;
|
|
// We need to use the order in messages since the children order is
|
|
// the render order and the expanded group needs to be the top most child
|
|
// and the overlay the child below it.
|
|
container.messages.forEach(message => {
|
|
const child = message.get_parent();
|
|
|
|
const [min, _] = child.get_preferred_height(width);
|
|
box.y2 = box.y1 + min;
|
|
child.allocate(box);
|
|
box.y1 = box.y2;
|
|
});
|
|
}
|
|
});
|
|
|
|
export const MessageView = GObject.registerClass({
|
|
Properties: {
|
|
'can-clear': GObject.ParamSpec.boolean(
|
|
'can-clear', null, null,
|
|
GObject.ParamFlags.READABLE,
|
|
false),
|
|
'empty': GObject.ParamSpec.boolean(
|
|
'empty', null, null,
|
|
GObject.ParamFlags.READABLE,
|
|
true),
|
|
'expanded-group': GObject.ParamSpec.object(
|
|
'expanded-group', null, null,
|
|
GObject.ParamFlags.READABLE,
|
|
Clutter.Actor),
|
|
},
|
|
Signals: {
|
|
'message-focused': {param_types: [Message]},
|
|
},
|
|
Implements: [St.Scrollable],
|
|
}, class MessageView extends St.Viewport {
|
|
messages = [];
|
|
|
|
_notificationSourceToGroup = new Map();
|
|
_nUrgent = 0;
|
|
|
|
_playerToMessage = new Map();
|
|
_mediaSource = new Mpris.MprisSource();
|
|
|
|
constructor() {
|
|
// Add an overlay that will be placed below the expanded group message
|
|
// to block interaction with other messages.
|
|
// Unfortunately there isn't a much better way to block
|
|
// interaction with widgets for this use-case.
|
|
const overlay = new Clutter.Actor({
|
|
reactive: true,
|
|
name: 'overlay',
|
|
visible: false,
|
|
});
|
|
|
|
super({
|
|
style_class: 'message-view',
|
|
layout_manager: new MessageViewLayout(overlay),
|
|
effect: new FadeEffect({name: 'highlight'}),
|
|
x_expand: true,
|
|
y_expand: true,
|
|
});
|
|
|
|
this._overlay = overlay;
|
|
this.add_child(this._overlay);
|
|
|
|
this._setupMpris();
|
|
this._setupNotifications();
|
|
}
|
|
|
|
get empty() {
|
|
return this.messages.length === 0;
|
|
}
|
|
|
|
get canClear() {
|
|
return this.messages.some(msg => msg.canClose());
|
|
}
|
|
|
|
_onKeyFocusIn(messageActor) {
|
|
this.emit('message-focused', messageActor);
|
|
}
|
|
|
|
vfunc_get_focus_chain() {
|
|
if (this.expandedGroup)
|
|
return [this.expandedGroup];
|
|
else
|
|
return this.messages.filter(m => m.visible).map(m => m.get_parent());
|
|
}
|
|
|
|
_addMessageAtIndex(message, index) {
|
|
if (this.messages.includes(message))
|
|
throw new Error('Message was already added previously');
|
|
|
|
const wasEmpty = this.empty;
|
|
const couldClear = this.canClear;
|
|
|
|
const item = new St.Bin({
|
|
child: message,
|
|
canFocus: false,
|
|
layout_manager: new ScaleLayout(),
|
|
pivot_point: new Graphene.Point({x: .5, y: .5}),
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
});
|
|
|
|
message.connect('key-focus-in', this._onKeyFocusIn.bind(this));
|
|
// Make sure that the messages array is updated even when
|
|
// _removeMessage() isn't called.
|
|
message.connect('destroy', () => {
|
|
const indexLocal = this.messages.indexOf(message);
|
|
if (indexLocal >= 0)
|
|
this.messages.splice(indexLocal, 1);
|
|
});
|
|
|
|
this.add_child(item);
|
|
this.messages.splice(index, 0, message);
|
|
|
|
if (wasEmpty !== this.empty)
|
|
this.notify('empty');
|
|
|
|
if (couldClear !== this.canClear)
|
|
this.notify('can-clear');
|
|
|
|
item.ease({
|
|
scale_x: 1,
|
|
scale_y: 1,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
}
|
|
|
|
_moveMessage(message, index) {
|
|
if (!this.messages.includes(message))
|
|
throw new Error('Impossible to move untracked message');
|
|
|
|
if (this.messages[index] === message)
|
|
return;
|
|
|
|
const item = message.get_parent();
|
|
|
|
item.ease({
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
this.messages.splice(this.messages.indexOf(message), 1);
|
|
this.messages.splice(index, 0, message);
|
|
this.queue_relayout();
|
|
item.ease({
|
|
scale_x: 1,
|
|
scale_y: 1,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
_removeMessage(message) {
|
|
const messages = this.messages;
|
|
|
|
if (!messages.includes(message))
|
|
throw new Error('Impossible to remove untracked message');
|
|
|
|
const item = message.get_parent();
|
|
|
|
item.ease({
|
|
scale_x: 0,
|
|
scale_y: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
const wasEmpty = this.empty;
|
|
const couldClear = this.canClear;
|
|
const index = this.messages.indexOf(message);
|
|
if (message.has_key_focus()) {
|
|
const nextMessage =
|
|
this.messages[index + 1] ||
|
|
this.messages[index - 1] ||
|
|
this;
|
|
nextMessage?.grab_key_focus();
|
|
}
|
|
|
|
// The message is removed from the messages array in the
|
|
// destroy signal handler
|
|
item.destroy();
|
|
|
|
if (wasEmpty !== this.empty)
|
|
this.notify('empty');
|
|
|
|
if (couldClear !== this.canClear)
|
|
this.notify('can-clear');
|
|
},
|
|
});
|
|
}
|
|
|
|
clear() {
|
|
const 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
|
|
const delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5);
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const message = messages[i];
|
|
message.get_parent().ease({
|
|
translation_x: this.width,
|
|
opacity: 0,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
delay: i * delay,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => message.close(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// When a group is expanded the user isn't allowed to scroll outside the expanded group,
|
|
// therefore the adjustment used by the MessageView needs to be different then the external
|
|
// adjustment used by the scrollbar and scrollview.
|
|
vfunc_set_adjustments(hadjustment, vadjustment) {
|
|
const internalAdjustment = new St.Adjustment({actor: vadjustment.actor});
|
|
|
|
this._scrollViewAdjustment = vadjustment;
|
|
this._adjValueOffset = 0;
|
|
|
|
this._adjBinding = new GObject.BindingGroup();
|
|
this._adjBinding.bind('lower',
|
|
this._scrollViewAdjustment,
|
|
'lower',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
this._adjBinding.bind('upper',
|
|
this._scrollViewAdjustment,
|
|
'upper', GObject.BindingFlags.SYNC_CREATE);
|
|
this._adjBinding.bind('step-increment',
|
|
this._scrollViewAdjustment, 'step-increment',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
this._adjBinding.bind('page-increment',
|
|
this._scrollViewAdjustment, 'page-increment',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
this._adjBinding.bind('page-size',
|
|
this._scrollViewAdjustment, 'page-size',
|
|
GObject.BindingFlags.SYNC_CREATE);
|
|
|
|
internalAdjustment.bind_property_full('value',
|
|
this._scrollViewAdjustment,
|
|
'value',
|
|
GObject.BindingFlags.BIDIRECTIONAL,
|
|
(bind, value) => {
|
|
return [true, value - this._adjValueOffset];
|
|
},
|
|
(bind, value) => {
|
|
return [true, value + this._adjValueOffset];
|
|
});
|
|
|
|
super.vfunc_set_adjustments(hadjustment, internalAdjustment);
|
|
}
|
|
|
|
vfunc_allocate(box) {
|
|
const group = this.expandedGroup;
|
|
|
|
this.vadjustment.freeze_notify();
|
|
|
|
const prevUpper = this.vadjustment.upper;
|
|
const prevAdjValueOffset = this._adjValueOffset;
|
|
|
|
super.vfunc_allocate(box);
|
|
|
|
if (group) {
|
|
// Decouple the internal adjustment from the external when there is an expanded group
|
|
this._adjBinding.set_source(null);
|
|
|
|
const pageHeight = this.vadjustment.pageSize;
|
|
const position = group.apply_relative_transform_to_point(this, new Graphene.Point3D());
|
|
const [groupHeight] = group.get_preferred_height(box.x2 - box.x1);
|
|
|
|
this._adjValueOffset = Math.max(0, position.y - EXPANDED_GROUP_OVERSHOT_HEIGHT);
|
|
|
|
// Limit the area the user can scroll to the expanded group with some extra space
|
|
this._scrollViewAdjustment.freeze_notify();
|
|
this._scrollViewAdjustment.upper =
|
|
Math.max(groupHeight + EXPANDED_GROUP_OVERSHOT_HEIGHT * 2, pageHeight);
|
|
this._scrollViewAdjustment.stepIncrement = pageHeight / 6;
|
|
this._scrollViewAdjustment.pageIncrement = pageHeight - pageHeight / 6;
|
|
this._scrollViewAdjustment.pageSize = pageHeight;
|
|
this._scrollViewAdjustment.thaw_notify();
|
|
|
|
// Adjust the value when new messages are added before the expanded group
|
|
if (this._adjValueOffset > prevAdjValueOffset) {
|
|
const offset = this.vadjustment.upper - prevUpper;
|
|
if (offset > 0)
|
|
this.vadjustment.value += offset;
|
|
}
|
|
} else if (this._adjBinding.source === null) {
|
|
this._adjValueOffset = 0;
|
|
this._adjBinding.set_source(this.vadjustment);
|
|
// We need to notify the 'value' property since it indirectly changed
|
|
this.vadjustment.notify('value');
|
|
}
|
|
|
|
this.vadjustment.thaw_notify();
|
|
}
|
|
|
|
vfunc_style_changed() {
|
|
// This widget doesn't use the normal st scroll view fade effect because
|
|
// highlighting groups needs more control over the fade.
|
|
const fadeOffset = this.get_theme_node().get_length('-st-vfade-offset');
|
|
this.get_effect('highlight').fadeMargin = fadeOffset;
|
|
|
|
super.vfunc_style_changed();
|
|
}
|
|
|
|
_setupMpris() {
|
|
this._mediaSource.connectObject(
|
|
'player-added', (_, player) => this._addPlayer(player),
|
|
'player-removed', (_, player) => this._removePlayer(player),
|
|
this);
|
|
|
|
this._mediaSource.players.forEach(player => {
|
|
this._addPlayer(player);
|
|
});
|
|
}
|
|
|
|
_addPlayer(player) {
|
|
const message = new MediaMessage(player);
|
|
this._playerToMessage.set(player, message);
|
|
this._addMessageAtIndex(message, 0);
|
|
}
|
|
|
|
_removePlayer(player) {
|
|
const message = this._playerToMessage.get(player);
|
|
this._removeMessage(message);
|
|
this._playerToMessage.delete(player);
|
|
}
|
|
|
|
_setupNotifications() {
|
|
Main.messageTray.connectObject(
|
|
'source-added', (_, source) => this._addNotificationSource(source),
|
|
'source-removed', (_, source) => this._removeNotificationSource(source),
|
|
this);
|
|
|
|
Main.messageTray.getSources().forEach(source => {
|
|
this._addNotificationSource(source);
|
|
});
|
|
}
|
|
|
|
_addNotificationSource(source) {
|
|
const group = new NotificationMessageGroup(source);
|
|
|
|
this._notificationSourceToGroup.set(source, group);
|
|
|
|
group.connectObject(
|
|
'notify::focus-child', () => this._onKeyFocusIn(group.focusChild),
|
|
'expand-toggle-requested', () => {
|
|
if (group.expanded)
|
|
this._setExpandedGroup(null).catch(logError);
|
|
else
|
|
this._setExpandedGroup(group).catch(logError);
|
|
},
|
|
'notify::has-urgent', () => {
|
|
if (group.hasUrgent)
|
|
this._nUrgent++;
|
|
else
|
|
this._nUrgent--;
|
|
|
|
const index = this._playerToMessage.size + (group.hasUrgent ? 0 : this._nUrgent);
|
|
this._moveMessage(group, index);
|
|
},
|
|
'notification-added', () => {
|
|
const index = this._playerToMessage.size + (group.hasUrgent ? 0 : this._nUrgent);
|
|
this._moveMessage(group, index);
|
|
}, this);
|
|
|
|
if (group.hasUrgent)
|
|
this._nUrgent++;
|
|
|
|
const index = this._playerToMessage.size + (group.hasUrgent ? 0 : this._nUrgent);
|
|
this._addMessageAtIndex(group, index);
|
|
}
|
|
|
|
_removeNotificationSource(source) {
|
|
const group = this._notificationSourceToGroup.get(source);
|
|
|
|
this._removeMessage(group);
|
|
|
|
if (group.hasUrgent)
|
|
this._nUrgent--;
|
|
|
|
this._notificationSourceToGroup.delete(source);
|
|
}
|
|
|
|
// Try to center the expanded group in the available space
|
|
_scrollToExpandedGroup() {
|
|
if (!this._expandedGroup)
|
|
return;
|
|
|
|
const group = this._expandedGroup;
|
|
const groupExpandedHeight = group.expandedHeight;
|
|
const position = group.apply_relative_transform_to_point(this, new Graphene.Point3D());
|
|
const groupCenter = position.y + (groupExpandedHeight / 2);
|
|
const pageHeight = this.vadjustment.pageSize;
|
|
const pageCenter = pageHeight / 2;
|
|
const value = Math.min(position.y, groupCenter - pageCenter);
|
|
|
|
this.vadjustment.ease(value, {
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration: GROUP_EXPENSION_TIME,
|
|
});
|
|
}
|
|
|
|
get expandedGroup() {
|
|
return this._expandedGroup;
|
|
}
|
|
|
|
async _setExpandedGroup(group) {
|
|
const prevGroup = this._expandedGroup;
|
|
|
|
if (prevGroup === group)
|
|
return;
|
|
|
|
this._expandedGroup = group;
|
|
this.notify('expanded-group');
|
|
|
|
// Collapse the previously expanded group
|
|
if (prevGroup) {
|
|
this._unhighlightGroup(prevGroup);
|
|
await prevGroup.collapse();
|
|
}
|
|
|
|
if (group) {
|
|
// Make sure that the overlay is the child below the expanded group
|
|
this.set_child_above_sibling(group.get_parent(), null);
|
|
this.set_child_below_sibling(this._overlay, group.get_parent());
|
|
this._overlay.show();
|
|
this._scrollToExpandedGroup();
|
|
|
|
this._highlightGroup(group);
|
|
await group.expand();
|
|
} else {
|
|
this._overlay.hide();
|
|
}
|
|
}
|
|
|
|
// Collapse expanded notification group
|
|
collapse() {
|
|
this._setExpandedGroup(null).catch(logError);
|
|
}
|
|
|
|
_highlightGroup(group) {
|
|
const effect = this.get_effect('highlight');
|
|
|
|
effect.opacity = 0.0;
|
|
effect.highlightActor = group;
|
|
this.ease_property('@effects.highlight.opacity', 1.0, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
});
|
|
}
|
|
|
|
_unhighlightGroup() {
|
|
this.ease_property('@effects.highlight.opacity', 0.0, {
|
|
progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
duration: MESSAGE_ANIMATION_TIME,
|
|
onStopped: () => {
|
|
const effect = this.get_effect('highlight');
|
|
effect.highlightActor = null;
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
const FadeEffect = GObject.registerClass({
|
|
Properties: {
|
|
'fade-margin': GObject.ParamSpec.float(
|
|
'fade-margin', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
0, Infinity, 0),
|
|
'opacity': GObject.ParamSpec.float(
|
|
'opacity', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
0, 1, 0),
|
|
'highlight-actor': GObject.ParamSpec.object(
|
|
'highlight-actor', null, null,
|
|
GObject.ParamFlags.READWRITE,
|
|
Clutter.Actor),
|
|
},
|
|
}, class FadeEffect extends Shell.GLSLEffect {
|
|
constructor(params) {
|
|
super(params);
|
|
|
|
this._heightLocation = this.get_uniform_location('height');
|
|
this._topFadePositionLocation = this.get_uniform_location('top_fade_position');
|
|
this._bottomFadePositionLocation = this.get_uniform_location('bottom_fade_position');
|
|
this._opacityLocation = this.get_uniform_location('opacity');
|
|
this._topEdgeFadeLocation = this.get_uniform_location('top_edge_fade');
|
|
this._bottomEdgeFadeLocation = this.get_uniform_location('bottom_edge_fade');
|
|
}
|
|
|
|
_updateEnabled() {
|
|
if (!this._vadjustment) {
|
|
this.enabled = false;
|
|
return;
|
|
}
|
|
|
|
const {upper, pageSize} = this._vadjustment;
|
|
|
|
this.enabled = (upper > pageSize && this._fadeMargin > 0.0) || this._highlightActor;
|
|
}
|
|
|
|
get highlightActor() {
|
|
return this._highlightActor;
|
|
}
|
|
|
|
set highlightActor(actor) {
|
|
if (this._highlightActor === actor)
|
|
return;
|
|
|
|
this._highlightActor = actor;
|
|
this.queue_repaint();
|
|
|
|
this._updateEnabled();
|
|
this.notify('highlight-actor');
|
|
}
|
|
|
|
get opacity() {
|
|
return this._opacity;
|
|
}
|
|
|
|
set opacity(opacity) {
|
|
if (this._opacity === opacity)
|
|
return;
|
|
|
|
this._opacity = opacity;
|
|
this.set_uniform_float(this._opacityLocation, 1, [opacity]);
|
|
this.queue_repaint();
|
|
|
|
this.notify('opacity');
|
|
}
|
|
|
|
get fadeMargin() {
|
|
return this._fadeMargin;
|
|
}
|
|
|
|
set fadeMargin(fadeMargin) {
|
|
if (this._fadeMargin === fadeMargin)
|
|
return;
|
|
|
|
this._fadeMargin = fadeMargin;
|
|
this.queue_repaint();
|
|
|
|
this.notify('fade-margin');
|
|
}
|
|
|
|
_vadjustmentChanged() {
|
|
const newAdj = this.actor.vadjustment;
|
|
if (this._vadjustment === newAdj)
|
|
return;
|
|
|
|
this._vadjustment?.disconnectObject(this);
|
|
this._vadjustment = newAdj;
|
|
this._vadjustment?.connectObject('changed', this._updateEnabled.bind(this));
|
|
this._updateEnabled();
|
|
}
|
|
|
|
vfunc_set_actor(actor) {
|
|
if (this.actor === actor)
|
|
return;
|
|
|
|
this.actor?.disconnectObject(this);
|
|
|
|
actor?.connectObject('notify::vadjustment', this._vadjustmentChanged.bind(this));
|
|
super.vfunc_set_actor(actor);
|
|
this._vadjustmentChanged();
|
|
}
|
|
|
|
vfunc_paint_target(node, paintContext) {
|
|
const {pageSize, upper, value} = this._vadjustment ?? [this.actor.height, this.actor.height, 0];
|
|
|
|
if (this._highlightActor) {
|
|
const position = this._highlightActor.apply_relative_transform_to_point(this.actor, new Graphene.Point3D());
|
|
|
|
this.set_uniform_float(this._topFadePositionLocation, 1, [position.y - value]);
|
|
this.set_uniform_float(this._bottomFadePositionLocation, 1, [(position.y + this._highlightActor.height - 1) - value]);
|
|
} else {
|
|
this.set_uniform_float(this._topFadePositionLocation, 1, [0.0]);
|
|
this.set_uniform_float(this._bottomFadePositionLocation, 1, [0.0]);
|
|
}
|
|
|
|
this.set_uniform_float(this._heightLocation, 1, [pageSize]);
|
|
|
|
this.set_uniform_float(this._topEdgeFadeLocation, 1, [Math.min(value, this._fadeMargin)]);
|
|
this.set_uniform_float(this._bottomEdgeFadeLocation, 1, [
|
|
pageSize - Math.min(upper - pageSize - value, this._fadeMargin),
|
|
]);
|
|
|
|
super.vfunc_paint_target(node, paintContext);
|
|
}
|
|
|
|
vfunc_build_pipeline() {
|
|
const dec = `uniform sampler2D tex; \n
|
|
uniform float height; \n
|
|
uniform float opacity; \n
|
|
uniform float top_edge_fade; \n
|
|
uniform float bottom_edge_fade; \n
|
|
uniform float top_fade_position; \n
|
|
uniform float bottom_fade_position; \n`;
|
|
|
|
const src = `cogl_color_out = cogl_color_in * texture2D (tex, vec2 (cogl_tex_coord_in[0].xy)); \n
|
|
float fade_base = 300; \n
|
|
float fade_height = 400; \n
|
|
float y = height * cogl_tex_coord_in[0].y; \n
|
|
float ratio = 1.0; \n
|
|
|
|
if (y < top_fade_position && top_fade_position > 0.0) { \n
|
|
float edge1 = top_fade_position - fade_height; \n
|
|
float edge2 = top_fade_position; \n
|
|
ratio = (smoothstep (edge1, edge2 + fade_base, y) - 1.0) * opacity + 1.0; \n
|
|
} else if (y > bottom_fade_position && bottom_fade_position < height) { \n
|
|
float edge1 = bottom_fade_position + fade_height; \n
|
|
float edge2 = bottom_fade_position; \n
|
|
ratio = (smoothstep (edge1, edge2 - fade_base, y) - 1.0) * opacity + 1.0; \n
|
|
} \n
|
|
|
|
if (top_edge_fade > 0.0) \n
|
|
ratio *= smoothstep (0.0, top_edge_fade, y); \n
|
|
if (bottom_edge_fade > 0.0 && bottom_edge_fade < height) \n
|
|
ratio *= smoothstep (height, bottom_edge_fade, y); \n
|
|
|
|
cogl_color_out *= ratio; \n`;
|
|
|
|
this.add_glsl_snippet(Cogl.SnippetHook.FRAGMENT, dec, src, true);
|
|
}
|
|
});
|