11ca8dd54f
The original UTC support in GWeather piggy-backed on the existing API, but as "country" or "city" don't make sense in the context of UTC or AoE, the concept of "named timezones" was introduced. Handle those explicitly to get back labels for those locations. https://gitlab.gnome.org/GNOME/gnome-shell/issues/150
595 lines
21 KiB
JavaScript
595 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();
|
|
|
|
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;
|
|
}
|
|
});
|