[california] Improved time/date widgets in Create/Update event dialog: Bug #725783



commit c448068c7165c76b1afcca40c8b60c2d50c89df0
Author: Jim Nelson <jim yorba org>
Date:   Tue Aug 5 17:05:02 2014 -0700

    Improved time/date widgets in Create/Update event dialog: Bug #725783
    
    This introduces new time/date widgets when creating or editing an
    event.  Rather than popping up over the existing window, a new card
    in the deck is shown (much like when editing recurring events)
    allowing the user to visually adjust date and time with mouse and/or
    the keyboard.
    
    This also removes the sustained duration auto-adjustment (bug #732031)

 src/Makefile.am                                 |    6 +
 src/base/base-object.vala                       |   10 +-
 src/calendar/calendar-date-span.vala            |   24 ++
 src/calendar/calendar-date.vala                 |    2 +
 src/calendar/calendar-exact-time-span.vala      |   60 ++++
 src/calendar/calendar-exact-time.vala           |   21 ++
 src/calendar/calendar-wall-time.vala            |  109 ++++++-
 src/california-resources.xml                    |    6 +
 src/component/component-event.vala              |   47 +---
 src/host/host-create-update-event.vala          |  203 +++----------
 src/host/host-create-update-recurring.vala      |   31 +--
 src/host/host-date-time-widget.vala             |  368 +++++++++++++++++++++++
 src/host/host-event-time-settings.vala          |  197 ++++++++++++
 src/host/host-main-window.vala                  |    8 +-
 src/host/host-quick-create-event.vala           |    7 +-
 src/host/host-show-event.vala                   |    3 +-
 src/rc/create-update-event.ui                   |  315 +++++++++-----------
 src/rc/create-update-recurring.ui               |    2 -
 src/rc/date-time-widget.ui                      |  250 +++++++++++++++
 src/rc/event-time-settings.ui                   |  155 ++++++++++
 src/tests/tests-calendar-exact-time.vala        |   63 ++++
 src/tests/tests-calendar-wall-time.vala         |   91 +++++--
 src/tests/tests.vala                            |    1 +
 src/toolkit/toolkit-button-connector.vala       |  148 +++------
 src/toolkit/toolkit-button-event.vala           |    4 +-
 src/toolkit/toolkit-card.vala                   |    1 -
 src/toolkit/toolkit-entry-filter-connector.vala |   87 ++++++
 src/view/week/week-day-pane.vala                |    2 +-
 src/view/week/week-grid.vala                    |   26 +-
 29 files changed, 1701 insertions(+), 546 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index 62c334a..a858b9a 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -93,6 +93,8 @@ california_VALASOURCES = \
        host/host-calendar-list-item.vala \
        host/host-create-update-event.vala \
        host/host-create-update-recurring.vala \
+       host/host-date-time-widget.vala \
+       host/host-event-time-settings.vala \
        host/host-import-calendar.vala \
        host/host-main-window.vala \
        host/host-quick-create-event.vala \
@@ -105,6 +107,7 @@ california_VALASOURCES = \
        \
        tests/tests.vala \
        tests/tests-calendar-date.vala \
+       tests/tests-calendar-exact-time.vala \
        tests/tests-calendar-month-of-year.vala \
        tests/tests-calendar-month-span.vala \
        tests/tests-calendar-wall-time.vala \
@@ -123,6 +126,7 @@ california_VALASOURCES = \
        toolkit/toolkit-deck-window.vala \
        toolkit/toolkit-editable-label.vala \
        toolkit/toolkit-entry-clear-text-connector.vala \
+       toolkit/toolkit-entry-filter-connector.vala \
        toolkit/toolkit-event-connector.vala \
        toolkit/toolkit-listbox-model.vala \
        toolkit/toolkit-motion-connector.vala \
@@ -181,6 +185,8 @@ california_RC = \
        rc/calendar-manager-list-item.ui \
        rc/create-update-event.ui \
        rc/create-update-recurring.ui \
+       rc/date-time-widget.ui \
+       rc/event-time-settings.ui \
        rc/google-authenticating.ui \
        rc/google-calendar-list.ui \
        rc/google-login.ui \
diff --git a/src/base/base-object.vala b/src/base/base-object.vala
index 97779dd..b4a391e 100644
--- a/src/base/base-object.vala
+++ b/src/base/base-object.vala
@@ -15,7 +15,15 @@ namespace California {
  */
 
 public abstract class BaseObject : Object {
-    public BaseObject() {
+    /**
+     * Returns the base class name as a string.
+     *
+     * This can be used as a dummy to_string() for { link BaseObject}s that don't carry state to
+     * report.
+     */
+    public string classname { get { return get_type().name(); } }
+    
+    protected BaseObject() {
     }
     
     /**
diff --git a/src/calendar/calendar-date-span.vala b/src/calendar/calendar-date-span.vala
index ac13827..f46f3b8 100644
--- a/src/calendar/calendar-date-span.vala
+++ b/src/calendar/calendar-date-span.vala
@@ -73,6 +73,30 @@ public class DateSpan : UnitSpan<Date> {
     }
     
     /**
+     * Returns a prettified string describing the { link Event}'s time span in as concise and
+     * economical manner possible.
+     *
+     * The supplied { link Date} pretty flags are applied to the two Date strings.  If either of
+     * the { link DateSpan} crosses a year boundary, the INCLUDE_YEAR flag is automatically added.
+     */
+    public string to_pretty_string(Calendar.Date.PrettyFlag date_flags) {
+        if (!start_date.year.equal_to(Calendar.System.today.year)
+            || !end_date.year.equal_to(Calendar.System.today.year)) {
+            date_flags |= Calendar.Date.PrettyFlag.INCLUDE_YEAR;
+        }
+        
+        if (is_same_day) {
+            // One-day event, print that date's "<full date>", including year if not
+            // current year
+            return start_date.to_pretty_string(date_flags);
+        }
+        
+        // Prints a span of dates, i.e. "Monday, January 3 to Thursday, January 6"
+        return _("%s to %s").printf(start_date.to_pretty_string(date_flags),
+            end_date.to_pretty_string(date_flags));
+    }
+    
+    /**
      * @inheritDoc
      */
     public override bool contains(Date date) {
diff --git a/src/calendar/calendar-date.vala b/src/calendar/calendar-date.vala
index 9f0e71f..2f8da5c 100644
--- a/src/calendar/calendar-date.vala
+++ b/src/calendar/calendar-date.vala
@@ -296,6 +296,8 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
     
     /**
      * Returns a { link Date} clamped between the two supplied Dates, inclusive.
+     *
+     * @see Span.clamp_between
      */
     public Date clamp(Date min, Date max) {
         GLib.Date clone = gdate;
diff --git a/src/calendar/calendar-exact-time-span.vala b/src/calendar/calendar-exact-time-span.vala
index 60aa3bd..d4632af 100644
--- a/src/calendar/calendar-exact-time-span.vala
+++ b/src/calendar/calendar-exact-time-span.vala
@@ -17,6 +17,18 @@ namespace California.Calendar {
 
 public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hashable<ExactTimeSpan> {
     /**
+     * Pretty-printing flags for { link to_pretty_string}.
+     */
+    [Flags]
+    public enum PrettyFlag {
+        NONE = 0,
+        /**
+         * Use multiple lines to format string if lengthy.
+         */
+        ALLOW_MULTILINE
+    }
+    
+    /**
      * Starting { link ExactTime} of the span.
      *
      * start_exact_time will always be earlier to or equal to { link end_exact_time}.
@@ -96,6 +108,54 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
     }
     
     /**
+     * Returns a prettified string describing the { link Event}'s time span in as concise and
+     * economical manner possible.
+     *
+     * The supplied { link Date} pretty flags are applied to the two Date strings.  If either of
+     * the { link DateSpan} crosses a year boundary, the INCLUDE_YEAR flag is automatically added.
+     */
+    public string to_pretty_string(Calendar.Date.PrettyFlag date_flags, PrettyFlag time_flags) {
+        bool allow_multiline = (time_flags & PrettyFlag.ALLOW_MULTILINE) != 0;
+        
+        if (!start_date.year.equal_to(Calendar.System.today.year)
+            || !end_date.year.equal_to(Calendar.System.today.year)) {
+            date_flags |= Calendar.Date.PrettyFlag.INCLUDE_YEAR;
+        }
+        
+        if (is_same_day) {
+            // A span of time, i.e. "3:30pm to 4:30pm"
+            string timespan = _("%s to %s").printf(
+                start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
+                end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE));
+            
+            // Single-day timed event, print "<full date>, <full start time> to <full end time>",
+            // including year if not current year
+            return "%s, %s".printf(start_date.to_pretty_string(date_flags), timespan);
+        }
+        
+        if (allow_multiline) {
+            // Multi-day timed event, print "<full time>, <full date>" on both lines,
+            // including year if either not current year
+            // Prints two full time and date strings on separate lines, i.e.:
+            // 12 January 2012, 3:30pm
+            // 13 January 2013, 6:30am
+            return _("%s, %s\n%s, %s").printf(
+                start_exact_time.to_pretty_date_string(date_flags),
+                start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
+                end_exact_time.to_pretty_date_string(date_flags),
+                end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE));
+        }
+        
+        // Prints full time and date strings on a single line, i.e.:
+        // 12 January 2012, 3:30pm to 13 January 2013, 6:30am
+        return _("%s, %s to %s, %s").printf(
+                start_exact_time.to_pretty_date_string(date_flags),
+                start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
+                end_exact_time.to_pretty_date_string(date_flags),
+                end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE));
+    }
+    
+    /**
      * Compares the { link start_exact_time} of two { link ExactTimeSpan}s.
      */
     public int compare_to(ExactTimeSpan other) {
diff --git a/src/calendar/calendar-exact-time.vala b/src/calendar/calendar-exact-time.vala
index 49ff335..d508dae 100644
--- a/src/calendar/calendar-exact-time.vala
+++ b/src/calendar/calendar-exact-time.vala
@@ -162,6 +162,27 @@ public class ExactTime : BaseObject, Gee.Comparable<ExactTime>, Gee.Hashable<Exa
     }
     
     /**
+     * Clamp the { link ExactTime} between a supplied floor and ceiling ExactTime.
+     *
+     * If null is passed for either value, it will be ignored (effectively making clamp() work like
+     * a floor() or ceiling() method).  If null is passed for both, the current ExactTime is
+     * returned.
+     *
+     * Results are indeterminate if a floor chronologically later than a ceiling is passed in.
+     */
+    public ExactTime clamp(ExactTime? floor, ExactTime? ceiling) {
+        ExactTime clamped = this;
+        
+        if (floor != null && clamped.compare_to(floor) < 0)
+            clamped = floor;
+        
+        if (ceiling != null && clamped.compare_to(ceiling) > 0)
+            clamped = ceiling;
+        
+        return clamped;
+    }
+    
+    /**
      * See DateTime.to_unix_time.
      */
     public time_t to_time_t() {
diff --git a/src/calendar/calendar-wall-time.vala b/src/calendar/calendar-wall-time.vala
index 57de06d..c68d5c2 100644
--- a/src/calendar/calendar-wall-time.vala
+++ b/src/calendar/calendar-wall-time.vala
@@ -348,56 +348,133 @@ public class WallTime : BaseObject, Gee.Comparable<WallTime>, Gee.Hashable<WallT
     /**
      * Round a unit of the { link WallTime} to a multiple of a supplied value.
      *
-     * By rounding wall-clock time, not only is the unit in question rounded down to a multiple of
+     * Supply a positive integer to round up, a negative integer to round down.
+     *
+     * By rounding wall-clock time, not only is the unit in question rounded to a multiple of
      * the supplied value, but the lesser units are truncated to zero.  Thus, 17:23:54 rounded down
      * to a multiple of 10 minutes returns 17:20:00.
      *
+     * rollover is set to true if rounding by the multiple rolls the WallTime over to the next day.
+     * Rolling back to the previous day isn't possible with this interface; rounding down any value
+     * earlier than midnight results in midnight.  Rollover can occur when rounding up.
+     *
+     * It's important to note that zero is treated as a multiple of all values.  Hence rounding
+     * 11:56:00 up to a multiple of 17 minutes will result in 12:00:00.  (In other words, don't
+     * confuse this method with { link adjust}.
+     *
      * If the { link TimeUnit} is already a multiple of the value, no change is made (although
      * there's no guarantee that the same WallTime instance will be returned, especially if the
      * lesser units are truncated).
      *
-     * A multiple of zero or a negative value is always rounded to the current WallTime.
-     *
-     * TODO: An interface to round up (which will need to deal with overflow).
+     * A multiple of zero is always rounded to the current WallTime.
      */
-    public WallTime round_down(int multiple, TimeUnit time_unit) {
-        if (multiple <= 0)
+    public WallTime round(int multiple, TimeUnit time_unit, out bool rollover) {
+        rollover = false;
+        
+        if (multiple == 0)
             return this;
         
-        // get value being manipulated
-        int current;
+        // get value being manipulated and its max value (min is always zero)
+        int current, max;
         switch (time_unit) {
             case TimeUnit.HOUR:
                 current = hour;
+                max = MAX_HOUR;
             break;
             
             case TimeUnit.MINUTE:
                 current = minute;
+                max = MAX_MINUTE;
             break;
             
             case TimeUnit.SECOND:
                 current = second;
+                max = MAX_SECOND;
+            break;
+            
+            default:
+                assert_not_reached();
+        }
+        
+        int rounded;
+        if (multiple < 0) {
+            // round down and watch for underflow (which shouldn't happen)
+            rounded = current - (current % multiple.abs());
+            assert(rounded >= 0);
+        } else {
+            assert(multiple > 0);
+            
+            // round up and watch for overflow (which can definitely happen)
+            int rem = current % multiple;
+            if (rem != 0) {
+                rounded = current + (multiple - rem);
+                if (rounded > max) {
+                    rounded = 0;
+                    rollover = true;
+                }
+            } else {
+                // no remainder then on the money
+                rounded = current;
+            }
+        }
+        
+        // construct new value and deal with rollover
+        Calendar.WallTime rounded_wall_time;
+        bool adjust_rollover = false;
+        switch (time_unit) {
+            case TimeUnit.HOUR:
+                // no adjust can be done, rollover is rollover here
+                rounded_wall_time = new WallTime(rounded, 0, 0);
+            break;
+            
+            case TimeUnit.MINUTE:
+                rounded_wall_time = new WallTime(hour, rounded, 0);
+                if (rollover)
+                    rounded_wall_time = rounded_wall_time.adjust(1, TimeUnit.HOUR, out adjust_rollover);
+            break;
+            
+            case TimeUnit.SECOND:
+                rounded_wall_time = new WallTime(hour, minute, rounded);
+                if (rollover)
+                    rounded_wall_time = rounded_wall_time.adjust(1, TimeUnit.MINUTE, out adjust_rollover);
             break;
             
             default:
                 assert_not_reached();
         }
         
-        // round down and watch for underflow (which shouldn't happen)
-        int rounded = current - (current % multiple.abs());
-        if (rounded < 0)
-            rounded = 0;
+        // handle adjustment causing rollover
+        rollover = rollover || adjust_rollover;
+        
+        return rounded_wall_time;
+    }
+    
+    /**
+     * Adjust the time by the specified amount without affecting other units.
+     *
+     * "Free adjust" is designed to work like adjusting a clock's time where each unit is disengaged
+     * from the others.  That is, if the minutes setting is adjusted from 59 to 0, the hour remains
+     * unchanged.
+     *
+     * An amount of zero returns the current { link WallTime}.
+     *
+     * @see adjust
+     */
+    public WallTime free_adjust(int amount, TimeUnit time_unit) {
+        if (amount == 0)
+            return this;
         
-        // return new value
+        // piggyback on adjust() to do the heavy lifting, then rearrange its results
+        WallTime adjusted = adjust(amount, time_unit, null);
         switch (time_unit) {
             case TimeUnit.HOUR:
-                return new WallTime(rounded, 0, 0);
+                return new WallTime(adjusted.hour, minute, second);
             
             case TimeUnit.MINUTE:
-                return new WallTime(hour, rounded, 0);
+                return new WallTime(hour, adjusted.minute, second);
             
             case TimeUnit.SECOND:
-                return new WallTime(hour, minute, rounded);
+                return new WallTime(hour, minute, adjusted.second);
             
             default:
                 assert_not_reached();
diff --git a/src/california-resources.xml b/src/california-resources.xml
index edf38ea..6d65154 100644
--- a/src/california-resources.xml
+++ b/src/california-resources.xml
@@ -25,6 +25,12 @@
         <file compressed="false">rc/create-update-recurring.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
+        <file compressed="false">rc/date-time-widget.ui</file>
+    </gresource>
+    <gresource prefix="/org/yorba/california">
+        <file compressed="false">rc/event-time-settings.ui</file>
+    </gresource>
+    <gresource prefix="/org/yorba/california">
         <file compressed="true">rc/google-authenticating.ui</file>
     </gresource>
     <gresource prefix="/org/yorba/california">
diff --git a/src/component/component-event.vala b/src/component/component-event.vala
index b4c2d34..ed3e2ed 100644
--- a/src/component/component-event.vala
+++ b/src/component/component-event.vala
@@ -244,6 +244,7 @@ public class Event : Instance, Gee.Comparable<Event> {
      *
      * This will return a DateSpan whether the Event is a DATE or DATE-TIME VEVENT.
      */
+    // TODO: Make date_span/exact_time_span a separate object
     public Calendar.DateSpan get_event_date_span(Calendar.Timezone? tz) {
         if (date_span != null)
             return date_span;
@@ -350,57 +351,23 @@ public class Event : Instance, Gee.Comparable<Event> {
      *
      * @return null if no time/date information is specified
      */
-    public string? get_event_time_pretty_string(Calendar.Timezone timezone) {
+    public string? get_event_time_pretty_string(Calendar.Date.PrettyFlag date_flags,
+        Calendar.ExactTimeSpan.PrettyFlag time_flags, Calendar.Timezone timezone) {
         if (date_span == null && exact_time_span == null)
             return null;
         
         // if any dates are not in current year, display year in all dates
-        Calendar.Date.PrettyFlag date_flags = Calendar.Date.PrettyFlag.NONE;
         Calendar.DateSpan date_span = get_event_date_span(timezone);
         if (!date_span.start_date.year.equal_to(Calendar.System.today.year)
             || !date_span.end_date.year.equal_to(Calendar.System.today.year)) {
             date_flags |= Calendar.Date.PrettyFlag.INCLUDE_YEAR;
         }
         
-        // span string is kinda tricky
-        string span;
-        if (is_all_day) {
-            if (date_span.is_same_day) {
-                // All-day one-day event, print that date's "<full date>", including year if not
-                // current year
-                span = date_span.start_date.to_pretty_string(date_flags);
-            } else {
-                // Prints a span of dates, i.e. "Monday, January 3 to Thursday, January 6"
-                span = _("%s to %s").printf(date_span.start_date.to_pretty_string(date_flags),
-                    date_span.end_date.to_pretty_string(date_flags));
-            }
-        } else {
-            Calendar.ExactTimeSpan exact_time_span = exact_time_span.to_timezone(timezone);
-            if (exact_time_span.is_same_day) {
-                // A span of time, i.e. "3:30pm to 4:30pm"
-                string timespan = _("%s to %s").printf(
-                    
exact_time_span.start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
-                    exact_time_span.end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE));
-                
-                // Single-day timed event, print "<full date>, <full start time> to <full end time>",
-                // including year if not current year
-                span = "%s, %s".printf(exact_time_span.start_date.to_pretty_string(date_flags),
-                    timespan);
-            } else {
-                // Multi-day timed event, print "<full time>, <full date>" on both lines,
-                // including year if either not current year
-                // Prints two full time and date strings on separate lines, i.e.:
-                // 12 January 2012, 3:30pm
-                // 13 January 2013, 6:30am
-                span = _("%s, %s\n%s, %s").printf(
-                    exact_time_span.start_exact_time.to_pretty_date_string(date_flags),
-                    
exact_time_span.start_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE),
-                    exact_time_span.end_exact_time.to_pretty_date_string(date_flags),
-                    exact_time_span.end_exact_time.to_pretty_time_string(Calendar.WallTime.PrettyFlag.NONE));
-            }
-        }
+        // if all day, just use the DateSpan's pretty string
+        if (is_all_day)
+            return date_span.to_pretty_string(date_flags);
         
-        return span;
+        return exact_time_span.to_timezone(timezone).to_pretty_string(date_flags, time_flags);
     }
     
     /**
diff --git a/src/host/host-create-update-event.vala b/src/host/host-create-update-event.vala
index 51aa3cd..757efb9 100644
--- a/src/host/host-create-update-event.vala
+++ b/src/host/host-create-update-event.vala
@@ -17,8 +17,6 @@ namespace California.Host {
 public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     public const string ID = "CreateUpdateEvent";
     
-    public const string PROP_SELECTED_DATE_SPAN = "selected-date-span";
-    
     private const int START_HOUR = 0;
     private const int END_HOUR = 23;
     private const int MIN_DIVISIONS = 15;
@@ -38,19 +36,7 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     private Gtk.Entry summary_entry;
     
     [GtkChild]
-    private Gtk.Button dtstart_date_button;
-    
-    [GtkChild]
-    private Gtk.ComboBoxText dtstart_time_combo;
-    
-    [GtkChild]
-    private Gtk.Button dtend_date_button;
-    
-    [GtkChild]
-    private Gtk.ComboBoxText dtend_time_combo;
-    
-    [GtkChild]
-    private Gtk.CheckButton all_day_toggle;
+    private Gtk.Label time_summary_label;
     
     [GtkChild]
     private Gtk.Entry location_entry;
@@ -64,16 +50,12 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     [GtkChild]
     private Gtk.Box rotating_button_box_container;
     
-    public Calendar.DateSpan selected_date_span { get; set; }
-    
     public bool is_update { get; set; default = false; }
     
     private new Component.Event event = new Component.Event.blank();
-    private Gee.HashMap<string, Calendar.WallTime> time_map = new Gee.HashMap<string, Calendar.WallTime>();
+    private EventTimeSettings.Message? dt = null;
     private Backing.CalendarSource? original_calendar_source;
     private Toolkit.ComboBoxTextModel<Backing.CalendarSource> calendar_model;
-    private Gtk.Button? last_date_button_touched = null;
-    private bool both_date_buttons_touched = false;
     
     private Toolkit.RotatingButtonBox rotating_button_box = new Toolkit.RotatingButtonBox();
     private Toolkit.EntryClearTextConnector summary_clear_text_connector;
@@ -86,29 +68,14 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
     private Gtk.Button cancel_recurring_button = new Gtk.Button.with_mnemonic(_("_Cancel"));
     
     public CreateUpdateEvent() {
-        // when selected_date_span updates, update date buttons as well
-        notify[PROP_SELECTED_DATE_SPAN].connect(() => {
-            dtstart_date_button.label = selected_date_span.start_date.to_standard_string();
-            dtend_date_button.label = selected_date_span.end_date.to_standard_string();
-        });
-        
         // create button is active only if summary is filled out; all other fields (so far)
         // guarantee valid values at all times
         summary_clear_text_connector = new Toolkit.EntryClearTextConnector(summary_entry);
-        summary_entry.bind_property("text-length", accept_button, "sensitive",
-            BindingFlags.SYNC_CREATE);
+        summary_entry.bind_property("text", accept_button, "sensitive", BindingFlags.SYNC_CREATE,
+            transform_summary_to_accept);
         
         location_clear_text_connector = new Toolkit.EntryClearTextConnector(location_entry);
         
-        // hide start/end time widgets if an all-day event ..."no-show-all" needed to avoid the
-        // merciless effects of show_all()
-        all_day_toggle.bind_property("active", dtstart_time_combo, "visible",
-            BindingFlags.INVERT_BOOLEAN | BindingFlags.SYNC_CREATE);
-        dtstart_time_combo.no_show_all = true;
-        all_day_toggle.bind_property("active", dtend_time_combo, "visible",
-            BindingFlags.INVERT_BOOLEAN | BindingFlags.SYNC_CREATE);
-        dtend_time_combo.no_show_all = true;
-        
         // use model to control calendars combo box
         calendar_model = new Toolkit.ComboBoxTextModel<Backing.CalendarSource>(calendar_combo,
             (cal) => cal.title);
@@ -147,7 +114,17 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         rotating_button_box.valign = Gtk.Align.END;
         rotating_button_box_container.add(rotating_button_box);
         
-        update_controls();
+        Calendar.System.instance.is_24hr_changed.connect(on_update_time_summary);
+    }
+    
+    ~CreateUpdateEvent() {
+        Calendar.System.instance.is_24hr_changed.disconnect(on_update_time_summary);
+    }
+    
+    private bool transform_summary_to_accept(Binding binding, Value source_value, ref Value target_value) {
+        target_value = summary_entry.text_length > 0 && (event != null ? event.is_valid(false) : false);
+        
+        return true;
     }
     
     public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message) {
@@ -155,7 +132,13 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         if (message == null)
             return;
         
-        event = (Component.Event) message;
+        if (message.type() == typeof(EventTimeSettings.Message)) {
+            dt = (EventTimeSettings.Message) message;
+        } else {
+            event = (Component.Event) message;
+            if (dt == null)
+                dt = new EventTimeSettings.Message.from_event(event);
+        }
         
         update_controls();
     }
@@ -166,69 +149,7 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         else
             summary_entry.text = "";
         
-        Calendar.WallTime initial_start_time, initial_end_time;
-        if (event.exact_time_span != null) {
-            all_day_toggle.active = false;
-            selected_date_span = event.exact_time_span.get_date_span();
-            initial_start_time =
-                event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
-            initial_end_time =
-                event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
-        } else if (event.date_span != null) {
-            all_day_toggle.active = true;
-            selected_date_span = event.date_span;
-            initial_start_time = Calendar.System.now.to_wall_time();
-            initial_end_time = Calendar.System.now.adjust_time(1, Calendar.TimeUnit.HOUR).to_wall_time();
-        } else {
-            all_day_toggle.active = false;
-            selected_date_span = new Calendar.DateSpan(Calendar.System.today, Calendar.System.today);
-            initial_start_time = Calendar.System.now.to_wall_time();
-            initial_end_time = Calendar.System.now.adjust_time(1, Calendar.TimeUnit.HOUR).to_wall_time();
-            
-            // set in Component.Event as well, to at least initialize it for use elsewhere while
-            // editing (such as the RRULE)
-            event.set_event_exact_time_span(new Calendar.ExactTimeSpan(
-                new Calendar.ExactTime(Calendar.Timezone.local, Calendar.System.today, initial_start_time),
-                new Calendar.ExactTime(Calendar.Timezone.local, Calendar.System.today, initial_end_time)
-            ));
-        }
-        
-        // initialize start and end time controls (as in, wall clock time)
-        Calendar.WallTime current = new Calendar.WallTime(START_HOUR, Calendar.WallTime.MIN_MINUTE, 0);
-        Calendar.WallTime end = new Calendar.WallTime(END_HOUR, Calendar.WallTime.MAX_MINUTE, 0);
-        int index = 0;
-        int dtstart_active_index = -1, dtend_active_index = -1;
-        bool rollover = false;
-        while (current.compare_to(end) <= 0 && !rollover) {
-            string fmt = current.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE);
-            
-            dtstart_time_combo.append_text(fmt);
-            dtend_time_combo.append_text(fmt);
-            
-            // use the latest time for each end of the span to initialize combo boxes, looking for
-            // exact match, otherwise taking the *next* index (to default to the future slot, not
-            // one that's past)
-            int cmp = initial_start_time.compare_to(current);
-            if (cmp == 0)
-                dtstart_active_index = index;
-            else if (cmp > 0)
-                dtstart_active_index = index + 1;
-            
-            cmp = initial_end_time.compare_to(current);
-            if (cmp == 0)
-                dtend_active_index = index;
-            else if (cmp > 0)
-                dtend_active_index = index + 1;
-            
-            index++;
-            
-            time_map.set(fmt, current);
-            current = current.adjust(MIN_DIVISIONS, Calendar.TimeUnit.MINUTE, out rollover);
-        }
-        
-        // set initial indices, careful to avoid overrun
-        dtstart_time_combo.set_active(dtstart_active_index.clamp(0, index - 1));
-        dtend_time_combo.set_active(dtend_active_index.clamp(0, index - 1));
+        on_update_time_summary();
         
         // set combo to event's calendar
         if (event.calendar_source != null) {
@@ -249,41 +170,17 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         original_calendar_source = event.calendar_source;
     }
     
-    [GtkCallback]
-    private void on_date_button_clicked(Gtk.Button button) {
-        bool is_dtstart = (button == dtstart_date_button);
-        
-        // if both buttons have been touched, go into free-selection mode with the dates, otherwise
-        // respect the original span duration
-        both_date_buttons_touched =
-            both_date_buttons_touched
-            || (last_date_button_touched != null && last_date_button_touched != button);
-        
-        Toolkit.CalendarPopup popup = new Toolkit.CalendarPopup(button,
-            is_dtstart ? selected_date_span.start_date : selected_date_span.end_date);
-        
-        popup.date_selected.connect((date) => {
-            // if both buttons touched, use free date selection, otherwise respect the original
-            // span duration
-            if (both_date_buttons_touched) {
-                selected_date_span = new Calendar.DateSpan(
-                    is_dtstart ? date : selected_date_span.start_date,
-                    !is_dtstart ? date : selected_date_span.end_date
-                );
-            } else {
-                selected_date_span = is_dtstart
-                    ? selected_date_span.adjust_start_date(date)
-                    : selected_date_span.adjust_end_date(date);
-            }
-        });
-        
-        popup.dismissed.connect(() => {
-            popup.destroy();
-        });
-        
-        popup.show_all();
-        
-        last_date_button_touched = button;
+    private void on_update_time_summary() {
+        // use the Message, not the Event, to load this up
+        time_summary_label.visible = true;
+        if (dt.date_span != null) {
+            time_summary_label.label = dt.date_span.to_pretty_string(Calendar.Date.PrettyFlag.NONE);
+        } else if (dt.exact_time_span != null) {
+            time_summary_label.label = 
dt.exact_time_span.to_timezone(Calendar.Timezone.local).to_pretty_string(
+                Calendar.Date.PrettyFlag.NONE, Calendar.ExactTimeSpan.PrettyFlag.NONE);
+        } else {
+            time_summary_label.visible = false;
+        }
     }
     
     [GtkCallback]
@@ -295,6 +192,14 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         jump_to_card_by_name(CreateUpdateRecurring.ID, event);
     }
     
+    [GtkCallback]
+    private void on_edit_time_button_clicked() {
+        if (dt == null)
+            dt = new EventTimeSettings.Message.from_event(event);
+        
+        jump_to_card_by_name(EventTimeSettings.ID, dt);
+    }
+    
     private void on_accept_button_clicked() {
         if (calendar_model.active == null)
             return;
@@ -322,13 +227,13 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
         // if updating the master, don't replace the dtstart/dtend, but do want to adjust it from
         // DATE to DATE-TIME or vice-versa
         if (!replace_dtstart) {
-            if (target.is_all_day != all_day_toggle.active) {
-                if (all_day_toggle.active) {
+            if (target.is_all_day != dt.is_all_day) {
+                if (dt.is_all_day) {
                     target.timed_to_all_day_event();
                 } else {
                     target.all_day_to_timed_event(
-                        time_map.get(dtstart_time_combo.get_active_text()),
-                        time_map.get(dtend_time_combo.get_active_text()),
+                        dt.exact_time_span.start_exact_time.to_wall_time(),
+                        dt.exact_time_span.end_exact_time.to_wall_time(),
                         Calendar.Timezone.local
                     );
                 }
@@ -337,18 +242,10 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
             return;
         }
         
-        if (all_day_toggle.active) {
-            target.set_event_date_span(selected_date_span);
-        } else {
-            target.set_event_exact_time_span(
-                new Calendar.ExactTimeSpan(
-                    new Calendar.ExactTime(Calendar.Timezone.local, selected_date_span.start_date,
-                        time_map.get(dtstart_time_combo.get_active_text())),
-                    new Calendar.ExactTime(Calendar.Timezone.local, selected_date_span.end_date,
-                        time_map.get(dtend_time_combo.get_active_text()))
-                )
-            );
-        }
+        if (dt.is_all_day)
+            target.set_event_date_span(dt.date_span);
+        else
+            target.set_event_exact_time_span(dt.exact_time_span);
     }
     
     private void create_update_event(Component.Event target, bool replace_dtstart) {
diff --git a/src/host/host-create-update-recurring.vala b/src/host/host-create-update-recurring.vala
index ce6c4eb..8d9fb7f 100644
--- a/src/host/host-create-update-recurring.vala
+++ b/src/host/host-create-update-recurring.vala
@@ -107,7 +107,7 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
     private Component.Event? master = null;
     private Gee.HashMap<Calendar.DayOfWeek, Gtk.CheckButton> on_day_checkbuttons = new Gee.HashMap<
         Calendar.DayOfWeek, Gtk.CheckButton>();
-    private bool blocking_insert_text_numbers_only_signal = false;
+    private Toolkit.EntryFilterConnector numeric_filter = new Toolkit.EntryFilterConnector.only_numeric();
     
     public CreateUpdateRecurring() {
         // "Repeating event" checkbox activates almost every other control in this dialog
@@ -141,6 +141,9 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
         on_day_checkbuttons[Calendar.DayOfWeek.FRI] = friday_checkbutton;
         on_day_checkbuttons[Calendar.DayOfWeek.SAT] = saturday_checkbutton;
         
+        numeric_filter.connect_to(every_entry);
+        numeric_filter.connect_to(after_entry);
+        
         // Ok button's sensitivity is tied to a whole-lotta controls here
         make_recurring_checkbutton.bind_property("active", ok_button, "sensitive",
             BindingFlags.SYNC_CREATE, transform_to_ok_button_sensitive);
@@ -445,32 +448,6 @@ public class CreateUpdateRecurring : Gtk.Grid, Toolkit.Card {
     }
     
     [GtkCallback]
-    private void on_insert_text_numbers_only(Gtk.Editable editable, string new_text, int new_text_length,
-        ref int position) {
-        // prevent recursion when our modified text is inserted (i.e. allow the base handler to
-        // deal new text directly)
-        if (blocking_insert_text_numbers_only_signal)
-            return;
-        
-        // filter out everything not a number
-        string numbers_only = from_string(new_text)
-            .filter(ch => ch.isdigit())
-            .to_string(ch => ch.to_string());
-        
-        // insert new text into place, ensure this handler doesn't attempt to process this
-        // modified text ... would use SignalHandler.block_by_func() and unblock_by_func(), but
-        // the bindings are ungood
-        if (!String.is_empty(numbers_only)) {
-            blocking_insert_text_numbers_only_signal = true;
-            editable.insert_text(numbers_only, numbers_only.length, ref position);
-            blocking_insert_text_numbers_only_signal = false;
-        }
-        
-        // don't let the base handler have at the original text
-        Signal.stop_emission_by_name(editable, "insert-text");
-    }
-    
-    [GtkCallback]
     private void on_cancel_button_clicked() {
         jump_back();
     }
diff --git a/src/host/host-date-time-widget.vala b/src/host/host-date-time-widget.vala
new file mode 100644
index 0000000..03f772f
--- /dev/null
+++ b/src/host/host-date-time-widget.vala
@@ -0,0 +1,368 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Host {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/date-time-widget.ui")]
+public class DateTimeWidget : Gtk.Box {
+    public const string PROP_ENABLE_TIME = "enable-time";
+    public const string PROP_ENABLE_DATE = "enable-date";
+    public const string PROP_DATE = "date";
+    public const string PROP_WALL_TIME = "wall-time";
+    public const string PROP_FLOOR = "floor";
+    public const string PROP_CEILING = "ceiling";
+    public const string PROP_OUT_OF_RANGE = "out-of-range";
+    
+    public bool enable_time { get; set; default = true; }
+    
+    public bool enable_date { get; set; default = true; }
+    
+    public Calendar.Date date { get; set; default = Calendar.System.today; }
+    
+    public Calendar.WallTime wall_time { get; set; default = Calendar.System.now.to_wall_time(); }
+    
+    public Calendar.ExactTime? floor { get; set; default = null; }
+    
+    public Calendar.ExactTime? ceiling { get; set; default = null; }
+    
+    /**
+     * Indicates if the widgets are filled-in with invalid values or is valid but out of the range
+     * of { link floor} and/or { link ceiling}.
+     */
+    public bool out_of_range { get; protected set; default = false; }
+    
+    [GtkChild]
+    private Gtk.Calendar calendar;
+    
+    [GtkChild]
+    private Gtk.Entry hour_entry;
+    
+    [GtkChild]
+    private Gtk.Label colon_label;
+    
+    [GtkChild]
+    private Gtk.Entry minutes_entry;
+    
+    [GtkChild]
+    private Gtk.Label meridiem_label;
+    
+    [GtkChild]
+    private Gtk.EventBox hour_up;
+    
+    [GtkChild]
+    private Gtk.EventBox hour_down;
+    
+    [GtkChild]
+    private Gtk.EventBox minutes_up;
+    
+    [GtkChild]
+    private Gtk.EventBox minutes_down;
+    
+    [GtkChild]
+    private Gtk.EventBox meridiem_up;
+    
+    [GtkChild]
+    private Gtk.EventBox meridiem_down;
+    
+    private Toolkit.ButtonConnector button_connector = new Toolkit.ButtonConnector();
+    private Toolkit.EntryFilterConnector numeric_filter = new Toolkit.EntryFilterConnector.only_numeric();
+    
+    public DateTimeWidget() {
+        button_connector.connect_to(hour_up);
+        button_connector.connect_to(hour_down);
+        button_connector.connect_to(minutes_up);
+        button_connector.connect_to(minutes_down);
+        button_connector.connect_to(meridiem_up);
+        button_connector.connect_to(meridiem_down);
+        
+        numeric_filter.connect_to(hour_entry);
+        numeric_filter.connect_to(minutes_entry);
+        
+        // specifically-enabled sensitivities
+        bind_bool_to_time_controls(PROP_ENABLE_TIME, iterate<Gtk.Widget>(
+            hour_up, hour_down, minutes_up, minutes_down, meridiem_up, meridiem_down,
+            hour_entry, colon_label, minutes_entry, meridiem_label));
+        
+        // set sensitivities for up/down widgets
+        foreach (Gtk.Widget widget in
+            iterate<Gtk.Widget>(hour_up, hour_down, minutes_up, minutes_down, meridiem_up, meridiem_down)) {
+            bind_property(PROP_DATE, widget, "sensitive", BindingFlags.SYNC_CREATE,
+                transform_adjustment_widget_to_sensitive);
+            bind_property(PROP_WALL_TIME, widget, "sensitive", BindingFlags.SYNC_CREATE,
+                transform_adjustment_widget_to_sensitive);
+            bind_property(PROP_FLOOR, widget, "sensitive", BindingFlags.SYNC_CREATE,
+                transform_adjustment_widget_to_sensitive);
+            bind_property(PROP_CEILING, widget, "sensitive", BindingFlags.SYNC_CREATE,
+                transform_adjustment_widget_to_sensitive);
+        }
+        
+        // update out_of_range when its dependencies change
+        bind_property(PROP_DATE, this, PROP_OUT_OF_RANGE, BindingFlags.SYNC_CREATE,
+            transform_to_out_of_range);
+        bind_property(PROP_WALL_TIME, this, PROP_OUT_OF_RANGE, BindingFlags.SYNC_CREATE,
+            transform_to_out_of_range);
+        bind_property(PROP_FLOOR, this, PROP_OUT_OF_RANGE, BindingFlags.SYNC_CREATE,
+            transform_to_out_of_range);
+        bind_property(PROP_CEILING, this, PROP_OUT_OF_RANGE, BindingFlags.SYNC_CREATE,
+            transform_to_out_of_range);
+        
+        bind_bool_to_time_controls(PROP_ENABLE_DATE, iterate<Gtk.Widget>(calendar));
+        
+        // use signal handlers to initialize widgets
+        on_date_changed();
+        on_wall_time_changed();
+        
+        connect_property_signals();
+        connect_widget_signals();
+        
+        // honor 24-hour time
+        Calendar.System.instance.is_24hr_changed.connect(system_24hr_changed);
+        system_24hr_changed();
+        
+        // GTK 3.12 requires this in order to constrain GtkEntry width, older versions were happy
+        // with width_chars alone
+#if GTK_312
+        hour_entry.max_width_chars = minutes_entry.max_width_chars = 2;
+#endif
+    }
+    
+    ~DateTimeWidget() {
+        Calendar.System.instance.is_24hr_changed.disconnect(system_24hr_changed);
+    }
+    
+    private void bind_bool_to_time_controls(string property, California.Iterable<Gtk.Widget> time_widgets) {
+        foreach (Gtk.Widget time_widget in time_widgets)
+            bind_property(property, time_widget, "sensitive", BindingFlags.SYNC_CREATE);
+    }
+    
+    // Determine if the up/down adjustments should be sensitive (if they're next value is valid)
+    private bool transform_adjustment_widget_to_sensitive(Binding binding, Value source_value,
+        ref Value target_value) {
+        int amount;
+        Calendar.TimeUnit time_unit;
+        if (!adjust_time_controls((Gtk.Widget) binding.target, out amount, out time_unit))
+            return false;
+        
+        target_value = is_valid_date_time(date, wall_time.adjust(amount, time_unit, null));
+        
+        return true;
+    }
+    
+    private bool transform_to_out_of_range(Binding binding, Value source_value, ref Value target_value) {
+        target_value = is_valid_date_time(date, wall_time);
+        
+        return true;
+    }
+    
+    private void connect_property_signals() {
+        notify[PROP_DATE].connect(on_date_changed);
+        notify[PROP_WALL_TIME].connect(on_wall_time_changed);
+    }
+    
+    private void disconnect_property_signals() {
+        notify[PROP_DATE].disconnect(on_date_changed);
+        notify[PROP_WALL_TIME].disconnect(on_wall_time_changed);
+    }
+    
+    private void connect_widget_signals() {
+        button_connector.clicked.connect(on_time_adjustment_clicked);
+        
+        calendar.day_selected.connect(on_calendar_day_selected);
+        calendar.month_changed.connect(on_calendar_month_or_year_changed);
+        calendar.next_year.connect(on_calendar_month_or_year_changed);
+        calendar.prev_year.connect(on_calendar_month_or_year_changed);
+        hour_entry.changed.connect(on_time_entry_changed);
+        minutes_entry.changed.connect(on_time_entry_changed);
+    }
+    
+    private void disconnect_widget_signals() {
+        button_connector.clicked.disconnect(on_time_adjustment_clicked);
+        
+        calendar.day_selected.disconnect(on_calendar_day_selected);
+        calendar.month_changed.disconnect(on_calendar_month_or_year_changed);
+        calendar.next_year.disconnect(on_calendar_month_or_year_changed);
+        calendar.prev_year.disconnect(on_calendar_month_or_year_changed);
+        hour_entry.changed.disconnect(on_time_entry_changed);
+        minutes_entry.changed.disconnect(on_time_entry_changed);
+    }
+    
+    private bool on_time_adjustment_clicked(Toolkit.ButtonEvent details) {
+        if (details.button != Toolkit.Button.PRIMARY)
+            return Toolkit.PROPAGATE;
+        
+        int amount;
+        Calendar.TimeUnit time_unit;
+        if (!adjust_time_controls(details.widget, out amount, out time_unit))
+            return Toolkit.PROPAGATE;
+        
+        // use free_adjust() to adjust each unit individually without affecting others
+        Calendar.WallTime new_wall_time = wall_time.free_adjust(amount, time_unit);
+        
+        // ensure it's clamped ... this assignment will update the entry fields, so don't
+        // disconnect widget signals
+        if (is_valid_date_time(date, new_wall_time))
+            wall_time = new_wall_time;
+        
+        return Toolkit.STOP;
+    }
+    
+    private bool adjust_time_controls(Gtk.Widget widget, out int amount, out Calendar.TimeUnit time_unit) {
+        if (widget == hour_up) {
+            amount = 1;
+            time_unit = Calendar.TimeUnit.HOUR;
+        } else if (widget == hour_down) {
+            amount = -1;
+            time_unit = Calendar.TimeUnit.HOUR;
+        } else if (widget == minutes_up) {
+            amount = 5;
+            time_unit = Calendar.TimeUnit.MINUTE;
+        } else if (widget == minutes_down) {
+            amount = -5;
+            time_unit = Calendar.TimeUnit.MINUTE;
+        } else if (widget == meridiem_up) {
+            amount = 12;
+            time_unit = Calendar.TimeUnit.HOUR;
+        } else if (widget == meridiem_down) {
+            amount = -12;
+            time_unit = Calendar.TimeUnit.HOUR;
+        } else {
+            amount = 0;
+            time_unit = Calendar.TimeUnit.HOUR;
+            
+            return false;
+        }
+        
+        return true;
+    }
+    
+    private bool is_valid_date_time(Calendar.Date proposed_date, Calendar.WallTime proposed_time) {
+        Calendar.ExactTime exact_time = new Calendar.ExactTime(Calendar.Timezone.local, proposed_date,
+            proposed_time);
+        
+        return exact_time.clamp(floor, ceiling).equal_to(exact_time);
+    }
+    
+    private Calendar.Date? get_selected_date() {
+        if (calendar.day == 0)
+            return null;
+        
+        try {
+            return new Calendar.Date(
+                Calendar.DayOfMonth.for(calendar.day),
+                Calendar.Month.for(calendar.month + 1),
+                new Calendar.Year(calendar.year)
+            );
+        } catch (CalendarError calerr) {
+            debug("Unable to generate date from Gtk.Calendar: %s", calerr.message);
+            
+            return null;
+        }
+    }
+    
+    private void on_calendar_day_selected() {
+        disconnect_property_signals();
+        
+        Calendar.Date? selected = get_selected_date();
+        if (selected != null && is_valid_date_time(selected, wall_time) && !selected.equal_to(date))
+            date = selected;
+        
+        // even if user picked invalid date, this resets selection to valid one
+        on_date_changed();
+        
+        connect_property_signals();
+    }
+    
+    private void on_calendar_month_or_year_changed() {
+        // If selected month/year is not for the current date, don't select the day of that month/year
+        // ... if selected month/year is for the current date, ensure that the day is selected ...
+        // and as a fallback, don't select the day of the month/year
+        Calendar.Date? selected = get_selected_date();
+        if (selected != null) {
+            if (selected.month_of_year().equal_to(date.month_of_year()))
+                calendar.day = date.day_of_month.value;
+            else
+                calendar.day = 0;
+        } else if (date.month.value == (calendar.month + 1) && date.year.value == calendar.year) {
+            calendar.day = date.day_of_month.value;
+        } else {
+            calendar.day = 0;
+        }
+    }
+    
+    private void on_date_changed() {
+        disconnect_widget_signals();
+        
+        calendar.day = date.day_of_month.value;
+        calendar.month = date.month.value - 1;
+        calendar.year = date.year.value;
+        
+        connect_widget_signals();
+    }
+    
+    private void on_wall_time_changed() {
+        disconnect_widget_signals();
+        
+        hour_entry.text = "%d".printf(Calendar.System.is_24hr ? wall_time.hour : wall_time.12hour);
+        minutes_entry.text = "%02d".printf(wall_time.minute);
+        meridiem_label.label = wall_time.is_pm ? Calendar.FMT_PM : Calendar.FMT_AM;
+        
+        connect_widget_signals();
+    }
+    
+    private void on_time_entry_changed() {
+        Calendar.WallTime new_wall_time;
+        bool valid = validate_time_entries(out new_wall_time);
+        
+        disconnect_property_signals();
+        
+        out_of_range = !valid;
+        if (valid)
+            wall_time = new_wall_time;
+        
+        connect_property_signals();
+    }
+    
+    private bool validate_time_entries(out Calendar.WallTime new_wall_time) {
+        // maintain current until validated
+        new_wall_time = wall_time;
+        
+        if (String.is_empty(hour_entry.text) || String.is_empty(minutes_entry.text))
+            return false;
+        
+        int hour = int.parse(hour_entry.text);
+        if (!Calendar.System.is_24hr && meridiem_label.label == Calendar.FMT_PM && hour < 12)
+            hour += 12;
+        
+        int min = int.parse(minutes_entry.text);
+        
+        if (hour > Calendar.WallTime.MAX_HOUR || hour < 0 || min > Calendar.WallTime.MAX_MINUTE || min < 0)
+            return false;
+        
+        Calendar.WallTime entry_wall_time = new Calendar.WallTime(hour, min, 0);
+        
+        Calendar.ExactTime entry_time = new Calendar.ExactTime(Calendar.Timezone.local, date, 
entry_wall_time);
+        if (floor != null && entry_time.compare_to(floor) < 0)
+            return false;
+        
+        if (ceiling != null && entry_time.compare_to(ceiling) > 0)
+            return false;
+        
+        new_wall_time = entry_wall_time;
+        
+        return true;
+    }
+    
+    private void system_24hr_changed() {
+        meridiem_label.visible = meridiem_up.visible = meridiem_down.visible = !Calendar.System.is_24hr;
+        meridiem_label.no_show_all = meridiem_up.no_show_all = meridiem_down.no_show_all = 
Calendar.System.is_24hr;
+        
+        // redo time widgets
+        on_wall_time_changed();
+    }
+}
+
+}
+
diff --git a/src/host/host-event-time-settings.vala b/src/host/host-event-time-settings.vala
new file mode 100644
index 0000000..9440ce2
--- /dev/null
+++ b/src/host/host-event-time-settings.vala
@@ -0,0 +1,197 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Host {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/event-time-settings.ui")]
+public class EventTimeSettings : Gtk.Box, Toolkit.Card {
+    public const string ID = "CaliforniaHostEventTimeSettings";
+    
+    public class Message : Object {
+        public Calendar.DateSpan? date_span { get; private set; default = null; }
+        
+        public Calendar.ExactTimeSpan? exact_time_span { get; private set; default = null; }
+        
+        public bool is_all_day { get { return exact_time_span == null; } }
+        
+        public Message.for_date_span(Calendar.DateSpan date_span) {
+            reset_date_span(date_span);
+        }
+        
+        public Message.for_exact_time_span(Calendar.ExactTimeSpan exact_time_span) {
+            reset_exact_time_span(exact_time_span);
+        }
+        
+        public Message.from_event(Component.Event event) {
+            if (event.is_all_day)
+                reset_date_span(event.date_span);
+            else
+                reset_exact_time_span(event.exact_time_span);
+        }
+        
+        public void reset_date_span(Calendar.DateSpan date_span) {
+            this.date_span = date_span;
+            exact_time_span = null;
+        }
+        
+        public void reset_exact_time_span(Calendar.ExactTimeSpan exact_time_span) {
+            date_span = null;
+            this.exact_time_span = exact_time_span;
+        }
+        
+        public Calendar.DateSpan get_event_date_span(Calendar.Timezone? tz) {
+        if (date_span != null)
+            return date_span;
+        
+        return new Calendar.DateSpan.from_exact_time_span(
+            tz != null ? exact_time_span.to_timezone(tz) : exact_time_span);
+        }
+    }
+    
+    [GtkChild]
+    private Gtk.Label summary_label;
+    
+    [GtkChild]
+    private Gtk.Box from_box;
+    
+    [GtkChild]
+    private Gtk.Box to_box;
+    
+    [GtkChild]
+    private Gtk.CheckButton all_day_checkbutton;
+    
+    [GtkChild]
+    private Gtk.Button ok_button;
+    
+    public string card_id { get { return ID; } }
+    public string? title { get { return null; } }
+    public Gtk.Widget? default_widget { get { return null; } }
+    public Gtk.Widget? initial_focus { get { return null; } }
+    
+    private Message? message = null;
+    private DateTimeWidget from_widget = new DateTimeWidget();
+    private DateTimeWidget to_widget = new DateTimeWidget();
+    
+    public EventTimeSettings() {
+        // need to manually pack the date/time widgets
+        from_box.pack_start(from_widget);
+        to_box.pack_start(to_widget);
+        
+        from_widget.notify[DateTimeWidget.PROP_DATE].connect(on_from_changed);
+        from_widget.notify[DateTimeWidget.PROP_WALL_TIME].connect(on_from_changed);
+        to_widget.notify[DateTimeWidget.PROP_DATE].connect(on_to_changed);
+        to_widget.notify[DateTimeWidget.PROP_WALL_TIME].connect(on_to_changed);
+        all_day_checkbutton.notify["active"].connect(on_update_summary);
+        
+        from_widget.bind_property(DateTimeWidget.PROP_OUT_OF_RANGE, ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_oor_to_sensitive);
+        to_widget.bind_property(DateTimeWidget.PROP_OUT_OF_RANGE, ok_button, "sensitive",
+            BindingFlags.SYNC_CREATE, transform_oor_to_sensitive);
+        
+        all_day_checkbutton.bind_property("active", from_widget, DateTimeWidget.PROP_ENABLE_TIME,
+            BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN);
+        all_day_checkbutton.bind_property("active", to_widget, DateTimeWidget.PROP_ENABLE_TIME,
+            BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN);
+        
+        Calendar.System.instance.is_24hr_changed.connect(on_update_summary);
+    }
+    
+    ~EventTimeSettings() {
+        Calendar.System.instance.is_24hr_changed.disconnect(on_update_summary);
+    }
+    
+    private bool transform_oor_to_sensitive(Binding binding, Value source_value, ref Value target_value) {
+        target_value = !to_widget.out_of_range && !from_widget.out_of_range;
+        
+        return true;
+    }
+    
+    public void jumped_to(Toolkit.Card? from, Toolkit.Card.Jump reason, Value? message_value) {
+        message = (Message) message_value;
+        
+        Calendar.DateSpan date_span = message.get_event_date_span(Calendar.Timezone.local);
+        from_widget.date = date_span.start_date;
+        to_widget.date = date_span.end_date;
+        
+        // only set wall time if not all day; let old wall times float so user can return to them
+        // later while Deck is active
+        if (message.exact_time_span != null) {
+            Calendar.ExactTimeSpan time_span = message.exact_time_span.to_timezone(Calendar.Timezone.local);
+            from_widget.wall_time = time_span.start_exact_time.to_wall_time();
+            to_widget.wall_time = time_span.end_exact_time.to_wall_time();
+        } else {
+            // set to defaults in case user wants to change from all-day to timed event
+            from_widget.wall_time = Calendar.System.now.to_wall_time().round(15, Calendar.TimeUnit.MINUTE,
+                null);
+            if (date_span.is_same_day) {
+                // one-hour event is default
+                to_widget.wall_time = from_widget.wall_time.adjust(1, Calendar.TimeUnit.HOUR, null);
+            } else {
+                // different days, same time on each day
+                to_widget.wall_time = from_widget.wall_time;
+            }
+        }
+        
+        all_day_checkbutton.active = (message.exact_time_span == null);
+    }
+    
+    [GtkCallback]
+    private void on_cancel_button_clicked() {
+        jump_back();
+    }
+    
+    [GtkCallback]
+    private void on_ok_button_clicked() {
+        if (all_day_checkbutton.active)
+            message.reset_date_span(get_date_span());
+        else
+            message.reset_exact_time_span(get_exact_time_span());
+        
+        jump_to_card_by_name(CreateUpdateEvent.ID, message);
+    }
+    
+    // This does not respect the all-day checkbox
+    private Calendar.DateSpan get_date_span() {
+        return new Calendar.DateSpan(from_widget.date, to_widget.date);
+    }
+    
+    // This does not respect the all-day checkbox
+    private Calendar.ExactTimeSpan get_exact_time_span() {
+        return new Calendar.ExactTimeSpan(
+            new Calendar.ExactTime(Calendar.System.timezone, from_widget.date, from_widget.wall_time),
+            new Calendar.ExactTime(Calendar.System.timezone, to_widget.date, to_widget.wall_time)
+        );
+    }
+    
+    private void on_update_summary() {
+        Calendar.Date.PrettyFlag date_flags = Calendar.Date.PrettyFlag.NONE;
+        Calendar.ExactTimeSpan.PrettyFlag time_flags = Calendar.ExactTimeSpan.PrettyFlag.NONE;
+        
+        if (all_day_checkbutton.active)
+            summary_label.label = get_date_span().to_pretty_string(date_flags);
+        else
+            summary_label.label = get_exact_time_span().to_pretty_string(date_flags, time_flags);
+    }
+    
+    private void on_from_changed() {
+        // clamp to_widget to not allow earlier date/times than from_widget
+        to_widget.floor = new Calendar.ExactTime(Calendar.System.timezone, from_widget.date,
+            from_widget.wall_time);
+        
+        on_update_summary();
+    }
+    
+    private void on_to_changed() {
+        // clamp from_widget to not allow later date/times than to_widget
+        from_widget.ceiling = new Calendar.ExactTime(Calendar.System.timezone, to_widget.date,
+            to_widget.wall_time);
+        
+        on_update_summary();
+    }
+}
+
+}
+
diff --git a/src/host/host-main-window.vala b/src/host/host-main-window.vala
index a753a18..5ad0fc7 100644
--- a/src/host/host-main-window.vala
+++ b/src/host/host-main-window.vala
@@ -435,9 +435,11 @@ public class MainWindow : Gtk.ApplicationWindow {
         
         CreateUpdateRecurring create_update_recurring = new CreateUpdateRecurring();
         
+        EventTimeSettings event_time_settings = new EventTimeSettings();
+        
         Toolkit.Deck deck = new Toolkit.Deck();
         deck.add_cards(
-            iterate<Toolkit.Card>(quick_create, create_update, create_update_recurring)
+            iterate<Toolkit.Card>(quick_create, create_update, create_update_recurring, event_time_settings)
             .to_array_list()
         );
         
@@ -456,9 +458,11 @@ public class MainWindow : Gtk.ApplicationWindow {
         
         CreateUpdateRecurring create_update_recurring = new CreateUpdateRecurring();
         
+        EventTimeSettings event_time_settings = new EventTimeSettings();
+        
         Toolkit.Deck deck = new Toolkit.Deck();
         deck.add_cards(
-            iterate<Toolkit.Card>(show_event, create_update, create_update_recurring)
+            iterate<Toolkit.Card>(show_event, create_update, create_update_recurring, event_time_settings)
             .to_array_list()
         );
         
diff --git a/src/host/host-quick-create-event.vala b/src/host/host-quick-create-event.vala
index d7d6da0..5d9c5a9 100644
--- a/src/host/host-quick-create-event.vala
+++ b/src/host/host-quick-create-event.vala
@@ -69,7 +69,8 @@ public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
         string eg;
         if (event != null && (event.date_span != null || event.exact_time_span != null)) {
             when_box.visible = true;
-            when_text_label.label = event.get_event_time_pretty_string(Calendar.Timezone.local);
+            when_text_label.label = event.get_event_time_pretty_string(Calendar.Date.PrettyFlag.NONE,
+                Calendar.ExactTimeSpan.PrettyFlag.ALLOW_MULTILINE, Calendar.Timezone.local);
             if (event.date_span != null)
                 eg = _("Example: Dinner at Tadich Grill 7:30pm");
             else
@@ -137,6 +138,10 @@ public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
         if (event == null)
             event = new Component.Event.blank();
         
+        // ensure it's at least valid
+        if (!event.is_valid(false))
+            event.set_event_date_span(Calendar.System.today.to_date_span());
+        
         // jump to Create/Update dialog and remove this Card from the Deck ... this ensures
         // that if the user presses Cancel in the Create/Update dialog the Deck exits rather
         // than returns here (via jump_home_or_user_closed())
diff --git a/src/host/host-show-event.vala b/src/host/host-show-event.vala
index 663130a..ea4d5be 100644
--- a/src/host/host-show-event.vala
+++ b/src/host/host-show-event.vala
@@ -118,7 +118,8 @@ public class ShowEvent : Gtk.Grid, Toolkit.Card {
         set_label(where_label, where_text, event.location);
         
         // time
-        set_label(when_label, when_text, event.get_event_time_pretty_string(Calendar.Timezone.local));
+        set_label(when_label, when_text, event.get_event_time_pretty_string(Calendar.Date.PrettyFlag.NONE,
+            Calendar.ExactTimeSpan.PrettyFlag.NONE, Calendar.Timezone.local));
         
         // description
         set_label(null, description_text, Markup.linkify(escape(event.description), linkify_delegate));
diff --git a/src/rc/create-update-event.ui b/src/rc/create-update-event.ui
index 92b2637..9961279 100644
--- a/src/rc/create-update-event.ui
+++ b/src/rc/create-update-event.ui
@@ -10,8 +10,8 @@
     <property name="margin_right">8</property>
     <property name="margin_top">8</property>
     <property name="margin_bottom">8</property>
-    <property name="row_spacing">8</property>
-    <property name="column_homogeneous">True</property>
+    <property name="row_spacing">6</property>
+    <property name="column_spacing">6</property>
     <child>
       <object class="GtkEntry" id="summary_entry">
         <property name="visible">True</property>
@@ -25,23 +25,57 @@
         <property name="placeholder_text" translatable="yes">Untitled event</property>
       </object>
       <packing>
+        <property name="left_attach">1</property>
+        <property name="top_attach">0</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="summary_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="label" translatable="yes">Summary</property>
+        <style>
+          <class name="dim-label"/>
+        </style>
+      </object>
+      <packing>
         <property name="left_attach">0</property>
         <property name="top_attach">0</property>
-        <property name="width">2</property>
+        <property name="width">1</property>
         <property name="height">1</property>
       </packing>
     </child>
     <child>
-      <object class="GtkBox" id="dt_selection_box">
+      <object class="GtkLabel" id="time_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="label" translatable="yes">Time</property>
+        <style>
+          <class name="dim-label"/>
+        </style>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkBox" id="time_box">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
         <property name="spacing">4</property>
-        <property name="baseline_position">top</property>
         <child>
-          <object class="GtkLabel" id="from_label">
+          <object class="GtkLabel" id="time_summary_label">
             <property name="visible">True</property>
             <property name="can_focus">False</property>
-            <property name="label" translatable="yes" comments="As in &quot;From &lt;date&gt; &lt;time&gt; 
to &lt;date&gt; &lt;time&gt;&quot;">From</property>
+            <property name="label">(none)</property>
+            <property name="selectable">True</property>
           </object>
           <packing>
             <property name="expand">False</property>
@@ -50,208 +84,148 @@
           </packing>
         </child>
         <child>
-          <object class="GtkButton" id="dtstart_date_button">
-            <property name="label">dtstart</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">False</property>
-            <property name="xalign">0.52999997138977051</property>
-            <signal name="clicked" handler="on_date_button_clicked" object="CaliforniaHostCreateUpdateEvent" 
swapped="no"/>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkComboBoxText" id="dtstart_time_combo">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">2</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkLabel" id="to_label">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="label" translatable="yes" comments="As in &quot;From &lt;date&gt; &lt;time&gt; 
to &lt;date&gt; &lt;time&gt;&quot;">to</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">3</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkButton" id="dtend_date_button">
-            <property name="label">dtend</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">False</property>
-            <signal name="clicked" handler="on_date_button_clicked" object="CaliforniaHostCreateUpdateEvent" 
swapped="no"/>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">4</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkComboBoxText" id="dtend_time_combo">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">5</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkCheckButton" id="all_day_toggle">
-            <property name="label" translatable="yes">_All-day</property>
+          <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">False</property>
-            <property name="halign">start</property>
+            <property name="receives_default">True</property>
+            <property name="margin_left">8</property>
             <property name="use_underline">True</property>
-            <property name="xalign">0</property>
-            <property name="image_position">right</property>
-            <property name="draw_indicator">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="position">6</property>
+            <property name="pack_type">end</property>
+            <property name="position">1</property>
           </packing>
         </child>
         <child>
-          <object class="GtkButton" id="recurring_button">
-            <property name="label" translatable="yes">Re_peats...</property>
+          <object class="GtkButton" id="edit_time_button">
             <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"/>
+            <property name="tooltip_text" translatable="yes">Set the start and end time</property>
+            <property name="relief">none</property>
+            <signal name="clicked" handler="on_edit_time_button_clicked" 
object="CaliforniaHostCreateUpdateEvent" swapped="no"/>
+            <child>
+              <object class="GtkImage" id="image1">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="icon_name">alarm-symbolic</property>
+              </object>
+            </child>
           </object>
           <packing>
             <property name="expand">False</property>
             <property name="fill">True</property>
-            <property name="pack_type">end</property>
-            <property name="position">7</property>
+            <property name="position">2</property>
           </packing>
         </child>
       </object>
       <packing>
-        <property name="left_attach">0</property>
+        <property name="left_attach">1</property>
         <property name="top_attach">1</property>
-        <property name="width">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkEntry" id="location_entry">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="activates_default">True</property>
+      </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>
     <child>
-      <object class="GtkGrid" id="grid1">
+      <object class="GtkLabel" id="location_label">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
-        <property name="row_spacing">6</property>
-        <property name="column_spacing">6</property>
-        <child>
-          <object class="GtkLabel" id="location_label">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="xalign">1</property>
-            <property name="label" translatable="yes">_Location</property>
-            <property name="use_underline">True</property>
-            <property name="mnemonic_widget">location_entry</property>
-            <property name="single_line_mode">True</property>
-            <style>
-              <class name="dim-label"/>
-            </style>
-          </object>
-          <packing>
-            <property name="left_attach">0</property>
-            <property name="top_attach">0</property>
-            <property name="width">1</property>
-            <property name="height">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkEntry" id="location_entry">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="activates_default">True</property>
-          </object>
-          <packing>
-            <property name="left_attach">1</property>
-            <property name="top_attach">0</property>
-            <property name="width">1</property>
-            <property name="height">1</property>
-          </packing>
-        </child>
+        <property name="xalign">1</property>
+        <property name="label" translatable="yes">_Location</property>
+        <property name="use_underline">True</property>
+        <property name="single_line_mode">True</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="GtkLabel" id="description_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="yalign">0</property>
+        <property name="label" translatable="yes">_Description</property>
+        <property name="use_underline">True</property>
+        <property name="wrap">True</property>
+        <property name="mnemonic_widget">description_textview</property>
+        <style>
+          <class name="dim-label"/>
+        </style>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">3</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkScrolledWindow" id="scrolledwindow1">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="shadow_type">in</property>
+        <property name="min_content_height">75</property>
         <child>
-          <object class="GtkLabel" id="description_label">
+          <object class="GtkViewport" id="viewport1">
             <property name="visible">True</property>
             <property name="can_focus">False</property>
-            <property name="xalign">1</property>
-            <property name="yalign">0</property>
-            <property name="label" translatable="yes">_Description</property>
-            <property name="use_underline">True</property>
-            <property name="wrap">True</property>
-            <property name="mnemonic_widget">description_textview</property>
-            <style>
-              <class name="dim-label"/>
-            </style>
-          </object>
-          <packing>
-            <property name="left_attach">0</property>
-            <property name="top_attach">1</property>
-            <property name="width">1</property>
-            <property name="height">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkScrolledWindow" id="scrolledwindow1">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="shadow_type">in</property>
-            <property name="min_content_height">75</property>
             <child>
-              <object class="GtkViewport" id="viewport1">
+              <object class="GtkTextView" id="description_textview">
                 <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <child>
-                  <object class="GtkTextView" id="description_textview">
-                    <property name="visible">True</property>
-                    <property name="can_focus">True</property>
-                    <property name="hexpand">True</property>
-                    <property name="vexpand">True</property>
-                    <property name="wrap_mode">word</property>
-                  </object>
-                </child>
+                <property name="can_focus">True</property>
+                <property name="hexpand">True</property>
+                <property name="vexpand">True</property>
+                <property name="wrap_mode">word</property>
               </object>
             </child>
           </object>
-          <packing>
-            <property name="left_attach">1</property>
-            <property name="top_attach">1</property>
-            <property name="width">1</property>
-            <property name="height">1</property>
-          </packing>
         </child>
+      </object>
+      <packing>
+        <property name="left_attach">1</property>
+        <property name="top_attach">3</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="calendar_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">1</property>
+        <property name="label" translatable="yes">Calendar</property>
         <style>
           <class name="dim-label"/>
         </style>
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">2</property>
-        <property name="width">2</property>
+        <property name="top_attach">4</property>
+        <property name="width">1</property>
         <property name="height">1</property>
       </packing>
     </child>
@@ -259,11 +233,12 @@
       <object class="GtkComboBoxText" id="calendar_combo">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
+        <property name="halign">start</property>
       </object>
       <packing>
-        <property name="left_attach">0</property>
-        <property name="top_attach">3</property>
-        <property name="width">2</property>
+        <property name="left_attach">1</property>
+        <property name="top_attach">4</property>
+        <property name="width">1</property>
         <property name="height">1</property>
       </packing>
     </child>
@@ -280,7 +255,7 @@
       </object>
       <packing>
         <property name="left_attach">0</property>
-        <property name="top_attach">4</property>
+        <property name="top_attach">5</property>
         <property name="width">2</property>
         <property name="height">1</property>
       </packing>
diff --git a/src/rc/create-update-recurring.ui b/src/rc/create-update-recurring.ui
index 205cef3..dccd51d 100644
--- a/src/rc/create-update-recurring.ui
+++ b/src/rc/create-update-recurring.ui
@@ -369,7 +369,6 @@
                     <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="insert-text" handler="on_insert_text_numbers_only" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
                   </object>
                   <packing>
                     <property name="expand">False</property>
@@ -434,7 +433,6 @@
                 <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="insert-text" handler="on_insert_text_numbers_only" 
object="CaliforniaHostCreateUpdateRecurring" swapped="no"/>
               </object>
               <packing>
                 <property name="expand">False</property>
diff --git a/src/rc/date-time-widget.ui b/src/rc/date-time-widget.ui
new file mode 100644
index 0000000..4fbf1d7
--- /dev/null
+++ b/src/rc/date-time-widget.ui
@@ -0,0 +1,250 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <template class="CaliforniaHostDateTimeWidget" parent="GtkBox">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="halign">center</property>
+    <property name="valign">center</property>
+    <property name="orientation">vertical</property>
+    <property name="spacing">8</property>
+    <child>
+      <object class="GtkCalendar" id="calendar">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="halign">end</property>
+        <property name="hexpand">False</property>
+        <property name="year">2014</property>
+        <property name="month">6</property>
+        <property name="day">23</property>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">False</property>
+        <property name="position">0</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkGrid" id="time_grid">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="halign">center</property>
+        <property name="valign">center</property>
+        <property name="hexpand">False</property>
+        <property name="vexpand">False</property>
+        <child>
+          <object class="GtkEntry" id="hour_entry">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="halign">center</property>
+            <property name="valign">center</property>
+            <property name="hexpand">False</property>
+            <property name="vexpand">False</property>
+            <property name="max_length">2</property>
+            <property name="width_chars">2</property>
+            <property name="xalign">1</property>
+            <property name="input_purpose">digits</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="colon_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="halign">center</property>
+            <property name="valign">center</property>
+            <property name="hexpand">False</property>
+            <property name="label" translatable="yes">:</property>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEntry" id="minutes_entry">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="halign">start</property>
+            <property name="hexpand">False</property>
+            <property name="max_length">2</property>
+            <property name="width_chars">2</property>
+            <property name="input_purpose">digits</property>
+          </object>
+          <packing>
+            <property name="left_attach">2</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEventBox" id="hour_up">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="visible_window">False</property>
+            <child>
+              <object class="GtkArrow" id="from_hour_up_arrow">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="halign">center</property>
+                <property name="hexpand">False</property>
+                <property name="arrow_type">up</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEventBox" id="hour_down">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="visible_window">False</property>
+            <child>
+              <object class="GtkArrow" id="from_hour_down_arrow">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="halign">center</property>
+                <property name="hexpand">False</property>
+                <property name="arrow_type">down</property>
+              </object>
+            </child>
+          </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="GtkLabel" id="meridiem_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label">am</property>
+            <property name="width_chars">3</property>
+            <property name="max_width_chars">2</property>
+          </object>
+          <packing>
+            <property name="left_attach">3</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEventBox" id="minutes_up">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="visible_window">False</property>
+            <child>
+              <object class="GtkArrow" id="from_minutes_up_arrow">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="halign">center</property>
+                <property name="hexpand">False</property>
+                <property name="arrow_type">up</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">2</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEventBox" id="minutes_down">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="visible_window">False</property>
+            <child>
+              <object class="GtkArrow" id="from_minutes_down_arrow">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="halign">center</property>
+                <property name="hexpand">False</property>
+                <property name="arrow_type">down</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">2</property>
+            <property name="top_attach">2</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEventBox" id="meridiem_up">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="visible_window">False</property>
+            <child>
+              <object class="GtkArrow" id="from_meridiem_up_arrow">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="halign">center</property>
+                <property name="hexpand">False</property>
+                <property name="arrow_type">up</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">3</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEventBox" id="meridiem_down">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="visible_window">False</property>
+            <child>
+              <object class="GtkArrow" id="from_meridiem_down_arrow">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="halign">center</property>
+                <property name="hexpand">False</property>
+                <property name="arrow_type">down</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">3</property>
+            <property name="top_attach">2</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">True</property>
+        <property name="position">1</property>
+      </packing>
+    </child>
+  </template>
+</interface>
diff --git a/src/rc/event-time-settings.ui b/src/rc/event-time-settings.ui
new file mode 100644
index 0000000..41a38cc
--- /dev/null
+++ b/src/rc/event-time-settings.ui
@@ -0,0 +1,155 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <template class="CaliforniaHostEventTimeSettings" parent="GtkBox">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="orientation">vertical</property>
+    <property name="spacing">8</property>
+    <child>
+      <object class="GtkLabel" id="summary_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_bottom">4</property>
+        <property name="label">(empty)</property>
+        <attributes>
+          <attribute name="weight" value="bold"/>
+        </attributes>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">True</property>
+        <property name="position">0</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkBox" id="date_time_widgets_box">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="halign">center</property>
+        <property name="hexpand">True</property>
+        <property name="spacing">4</property>
+        <child>
+          <object class="GtkBox" id="from_box">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="orientation">vertical</property>
+            <child>
+              <placeholder/>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="to_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="margin_left">4</property>
+            <property name="margin_right">4</property>
+            <property name="label" translatable="yes" comments="As in &quot;From 9pm to 
10pm&quot;">to</property>
+            <attributes>
+              <attribute name="weight" value="bold"/>
+            </attributes>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="to_box">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="orientation">vertical</property>
+            <child>
+              <placeholder/>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">True</property>
+        <property name="position">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkCheckButton" id="all_day_checkbutton">
+        <property name="label" translatable="yes">_All-day event</property>
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="receives_default">False</property>
+        <property name="halign">end</property>
+        <property name="use_underline">True</property>
+        <property name="xalign">0</property>
+        <property name="draw_indicator">True</property>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">True</property>
+        <property name="position">2</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkButtonBox" id="buttonbox1">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="halign">end</property>
+        <property name="margin_top">8</property>
+        <property name="spacing">8</property>
+        <property name="homogeneous">True</property>
+        <property name="layout_style">start</property>
+        <child>
+          <object class="GtkButton" id="cancel_button">
+            <property name="label" translatable="yes">_Cancel</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_cancel_button_clicked" 
object="CaliforniaHostEventTimeSettings" swapped="no"/>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="ok_button">
+            <property name="label" translatable="yes">_OK</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="use_underline">True</property>
+            <signal name="clicked" handler="on_ok_button_clicked" object="CaliforniaHostEventTimeSettings" 
swapped="no"/>
+            <style>
+              <class name="suggested-action"/>
+            </style>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">True</property>
+        <property name="pack_type">end</property>
+        <property name="position">3</property>
+      </packing>
+    </child>
+  </template>
+</interface>
diff --git a/src/tests/tests-calendar-exact-time.vala b/src/tests/tests-calendar-exact-time.vala
new file mode 100644
index 0000000..7d4b9e5
--- /dev/null
+++ b/src/tests/tests-calendar-exact-time.vala
@@ -0,0 +1,63 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Tests {
+
+internal class CalendarExactTime : UnitTest.Harness {
+    private Calendar.ExactTime? now;
+    private Calendar.ExactTime? past;
+    private Calendar.ExactTime? future;
+    
+    public CalendarExactTime() {
+        add_case("clamp-floor-unaltered", clamp_floor_unaltered);
+        add_case("clamp-floor-altered", clamp_floor_altered);
+        add_case("clamp-ceiling-unaltered", clamp_ceiling_unaltered);
+        add_case("clamp-ceiling-altered", clamp_ceiling_altered);
+        add_case("clamp-both-unaltered", clamp_both_unaltered);
+        add_case("clamp-both-altered", clamp_both_altered);
+    }
+    
+    protected override void setup() throws Error {
+        Calendar.init();
+        
+        now = Calendar.System.now;
+        past = now.adjust_time(-1, Calendar.TimeUnit.MINUTE);
+        future = now.adjust_time(1, Calendar.TimeUnit.MINUTE);
+    }
+    
+    protected override void teardown() {
+        now = past = future = null;
+        
+        Calendar.terminate();
+    }
+    
+    private bool clamp_floor_unaltered() throws Error {
+        return now.clamp(past, null).equal_to(now);
+    }
+    
+    private bool clamp_floor_altered() throws Error {
+        return now.clamp(future, null).equal_to(future);
+    }
+    
+    private bool clamp_ceiling_unaltered() throws Error {
+        return now.clamp(null, future).equal_to(now);
+    }
+    
+    private bool clamp_ceiling_altered() throws Error {
+        return now.clamp(null, past).equal_to(past);
+    }
+    
+    private bool clamp_both_unaltered() throws Error {
+        return now.clamp(past, future).equal_to(now);
+    }
+    
+    private bool clamp_both_altered() throws Error {
+        return now.clamp(past, past).equal_to(past);
+    }
+}
+
+}
+
diff --git a/src/tests/tests-calendar-wall-time.vala b/src/tests/tests-calendar-wall-time.vala
index ef974de..5844cfd 100644
--- a/src/tests/tests-calendar-wall-time.vala
+++ b/src/tests/tests-calendar-wall-time.vala
@@ -8,12 +8,17 @@ namespace California.Tests {
 
 internal class CalendarWallTime : UnitTest.Harness {
     public CalendarWallTime() {
-        add_case("round-down-perverse", round_down_perverse);
-        add_case("round-down-zero", round_down_zero);
+        add_case("round-zero", round_zero);
         add_case("round-down-hour-no-change", round_down_hour_no_change);
         add_case("round-down-hour-change", round_down_hour_change);
         add_case("round-down-minute", round_down_minute);
         add_case("round-down-second", round_down_second);
+        add_case("round-down-no-rollover", round_down_no_rollover);
+        add_case("round-up-hour-no-change", round_up_hour_no_change);
+        add_case("round-up-hour-change", round_up_hour_change);
+        add_case("round-up-minute", round_up_minute);
+        add_case("round-up-second", round_up_second);
+        add_case("round-up-rollover", round_up_rollover);
     }
     
     protected override void setup() throws Error {
@@ -24,46 +29,92 @@ internal class CalendarWallTime : UnitTest.Harness {
         Calendar.terminate();
     }
     
-    private bool round_down_perverse() throws Error {
+    private bool round_zero() throws Error {
         Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 14);
-        Calendar.WallTime round_down = wall_time.round_down(-1, Calendar.TimeUnit.MINUTE);
+        bool rollover;
+        Calendar.WallTime rounded = wall_time.round(0, Calendar.TimeUnit.HOUR, out rollover);
         
-        return wall_time.equal_to(round_down);
-    }
-    
-    private bool round_down_zero() throws Error {
-        Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 14);
-        Calendar.WallTime round_down = wall_time.round_down(0, Calendar.TimeUnit.HOUR);
-        
-        return wall_time.equal_to(round_down);
+        return !rollover && wall_time.equal_to(rounded);
     }
     
     private bool round_down_hour_no_change() throws Error {
         Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 14);
-        Calendar.WallTime round_down = wall_time.round_down(2, Calendar.TimeUnit.HOUR);
+        bool rollover;
+        Calendar.WallTime round_down = wall_time.round(-2, Calendar.TimeUnit.HOUR, out rollover);
         
-        return round_down.hour == 10 && round_down.minute == 0 && round_down.second == 0;
+        return !rollover && round_down.hour == 10 && round_down.minute == 0 && round_down.second == 0;
     }
     
     private bool round_down_hour_change() throws Error {
         Calendar.WallTime wall_time = new Calendar.WallTime(9, 12, 14);
-        Calendar.WallTime round_down = wall_time.round_down(2, Calendar.TimeUnit.HOUR);
+        bool rollover;
+        Calendar.WallTime round_down = wall_time.round(-2, Calendar.TimeUnit.HOUR, out rollover);
         
-        return round_down.hour == 8 && round_down.minute == 0 && round_down.second == 0;
+        return !rollover && round_down.hour == 8 && round_down.minute == 0 && round_down.second == 0;
     }
     
     private bool round_down_minute() throws Error {
         Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 14);
-        Calendar.WallTime round_down = wall_time.round_down(10, Calendar.TimeUnit.MINUTE);
+        bool rollover;
+        Calendar.WallTime round_down = wall_time.round(-10, Calendar.TimeUnit.MINUTE, out rollover);
         
-        return round_down.hour == 10 && round_down.minute == 10 && round_down.second == 0;
+        return !rollover && round_down.hour == 10 && round_down.minute == 10 && round_down.second == 0;
     }
     
     private bool round_down_second() throws Error {
         Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 16);
-        Calendar.WallTime round_down = wall_time.round_down(15, Calendar.TimeUnit.SECOND);
+        bool rollover;
+        Calendar.WallTime round_down = wall_time.round(-15, Calendar.TimeUnit.SECOND, out rollover);
+        
+        return !rollover && round_down.hour == 10 && round_down.minute == 12 && round_down.second == 15;
+    }
+    
+    private bool round_down_no_rollover() throws Error {
+        Calendar.WallTime wall_time = Calendar.WallTime.earliest;
+        bool rollover;
+        Calendar.WallTime round_down = wall_time.round(-15, Calendar.TimeUnit.SECOND, out rollover);
+        
+        return !rollover && round_down.equal_to(wall_time);
+    }
+    
+    private bool round_up_hour_no_change() throws Error {
+        Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 14);
+        bool rollover;
+        Calendar.WallTime round_up = wall_time.round(2, Calendar.TimeUnit.HOUR, out rollover);
+        
+        return !rollover && round_up.hour == 10 && round_up.minute == 0 && round_up.second == 0;
+    }
+    
+    private bool round_up_hour_change() throws Error {
+        Calendar.WallTime wall_time = new Calendar.WallTime(9, 12, 14);
+        bool rollover;
+        Calendar.WallTime round_up = wall_time.round(2, Calendar.TimeUnit.HOUR, out rollover);
+        
+        return !rollover && round_up.hour == 10 && round_up.minute == 0 && round_up.second == 0;
+    }
+    
+    private bool round_up_minute() throws Error {
+        Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 14);
+        bool rollover;
+        Calendar.WallTime round_up = wall_time.round(10, Calendar.TimeUnit.MINUTE, out rollover);
+        
+        return !rollover && round_up.hour == 10 && round_up.minute == 20 && round_up.second == 0;
+    }
+    
+    private bool round_up_second() throws Error {
+        Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 16);
+        bool rollover;
+        Calendar.WallTime round_up = wall_time.round(15, Calendar.TimeUnit.SECOND, out rollover);
+        
+        return !rollover && round_up.hour == 10 && round_up.minute == 12 && round_up.second == 30;
+    }
+    
+    private bool round_up_rollover() throws Error {
+        Calendar.WallTime wall_time = new Calendar.WallTime(23, 55, 16);
+        bool rollover;
+        Calendar.WallTime round_up = wall_time.round(15, Calendar.TimeUnit.MINUTE, out rollover);
         
-        return round_down.hour == 10 && round_down.minute == 12 && round_down.second == 15;
+        return rollover && round_up.hour == 0 && round_up.minute == 0 && round_up.second == 0;
     }
 }
 
diff --git a/src/tests/tests.vala b/src/tests/tests.vala
index 24c171e..11a3a47 100644
--- a/src/tests/tests.vala
+++ b/src/tests/tests.vala
@@ -17,6 +17,7 @@ public int run(string[] args) {
     UnitTest.Harness.register(new CalendarMonthSpan());
     UnitTest.Harness.register(new CalendarMonthOfYear());
     UnitTest.Harness.register(new CalendarWallTime());
+    UnitTest.Harness.register(new CalendarExactTime());
     UnitTest.Harness.register(new QuickAdd());
     UnitTest.Harness.register(new QuickAddRecurring());
     
diff --git a/src/toolkit/toolkit-button-connector.vala b/src/toolkit/toolkit-button-connector.vala
index 0549f57..a91f943 100644
--- a/src/toolkit/toolkit-button-connector.vala
+++ b/src/toolkit/toolkit-button-connector.vala
@@ -20,46 +20,12 @@ namespace California.Toolkit {
  */
 
 public class ButtonConnector : EventConnector {
-    // GDK reports 250ms is used to determine if a click is a double-click (and another 250ms for
-    // triple-click), so pause just a little more than that to determine if all the clicking is
-    // done
-    private const int CLICK_DETERMINATION_DELAY_MSEC = 255;
-    
-    // The actual ButtonEvent, with some useful functionality for release timeouts
-    private class InternalButtonEvent : ButtonEvent {
-        private Scheduled? scheduled_timeout = null;
-        
-        public signal void release_timeout();
-        
-        public InternalButtonEvent(Gtk.Widget widget, Gdk.EventButton event) {
-            base (widget, event);
-        }
-        
-        public override void update_press(Gtk.Widget widget, Gdk.EventButton press_event) {
-            base.update_press(widget, press_event);
-            
-            if (scheduled_timeout != null)
-                scheduled_timeout.cancel();
-        }
-        
-        public override void update_release(Gtk.Widget widget, Gdk.EventButton release_event) {
-            base.update_release(widget, release_event);
-            
-            scheduled_timeout = new Scheduled.once_after_msec(CLICK_DETERMINATION_DELAY_MSEC,
-                on_timeout, Priority.LOW);
-        }
-        
-        private void on_timeout() {
-            release_timeout();
-        }
-    }
-    
-    private Gee.HashMap<Gtk.Widget, InternalButtonEvent> primary_states = new Gee.HashMap<
-        Gtk.Widget, InternalButtonEvent>();
-    private Gee.HashMap<Gtk.Widget, InternalButtonEvent> secondary_states = new Gee.HashMap<
-        Gtk.Widget, InternalButtonEvent>();
-    private Gee.HashMap<Gtk.Widget, InternalButtonEvent> tertiary_states = new Gee.HashMap<
-        Gtk.Widget, InternalButtonEvent>();
+    private Gee.HashMap<Gtk.Widget, ButtonEvent> primary_states = new Gee.HashMap<
+        Gtk.Widget, ButtonEvent>();
+    private Gee.HashMap<Gtk.Widget, ButtonEvent> secondary_states = new Gee.HashMap<
+        Gtk.Widget, ButtonEvent>();
+    private Gee.HashMap<Gtk.Widget, ButtonEvent> tertiary_states = new Gee.HashMap<
+        Gtk.Widget, ButtonEvent>();
     
     /**
      * The "raw" "button-pressed" signal received by { link ButtonConnector}.
@@ -80,33 +46,26 @@ public class ButtonConnector : EventConnector {
     /**
      * Fired when a button is pressed and released once.
      *
-     * The "guaranteed" flag is important to distinguish here.  If set, that indicates a timeout
-     * has occurred and the user did not follow the click with a second or third.  If not set,
-     * this was fired immediately after the user released the button and it is unknown if the user
-     * intends to follow it with more clicks.
-     *
-     * Because no timeout has occurred, unguaranteed clicks can be processed immediately if they
-     * occur on a widget or location where double- and triple-clicks are meaningless.
-     *
-     * NOTE: This means "clicked" (and { link double_clicked} and { link triple_clicked} will be
-     * fired ''twice'', once unguaranteed, once guaranteed.  To prevent double-processing, handlers
-     * should always check the flag.
+     * Note that this can be fired after { link double_clicked} and { link triple_clicked}.  That
+     * indicates that the user double- or triple-clicked ''and'' the other signal handlers did not
+     * return { link Toolkit.STOP}, indicating the event was unhandled or unabsorbed by the signal
+     * handlers.  If either returns STOP, "clicked" will not fire.
      */
-    public signal void clicked(ButtonEvent details, bool guaranteed);
+    public signal bool clicked(ButtonEvent details);
     
     /**
      * Fired when a button is pressed and released twice in succession.
      *
-     * See { link clicked} for an explanation of the { link guaranteed} flag.
+     * See { link clicked} for an explanation of signal firing order.
      */
-    public signal void double_clicked(ButtonEvent details, bool guaranteed);
+    public signal bool double_clicked(ButtonEvent details);
     
     /**
      * Fired when a button is pressed and released thrice in succession.
      *
-     * See { link clicked} for an explanation of the { link guaranteed} flag.
+     * See { link clicked} for an explanation of signal firing order.
      */
-    public signal void triple_clicked(ButtonEvent details, bool guaranteed);
+    public signal bool triple_clicked(ButtonEvent details);
     
     /**
      * Create a new { link ButtonConnector} for monitoring (mouse) button events from Gtk.Widgets.
@@ -119,7 +78,7 @@ public class ButtonConnector : EventConnector {
      * Subclasses may override this method to hook into this event before or after the signal
      * has fired.
      *
-     * @return { link EVENT_STOP} or { link EVENT_PROPAGATE}.
+     * @return { link STOP} or { link PROPAGATE}.
      */
     protected virtual bool notify_pressed(Gtk.Widget widget, Button button, Gdk.Point point,
         Gdk.EventType event_type) {
@@ -130,7 +89,7 @@ public class ButtonConnector : EventConnector {
      * Subclasses may override this method to hook into this event before or after the signal
      * has fired.
      *
-     * @return { link EVENT_STOP} or { link EVENT_PROPAGATE}.
+     * @return { link STOP} or { link PROPAGATE}.
      */
     protected virtual bool notify_released(Gtk.Widget widget, Button button, Gdk.Point point,
         Gdk.EventType event_type) {
@@ -141,24 +100,24 @@ public class ButtonConnector : EventConnector {
      * Subclasses may override this method to hook into this event before or after the signal
      * has fired.
      */
-    protected virtual void notify_clicked(ButtonEvent details, bool guaranteed) {
-        clicked(details, guaranteed);
+    protected virtual bool notify_clicked(ButtonEvent details) {
+        return clicked(details);
     }
     
     /**
      * Subclasses may override this method to hook into this event before or after the signal
      * has fired.
      */
-    protected virtual void notify_double_clicked(ButtonEvent details, bool guaranteed) {
-        double_clicked(details, guaranteed);
+    protected virtual bool notify_double_clicked(ButtonEvent details) {
+        return double_clicked(details);
     }
     
     /**
      * Subclasses may override this method to hook into this event before or after the signal
      * has fired.
      */
-    protected virtual void notify_triple_clicked(ButtonEvent details, bool guaranteed) {
-        triple_clicked(details, guaranteed);
+    protected virtual bool notify_triple_clicked(ButtonEvent details) {
+        return triple_clicked(details);
     }
     
     protected override void connect_signals(Gtk.Widget widget) {
@@ -182,7 +141,7 @@ public class ButtonConnector : EventConnector {
         tertiary_states.unset(widget);
     }
     
-    private Gee.HashMap<Gtk.Widget, InternalButtonEvent>? get_states_map(Button button) {
+    private Gee.HashMap<Gtk.Widget, ButtonEvent>? get_states_map(Button button) {
         switch (button) {
             case Button.PRIMARY:
                 return primary_states;
@@ -208,7 +167,7 @@ public class ButtonConnector : EventConnector {
     }
     
     private bool process_button_event(Gtk.Widget widget, Gdk.EventButton event,
-        Button button, Gee.HashMap<Gtk.Widget, InternalButtonEvent>? button_states) {
+        Button button, Gee.HashMap<Gtk.Widget, ButtonEvent>? button_states) {
         switch(event.type) {
             case Gdk.EventType.BUTTON_PRESS:
             case Gdk.EventType.2BUTTON_PRESS:
@@ -227,10 +186,9 @@ public class ButtonConnector : EventConnector {
                 // previous press (possible for multiple press events to arrive back-to-back
                 // when double- and triple-clicking)
                 if (button_states != null) {
-                    InternalButtonEvent? details = button_states.get(widget);
+                    ButtonEvent? details = button_states.get(widget);
                     if (details == null) {
-                        details = new InternalButtonEvent(widget, event);
-                        details.release_timeout.connect(on_release_timeout);
+                        details = new ButtonEvent(widget, event);
                         button_states.set(widget, details);
                     } else {
                         details.update_press(widget, event);
@@ -251,26 +209,11 @@ public class ButtonConnector : EventConnector {
                 
                 // update saved state (if any) with release info and start timer
                 if (button_states != null) {
-                    InternalButtonEvent? details = button_states.get(widget);
+                    ButtonEvent? details = button_states.get(widget);
                     if (details != null) {
-                        // fire "unguaranteed" clicked signals now (with button release) rather than
-                        // wait for timeout using the current value of press_type before the details
-                        // are updated
-                        switch (details.press_type) {
-                            case Gdk.EventType.BUTTON_PRESS:
-                                notify_clicked(details, false);
-                            break;
-                            
-                            case Gdk.EventType.2BUTTON_PRESS:
-                                notify_double_clicked(details, false);
-                            break;
-                            
-                            case Gdk.EventType.3BUTTON_PRESS:
-                                notify_triple_clicked(details, false);
-                            break;
-                        }
-                        
                         details.update_release(widget, event);
+                        
+                        return synthesize_click(details);
                     }
                 }
             break;
@@ -279,27 +222,38 @@ public class ButtonConnector : EventConnector {
         return Toolkit.PROPAGATE;
     }
     
-    private void on_release_timeout(InternalButtonEvent details) {
-        // release button timed-out, meaning it's time to evaluate where the sequence stands and
-        // notify subscribers
+    private bool synthesize_click(ButtonEvent details) {
+        // use Accept flags to indicate which signal(s) to fire
+        bool result = Toolkit.PROPAGATE;
         switch (details.press_type) {
             case Gdk.EventType.BUTTON_PRESS:
-                notify_clicked(details, true);
+                result = notify_clicked(details);
             break;
             
             case Gdk.EventType.2BUTTON_PRESS:
-                notify_double_clicked(details, true);
+                result = notify_double_clicked(details);
+                if (result != Toolkit.STOP)
+                    result = notify_clicked(details);
             break;
             
             case Gdk.EventType.3BUTTON_PRESS:
-                notify_triple_clicked(details, true);
+                result = notify_triple_clicked(details);
+                if (result != Toolkit.STOP) {
+                    result = notify_double_clicked(details);
+                    if (result != Toolkit.STOP)
+                        result = notify_clicked(details);
+                }
             break;
         }
         
-        // drop state, now finished with it
-        Gee.HashMap<Gtk.Widget, InternalButtonEvent>? states_map = get_states_map(details.button);
-        if (states_map != null)
-            states_map.unset(details.widget);
+        if (result == Toolkit.STOP) {
+            // drop event entirely
+            Gee.HashMap<Gtk.Widget, ButtonEvent>? states_map = get_states_map(details.button);
+            if (states_map != null)
+                states_map.unset(details.widget);
+        }
+        
+        return result;
     }
 }
 
diff --git a/src/toolkit/toolkit-button-event.vala b/src/toolkit/toolkit-button-event.vala
index 775267e..25319a0 100644
--- a/src/toolkit/toolkit-button-event.vala
+++ b/src/toolkit/toolkit-button-event.vala
@@ -106,7 +106,7 @@ public class ButtonEvent : BaseObject {
     }
     
     // Update state with the next button press
-    internal virtual void update_press(Gtk.Widget widget, Gdk.EventButton press_event) {
+    internal void update_press(Gtk.Widget widget, Gdk.EventButton press_event) {
         assert(this.widget == widget);
         assert(Button.from_button_event(press_event) == button);
         
@@ -116,7 +116,7 @@ public class ButtonEvent : BaseObject {
     }
     
     // Update state with the next button release and start the release timer
-    internal virtual void update_release(Gtk.Widget widget, Gdk.EventButton release_event) {
+    internal void update_release(Gtk.Widget widget, Gdk.EventButton release_event) {
         assert(this.widget == widget);
         assert(Button.from_button_event(release_event) == button);
         
diff --git a/src/toolkit/toolkit-card.vala b/src/toolkit/toolkit-card.vala
index 8e2daee..7450aea 100644
--- a/src/toolkit/toolkit-card.vala
+++ b/src/toolkit/toolkit-card.vala
@@ -163,7 +163,6 @@ public interface Card : Gtk.Widget {
      * This is called before dealing with { link default_widget} and { link initial_focus}, so
      * changes to those properties in this call, if need be.
      */
-    // TODO: Use a JumpContext object instead.
     public abstract void jumped_to(Card? from, Jump reason, Value? message);
     
     /**
diff --git a/src/toolkit/toolkit-entry-filter-connector.vala b/src/toolkit/toolkit-entry-filter-connector.vala
new file mode 100644
index 0000000..a856d2e
--- /dev/null
+++ b/src/toolkit/toolkit-entry-filter-connector.vala
@@ -0,0 +1,87 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+namespace California.Toolkit {
+
+/**
+ * A connector that allows for filtering all text inserted into a Gtk.Entry.
+ */
+
+public class EntryFilterConnector : BaseObject {
+    private Gee.MapFunc<string, string> filter;
+    private Gee.HashSet<Gtk.Entry> entries = new Gee.HashSet<Gtk.Entry>();
+    private Gee.HashSet<Gtk.Entry> in_signal = new Gee.HashSet<Gtk.Entry>();
+    
+    /**
+     * A generic filtering mechanism for all connected Gtk.Entry's.
+     */
+    public EntryFilterConnector(Gee.MapFunc<string, string> filter) {
+        this.filter = filter;
+    }
+    
+    /**
+     * A specific filter for allowing only numeric input.
+     */
+    public EntryFilterConnector.only_numeric() {
+        this (numeric_filter);
+    }
+    
+    ~EntryFilterConnector() {
+        traverse_safely<Gtk.Entry>(entries).iterate(disconnect_from);
+    }
+    
+    public void connect_to(Gtk.Entry entry) {
+        if (!entries.add(entry))
+            return;
+        
+        entry.insert_text.connect(on_entry_insert);
+    }
+    
+    public void disconnect_from(Gtk.Entry entry) {
+        if (!entries.remove(entry))
+            return;
+        
+        entry.insert_text.disconnect(on_entry_insert);
+    }
+    
+    private static string numeric_filter(owned string str) {
+        return from_string(str)
+            .filter(ch => ch.isdigit())
+            .to_string(ch => ch.to_string());
+    }
+    
+    private void on_entry_insert(Gtk.Editable editable, string new_text, int new_text_length,
+        ref int position) {
+        Gtk.Entry entry = (Gtk.Entry) editable;
+        
+        // prevent recursion when our modified text is inserted (i.e. allow the base handler to
+        // deal with new text directly)
+        if (entry in in_signal)
+            return;
+        
+        // filter
+        string filtered = filter(new_text);
+        
+        // insert new text into place, ensure this handler doesn't attempt to process this
+        // modified text ... would use SignalHandler.block_by_func() and unblock_by_func(), but
+        // the bindings are ungood
+        if (!String.is_empty(filtered)) {
+            in_signal.add(entry);
+            editable.insert_text(filtered, filtered.length, ref position);
+            in_signal.remove(entry);
+        }
+        
+        // don't let the base handler have at the original text
+        Signal.stop_emission_by_name(editable, "insert-text");
+    }
+    
+    public override string to_string() {
+        return classname;
+    }
+}
+
+}
+
diff --git a/src/view/week/week-day-pane.vala b/src/view/week/week-day-pane.vala
index 5090355..cf3a558 100644
--- a/src/view/week/week-day-pane.vala
+++ b/src/view/week/week-day-pane.vala
@@ -152,7 +152,7 @@ internal class DayPane : Pane, Common.InstanceContainer {
     
     public void update_selection(Calendar.WallTime wall_time) {
         // round down to the nearest 15-minute mark
-        Calendar.WallTime rounded_time = wall_time.round_down(15, Calendar.TimeUnit.MINUTE);
+        Calendar.WallTime rounded_time = wall_time.round(-15, Calendar.TimeUnit.MINUTE, null);
         
         // assign start first, end second (ordering doesn't matter, possible to select upwards)
         if (selection_start == null) {
diff --git a/src/view/week/week-grid.vala b/src/view/week/week-grid.vala
index 1920b6c..6556a02 100644
--- a/src/view/week/week-grid.vala
+++ b/src/view/week/week-grid.vala
@@ -318,35 +318,35 @@ internal class Grid : Gtk.Box {
         return date_to_all_day.get(cell_date);
     }
     
-    private void on_instance_container_clicked(Toolkit.ButtonEvent details, bool guaranteed) {
-        // only interested in unguaranteed clicks on the primary mouse button
-        if (details.button != Toolkit.Button.PRIMARY || guaranteed)
-            return;
+    private bool on_instance_container_clicked(Toolkit.ButtonEvent details) {
+        if (details.button != Toolkit.Button.PRIMARY)
+            return Toolkit.PROPAGATE;
         
         Common.InstanceContainer instance_container = (Common.InstanceContainer) details.widget;
         
         Component.Event? event = instance_container.get_event_at(details.press_point);
         if (event != null)
             owner.request_display_event(event, instance_container, details.press_point);
+        
+        return Toolkit.STOP;
     }
     
-    private void on_instance_container_double_clicked(Toolkit.ButtonEvent details, bool guaranteed) {
-        // only interested in unguaranteed double-clicks on the primary mouse button
-        if (details.button != Toolkit.Button.PRIMARY || guaranteed)
-            return;
+    private bool on_instance_container_double_clicked(Toolkit.ButtonEvent details) {
+        if (details.button != Toolkit.Button.PRIMARY)
+            return Toolkit.PROPAGATE;
         
         Common.InstanceContainer instance_container = (Common.InstanceContainer) details.widget;
         
         // if an event is at this location, don't process
         if (instance_container.get_event_at(details.press_point) != null)
-            return;
+            return Toolkit.PROPAGATE;
         
         // if a DayPane, use double-click to determine rounded time of the event's start
         DayPane? day_pane = instance_container as DayPane;
         if (day_pane != null) {
             // convert click into starting time on the day pane rounded down to the nearest half-hour
-            Calendar.WallTime wall_time = day_pane.get_wall_time(details.press_point.y).round_down(
-                30, Calendar.TimeUnit.MINUTE);
+            Calendar.WallTime wall_time = day_pane.get_wall_time(details.press_point.y).round(-30,
+                Calendar.TimeUnit.MINUTE, null);
             
             Calendar.ExactTime start_time = new Calendar.ExactTime(Calendar.Timezone.local,
                 day_pane.date, wall_time);
@@ -355,12 +355,14 @@ internal class Grid : Gtk.Box {
                 new Calendar.ExactTimeSpan(start_time, start_time.adjust_time(1, Calendar.TimeUnit.HOUR)),
                 day_pane, details.press_point);
             
-            return;
+            return Toolkit.STOP;
         }
         
         // otherwise, an all-day-cell, so request an all-day event
         owner.request_create_all_day_event(instance_container.contained_span, instance_container,
             details.press_point);
+        
+        return Toolkit.STOP;
     }
     
     private void on_day_pane_motion(Toolkit.MotionEvent details) {


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