messageList: Make notification groups expandable/collapsible
This implements the new expand/collapse feature for notification groups
according to the new notification designs [1].
[1] 9e2bed6f37/notifications-calendar/notifications-grouping.png
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3012>
This commit is contained in:
parent
dae4179b3d
commit
47de83a922
@ -6,6 +6,7 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/carousel-arrow-previous-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/carousel-arrow-previous-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/cog-wheel-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/cog-wheel-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/dark-mode-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/dark-mode-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/group-collapse-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/notification-expand-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/notification-expand-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/ornament-check-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/ornament-check-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/ornament-dot-checked-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/ornament-dot-checked-symbolic.svg</file>
|
||||||
|
37
data/icons/scalable/actions/group-collapse-symbolic.svg
Normal file
37
data/icons/scalable/actions/group-collapse-symbolic.svg
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
height="16px"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16px"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
id="g456"
|
||||||
|
transform="matrix(1,0,0,-1,392,1185.9336)"
|
||||||
|
style="fill:#2e3436;fill-opacity:1">
|
||||||
|
<g
|
||||||
|
id="g143"
|
||||||
|
transform="translate(0,-0.50051875)"
|
||||||
|
style="fill:#2e3436;fill-opacity:1">
|
||||||
|
<g
|
||||||
|
id="g144"
|
||||||
|
transform="translate(0,0.5)"
|
||||||
|
style="fill:#2e3436;fill-opacity:1">
|
||||||
|
<path
|
||||||
|
d="m -388,1172.4341 c 0,-0.2656 0.1055,-0.5195 0.293,-0.707 0.3906,-0.3906 1.0234,-0.3906 1.414,0 l 2.293,2.293 2.293,-2.293 c 0.3906,-0.3906 1.0234,-0.3906 1.414,0 0.1875,0.1875 0.293,0.4414 0.293,0.707 0,0.2656 -0.1055,0.5195 -0.293,0.707 l -3,3 c -0.3906,0.3907 -1.0234,0.3907 -1.414,0 l -3,-3 c -0.1875,-0.1875 -0.293,-0.4414 -0.293,-0.707 z"
|
||||||
|
fill="#2e3436"
|
||||||
|
id="path177459-1"
|
||||||
|
style="fill:#2e3436;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
d="m -388,1183.4342 c 0,0.2656 0.1055,0.5195 0.293,0.707 0.3906,0.3906 1.0234,0.3906 1.414,0 l 2.293,-2.293 2.293,2.293 c 0.3906,0.3906 1.0234,0.3906 1.414,0 0.1875,-0.1875 0.293,-0.4414 0.293,-0.707 0,-0.2656 -0.1055,-0.5195 -0.293,-0.707 l -3,-3 c -0.3906,-0.3907 -1.0234,-0.3907 -1.414,0 l -3,3 c -0.1875,0.1875 -0.293,0.4414 -0.293,0.707 z"
|
||||||
|
fill="#2e3436"
|
||||||
|
id="path142"
|
||||||
|
style="fill:#2e3436;fill-opacity:1" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
@ -69,6 +69,17 @@
|
|||||||
margin: 0 $base_margin;
|
margin: 0 $base_margin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// close button
|
||||||
|
.message-collapse-button {
|
||||||
|
@extend .icon-button;
|
||||||
|
color: $fg_color;
|
||||||
|
background-color: transparentize($fg_color, 0.8);
|
||||||
|
padding: 4px !important;
|
||||||
|
border: 4px transparent solid;
|
||||||
|
&:hover {background-color: transparentize($fg_color, 0.7);}
|
||||||
|
&:active {background-color: transparentize($fg_color, 0.8);}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// message bubbles
|
// message bubbles
|
||||||
@ -78,6 +89,19 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: $modal_radius;
|
border-radius: $modal_radius;
|
||||||
|
|
||||||
|
background-color: if($variant == 'light', $card_bg_color, lighten($card_bg_color, 5%));
|
||||||
|
|
||||||
|
&:second-in-stack {
|
||||||
|
background-color: if($variant == 'light', darken($card_bg_color, 4%), darken($card_bg_color, 1%));
|
||||||
|
box-shadow: 0 1px 1px 0 $card_shadow_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:lower-in-stack {
|
||||||
|
background-color: if($variant == 'light', darken($card_bg_color, 7%), darken($card_bg_color, 4%));
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: if($variant == 'light', darken($card_bg_color, 10%), transparent); // a not ideal workaround for light theme
|
||||||
|
}
|
||||||
|
|
||||||
// message header
|
// message header
|
||||||
.message-header {
|
.message-header {
|
||||||
spacing: $base_padding;
|
spacing: $base_padding;
|
||||||
|
@ -878,4 +878,28 @@ class CalendarMessageList extends St.Widget {
|
|||||||
this._clearButton, 'reactive',
|
this._clearButton, 'reactive',
|
||||||
GObject.BindingFlags.SYNC_CREATE);
|
GObject.BindingFlags.SYNC_CREATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maybeCollapseMessageGroupForEvent(event) {
|
||||||
|
if (!this._messageView.expandedGroup)
|
||||||
|
return Clutter.EVENT_PROPAGATE;
|
||||||
|
|
||||||
|
if (event.type() === Clutter.EventType.KEY_PRESS &&
|
||||||
|
event.get_key_symbol() === Clutter.KEY_Escape) {
|
||||||
|
this._messageView.collapse();
|
||||||
|
return Clutter.EVENT_STOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetActor = global.stage.get_event_actor(event);
|
||||||
|
const onScrollbar =
|
||||||
|
this._scrollView.contains(targetActor) &&
|
||||||
|
!this._messageView.contains(targetActor);
|
||||||
|
|
||||||
|
if ((event.type() === Clutter.EventType.BUTTON_PRESS ||
|
||||||
|
event.type() === Clutter.EventType.TOUCH_BEGIN) &&
|
||||||
|
!this._messageView.expandedGroup.contains(targetActor) &&
|
||||||
|
!onScrollbar)
|
||||||
|
this._messageView.collapse();
|
||||||
|
|
||||||
|
return Clutter.EVENT_PROPAGATE;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
@ -920,6 +920,18 @@ class DateMenuButton extends PanelMenu.Button {
|
|||||||
this._messageList = new Calendar.CalendarMessageList();
|
this._messageList = new Calendar.CalendarMessageList();
|
||||||
hbox.add_child(this._messageList);
|
hbox.add_child(this._messageList);
|
||||||
|
|
||||||
|
// Collapse notification groups when the user clicks outside of the expanded group
|
||||||
|
this.menu.actor.connectObject(
|
||||||
|
'captured-event::button', (_, event) => {
|
||||||
|
return this._messageList.maybeCollapseMessageGroupForEvent(event);
|
||||||
|
},
|
||||||
|
'captured-event::touch', (_, event) => {
|
||||||
|
return this._messageList.maybeCollapseMessageGroupForEvent(event);
|
||||||
|
},
|
||||||
|
'captured-event::key', (_, event) => {
|
||||||
|
return this._messageList.maybeCollapseMessageGroupForEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
// Fill up the second column
|
// Fill up the second column
|
||||||
const boxLayout = new CalendarColumnLayout([this._calendar, this._date]);
|
const boxLayout = new CalendarColumnLayout([this._calendar, this._date]);
|
||||||
const vbox = new St.Widget({
|
const vbox = new St.Widget({
|
||||||
|
@ -20,6 +20,13 @@ const MESSAGE_ANIMATION_TIME = 100;
|
|||||||
|
|
||||||
const DEFAULT_EXPAND_LINES = 6;
|
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(
|
export const URLHighlighter = GObject.registerClass(
|
||||||
class URLHighlighter extends St.Label {
|
class URLHighlighter extends St.Label {
|
||||||
_init(text = '', lineWrap, allowMarkup) {
|
_init(text = '', lineWrap, allowMarkup) {
|
||||||
@ -824,6 +831,10 @@ class MediaMessage extends Message {
|
|||||||
|
|
||||||
const NotificationMessageGroup = GObject.registerClass({
|
const NotificationMessageGroup = GObject.registerClass({
|
||||||
Properties: {
|
Properties: {
|
||||||
|
'expanded': GObject.ParamSpec.boolean(
|
||||||
|
'expanded', null, null,
|
||||||
|
GObject.ParamFlags.READABLE,
|
||||||
|
false),
|
||||||
'has-urgent': GObject.ParamSpec.boolean(
|
'has-urgent': GObject.ParamSpec.boolean(
|
||||||
'has-urgent', null, null,
|
'has-urgent', null, null,
|
||||||
GObject.ParamFlags.READWRITE,
|
GObject.ParamFlags.READWRITE,
|
||||||
@ -835,26 +846,40 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
},
|
},
|
||||||
Signals: {
|
Signals: {
|
||||||
'notification-added': {},
|
'notification-added': {},
|
||||||
|
'expand-toggle-requested': {},
|
||||||
},
|
},
|
||||||
}, class NotificationMessageGroup extends St.Widget {
|
}, class NotificationMessageGroup extends St.Widget {
|
||||||
constructor(source) {
|
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({
|
const header = new St.BoxLayout({
|
||||||
style_class: 'message-group-header',
|
style_class: 'message-group-header',
|
||||||
x_expand: true,
|
x_expand: true,
|
||||||
|
visible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
super({
|
super({
|
||||||
style_class: 'message-notification-group',
|
style_class: 'message-notification-group',
|
||||||
x_expand: true,
|
x_expand: true,
|
||||||
layout_manager: new Clutter.BoxLayout({
|
layout_manager: new MessageGroupExpanderLayout(cover, header),
|
||||||
orientation: Clutter.Orientation.VERTICAL,
|
actions: action,
|
||||||
}),
|
|
||||||
reactive: true,
|
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._headerBox = header;
|
||||||
|
|
||||||
this.source = source;
|
this.source = source;
|
||||||
|
this._expanded = false;
|
||||||
this._notificationToMessage = new Map();
|
this._notificationToMessage = new Map();
|
||||||
this._nUrgent = 0;
|
this._nUrgent = 0;
|
||||||
this._focusChild = null;
|
this._focusChild = null;
|
||||||
@ -871,7 +896,21 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
|
|
||||||
this._headerBox.add_child(titleLabel);
|
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._headerBox);
|
||||||
|
this.add_child(this._cover);
|
||||||
|
|
||||||
source.connectObject(
|
source.connectObject(
|
||||||
'notification-added', (_, notification) => this._addNotification(notification),
|
'notification-added', (_, notification) => this._addNotification(notification),
|
||||||
@ -883,6 +922,11 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get expanded() {
|
||||||
|
// Consider this group to be expanded when it has only one message
|
||||||
|
return this._expanded || this._notificationToMessage.size === 1;
|
||||||
|
}
|
||||||
|
|
||||||
get hasUrgent() {
|
get hasUrgent() {
|
||||||
return this._nUrgent > 0;
|
return this._nUrgent > 0;
|
||||||
}
|
}
|
||||||
@ -898,6 +942,95 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
return this._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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
canClose() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -929,6 +1062,7 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
if (isUrgent)
|
if (isUrgent)
|
||||||
this._nUrgent++;
|
this._nUrgent++;
|
||||||
|
|
||||||
|
const wasExpanded = this.expanded;
|
||||||
const item = new St.Bin({
|
const item = new St.Bin({
|
||||||
child: message,
|
child: message,
|
||||||
canFocus: false,
|
canFocus: false,
|
||||||
@ -938,10 +1072,36 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
scale_y: 0,
|
scale_y: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
message.connectObject('key-focus-in', this._onKeyFocusIn.bind(this), this);
|
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;
|
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.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
|
// The first message doesn't need to be animated since the entire group is animated
|
||||||
if (this._notificationToMessage.size > 1) {
|
if (this._notificationToMessage.size > 1) {
|
||||||
@ -955,6 +1115,9 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
item.set_scale(1.0, 1.0);
|
item.set_scale(1.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wasExpanded !== this.expanded)
|
||||||
|
this.notify('expanded');
|
||||||
|
|
||||||
if (oldHasUrgent !== this.hasUrgent)
|
if (oldHasUrgent !== this.hasUrgent)
|
||||||
this.notify('has-urgent');
|
this.notify('has-urgent');
|
||||||
this.emit('notification-added');
|
this.emit('notification-added');
|
||||||
@ -969,6 +1132,8 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
|
|
||||||
message.disconnectObject(this);
|
message.disconnectObject(this);
|
||||||
|
|
||||||
|
item.layout_manager.scalingEnabled = this._expanded;
|
||||||
|
|
||||||
item.ease({
|
item.ease({
|
||||||
scale_x: 0,
|
scale_x: 0,
|
||||||
scale_y: 0,
|
scale_y: 0,
|
||||||
@ -977,10 +1142,29 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
item.destroy();
|
item.destroy();
|
||||||
this._notificationToMessage.delete(notification);
|
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() {
|
vfunc_map() {
|
||||||
// Acknowledge all notifications once they are mapped
|
// Acknowledge all notifications once they are mapped
|
||||||
this._notificationToMessage.forEach((_, notification) => {
|
this._notificationToMessage.forEach((_, notification) => {
|
||||||
@ -989,6 +1173,13 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
super.vfunc_map();
|
super.vfunc_map();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vfunc_get_focus_chain() {
|
||||||
|
if (this.expanded)
|
||||||
|
return this.get_children();
|
||||||
|
else
|
||||||
|
return [this.get_first_child()];
|
||||||
|
}
|
||||||
|
|
||||||
_moveMessage(message, index) {
|
_moveMessage(message, index) {
|
||||||
if (this.get_child_at_index(index) === message)
|
if (this.get_child_at_index(index) === message)
|
||||||
return;
|
return;
|
||||||
@ -1000,7 +1191,13 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
duration: MESSAGE_ANIMATION_TIME,
|
duration: MESSAGE_ANIMATION_TIME,
|
||||||
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
||||||
onComplete: () => {
|
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.set_child_at_index(item, index);
|
||||||
|
this._ensureCoverPosition();
|
||||||
|
this._updateStackedMessagesFade();
|
||||||
item.ease({
|
item.ease({
|
||||||
scale_x: 1,
|
scale_x: 1,
|
||||||
scale_y: 1,
|
scale_y: 1,
|
||||||
@ -1010,6 +1207,235 @@ const NotificationMessageGroup = GObject.registerClass({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
export const MessageView = GObject.registerClass({
|
||||||
@ -1022,11 +1448,15 @@ export const MessageView = GObject.registerClass({
|
|||||||
'empty', null, null,
|
'empty', null, null,
|
||||||
GObject.ParamFlags.READABLE,
|
GObject.ParamFlags.READABLE,
|
||||||
true),
|
true),
|
||||||
|
'expanded-group': GObject.ParamSpec.object(
|
||||||
|
'expanded-group', null, null,
|
||||||
|
GObject.ParamFlags.READABLE,
|
||||||
|
Clutter.Actor),
|
||||||
},
|
},
|
||||||
Signals: {
|
Signals: {
|
||||||
'message-focused': {param_types: [Message]},
|
'message-focused': {param_types: [Message]},
|
||||||
},
|
},
|
||||||
}, class MessageView extends St.BoxLayout {
|
}, class MessageView extends St.Viewport {
|
||||||
messages = [];
|
messages = [];
|
||||||
|
|
||||||
_notificationSourceToGroup = new Map();
|
_notificationSourceToGroup = new Map();
|
||||||
@ -1036,13 +1466,26 @@ export const MessageView = GObject.registerClass({
|
|||||||
_mediaSource = new Mpris.MprisSource();
|
_mediaSource = new Mpris.MprisSource();
|
||||||
|
|
||||||
constructor() {
|
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({
|
super({
|
||||||
style_class: 'message-view',
|
style_class: 'message-view',
|
||||||
orientation: Clutter.Orientation.VERTICAL,
|
layout_manager: new MessageViewLayout(overlay),
|
||||||
x_expand: true,
|
x_expand: true,
|
||||||
y_expand: true,
|
y_expand: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._overlay = overlay;
|
||||||
|
this.add_child(this._overlay);
|
||||||
|
|
||||||
this._setupMpris();
|
this._setupMpris();
|
||||||
this._setupNotifications();
|
this._setupNotifications();
|
||||||
}
|
}
|
||||||
@ -1059,6 +1502,13 @@ export const MessageView = GObject.registerClass({
|
|||||||
this.emit('message-focused', 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) {
|
_addMessageAtIndex(message, index) {
|
||||||
if (this.messages.includes(message))
|
if (this.messages.includes(message))
|
||||||
throw new Error('Message was already added previously');
|
throw new Error('Message was already added previously');
|
||||||
@ -1084,7 +1534,7 @@ export const MessageView = GObject.registerClass({
|
|||||||
this.messages.splice(indexLocal, 1);
|
this.messages.splice(indexLocal, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.insert_child_at_index(item, index);
|
this.add_child(item);
|
||||||
this.messages.splice(index, 0, message);
|
this.messages.splice(index, 0, message);
|
||||||
|
|
||||||
if (wasEmpty !== this.empty)
|
if (wasEmpty !== this.empty)
|
||||||
@ -1116,7 +1566,6 @@ export const MessageView = GObject.registerClass({
|
|||||||
duration: MESSAGE_ANIMATION_TIME,
|
duration: MESSAGE_ANIMATION_TIME,
|
||||||
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
this.set_child_at_index(item, index);
|
|
||||||
this.messages.splice(this.messages.indexOf(message), 1);
|
this.messages.splice(this.messages.indexOf(message), 1);
|
||||||
this.messages.splice(index, 0, message);
|
this.messages.splice(index, 0, message);
|
||||||
this.queue_relayout();
|
this.queue_relayout();
|
||||||
@ -1235,6 +1684,12 @@ export const MessageView = GObject.registerClass({
|
|||||||
|
|
||||||
group.connectObject(
|
group.connectObject(
|
||||||
'notify::focus-child', () => this._onKeyFocusIn(group.focusChild),
|
'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', () => {
|
'notify::has-urgent', () => {
|
||||||
if (group.hasUrgent)
|
if (group.hasUrgent)
|
||||||
this._nUrgent++;
|
this._nUrgent++;
|
||||||
@ -1266,4 +1721,36 @@ export const MessageView = GObject.registerClass({
|
|||||||
|
|
||||||
this._notificationSourceToGroup.delete(source);
|
this._notificationSourceToGroup.delete(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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();
|
||||||
|
await group.expand();
|
||||||
|
} else {
|
||||||
|
this._overlay.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse expanded notification group
|
||||||
|
collapse() {
|
||||||
|
this._setExpandedGroup(null).catch(logError);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user