f597a0a11c
This ensures that we do not create a new GTimeZone with g_time_zone_new_local() repeatedly. Currently, that will cause GTimeZone to open(), mmap() and parse /etc/localtime while on the main thread. We already track timezone changes, so we can cache this value and reuse it if we: 1) Clear the cache when timezone changes 2) Use the only GDateTime API available to us here, which means we imply the current time. But this is how environment.js uses the date and time anyway, so no loss. We maintain the old form for plugin compatibility. https://gitlab.gnome.org/GNOME/gnome-shell/issues/2279
600 lines
21 KiB
JavaScript
600 lines
21 KiB
JavaScript
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
|
|
|
|
const GLib = imports.gi.GLib;
|
|
const Gio = imports.gi.Gio;
|
|
const GnomeDesktop = imports.gi.GnomeDesktop;
|
|
const GObject = imports.gi.GObject;
|
|
const Gtk = imports.gi.Gtk;
|
|
const GWeather = imports.gi.GWeather;
|
|
const Lang = imports.lang;
|
|
const Mainloop = imports.mainloop;
|
|
const Pango = imports.gi.Pango;
|
|
const Cairo = imports.cairo;
|
|
const Clutter = imports.gi.Clutter;
|
|
const Shell = imports.gi.Shell;
|
|
const St = imports.gi.St;
|
|
const Atk = imports.gi.Atk;
|
|
|
|
const Params = imports.misc.params;
|
|
const Util = imports.misc.util;
|
|
const Main = imports.ui.main;
|
|
const PanelMenu = imports.ui.panelMenu;
|
|
const PopupMenu = imports.ui.popupMenu;
|
|
const Calendar = imports.ui.calendar;
|
|
const Weather = imports.misc.weather;
|
|
const System = imports.system;
|
|
|
|
function _isToday(date) {
|
|
let now = new Date();
|
|
return now.getYear() == date.getYear() &&
|
|
now.getMonth() == date.getMonth() &&
|
|
now.getDate() == date.getDate();
|
|
}
|
|
|
|
var TodayButton = new Lang.Class({
|
|
Name: 'TodayButton',
|
|
|
|
_init(calendar) {
|
|
// Having the ability to go to the current date if the user is already
|
|
// on the current date can be confusing. So don't make the button reactive
|
|
// until the selected date changes.
|
|
this.actor = new St.Button({ style_class: 'datemenu-today-button',
|
|
x_expand: true, x_align: St.Align.START,
|
|
can_focus: true,
|
|
reactive: false
|
|
});
|
|
this.actor.connect('clicked', () => {
|
|
this._calendar.setDate(new Date(), false);
|
|
});
|
|
|
|
let hbox = new St.BoxLayout({ vertical: true });
|
|
this.actor.add_actor(hbox);
|
|
|
|
this._dayLabel = new St.Label({ style_class: 'day-label',
|
|
x_align: Clutter.ActorAlign.START });
|
|
hbox.add_actor(this._dayLabel);
|
|
|
|
this._dateLabel = new St.Label({ style_class: 'date-label' });
|
|
hbox.add_actor(this._dateLabel);
|
|
|
|
this._calendar = calendar;
|
|
this._calendar.connect('selected-date-changed', (calendar, date) => {
|
|
// Make the button reactive only if the selected date is not the
|
|
// current date.
|
|
this.actor.reactive = !_isToday(date)
|
|
});
|
|
},
|
|
|
|
setDate(date) {
|
|
this._dayLabel.set_text(date.toLocaleFormat('%A'));
|
|
|
|
/* Translators: This is the date format to use when the calendar popup is
|
|
* shown - it is shown just below the time in the shell (e.g. "Tue 9:29 AM").
|
|
*/
|
|
let dateFormat = Shell.util_translate_time_string (N_("%B %e %Y"));
|
|
this._dateLabel.set_text(date.toLocaleFormat(dateFormat));
|
|
|
|
/* Translators: This is the accessible name of the date button shown
|
|
* below the time in the shell; it should combine the weekday and the
|
|
* date, e.g. "Tuesday February 17 2015".
|
|
*/
|
|
dateFormat = Shell.util_translate_time_string (N_("%A %B %e %Y"));
|
|
this.actor.accessible_name = date.toLocaleFormat(dateFormat);
|
|
}
|
|
});
|
|
|
|
var WorldClocksSection = new Lang.Class({
|
|
Name: 'WorldClocksSection',
|
|
|
|
_init() {
|
|
this._clock = new GnomeDesktop.WallClock();
|
|
this._clockNotifyId = 0;
|
|
|
|
this._locations = [];
|
|
|
|
this.actor = new St.Button({ style_class: 'world-clocks-button',
|
|
x_fill: true,
|
|
can_focus: true });
|
|
this.actor.connect('clicked', () => {
|
|
this._clockAppMon.activateApp();
|
|
|
|
Main.overview.hide();
|
|
Main.panel.closeCalendar();
|
|
});
|
|
|
|
let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL });
|
|
this._grid = new St.Widget({ style_class: 'world-clocks-grid',
|
|
layout_manager: layout });
|
|
layout.hookup_style(this._grid);
|
|
|
|
this.actor.child = this._grid;
|
|
|
|
this._clockAppMon = new Util.AppSettingsMonitor('org.gnome.clocks.desktop',
|
|
'org.gnome.clocks');
|
|
this._clockAppMon.connect('available-changed',
|
|
this._sync.bind(this));
|
|
this._clockAppMon.watchSetting('world-clocks',
|
|
this._clocksChanged.bind(this));
|
|
this._sync();
|
|
},
|
|
|
|
_sync() {
|
|
this.actor.visible = this._clockAppMon.available;
|
|
},
|
|
|
|
_clocksChanged(settings) {
|
|
this._grid.destroy_all_children();
|
|
this._locations = [];
|
|
|
|
let world = GWeather.Location.get_world();
|
|
let clocks = settings.get_value('world-clocks').deep_unpack();
|
|
for (let i = 0; i < clocks.length; i++) {
|
|
if (!clocks[i].location)
|
|
continue;
|
|
let l = world.deserialize(clocks[i].location);
|
|
if (l)
|
|
this._locations.push({ location: l });
|
|
}
|
|
|
|
this._locations.sort((a, b) => {
|
|
return a.location.get_timezone().get_offset() -
|
|
b.location.get_timezone().get_offset();
|
|
});
|
|
|
|
let layout = this._grid.layout_manager;
|
|
let title = (this._locations.length == 0) ? _("Add world clocks…")
|
|
: _("World Clocks");
|
|
let header = new St.Label({ style_class: 'world-clocks-header',
|
|
x_align: Clutter.ActorAlign.START,
|
|
text: title });
|
|
layout.attach(header, 0, 0, 2, 1);
|
|
this.actor.label_actor = header;
|
|
|
|
for (let i = 0; i < this._locations.length; i++) {
|
|
let l = this._locations[i].location;
|
|
|
|
let name = l.get_level() == GWeather.LocationLevel.NAMED_TIMEZONE ? l.get_name()
|
|
: l.get_city_name();
|
|
let label = new St.Label({ style_class: 'world-clocks-city',
|
|
text: name,
|
|
x_align: Clutter.ActorAlign.START,
|
|
x_expand: true });
|
|
|
|
let time = new St.Label({ style_class: 'world-clocks-time',
|
|
x_align: Clutter.ActorAlign.END,
|
|
x_expand: true });
|
|
|
|
if (this._grid.text_direction == Clutter.TextDirection.RTL) {
|
|
layout.attach(time, 0, i + 1, 1, 1);
|
|
layout.attach(label, 1, i + 1, 1, 1);
|
|
} else {
|
|
layout.attach(label, 0, i + 1, 1, 1);
|
|
layout.attach(time, 1, i + 1, 1, 1);
|
|
}
|
|
|
|
this._locations[i].actor = time;
|
|
}
|
|
|
|
if (this._grid.get_n_children() > 1) {
|
|
if (!this._clockNotifyId)
|
|
this._clockNotifyId =
|
|
this._clock.connect('notify::clock', this._updateLabels.bind(this));
|
|
this._updateLabels();
|
|
} else {
|
|
if (this._clockNotifyId)
|
|
this._clock.disconnect(this._clockNotifyId);
|
|
this._clockNotifyId = 0;
|
|
}
|
|
},
|
|
|
|
_updateLabels() {
|
|
for (let i = 0; i < this._locations.length; i++) {
|
|
let l = this._locations[i];
|
|
let tz = GLib.TimeZone.new(l.location.get_timezone().get_tzid());
|
|
let now = GLib.DateTime.new_now(tz);
|
|
l.actor.text = Util.formatTime(now, { timeOnly: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
var WeatherSection = new Lang.Class({
|
|
Name: 'WeatherSection',
|
|
|
|
_init() {
|
|
this._weatherClient = new Weather.WeatherClient();
|
|
|
|
this.actor = new St.Button({ style_class: 'weather-button',
|
|
x_fill: true,
|
|
can_focus: true });
|
|
this.actor.connect('clicked', () => {
|
|
this._weatherClient.activateApp();
|
|
|
|
Main.overview.hide();
|
|
Main.panel.closeCalendar();
|
|
});
|
|
this.actor.connect('notify::mapped', () => {
|
|
if (this.actor.mapped)
|
|
this._weatherClient.update();
|
|
});
|
|
|
|
let box = new St.BoxLayout({ style_class: 'weather-box',
|
|
vertical: true });
|
|
|
|
this.actor.child = box;
|
|
|
|
box.add_child(new St.Label({ style_class: 'weather-header',
|
|
x_align: Clutter.ActorAlign.START,
|
|
text: _("Weather") }));
|
|
|
|
this._conditionsLabel = new St.Label({ style_class: 'weather-conditions',
|
|
x_align: Clutter.ActorAlign.START });
|
|
this._conditionsLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
|
|
this._conditionsLabel.clutter_text.line_wrap = true;
|
|
box.add_child(this._conditionsLabel);
|
|
|
|
this._weatherClient.connect('changed', this._sync.bind(this));
|
|
this._sync();
|
|
},
|
|
|
|
_getSummary(info, capitalize=false) {
|
|
let options = capitalize ? GWeather.FormatOptions.SENTENCE_CAPITALIZATION
|
|
: GWeather.FormatOptions.NO_CAPITALIZATION;
|
|
|
|
let [ok, phenomenon, qualifier] = info.get_value_conditions();
|
|
if (ok)
|
|
return new GWeather.Conditions({ significant: true,
|
|
phenomenon,
|
|
qualifier }).to_string_full(options);
|
|
|
|
let [, sky] = info.get_value_sky();
|
|
return GWeather.Sky.to_string_full(sky, options);
|
|
},
|
|
|
|
_sameSummary(info1, info2) {
|
|
let [ok1, phenom1, qualifier1] = info1.get_value_conditions();
|
|
let [ok2, phenom2, qualifier2] = info2.get_value_conditions();
|
|
if (ok1 || ok2)
|
|
return ok1 == ok2 && phenom1 == phenom2 && qualifier1 == qualifier2;
|
|
|
|
let [, sky1] = info1.get_value_sky();
|
|
let [, sky2] = info2.get_value_sky();
|
|
return sky1 == sky2;
|
|
},
|
|
|
|
_getSummaryText() {
|
|
let info = this._weatherClient.info;
|
|
let forecasts = info.get_forecast_list();
|
|
if (forecasts.length == 0) // No forecasts, just current conditions
|
|
return '%s.'.format(this._getSummary(info, true));
|
|
|
|
let current = info;
|
|
let infos = [info];
|
|
for (let i = 0; i < forecasts.length; i++) {
|
|
let [ok, timestamp] = forecasts[i].get_value_update();
|
|
if (!_isToday(new Date(timestamp * 1000)))
|
|
continue; // Ignore forecasts from other days
|
|
|
|
if (this._sameSummary(current, forecasts[i]))
|
|
continue; // Ignore consecutive runs of equal summaries
|
|
|
|
current = forecasts[i];
|
|
if (infos.push(current) == 3)
|
|
break; // Use a maximum of three summaries
|
|
}
|
|
|
|
let fmt;
|
|
switch(infos.length) {
|
|
/* Translators: %s is a weather condition like "Clear sky"; see
|
|
libgweather for the possible condition strings. If at all
|
|
possible, the sentence should match the grammatical case etc. of
|
|
the inserted conditions. */
|
|
case 1: fmt = _("%s all day."); break;
|
|
|
|
/* Translators: %s is a weather condition like "Clear sky"; see
|
|
libgweather for the possible condition strings. If at all
|
|
possible, the sentence should match the grammatical case etc. of
|
|
the inserted conditions. */
|
|
case 2: fmt = _("%s, then %s later."); break;
|
|
|
|
/* Translators: %s is a weather condition like "Clear sky"; see
|
|
libgweather for the possible condition strings. If at all
|
|
possible, the sentence should match the grammatical case etc. of
|
|
the inserted conditions. */
|
|
case 3: fmt = _("%s, then %s, followed by %s later."); break;
|
|
}
|
|
let summaries = infos.map((info, i) => {
|
|
let capitalize = i == 0 && fmt.startsWith('%s');
|
|
return this._getSummary(info, capitalize);
|
|
});
|
|
return String.prototype.format.apply(fmt, summaries);
|
|
},
|
|
|
|
_getLabelText() {
|
|
if (!this._weatherClient.hasLocation)
|
|
return _("Select a location…");
|
|
|
|
if (this._weatherClient.loading)
|
|
return _("Loading…");
|
|
|
|
let info = this._weatherClient.info;
|
|
if (info.is_valid())
|
|
return this._getSummaryText() + ' ' +
|
|
/* Translators: %s is a temperature with unit, e.g. "23℃" */
|
|
_("Feels like %s.").format(info.get_apparent());
|
|
|
|
if (info.network_error())
|
|
return _("Go online for weather information");
|
|
|
|
return _("Weather information is currently unavailable");
|
|
},
|
|
|
|
_sync() {
|
|
this.actor.visible = this._weatherClient.available;
|
|
|
|
if (!this.actor.visible)
|
|
return;
|
|
|
|
this._conditionsLabel.text = this._getLabelText();
|
|
}
|
|
});
|
|
|
|
var MessagesIndicator = new Lang.Class({
|
|
Name: 'MessagesIndicator',
|
|
|
|
_init() {
|
|
this.actor = new St.Icon({ icon_name: 'message-indicator-symbolic',
|
|
icon_size: 16,
|
|
visible: false, y_expand: true,
|
|
y_align: Clutter.ActorAlign.CENTER });
|
|
|
|
this._sources = [];
|
|
|
|
Main.messageTray.connect('source-added', this._onSourceAdded.bind(this));
|
|
Main.messageTray.connect('source-removed', this._onSourceRemoved.bind(this));
|
|
Main.messageTray.connect('queue-changed', this._updateCount.bind(this));
|
|
|
|
let sources = Main.messageTray.getSources();
|
|
sources.forEach(source => { this._onSourceAdded(null, source); });
|
|
},
|
|
|
|
_onSourceAdded(tray, source) {
|
|
source.connect('count-updated', this._updateCount.bind(this));
|
|
this._sources.push(source);
|
|
this._updateCount();
|
|
},
|
|
|
|
_onSourceRemoved(tray, source) {
|
|
this._sources.splice(this._sources.indexOf(source), 1);
|
|
this._updateCount();
|
|
},
|
|
|
|
_updateCount() {
|
|
let count = 0;
|
|
this._sources.forEach(source => { count += source.unseenCount; });
|
|
count -= Main.messageTray.queueCount;
|
|
|
|
this.actor.visible = (count > 0);
|
|
}
|
|
});
|
|
|
|
var IndicatorPad = new Lang.Class({
|
|
Name: 'IndicatorPad',
|
|
Extends: St.Widget,
|
|
|
|
_init(actor) {
|
|
this._source = actor;
|
|
this._source.connect('notify::visible', () => { this.queue_relayout(); });
|
|
this.parent();
|
|
},
|
|
|
|
vfunc_get_preferred_width(container, forHeight) {
|
|
if (this._source.visible)
|
|
return this._source.get_preferred_width(forHeight);
|
|
return [0, 0];
|
|
},
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
if (this._source.visible)
|
|
return this._source.get_preferred_height(forWidth);
|
|
return [0, 0];
|
|
}
|
|
});
|
|
|
|
var FreezableBinLayout = new Lang.Class({
|
|
Name: 'FreezableBinLayout',
|
|
Extends: Clutter.BinLayout,
|
|
|
|
_init() {
|
|
this.parent();
|
|
|
|
this._frozen = false;
|
|
this._savedWidth = [NaN, NaN];
|
|
this._savedHeight = [NaN, NaN];
|
|
},
|
|
|
|
set frozen(v) {
|
|
if (this._frozen == v)
|
|
return;
|
|
|
|
this._frozen = v;
|
|
if (!this._frozen)
|
|
this.layout_changed();
|
|
},
|
|
|
|
vfunc_get_preferred_width(container, forHeight) {
|
|
if (!this._frozen || this._savedWidth.some(isNaN))
|
|
return this.parent(container, forHeight);
|
|
return this._savedWidth;
|
|
},
|
|
|
|
vfunc_get_preferred_height(container, forWidth) {
|
|
if (!this._frozen || this._savedHeight.some(isNaN))
|
|
return this.parent(container, forWidth);
|
|
return this._savedHeight;
|
|
},
|
|
|
|
vfunc_allocate(container, allocation, flags) {
|
|
this.parent(container, allocation, flags);
|
|
|
|
let [width, height] = allocation.get_size();
|
|
this._savedWidth = [width, width];
|
|
this._savedHeight = [height, height];
|
|
}
|
|
});
|
|
|
|
var CalendarColumnLayout = new Lang.Class({
|
|
Name: 'CalendarColumnLayout',
|
|
Extends: Clutter.BoxLayout,
|
|
|
|
_init(actor) {
|
|
this.parent({ orientation: Clutter.Orientation.VERTICAL });
|
|
this._calActor = actor;
|
|
},
|
|
|
|
vfunc_get_preferred_width(container, forHeight) {
|
|
if (!this._calActor || this._calActor.get_parent() != container)
|
|
return this.parent(container, forHeight);
|
|
return this._calActor.get_preferred_width(forHeight);
|
|
}
|
|
});
|
|
|
|
var DateMenuButton = new Lang.Class({
|
|
Name: 'DateMenuButton',
|
|
Extends: PanelMenu.Button,
|
|
|
|
_init() {
|
|
let item;
|
|
let hbox;
|
|
let vbox;
|
|
|
|
let menuAlignment = 0.5;
|
|
if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
|
|
menuAlignment = 1.0 - menuAlignment;
|
|
this.parent(menuAlignment);
|
|
|
|
this._clockDisplay = new St.Label({ y_align: Clutter.ActorAlign.CENTER });
|
|
this._indicator = new MessagesIndicator();
|
|
|
|
let box = new St.BoxLayout();
|
|
box.add_actor(new IndicatorPad(this._indicator.actor));
|
|
box.add_actor(this._clockDisplay);
|
|
box.add_actor(this._indicator.actor);
|
|
|
|
this.actor.label_actor = this._clockDisplay;
|
|
this.actor.add_actor(box);
|
|
this.actor.add_style_class_name ('clock-display');
|
|
|
|
|
|
let layout = new FreezableBinLayout();
|
|
let bin = new St.Widget({ layout_manager: layout });
|
|
// For some minimal compatibility with PopupMenuItem
|
|
bin._delegate = this;
|
|
this.menu.box.add_child(bin);
|
|
|
|
hbox = new St.BoxLayout({ name: 'calendarArea' });
|
|
bin.add_actor(hbox);
|
|
|
|
this._calendar = new Calendar.Calendar();
|
|
this._calendar.connect('selected-date-changed',
|
|
(calendar, date) => {
|
|
layout.frozen = !_isToday(date);
|
|
this._messageList.setDate(date);
|
|
});
|
|
|
|
this.menu.connect('open-state-changed', (menu, isOpen) => {
|
|
// Whenever the menu is opened, select today
|
|
if (isOpen) {
|
|
let now = new Date();
|
|
this._calendar.setDate(now);
|
|
this._date.setDate(now);
|
|
this._messageList.setDate(now);
|
|
}
|
|
});
|
|
|
|
// Fill up the first column
|
|
this._messageList = new Calendar.CalendarMessageList();
|
|
hbox.add(this._messageList.actor, { expand: true, y_fill: false, y_align: St.Align.START });
|
|
|
|
// Fill up the second column
|
|
let boxLayout = new CalendarColumnLayout(this._calendar.actor);
|
|
vbox = new St.Widget({ style_class: 'datemenu-calendar-column',
|
|
layout_manager: boxLayout });
|
|
boxLayout.hookup_style(vbox);
|
|
hbox.add(vbox);
|
|
|
|
this._date = new TodayButton(this._calendar);
|
|
vbox.add_actor(this._date.actor);
|
|
|
|
vbox.add_actor(this._calendar.actor);
|
|
|
|
this._displaysSection = new St.ScrollView({ style_class: 'datemenu-displays-section vfade',
|
|
x_expand: true, x_fill: true,
|
|
overlay_scrollbars: true });
|
|
this._displaysSection.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
|
|
vbox.add_actor(this._displaysSection);
|
|
|
|
let displaysBox = new St.BoxLayout({ vertical: true,
|
|
style_class: 'datemenu-displays-box' });
|
|
this._displaysSection.add_actor(displaysBox);
|
|
|
|
this._clocksItem = new WorldClocksSection();
|
|
displaysBox.add(this._clocksItem.actor, { x_fill: true });
|
|
|
|
this._weatherItem = new WeatherSection();
|
|
displaysBox.add(this._weatherItem.actor, { x_fill: true });
|
|
|
|
// Done with hbox for calendar and event list
|
|
|
|
this._clock = new GnomeDesktop.WallClock();
|
|
this._clock.bind_property('clock', this._clockDisplay, 'text', GObject.BindingFlags.SYNC_CREATE);
|
|
this._clock.connect('notify::timezone', this._updateTimeZone.bind(this));
|
|
|
|
Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
|
|
this._sessionUpdated();
|
|
},
|
|
|
|
_getEventSource() {
|
|
return new Calendar.DBusEventSource();
|
|
},
|
|
|
|
_setEventSource(eventSource) {
|
|
if (this._eventSource)
|
|
this._eventSource.destroy();
|
|
|
|
this._calendar.setEventSource(eventSource);
|
|
this._messageList.setEventSource(eventSource);
|
|
|
|
this._eventSource = eventSource;
|
|
},
|
|
|
|
_updateTimeZone() {
|
|
// SpiderMonkey caches the time zone so we must explicitly clear it
|
|
// before we can update the calendar, see
|
|
// https://bugzilla.gnome.org/show_bug.cgi?id=678507
|
|
System.clearDateCaches();
|
|
|
|
// To reduce the number of times that Shell opens /etc/localtime on
|
|
// the main thread to parse timezone info, we cache the result. That
|
|
// needs to be cleared when we change timezones.
|
|
Shell.util_clear_timezone_cache();
|
|
|
|
this._calendar.updateTimeZone();
|
|
},
|
|
|
|
_sessionUpdated() {
|
|
let eventSource;
|
|
let showEvents = Main.sessionMode.showCalendarEvents;
|
|
if (showEvents) {
|
|
eventSource = this._getEventSource();
|
|
} else {
|
|
eventSource = new Calendar.EmptyEventSource();
|
|
}
|
|
this._setEventSource(eventSource);
|
|
|
|
// Displays are not actually expected to launch Settings when activated
|
|
// but the corresponding app (clocks, weather); however we can consider
|
|
// that display-specific settings, so re-use "allowSettings" here ...
|
|
this._displaysSection.visible = Main.sessionMode.allowSettings;
|
|
}
|
|
});
|