[california] Display recurrence summary in event info, event editor: Bug #732930



commit 8714ecb518454290794422248bf7d5d95233ddcb
Author: Jim Nelson <jim yorba org>
Date:   Fri Sep 5 15:47:52 2014 -0700

    Display recurrence summary in event info, event editor: Bug #732930
    
    RecurrenceRule can now explain itself as long as the RRULE is simply
    defined.  (California will never produce an RRULE it can't explain.)
    This explanation is now displayed in the event popover, the main
    editor pane, and the recurrence editor as a guide for the user.

 po/POTFILES.in                               |    1 +
 po/POTFILES.skip                             |    1 +
 src/calendar/calendar-date.vala              |   13 +-
 src/calendar/calendar-day-of-week.vala       |   21 ++
 src/collection/collection.vala               |   16 ++
 src/component/component-recurrence-rule.vala |  321 +++++++++++++++++++++++++-
 src/host/host-create-update-event.vala       |   14 ++
 src/host/host-create-update-recurring.vala   |   54 ++++-
 src/host/host-show-event.vala                |   11 +
 src/rc/create-update-event.ui                |   98 ++++++--
 src/rc/create-update-recurring.ui            |   27 ++-
 src/rc/show-event.ui                         |   18 ++
 12 files changed, 555 insertions(+), 40 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index ccc533e..0cb24e1 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -13,6 +13,7 @@ src/calendar/calendar-date.vala
 src/calendar/calendar-exact-time-span.vala
 src/calendar/calendar.vala
 src/component/component.vala
+src/component/component-recurrence-rule.vala
 src/host/host-create-update-event.vala
 src/host/host-create-update-recurring.vala
 src/host/host-import-calendar.vala
diff --git a/po/POTFILES.skip b/po/POTFILES.skip
index 79c10da..00e9fcb 100644
--- a/po/POTFILES.skip
+++ b/po/POTFILES.skip
@@ -11,6 +11,7 @@ src/calendar/calendar-date-span.c
 src/calendar/calendar-exact-time-span.c
 src/component/component.c
 src/component/component-event.c
+src/component/component-recurrence-rule.c
 src/host/host-create-update-event.c
 src/host/host-import-calendar.c
 src/host/host-main-window.c
diff --git a/src/calendar/calendar-date.vala b/src/calendar/calendar-date.vala
index 2f8da5c..1bf41ce 100644
--- a/src/calendar/calendar-date.vala
+++ b/src/calendar/calendar-date.vala
@@ -41,10 +41,17 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
          */
         COMPACT,
         /**
-         * Indicates that the year should be included in the return date string.
+         * Indicates that the year should be included in the returned date string.
          */
         INCLUDE_YEAR,
         /**
+         * Indicates that the year should be included in the returned string if it's not the current
+         * year.
+         *
+         * { link INCLUDE_YEAR} overrides this flag to always return the year in the string.
+         */
+        INCLUDE_OTHER_YEAR,
+        /**
          * Indicates that the localized string for "Today" should not be used if the date matches
          * { link System.today}.
          */
@@ -345,12 +352,16 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
         bool compact = (flags & PrettyFlag.COMPACT) != 0;
         bool abbrev = (flags & PrettyFlag.ABBREV) != 0;
         bool with_year = (flags & PrettyFlag.INCLUDE_YEAR) != 0;
+        bool with_other_year = (flags & PrettyFlag.INCLUDE_OTHER_YEAR) != 0;
         bool no_today = (flags & PrettyFlag.NO_TODAY) != 0;
         bool no_dow = (flags & PrettyFlag.NO_DAY_OF_WEEK) != 0;
         
         if (!no_today && !with_year && equal_to(System.today))
             return _("Today");
         
+        if (!with_year && with_other_year && !year.equal_to(System.today.year))
+            with_year = true;
+        
         unowned string fmt;
         if (abbrev) {
             if (no_dow)
diff --git a/src/calendar/calendar-day-of-week.vala b/src/calendar/calendar-day-of-week.vala
index 4fc8500..0ace8c3 100644
--- a/src/calendar/calendar-day-of-week.vala
+++ b/src/calendar/calendar-day-of-week.vala
@@ -277,6 +277,27 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
         return new DayOfWeekIterator(first_of_week);
     }
     
+    public static CompareDataFunc<DayOfWeek> get_comparator_for_first_of_week(FirstOfWeek fow) {
+        switch (fow) {
+            case FirstOfWeek.MONDAY:
+                return monday_comparator;
+            
+            case FirstOfWeek.SUNDAY:
+                return sunday_comparator;
+            
+            default:
+                assert_not_reached();
+        }
+    }
+    
+    private static int monday_comparator(DayOfWeek a, DayOfWeek b) {
+        return a.value_monday - b.value_monday;
+    }
+    
+    private static int sunday_comparator(DayOfWeek a, DayOfWeek b) {
+        return a.value_sunday - b.value_sunday;
+    }
+    
     public bool equal_to(DayOfWeek other) {
         return this == other;
     }
diff --git a/src/collection/collection.vala b/src/collection/collection.vala
index 6557566..f352f6b 100644
--- a/src/collection/collection.vala
+++ b/src/collection/collection.vala
@@ -26,6 +26,22 @@ public inline bool is_empty(Gee.Collection? c) {
 }
 
 /**
+ * Returns true if the two Collections contains all the same elements and the same number of elements.
+ */
+public bool equal<G>(Gee.Collection<G>? a, Gee.Collection<G>? b) {
+    if ((a == null || b == null) && a != b)
+        return false;
+    
+    if (a == b)
+        return true;
+    
+    if (size(a) != size(b))
+        return false;
+    
+    return a.contains_all(b);
+}
+
+/**
  * Returns the size of the Collection, zero if null.
  */
 public inline int size(Gee.Collection? c) {
diff --git a/src/component/component-recurrence-rule.vala b/src/component/component-recurrence-rule.vala
index 9bd45e3..0a9286c 100644
--- a/src/component/component-recurrence-rule.vala
+++ b/src/component/component-recurrence-rule.vala
@@ -20,6 +20,14 @@ public class RecurrenceRule : BaseObject {
     public const string PROP_INTERVAL = "interval";
     public const string PROP_FIRST_OF_WEEK = "first-of-week";
     
+    private const Calendar.Date.PrettyFlag UNTIL_DATE_PRETTY_FLAGS =
+        Calendar.Date.PrettyFlag.ABBREV
+        | Calendar.Date.PrettyFlag.NO_DAY_OF_WEEK
+        | Calendar.Date.PrettyFlag.INCLUDE_OTHER_YEAR;
+    
+    private const Calendar.WallTime.PrettyFlag UNTIL_TIME_PRETTY_FLAGS =
+        Calendar.WallTime.PrettyFlag.NONE;
+    
     /**
      * Enumeration of various BY rules (BYSECOND, BYMINUTE, etc.)
      */
@@ -354,9 +362,7 @@ public class RecurrenceRule : BaseObject {
      * Encode a Gee.Map of { link Calendar.DayOfWeek} and its position into a value for
      * { link set_by_rule} when using { link ByRule.DAY}.
      *
-     * Use null for DayOfWeek and zero for position to mean "any" or "every".
-     *
-     * @see encode_day
+     * See { link encode_day} for more information about how encoding works.
      */
     public static Gee.Collection<int> encode_days(Gee.Map<Calendar.DayOfWeek?, int>? day_values) {
         if (day_values == null || day_values.size == 0)
@@ -432,6 +438,9 @@ public class RecurrenceRule : BaseObject {
     /**
      * Returns a read-only sorted set of BY rule settings for the specified { link ByRule}.
      *
+     * Note that because BYDAY rules are bit-encoded, their sorting has no relationship to their
+     * decoded values.  Callers should decode each value and sort them according to their needs.
+     *
      * See [[https://tools.ietf.org/html/rfc5545#section-3.3.10]] for information how these values
      * operate according to their associated ByRule and this RRULE's { link freq}.
      */
@@ -577,6 +586,312 @@ public class RecurrenceRule : BaseObject {
             ical_by_ar[index] = (short) iCal.RECURRENCE_ARRAY_MAX;
     }
     
+    /**
+     * Returns a natural-language string explaining the { link RecurrenceRule} for the user.
+     *
+     * Returns null if the RRULE is beyond the comprehension of this parser.
+     */
+    public string? explain(Calendar.Date start_date) {
+        switch (freq) {
+            case iCal.icalrecurrencetype_frequency.DAILY_RECURRENCE:
+                return explain_daily(ngettext("day", "%d days", interval).printf(interval));
+            
+            case iCal.icalrecurrencetype_frequency.WEEKLY_RECURRENCE:
+                return explain_weekly(ngettext("week", "%d weeks", interval).printf(interval));
+            
+            case iCal.icalrecurrencetype_frequency.MONTHLY_RECURRENCE:
+                Gee.Set<ByRule> byrules = get_active_by_rules();
+                bool has_byday = byrules.contains(ByRule.DAY);
+                bool has_bymonthday = byrules.contains(ByRule.MONTH_DAY);
+                
+                // requires one and only one
+                if (has_byday == has_bymonthday || byrules.size != 1)
+                    return null;
+                
+                string unit = ngettext("month", "%d months", interval).printf(interval);
+                
+                if (has_byday)
+                    return explain_monthly_byday(unit);
+                else
+                    return explain_monthly_bymonthday(unit);
+            
+            case iCal.icalrecurrencetype_frequency.YEARLY_RECURRENCE:
+                return explain_yearly(ngettext("year", "%d years", interval).printf(interval), start_date);
+            
+            default:
+                return null;
+        }
+        
+    }
+    
+    private string? explain_daily(string units) {
+        // only explain basic DAILY RRULEs
+        if (get_active_by_rules().size != 0)
+            return null;
+        
+        if (count > 0) {
+            // As in, "Repeats every day, 2 times"
+            return _("Repeats every %s, %s").printf(units,
+                ngettext("%d time", "%d times", count).printf(count)
+            );
+        }
+        
+        if (until_date != null) {
+            // As in, "Repeats every week until Sept. 2, 2014"
+            return _("Repeats every %s until %s").printf(units,
+                until_date.to_pretty_string(UNTIL_DATE_PRETTY_FLAGS)
+            );
+        }
+        
+        if (until_exact_time != null) {
+            // As in, "Repeats every month until Sept. 2, 2014, 8:00pm"
+            return _("Repeats every %s until %s, %s").printf(units,
+                until_exact_time.to_pretty_date_string(UNTIL_DATE_PRETTY_FLAGS),
+                until_exact_time.to_pretty_time_string(UNTIL_TIME_PRETTY_FLAGS)
+            );
+        }
+        
+        // As in, "Repeats every day"
+        return _("Repeats every %s").printf(units);
+    }
+    
+    // Use only with WEEKLY RRULEs
+    private string? explain_days_of_the_week() {
+        // Gather all the DayOfWeeks amd sort by start of week
+        Gee.TreeSet<Calendar.DayOfWeek> dows = new Gee.TreeSet<Calendar.DayOfWeek>(
+            Calendar.DayOfWeek.get_comparator_for_first_of_week(Calendar.System.first_of_week));
+        foreach (int day in get_by_rule(ByRule.DAY)) {
+            Calendar.DayOfWeek dow;
+            if (!decode_day(day, out dow, null))
+                return null;
+            
+            dows.add(dow);
+        }
+        
+        // must be at least one to work
+        if (dows.size == 0)
+            return null;
+        
+        // look for expressible patterns
+        if (dows.size == Calendar.DayOfWeek.COUNT)
+            return _("every day");
+        
+        Gee.Collection<Calendar.DayOfWeek> weekend_days =
+            from_array<Calendar.DayOfWeek>(Calendar.DayOfWeek.weekend_days).to_array_list();
+        if (Collection.equal<Calendar.DayOfWeek>(weekend_days, dows))
+            return _("the weekend");
+        
+        Gee.Collection<Calendar.DayOfWeek> weekdays =
+            from_array<Calendar.DayOfWeek>(Calendar.DayOfWeek.weekdays).to_array_list();
+        if (Collection.equal<Calendar.DayOfWeek>(weekdays, dows))
+            return _("weekdays");
+        
+        // assemble a text list of days
+        StringBuilder days_of_the_week = new StringBuilder();
+        bool first = true;
+        foreach (Calendar.DayOfWeek dow in dows) {
+            if (!first) {
+                // Separator between days of the week, i.e. "Monday, Tuesday, Wednesday"
+                days_of_the_week.append(_(", "));
+            }
+            
+            days_of_the_week.append(dow.abbrev_name);
+            first = false;
+        }
+        
+        return days_of_the_week.str;
+    }
+    
+    private string? explain_weekly(string units) {
+        // can only explain WEEKLY BYDAY rules
+        Gee.Set<ByRule> byrules = get_active_by_rules();
+        if (byrules.size != 1 || !byrules.contains(ByRule.DAY))
+            return null;
+        
+        string? days_of_the_week = explain_days_of_the_week();
+        if (String.is_empty(days_of_the_week))
+            return null;
+        
+        if (count > 0) {
+            // As in, "Repeats every week on Monday, Tuesday, 3 times"
+            return _("Repeats every %s on %s, %s").printf(units, days_of_the_week,
+                ngettext("%d time", "%d times", count).printf(count)
+            );
+        }
+        
+        if (until_date != null) {
+            // As in, "Repeats every week on Thursday until Sept. 2, 2014"
+            return _("Repeats every %s on %s until %s").printf(units, days_of_the_week,
+                until_date.to_pretty_string(UNTIL_DATE_PRETTY_FLAGS)
+            );
+        }
+        
+        if (until_exact_time != null) {
+            // As in, "Repeats every week on Friday, Saturday until Sept. 2, 2014, 8:00pm"
+            return _("Repeats every %s on %s until %s, %s").printf(units, days_of_the_week,
+                until_exact_time.to_pretty_date_string(UNTIL_DATE_PRETTY_FLAGS),
+                until_exact_time.to_pretty_time_string(UNTIL_TIME_PRETTY_FLAGS)
+            );
+        }
+        
+        // As in, "Repeats every week on Monday, Wednesday, Friday"
+        return _("Repeats every %s on %s").printf(units, days_of_the_week);
+    }
+    
+    private string? explain_monthly_byday(string units) {
+        // only support one day of the week for BYMONTHDAT RRULEs
+        Gee.Set<int> byday = get_by_rule(ByRule.DAY);
+        if (byday.size != 1)
+            return null;
+        
+        Calendar.DayOfWeek? dow;
+        int position;
+        if (!decode_day(traverse<int>(byday).first(), out dow, out position))
+            return null;
+        
+        // only support a small set of possibilites here
+        if (dow == null)
+            return null;
+        
+        string day;
+        switch (position) {
+            case 1:
+                // As in, "first Thursday of the month"
+                day = _("first %s").printf(dow.full_name);
+            break;
+            
+            case 2:
+                // As in, "second Thursday of the month"
+                day = _("second %s").printf(dow.full_name);
+            break;
+            
+            case 3:
+                // As in, "third Thursday of the month"
+                day = _("third %s").printf(dow.full_name);
+            break;
+            
+            case 4:
+                // As in, "fourth Thursday of the month"
+                day = _("fourth %s").printf(dow.full_name);
+            break;
+            
+            case 5:
+                // As in, "fifth Thursday of the month"
+                day = _("fifth %s").printf(dow.full_name);
+            break;
+            
+            case -1:
+                // As in, "last Thursday of the month"
+                day = _("last %s").printf(dow.full_name);
+            break;
+            
+            default:
+                return null;
+        }
+        
+        if (count > 0) {
+            // As in, "Repeats every month on the first Tuesday, 3 times"
+            return _("Repeats every %s on the %s, %s").printf(units, day,
+                ngettext("%d time", "%d times", count).printf(count)
+            );
+        }
+        
+        if (until_date != null) {
+            // As in, "Repeats every month on the second Monday until Sept. 2, 2014"
+            return _("Repeats every %s on the %s until %s").printf(units, day,
+                until_date.to_pretty_string(UNTIL_DATE_PRETTY_FLAGS)
+            );
+        }
+        
+        if (until_exact_time != null) {
+            // As in, "Repeats every month on the last Friday until Sept. 2, 2014, 8:00pm"
+            return _("Repeats every %s on the %s until %s, %s").printf(units, day,
+                until_exact_time.to_pretty_date_string(UNTIL_DATE_PRETTY_FLAGS),
+                until_exact_time.to_pretty_time_string(UNTIL_TIME_PRETTY_FLAGS)
+            );
+        }
+        
+        // As in, "Repeats every month on the third Tuesday"
+        return _("Repeats every %s on the %s").printf(units, day);
+    }
+    
+    private string? explain_monthly_bymonthday(string units) {
+        // only MONTHLY BYDAY RRULEs
+        Gee.Set<int> byrules = get_active_by_rules();
+        if (byrules.size != 1 || !byrules.contains(ByRule.MONTH_DAY))
+            return null;
+        
+        // currently only support one monthday (generally, the same as DTSTART)
+        Gee.Set<int> monthdays = get_by_rule(ByRule.MONTH_DAY);
+        if (monthdays.size != 1)
+            return null;
+        
+        // As in, "Repeats on day 4 of the month"
+        string day = "day %d".printf(traverse<int>(monthdays).first());
+        
+        if (count > 0) {
+            // As in, "Repeats every month on day 4, 3 times"
+            return _("Repeats every %s on %s, %s").printf(units, day,
+                ngettext("%d time", "%d times", count).printf(count)
+            );
+        }
+        
+        if (until_date != null) {
+            // As in, "Repeats every month on day 21 until Sept. 2, 2014"
+            return _("Repeats every %s on %s until %s").printf(units, day,
+                until_date.to_pretty_string(UNTIL_DATE_PRETTY_FLAGS)
+            );
+        }
+        
+        if (until_exact_time != null) {
+            // As in, "Repeats every month on day 20 until Sept. 2, 2014, 8:00pm"
+            return _("Repeats every %s on %s until %s, %s").printf(units, day,
+                until_exact_time.to_pretty_date_string(UNTIL_DATE_PRETTY_FLAGS),
+                until_exact_time.to_pretty_time_string(UNTIL_TIME_PRETTY_FLAGS)
+            );
+        }
+        
+        // As in, "Repeats every month on day 5"
+        return _("Repeats every %s on %s").printf(units, day);
+    }
+    
+    private string? explain_yearly(string units, Calendar.Date start_date) {
+        // only explain basic YEARLY RRULEs
+        if (get_active_by_rules().size != 0)
+            return null;
+        
+        string date = start_date.to_pretty_string(
+            Calendar.Date.PrettyFlag.NO_DAY_OF_WEEK
+            | Calendar.Date.PrettyFlag.NO_TODAY
+            | Calendar.Date.PrettyFlag.ABBREV
+        );
+        
+        if (count > 0) {
+            // As in, "Repeats every year on 3 March 2014, 2 times"
+            return _("Repeats every %s on %s, %s").printf(units, date,
+                ngettext("%d time", "%d times", count).printf(count)
+            );
+        }
+        
+        if (until_date != null) {
+            // As in, "Repeats every year on 3 March 2014 until Sept. 2, 2014"
+            return _("Repeats every %s on %s until %s").printf(units, date,
+                until_date.to_pretty_string(UNTIL_DATE_PRETTY_FLAGS)
+            );
+        }
+        
+        if (until_exact_time != null) {
+            // As in, "Repeats every year on 3 March 2014 until Sept. 2, 2014, 8:00pm"
+            return _("Repeats every %s on %s until %s, %s").printf(units, date,
+                until_exact_time.to_pretty_date_string(UNTIL_DATE_PRETTY_FLAGS),
+                until_exact_time.to_pretty_time_string(UNTIL_TIME_PRETTY_FLAGS)
+            );
+        }
+        
+        // As in, "Repeats every year on 3 March 2014"
+        return _("Repeats every %s on %s").printf(units, date);
+    }
+    
     public override string to_string() {
         return "RRULE %s".printf(freq.to_string());
     }
diff --git a/src/host/host-create-update-event.vala b/src/host/host-create-update-event.vala
index 5320792..94c5cb1 100644
--- a/src/host/host-create-update-event.vala
+++ b/src/host/host-create-update-event.vala
@@ -48,6 +48,9 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     private Gtk.ComboBoxText calendar_combo;
     
     [GtkChild]
+    private Gtk.Label recurring_explanation_label;
+    
+    [GtkChild]
     private Gtk.Box rotating_button_box_container;
     
     public bool is_update { get; set; default = false; }
@@ -155,6 +158,17 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         location_entry.text = event.location ?? "";
         description_textview.buffer.text = event.description ?? "";
         
+        // if RecurrenceRule.explain() returns null, means it cannot express the RRULE, which
+        // should be made clear here
+        string? explanation = null;
+        if (event.rrule != null) {
+            explanation = event.rrule.explain(event.get_event_date_span(Calendar.Timezone.local).start_date);
+            if (explanation == null)
+                explanation = _("It's complicated…");
+        }
+        
+        recurring_explanation_label.label = explanation ?? _("Never");
+        
         accept_button.label = is_update ? _("_Save") : _("C_reate");
         accept_button.use_underline = true;
         
diff --git a/src/host/host-create-update-recurring.vala b/src/host/host-create-update-recurring.vala
index 48683ee..ad49c4a 100644
--- a/src/host/host-create-update-recurring.vala
+++ b/src/host/host-create-update-recurring.vala
@@ -95,6 +95,9 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
     private Gtk.RadioButton ends_on_radiobutton;
     
     [GtkChild]
+    private Gtk.Label recurring_explanation_label;
+    
+    [GtkChild]
     private Gtk.Label warning_label;
     
     [GtkChild]
@@ -132,6 +135,10 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
         bind_property(PROP_END_DATE, end_date_button, "label", BindingFlags.SYNC_CREATE,
             transform_date_to_string);
         
+        // update recurring explanation when start/end date changes
+        notify[PROP_START_DATE].connect(on_update_explanation);
+        notify[PROP_END_DATE].connect(on_update_explanation);
+        
         // map on-day checkboxes to days of week
         on_day_checkbuttons[Calendar.DayOfWeek.SUN] = sunday_checkbutton;
         on_day_checkbuttons[Calendar.DayOfWeek.MON] = monday_checkbutton;
@@ -141,6 +148,10 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
         on_day_checkbuttons[Calendar.DayOfWeek.FRI] = friday_checkbutton;
         on_day_checkbuttons[Calendar.DayOfWeek.SAT] = saturday_checkbutton;
         
+        // updating any of them updates the recurring explanation
+        foreach (Gtk.CheckButton checkbutton in on_day_checkbuttons.values)
+            checkbutton.toggled.connect(on_update_explanation);
+        
         numeric_filter.connect_to(every_entry);
         numeric_filter.connect_to(after_entry);
         
@@ -255,6 +266,8 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
                 checkbutton.active = false;
         }
         
+        update_explanation(master.rrule, master.get_event_date_span(Calendar.Timezone.local).start_date);
+        
         // set remaining defaults if not a recurring event
         if (master.rrule == null) {
             repeats_combobox.active = Repeats.DAILY;
@@ -332,6 +345,13 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
         warning_label.visible = supported != null;
     }
     
+    private void update_explanation(Component.RecurrenceRule? rrule, Calendar.Date? start_date) {
+        string? explanation = (rrule != null && start_date != null) ? rrule.explain(start_date) : null;
+        recurring_explanation_label.label = explanation;
+        recurring_explanation_label.visible = !String.is_empty(explanation);
+        recurring_explanation_label.no_show_all = String.is_empty(explanation);
+    }
+    
     // Returns a logging string for why not reported, null if supported
     private string? is_supported_rrule() {
         // only some frequencies support, and in some of those, certain requirements
@@ -389,6 +409,11 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
     }
     
     [GtkCallback]
+    private void on_update_explanation() {
+        update_explanation(make_recurring_checkbutton.active ? make_rrule() : null, start_date);
+    }
+    
+    [GtkCallback]
     private void on_repeats_combobox_changed() {
         on_repeats_combobox_or_every_entry_changed();
     }
@@ -466,13 +491,7 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
         jump_to_card_by_name(CreateUpdateEvent.ID, event);
     }
     
-    private void update_master() {
-        if (!make_recurring_checkbutton.active) {
-            master.make_recurring(null);
-            
-            return;
-        }
-        
+    private Component.RecurrenceRule make_rrule() {
         iCal.icalrecurrencetype_frequency freq;
         switch (repeats_combobox.active) {
             case Repeats.DAILY:
@@ -527,7 +546,9 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
                 });
             }
             
-            start_date = new_start_date;
+            // avoid property change notification, as this can start a signal storm
+            if (!start_date.equal_to(new_start_date))
+                start_date = new_start_date;
         }
         
         // set start and end dates (which may actually be date-times, so use adjust)
@@ -547,8 +568,14 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
         
         if (rrule.is_monthly) {
             if (repeats_combobox.active == Repeats.DAY_OF_THE_WEEK) {
+                // if > 4th week of month, use last week position indicator, since many months don't
+                // have more than 4 weeks
+                int position = start_date.week_of(Calendar.System.first_of_week).week_of_month;
+                if (position > 4)
+                    position = -1;
+                
                 Gee.HashMap<Calendar.DayOfWeek?, int> by_day = new Gee.HashMap<Calendar.DayOfWeek?, int>();
-                by_day[start_date.day_of_week] = 
start_date.week_of(Calendar.System.first_of_week).week_of_month;
+                by_day[start_date.day_of_week] = position;
                 rrule.set_by_rule(Component.RecurrenceRule.ByRule.DAY,
                     Component.RecurrenceRule.encode_days(by_day));
             } else {
@@ -558,11 +585,18 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
             }
         }
         
+        return rrule;
+    }
+    
+    private void update_master() {
         // remove EXDATEs and RDATEs, those are not currently supported
         master.exdates = null;
         master.rdates = null;
         
-        master.make_recurring(rrule);
+        if (!make_recurring_checkbutton.active)
+            master.make_recurring(null);
+        else
+            master.make_recurring(make_rrule());
     }
 }
 
diff --git a/src/host/host-show-event.vala b/src/host/host-show-event.vala
index cc1ab59..ee252ba 100644
--- a/src/host/host-show-event.vala
+++ b/src/host/host-show-event.vala
@@ -55,6 +55,9 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
     private Gtk.Label description_text;
     
     [GtkChild]
+    private Gtk.Label recurring_explanation_label;
+    
+    [GtkChild]
     private Gtk.Box rotating_button_box_container;
     
     private new Component.Event event;
@@ -166,6 +169,14 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
         // description
         set_label(null, description_text, Markup.linkify(escape(event.description), linkify_delegate));
         
+        // recurring explanation (if appropriate)
+        string? explanation = (event.rrule != null)
+            ? event.rrule.explain(event.get_event_date_span(Calendar.Timezone.local).start_date)
+            : null;
+        recurring_explanation_label.label = explanation ?? "";
+        recurring_explanation_label.visible = !String.is_empty(explanation);
+        recurring_explanation_label.no_show_all = String.is_empty(explanation);
+        
         // if read-only, don't show Delete or Edit buttons; since they're the only two, don't show
         // the entire button box
         bool read_only = event.calendar_source != null && event.calendar_source.read_only;
diff --git a/src/rc/create-update-event.ui b/src/rc/create-update-event.ui
index 9961279..f7a8a60 100644
--- a/src/rc/create-update-event.ui
+++ b/src/rc/create-update-event.ui
@@ -84,23 +84,6 @@
           </packing>
         </child>
         <child>
-          <object class="GtkButton" id="recurring_button">
-            <property name="label" translatable="yes">Re_peats…</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">True</property>
-            <property name="margin_left">8</property>
-            <property name="use_underline">True</property>
-            <signal name="clicked" handler="on_recurring_button_clicked" 
object="CaliforniaHostCreateUpdateEvent" swapped="no"/>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="pack_type">end</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-        <child>
           <object class="GtkButton" id="edit_time_button">
             <property name="visible">True</property>
             <property name="can_focus">True</property>
@@ -119,7 +102,7 @@
           <packing>
             <property name="expand">False</property>
             <property name="fill">True</property>
-            <property name="position">2</property>
+            <property name="position">1</property>
           </packing>
         </child>
       </object>
@@ -138,7 +121,7 @@
       </object>
       <packing>
         <property name="left_attach">1</property>
-        <property name="top_attach">2</property>
+        <property name="top_attach">3</property>
         <property name="width">1</property>
         <property name="height">1</property>
       </packing>
@@ -157,7 +140,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">2</property>
+        <property name="top_attach">3</property>
         <property name="width">1</property>
         <property name="height">1</property>
       </packing>
@@ -178,7 +161,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">3</property>
+        <property name="top_attach">4</property>
         <property name="width">1</property>
         <property name="height">1</property>
       </packing>
@@ -207,7 +190,7 @@
       </object>
       <packing>
         <property name="left_attach">1</property>
-        <property name="top_attach">3</property>
+        <property name="top_attach">4</property>
         <property name="width">1</property>
         <property name="height">1</property>
       </packing>
@@ -224,7 +207,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">4</property>
+        <property name="top_attach">5</property>
         <property name="width">1</property>
         <property name="height">1</property>
       </packing>
@@ -237,7 +220,7 @@
       </object>
       <packing>
         <property name="left_attach">1</property>
-        <property name="top_attach">4</property>
+        <property name="top_attach">5</property>
         <property name="width">1</property>
         <property name="height">1</property>
       </packing>
@@ -255,10 +238,75 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">5</property>
+        <property name="top_attach">6</property>
         <property name="width">2</property>
         <property name="height">1</property>
       </packing>
     </child>
+    <child>
+      <object class="GtkLabel" id="repeats_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="label" translatable="yes">Recurrence</property>
+        <style>
+          <class name="dim-label"/>
+        </style>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkBox" id="recurring_box">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="spacing">4</property>
+        <child>
+          <object class="GtkLabel" id="recurring_explanation_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="xalign">0</property>
+            <property name="label">(none)</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="recurring_button">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Add or remove recurrences of the 
event</property>
+            <property name="relief">none</property>
+            <signal name="clicked" handler="on_recurring_button_clicked" 
object="CaliforniaHostCreateUpdateEvent" swapped="no"/>
+            <child>
+              <object class="GtkImage" id="image2">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">rotation-allowed-symbolic</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">1</property>
+        <property name="top_attach">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
   </template>
 </interface>
diff --git a/src/rc/create-update-recurring.ui b/src/rc/create-update-recurring.ui
index 1e52fc5..ae1b67a 100644
--- a/src/rc/create-update-recurring.ui
+++ b/src/rc/create-update-recurring.ui
@@ -19,6 +19,7 @@
         <property name="use_underline">True</property>
         <property name="xalign">0</property>
         <property name="draw_indicator">True</property>
+        <signal name="toggled" handler="on_update_explanation" object="CaliforniaHostCreateUpdateRecurring" 
swapped="no"/>
       </object>
       <packing>
         <property name="left_attach">0</property>
@@ -137,6 +138,7 @@
               <item id="4" translatable="yes">Yearly</item>
             </items>
             <signal name="changed" handler="on_repeats_combobox_changed" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+            <signal name="changed" handler="on_update_explanation" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
           </object>
           <packing>
             <property name="left_attach">1</property>
@@ -285,6 +287,7 @@
                 <property name="xalign">0</property>
                 <property name="active">True</property>
                 <property name="draw_indicator">True</property>
+                <signal name="toggled" handler="on_update_explanation" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
               </object>
               <packing>
                 <property name="expand">False</property>
@@ -307,6 +310,7 @@
                     <property name="xalign">0</property>
                     <property name="draw_indicator">True</property>
                     <property name="group">never_radiobutton</property>
+                    <signal name="toggled" handler="on_update_explanation" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
                   </object>
                   <packing>
                     <property name="expand">False</property>
@@ -351,6 +355,7 @@
                     <property name="active">True</property>
                     <property name="draw_indicator">True</property>
                     <property name="group">never_radiobutton</property>
+                    <signal name="toggled" handler="on_update_explanation" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
                   </object>
                   <packing>
                     <property name="expand">False</property>
@@ -367,6 +372,7 @@
                     <property name="width_chars">5</property>
                     <property name="input_purpose">number</property>
                     <signal name="changed" handler="on_after_entry_changed" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+                    <signal name="changed" handler="on_update_explanation" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
                   </object>
                   <packing>
                     <property name="expand">False</property>
@@ -431,6 +437,7 @@
                 <property name="width_chars">5</property>
                 <property name="input_purpose">number</property>
                 <signal name="changed" handler="on_every_entry_changed" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
+                <signal name="changed" handler="on_update_explanation" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
               </object>
               <packing>
                 <property name="expand">False</property>
@@ -516,7 +523,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">3</property>
+        <property name="top_attach">4</property>
         <property name="width">2</property>
         <property name="height">1</property>
       </packing>
@@ -538,6 +545,24 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
+        <property name="top_attach">3</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="recurring_explanation_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label">(empty)</property>
+        <property name="wrap">True</property>
+        <property name="max_width_chars">64</property>
+        <attributes>
+          <attribute name="weight" value="bold"/>
+        </attributes>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
         <property name="top_attach">2</property>
         <property name="width">2</property>
         <property name="height">1</property>
diff --git a/src/rc/show-event.ui b/src/rc/show-event.ui
index 29dd4e8..f7a769d 100644
--- a/src/rc/show-event.ui
+++ b/src/rc/show-event.ui
@@ -199,6 +199,24 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
+        <property name="top_attach">4</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="recurring_explanation_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">0</property>
+        <property name="label">(empty)</property>
+        <property name="wrap">True</property>
+        <attributes>
+          <attribute name="weight" value="bold"/>
+        </attributes>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
         <property name="top_attach">3</property>
         <property name="width">1</property>
         <property name="height">1</property>



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]