// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- /* exported DateMenuButton */ const { Clutter, Gio, GLib, GnomeDesktop, GObject, GWeather, Pango, Shell, St } = imports.gi; const Util = imports.misc.util; const Main = imports.ui.main; const PanelMenu = imports.ui.panelMenu; const Calendar = imports.ui.calendar; const Weather = imports.misc.weather; const System = imports.system; const { loadInterfaceXML } = imports.misc.fileUtils; const MAX_FORECASTS = 5; const ClocksIntegrationIface = loadInterfaceXML('org.gnome.Shell.ClocksIntegration'); const ClocksProxy = Gio.DBusProxy.makeProxyWrapper(ClocksIntegrationIface); function _isToday(date) { let now = new Date(); return now.getYear() == date.getYear() && now.getMonth() == date.getMonth() && now.getDate() == date.getDate(); } function _gDateTimeToDate(datetime) { return new Date(datetime.to_unix() * 1000 + datetime.get_microsecond() / 1000); } var TodayButton = GObject.registerClass( class TodayButton extends St.Button { _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. super._init({ style_class: 'datemenu-today-button', x_expand: true, can_focus: true, reactive: false, }); let hbox = new St.BoxLayout({ vertical: true }); this.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, datetime) => { // Make the button reactive only if the selected date is not the // current date. this.reactive = !_isToday(_gDateTimeToDate(datetime)); }); } vfunc_clicked() { this._calendar.setDate(new Date(), false); } 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 top bar (e.g., * "Tue 9:29 AM"). The string itself should become a full date, e.g., * "February 17 2015". */ let dateFormat = Shell.util_translate_time_string(N_("%B %-d %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.accessible_name = date.toLocaleFormat(dateFormat); } }); var WorldClocksSection = GObject.registerClass( class WorldClocksSection extends St.Button { _init() { super._init({ style_class: 'world-clocks-button', can_focus: true, x_expand: true, }); this._clock = new GnomeDesktop.WallClock(); this._clockNotifyId = 0; this._locations = []; let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); this._grid = new St.Widget({ style_class: 'world-clocks-grid', x_expand: true, layout_manager: layout }); layout.hookup_style(this._grid); this.child = this._grid; this._clocksApp = null; this._clocksProxy = new ClocksProxy( Gio.DBus.session, 'org.gnome.clocks', '/org/gnome/clocks', this._onProxyReady.bind(this), null /* cancellable */, Gio.DBusProxyFlags.DO_NOT_AUTO_START | Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES); this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.world-clocks', }); this._settings.connect('changed', this._clocksChanged.bind(this)); this._clocksChanged(); this._appSystem = Shell.AppSystem.get_default(); this._appSystem.connect('installed-changed', this._sync.bind(this)); this._sync(); } vfunc_clicked() { if (this._clocksApp) this._clocksApp.activate(); Main.overview.hide(); Main.panel.closeCalendar(); } _sync() { this._clocksApp = this._appSystem.lookup_app('org.gnome.clocks.desktop'); this.visible = this._clocksApp != null; } _clocksChanged() { this._grid.destroy_all_children(); this._locations = []; let world = GWeather.Location.get_world(); let clocks = this._settings.get_value('locations').deep_unpack(); for (let i = 0; i < clocks.length; i++) { let l = world.deserialize(clocks[i]); if (l && l.get_timezone() != null) 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.label_actor = header; let localOffset = GLib.DateTime.new_now_local().get_utc_offset(); for (let i = 0; i < this._locations.length; i++) { let l = this._locations[i].location; let name = l.get_city_name() || l.get_name(); let label = new St.Label({ style_class: 'world-clocks-city', text: name, x_align: Clutter.ActorAlign.START, y_align: Clutter.ActorAlign.CENTER, x_expand: true }); let time = new St.Label({ style_class: 'world-clocks-time' }); let otherOffset = this._getTimeAtLocation(l).get_utc_offset(); let offset = (otherOffset - localOffset) / GLib.TIME_SPAN_HOUR; let fmt = Math.trunc(offset) == offset ? '%s%.0f' : '%s%.1f'; let prefix = offset >= 0 ? '+' : '-'; let tz = new St.Label({ style_class: 'world-clocks-timezone', text: fmt.format(prefix, Math.abs(offset)), x_align: Clutter.ActorAlign.END, y_align: Clutter.ActorAlign.CENTER }); if (this._grid.text_direction == Clutter.TextDirection.RTL) { layout.attach(tz, 0, i + 1, 1, 1); layout.attach(time, 1, i + 1, 1, 1); layout.attach(label, 2, i + 1, 1, 1); } else { layout.attach(label, 0, i + 1, 1, 1); layout.attach(time, 1, i + 1, 1, 1); layout.attach(tz, 2, 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; } } _getTimeAtLocation(location) { let tz = GLib.TimeZone.new(location.get_timezone().get_tzid()); return GLib.DateTime.new_now(tz); } _updateLabels() { for (let i = 0; i < this._locations.length; i++) { let l = this._locations[i]; let now = this._getTimeAtLocation(l.location); l.actor.text = Util.formatTime(now, { timeOnly: true }); } } _onProxyReady(proxy, error) { if (error) { log('Failed to create GNOME Clocks proxy: %s'.format(error)); return; } this._clocksProxy.connect('g-properties-changed', this._onClocksPropertiesChanged.bind(this)); this._onClocksPropertiesChanged(); } _onClocksPropertiesChanged() { if (this._clocksProxy.g_name_owner == null) return; this._settings.set_value('locations', new GLib.Variant('av', this._clocksProxy.Locations)); } }); var WeatherSection = GObject.registerClass( class WeatherSection extends St.Button { _init() { super._init({ style_class: 'weather-button', can_focus: true, x_expand: true, }); this._weatherClient = new Weather.WeatherClient(); let box = new St.BoxLayout({ style_class: 'weather-box', vertical: true, x_expand: true, }); this.child = box; let titleBox = new St.BoxLayout({ style_class: 'weather-header-box' }); titleBox.add_child(new St.Label({ style_class: 'weather-header', x_align: Clutter.ActorAlign.START, x_expand: true, y_align: Clutter.ActorAlign.END, text: _('Weather'), })); box.add_child(titleBox); this._titleLocation = new St.Label({ style_class: 'weather-header location', x_align: Clutter.ActorAlign.END, y_align: Clutter.ActorAlign.END, }); titleBox.add_child(this._titleLocation); let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); this._forecastGrid = new St.Widget({ style_class: 'weather-grid', layout_manager: layout, }); layout.hookup_style(this._forecastGrid); box.add_child(this._forecastGrid); this._weatherClient.connect('changed', this._sync.bind(this)); this._sync(); } vfunc_map() { this._weatherClient.update(); super.vfunc_map(); } vfunc_clicked() { this._weatherClient.activateApp(); Main.overview.hide(); Main.panel.closeCalendar(); } _getInfos() { let forecasts = this._weatherClient.info.get_forecast_list(); let now = GLib.DateTime.new_now_local(); let current = GLib.DateTime.new_from_unix_local(0); let infos = []; for (let i = 0; i < forecasts.length; i++) { const [valid, timestamp] = forecasts[i].get_value_update(); if (!valid || timestamp === 0) continue; // 0 means 'never updated' const datetime = GLib.DateTime.new_from_unix_local(timestamp); if (now.difference(datetime) > 0) continue; // Ignore earlier forecasts if (datetime.difference(current) < GLib.TIME_SPAN_HOUR) continue; // Enforce a minimum interval of 1h if (infos.push(forecasts[i]) == MAX_FORECASTS) break; // Use a maximum of five forecasts current = datetime; } return infos; } _addForecasts() { let layout = this._forecastGrid.layout_manager; let infos = this._getInfos(); if (this._forecastGrid.text_direction == Clutter.TextDirection.RTL) infos.reverse(); let col = 0; infos.forEach(fc => { const [valid_, timestamp] = fc.get_value_update(); let timeStr = Util.formatTime(new Date(timestamp * 1000), { timeOnly: true, ampm: false, }); const [, tempValue] = fc.get_value_temp(GWeather.TemperatureUnit.DEFAULT); const tempPrefix = tempValue >= 0 ? ' ' : ''; let time = new St.Label({ style_class: 'weather-forecast-time', text: timeStr, x_align: Clutter.ActorAlign.CENTER, }); let icon = new St.Icon({ style_class: 'weather-forecast-icon', icon_name: fc.get_symbolic_icon_name(), x_align: Clutter.ActorAlign.CENTER, x_expand: true, }); let temp = new St.Label({ style_class: 'weather-forecast-temp', text: '%s%.0f°'.format(tempPrefix, tempValue), x_align: Clutter.ActorAlign.CENTER, }); temp.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; layout.attach(time, col, 0, 1, 1); layout.attach(icon, col, 1, 1, 1); layout.attach(temp, col, 2, 1, 1); col++; }); } _setStatusLabel(text) { let layout = this._forecastGrid.layout_manager; let label = new St.Label({ text }); layout.attach(label, 0, 0, 1, 1); } _updateForecasts() { this._forecastGrid.destroy_all_children(); if (!this._weatherClient.hasLocation) { this._setStatusLabel(_("Select a location…")); return; } let info = this._weatherClient.info; let loc = info.get_location(); if (loc.get_level() !== GWeather.LocationLevel.CITY && loc.has_coords()) { let world = GWeather.Location.get_world(); loc = world.find_nearest_city(...loc.get_coords()); } this._titleLocation.text = loc.get_name(); if (this._weatherClient.loading) { this._setStatusLabel(_("Loading…")); return; } if (info.is_valid()) { this._addForecasts(); return; } if (info.network_error()) this._setStatusLabel(_("Go online for weather information")); else this._setStatusLabel(_("Weather information is currently unavailable")); } _sync() { this.visible = this._weatherClient.available; if (!this.visible) return; this._titleLocation.visible = this._weatherClient.hasLocation; this._updateForecasts(); } }); var MessagesIndicator = GObject.registerClass( class MessagesIndicator extends St.Icon { _init() { super._init({ icon_size: 16, visible: false, y_expand: true, y_align: Clutter.ActorAlign.CENTER, }); this._sources = []; this._count = 0; this._doNotDisturb = false; this._settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications', }); this._settings.connect('changed::show-banners', this._sync.bind(this)); 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)); this.connect('destroy', () => { this._settings.run_dispose(); this._settings = null; }); } _onSourceAdded(tray, source) { source.connect('notify::count', 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)); this._count = count - Main.messageTray.queueCount; this._sync(); } _sync() { let doNotDisturb = !this._settings.get_boolean('show-banners'); this.icon_name = doNotDisturb ? 'notifications-disabled-symbolic' : 'message-indicator-symbolic'; this.visible = doNotDisturb || this._count > 0; } }); var IndicatorPad = GObject.registerClass( class IndicatorPad extends St.Widget { _init(actor) { this._source = actor; this._source.connect('notify::size', () => this.queue_relayout()); super._init(); this._source.bind_property('visible', this, 'visible', GObject.BindingFlags.SYNC_CREATE); } vfunc_get_preferred_width(forHeight) { return this._source.get_preferred_width(forHeight); } vfunc_get_preferred_height(forWidth) { return this._source.get_preferred_height(forWidth); } }); var FreezableBinLayout = GObject.registerClass( class FreezableBinLayout extends Clutter.BinLayout { _init() { super._init(); 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 super.vfunc_get_preferred_width(container, forHeight); return this._savedWidth; } vfunc_get_preferred_height(container, forWidth) { if (!this._frozen || this._savedHeight.some(isNaN)) return super.vfunc_get_preferred_height(container, forWidth); return this._savedHeight; } vfunc_allocate(container, allocation, flags) { super.vfunc_allocate(container, allocation, flags); let [width, height] = allocation.get_size(); this._savedWidth = [width, width]; this._savedHeight = [height, height]; } }); var CalendarColumnLayout = GObject.registerClass( class CalendarColumnLayout extends Clutter.BoxLayout { _init(actor) { super._init({ orientation: Clutter.Orientation.VERTICAL }); this._calActor = actor; } vfunc_get_preferred_width(container, forHeight) { if (!this._calActor || this._calActor.get_parent() != container) return super.vfunc_get_preferred_width(container, forHeight); return this._calActor.get_preferred_width(forHeight); } }); var DateMenuButton = GObject.registerClass( class DateMenuButton extends PanelMenu.Button { _init() { let hbox; let vbox; let menuAlignment = 0.5; if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) menuAlignment = 1.0 - menuAlignment; super._init(menuAlignment); this._clockDisplay = new St.Label({ y_align: Clutter.ActorAlign.CENTER }); this._indicator = new MessagesIndicator(); let box = new St.BoxLayout({ style_class: 'clock-display-box' }); box.add_actor(new IndicatorPad(this._indicator)); box.add_actor(this._clockDisplay); box.add_actor(this._indicator); this.label_actor = this._clockDisplay; this.add_actor(box); this.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, datetime) => { let date = _gDateTimeToDate(datetime); 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_child(this._messageList); // Fill up the second column let boxLayout = new CalendarColumnLayout(this._calendar); 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); vbox.add_actor(this._calendar); this._displaysSection = new St.ScrollView({ style_class: 'datemenu-displays-section vfade', x_expand: true, overlay_scrollbars: true }); this._displaysSection.set_policy(St.PolicyType.NEVER, St.PolicyType.EXTERNAL); vbox.add_actor(this._displaysSection); let displaysBox = new St.BoxLayout({ vertical: true, x_expand: true, style_class: 'datemenu-displays-box' }); this._displaysSection.add_actor(displaysBox); this._clocksItem = new WorldClocksSection(); displaysBox.add_child(this._clocksItem); this._weatherItem = new WeatherSection(); displaysBox.add_child(this._weatherItem); // 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; } });