From 464f552dd2ed08737108cb4f289864b2fec1054e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCllner?= Date: Fri, 5 Dec 2014 16:24:35 +0100 Subject: [PATCH] calendar: Add MessageList and Section/Message base types The message list is a scrollable list that will hold sections of different types of time-related messages like notifications, calendar events or birthday reminders. When no section displays any content for the selected date, a placeholder is shown instead. https://bugzilla.gnome.org/show_bug.cgi?id=744817 --- data/gnome-shell-theme.gresource.xml | 1 + data/theme/gnome-shell-sass | 2 +- data/theme/gnome-shell.css | 72 ++++- data/theme/no-events.svg | 119 ++++++++ js/ui/calendar.js | 421 ++++++++++++++++++++++++++- 5 files changed, 609 insertions(+), 6 deletions(-) create mode 100644 data/theme/no-events.svg diff --git a/data/gnome-shell-theme.gresource.xml b/data/gnome-shell-theme.gresource.xml index 067d3243c..9e270d056 100644 --- a/data/gnome-shell-theme.gresource.xml +++ b/data/gnome-shell-theme.gresource.xml @@ -19,6 +19,7 @@ gnome-shell-high-contrast.css logged-in-indicator.svg more-results.svg + no-events.svg noise-texture.png page-indicator-active.svg page-indicator-inactive.svg diff --git a/data/theme/gnome-shell-sass b/data/theme/gnome-shell-sass index a1ea9bf18..e260e8a5b 160000 --- a/data/theme/gnome-shell-sass +++ b/data/theme/gnome-shell-sass @@ -1 +1 @@ -Subproject commit a1ea9bf18ae85deaffdedbf2f52c7c1a820086a9 +Subproject commit e260e8a5bcc5907bbf3b4b3cc0df5bf11ff49b59 diff --git a/data/theme/gnome-shell.css b/data/theme/gnome-shell.css index b5926c48b..849aac212 100644 --- a/data/theme/gnome-shell.css +++ b/data/theme/gnome-shell.css @@ -592,26 +592,41 @@ StScrollBar { margin-bottom: 1em; } .calendar, -.datemenu-today-button { +.datemenu-today-button, +.message-list-sections { margin: 0 1.5em; } .datemenu-calendar-column { spacing: 0.5em; padding-bottom: 3em; } -.datemenu-today-button { +.datemenu-today-button, +.message-list-section-title { border-radius: 4px; padding: .4em; } -.datemenu-today-button:hover, .datemenu-today-button:focus { +.message-list-section-list:ltr { + padding-left: .4em; } + +.message-list-section-list:rtl { + padding-right: .4em; } + +.datemenu-today-button:hover, .datemenu-today-button:focus, +.message-list-section-title:hover, +.message-list-section-title:focus { background-color: #454c4c; } -.datemenu-today-button:active { +.datemenu-today-button:active, +.message-list-section-title:active { color: white; background-color: #215d9c; } .datemenu-today-button .date-label { font-size: 1.5em; } +.message-list-section-title { + color: #8e8e80; + font-weight: bold; } + .calendar-month-label { color: #e2e2df; font-weight: bold; @@ -681,6 +696,55 @@ StScrollBar { color: rgba(238, 238, 236, 0.15); opacity: 0.5; } +/* Message list */ +.message-list { + width: 340px; } + +.message-list-sections { + spacing: 1.5em; } + +.message-list-section, +.message-list-section-list { + spacing: 0.7em; } + +.message-list-section-title-box { + spacing: 0.4em; } + +.message-list-section-close > StIcon { + icon-size: 16px; + border-radius: 8px; + color: #393f3f; + background-color: #59594f; } + +/* FIXME: how do you do this in sass? */ +.message-list-section-close:hover > StIcon, +.message-list-section-close:focus > StIcon { + background-color: #8e8e80; } + +.message { + background-color: #454c4c; } + .message:hover, .message:focus { + background-color: #5d6767; } + +.message-icon-bin { + padding: 5px; } + +.message-icon-bin > StIcon { + icon-size: 48px; } + +.message-secondary-bin { + color: #8e8e80; } + +.message-secondary-bin > StIcon { + icon-size: 16px; } + +.message-title { + font-weight: bold; } + +.message-content { + padding: 5px; + spacing: 5px; } + .events-table { width: 15em; spacing-columns: 1em; diff --git a/data/theme/no-events.svg b/data/theme/no-events.svg new file mode 100644 index 000000000..8ab08a962 --- /dev/null +++ b/data/theme/no-events.svg @@ -0,0 +1,119 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/ui/calendar.js b/js/ui/calendar.js index a09350a13..d4046cfcb 100644 --- a/js/ui/calendar.js +++ b/js/ui/calendar.js @@ -1,8 +1,10 @@ // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +const Atk = imports.gi.Atk; const Clutter = imports.gi.Clutter; const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; +const Gtk = imports.gi.Gtk; const Lang = imports.lang; const St = imports.gi.St; const Signals = imports.signals; @@ -12,12 +14,16 @@ const Mainloop = imports.mainloop; const Meta = imports.gi.Meta; const Shell = imports.gi.Shell; +const Main = imports.ui.main; +const Tweener = imports.ui.tweener; const Util = imports.misc.util; const MSECS_IN_DAY = 24 * 60 * 60 * 1000; const SHOW_WEEKDATE_KEY = 'show-weekdate'; const ELLIPSIS_CHAR = '\u2026'; +const MESSAGE_ANIMATION_TIME = 0.1; + // alias to prevent xgettext from picking up strings translated in GTK+ const gtk30_ = Gettext_gtk30.gettext; const NC_ = function(context, str) { return str; }; @@ -852,9 +858,324 @@ const Calendar = new Lang.Class({ })); } }); - Signals.addSignalMethods(Calendar.prototype); +const ScaleLayout = new Lang.Class({ + Name: 'ScaleLayout', + Extends: Clutter.BinLayout, + + _connectContainer: function(container) { + if (this._container == container) + return; + + if (this._container) + for (let id of this._signals) + this._container.disconnect(id); + + this._container = container; + this._signals = []; + + if (this._container) + for (let signal of ['notify::scale-x', 'notify::scale-y']) { + let id = this._container.connect(signal, Lang.bind(this, + function() { + this.layout_changed(); + })); + this._signals.push(id); + } + }, + + vfunc_get_preferred_width: function(container, forHeight) { + this._connectContainer(container); + + let [min, nat] = this.parent(container, forHeight); + return [Math.floor(min * container.scale_x), + Math.floor(nat * container.scale_x)]; + }, + + vfunc_get_preferred_height: function(container, forWidth) { + this._connectContainer(container); + + let [min, nat] = this.parent(container, forWidth); + return [Math.floor(min * container.scale_y), + Math.floor(nat * container.scale_y)]; + } +}); + +const Message = new Lang.Class({ + Name: 'Message', + + _init: function(title, body) { + this.actor = new St.Button({ style_class: 'message', + accessible_role: Atk.Role.NOTIFICATION, + can_focus: true, + x_expand: true, x_fill: true }); + + let hbox = new St.BoxLayout(); + this.actor.set_child(hbox); + + this._iconBin = new St.Bin({ style_class: 'message-icon-bin', + y_expand: true, + visible: false }); + this._iconBin.set_y_align(Clutter.ActorAlign.START); + hbox.add_actor(this._iconBin); + + let contentBox = new St.BoxLayout({ style_class: 'message-content', + vertical: true, x_expand: true }); + hbox.add_actor(contentBox); + + let titleBox = new St.BoxLayout(); + contentBox.add_actor(titleBox); + + this.titleLabel = new St.Label({ style_class: 'message-title', + x_expand: true, + x_align: Clutter.ActorAlign.START }); + this.setTitle(title); + titleBox.add_actor(this.titleLabel); + + this._secondaryBin = new St.Bin({ style_class: 'message-secondary-bin' }); + titleBox.add_actor(this._secondaryBin); + + let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic', + icon_size: 16 }); + this._closeButton = new St.Button({ child: closeIcon, visible: false }); + titleBox.add_actor(this._closeButton); + + this.bodyLabel = new URLHighlighter(body, false, this._useBodyMarkup); + this.bodyLabel.actor.add_style_class_name('message-body'); + contentBox.add_actor(this.bodyLabel.actor); + + this._closeButton.connect('clicked', Lang.bind(this, + function() { + this.emit('close'); + })); + this.actor.connect('notify::hover', Lang.bind(this, this._sync)); + this.actor.connect('clicked', Lang.bind(this, this._onClicked)); + this.actor.connect('destroy', Lang.bind(this, this._onDestroy)); + this._sync(); + }, + + setIcon: function(actor) { + this._iconBin.child = actor; + this._iconBin.visible = (actor != null); + }, + + setSecondaryActor: function(actor) { + this._secondaryBin.child = actor; + }, + + setTitle: function(text) { + let title = text ? _fixMarkup(text.replace(/\n/g, ' '), false) : ''; + this.titleLabel.text = title; + }, + + setBody: function(text) { + this.bodyLabel.setMarkup(text, this._useBodyMarkup); + }, + + setUseBodyMarkup: function(enable) { + if (this._useBodyMarkup === enable) + return; + this._useBodyMarkup = enable; + if (this.bodyLabel) + this.setBody(this.bodyLabel.actor.text); + }, + + canClear: function() { + return true; + }, + + _sync: function() { + let hovered = this.actor.hover; + this._closeButton.visible = hovered; + this._secondaryBin.visible = !hovered; + }, + + _onClicked: function() { + }, + + _onDestroy: function() { + } +}); +Signals.addSignalMethods(Message.prototype); + +const MessageListSection = new Lang.Class({ + Name: 'MessageListSection', + + _init: function(title) { + this.actor = new St.BoxLayout({ style_class: 'message-list-section', + clip_to_allocation: true, + x_expand: true, vertical: true }); + let titleBox = new St.BoxLayout({ style_class: 'message-list-section-title-box' }); + this.actor.add_actor(titleBox); + + this._title = new St.Button({ style_class: 'message-list-section-title', + label: title, + can_focus: true, + x_expand: true, + x_align: St.Align.START }); + titleBox.add_actor(this._title); + + this._title.connect('clicked', Lang.bind(this, this._onTitleClicked)); + this._title.connect('key-focus-in', Lang.bind(this, this._onKeyFocusIn)); + + let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic' }); + this._closeButton = new St.Button({ style_class: 'message-list-section-close', + child: closeIcon, + accessible_name: _("Clear section"), + can_focus: true }); + this._closeButton.set_x_align(Clutter.ActorAlign.END); + titleBox.add_actor(this._closeButton); + + this._closeButton.connect('clicked', Lang.bind(this, this.clear)); + + this._list = new St.BoxLayout({ style_class: 'message-list-section-list', + vertical: true }); + this.actor.add_actor(this._list); + + this._list.connect('actor-added', Lang.bind(this, this._sync)); + this._list.connect('actor-removed', Lang.bind(this, this._sync)); + + this._messages = new Map(); + this._date = new Date(); + this.empty = true; + this._sync(); + }, + + _onTitleClicked: function() { + Main.overview.hide(); + Main.panel.closeCalendar(); + }, + + _onKeyFocusIn: function(actor) { + this.emit('key-focus-in', actor); + }, + + setDate: function(date) { + if (_sameDay(date, this._date)) + return; + this._date = date; + this._sync(); + }, + + addMessage: function(message, animate) { + this.addMessageAtIndex(message, 0, animate); + }, + + addMessageAtIndex: function(message, index, animate) { + let obj = { + container: null, + destroyId: 0, + keyFocusId: 0, + closeId: 0 + }; + let pivot = new Clutter.Point({ x: .5, y: .5 }); + let scale = animate ? 0 : 1; + obj.container = new St.Widget({ layout_manager: new ScaleLayout(), + pivot_point: pivot, + scale_x: scale, scale_y: scale }); + obj.keyFocusId = message.actor.connect('key-focus-in', + Lang.bind(this, this._onKeyFocusIn)); + obj.destroyId = message.actor.connect('destroy', + Lang.bind(this, function() { + this.removeMessage(message, false); + })); + obj.closeId = message.connect('close', + Lang.bind(this, function() { + this.removeMessage(message, true); + })); + + this._messages.set(message, obj); + obj.container.add_actor(message.actor); + + this._list.insert_child_at_index(obj.container, index); + + if (animate) + Tweener.addTween(obj.container, { scale_x: 1, + scale_y: 1, + time: MESSAGE_ANIMATION_TIME, + transition: 'easeOutQuad' }); + }, + + removeMessage: function(message, animate) { + let obj = this._messages.get(message); + + message.actor.disconnect(obj.destroyId); + message.actor.disconnect(obj.keyFocusId); + message.disconnect(obj.closeId); + + this._messages.delete(message); + + if (animate) + Tweener.addTween(obj.container, { scale_x: 0, scale_y: 0, + time: MESSAGE_ANIMATION_TIME, + transition: 'easeOutQuad', + onComplete: function() { + obj.container.destroy(); + }}); + else + obj.container.destroy(); + }, + + clear: function() { + let messages = [...this._messages.keys()].filter(function(message) { + return message.canClear(); + }); + + // If there are few messages, letting them all zoom out looks OK + if (messages.length < 2) { + messages.forEach(Lang.bind(this, function(message) { + this.removeMessage(message, true); })); + } else { + // Otherwise we slide them out one by one, and then zoom them + // out "off-screen" in the end to smoothly shrink the parent + let delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5); + for (let i = 0; i < messages.length; i++) { + let message = messages[i]; + let obj = this._messages.get(message); + Tweener.addTween(obj.container, + { anchor_x: this._list.width, + opacity: 0, + time: MESSAGE_ANIMATION_TIME, + delay: i * delay, + transition: 'easeOutQuad', + onComplete: Lang.bind(this, function() { + this.removeMessage(message, true); + })}); + } + } + }, + + _canClear: function() { + for (let message of this._messages.keys()) + if (message.canClear()) + return true; + return false; + }, + + _isToday: function() { + let today = new Date(); + return _sameDay(this._date, today); + }, + + _syncVisible: function() { + this.actor.visible = !this.empty; + }, + + _sync: function() { + let empty = this._list.get_n_children() == 0; + let changed = this.empty !== empty; + this.empty = empty; + + if (changed) + this.emit('empty-changed'); + + this._closeButton.visible = this._canClear(); + this._syncVisible(); + } +}); +Signals.addSignalMethods(MessageListSection.prototype); + const EventsList = new Lang.Class({ Name: 'EventsList', @@ -1030,3 +1351,101 @@ const EventsList = new Lang.Class({ } } }); + +const Placeholder = new Lang.Class({ + Name: 'Placeholder', + + _init: function() { + this.actor = new St.BoxLayout({ style_class: 'message-list-placeholder', + vertical: true }); + + this._date = new Date(); + + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/no-events.svg'); + let gicon = new Gio.FileIcon({ file: file }); + + this._icon = new St.Icon({ gicon: gicon }); + this.actor.add_actor(this._icon); + + this._label = new St.Label({ text: _("No Events") }); + this.actor.add_actor(this._label); + } +}); + +const MessageList = new Lang.Class({ + Name: 'MessageList', + + _init: function() { + this.actor = new St.Widget({ style_class: 'message-list', + layout_manager: new Clutter.BinLayout(), + x_expand: true, y_expand: true }); + + this._placeholder = new Placeholder(); + this.actor.add_actor(this._placeholder.actor); + + this._scrollView = new St.ScrollView({ style_class: 'vfade', + overlay_scrollbars: true, + x_expand: true, y_expand: true, + x_fill: true, y_fill: true }); + this._scrollView.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); + this.actor.add_actor(this._scrollView); + + this._sectionList = new St.BoxLayout({ style_class: 'message-list-sections', + vertical: true, + y_expand: true, + y_align: Clutter.ActorAlign.START }); + this._scrollView.add_actor(this._sectionList); + this._sections = new Map(); + }, + + _addSection: function(section) { + let obj = { + destroyId: 0, + visibleId: 0, + emptyChangedId: 0, + keyFocusId: 0 + }; + obj.destroyId = section.actor.connect('destroy', Lang.bind(this, + function() { + this._removeSection(section); + })); + obj.visibleId = section.actor.connect('notify::visible', + Lang.bind(this, this._sync)); + obj.emptyChangedId = section.connect('empty-changed', + Lang.bind(this, this._sync)); + obj.keyFocusId = section.connect('key-focus-in', + Lang.bind(this, this._onKeyFocusIn)); + + this._sections.set(section, obj); + this._sectionList.add_actor(section.actor); + this._sync(); + }, + + _removeSection: function(section) { + let obj = this._sections.get(section); + section.actor.disconnect(obj.destroyId); + section.actor.disconnect(obj.visibleId); + section.disconnect(obj.emptyChangedId); + section.disconnect(obj.keyFocusId); + + this._sections.delete(section); + this._sectionList.remove_actor(section.actor); + this._sync(); + }, + + _onKeyFocusIn: function(section, actor) { + Util.ensureActorVisibleInScrollView(this._scrollView, actor); + }, + + _sync: function() { + let showPlaceholder = [...this._sections.keys()].every(function(s) { + return s.empty || !s.actor.visible; + }); + this._placeholder.actor.visible = showPlaceholder; + }, + + setDate: function(date) { + for (let section of this._sections.keys()) + section.setDate(date); + } +});