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.POINTER);
|
|
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 [Math.floor(min), Math.floor(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);
|
|
}
|
|
});
|