diff --git a/calendar-server/cinnamon-calendar-server.in b/calendar-server/cinnamon-calendar-server.in new file mode 100755 index 0000000000..fc41bec087 --- /dev/null +++ b/calendar-server/cinnamon-calendar-server.in @@ -0,0 +1,6 @@ +#!/bin/sh + +export GI_TYPELIB_PATH="@PKGLIBDIR@:@MUFFINLIBDIR@" +export LD_LIBRARY_PATH="@PKGLIBDIR@:@MUFFINLIBDIR@" + +exec /usr/libexec/cinnamon/cinnamon-calendar-server.py \ No newline at end of file diff --git a/calendar-server/cinnamon-calendar-server.py b/calendar-server/cinnamon-calendar-server.py new file mode 100755 index 0000000000..6315045182 --- /dev/null +++ b/calendar-server/cinnamon-calendar-server.py @@ -0,0 +1,446 @@ +#!/usr/bin/python3 +import os +import sys +import setproctitle +import locale +import gettext +import functools +import logging +import time +from setproctitle import setproctitle +import signal + +import gi +gi.require_version('EDataServer', '1.2') +gi.require_version('ECal', '2.0') +gi.require_version('ICal', '3.0') +gi.require_version('Cinnamon', '0.1') +from gi.repository import GLib, Gio, GObject +from gi.repository import EDataServer, ECal, ICal, ICalGLib +from gi.repository import Cinnamon + +BUS_NAME = "org.cinnamon.CalendarServer" +BUS_PATH = "/org/cinnamon/CalendarServer" + +CINNAMON_GSCHEMA = "org.cinnamon" +KEEP_ALIVE_KEY = "calendar-server-keep-active" + +class CalendarInfo(): + def __init__(self, source, client): + # print(source, client) + self.source = source + self.client = client + + self.color = source.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR).get_color() + + self.start = None + self.end = None + + self.view = None + self.view_cancellable = None + self.events = [] + + def destroy(self): + if self.view_cancellable != None: + self.view_cancellable.cancel() + + if self.view != None: + self.view.stop() + self.view = None + +class Event(): + def __init__(self, uid, color, summary, all_day, start_timet, end_timet): + self.__dict__.update(locals()) + +class CalendarServer(Gio.Application): + def __init__(self): + Gio.Application.__init__(self, + application_id=BUS_NAME, + inactivity_timeout=20000, + flags=Gio.ApplicationFlags.REPLACE | + Gio.ApplicationFlags.ALLOW_REPLACEMENT | + Gio.ApplicationFlags.IS_SERVICE) + self.bus_connection = None + self.interface = None + self.registry = None + self.registery_watcher = None + self.client_appeared_id = 0 + self.client_disappeared_id = 0 + + self.calendars = {} + + self.current_month_start = 0 + self.current_month_end = 0 + + self.zone = None + self.update_timezone() + + try: + self.session_bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + except: + print("Unable to get session connection, fatal!") + exit(1) + + self.interface = Cinnamon.CalendarServerSkeleton.new() + self.interface.connect("handle-set-time-range", self.handle_set_time_range) + self.interface.connect("handle-exit", self.handle_exit) + self.interface.export(self.session_bus, BUS_PATH) + + try: + self.register(None) + except GLib.Error as e: + print("couldn't register on bus: ", e.message) + + def update_timezone(self): + location = ECal.system_timezone_get_location() + + if location == None: + self.zone = ICalGLib.Timezone.get_utc_timezone().copy() + else: + self.zone = ICalGLib.Timezone.get_builtin_timezone(location).copy() + + def do_startup(self): + Gio.Application.do_startup(self) + + self.keep_alive = False + self.settings = Gio.Settings(schema_id=CINNAMON_GSCHEMA) + self.settings.connect("changed::" + KEEP_ALIVE_KEY, self.keep_alive_changed) + self.keep_alive_changed(None, None) + + # This makes the inactivity timeout work. Otherwise timeout is fixed at 10s after startup. + self.hold() + self.release() + + EDataServer.SourceRegistry.new(None, self.got_registry_callback) + + def keep_alive_changed(self, settings, key): + new_keep_alive = self.settings.get_boolean(KEEP_ALIVE_KEY) + + if new_keep_alive == self.keep_alive: + return + + if new_keep_alive: + self.hold() + else: + self.release() + + self.keep_alive = new_keep_alive + + def do_activate(self): + pass + + def got_registry_callback(self, source, res): + try: + self.registry = EDataServer.SourceRegistry.new_finish(res) + except GLib.Error as e: + print(e) + self.quit() + + self.registry_watcher = EDataServer.SourceRegistryWatcher.new(self.registry, None) + + self.client_appeared_id = self.registry_watcher.connect("appeared", self.source_appeared) + self.client_disappeared_id = self.registry_watcher.connect("disappeared", self.source_disappeared) + self.registry_watcher.connect("filter", self.is_relevant_source) + + # This forces the watcher to notify about all pre-existing sources (so + # the callbacks can process them) + self.registry_watcher.reclaim() + + def source_appeared(self, watcher, source): + print(source.get_display_name()) + ECal.Client.connect(source, ECal.ClientSourceType.EVENTS, 10, None, self.ecal_client_connected, source) + + # ??? should be (self, source, res) but we get the client instead + def ecal_client_connected(self, c, res, source): + try: + client = ECal.Client.connect_finish(res) + client.set_default_timezone(self.zone) + + calendar = CalendarInfo(source, client) + self.calendars[source.get_uid()] = calendar + + self.interface.set_property("has-calendars", True) + + if self.current_month_start != 0 and self.current_month_end != 0: + self.create_view_for_calendar(calendar) + except GLib.Error as e: + # what to do + print("couldn't connect to source", e.message) + return + + def source_disappeared(self, watcher, source): + try: + calendar = self.calendars[source.get_uid()] + except KeyError: + # We had a source but it wasn't for a calendar. + return + + self.interface.emit_client_disappeared(source.get_uid()) + calendar.destroy() + + del self.calendars[source.get_uid()] + if len(self.calendars) > 0: + return + + self.interface.set_property("has-calendars", False) + + def is_relevant_source(self, watcher, source): + relevant = source.has_extension(EDataServer.SOURCE_EXTENSION_CALENDAR) and \ + source.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR).get_selected() + return relevant + + def handle_set_time_range(self, iface, inv, time_since, time_until, force_reload): + print("SET TIME: from %s to %s" % (GLib.DateTime.new_from_unix_local(time_since).format_iso8601(), + GLib.DateTime.new_from_unix_local(time_until).format_iso8601())) + + if time_since == self.current_month_start and time_until == self.current_month_end: + if not force_reload: + self.interface.complete_set_time_range(inv) + return True + + self.current_month_start = time_since + self.current_month_end = time_until + + self.interface.set_property("since", time_since); + self.interface.set_property("until", time_until); + + for uid in self.calendars.keys(): + calendar = self.calendars[uid] + self.create_view_for_calendar(calendar) + + self.interface.complete_set_time_range(inv) + return True + + def handle_exit(self, iface, inv): + self.exit() + self.interface.complete_exit(inv) + + def create_view_for_calendar(self, calendar): + if calendar.view_cancellable != None: + calendar.view_cancellable.cancel() + calendar.view_cancellable = Gio.Cancellable() + + if calendar.view != None: + calendar.view.stop() + calendar.view = None + + from_iso = ECal.isodate_from_time_t(self.current_month_start) + to_iso = ECal.isodate_from_time_t(self.current_month_end) + + calendar.start = self.current_month_start + calendar.end = self.current_month_end + + query = "occur-in-time-range? (make-time \"%s\") (make-time \"%s\") \"%s\"" %\ + (from_iso, to_iso, self.zone.get_location()) + + calendar.client.get_view(query, calendar.view_cancellable, self.got_calendar_view, calendar) + + def got_calendar_view(self, client, res, calendar): + if calendar.view_cancellable.is_cancelled(): + return + + try: + success, view = client.get_view_finish(res) + calendar.view = view + except GLib.Error as e: + print("get view failed: ", e.message) + return + + view.set_flags(ECal.ClientViewFlags.NOTIFY_INITIAL) + view.connect("objects-added", self.view_objects_added, calendar) + view.connect("objects-modified", self.view_objects_modified, calendar) + view.connect("objects-removed", self.view_objects_removed, calendar) + view.start() + + def view_objects_added(self, view, objects, calendar): + self.handle_new_or_modified_objects(view, objects, calendar) + + def view_objects_modified(self, view, objects, calendar): + self.handle_new_or_modified_objects(view, objects, calendar) + + def view_objects_removed(self, view, component_ids, calendar): + print("objects removed: ", component_ids) + + self.handle_removed_objects(view, component_ids, calendar) + + def handle_new_or_modified_objects(self, view, objects, calendar): + if (calendar.view_cancellable.is_cancelled()): + return + + events = [] + + for ical_comp in objects: + + if ical_comp.get_uid() == None: + continue + + if (not ECal.util_component_is_instance (ical_comp)) and \ + ECal.util_component_has_recurrences(ical_comp): + calendar.client.generate_instances_for_object( + ical_comp, + calendar.start, + calendar.end, + calendar.view_cancellable, + self.recurrence_generated, + calendar + ) + else: + comp = ECal.Component.new_from_icalcomponent(ical_comp) + comptext = comp.get_summary() + if comptext != None: + summary = comptext.get_value() + else: + summary = "" + + dts_prop = ical_comp.get_first_property(ICalGLib.PropertyKind.DTSTART_PROPERTY) + ical_time_start = dts_prop.get_dtstart() + start_timet = self.ical_time_get_timet(calendar.client, ical_time_start, dts_prop); + all_day = ical_time_start.is_date() + + dte_prop = ical_comp.get_first_property(ICalGLib.PropertyKind.DTEND_PROPERTY) + + if dte_prop != None: + ical_time_end = dte_prop.get_dtend() + end_timet = self.ical_time_get_timet(calendar.client, ical_time_end, dte_prop); + else: + end_timet = start_timet + (60 * 30) # Default to 30m if the end time is bad. + + event = Event( + self.create_uid(calendar, comp), + calendar.color, + summary, + all_day, + start_timet, + end_timet + ) + + events.append(event) + if len(events) > 0: + self.emit_events_added_or_updated(calendar, events) + + def recurrence_generated(self, ical_comp, instance_start, instance_end, calendar, cancellable): + if calendar.view_cancellable.is_cancelled(): + return False + + comp = ECal.Component.new_from_icalcomponent(ical_comp) + all_objects = GLib.VariantBuilder(GLib.VariantType.new("a(sssbxx)")) + + comptext = comp.get_summary() + if comptext != None: + summary = comptext.get_value() + else: + summary = "" + + default_zone = calendar.client.get_default_timezone (); + + dts_timezone = instance_start.get_timezone() + if dts_timezone == None: + dts_timezone = default_zone + + dte_timezone = instance_end.get_timezone() + if dte_timezone == None: + dte_timezone = default_zone + + all_day = instance_start.is_date() + start_timet = instance_start.as_timet_with_zone(dts_timezone) + end_timet = instance_end.as_timet_with_zone(dte_timezone) + + event = Event( + self.create_uid(calendar, comp), + calendar.color, + summary, + all_day, + start_timet, + end_timet + ) + + self.emit_events_added_or_updated(calendar, [event]) + + return True + + def emit_events_added_or_updated(self, calendar, events): + # print("package: ",len(events)) + all_events = GLib.VariantBuilder(GLib.VariantType.new("a(sssbxx)")) + + for event in events: + if event.end_timet <= (calendar.start - 1) and event.start_timet >= calendar.end: + continue + + event_var = GLib.Variant( + "(sssbxx)", + [ + event.uid, + event.color, + event.summary, + event.all_day, + event.start_timet, + event.end_timet + ] + ) + + all_events.add_value(event_var) + + self.interface.emit_events_added_or_updated(all_events.end()) + + def ical_time_get_timet(self, client, ical_time, prop): + tzid = prop.get_first_parameter(ICalGLib.ParameterKind.TZID_PARAMETER) + if tzid: + timezone = ECal.TimezoneCache.get_timezone(client, tzid.get_tzid()) + elif ical_time.is_utc(): + timezone = ICal.Timezone.get_utc_timezone() + else: + timezone = client.get_default_timezone() + + ical_time.set_timezone(timezone) + return ical_time.as_timet_with_zone(timezone) + + def create_uid(self, calendar, ecal_comp): + # format from gcal-event.c (gnome-calendar) + + source_id = calendar.source.get_uid() + comp_id = ecal_comp.get_id() + return self.get_id_from_comp_id(comp_id, source_id) + + def get_id_from_comp_id(self, comp_id, source_id): + if comp_id.get_rid() != None: + return "%s:%s:%s" % (source_id, comp_id.get_uid(), comp_id.get_rid()) + else: + return "%s:%s" % (source_id, comp_id.get_uid()) + + def handle_removed_objects(self, view, component_ids, calendar): + # what else? + # print("handle: ", uuid_list) + source_id = calendar.source.get_uid() + + uids = [] + + for comp_id in component_ids: + uid = self.get_id_from_comp_id(comp_id, source_id) + uids.append(uid) + + uids_string = "::".join(uids) + + if uids_string != "": + self.interface.emit_events_removed(uids_string) + + def exit(self): + self.registry_watcher.disconnect(self.client_appeared_id) + self.registry_watcher.disconnect(self.client_disappeared_id) + + for uid in self.calendars.keys(): + self.calendars[uid].destroy() + + GLib.idle_add(self.quit) + +def main(): + setproctitle("cinnamon-calendar-server") + + server = CalendarServer() + signal.signal(signal.SIGINT, lambda s, f: server.exit()) + signal.signal(signal.SIGTERM, lambda s, f: server.exit()) + + server.run(sys.argv) + return 0 + +if __name__ == "__main__": + main() diff --git a/calendar-server/meson.build b/calendar-server/meson.build new file mode 100644 index 0000000000..5dc4d2b8eb --- /dev/null +++ b/calendar-server/meson.build @@ -0,0 +1,29 @@ + +server_launcher_conf = configuration_data() +server_launcher_conf.set('MUFFINLIBDIR', muffinlibdir) +server_launcher_conf.set('PKGLIBDIR', join_paths(prefix, pkglibdir)) + +launcher = configure_file( + input: 'cinnamon-calendar-server.in', + output: 'cinnamon-calendar-server', + configuration: server_launcher_conf, + install_dir: bindir, + install_mode: 'rwxr-xr-x' +) + +service_conf = configuration_data() +service_conf.set('BINDIR', bindir) + +launcher = configure_file( + input: 'org.cinnamon.CalendarServer.service.in', + output: 'org.cinnamon.CalendarServer.service', + configuration: service_conf, + install_dir: dbus_services_dir +) + +install_data( + 'cinnamon-calendar-server.py', + install_dir: libexecdir, + install_mode: 'rwxr-xr-x' +) + diff --git a/calendar-server/org.cinnamon.CalendarServer.service.in b/calendar-server/org.cinnamon.CalendarServer.service.in new file mode 100644 index 0000000000..1b7029f2a0 --- /dev/null +++ b/calendar-server/org.cinnamon.CalendarServer.service.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.cinnamon.CalendarServer +Exec=/@BINDIR@/cinnamon-calendar-server diff --git a/data/org.cinnamon.gschema.xml b/data/org.cinnamon.gschema.xml index 9b22ba441e..23f2830007 100644 --- a/data/org.cinnamon.gschema.xml +++ b/data/org.cinnamon.gschema.xml @@ -530,7 +530,15 @@ Controls the default label used by the application menu. - + + false + If true, the cinnamon-calendar-server process will run continuously, allowing push updates to the calendar applet. If false, it will only run and update periodically, or when a new month is selected. + + + 15 + Amount of time (in minutes) between poking the cinnamon-calendar-server + When 'calendar-server-keep-active' is false, it will run briefly at the interval defined here, to re-fetch events. + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/data/theme/calendar-today-selected.svg b/data/theme/calendar-today-selected.svg new file mode 100644 index 0000000000..10d72f368a --- /dev/null +++ b/data/theme/calendar-today-selected.svg @@ -0,0 +1,71 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/data/theme/calendar-today.svg b/data/theme/calendar-today.svg index 5f80fe6ffe..c46bba14dc 100644 --- a/data/theme/calendar-today.svg +++ b/data/theme/calendar-today.svg @@ -1,11 +1,74 @@ - - - - - - - - - + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/data/theme/cinnamon.css b/data/theme/cinnamon.css index 6c9856b206..10362f0657 100644 --- a/data/theme/cinnamon.css +++ b/data/theme/cinnamon.css @@ -531,8 +531,152 @@ StScrollBar StButton#vhandle:hover { /*calendar-background allows the date applet calendar to be themed separately from other applet menus*/ .calendar-background { } + +.calendar-main-box { + padding: 0 1.0em; + max-height: 450px; + height: 450px; +} + +.calendar-events-main-box { + margin-right: .5em; + padding: .75em; + min-width: 450px; + border: 1px solid #666; + border-radius: 8px; + background-gradient-direction: vertical; + background-gradient-start: rgba(85,85,85,0.8); + background-gradient-end: rgba(85,85,85,0.2); +} + +.calendar-events-no-events-label { + font-size: 1.1em; + color: #cccccc; + font-weight: bold; + text-align: center; +} + +.calendar-events-date-label { + padding: .2em 0 .75em .2em; + font-size: 1.1em; + color: #cccccc; + font-weight: bold; + text-align: center; +} + +.calendar-events-event-container { + padding: .1em; +} + +.calendar-events-scrollbox { +} + +.calendar-events-main-box .separator { + -margin-horizontal: 1em; + -gradient-height: 1px; + -gradient-start: #666666; + -gradient-end: #666666; +} + +.calendar-event-button { + margin: 6px 0 6px 0; + border-radius: 4px; +} + +.calendar-event-button:past, +.calendar-event-button:present, +.calendar-event-button:future { +} + +.calendar-event-button:hover { + background-gradient-direction: vertical; + background-gradient-start: rgba(255,255,255,0.2); + background-gradient-end: rgba(255,255,255,0.08); + box-shadow: inset 0px 0px 1px 1px rgba(255,255,255,0.06); +} + +.calendar-event-color-strip { + width: 4px; + border-radius: 4px 0 0 4px; +} + +.calendar-event-row-content { + margin: 7px; +} + +.calendar-event-time { + color: #cccccc; + font-weight: bold; + text-align: left; + margin-bottom: .6em; +} + +.calendar-event-time:past { + color: rgba(204,204,204,.3); +} + +.calendar-event-time:all-day { + color: rgba(0,255,0,0.6); +} + +.calendar-event-time:future { +} + +.calendar-event-countdown { + color: #cccccc; + font-weight: bold; + text-align: right; + margin-bottom: .6em; +} + +.calendar-event-countdown:soon { + color: #ffffff; +} + +.calendar-event-countdown:imminent { + color: rgba(255,255,0,0.6); +} + +.calendar-event-countdown:current { + color: rgba(0,255,0,0.6); +} + +.calendar-event-summary { + color: #cccccc; + text-align: left; + width: 200px; +} + +.calendar-today-home-button { + margin: 6px 0 6px 0; + padding: 6px; + border-radius: 4px; +} + +.calendar-today-home-button:hover { + background-gradient-direction: vertical; + background-gradient-start: rgba(255,255,255,0.2); + background-gradient-end: rgba(255,255,255,0.08); + box-shadow: inset 0px 0px 1px 1px rgba(255,255,255,0.06); +} + +.calendar-today-day-label { + font-size: 1.75em; + color: #cccccc; + font-weight: bold; + text-align: center; + padding-bottom: .1em; +} + +.calendar-today-date-label { + font-size: 1.1em; + color: #cccccc; + font-weight: bold; + text-align: center; +} + .calendar { - padding: .4em 1.75em; + padding: 0 0.5em 0.4em; spacing-rows: 0px; spacing-columns: 0px; } @@ -572,16 +716,25 @@ StScrollBar StButton#vhandle:hover { .calendar-change-month-forward:active { background-color: #aaaaaa; } -.datemenu-date-label { - padding: .4em 1.75em; - font-size: 1.1em; - color: #cccccc; - font-weight: bold; + +.calendar-day-event-dot-box { + margin-top: 32px; + + /* any other way do do something like this? */ + max-rows: 2; } + +.calendar-day-event-dot { + margin: 1px; + border-radius: 2px; + width: 4px; + height: 4px; +} + .calendar-day-base { text-align: center; - width: 2.4em; - height: 2.4em; + width: 3.5em; + height: 3.5em; } .calendar-day-base:hover { background-color: #777777; @@ -634,14 +787,24 @@ StScrollBar StButton#vhandle:hover { color: #ffffff; font-weight: bold; } + +.calendar-today:selected { + background-image: url("calendar-today-selected.svg"); +} + .calendar-other-month-day { color: #888888; background-color: rgba(64, 64, 64, 0.5); } -.calendar-day-with-events { - font-weight: bold; - color: #cccccc; + +.calendar-not-today { +} + +.calendar-not-today:selected { + background-image: url("calendar-selected.svg"); } + + /* =================================================================== * Notifications * ===================================================================*/ diff --git a/debian/control b/debian/control index 461215e9fa..5a5174b8fe 100644 --- a/debian/control +++ b/debian/control @@ -55,8 +55,12 @@ Depends: gir1.2-cinnamondesktop-3.0 (>= 4.8), gir1.2-cmenu-3.0 (>= 4.8), gir1.2-cvc-1.0, + gir1.2-ecal-2.0, + gir1.2-edataserver-1.2, gir1.2-gkbd-3.0, + gir1.2-goa-1.0, gir1.2-gtkclutter-1.0, + gir1.2-ical-3.0, gir1.2-keybinder-3.0, gir1.2-meta-muffin-0.0, gir1.2-nm-1.0 [linux-any] | gir1.2-networkmanager-1.0 [linux-any], diff --git a/files/usr/share/cinnamon/applets/calendar@cinnamon.org/applet.js b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/applet.js index 38dcc5cab6..f0e4908bee 100644 --- a/files/usr/share/cinnamon/applets/calendar@cinnamon.org/applet.js +++ b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/applet.js @@ -1,5 +1,6 @@ const Applet = imports.ui.applet; const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; const Lang = imports.lang; const Clutter = imports.gi.Clutter; const St = imports.gi.St; @@ -8,8 +9,14 @@ const PopupMenu = imports.ui.popupMenu; const UPowerGlib = imports.gi.UPowerGlib; const Settings = imports.ui.settings; const Calendar = require('./calendar'); +const EventView = require('./eventView'); const CinnamonDesktop = imports.gi.CinnamonDesktop; const Main = imports.ui.main; +const Separator = imports.ui.separator; + +const DAY_FORMAT = CinnamonDesktop.WallClock.lctime_format("cinnamon", "%A"); +const DATE_FORMAT_SHORT = CinnamonDesktop.WallClock.lctime_format("cinnamon", "%B %-e, %Y"); +const DATE_FORMAT_FULL = CinnamonDesktop.WallClock.lctime_format("cinnamon", "%A, %B %-e, %Y"); String.prototype.capitalize = function() { return this.charAt(0).toUpperCase() + this.slice(1); @@ -46,18 +53,95 @@ class CinnamonCalendarApplet extends Applet.TextApplet { this._initContextMenu(); this.menu.setCustomStyleClass('calendar-background'); - // Date - this._date = new St.Label(); - this._date.style_class = 'datemenu-date-label'; - this.menu.addActor(this._date); - this.settings = new Settings.AppletSettings(this, "calendar@cinnamon.org", this.instance_id); + this.desktop_settings = new Gio.Settings({ schema_id: "org.cinnamon.desktop.interface" }); - // Calendar this.clock = new CinnamonDesktop.WallClock(); - this._calendar = new Calendar.Calendar(this.settings); + this.clock_notify_id = 0; + + // Events + this.events_manager = new EventView.EventsManager(this.settings, this.desktop_settings); + this.events_manager.connect("events-manager-ready", this._events_manager_ready.bind(this)); + this.events_manager.connect("has-calendars-changed", this._has_calendars_changed.bind(this)); + this.events_manager.connect("run-periodic-update", this._run_periodic_update.bind(this)); + + let box = new St.BoxLayout( + { + style_class: 'calendar-main-box', + vertical: false + } + ); + this.menu.addActor(box); + + this.event_list = this.events_manager.get_event_list(); + this.event_list.connect("launched-calendar", Lang.bind(this.menu, this.menu.toggle)); + + // hack to allow event list scrollbar to be dragged. + this.event_list.connect("start-pass-events", Lang.bind(this.menu, () => { + this.menu.passEvents = true; + })); + this.event_list.connect("stop-pass-events", Lang.bind(this.menu, () => { + this.menu.passEvents = false; + })); + + box.add_actor(this.event_list.actor); + + let calbox = new St.BoxLayout( + { + vertical: true + } + ); + + this.go_home_button = new St.BoxLayout( + { + style_class: "calendar-today-home-button", + x_align: Clutter.ActorAlign.CENTER, + reactive: true, + vertical: true + } + ); + + this.go_home_button.connect("enter-event", Lang.bind(this, (actor, event) => { + actor.add_style_pseudo_class("hover"); + })); + + this.go_home_button.connect("leave-event", Lang.bind(this, (actor, event) => { + actor.remove_style_pseudo_class("hover"); + })); + + this.go_home_button.connect("button-press-event", Lang.bind(this, (actor, event) => { + if (event.get_button() == Clutter.BUTTON_PRIMARY) { + // button immediately becomes non-reactive, so leave-event will never fire. + actor.remove_style_pseudo_class("hover"); + this._updateCalendar(); + return Clutter.EVENT_STOP; + } + })); + + calbox.add_actor(this.go_home_button); + + // Calendar + this._day = new St.Label( + { + style_class: "calendar-today-day-label" + } + ); + this.go_home_button.add_actor(this._day); + + // Date + this._date = new St.Label( + { + style_class: "calendar-today-date-label" + } + ); + this.go_home_button.add_actor(this._date); + + this._calendar = new Calendar.Calendar(this.settings, this.events_manager); + this._calendar.connect("selected-date-changed", Lang.bind(this, this._updateClockAndDate)); + calbox.add_actor(this._calendar.actor); + + box.add_actor(calbox); - this.menu.addActor(this._calendar.actor); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); let item = new PopupMenu.PopupMenuItem(_("Date and Time Settings")) @@ -65,8 +149,7 @@ class CinnamonCalendarApplet extends Applet.TextApplet { this.menu.addMenuItem(item); - this._dateFormatFull = CinnamonDesktop.WallClock.lctime_format("cinnamon", "%A, %B %-e, %Y"); - + this.settings.bind("show-events", "show_events", this._onSettingsChanged); this.settings.bind("use-custom-format", "use_custom_format", this._onSettingsChanged); this.settings.bind("custom-format", "custom_format", this._onSettingsChanged); this.settings.bind("keyOpen", "keyOpen", this._setKeybinding); @@ -75,7 +158,6 @@ class CinnamonCalendarApplet extends Applet.TextApplet { /* FIXME: Add gobject properties to the WallClock class to allow easier access from * its clients, and possibly a separate signal to notify of updates to these properties * (though GObject "changed" would be sufficient.) */ - this.desktop_settings = new Gio.Settings({ schema_id: "org.cinnamon.desktop.interface" }); this.desktop_settings.connect("changed::clock-use-24h", Lang.bind(this, function(key) { this._onSettingsChanged(); })); @@ -83,8 +165,6 @@ class CinnamonCalendarApplet extends Applet.TextApplet { this._onSettingsChanged(); })); - this.clock_notify_id = 0; - // https://bugzilla.gnome.org/show_bug.cgi?id=655129 this._upClient = new UPowerGlib.Client(); try { @@ -117,6 +197,8 @@ class CinnamonCalendarApplet extends Applet.TextApplet { _onSettingsChanged() { this._updateFormatString(); this._updateClockAndDate(); + this.event_list.actor.visible = this.events_manager.is_active(); + this.events_manager.select_date(this._calendar.getSelectedDate()); } on_custom_format_button_pressed() { @@ -158,6 +240,23 @@ class CinnamonCalendarApplet extends Applet.TextApplet { } } + _events_manager_ready(em) { + this.event_list.actor.visible = this.events_manager.is_active(); + // log("em ready"); + this.events_manager.select_date(this._calendar.getSelectedDate(), true); + } + + _has_calendars_changed(em) { + this.event_list.actor.visible = this.events_manager.is_active(); + this.events_manager.select_date(this._calendar.getSelectedDate()); + } + + _run_periodic_update(em) { + // log("update"); + this.event_list.actor.visible = this.events_manager.is_active(); + this.events_manager.select_date(this._calendar.getSelectedDate(), true); + } + _updateClockAndDate() { let label_string = this.clock.get_clock(); @@ -165,14 +264,19 @@ class CinnamonCalendarApplet extends Applet.TextApplet { label_string = label_string.capitalize(); } + this.go_home_button.reactive = !this._calendar.todaySelected(); + this.set_applet_label(label_string); - /* Applet content - st_label_set_text and set_applet_tooltip both compare new to - * existing strings before proceeding, so no need to check here also */ - let dateFormattedFull = this.clock.get_clock_for_format(this._dateFormatFull).capitalize(); + let dateFormattedFull = this.clock.get_clock_for_format(DATE_FORMAT_FULL).capitalize(); + let dateFormattedShort = this.clock.get_clock_for_format(DATE_FORMAT_SHORT).capitalize(); + let dayFormatted = this.clock.get_clock_for_format(DAY_FORMAT).capitalize(); - this._date.set_text(dateFormattedFull); + this._day.set_text(dayFormatted); + this._date.set_text(dateFormattedShort); this.set_applet_tooltip(dateFormattedFull); + + this.events_manager.select_date(this._calendar.getSelectedDate()); } on_applet_added_to_panel() { @@ -183,6 +287,7 @@ class CinnamonCalendarApplet extends Applet.TextApplet { } /* Populates the calendar so our menu allocation is correct for animation */ + this.events_manager.start_events(); this._updateCalendar(); } @@ -207,9 +312,7 @@ class CinnamonCalendarApplet extends Applet.TextApplet { } _updateCalendar () { - let now = new Date(); - - this._calendar.setDate(now, true); + this._calendar.setDate(new Date(), true); } on_orientation_changed (orientation) { diff --git a/files/usr/share/cinnamon/applets/calendar@cinnamon.org/calendar.js b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/calendar.js index 676fe62853..0bc7481deb 100644 --- a/files/usr/share/cinnamon/applets/calendar@cinnamon.org/calendar.js +++ b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/calendar.js @@ -2,6 +2,7 @@ const Clutter = imports.gi.Clutter; const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; const Lang = imports.lang; const St = imports.gi.St; const Signals = imports.signals; @@ -9,6 +10,7 @@ const Pango = imports.gi.Pango; const Gettext_gtk30 = imports.gettext.domain('gtk30'); const Cinnamon = imports.gi.Cinnamon; const Settings = imports.ui.settings; +const Mainloop = imports.mainloop; const MSECS_IN_DAY = 24 * 60 * 60 * 1000; const WEEKDATE_HEADER_WIDTH_DIGITS = 3; @@ -25,6 +27,13 @@ function _sameDay(dateA, dateB) { dateA.getYear() == dateB.getYear()); } +function _today(date) { + let today = new Date(); + return (date.getDate() == today.getDate() && + date.getMonth() == today.getMonth() && + date.getYear() == today.getYear()); +} + function _sameYear(dateA, dateB) { return (dateA.getYear() == dateB.getYear()); } @@ -137,16 +146,24 @@ function _dateIntervalsOverlap(a0, a1, b0, b1) } class Calendar { - constructor(settings) { + constructor(settings, events_manager) { + this.events_manager = events_manager; this._weekStart = Cinnamon.util_get_week_start(); this._weekdate = NaN; this._digitWidth = NaN; this.settings = settings; + this.update_id = 0; + this.settings.bindWithObject(this, "show-week-numbers", "show_week_numbers", this._onSettingsChange); this.desktop_settings = new Gio.Settings({ schema_id: DESKTOP_SCHEMA }); this.desktop_settings.connect("changed::" + FIRST_WEEKDAY_KEY, Lang.bind(this, this._onSettingsChange)); + this.events_enabled = true; + this.events_manager.connect("events-updated", this._events_updated.bind(this)); + this.events_manager.connect("events-manager-ready", this._update_events_enabled.bind(this)); + this.events_manager.connect("calendars-changed", this._update_events_enabled.bind(this)); + // Find the ordering for month/year in the calendar heading let var_name = 'calendar:MY'; @@ -176,6 +193,34 @@ class Calendar { this._buildHeader (); } + _events_updated(events_manager) { + this._queue_update(); + } + + _cancel_update() { + if (this.update_id > 0) { + Mainloop.source_remove(this.update_id); + this.update_id = 0; + } + } + + _queue_update() { + this._cancel_update() + + this.update_id = Mainloop.idle_add(Lang.bind(this, this._idle_do_update)); + } + + _idle_do_update() { + this.update_id = 0; + this._update(); + + return GLib.SOURCE_REMOVE; + } + + _update_events_enabled(em) { + this.events_enabled = this.events_manager.is_active() + } + _onSettingsChange(object, key, old_val, new_val) { if (key == FIRST_WEEKDAY_KEY) this._weekStart = Cinnamon.util_get_week_start(); this._buildHeader(); @@ -186,14 +231,26 @@ class Calendar { setDate(date, forceReload) { if (!_sameDay(date, this._selectedDate)) { this._selectedDate = date; + this.emit('selected-date-changed', this._selectedDate); this._update(forceReload); - this.emit('selected-date-changed', new Date(this._selectedDate)); } else { if (forceReload) this._update(forceReload); } } + getSelectedDate() { + return this._selectedDate; + } + + todaySelected() { + let today = new Date(); + + return this._selectedDate.getDate() == today.getDate() && + this._selectedDate.getMonth() == today.getMonth() && + this._selectedDate.getYear() == today.getYear(); + } + _buildHeader() { let offsetCols = this.show_week_numbers ? 1 : 0; this.actor.destroy_all_children(); @@ -336,6 +393,8 @@ class Calendar { _update(forceReload) { let now = new Date(); + this._update_events_enabled(); + this._monthLabel.text = this._selectedDate.toLocaleFormat('%OB').capitalize(); this._yearLabel.text = this._selectedDate.toLocaleFormat('%Y'); @@ -354,13 +413,40 @@ class Calendar { let iter = new Date(beginDate); let row = 2; - while (true) { - let button = new St.Button({ label: iter.getDate().toString() }); - button.reactive = false; + while (true) { + let group = new Clutter.Actor( + { + layout_manager: new Clutter.FixedLayout() + } + ); + let button = new St.Button( + { + label: iter.getDate().toString(), + } + ); + + group.add_actor(button); + + let dot_box = new Cinnamon.GenericContainer( + { + style_class: "calendar-day-event-dot-box", + constraints: new Clutter.BindConstraint( + { + source: group, + coordinate: Clutter.BindCoordinate.WIDTH + } + ) + } + ) + dot_box.connect('allocate', this._allocate_dot_box.bind(this)) + group.add_actor(dot_box); let iterStr = iter.toUTCString(); - button.connect('clicked', Lang.bind(this, function() { + button.connect('clicked', Lang.bind(this, function(b) { + if (!this.events_enabled) { + return; + } let newlySelectedDate = new Date(iterStr); this.setDate(newlySelectedDate, false); })); @@ -377,18 +463,21 @@ class Calendar { if (iter.getDay() == this._weekStart) styleClass = 'calendar-day-left ' + styleClass; - if (_sameDay(now, iter)) + if (_today(iter)) styleClass += ' calendar-today'; else if (iter.getMonth() != this._selectedDate.getMonth()) styleClass += ' calendar-other-month-day'; + else + styleClass += ' calendar-not-today'; - if (_sameDay(this._selectedDate, iter)) - button.add_style_pseudo_class('active'); + if (_sameDay(this._selectedDate, iter)) { + button.add_style_pseudo_class('selected'); + } button.style_class = styleClass; let offsetCols = this.show_week_numbers ? 1 : 0; - this.actor.add(button, + this.actor.add(group, { row: row, col: offsetCols + (7 + iter.getDay() - this._weekStart) % 7 }); if (this.show_week_numbers && iter.getDay() == 4) { @@ -398,6 +487,27 @@ class Calendar { { row: row, col: 0, y_align: St.Align.MIDDLE }); } + let color_set = this.events_manager.get_colors_for_date(iter); + + if (this.events_enabled && color_set !== null) { + let node = dot_box.get_theme_node(); + let dot_box_width = node.get_width(); + let dot_width = dot_box_width / color_set.length; + + for (let i = 0; i < color_set.length; i++) { + let color = color_set[i]; + let dot = new St.Bin( + { + style_class: "calendar-day-event-dot", + style: `background-color: ${color};`, + x_align: Clutter.ActorAlign.CENTER + } + ); + + dot_box.add_actor(dot); + } + } + iter.setTime(iter.getTime() + MSECS_IN_DAY); if (iter.getDay() == this._weekStart) { row++; @@ -409,6 +519,57 @@ class Calendar { } } } + + _allocate_dot_box (actor, box, flags) { + let children = actor.get_children(); + + if (children.length == 0) { + return; + } + + let a_dot = children[0]; + + let box_width = box.x2 - box.x1; + let box_height = box.y2 - box.y1; + let [mw, nw] = a_dot.get_preferred_width(-1); + let [mh, nh] = a_dot.get_preferred_height(-1); + + let max_children_per_row = Math.trunc(box_width / nw); + + let [found, max_rows] = actor.get_theme_node().lookup_double("max-rows", false); + + if (found) { + max_rows = Math.trunc(max_rows); + } else { + max_rows = 2; + } + + let n_rows = Math.min(max_rows, Math.ceil(children.length / max_children_per_row)); + + let dots_left = children.length; + let i = 0; + for (let dot_row = 0; dot_row < n_rows; dot_row++, dots_left -= max_children_per_row) { + let dots_this_row = Math.min(dots_left, max_children_per_row); + let total_child_width = nw * dots_this_row; + + let start_x = (box_width - total_child_width) / 2; + + let cbox = new Clutter.ActorBox(); + cbox.x1 = start_x + cbox.y1 = dot_row * nh; + cbox.x2 = cbox.x1 + nw; + cbox.y2 = cbox.y1 + nh; + + while (i < ((dot_row * max_children_per_row) + dots_this_row)) { + children[i].allocate(cbox, flags); + + cbox.x1 += nw; + cbox.x2 += nw; + + i++; + } + } + } } Signals.addSignalMethods(Calendar.prototype); diff --git a/files/usr/share/cinnamon/applets/calendar@cinnamon.org/eventView.js b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/eventView.js new file mode 100644 index 0000000000..a1eed1f046 --- /dev/null +++ b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/eventView.js @@ -0,0 +1,796 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Clutter = imports.gi.Clutter; +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const Goa = imports.gi.Goa; +const Lang = imports.lang; +const St = imports.gi.St; +const Signals = imports.signals; +const Pango = imports.gi.Pango; +const Cinnamon = imports.gi.Cinnamon; +const Settings = imports.ui.settings; +const Atk = imports.gi.Atk; +const Gtk = imports.gi.Gtk; +const CinnamonDesktop = imports.gi.CinnamonDesktop; +const Separator = imports.ui.separator; +const Main = imports.ui.main; +const Util = imports.misc.util; +const Mainloop = imports.mainloop; +const Tweener = imports.ui.tweener; + +// TODO: this is duplicated from applet.js +const DATE_FORMAT_FULL = CinnamonDesktop.WallClock.lctime_format("cinnamon", "%A, %B %-e, %Y"); + +function js_date_to_gdatetime(js_date) { + let unix = js_date.getTime() / 1000; // getTime returns ms + return GLib.DateTime.new_from_unix_local(unix); +} + +function get_date_only_from_datetime(gdatetime) { + let date_only = GLib.DateTime.new_local( + gdatetime.get_year(), + gdatetime.get_month(), + gdatetime.get_day_of_month(), 0, 0, 0 + ) + + return date_only; +} + +function get_month_year_only_from_datetime(gdatetime) { + let month_year_only = GLib.DateTime.new_local( + gdatetime.get_year(), + gdatetime.get_month(), + 1, 0, 0, 0 + ) + + return month_year_only; +} + +function format_timespan(timespan) { + let minutes = Math.floor(timespan / GLib.TIME_SPAN_MINUTE) + + if (minutes < 10) { + return ["imminent", _("Starting in a few minutes")]; + } + + if (minutes < 60) { + return ["soon", _(`Starting in ${minutes} minutes`)]; + } + + let hours = Math.floor(minutes / 60); + + if (hours > 6) { + let now = GLib.DateTime.new_now_local(); + let later = now.add_hours(hours); + + if (later.get_hour() > 18) { + return ["", _("This evening")] + } + + return ["", _("Starting later today")]; + } + + return ["", ngettext("In %d hour", "In %d hours", hours).format(hours)]; +} + + +// GLib.DateTime.equal is broken +function gdate_time_equals(dt1, dt2) { + return dt1.to_unix() === dt2.to_unix(); +} + +class EventData { + constructor(data_var) { + const [id, color, summary, all_day, start_time, end_time] = data_var.deep_unpack(); + this.id = id; + this.start = GLib.DateTime.new_from_unix_local(start_time); + this.end = GLib.DateTime.new_from_unix_local(end_time); + this.all_day = all_day; + this.duration = all_day ? -1 : this.end.difference(this.start); + + let date_only_start = get_date_only_from_datetime(this.start) + let date_only_today = get_date_only_from_datetime(GLib.DateTime.new_now_local()) + + this.is_today = gdate_time_equals(date_only_start, date_only_today); + this.summary = summary + this.color = color; + } + + equal(other_event) { + return (this.start.to_unix() === other_event.start.to_unix() && + this.end.to_unix() === other_event.end.to_unix() && + this.all_day === other_event.all_day && + this.summary === other_event.summary && + this.color === other_event.color); + } +} + +class EventDataList { + constructor(gdate_only) { + // a system timestamp, to let the event list know if the array it received + // is changed. + this.gdate_only = gdate_only + this.timestamp = 0; + this.length = 0; + this._events = {} + } + + add_or_update(event_data) { + let existing = this._events[event_data.id]; + + if (existing === undefined) { + this.length++; + } + + if (existing !== undefined && event_data.equal(existing)) { + return false;; + } + + this._events[event_data.id] = event_data; + this.timestamp = GLib.get_monotonic_time(); + + return true; + } + + delete(id) { + let existing = this._events[id]; + + if (existing === undefined) { + return false; + } + + this.length --; + delete this._events[id]; + + this.timestamp = GLib.get_monotonic_time(); + return true; + } + + get_event_list() { + let now = GLib.DateTime.new_now_local(); + + let events_as_array = []; + for (let id in this._events) { + events_as_array.push(this._events[id]) + } + + // chrono order for non-current day + events_as_array.sort((a, b) => { + return a.start.to_unix() - b.start.to_unix(); + }); + + if (!gdate_time_equals(get_date_only_from_datetime(now), this.gdate_only)) { + return events_as_array; + } + + // for the current day keep all-day events just above the current or first pending event + let all_days = [] + let final_list = [] + + for (let i = 0; i < events_as_array.length; i++) { + if (events_as_array[i].all_day) { + all_days.push(events_as_array[i]); + } + } + + all_days.reverse(); + let all_days_inserted = false; + + for (let i = events_as_array.length - 1; i >= 0; i--) { + let event = events_as_array[i]; + + if (event.all_day && all_days_inserted) { + break; + } + + if (event.end.difference(now) < 0 && !all_days_inserted) { + for (let j = 0; j < all_days.length ; j++) { + final_list.push(all_days[j]); + } + all_days_inserted = true; + } + + final_list.push(event); + } + + final_list.reverse(); + return final_list; + } + + get_colors() { + let color_set = []; + + for (let event of this.get_event_list()) { + color_set.push(event.color); + } + + return color_set; + } +} + +class EventsManager { + constructor(settings, desktop_settings) { + this.settings = settings; + this.desktop_settings = desktop_settings; + this._calendar_server = null; + this.current_month_year = null; + this.current_selected_date = null; + this.events_by_date = {}; + + this._inited = false; + this._has_calendars = false; + + this._periodic_update_timer_id = 0; + + this._reload_today_id = 0; + + this._force_reload_pending = false; + this._event_list = null; + } + + start_events() { + if (this._calendar_server == null) { + Cinnamon.CalendarServerProxy.new_for_bus( + Gio.BusType.SESSION, + // Gio.DBusProxyFlags.NONE, + Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION, + "org.cinnamon.CalendarServer", + "/org/cinnamon/CalendarServer", + null, + this._calendar_server_ready.bind(this) + ); + } + + Goa.Client.new(null, this._goa_client_new_finished.bind(this)); + } + + _goa_client_new_finished(source, res) { + try { + this.goa_client = Goa.Client.new_finish(res); + this.goa_client.connect("account-added", this._update_goa_client_has_calendars.bind(this)); + this.goa_client.connect("account-changed", this._update_goa_client_has_calendars.bind(this)); + this.goa_client.connect("account-removed", this._update_goa_client_has_calendars.bind(this)); + } catch (e) { + log("could not connect to calendar server process: " + e); + } + } + + _update_goa_client_has_calendars(client, changed_objects) { + // goa can tell us if there are any accounts with enabled + // calendars. Asking our calendar server ("has-calenders") + // is useless if the server is only on periodic updates, as + // we'd need to wait to ask it once it was 'initialized'. + // This prevents having to start another process at all, if + // there's no reason to. + + let objects = this.goa_client.get_accounts(); + let any_calendars = false; + + for (let obj of objects) { + let account = obj.get_account() + if (!account.calendar_disabled) { + any_calendars = true; + } + } + + if (any_calendars !== this._has_calendars) { + // log("has calendars: "+any_calendars) + this._has_calendars = any_calendars; + this.emit("has-calendars-changed"); + } + } + + _calendar_server_ready(obj, res) { + try { + this._calendar_server = Cinnamon.CalendarServerProxy.new_for_bus_finish(res); + + this._calendar_server.connect( + "events-added-or-updated", + this._handle_added_or_updated_events.bind(this) + ); + + this._calendar_server.connect( + "events-removed", + this._handle_removed_events.bind(this) + ); + + this._calendar_server.connect( + "client-disappeared", + this._handle_client_disappeared.bind(this) + ); + + global.settings.connect("changed::calendar-server-keep-active", this._start_periodic_timer.bind(this)); + global.settings.connect("changed::calendar-server-update-interval", this._start_periodic_timer.bind(this)); + + this._start_periodic_timer(null, null); + this._update_goa_client_has_calendars(null, null); + this._inited = true; + + this.emit("events-manager-ready"); + } catch (e) { + log("could not connect to calendar server process: " + e); + return; + } + } + + _stop_periodic_timer() { + if (this._periodic_update_timer_id > 0) { + Mainloop.source_remove(this._periodic_update_timer_id); + this._periodic_update_timer_id = 0; + } + } + + _start_periodic_timer(settings, key) { + this._stop_periodic_timer(); + + if (!global.settings.get_boolean("calendar-server-keep-active")) { + this._periodic_update_timer_id = Mainloop.timeout_add_seconds( + global.settings.get_int("calendar-server-update-interval") * 60, + this._perform_periodic_update.bind(this) + ); + } + } + + _perform_periodic_update() { + this.emit("run-periodic-update"); + + return GLib.SOURCE_CONTINUE; + } + + _handle_added_or_updated_events(server, varray) { + let changed = false; + + let events = varray.unpack() + for (let n = 0; n < events.length; n++) { + let data = new EventData(events[n]); + let gdate_only = get_date_only_from_datetime(data.start); + let hash = gdate_only.to_unix(); + + if (this.events_by_date[hash] === undefined) { + this.events_by_date[hash] = new EventDataList(gdate_only); + } + + if (this.events_by_date[hash].add_or_update(data)) { + if (gdate_time_equals(gdate_only, this.current_selected_date)) { + changed = true; + } + } + } + // log("handle added : "+changed); + if (changed) { + this._event_list.set_events(this.events_by_date[this.current_selected_date.to_unix()]); + } + + this.emit("events-updated"); + } + + _handle_removed_events(server, uids_string) { + let uids = uids_string.split("::") + for (let hash in this.events_by_date) { + let event_data_list = this.events_by_date[hash]; + + for (let uid of uids) { + event_data_list.delete(uid); + } + } + + this.queue_reload_today(false); + + this.emit("events-updated"); + } + + _handle_client_disappeared(server, uid) { + this.queue_reload_today(true); + } + + get_event_list() { + if (this._event_list !== null) { + return this._event_list; + } + + this._event_list = new EventList(this.settings, this.desktop_settings); + + return this._event_list; + } + + fetch_month_events(month_year, force) { + if (!force && this.current_month_year !== null && gdate_time_equals(month_year, this.current_month_year)) { + return; + } + // global.logTrace("fetch"); + this.current_month_year = month_year; + this.events_by_date = {}; + + // get first day of month + let day_one = get_month_year_only_from_datetime(month_year) + let week_day = day_one.get_day_of_week() + let week_start = Cinnamon.util_get_week_start(); + + // back up to the start of the week preceeding day 1 + let start = day_one.add_days( -(week_day - week_start) ) + // The calendar has 42 boxes + let end = start.add_days(42).add_seconds(-1) + + this._calendar_server.call_set_time_range(start.to_unix(), end.to_unix(), force, null, this.call_finished.bind(this)); + + // If we just started the server and asked for events here, the timer can be reset. + this._start_periodic_timer(); + } + + call_finished(server, res) { + try { + this._calendar_server.call_set_time_range_finish(res); + } catch (e) { + log(e); + } + } + + _cancel_reload_today() { + if (this._reload_today_id > 0) { + Mainloop.source_remove(this._reload_today_id); + this._reload_today_id = 0; + } + } + + queue_reload_today(force) { + this._cancel_reload_today() + + if (force) { + this._force_reload_pending = true; + } + + this._reload_today_id = Mainloop.idle_add(Lang.bind(this, this._idle_do_reload_today)); + } + + _idle_do_reload_today() { + this._reload_today_id = 0; + + this.select_date(new Date(), this._force_reload_pending); + this._force_reload_pending = false; + + return GLib.SOURCE_REMOVE; + } + + select_date(date, force) { + if (!this.is_active()) { + return; + } + + // date is a js Date(). Eventually the calendar side should use + // GDateTime, but for now we'll convert it here - it's a bit more + // useful for dealing with events. + let gdate = js_date_to_gdatetime(date); + + let gdate_only = get_date_only_from_datetime(gdate); + let month_year = get_month_year_only_from_datetime(gdate_only); + this.fetch_month_events(month_year, force); + + this._event_list.set_date(gdate); + + let delay_no_events_label = this.current_selected_date === null; + if (this.current_selected_date !== null) { + let new_month = !gdate_time_equals(get_month_year_only_from_datetime(this.current_selected_date), + get_month_year_only_from_datetime(gdate_only)) + delay_no_events_label = new_month; + } + + this.current_selected_date = gdate_only; + + let existing_event_list = this.events_by_date[gdate_only.to_unix()]; + if (existing_event_list !== undefined) { + // log("---------------cache hit"); + this._event_list.set_events(existing_event_list, delay_no_events_label); + } else { + this._event_list.set_events(null, delay_no_events_label); + } + } + + get_colors_for_date(js_date) { + let gdate = js_date_to_gdatetime(js_date); + let date_only = get_date_only_from_datetime(gdate); + + let event_data_list = this.events_by_date[date_only.to_unix()]; + + return event_data_list !== undefined ? event_data_list.get_colors() : null; + } + + is_active() { + return this._inited && + this.settings.getValue("show-events") && + this._calendar_server !== null && + this._has_calendars; + } +} +Signals.addSignalMethods(EventsManager.prototype); + +class EventList { + constructor(settings, desktop_settings) { + this.settings = settings; + this.desktop_settings = desktop_settings; + this._no_events_timeout_id = 0; + this._rows = [] + this._current_event_data_list_timestamp = 0; + + this.actor = new St.BoxLayout( + { + style_class: "calendar-events-main-box", + vertical: true, + visible: false + } + ); + + this.selected_date_label = new St.Label( + { + style_class: "calendar-events-date-label" + } + ) + this.actor.add_actor(this.selected_date_label); + + this.no_events_label = new St.Label( + { + style_class: "calendar-events-no-events-label", + text: _("Nothing scheduled!"), + visible: false, + y_align: Clutter.ActorAlign.CENTER, + y_expand: true + } + ); + this.actor.add_actor(this.no_events_label); + + this.events_box = new St.BoxLayout( + { + style_class: 'calendar-events-event-container', + vertical: true, + accessible_role: Atk.Role.LIST + } + ); + this.events_scroll_box = new St.ScrollView( + { + style_class: 'vfade calendar-events-scrollbox', + hscrollbar_policy: Gtk.PolicyType.NEVER, + vscrollbar_policy: Gtk.PolicyType.AUTOMATIC + } + ); + + let vscroll = this.events_scroll_box.get_vscroll_bar(); + vscroll.connect('scroll-start', Lang.bind(this, () => { + this.emit("start-pass-events"); + })); + vscroll.connect('scroll-stop', Lang.bind(this, () => { + this.emit("stop-pass-events"); + })); + + this.events_scroll_box.add_actor(this.events_box); + this.actor.add_actor(this.events_scroll_box); + } + + set_date(gdate) { + this.selected_date_label.set_text(gdate.format(DATE_FORMAT_FULL)); + } + + set_events(event_data_list, delay_no_events_label) { + if (event_data_list !== null && event_data_list.timestamp === this._current_event_data_list_timestamp) { + this._rows.forEach((row) => { + row.update_variations(); + }); + return; + } + + this.events_box.get_children().forEach((actor) => { + actor.destroy(); + }) + + this._rows = []; + + if (this._no_events_timeout_id > 0) { + Mainloop.source_remove(this._no_events_timeout_id); + this._no_events_timeout_id = 0; + } + + if (event_data_list === null) { + // Show the 'no events' label, but wait a little bit to give the calendar server + // to deliver some events if there are any. + if (delay_no_events_label) { + this._no_events_timeout_id = Mainloop.timeout_add(600, Lang.bind(this, function() { + this._no_events_timeout_id = 0 + this.no_events_label.show(); + return GLib.SOURCE_REMOVE; + })); + } else { + this.no_events_label.show(); + } + + this._current_event_data_list_timestamp = 0; + return; + } + + this.no_events_label.hide(); + this._current_event_data_list_timestamp = event_data_list.timestamp; + + let events = event_data_list.get_event_list(); + + let scroll_to_row = null; + let first_row_done = false; + + for (let event_data of events) { + if (first_row_done) { + this.events_box.add_actor(new Separator.Separator().actor); + } + + let row = new EventRow( + event_data, + { + use_24h: this.desktop_settings.get_boolean("clock-use-24h") + } + ); + + row.connect("view-event", Lang.bind(this, (row, uuid) => { + this.emit("launched-calendar"); + Util.trySpawn(["gnome-calendar", "--uuid", uuid], false); + })) + + this.events_box.add_actor(row.actor); + + first_row_done = true; + if (row.is_current_or_next && scroll_to_row === null) { + scroll_to_row = row; + } + + this._rows.push(row); + } + + Mainloop.idle_add(Lang.bind(this, function(row) { + let vscroll = this.events_scroll_box.get_vscroll_bar(); + + if (row != null) { + let mid_position = row.actor.y + (row.actor.height / 2) - (this.events_box.height / 2); + vscroll.get_adjustment().set_value(mid_position); + } else { + vscroll.get_adjustment().set_value(0); + } + }, scroll_to_row)); + } +} +Signals.addSignalMethods(EventList.prototype); + +class EventRow { + constructor(event, params) { + this.event = event; + this.is_current_or_next = false; + + this.actor = new St.BoxLayout( + { + style_class: "calendar-event-button", + reactive: true + } + ); + + this.actor.connect("enter-event", Lang.bind(this, () => { + this.actor.add_style_pseudo_class("hover"); + })) + + this.actor.connect("leave-event", Lang.bind(this, () => { + this.actor.remove_style_pseudo_class("hover"); + })) + + if (GLib.find_program_in_path("gnome-calendar")) { + this.actor.connect("button-press-event", Lang.bind(this, (actor, event) => { + if (event.get_button() == Clutter.BUTTON_PRIMARY) { + this.emit("view-event", this.event.id); + return Clutter.EVENT_STOP; + } + })); + } + + let color_strip = new St.Bin( + { + style_class: "calendar-event-color-strip", + style: `background-color: ${event.color};` + } + ) + + this.actor.add(color_strip); + + let vbox = new St.BoxLayout( + { + style_class: "calendar-event-row-content", + x_expand: true, + vertical: true + } + ); + this.actor.add_actor(vbox); + + let label_box = new St.BoxLayout( + { + name: "label-box", + x_expand: true + }); + vbox.add_actor(label_box); + + let time_format = "%l:%M %p" + + if (params.use_24h) { + time_format = "%H:%M" + } + + let text = null; + + if (this.event.all_day) { + text = _("All day"); + } else { + text = `${this.event.start.format(time_format)}`; + } + + this.event_time = new St.Label( + { + x_align: Clutter.ActorAlign.START, + text: text, + style_class: "calendar-event-time" + } + ); + label_box.add(this.event_time, { expand: true, x_fill: true }); + + this.countdown_label = new St.Label( + { + /// text set below + x_align: Clutter.ActorAlign.END, + style_class: "calendar-event-countdown", + } + ); + + label_box.add(this.countdown_label, { expand: true, x_fill: true }); + + let event_summary = new St.Label( + { + text: this.event.summary, + y_expand: true, + style_class: "calendar-event-summary" + } + ); + + event_summary.get_clutter_text().line_wrap = true; + event_summary.get_clutter_text().ellipsize = Pango.EllipsizeMode.NEVER; + vbox.add(event_summary, { expand: true }); + + this.update_variations(); + } + + update_variations() { + let time_until_start = this.event.start.difference(GLib.DateTime.new_now_local()) + let time_until_finish = this.event.end.difference(GLib.DateTime.new_now_local()) + + if (time_until_finish < 0) { + this.actor.set_style_pseudo_class("past"); + this.event_time.set_style_pseudo_class("past"); + + this.countdown_label.set_text(""); + } else if (time_until_start > 0) { + this.actor.set_style_pseudo_class("future"); + this.event_time.set_style_pseudo_class("future"); + + if (this.event.is_today) { + let [countdown_pclass, text] = format_timespan(time_until_start); + this.countdown_label.set_text(text); + this.countdown_label.add_style_pseudo_class(countdown_pclass); + this.is_current_or_next = !this.event.all_day; + } else { + this.countdown_label.set_text(""); + } + } else { + this.actor.set_style_pseudo_class("present"); + this.event_time.set_style_pseudo_class(""); + + if (this.event.all_day) { + this.countdown_label.set_text(""); + this.event_time.set_style_pseudo_class("all-day"); + } else { + this.countdown_label.set_text(_("In progress")); + this.countdown_label.set_style_pseudo_class("current"); + } + + this.is_current_or_next = this.event.is_today && !this.event.all_day; + } + } +} +Signals.addSignalMethods(EventRow.prototype); diff --git a/files/usr/share/cinnamon/applets/calendar@cinnamon.org/settings-schema.json b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/settings-schema.json index 86e89c8280..bcc03e71e9 100644 --- a/files/usr/share/cinnamon/applets/calendar@cinnamon.org/settings-schema.json +++ b/files/usr/share/cinnamon/applets/calendar@cinnamon.org/settings-schema.json @@ -3,6 +3,18 @@ "type": "section", "description": "Display" }, + "show-events" : { + "type" : "switch", + "default" : true, + "description": "Show calendar events", + "tooltip": "Check this to display events in the calendar." + }, + "single-calendar-event-mark" : { + "type" : "switch", + "default" : false, + "description": "Only show if a day is busy or free in the calendar view.", + "tooltip": "If this is enabled, only a simple mark will show when a day has one or more events." + }, "show-week-numbers" : { "type" : "switch", "default" : false, diff --git a/meson.build b/meson.build index 0d0caf7790..b80cf322be 100644 --- a/meson.build +++ b/meson.build @@ -18,6 +18,7 @@ pkglibdir = join_paths(libdir, meson.project_name().to_lower()) servicedir = join_paths(datadir, 'dbus-1', 'services') pkgdatadir = join_paths(datadir, meson.project_name().to_lower()) po_dir = join_paths(meson.source_root(), 'po') +dbus_services_dir = dependency('dbus-1').get_pkgconfig_variable('session_bus_services_dir', define_variable: ['datadir', datadir]) # dependencies cjs = dependency('cjs-1.0', version: '>= 4.8.0') @@ -178,5 +179,6 @@ install_subdir( strip_directory: true, ) +subdir('calendar-server') subdir('python3') subdir('install-scripts') diff --git a/src/meson.build b/src/meson.build index 015749e8db..34b130d50f 100644 --- a/src/meson.build +++ b/src/meson.build @@ -3,6 +3,12 @@ subdir('hotplug-sniffer') include_src = include_directories('.') +calendar_generated = gnome.gdbus_codegen('cinnamon-calendar', + sources: 'org.cinnamon.CalendarServer.xml', + interface_prefix: 'org.cinnamon.', + namespace: 'Cinnamon' +) + cinnamon_headers = [ 'cinnamon-app.h', 'cinnamon-app-system.h', @@ -54,6 +60,7 @@ cinnamon_sources = [ 'cinnamon-wm.c', 'cinnamon-xfixes-cursor.c', cinnamon_headers, + calendar_generated ] cinnamon_enum_types = gnome.mkenums_simple( diff --git a/src/org.cinnamon.CalendarServer.xml b/src/org.cinnamon.CalendarServer.xml new file mode 100644 index 0000000000..cf8b507005 --- /dev/null +++ b/src/org.cinnamon.CalendarServer.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file