[california] Week View: Closes bug #725767



commit d0b660414169ce0cbc9847f03047ebf1d0312014
Author: Jim Nelson <jim yorba org>
Date:   Thu May 22 15:44:51 2014 -0700

    Week View: Closes bug #725767
    
    Introduces Week View, which required refactoring the host container
    (main window) and refactoring a number of month view widgets to share
    code between the two views.  Although a large patch, this sets up
    California to more easily add other views and in general operate in a
    consistent way in all of them.
    
    A number of simplifications were also introduced in the toolkit/ unit,
    which can be used in future code and potentially backported to
    existing code.

 po/POTFILES.in                                 |    2 +
 src/Makefile.am                                |   18 +
 src/calendar/calendar-date.vala                |   22 +-
 src/calendar/calendar-exact-time-span.vala     |   10 +-
 src/calendar/calendar-wall-time.vala           |   64 +++-
 src/calendar/calendar-week.vala                |    2 +-
 src/calendar/calendar.vala                     |   64 ++-
 src/collection/collection-simple-iterable.vala |   14 +
 src/component/component-event.vala             |    9 +
 src/host/host-main-window.vala                 |  134 +++++-
 src/tests/tests-calendar-wall-time.vala        |   71 +++
 src/tests/tests.vala                           |    1 +
 src/toolkit/toolkit-button-connector.vala      |  334 +++++++++++++
 src/toolkit/toolkit-button-event.vala          |  108 +++++
 src/toolkit/toolkit-deck.vala                  |    7 +-
 src/toolkit/toolkit-event-connector.vala       |   93 ++++
 src/toolkit/toolkit-listbox-model.vala         |    2 +-
 src/toolkit/toolkit-stack-model.vala           |  337 +++++++++++++
 src/toolkit/toolkit.vala                       |   12 +
 src/util/util-gfx.vala                         |    4 +-
 src/view/common/common-events-cell.vala        |  610 ++++++++++++++++++++++++
 src/view/common/common.vala                    |   31 ++
 src/view/month/month-cell.vala                 |  595 +----------------------
 src/view/month/month-controller.vala           |  148 ++----
 src/view/month/month-grid.vala                 |   13 +-
 src/view/month/month.vala                      |    7 +-
 src/view/view-container.vala                   |   24 +
 src/view/view-controllable.vala                |   21 +-
 src/view/view-palette.vala                     |  209 ++++++++
 src/view/view.vala                             |   10 +-
 src/view/week/week-all-day-cell.vala           |   69 +++
 src/view/week/week-controller.vala             |  178 +++++++
 src/view/week/week-day-pane.vala               |  222 +++++++++
 src/view/week/week-grid.vala                   |  333 +++++++++++++
 src/view/week/week-hour-runner.vala            |   59 +++
 src/view/week/week-pane.vala                   |  135 ++++++
 src/view/week/week.vala                        |   39 ++
 37 files changed, 3260 insertions(+), 751 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 53a58b3..aaac90f 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -13,6 +13,8 @@ src/host/host-create-update-event.vala
 src/host/host-import-calendar.vala
 src/host/host-main-window.vala
 src/host/host-show-event.vala
+src/view/month/month-controller.vala
+src/view/week/week-controller.vala
 [type: gettext/glade]src/rc/activator-list.ui
 [type: gettext/glade]src/rc/app-menu.interface
 [type: gettext/glade]src/rc/calendar-import.ui
diff --git a/src/Makefile.am b/src/Makefile.am
index bc3cec5..6f176ea 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -104,18 +104,23 @@ california_VALASOURCES = \
        tests/tests-calendar-date.vala \
        tests/tests-calendar-month-of-year.vala \
        tests/tests-calendar-month-span.vala \
+       tests/tests-calendar-wall-time.vala \
        tests/tests-quick-add.vala \
        \
        toolkit/toolkit.vala \
+       toolkit/toolkit-button-connector.vala \
+       toolkit/toolkit-button-event.vala \
        toolkit/toolkit-calendar-popup.vala \
        toolkit/toolkit-card.vala \
        toolkit/toolkit-combo-box-text-model.vala \
        toolkit/toolkit-deck.vala \
        toolkit/toolkit-deck-window.vala \
        toolkit/toolkit-editable-label.vala \
+       toolkit/toolkit-event-connector.vala \
        toolkit/toolkit-listbox-model.vala \
        toolkit/toolkit-mutable-widget.vala \
        toolkit/toolkit-popup.vala \
+       toolkit/toolkit-stack-model.vala \
        \
        util/util-gfx.vala \
        util/util-memory.vala \
@@ -125,13 +130,26 @@ california_VALASOURCES = \
        unit-test/unit-test-harness.vala \
        \
        view/view.vala \
+       view/view-container.vala \
        view/view-controllable.vala \
+       view/view-palette.vala \
+       \
+       view/common/common.vala \
+       view/common/common-events-cell.vala \
        \
        view/month/month.vala \
        view/month/month-cell.vala \
        view/month/month-controller.vala \
        view/month/month-grid.vala \
        \
+       view/week/week.vala \
+       view/week/week-all-day-cell.vala \
+       view/week/week-controller.vala \
+       view/week/week-day-pane.vala \
+       view/week/week-grid.vala \
+       view/week/week-hour-runner.vala \
+       view/week/week-pane.vala \
+       \
        $(NULL)
 
 california_SOURCES = \
diff --git a/src/calendar/calendar-date.vala b/src/calendar/calendar-date.vala
index 0644d02..a8beab0 100644
--- a/src/calendar/calendar-date.vala
+++ b/src/calendar/calendar-date.vala
@@ -44,7 +44,11 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
          * Indicates that the localized string for "Today" should not be used if the date matches
          * { link System.today}.
          */
-        NO_TODAY
+        NO_TODAY,
+        /**
+         * Indicates the day of week should not be included.
+         */
+        NO_DAY_OF_WEEK
     }
     
     
@@ -299,15 +303,23 @@ public class Date : Unit<Date>, Gee.Comparable<Date>, Gee.Hashable<Date> {
         bool abbrev = (flags & PrettyFlag.ABBREV) != 0;
         bool with_year = (flags & PrettyFlag.INCLUDE_YEAR) != 0;
         bool no_today = (flags & PrettyFlag.NO_TODAY) != 0;
+        bool no_dow = (flags & PrettyFlag.NO_DAY_OF_WEEK) != 0;
         
         if (!no_today && !with_year && equal_to(System.today))
             return _("Today");
         
         unowned string fmt;
-        if (abbrev)
-            fmt = with_year ? FMT_PRETTY_DATE_ABBREV : FMT_PRETTY_DATE_ABBREV_NO_YEAR;
-        else
-            fmt = with_year ? FMT_PRETTY_DATE : FMT_PRETTY_DATE_NO_YEAR;
+        if (abbrev) {
+            if (no_dow)
+                fmt = with_year ? FMT_PRETTY_DATE_ABBREV_NO_DOW : FMT_PRETTY_DATE_ABBREV_NO_DOW_NO_YEAR;
+            else
+                fmt = with_year ? FMT_PRETTY_DATE_ABBREV : FMT_PRETTY_DATE_ABBREV_NO_YEAR;
+        } else {
+            if (no_dow)
+                fmt = with_year ? FMT_PRETTY_DATE_NO_DOW : FMT_PRETTY_DATE_NO_DOW_NO_YEAR;
+            else
+                fmt = with_year ? FMT_PRETTY_DATE : FMT_PRETTY_DATE_NO_YEAR;
+        }
         
         return String.reduce_whitespace(format(fmt));
     }
diff --git a/src/calendar/calendar-exact-time-span.vala b/src/calendar/calendar-exact-time-span.vala
index a525a91..60aa3bd 100644
--- a/src/calendar/calendar-exact-time-span.vala
+++ b/src/calendar/calendar-exact-time-span.vala
@@ -67,7 +67,7 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
         end_date = new Date.from_exact_time(end_exact_time);
     }
     
-    public ExactTimeSpan.from_date_span(DateSpan span, Timezone tz) {
+    public ExactTimeSpan.from_span(Span span, Timezone tz) {
         this (span.earliest_exact_time(tz), span.latest_exact_time(tz));
     }
     
@@ -88,6 +88,14 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
     }
     
     /**
+     * Returns true if the { link ExactTime} is in this { link ExactTimeSpan}.
+     */
+    public bool contains(ExactTime exact_time) {
+        return start_exact_time.compare_to(exact_time) <= 0
+            && end_exact_time.compare_to(exact_time) >= 0;
+    }
+    
+    /**
      * Compares the { link start_exact_time} of two { link ExactTimeSpan}s.
      */
     public int compare_to(ExactTimeSpan other) {
diff --git a/src/calendar/calendar-wall-time.vala b/src/calendar/calendar-wall-time.vala
index 495fafb..efa8d34 100644
--- a/src/calendar/calendar-wall-time.vala
+++ b/src/calendar/calendar-wall-time.vala
@@ -337,6 +337,65 @@ 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
+     * 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.
+     *
+     * 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).
+     */
+    public WallTime round_down(int multiple, TimeUnit time_unit) {
+        if (multiple <= 0)
+            return this;
+        
+        // get value being manipulated
+        int current;
+        switch (time_unit) {
+            case TimeUnit.HOUR:
+                current = hour;
+            break;
+            
+            case TimeUnit.MINUTE:
+                current = minute;
+            break;
+            
+            case TimeUnit.SECOND:
+                current = second;
+            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;
+        
+        // return new value
+        switch (time_unit) {
+            case TimeUnit.HOUR:
+                return new WallTime(rounded, 0, 0);
+            
+            case TimeUnit.MINUTE:
+                return new WallTime(hour, rounded, 0);
+            
+            case TimeUnit.SECOND:
+                return new WallTime(hour, minute, rounded);
+            
+            default:
+                assert_not_reached();
+        }
+    }
+    
+    /**
      * Returns a prettified, localized user-visible string.
      *
      * The string respects { link System.is_24hr}.
@@ -361,8 +420,9 @@ public class WallTime : BaseObject, Gee.Comparable<WallTime>, Gee.Hashable<WallT
         
         // Not marked for translation on thw assumption that a 12-hour hour followed by the meridiem
         // isn't something that varies between locales, on the assumption that the user has
-        // specified 12-hour time to begin with
-        if (optional_min && minute == 0)
+        // specified 12-hour time to begin with ... don't allow for 24-hour time because it doesn't
+        // look right (especially early hours, i.e. "0", "2")
+        if (optional_min && minute == 0 && !is_24hr)
             return "%d%s".printf(is_24hr ? hour : 12hour, meridiem);
         
         if (!include_sec) {
diff --git a/src/calendar/calendar-week.vala b/src/calendar/calendar-week.vala
index b52d83d..2ff35a0 100644
--- a/src/calendar/calendar-week.vala
+++ b/src/calendar/calendar-week.vala
@@ -101,7 +101,7 @@ public class Week : Unit<Week>, Gee.Comparable<Week>, Gee.Hashable<Week> {
     }
     
     public override string to_string() {
-        return "week %d of %s (%s)".printf(week_of_year, month_of_year.to_string(), base.to_string());
+        return "week %d of %s (%s)".printf(week_of_year, month_of_year.to_string(), 
to_date_span().to_string());
     }
 }
 
diff --git a/src/calendar/calendar.vala b/src/calendar/calendar.vala
index 6c4d5bc..d726823 100644
--- a/src/calendar/calendar.vala
+++ b/src/calendar/calendar.vala
@@ -44,25 +44,29 @@ public enum TimeUnit {
 
 private int init_count = 0;
 
-private static unowned string FMT_MONTH_YEAR_FULL;
-private static unowned string FMT_MONTH_YEAR_ABBREV;
-private static unowned string FMT_MONTH_FULL;
-private static unowned string FMT_MONTH_ABBREV;
-private static unowned string FMT_DAY_OF_WEEK_FULL;
-private static unowned string FMT_DAY_OF_WEEK_ABBREV;
-private static unowned string FMT_FULL_DATE;
-private static unowned string FMT_PRETTY_DATE;
-private static unowned string FMT_PRETTY_DATE_NO_YEAR;
-private static unowned string FMT_PRETTY_DATE_ABBREV;
-private static unowned string FMT_PRETTY_DATE_ABBREV_NO_YEAR;
-private static unowned string FMT_AM;
-private static unowned string FMT_BRIEF_AM;
-private static unowned string FMT_PM;
-private static unowned string FMT_BRIEF_PM;
-private static unowned string FMT_12HOUR_MIN_MERIDIEM;
-private static unowned string FMT_12HOUR_MIN_SEC_MERIDIEM;
-private static unowned string FMT_24HOUR_MIN;
-private static unowned string FMT_24HOUR_MIN_SEC;
+private unowned string FMT_MONTH_YEAR_FULL;
+private unowned string FMT_MONTH_YEAR_ABBREV;
+private unowned string FMT_MONTH_FULL;
+private unowned string FMT_MONTH_ABBREV;
+private unowned string FMT_DAY_OF_WEEK_FULL;
+private unowned string FMT_DAY_OF_WEEK_ABBREV;
+private unowned string FMT_FULL_DATE;
+private unowned string FMT_PRETTY_DATE;
+private unowned string FMT_PRETTY_DATE_NO_YEAR;
+private unowned string FMT_PRETTY_DATE_ABBREV;
+private unowned string FMT_PRETTY_DATE_ABBREV_NO_YEAR;
+private unowned string FMT_PRETTY_DATE_NO_DOW;
+private unowned string FMT_PRETTY_DATE_ABBREV_NO_DOW;
+private unowned string FMT_PRETTY_DATE_NO_DOW_NO_YEAR;
+private unowned string FMT_PRETTY_DATE_ABBREV_NO_DOW_NO_YEAR;
+private unowned string FMT_AM;
+private unowned string FMT_BRIEF_AM;
+private unowned string FMT_PM;
+private unowned string FMT_BRIEF_PM;
+private unowned string FMT_12HOUR_MIN_MERIDIEM;
+private unowned string FMT_12HOUR_MIN_SEC_MERIDIEM;
+private unowned string FMT_24HOUR_MIN;
+private unowned string FMT_24HOUR_MIN_SEC;
 
 private unowned string MIDNIGHT;
 private unowned string NOON;
@@ -128,6 +132,24 @@ public void init() throws Error {
     /// See http://www.cplusplus.com/reference/ctime/strftime/ for format reference
     FMT_PRETTY_DATE_ABBREV_NO_YEAR = _("%a, %b %e");
     
+    // A "pretty" date with no day of week according to locale preferences, i.e. "March 10, 2014"
+    // See http://www.cplusplus.com/reference/ctime/strftime/ for format reference
+    FMT_PRETTY_DATE_NO_DOW = _("%B %e, %Y");
+    
+    // A "pretty" date abbreviated with no day of week according to locale preferences,
+    // i.e. "Mar 10, 2014"
+    // See http://www.cplusplus.com/reference/ctime/strftime/ for format reference
+    FMT_PRETTY_DATE_ABBREV_NO_DOW = _("%b %e, %Y");
+    
+    // A "pretty" date with no day of week or year according to locale preferences, i.e. "March 10"
+    // See http://www.cplusplus.com/reference/ctime/strftime/ for format reference
+    FMT_PRETTY_DATE_NO_DOW_NO_YEAR = _("%B %e");
+    
+    // A "pretty" date abbreviated with no day of week or year according to locale preferences,
+    // i.e. "Mar 10"
+    // See http://www.cplusplus.com/reference/ctime/strftime/ for format reference
+    FMT_PRETTY_DATE_ABBREV_NO_DOW_NO_YEAR = _("%b %e");
+    
     /// Ante meridiem
     /// (Please translate even if 24-hour clock used in your locale; this allows for GNOME time
     /// format user settings to be honored)
@@ -159,10 +181,10 @@ public void init() throws Error {
     FMT_12HOUR_MIN_SEC_MERIDIEM = _("%d:%02d:%02d%s");
     
     /// The 24-hour time with minutes, i.e. "17:06"
-    FMT_24HOUR_MIN = _("%d:%02d");
+    FMT_24HOUR_MIN = _("%02d:%02d");
     
     /// The 24-hour time with minutes and seconds, i.e. "17:06:31"
-    FMT_24HOUR_MIN_SEC = _("%d:%02d:%02d");
+    FMT_24HOUR_MIN_SEC = _("%02d:%02d:%02d");
     
     // Used by quick-add to convert a user's day unit into an internal value.  Common abbreviations
     // (without punctuation) should be included.  Each word must be separated by semi-colons.
diff --git a/src/collection/collection-simple-iterable.vala b/src/collection/collection-simple-iterable.vala
index b880a6a..ad628dc 100644
--- a/src/collection/collection-simple-iterable.vala
+++ b/src/collection/collection-simple-iterable.vala
@@ -15,11 +15,25 @@ namespace California.Collection {
  * @see SimpleIterator
  */
 
+[GenericAccessors]
 public interface SimpleIterable<G> : BaseObject {
     /**
      * Returns a { link SimpleIterator} that can be used with Vala's foreach keyword.
      */
     public abstract SimpleIterator<G> iterator();
+    
+    /**
+     * Returns all the items in the { link SimpleIterable} as a single Gee.List.
+     */
+    public Gee.List<G> as_list(owned Gee.EqualDataFunc<G>? equal_func = null) {
+        Gee.List<G> list = new Gee.ArrayList<G>((owned) equal_func);
+        
+        SimpleIterator<G> iter = iterator();
+        while (iter.next())
+            list.add(iter.get());
+        
+        return list;
+    }
 }
 
 }
diff --git a/src/component/component-event.vala b/src/component/component-event.vala
index a2fc8c2..adba976 100644
--- a/src/component/component-event.vala
+++ b/src/component/component-event.vala
@@ -63,6 +63,15 @@ public class Event : Instance, Gee.Comparable<Event> {
     public bool is_all_day { get; private set; }
     
     /**
+     * Convenience property for determining if { link Event} spans one or more full days.
+     */
+    public bool is_day_spanning {
+        get {
+            return is_all_day || exact_time_span.duration.days >= 1;
+        }
+    }
+    
+    /**
      * Location of an { link Event}.
      */
     public string? location { get; set; default = null; }
diff --git a/src/host/host-main-window.vala b/src/host/host-main-window.vala
index af21484..87314ee 100644
--- a/src/host/host-main-window.vala
+++ b/src/host/host-main-window.vala
@@ -25,19 +25,32 @@ public class MainWindow : Gtk.ApplicationWindow {
     private const string ACTION_PREVIOUS = "win.previous";
     private const string ACCEL_PREVIOUS = "<Alt>Left";
     
+    private const string ACTION_MONTH = "win.view-month";
+    private const string ACCEL_MONTH = "<Ctrl>M";
+    
+    private const string ACTION_WEEK = "win.view-week";
+    private const string ACCEL_WEEK = "<Ctrl>W";
+    
     private static const ActionEntry[] action_entries = {
         { "quick-create-event", on_quick_create_event },
         { "jump-to-today", on_jump_to_today },
         { "next", on_next },
-        { "previous", on_previous }
+        { "previous", on_previous },
+        { "view-month", on_view_month },
+        { "view-week", on_view_week }
     };
     
     // Set as a property so it can be bound to the current View.Controllable
     public Calendar.FirstOfWeek first_of_week { get; set; }
     
-    private View.Controllable current_view;
-    private View.Month.Controller month_view = new View.Month.Controller();
     private Gtk.Button quick_add_button;
+    private View.Controllable month_view = new View.Month.Controller();
+    private View.Controllable week_view = new View.Week.Controller();
+    private View.Controllable? current_controller = null;
+    private Gee.HashSet<Binding> current_bindings = new Gee.HashSet<Binding>();
+    private Gtk.Stack view_stack = new Gtk.Stack();
+    private Gtk.HeaderBar headerbar = new Gtk.HeaderBar();
+    private Gtk.Button today = new Gtk.Button.with_label(_("_Today"));
     
     public MainWindow(Application app) {
         Object (application: app);
@@ -54,12 +67,23 @@ public class MainWindow : Gtk.ApplicationWindow {
         Application.instance.add_accelerator(ACCEL_JUMP_TO_TODAY, ACTION_JUMP_TO_TODAY, null);
         Application.instance.add_accelerator(rtl ? ACCEL_PREVIOUS : ACCEL_NEXT, ACTION_NEXT, null);
         Application.instance.add_accelerator(rtl ? ACCEL_NEXT : ACCEL_PREVIOUS, ACTION_PREVIOUS, null);
+        Application.instance.add_accelerator(ACCEL_MONTH, ACTION_MONTH, null);
+        Application.instance.add_accelerator(ACCEL_WEEK, ACTION_WEEK, null);
+        
+        // view stack settings
+        view_stack.homogeneous = true;
+        view_stack.transition_duration = Toolkit.DEFAULT_STACK_TRANSITION_DURATION_MSEC;
+        view_stack.transition_type = Gtk.StackTransitionType.SLIDE_UP_DOWN;
+        
+        // subscribe before adding so first add to initialize UI
+        view_stack.notify["visible-child"].connect(on_view_changed);
         
-        // start in Month view
-        current_view = month_view;
+        // add views to view stack, first added is first shown
+        add_controller(month_view);
+        add_controller(week_view);
         
-        // create GtkHeaderBar and pack it in
-        Gtk.HeaderBar headerbar = new Gtk.HeaderBar();
+        // if not on Unity, use headerbar as the titlebar (removes window chrome) and provide close
+        // button for users who might have trouble finding it otherwise
 #if !ENABLE_UNITY
         // Unity doesn't support GtkHeaderBar-as-title-bar very well yet; when set, the main
         // window can't be resized no matter what additional GtkWindow properties are set
@@ -67,7 +91,6 @@ public class MainWindow : Gtk.ApplicationWindow {
         set_titlebar(headerbar);
 #endif
         
-        Gtk.Button today = new Gtk.Button.with_label(_("_Today"));
         today.valign = Gtk.Align.CENTER;
         today.use_underline = true;
         today.tooltip_text = _("Jump to today's date (Ctrl+T)");
@@ -91,9 +114,19 @@ public class MainWindow : Gtk.ApplicationWindow {
         nav_buttons.pack_start(prev);
         nav_buttons.pack_end(next);
         
+        // TODO:
+        // Remove Gtk.StackSwitcher for a few reasons: (a) the buttons are kinda wide and
+        // would like to conserve header bar space; (b) want to add tooltips to buttons; and (c)
+        // want to move to icons at some point
+        Gtk.StackSwitcher view_switcher = new Gtk.StackSwitcher();
+        view_switcher.stack = view_stack;
+        view_switcher.get_style_context().add_class(Gtk.STYLE_CLASS_LINKED);
+        view_switcher.get_style_context().add_class(Gtk.STYLE_CLASS_RAISED);
+        
         // pack left-side of window
         headerbar.pack_start(today);
         headerbar.pack_start(nav_buttons);
+        headerbar.pack_start(view_switcher);
         
         quick_add_button = new Gtk.Button.from_icon_name("list-add-symbolic", Gtk.IconSize.MENU);
         quick_add_button.valign = Gtk.Align.CENTER;
@@ -121,32 +154,77 @@ public class MainWindow : Gtk.ApplicationWindow {
         headerbar.pack_end(calendars);
         
         Gtk.Box layout = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+        // if on Unity, since headerbar is not the titlebar, need to pack it like any other widget
 #if ENABLE_UNITY
         layout.pack_start(headerbar, false, true, 0);
 #endif
-        layout.pack_end(month_view.get_container(), true, true, 0);
-        
-        // current host bindings and signals
-        current_view.request_create_timed_event.connect(on_request_create_timed_event);
-        current_view.request_create_all_day_event.connect(on_request_create_all_day_event);
-        current_view.request_display_event.connect(on_request_display_event);
-        current_view.bind_property(View.Controllable.PROP_CURRENT_LABEL, headerbar, "title",
-            BindingFlags.SYNC_CREATE);
-        current_view.bind_property(View.Controllable.PROP_IS_VIEWING_TODAY, today, "sensitive",
-            BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN);
-        current_view.bind_property(View.Controllable.PROP_FIRST_OF_WEEK, this, PROP_FIRST_OF_WEEK,
-            BindingFlags.BIDIRECTIONAL);
+        layout.pack_end(view_stack, true, true, 0);
         
         add(layout);
     }
     
+    public override void map() {
+        // give View.Palette a chance to gather display metrics for the various Views (week, months,
+        // etc.)
+        View.Palette.instance.main_window_mapped(this);
+        
+        base.map();
+    }
+    
+    private void add_controller(View.Controllable controller) {
+        view_stack.add_titled(controller.get_container(), controller.title, controller.title);
+        controller.get_container().show_all();
+    }
+    
+    private unowned View.Container? current_view_container() {
+        return (View.Container?) view_stack.get_visible_child();
+    }
+    
+    private void on_view_changed() {
+        View.Container? view_container = current_view_container();
+        if (view_container != null && view_container.owner == current_controller)
+            return;
+        
+        if (current_controller != null) {
+            // signals
+            current_controller.request_create_timed_event.disconnect(on_request_create_timed_event);
+            current_controller.request_create_all_day_event.disconnect(on_request_create_all_day_event);
+            current_controller.request_display_event.disconnect(on_request_display_event);
+            
+            // clear bindings to unbind all of them
+            current_bindings.clear();
+        }
+        
+        if (view_container != null) {
+            current_controller = view_container.owner;
+            
+            // signals
+            current_controller.request_create_timed_event.connect(on_request_create_timed_event);
+            current_controller.request_create_all_day_event.connect(on_request_create_all_day_event);
+            current_controller.request_display_event.connect(on_request_display_event);
+            
+            // bindings
+            Binding binding = current_controller.bind_property(View.Controllable.PROP_CURRENT_LABEL,
+                headerbar, "title", BindingFlags.SYNC_CREATE);
+            current_bindings.add(binding);
+            
+            binding = current_controller.bind_property(View.Controllable.PROP_IS_VIEWING_TODAY, today,
+                "sensitive", BindingFlags.SYNC_CREATE | BindingFlags.INVERT_BOOLEAN);
+            current_bindings.add(binding);
+            
+            binding = current_controller.bind_property(View.Controllable.PROP_FIRST_OF_WEEK, this,
+                PROP_FIRST_OF_WEEK, BindingFlags.BIDIRECTIONAL);
+            current_bindings.add(binding);
+        }
+    }
+    
     private void show_deck(Gtk.Widget relative_to, Gdk.Point? for_location, Toolkit.Deck deck) {
         Toolkit.DeckWindow deck_window = new Toolkit.DeckWindow(this, deck);
         
         // when the dialog closes, reset View.Controllable state (selection is maintained while
         // use is viewing/editing interaction) and destroy widgets
         deck_window.deck.dismiss.connect(() => {
-            current_view.unselect_all();
+            current_controller.unselect_all();
             deck_window.hide();
             // give the dialog a change to hide before allowing other signals to fire, which may
             // invoke another dialog (prevents multiple dialogs on screen at same time)
@@ -178,15 +256,23 @@ public class MainWindow : Gtk.ApplicationWindow {
     }
     
     private void on_jump_to_today() {
-        current_view.today();
+        current_controller.today();
     }
     
     private void on_next() {
-        current_view.next();
+        current_controller.next();
     }
     
     private void on_previous() {
-        current_view.previous();
+        current_controller.previous();
+    }
+    
+    private void on_view_month() {
+        view_stack.set_visible_child(month_view.get_container());
+    }
+    
+    private void on_view_week() {
+        view_stack.set_visible_child(week_view.get_container());
     }
     
     private void on_request_create_timed_event(Calendar.ExactTimeSpan initial, Gtk.Widget relative_to,
diff --git a/src/tests/tests-calendar-wall-time.vala b/src/tests/tests-calendar-wall-time.vala
new file mode 100644
index 0000000..ef974de
--- /dev/null
+++ b/src/tests/tests-calendar-wall-time.vala
@@ -0,0 +1,71 @@
+/* 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 CalendarWallTime : UnitTest.Harness {
+    public CalendarWallTime() {
+        add_case("round-down-perverse", round_down_perverse);
+        add_case("round-down-zero", round_down_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);
+    }
+    
+    protected override void setup() throws Error {
+        Calendar.init();
+    }
+    
+    protected override void teardown() {
+        Calendar.terminate();
+    }
+    
+    private bool round_down_perverse() throws Error {
+        Calendar.WallTime wall_time = new Calendar.WallTime(10, 12, 14);
+        Calendar.WallTime round_down = wall_time.round_down(-1, Calendar.TimeUnit.MINUTE);
+        
+        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);
+    }
+    
+    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);
+        
+        return 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);
+        
+        return 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);
+        
+        return 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);
+        
+        return round_down.hour == 10 && round_down.minute == 12 && round_down.second == 15;
+    }
+}
+
+}
+
diff --git a/src/tests/tests.vala b/src/tests/tests.vala
index 20b6638..1d484b0 100644
--- a/src/tests/tests.vala
+++ b/src/tests/tests.vala
@@ -11,6 +11,7 @@ public int run(string[] args) {
     UnitTest.Harness.register(new CalendarDate());
     UnitTest.Harness.register(new CalendarMonthSpan());
     UnitTest.Harness.register(new CalendarMonthOfYear());
+    UnitTest.Harness.register(new CalendarWallTime());
     
     return UnitTest.Harness.exec_all();
 }
diff --git a/src/toolkit/toolkit-button-connector.vala b/src/toolkit/toolkit-button-connector.vala
new file mode 100644
index 0000000..915134d
--- /dev/null
+++ b/src/toolkit/toolkit-button-connector.vala
@@ -0,0 +1,334 @@
+/* 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 { link EventConnector} for (mouse) button events.
+ *
+ * "Raw" GDK events may be trapped by subscribing to { link pressed} and { link released}.  These
+ * signals also provide Cancellables; if set (cancelled), the event will not propagate further.
+ *
+ * Otherwise, ButtonConnector will continue monitoring raw events and convert them into friendlier
+ * signals: { link clicked}, { link double_clicked}, and { link triple_clicked}.  A complete set
+ * of press/release events are effectively translated into a single clicked event.  This relieves
+ * the application of the problem of receving a clicked event and having to wait to determine if
+ * a double-click will follow.
+ */
+
+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 uint timeout_id = 0;
+        
+        public signal void release_timeout();
+        
+        public InternalButtonEvent(Gtk.Widget widget, Gdk.EventButton event) {
+            base (widget, event);
+        }
+        
+        ~InternalButtonEvent() {
+            cancel_timeout();
+        }
+        
+        private void cancel_timeout() {
+            if (timeout_id == 0)
+                return;
+            
+            Source.remove(timeout_id);
+            timeout_id = 0;
+        }
+        
+        public override void update_press(Gtk.Widget widget, Gdk.EventButton press_event) {
+            base.update_press(widget, press_event);
+            
+            cancel_timeout();
+        }
+        
+        public override void update_release(Gtk.Widget widget, Gdk.EventButton release_event) {
+            base.update_release(widget, release_event);
+            
+            cancel_timeout();
+            timeout_id = Timeout.add(CLICK_DETERMINATION_DELAY_MSEC, on_timeout, Priority.LOW);
+        }
+        
+        private bool on_timeout() {
+            timeout_id = 0;
+            
+            release_timeout();
+            
+            return false;
+        }
+    }
+    
+    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 Cancellable cancellable = new Cancellable();
+    
+    /**
+     * The "raw" "button-pressed" signal received by { link ButtonConnector}.
+     *
+     * Signal subscribers should cancel the Cancellable to prevent propagation of the event.
+     * This will prevent the various "clicked" signals from firing.
+     */
+    public signal void pressed(Gtk.Widget widget, Gdk.EventButton event, Cancellable cancellable);
+    
+    /**
+     * The "raw" "button-released" signal received by { link ButtonConnector}.
+     *
+     * Signal subscribers should cancel the Cancellable to prevent propagation of the event.
+     * This will prevent the various "clicked" signals from firing.
+     */
+    public signal void released(Gtk.Widget widget, Gdk.EventButton event, Cancellable cancellable);
+    
+    /**
+     * 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.
+     */
+    public signal void clicked(ButtonEvent details, bool guaranteed);
+    
+    /**
+     * Fired when a button is pressed and released twice in succession.
+     *
+     * See { link clicked} for an explanation of the { link guaranteed} flag.
+     */
+    public signal void double_clicked(ButtonEvent details, bool guaranteed);
+    
+    /**
+     * Fired when a button is pressed and released thrice in succession.
+     *
+     * See { link clicked} for an explanation of the { link guaranteed} flag.
+     */
+    public signal void triple_clicked(ButtonEvent details, bool guaranteed);
+    
+    /**
+     * Create a new { link ButtonConnector} for monitoring (mouse) button events from Gtk.Widgets.
+     */
+    public ButtonConnector() {
+        base (Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK);
+    }
+    
+    /**
+     * 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}.
+     */
+    protected virtual bool notify_pressed(Gtk.Widget widget, Gdk.EventButton event) {
+        pressed(widget, event, cancellable);
+        
+        return stop_propagation();
+    }
+    
+    /**
+     * 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}.
+     */
+    protected virtual bool notify_released(Gtk.Widget widget, Gdk.EventButton event) {
+        released(widget, event, cancellable);
+        
+        return stop_propagation();
+    }
+    
+    /**
+     * 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);
+    }
+    
+    /**
+     * 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);
+    }
+    
+    /**
+     * 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 override void connect_signals(Gtk.Widget widget) {
+        // clear this, just in case something was lingering
+        clear_widget(widget);
+        
+        widget.button_press_event.connect(on_button_event);
+        widget.button_release_event.connect(on_button_event);
+    }
+    
+    protected override void disconnect_signals(Gtk.Widget widget) {
+        clear_widget(widget);
+        
+        widget.button_press_event.disconnect(on_button_event);
+        widget.button_release_event.disconnect(on_button_event);
+    }
+    
+    private void clear_widget(Gtk.Widget widget) {
+        primary_states.unset(widget);
+        secondary_states.unset(widget);
+        tertiary_states.unset(widget);
+    }
+    
+    // Checks if the Cancellable has been cancelled, in which case return EVENT_STOP and replaces
+    // the Cancellable
+    private bool stop_propagation() {
+        if (!cancellable.is_cancelled())
+            return EVENT_PROPAGATE;
+        
+        cancellable = new Cancellable();
+        
+        return EVENT_STOP;
+    }
+    
+    private Gee.HashMap<Gtk.Widget, InternalButtonEvent>? get_states_map(Button button) {
+        switch (button) {
+            case Button.PRIMARY:
+                return primary_states;
+            
+            case Button.SECONDARY:
+                return secondary_states;
+            
+            case Button.TERTIARY:
+                return tertiary_states;
+            
+            case Button.OTHER:
+                return null;
+            
+            default:
+                assert_not_reached();
+        }
+    }
+    
+    private bool on_button_event(Gtk.Widget widget, Gdk.EventButton event) {
+        Button button = Button.from_event(event);
+        
+        return process_button_event(widget, event, button, get_states_map(button));
+    }
+    
+    private bool process_button_event(Gtk.Widget widget, Gdk.EventButton event,
+        Button button, Gee.HashMap<Gtk.Widget, InternalButtonEvent>? button_states) {
+        switch(event.type) {
+            case Gdk.EventType.BUTTON_PRESS:
+            case Gdk.EventType.2BUTTON_PRESS:
+            case Gdk.EventType.3BUTTON_PRESS:
+                // notify of raw event
+                if (notify_pressed(widget, event) == EVENT_STOP) {
+                    // drop any lingering state
+                    if (button_states != null)
+                        button_states.unset(widget);
+                    
+                    return EVENT_STOP;
+                }
+                
+                // save state for the release event, potentially updating existing state from
+                // 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);
+                    if (details == null) {
+                        details = new InternalButtonEvent(widget, event);
+                        details.release_timeout.connect(on_release_timeout);
+                        button_states.set(widget, details);
+                    } else {
+                        details.update_press(widget, event);
+                    }
+                }
+            break;
+            
+            case Gdk.EventType.BUTTON_RELEASE:
+                // notify of raw event
+                if (notify_released(widget, event) == EVENT_STOP) {
+                    // release lingering state
+                    if (button_states != null)
+                        button_states.unset(widget);
+                    
+                    return EVENT_STOP;
+                }
+                
+                // update saved state (if any) with release info and start timer
+                if (button_states != null) {
+                    InternalButtonEvent? 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);
+                    }
+                }
+            break;
+        }
+        
+        return EVENT_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
+        switch (details.press_type) {
+            case Gdk.EventType.BUTTON_PRESS:
+                notify_clicked(details, true);
+            break;
+            
+            case Gdk.EventType.2BUTTON_PRESS:
+                notify_double_clicked(details, true);
+            break;
+            
+            case Gdk.EventType.3BUTTON_PRESS:
+                notify_triple_clicked(details, true);
+            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);
+    }
+}
+
+}
+
diff --git a/src/toolkit/toolkit-button-event.vala b/src/toolkit/toolkit-button-event.vala
new file mode 100644
index 0000000..917a54f
--- /dev/null
+++ b/src/toolkit/toolkit-button-event.vala
@@ -0,0 +1,108 @@
+/* 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 {
+
+/**
+ * Enumeration for (mouse) buttons.
+ *
+ * @see ButtonConnector
+ */
+public enum Button {
+    PRIMARY,
+    SECONDARY,
+    TERTIARY,
+    OTHER;
+    
+    /**
+     * Converts the button field of a Gdk.EventButton to a { link Button} enumeration.
+     */
+    public static Button from_event(Gdk.EventButton event) {
+        switch (event.button) {
+            case 1:
+                return PRIMARY;
+            
+            case 3:
+                return SECONDARY;
+            
+            case 2:
+                return TERTIARY;
+            
+            default:
+                return OTHER;
+        }
+    }
+}
+
+/**
+ * Details of a (mouse) button event as reported by { link ButtonConnector}.
+ */
+
+public class ButtonEvent : BaseObject {
+    /**
+     * The Gtk.Widget the button press occurred on.
+     *
+     * Even if the button is released over a different widget, this widget is always reported
+     * by GTK and all coordinates are relative to it.
+     */
+    public Gtk.Widget widget { get; private set; }
+    
+    /**
+     * The { link Button} the event originated from.
+     */
+    public Button button { get; private set; }
+    
+    /**
+     * The last-seen button press type.
+     */
+    public Gdk.EventType press_type { get; private set; }
+    
+    /**
+     * The x,y coordinates (in { link widget}'s coordinate system} the last press occurred.
+     */
+    private Gdk.Point _press_point = Gdk.Point();
+    public Gdk.Point press_point { get { return _press_point; } }
+    
+    /**
+     * The x,y coordinates (in { link widget}'s coordinate system} the last release occurred.
+     */
+    private Gdk.Point _release_point = Gdk.Point();
+    public Gdk.Point release_point { get { return _release_point; } }
+    
+    internal ButtonEvent(Gtk.Widget widget, Gdk.EventButton press_event) {
+        this.widget = widget;
+        button = Button.from_event(press_event);
+        press_type = press_event.type;
+        _press_point.x = (int) press_event.x;
+        _press_point.y = (int) press_event.y;
+    }
+    
+    // Update state with the next button press
+    internal virtual void update_press(Gtk.Widget widget, Gdk.EventButton press_event) {
+        assert(this.widget == widget);
+        assert(Button.from_event(press_event) == button);
+        
+        press_type = press_event.type;
+        _press_point.x = (int) press_event.x;
+        _press_point.y = (int) press_event.y;
+    }
+    
+    // Update state with the next button release and start the release timer
+    internal virtual void update_release(Gtk.Widget widget, Gdk.EventButton release_event) {
+        assert(this.widget == widget);
+        assert(Button.from_event(release_event) == button);
+        
+        _release_point.x = (int) release_event.x;
+        _release_point.y = (int) release_event.y;
+    }
+    
+    public override string to_string() {
+        return "EventDetails: button=%s press_type=%s".printf(button.to_string(), press_type.to_string());
+    }
+}
+
+}
+
diff --git a/src/toolkit/toolkit-deck.vala b/src/toolkit/toolkit-deck.vala
index c8b6803..e5f8acc 100644
--- a/src/toolkit/toolkit-deck.vala
+++ b/src/toolkit/toolkit-deck.vala
@@ -15,11 +15,6 @@ namespace California.Toolkit {
 
 public class Deck : Gtk.Stack {
     /**
-     * A slightly slower transition duration than default.
-     */
-    public const int DEFAULT_TRANSITION_MSEC = 300;
-    
-    /**
      * @inheritedDoc
      */
     public Gtk.Widget? default_widget { get { return null; } }
@@ -72,7 +67,7 @@ public class Deck : Gtk.Stack {
      */
     public Deck() {
         transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT;
-        transition_duration = DEFAULT_TRANSITION_MSEC;
+        transition_duration = DEFAULT_STACK_TRANSITION_DURATION_MSEC;
         
         notify["visible-child"].connect(on_child_to_top);
     }
diff --git a/src/toolkit/toolkit-event-connector.vala b/src/toolkit/toolkit-event-connector.vala
new file mode 100644
index 0000000..66fa695
--- /dev/null
+++ b/src/toolkit/toolkit-event-connector.vala
@@ -0,0 +1,93 @@
+/* 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 {
+
+/**
+ * An EventConnector is a type of signalling mechanism for specific user-input events.
+ *
+ * Gtk.Widgets are connected to EventConnector via { link connect_to}.  EventConnector signals can
+ * then monitored for specific events originating from all the connected widgets.  This promotes
+ * reuse of code, as EventConenctor objects may be shared among disparate widgets, or separate
+ * instances for each, with each EventConnector (or a custom subclass) able to maintain its own
+ * state rather than having to pollute a container widget's space with its own concerns.
+ *
+ * In general, EventConnectors will not work with NO_WINDOW widgets.  Place them in a Gtk.EventBox
+ * and connect this object to that.
+ */
+
+public abstract class EventConnector : BaseObject {
+    // helper consts for subclasses
+    protected const bool EVENT_PROPAGATE = false;
+    protected const bool EVENT_STOP = true;
+    
+    private Gdk.EventMask event_mask;
+    private Gee.HashSet<Gtk.Widget> widgets = new Gee.HashSet<Gtk.Widget>();
+    
+    protected EventConnector(Gdk.EventMask event_mask) {
+        this.event_mask = event_mask;
+    }
+    
+    ~EventConnector() {
+        // use to_array() to avoid iterator issues as widgets are removed
+        foreach (Gtk.Widget widget in widgets.to_array())
+            disconnect_from(widget);
+    }
+    
+    /**
+     * Have this { link EventConnector} monitor the widget for the connector's specific events.
+     */
+    public void connect_to(Gtk.Widget widget) {
+        // don't continue if already connected
+        if (!widgets.add(widget))
+            return;
+        
+        widget.add_events(event_mask);
+        connect_signals(widget);
+        widget.destroy.connect(on_widget_destroy);
+    }
+    
+    /**
+     * Have this { link EventConnector} stop monitoring the widget for the connector's specific
+     * events.
+     *
+     * If the widget is destroyed, EventConnector will automatically stop monitoring it.
+     */
+    public void disconnect_from(Gtk.Widget widget) {
+        // don't disconnect if not connected
+        if (!widgets.remove(widget))
+            return;
+        
+        // can't remove event mask safely, so just don't
+        disconnect_signals(widget);
+        widget.destroy.disconnect(on_widget_destroy);
+    }
+    
+    private void on_widget_destroy(Gtk.Widget widget) {
+        disconnect_from(widget);
+    }
+    
+    /**
+     * Subclasses should use this method to connect to their appropriate signals.
+     *
+     * The event mask is updated automatically, so that's not necessary.
+     */
+    protected abstract void connect_signals(Gtk.Widget widget);
+    
+    /**
+     * Subclasses should use this method to disconnect the signals they connected to.
+     *
+     * This is also a good time to clean up any lingering state.
+     */
+    protected abstract void disconnect_signals(Gtk.Widget widget);
+    
+    public override string to_string() {
+        return get_class().get_type().name();
+    }
+}
+
+}
+
diff --git a/src/toolkit/toolkit-listbox-model.vala b/src/toolkit/toolkit-listbox-model.vala
index 7fc29a5..aca1582 100644
--- a/src/toolkit/toolkit-listbox-model.vala
+++ b/src/toolkit/toolkit-listbox-model.vala
@@ -182,7 +182,7 @@ public class ListBoxModel<G> : BaseObject {
             return false;
         
         if (remove_from_listbox)
-            listbox.remove(row);
+            row.destroy();
         
         removed(item);
         
diff --git a/src/toolkit/toolkit-stack-model.vala b/src/toolkit/toolkit-stack-model.vala
new file mode 100644
index 0000000..cbd72be
--- /dev/null
+++ b/src/toolkit/toolkit-stack-model.vala
@@ -0,0 +1,337 @@
+/* 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 caching read-ahead model for Gtk.Stack.
+ *
+ * StackModel allows for items of any type to be stored in sorted order and presented in a Gtk.Stack
+ * via presentation Gtk.Widgets generated by the caller for each item.  Gtk.Stack (and
+ * Gtk.Container) do not have a notion of ordering, so StackModel "fakes" a sense of ordering by
+ * configuring the Gtk.Stack prior to each transition to make it look like one presentation widget
+ * is spatially above/below or left/right to the widget being transitioned to.
+ *
+ * StackModel also caches presentation widgets.  A { link TrimPresentationFromCache} callback can
+ * be supplied to selectively remove widgets from the cache, while a
+ * { link EnsurePresentationInCache} callback can be supplied to enforce locality.
+ *
+ * If caching and read-ahead are used, the Gtk.Stack is probably not well-suited for a
+ * Gtk.StackSwitcher, since items may come and go at almost any time.  It's for this reason that
+ * { link ModelPresentation} returns an id but not a title for the widget.
+ *
+ * @see Deck
+ */
+
+public class StackModel<G> : BaseObject {
+    public const string PROP_STACK = "stack";
+    public const string PROP_VISIBLE_ITEM = "visible-item";
+    
+    /**
+     * Transition type for spatial transitions according to ordering.
+     */
+    public enum OrderedTransitionType {
+        CROSSFADE,
+        SLIDE_LEFT_RIGHT,
+        SLIDE_UP_DOWN;
+        
+        /**
+         * Returns the Gtk.StackTransitionType that matches the { link OrderedTransitionType} for
+         * the direction implied by the comparison result.
+         *
+         * Negative values are to the left or up, positive values are to the right or down.
+         * There is no direction for crossfading.  Zero means equal, returning NONE unless the
+         * ordered type is CROSSFADE.
+         */
+        public Gtk.StackTransitionType to_stack_transition(int compare) {
+            if (compare == 0)
+                return (this == CROSSFADE) ? Gtk.StackTransitionType.CROSSFADE : 
Gtk.StackTransitionType.NONE;
+            
+            switch (this) {
+                case CROSSFADE:
+                    return Gtk.StackTransitionType.CROSSFADE;
+                
+                case SLIDE_LEFT_RIGHT:
+                    return (compare < 0) ? Gtk.StackTransitionType.SLIDE_LEFT : 
Gtk.StackTransitionType.SLIDE_RIGHT;
+                
+                case SLIDE_UP_DOWN:
+                    return (compare < 0) ? Gtk.StackTransitionType.SLIDE_UP : 
Gtk.StackTransitionType.SLIDE_DOWN;
+                
+                default:
+                    assert_not_reached();
+            }
+        }
+    }
+    
+    /**
+     * Callback to convert the item into a child widget for the { link stack}.
+     *
+     * The callback may also return an identifier for the widget, which may be used to reference
+     * it later in the stack.  Note that { link StackModel} doesn't store or track this identifier.
+     */
+    public delegate Gtk.Widget ModelPresentation<G>(G item, out string? id);
+    
+    /**
+     * Callback for determining if the presentation Gtk.Widget for an item should be kept in the
+     * cache.
+     *
+     * Returns true if the widget associated with the item should be removed from the cache.
+     * visible_item indicates which item is currently being presented to the user.
+     */
+    public delegate bool TrimPresentationFromCache<G>(G item, G? visible_item);
+    
+    /**
+     * Callback for maintaining read-ahead presentation Gtk.Widgets in the cache.
+     *
+     * The caller should return a collection of items that should be introduced into the cache,
+     * if not already present.  Presentation widgets will be generated for the items to ensure
+     * they're ready for display.
+     *
+     * This is used as a read-ahead mechanism as well as a way for the caller to enforce cache
+     * locality.  It can be used, for example, to guarantee that certain items are always stored
+     * in the cache, such as a "home" page, as well as the next and previous ''n'' items.
+     *
+     * visible_item indicates which item is currently being presented to the user.
+     */
+    public delegate Gee.Collection<G>? EnsurePresentationInCache<G>(G? visible_item);
+    
+    /**
+     * The Gtk.Stack the { link StackModel} is backing.
+     */
+    public Gtk.Stack stack { get; private set; }
+    
+    /**
+     * The current visible item in the { link stack}.
+     */
+    public G? visible_item { get; private set; default = null; }
+    
+    private OrderedTransitionType ordered_transition_type;
+    private unowned ModelPresentation<G> model_presentation;
+    private unowned TrimPresentationFromCache<G>? trim_from_cache;
+    private unowned EnsurePresentationInCache<G>? ensure_in_cache;
+    private unowned CompareDataFunc<G>? comparator;
+    private Gee.HashMap<G, Gtk.Widget?> items;
+    private bool in_balance_cache = false;
+    private bool stack_destroyed = false;
+    
+    public StackModel(Gtk.Stack stack,
+        OrderedTransitionType ordered_transition_type,
+        ModelPresentation<G> model_presentation,
+        TrimPresentationFromCache<G>? trim_from_cache = null,
+        EnsurePresentationInCache<G>? ensure_in_cache = null,
+        CompareDataFunc<G>? comparator = null,
+        owned Gee.HashDataFunc<G>? hash_func = null,
+        owned Gee.EqualDataFunc<G>? equal_func = null) {
+        
+        this.stack = stack;
+        this.ordered_transition_type = ordered_transition_type;
+        this.model_presentation = model_presentation;
+        this.trim_from_cache = trim_from_cache;
+        this.ensure_in_cache = ensure_in_cache;
+        this.comparator = comparator;
+        
+        items = new Gee.HashMap<G, Gtk.Widget?>((owned) hash_func, (owned) equal_func);
+        
+        stack.remove.connect(on_stack_removed);
+        stack.notify["visible-child"].connect(on_stack_child_visible);
+        stack.destroy.connect(on_stack_destroyed);
+    }
+    
+    ~StackModel() {
+        stack.remove.disconnect(on_stack_removed);
+        stack.notify["visible-child"].disconnect(on_stack_child_visible);
+        stack.destroy.disconnect(on_stack_destroyed);
+    }
+    
+    /**
+     * Add the item to the { link StackModel}.
+     *
+     * This will not necessarily make the item visible (in particular, only if the { link stack}
+     * is already empty).  Use { link show_item} for that.
+     *
+     * Returns true if the item was added, false otherwise (already present).
+     */
+    public bool add(G item) {
+        if (items.has_key(item))
+            return false;
+        
+        items.set(item, null);
+        
+        // don't need to balance the cache; "visible-child" will do that automatically when
+        // show() is called
+        
+        return true;
+    }
+    
+    /**
+     * Removes the item from the { link StackModel}.
+     *
+     * If the item is already visible in the { link stack}, the Gtk.Stack will itself determine
+     * which widget will take its place.  If this is undesirable, call { link show} ''before''
+     * removing the item.
+     *
+     * Returns true if the item was removed, false otherwise (not present).
+     */
+    public bool remove(G item) {
+        Gtk.Widget? presentation;
+        if (!items.unset(item, out presentation))
+            return false;
+        
+        // remove from stack, let "removed" signal handler do the rest
+        if (presentation != null)
+            presentation.destroy();
+        
+        return true;
+    }
+    
+    /**
+     * Show the item using the specified transition.
+     *
+     * If the item was not already present in { link StackModel}, it will be added.
+     *
+     * @see add
+     */
+    public void show(G item) {
+        add(item);
+        
+        Gtk.Widget presentation = ensure_presentation_exists(item);
+        
+        if (visible_item == null) {
+            stack.transition_type = Gtk.StackTransitionType.NONE;
+        } else {
+            stack.transition_type = ordered_transition_type.to_stack_transition(
+                item_comparator(visible_item, item));
+        }
+        
+        stack.set_visible_child(presentation);
+    }
+    
+    private void on_stack_removed(Gtk.Widget child) {
+        // remove from cache, if present
+        bool found = false;
+        Gee.MapIterator<G, Gtk.Widget?> iter = items.map_iterator();
+        while (iter.next()) {
+            if (iter.get_value() == child) {
+                found = true;
+                iter.set_value(null);
+                
+                break;
+            }
+        }
+        
+        // only destroy widget if found (otherwise added externally from StackModel, so not ours
+        // to break)
+        if (found) {
+            child.destroy();
+            balance_cache("on_stack_removed");
+        }
+    }
+    
+    private void on_stack_child_visible() {
+        if (stack.visible_child == null) {
+            visible_item = null;
+            
+            return;
+        }
+        
+        // find item for widget ... obviously for larger stacks a reverse mapping (perhaps with
+        // get/set_data()) would be preferable, but this will do for now
+        Gee.MapIterator<G, Gtk.Widget?> iter = items.map_iterator();
+        while (iter.next()) {
+            if (iter.get_value() == stack.visible_child) {
+                visible_item = iter.get_key();
+                
+                // to avoid stutter, only balance the cache when the transition has completed,
+                // which (apparently) it has not when this change is made (probably made at start
+                // of transition, not the end) ... "transition-running" property would be useful
+                // here, but that's not available until GTK 3.12
+                Idle.add(() => {
+                    balance_cache("on_stack_child_visible");
+                    
+                    return false;
+                }, Priority.LOW);
+                
+                return;
+            }
+        }
+        
+        // nothing found
+        visible_item = null;
+    }
+    
+    private void on_stack_destroyed() {
+        stack_destroyed = true;
+    }
+    
+    private Gtk.Widget ensure_presentation_exists(G item) {
+        Gtk.Widget? presentation = items.get(item);
+        if (presentation != null)
+            return presentation;
+        
+        // item -> presentation widget and identifier
+        string? id;
+        presentation = model_presentation(item, out id);
+        presentation.show_all();
+        
+        // mappings
+        items.set(item, presentation);
+        
+        // add to stack using identifier
+        if (id != null)
+            stack.add_named(presentation, id);
+        else
+            stack.add(presentation);
+        
+        return presentation;
+    }
+    
+    private void balance_cache(string why) {
+        // don't balance the cache if the stack is destroyed or if already balancing the cache
+        if (stack_destroyed || in_balance_cache)
+            return;
+        
+        in_balance_cache = true;
+        
+        // trim existing widgets from cache
+        if (trim_from_cache != null) {
+            Gee.MapIterator<G, Gtk.Widget?> iter = items.map_iterator();
+            while (iter.next()) {
+                Gtk.Widget? presentation = iter.get_value();
+                if (presentation != null && trim_from_cache(iter.get_key(), visible_item)) {
+                    // set_value before removing from stack to prevent our signal handler from
+                    // unsetting underneath us and causing iterator stamp problems
+                    iter.set_value(null);
+                    presentation.destroy();
+                }
+            }
+        }
+        
+        // read-ahead (add any widgets the user requires)
+        if (ensure_in_cache != null) {
+            Gee.Collection<G>? ensure_items = ensure_in_cache(visible_item);
+            if (ensure_items != null && ensure_items.size > 0) {
+                foreach (G ensure_item in ensure_items)
+                    ensure_presentation_exists(ensure_item);
+            }
+        }
+        
+        in_balance_cache = false;
+    }
+    
+    private int item_comparator(G a, G b) {
+        if (comparator != null)
+            return comparator(a, b);
+        
+        return Gee.Functions.get_compare_func_for(typeof(G))(a, b);
+    }
+    
+    public override string to_string() {
+        return "StackModel (%d items)".printf(items.size);
+    }
+}
+
+}
+
diff --git a/src/toolkit/toolkit.vala b/src/toolkit/toolkit.vala
index c8afc0a..ec84471 100644
--- a/src/toolkit/toolkit.vala
+++ b/src/toolkit/toolkit.vala
@@ -10,6 +10,18 @@
 
 namespace California.Toolkit {
 
+/**
+ * Gtk.Stack transition duration is a little quick for my tastes; this default value seems a bit
+ * smoother to me.
+ */
+public const int DEFAULT_STACK_TRANSITION_DURATION_MSEC = 300;
+
+/**
+ * Gtk.Stack transition duration for slower transitions (where it really needs to be obvious to
+ * user what's going on).
+ */
+public const int SLOW_STACK_TRANSITION_DURATION_MSEC = 500;
+
 private int init_count = 0;
 
 public void init() throws Error {
diff --git a/src/util/util-gfx.vala b/src/util/util-gfx.vala
index e9c83a5..72bab00 100644
--- a/src/util/util-gfx.vala
+++ b/src/util/util-gfx.vala
@@ -7,8 +7,10 @@
 namespace California.Gfx {
 
 public const Gdk.Color RGB_BLACK = { 0, 0, 0 };
+public const Gdk.Color RGB_WHITE = { 255, 255, 255 };
 
-public const Gdk.RGBA RGBA_BLACK = { 0.0, 0.0, 0.0, 0.0 };
+public const Gdk.RGBA RGBA_BLACK = { 0.0, 0.0, 0.0, 1.0 };
+public const Gdk.RGBA RGBA_WHITE = { 1.0, 1.0, 1.0, 1.0 };
 
 /**
  * Convert an RGB string into an RGB structure.
diff --git a/src/view/common/common-events-cell.vala b/src/view/common/common-events-cell.vala
new file mode 100644
index 0000000..4b54ab0
--- /dev/null
+++ b/src/view/common/common-events-cell.vala
@@ -0,0 +1,610 @@
+/* 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.View.Common {
+
+/**
+ * A (generally) square cell which displays { link Component.Event}s, one per line, with brief
+ * time information and summary and a capped bar for all-day or day-spanning events.
+ */
+
+internal abstract class EventsCell : Gtk.EventBox {
+    public const string PROP_DATE = "date";
+    public const string PROP_NEIGHBORS = "neighbors";
+    public const string PROP_TOP_LINE_TEXT = "top-line-text";
+    public const string PROP_TOP_LINE_RGBA = "top-line-rgba";
+    public const string PROP_SELECTED = "selected";
+    
+    private const double ROUNDED_CAP_RADIUS = 5.0;
+    private const int POINTED_CAP_WIDTH_PX = 6;
+    
+    private const double DEGREES = Math.PI / 180.0;
+    
+    private const string KEY_TOOLTIP = "california-events-cell-tooltip";
+    
+    private const Calendar.WallTime.PrettyFlag PRETTY_TIME_FLAGS =
+        Calendar.WallTime.PrettyFlag.OPTIONAL_MINUTES
+        | Calendar.WallTime.PrettyFlag.BRIEF_MERIDIEM;
+    
+    private enum CapEffect {
+        NONE,
+        BLOCKED,
+        ROUNDED,
+        POINTED
+    }
+    
+    /**
+     * The { link Calendar.Date} this { link EventsCell} is displaying.
+     */
+    public Calendar.Date date { get; private set; }
+    
+    /**
+     * The horizontal neighbors for this { link EventsCell}.
+     *
+     * Since cells are designed to be displayed horizontally (say, 7 per week), each cell needs
+     * to know the { link Calendar.Date}s of its neighbors so they can arrange line numbers when
+     * displaying all-day and day-spanning events.
+     */
+    public Calendar.DateSpan neighbors { get; private set; }
+    
+    /**
+     * Top line (title or summary) text, drawn in { link Palette.normal_font}.
+     *
+     * Set to empty string if space should be reserved but blank, null if not used and class may
+     * use space to draw events.
+     */
+    public string? top_line_text { get; set; default = null; }
+    
+    /**
+     * Color of { link top_line_text}.
+     */
+    private Gdk.RGBA _top_line_rgba = Gdk.RGBA();
+    public Gdk.RGBA top_line_rgba {
+        get { return _top_line_rgba; }
+        set { _top_line_rgba = value; queue_draw(); }
+    }
+    
+    // to avoid lots of redraws, only queue_draw() if set changes value
+    private bool _selected = false;
+    public bool selected {
+        get {
+            return _selected;
+        }
+        
+        set {
+            if (_selected != value)
+                queue_draw();
+            
+            _selected = value;
+        }
+    }
+    
+    private Gee.TreeSet<Component.Event> sorted_events = new 
Gee.TreeSet<Component.Event>(all_day_comparator);
+    private Gee.HashMap<int, Component.Event> line_to_event = new Gee.HashMap<int, Component.Event>();
+    
+    private Gtk.DrawingArea canvas = new Gtk.DrawingArea();
+    
+    public EventsCell(Calendar.Date date, Calendar.DateSpan neighbors) {
+        assert(date in neighbors);
+        
+        this.date = date;
+        this.neighbors = neighbors;
+        top_line_rgba = Palette.instance.day_in_range;
+        
+        // see query_tooltip() for implementation
+        has_tooltip = true;
+        
+        // wrap the EventBox around the DrawingArea, which is the real widget of interest for this
+        // class
+        add(canvas);
+        
+        notify[PROP_TOP_LINE_TEXT].connect(queue_draw);
+        
+        Palette.instance.palette_changed.connect(queue_draw);
+        Calendar.System.instance.is_24hr_changed.connect(on_24hr_changed);
+        Calendar.System.instance.today_changed.connect(on_today_changed);
+        
+        canvas.draw.connect(on_draw);
+    }
+    
+    ~EventsCell() {
+        Palette.instance.palette_changed.disconnect(queue_draw);
+        Calendar.System.instance.is_24hr_changed.disconnect(on_24hr_changed);
+        Calendar.System.instance.today_changed.disconnect(on_today_changed);
+    }
+    
+    /**
+     * Subclasses must provide a translation of a { link Calendar.Date} into a { link EventsCell}
+     * adjoining this one (in whatever container they're associated with).
+     *
+     * This allows for EventCells to communicate with each other to arrange line numbering for
+     * all-day and day-spanning events.
+     */
+    protected abstract EventsCell? get_cell_for_date(Calendar.Date cell_date);
+    
+    // this comparator uses the standard Event comparator with one exception: if both Events require
+    // solid span lines, it sorts the one(s) with the furthest out end dates to the top, to ensure
+    // they are at the top of the drawn lines and prevent gaps and skips in the connected bars
+    private static int all_day_comparator(Component.Event a, Component.Event b) {
+        if (a == b)
+            return 0;
+        
+        if (!a.is_day_spanning && !b.is_day_spanning)
+            return a.compare_to(b);
+        
+        Calendar.DateSpan a_span = a.get_event_date_span(Calendar.Timezone.local);
+        Calendar.DateSpan b_span = b.get_event_date_span(Calendar.Timezone.local);
+        
+        int compare = a_span.start_date.compare_to(b_span.start_date);
+        if (compare != 0)
+            return compare;
+        
+        compare = b_span.end_date.compare_to(a_span.end_date);
+        if (compare != 0)
+            return compare;
+        
+        // to stabilize
+        return a.compare_to(b);
+    }
+    
+    /**
+     * Returns true if the point at x,y is within the { link Cell}'s width and height.
+     */
+    public bool is_hit(int x, int y) {
+        return x >= 0 && x < get_allocated_width() && y >= 0 && y < get_allocated_height();
+    }
+    
+    /**
+     * Returns the assigned line number for the event, -1 if not found in { link Cell}.
+     */
+    public int get_line_for_event(Component.Event event) {
+        Gee.MapIterator<int, Component.Event> iter = line_to_event.map_iterator();
+        while (iter.next()) {
+            if (iter.get_value().equal_to(event))
+                return iter.get_key();
+        }
+        
+        return -1;
+    }
+    
+    public void change_date_and_neighbors(Calendar.Date date, Calendar.DateSpan neighbors) {
+        assert(date in neighbors);
+        
+        if (!date.equal_to(this.date)) {
+            this.date = date;
+            
+            // stored events are now bogus
+            clear();
+            queue_draw();
+        }
+        
+        if (!neighbors.equal_to(this.neighbors)) {
+            this.neighbors = neighbors;
+            
+            // need to reassign line numbers, as they depend on neighbors
+            assign_line_numbers();
+            queue_draw();
+        }
+    }
+    
+    public void clear() {
+        line_to_event.clear();
+        
+        foreach (Component.Event event in sorted_events.to_array())
+            internal_remove_event(event);
+        
+        queue_draw();
+    }
+    
+    public void add_event(Component.Event event) {
+        if (!sorted_events.add(event))
+            return;
+        
+        // subscribe to interesting mutable properties
+        event.notify[Component.Event.PROP_SUMMARY].connect(queue_draw);
+        event.notify[Component.Event.PROP_DATE_SPAN].connect(on_span_updated);
+        event.notify[Component.Event.PROP_EXACT_TIME_SPAN].connect(on_span_updated);
+        
+        assign_line_numbers();
+        
+        queue_draw();
+    }
+    
+    private bool internal_remove_event(Component.Event event) {
+        if (!sorted_events.remove(event))
+            return false;
+        
+        event.notify[Component.Event.PROP_SUMMARY].disconnect(queue_draw);
+        event.notify[Component.Event.PROP_DATE_SPAN].disconnect(on_span_updated);
+        event.notify[Component.Event.PROP_EXACT_TIME_SPAN].disconnect(on_span_updated);
+        
+        return true;
+    }
+    
+    public void remove_event(Component.Event event) {
+        if (!internal_remove_event(event))
+            return;
+        
+        assign_line_numbers();
+        
+        queue_draw();
+    }
+    
+    /**
+     * To be called by the owning widget when a calendar's visibility has changed.
+     *
+     * This causes event line numbers to be reassigned and thie { link Cell} redrawn, if the
+     * calendar in question has any events in this date.
+     */
+    public void notify_calendar_visibility_changed(Backing.CalendarSource calendar_source) {
+        if (!traverse<Component.Event>(sorted_events).any((event) => event.calendar_source == 
calendar_source))
+            return;
+        
+        // found one
+        assign_line_numbers();
+        queue_draw();
+    }
+    
+    // Called internally by other Cells when (a) they're in charge of assigning a multi-day event
+    // its line number for the week and (b) that line number has changed.
+    private void notify_assigned_line_number_changed(Gee.Collection<Component.Event> events) {
+        if (!traverse<Component.Event>(sorted_events).contains_any(events))
+            return;
+        
+        assign_line_numbers();
+        queue_draw();
+    }
+    
+    // each event gets a line of the cell to draw in; this clears all assigned line numbers and
+    // re-assigns from the sorted set of events, making sure holes are filled where possible ...
+    // if an event starts in this cell or this cell is the first day of a week an event is in,
+    // this cell is responsible for assigning a line number to it, which the other cells of the
+    // same week will honor (so a continuous line can be drawn)
+    private void assign_line_numbers() {
+        Gee.HashMap<int, Component.Event> old_line_to_event = line_to_event;
+        line_to_event = new Gee.HashMap<int, Component.Event>();
+        
+        // track each event whose line number this cell is responsible for assigning that gets
+        // reassigned because of this
+        Gee.ArrayList<Component.Event> reassigned = new Gee.ArrayList<Component.Event>();
+        
+        foreach (Component.Event event in sorted_events) {
+            if (!event.calendar_source.visible)
+                continue;
+            
+            bool notify_reassigned = false;
+            if (event.is_day_spanning) {
+                // get the first day of this week the event exists in ... if not the current cell's
+                // date, get the assigned line number from the first day of this week the event
+                // exists in
+                Calendar.Date first_date = get_event_first_day_in_neighbors(event);
+                if (!date.equal_to(first_date)) {
+                    int event_line = -1;
+                    EventsCell? cell = get_cell_for_date(first_date);
+                    if (cell != null)
+                        event_line = cell.get_line_for_event(event);
+                    
+                    if (event_line >= 0) {
+                        assign_line_number(event_line, event);
+                        
+                        continue;
+                    }
+                } else {
+                    // only worried about multi-day events being reassigned, as that's what effects
+                    // other cells (i.e. when notifying of reassignment)
+                    notify_reassigned = event.get_event_date_span(Calendar.Timezone.local).duration.days > 1;
+                }
+            } else if (!event.is_all_day) {
+                // if timed event is in this date but started elsewhere, don't display (unless it
+                // requires a span, above)
+                Calendar.Date start_date = new Calendar.Date.from_exact_time(
+                    event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local));
+                if (!start_date.equal_to(date))
+                    continue;
+            }
+            
+            // otherwise, a timed event, a single-day event, or a multi-day event which starts here,
+            // so assign
+            int assigned = assign_line_number(-1, event);
+            
+            // if this cell assigns the line number and the event is not new and the number has changed,
+            // inform all the other cells following this day's in the current week
+            if (notify_reassigned && old_line_to_event.values.contains(event) && 
old_line_to_event.get(assigned) != event)
+                reassigned.add(event);
+        }
+        
+        if (reassigned.size > 0) {
+            // only need to tell cells following this day's neighbors about the reassignment
+            Calendar.DateSpan span = new Calendar.DateSpan(date.next(), neighbors.end_date).clamp_between(
+                neighbors);
+            
+            foreach (Calendar.Date span_date in span) {
+                EventsCell? cell = get_cell_for_date(span_date);
+                if (cell != null && cell != this)
+                    cell.notify_assigned_line_number_changed(reassigned);
+            }
+        }
+    }
+    
+    private int assign_line_number(int force_line_number, Component.Event event) {
+        // kinda dumb, but this prevents holes appearing in lines where, due to the shape of the
+        // all-day events, could be filled
+        int line_number = 0;
+        if (force_line_number < 0) {
+            while (line_to_event.has_key(line_number))
+                line_number++;
+        } else {
+            line_number = force_line_number;
+        }
+        
+        line_to_event.set(line_number, event);
+        
+        return line_number;
+    }
+    
+    public bool has_events() {
+        return sorted_events.size > 0;
+    }
+    
+    private void on_24hr_changed() {
+        if (has_events())
+            queue_draw();
+    }
+    
+    private void on_today_changed(Calendar.Date old_today, Calendar.Date new_today) {
+        // need to know re: redrawing background color to indicate current day
+        if (date.equal_to(old_today) || date.equal_to(new_today))
+            queue_draw();
+    }
+    
+    private void on_span_updated(Object object, ParamSpec param) {
+        Component.Event event = (Component.Event) object;
+        
+        // remove from cell if no longer in this day, otherwise remove and add again to sorted_events
+        // to re-sort
+        if (!(date in event.get_event_date_span(Calendar.Timezone.local))) {
+            remove_event(event);
+        } else if (sorted_events.remove(event)) {
+            sorted_events.add(event);
+            assign_line_numbers();
+        }
+        
+        queue_draw();
+    }
+    
+    public override bool query_tooltip(int x, int y, bool keyboard_mode, Gtk.Tooltip tooltip) {
+        Component.Event? event = get_event_at(Gdk.Point() { x = x, y = y });
+        if (event == null)
+            return false;
+        
+        string? tooltip_text = event.get_data<string?>(KEY_TOOLTIP);
+        if (String.is_empty(tooltip_text))
+            return false;
+        
+        tooltip.set_text(tooltip_text);
+        
+        return true;
+    }
+    
+    // Returns the first day of this cell's neighbors that the event is in ... this could be
+    // the event's starting day or the first day of this week (i.e. Monday or Sunday), depending
+    // on the definition of neighbors
+    private Calendar.Date get_event_first_day_in_neighbors(Component.Event event) {
+        // Remember: event start date may be before the date of any of this cell's neighbors
+        Calendar.Date event_start_date = event.get_event_date_span(Calendar.Timezone.local).start_date;
+        
+        return (event_start_date in neighbors) ? event_start_date : neighbors.start_date;
+    }
+    
+    /**
+     * Override to draw borders at the right time in the layering.
+     *
+     * This keeps solid all-day bars on top of the borders, achieving an effect of continuation.
+     */
+    protected virtual void draw_borders(Cairo.Context ctx) {
+    }
+    
+    private bool on_draw(Cairo.Context ctx) {
+        // shade background of cell for selection or if today
+        if (selected) {
+            Gdk.cairo_set_source_rgba(ctx, Palette.instance.selection);
+            ctx.paint();
+        } else if (date.equal_to(Calendar.System.today)) {
+            Gdk.cairo_set_source_rgba(ctx, Palette.instance.current_day);
+            ctx.paint();
+        }
+        
+        // draw borders now, before everything else (but after background color)
+        ctx.save();
+        draw_borders(ctx);
+        ctx.restore();
+        
+        if (top_line_text != null)
+            draw_line_of_text(ctx, -1, top_line_rgba, top_line_text, CapEffect.NONE, CapEffect.NONE);
+        
+        // walk the assigned line numbers for each event and draw
+        Gee.MapIterator<int, Component.Event> iter = line_to_event.map_iterator();
+        while (iter.next()) {
+            Component.Event event = iter.get_value();
+            Calendar.DateSpan date_span = event.get_event_date_span(Calendar.Timezone.local);
+            
+            bool display_text = true;
+            if (event.is_day_spanning) {
+                // only show the title if (a) the first day of an all-day event or (b) this is the
+                // first day of a contiguous span of a multi-day event.  (b) handles the contingency of a
+                // multi-day event starting in a previous week prior to the top of the current view
+                display_text = date_span.start_date.equal_to(date) || neighbors.start_date.equal_to(date);
+            }
+            
+            string text;
+            if (display_text) {
+                if (event.is_all_day) {
+                    text = event.summary;
+                } else {
+                    Calendar.ExactTime local_start = event.exact_time_span.start_exact_time.to_timezone(
+                        Calendar.Timezone.local);
+                    text = "%s %s".printf(local_start.to_pretty_time_string(PRETTY_TIME_FLAGS), 
event.summary);
+                }
+            } else {
+                text = "";
+            }
+            
+            // use caps on both ends of all-day events depending whether this is the start, end,
+            // or start/end of week of continuing event
+            CapEffect left_effect = CapEffect.NONE;
+            CapEffect right_effect = CapEffect.NONE;
+            if (event.is_day_spanning) {
+                if (date_span.start_date.equal_to(date))
+                    left_effect = CapEffect.ROUNDED;
+                else if (neighbors.start_date.equal_to(date))
+                    left_effect = CapEffect.POINTED;
+                else
+                    left_effect = CapEffect.BLOCKED;
+                
+                if (date_span.end_date.equal_to(date))
+                    right_effect = CapEffect.ROUNDED;
+                else if (neighbors.end_date.equal_to(date))
+                    right_effect = CapEffect.POINTED;
+                else
+                    right_effect = CapEffect.BLOCKED;
+            }
+            
+            Pango.Layout layout = draw_line_of_text(ctx, iter.get_key(), 
event.calendar_source.color_as_rgba(),
+                text, left_effect, right_effect);
+            event.set_data<string?>(KEY_TOOLTIP, layout.is_ellipsized() ? text : null);
+        }
+        
+        return true;
+    }
+    
+    // Returns top y position of line; negative line numbers are treated as top line
+    // The number is currently not clamped to the height of the widget.
+    private int get_line_top_y(int line_number) {
+        int y;
+        if (line_number < 0) {
+            // if no top line, line_number < 0 is bogus
+            y = (top_line_text != null) ? Palette.TEXT_MARGIN_PX : 0;
+        } else {
+            y = Palette.TEXT_MARGIN_PX;
+            
+            // starting y of top line
+            if (top_line_text != null)
+                y += Palette.instance.normal_font_height_px + Palette.LINE_PADDING_PX;
+            
+            // add additional lines
+            y += line_number * (Palette.instance.small_font_height_px + Palette.LINE_PADDING_PX);
+        }
+        
+        return y;
+    }
+    
+    // If line number is negative, the top line is drawn; otherwise, zero-based line numbers get
+    // "regular" treatment
+    private Pango.Layout draw_line_of_text(Cairo.Context ctx, int line_number, Gdk.RGBA rgba,
+        string text, CapEffect left_effect, CapEffect right_effect) {
+        bool is_reversed = (left_effect != CapEffect.NONE || right_effect != CapEffect.NONE);
+        
+        int left = 0;
+        int right = get_allocated_width();
+        int top = get_line_top_y(line_number);
+        int bottom = top + Palette.instance.small_font_height_px;
+        
+        // use event color for text unless reversed, where it becomes the background color
+        Gdk.cairo_set_source_rgba(ctx, rgba);
+        if (is_reversed) {
+            // draw background rectangle in spec'd color with text in white
+            switch (right_effect) {
+                case CapEffect.ROUNDED:
+                    ctx.new_sub_path();
+                    // sub 2 to avoid touching right calendar line
+                    ctx.arc(right - 2 - ROUNDED_CAP_RADIUS, top + ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
+                        -90.0 * DEGREES, 0 * DEGREES);
+                    ctx.arc(right - 2 - ROUNDED_CAP_RADIUS, bottom - ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
+                        0 * DEGREES, 90.0 * DEGREES);
+                break;
+                
+                case CapEffect.POINTED:
+                    ctx.move_to(right - POINTED_CAP_WIDTH_PX, top);
+                    ctx.line_to(right - 1, top + (Palette.instance.small_font_height_px / 2));
+                    ctx.line_to(right - POINTED_CAP_WIDTH_PX, bottom);
+                break;
+                
+                case CapEffect.BLOCKED:
+                default:
+                    ctx.move_to(right, top);
+                    ctx.line_to(right, bottom);
+                break;
+            }
+            
+            switch (left_effect) {
+                case CapEffect.ROUNDED:
+                    // add one to avoid touching cell to the left's right calendar line
+                    ctx.arc(left + 1 + ROUNDED_CAP_RADIUS, bottom - ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
+                        90.0 * DEGREES, 180.0 * DEGREES);
+                    ctx.arc(left + 1 + ROUNDED_CAP_RADIUS, top + ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
+                        180.0 * DEGREES, 270.0 * DEGREES);
+                break;
+                
+                case CapEffect.POINTED:
+                    ctx.line_to(left + POINTED_CAP_WIDTH_PX, bottom);
+                    ctx.line_to(left + 1, top + (Palette.instance.small_font_height_px / 2));
+                    ctx.line_to(left + POINTED_CAP_WIDTH_PX, top);
+                break;
+                
+                case CapEffect.BLOCKED:
+                default:
+                    ctx.line_to(left, bottom);
+                    ctx.line_to(left, top);
+                break;
+            }
+            
+            // fill with event color
+            ctx.fill_preserve();
+            
+            // close path from last point (deals with capped and uncapped ends) and paint
+            ctx.close_path();
+            ctx.stroke ();
+            
+            // set to white for text
+            Gdk.cairo_set_source_rgba(ctx, Gdk.RGBA() { red = 1.0, green = 1.0, blue = 1.0, alpha = 1.0 });
+        }
+        
+        // add a couple of pixels to the text margins if capped
+        int left_text_margin = Palette.TEXT_MARGIN_PX + (left_effect != CapEffect.NONE ? 3 : 0);
+        int right_text_margin = Palette.TEXT_MARGIN_PX + (right_effect != CapEffect.NONE ? 3 : 0);
+        
+        Pango.Layout layout = create_pango_layout(text);
+        // Use normal font for very top line, small font for all others (see get_line_top_y())
+        layout.set_font_description((line_number < 0)
+            ? Palette.instance.normal_font
+            : Palette.instance.small_font);
+        layout.set_ellipsize(Pango.EllipsizeMode.END);
+        layout.set_width((right - left - left_text_margin - right_text_margin) * Pango.SCALE);
+        
+        ctx.move_to(left_text_margin, top);
+        Pango.cairo_show_layout(ctx, layout);
+        
+        return layout;
+    }
+    
+    /**
+     * Returns a hit result for { link Component.Event}, if hit at all.
+     *
+     * The Gdk.Point must be relative to the widget's coordinate system.
+     */
+    public Component.Event? get_event_at(Gdk.Point point) {
+        for (int line_number = 0; line_number < line_to_event.size; line_number++) {
+            int y = get_line_top_y(line_number);
+            if (point.y >= y && point.y < (y + Palette.instance.small_font_height_px))
+                return line_to_event.get(line_number);
+        }
+        
+        return null;
+    }
+}
+
+}
+
diff --git a/src/view/common/common.vala b/src/view/common/common.vala
new file mode 100644
index 0000000..0ae0b55
--- /dev/null
+++ b/src/view/common/common.vala
@@ -0,0 +1,31 @@
+/* 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.View.Common {
+
+private int init_count = 0;
+
+public void init() throws Error {
+    if (!Unit.do_init(ref init_count))
+        return;
+    
+    // unit initialization
+    Calendar.init();
+    Component.init();
+    Toolkit.init();
+}
+
+public void terminate() {
+    if (!Unit.do_terminate(ref init_count))
+        return;
+    
+    Toolkit.terminate();
+    Component.terminate();
+    Calendar.terminate();
+}
+
+}
+
diff --git a/src/view/month/month-cell.vala b/src/view/month/month-cell.vala
index 4cdf2d2..cc21f3a 100644
--- a/src/view/month/month-cell.vala
+++ b/src/view/month/month-cell.vala
@@ -7,412 +7,50 @@
 namespace California.View.Month {
 
 /**
- * A single cell within a { link MonthGrid}.
+ * A square cell in the { link Month.Grid} displaying events.
+ *
+ * @see View.Common.EventsCell
  */
 
-private class Cell : Gtk.EventBox {
-    private const int TOP_LINE_FONT_SIZE_PT = 11;
-    private const int LINE_FONT_SIZE_PT = 8;
-    
-    private const int TEXT_MARGIN_PX = 2;
-    private const int LINE_SPACING_PX = 4;
-    
-    private const double ROUNDED_CAP_RADIUS = 5.0;
-    private const int POINTED_CAP_WIDTH_PX = 6;
-    
-    private const double DEGREES = Math.PI / 180.0;
-    
-    private const string KEY_TOOLTIP = "california-view-month-cell-tooltip";
-    
-    private const Calendar.WallTime.PrettyFlag PRETTY_TIME_FLAGS =
-        Calendar.WallTime.PrettyFlag.OPTIONAL_MINUTES
-        | Calendar.WallTime.PrettyFlag.BRIEF_MERIDIEM;
-    
-    private enum CapEffect {
-        NONE,
-        BLOCKED,
-        ROUNDED,
-        POINTED
-    }
-    
+internal class Cell : Common.EventsCell {
     public weak Grid owner { get; private set; }
     public int row { get; private set; }
     public int col { get; private set; }
     
-    // to avoid lots of redraws, only queue_draw() if set changes value
-    private Calendar.Date? _date = null;
-    public Calendar.Date? date {
-        get {
-            return _date;
-        }
+    public Cell(Grid owner, Calendar.Date date, int row, int col) {
+        base (date, date.week_of(owner.first_of_week).to_date_span());
         
-        set {
-            if ((_date == null || value == null) && _date != value)
-                queue_draw();
-            else if (_date != null && value != null && !_date.equal_to(value))
-                queue_draw();
-            
-            _date = value;
-        }
-    }
-    
-    // to avoid lots of redraws, only queue_draw() if set changes value
-    private bool _selected = false;
-    public bool selected {
-        get {
-            return _selected;
-        }
-        
-        set {
-            if (_selected != value)
-                queue_draw();
-            
-            _selected = value;
-        }
-    }
-    
-    private Gee.TreeSet<Component.Event> sorted_events = new 
Gee.TreeSet<Component.Event>(all_day_comparator);
-    private Gee.HashMap<int, Component.Event> line_to_event = new Gee.HashMap<int, Component.Event>();
-    
-    // TODO: We may need to get these colors from the theme
-    private static Gdk.RGBA RGBA_BORDER = { red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0 };
-    private static Gdk.RGBA RGBA_DAY_OF_MONTH = { red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0 };
-    private static Gdk.RGBA RGBA_DAY_OUTSIDE_MONTH = { red: 0.6, green: 0.6, blue: 0.6, alpha: 1.0 };
-    private static Gdk.RGBA RGBA_CURRENT_DAY = { red: 0.0, green: 0.25, blue: 0.50, alpha: 0.10 };
-    private static Gdk.RGBA RGBA_SELECTED = { red: 0.0, green: 0.50, blue: 0.50, alpha: 0.10 };
-    
-    private static Pango.FontDescription top_line_font;
-    private static Pango.FontDescription line_font;
-    private static int top_line_height_px = -1;
-    private static int line_height_px = -1;
-    
-    private Gtk.DrawingArea canvas = new Gtk.DrawingArea();
-    
-    public Cell(Grid owner, int row, int col) {
         this.owner = owner;
         this.row = row;
         this.col = col;
         
-        // see query_tooltip() for implementation
-        has_tooltip = true;
-        
-        // wrap the EventBox around the DrawingArea, which is the real widget of interest for this
-        // class
-        add(canvas);
+        notify[PROP_DATE].connect(update_top_line);
         
-        notify["date"].connect(queue_draw);
-        notify["selected"].connect(queue_draw);
-        Calendar.System.instance.is_24hr_changed.connect(on_24hr_changed);
-        Calendar.System.instance.today_changed.connect(on_today_changed);
+        owner.notify[Grid.PROP_FIRST_OF_WEEK].connect(on_first_of_week_changed);
+        owner.owner.notify[Controller.PROP_SHOW_OUTSIDE_MONTH].connect(update_top_line);
         
-        canvas.draw.connect(on_draw);
+        update_top_line();
     }
     
     ~Cell() {
-        Calendar.System.instance.is_24hr_changed.disconnect(on_24hr_changed);
-        Calendar.System.instance.today_changed.disconnect(on_today_changed);
-    }
-    
-    internal static void init() {
-        top_line_font = new Pango.FontDescription();
-        top_line_font.set_size(TOP_LINE_FONT_SIZE_PT * Pango.SCALE);
-        
-        line_font = new Pango.FontDescription();
-        line_font.set_size(LINE_FONT_SIZE_PT * Pango.SCALE);
-        
-        // top_line_height_px and line_height_px can't be calculated until one of the Cells is
-        // rendered
-    }
-    
-    internal static void terminate() {
-        top_line_font = null;
-        line_font = null;
-    }
-    
-    // this comparator uses the standard Event comparator with one exception: if both Events require
-    // solid span lines, it sorts the one(s) with the furthest out end dates to the top, to ensure
-    // they are at the top of the drawn lines and prevent gaps and skips in the connected bars
-    private static int all_day_comparator(Component.Event a, Component.Event b) {
-        if (a == b)
-            return 0;
-        
-        if (!requires_span(a) && !requires_span(b))
-            return a.compare_to(b);
-        
-        Calendar.DateSpan a_span = a.get_event_date_span(Calendar.Timezone.local);
-        Calendar.DateSpan b_span = b.get_event_date_span(Calendar.Timezone.local);
-        
-        int compare = a_span.start_date.compare_to(b_span.start_date);
-        if (compare != 0)
-            return compare;
-        
-        compare = b_span.end_date.compare_to(a_span.end_date);
-        if (compare != 0)
-            return compare;
-        
-        // to stabilize
-        return a.compare_to(b);
-    }
-    
-    /**
-     * Returns true if the point at x,y is within the { link Cell}'s width and height.
-     */
-    public bool is_hit(int x, int y) {
-        return x >= 0 && x < get_allocated_width() && y >= 0 && y < get_allocated_height();
-    }
-    
-    /**
-     * Returns the assigned line number for the event, -1 if not found in { link Cell}.
-     */
-    public int get_line_for_event(Component.Event event) {
-        Gee.MapIterator<int, Component.Event> iter = line_to_event.map_iterator();
-        while (iter.next()) {
-            if (iter.get_value().equal_to(event))
-                return iter.get_key();
-        }
-        
-        return -1;
-    }
-    
-    public void clear() {
-        date = null;
-        line_to_event.clear();
-        
-        foreach (Component.Event event in sorted_events.to_array())
-            internal_remove_event(event);
-        
-        queue_draw();
-    }
-    
-    public void add_event(Component.Event event) {
-        if (!sorted_events.add(event))
-            return;
-        
-        // subscribe to interesting mutable properties
-        event.notify[Component.Event.PROP_SUMMARY].connect(queue_draw);
-        event.notify[Component.Event.PROP_DATE_SPAN].connect(on_span_updated);
-        event.notify[Component.Event.PROP_EXACT_TIME_SPAN].connect(on_span_updated);
-        
-        assign_line_numbers();
-        
-        queue_draw();
+        owner.notify[Grid.PROP_FIRST_OF_WEEK].disconnect(on_first_of_week_changed);
+        owner.owner.notify[Controller.PROP_SHOW_OUTSIDE_MONTH].disconnect(update_top_line);
     }
     
-    private bool internal_remove_event(Component.Event event) {
-        if (!sorted_events.remove(event))
-            return false;
-        
-        event.notify[Component.Event.PROP_SUMMARY].disconnect(queue_draw);
-        event.notify[Component.Event.PROP_DATE_SPAN].disconnect(on_span_updated);
-        event.notify[Component.Event.PROP_EXACT_TIME_SPAN].disconnect(on_span_updated);
-        
-        return true;
-    }
-    
-    public void remove_event(Component.Event event) {
-        if (!internal_remove_event(event))
-            return;
-        
-        assign_line_numbers();
-        
-        queue_draw();
-    }
-    
-    /**
-     * Called by { link Controllable} when a calendar's visibility has changed.
-     *
-     * This causes event line numbers to be reassigned and thie { link Cell} redrawn, if the
-     * calendar in question has any events in this date.
-     */
-    public void notify_calendar_visibility_changed(Backing.CalendarSource calendar_source) {
-        if (!traverse<Component.Event>(sorted_events).any((event) => event.calendar_source == 
calendar_source))
-            return;
-        
-        // found one
-        assign_line_numbers();
-        queue_draw();
+    protected override Common.EventsCell? get_cell_for_date(Calendar.Date cell_date) {
+        return owner.get_cell_for_date(cell_date);
     }
     
-    // Called internally by other Cells when (a) they're in charge of assigning a multi-day event
-    // its line number for the week and (b) that line number has changed.
-    private void notify_assigned_line_number_changed(Gee.Collection<Component.Event> events) {
-        if (!traverse<Component.Event>(sorted_events).contains_any(events))
-            return;
-        
-        assign_line_numbers();
-        queue_draw();
+    private void on_first_of_week_changed() {
+        change_date_and_neighbors(date, date.week_of(owner.first_of_week).to_date_span());
     }
     
-    // criteria for an event requiring a solid span on the grid
-    private static bool requires_span(Component.Event event) {
-        return event.is_all_day || event.exact_time_span.duration.days >= 1;
-    }
-    
-    // each event gets a line of the cell to draw in; this clears all assigned line numbers and
-    // re-assigns from the sorted set of events, making sure holes are filled where possible ...
-    // if an event starts in this cell or this cell is the first day of a week an event is in,
-    // this cell is responsible for assigning a line number to it, which the other cells of the
-    // same week will honor (so a continuous line can be drawn)
-    private void assign_line_numbers() {
-        Gee.HashMap<int, Component.Event> old_line_to_event = line_to_event;
-        line_to_event = new Gee.HashMap<int, Component.Event>();
-        
-        // track each event whose line number this cell is responsible for assigning that gets
-        // reassigned because of this
-        Gee.ArrayList<Component.Event> reassigned = new Gee.ArrayList<Component.Event>();
-        
-        foreach (Component.Event event in sorted_events) {
-            if (!event.calendar_source.visible)
-                continue;
-            
-            bool notify_reassigned = false;
-            if (requires_span(event)) {
-                // get the first day of this week the event exists in ... if not the current cell's
-                // date, get the assigned line number from the first day of this week the event
-                // exists in
-                Calendar.Date first_date = get_event_first_day_this_week(event);
-                if (!date.equal_to(first_date)) {
-                    int event_line = -1;
-                    Cell? cell = owner.get_cell_for_date(first_date);
-                    if (cell != null)
-                        event_line = cell.get_line_for_event(event);
-                    
-                    if (event_line >= 0) {
-                        assign_line_number(event_line, event);
-                        
-                        continue;
-                    }
-                } else {
-                    // only worried about multi-day events being reassigned, as that's what effects
-                    // other cells (i.e. when notifying of reassignment)
-                    notify_reassigned = event.get_event_date_span(Calendar.Timezone.local).duration.days > 1;
-                }
-            } else if (!event.is_all_day) {
-                // if timed event is in this date but started elsewhere, don't display (unless it
-                // requires a span, above)
-                Calendar.Date start_date = new Calendar.Date.from_exact_time(
-                    event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local));
-                if (!start_date.equal_to(date))
-                    continue;
-            }
-            
-            // otherwise, a timed event, a single-day event, or a multi-day event which starts here,
-            // so assign
-            int assigned = assign_line_number(-1, event);
-            
-            // if this cell assigns the line number and the event is not new and the number has changed,
-            // inform all the other cells following this day's in the current week
-            if (notify_reassigned && old_line_to_event.values.contains(event) && 
old_line_to_event.get(assigned) != event)
-                reassigned.add(event);
-        }
-        
-        if (reassigned.size > 0) {
-            // only need to tell cells following this day's in the current week about the reassignment
-            Calendar.Week this_week = date.week_of(owner.first_of_week);
-            Calendar.DateSpan span = new Calendar.DateSpan(date.next(), 
this_week.end_date).clamp_between(this_week);
-            
-            foreach (Calendar.Date span_date in span) {
-                Cell? cell = owner.get_cell_for_date(span_date);
-                if (cell != null && cell != this)
-                    cell.notify_assigned_line_number_changed(reassigned);
-            }
-        }
-    }
-    
-    private int assign_line_number(int force_line_number, Component.Event event) {
-        // kinda dumb, but this prevents holes appearing in lines where, due to the shape of the
-        // all-day events, could be filled
-        int line_number = 0;
-        if (force_line_number < 0) {
-            while (line_to_event.has_key(line_number))
-                line_number++;
-        } else {
-            line_number = force_line_number;
-        }
-        
-        line_to_event.set(line_number, event);
-        
-        return line_number;
-    }
-    
-    public bool has_events() {
-        return sorted_events.size > 0;
-    }
-    
-    private void on_24hr_changed() {
-        if (has_events())
-            queue_draw();
-    }
-    
-    private void on_today_changed(Calendar.Date old_today, Calendar.Date new_today) {
-        // need to know re: redrawing background color to indicate current day
-        if (date != null && (date.equal_to(old_today) || date.equal_to(new_today)))
-            queue_draw();
-    }
-    
-    private void on_span_updated(Object object, ParamSpec param) {
-        if (date == null)
-            return;
-        
-        Component.Event event = (Component.Event) object;
-        
-        // remove from cell if no longer in this day, otherwise remove and add again to sorted_events
-        // to re-sort
-        if (!(date in event.get_event_date_span(Calendar.Timezone.local))) {
-            remove_event(event);
-        } else if (sorted_events.remove(event)) {
-            sorted_events.add(event);
-            assign_line_numbers();
-        }
-        
-        queue_draw();
-    }
-    
-    public override bool query_tooltip(int x, int y, bool keyboard_mode, Gtk.Tooltip tooltip) {
-        Component.Event? event = get_event_at(Gdk.Point() { x = x, y = y });
-        if (event == null)
-            return false;
-        
-        string? tooltip_text = event.get_data<string?>(KEY_TOOLTIP);
-        if (String.is_empty(tooltip_text))
-            return false;
-        
-        tooltip.set_text(tooltip_text);
-        
-        return true;
-    }
-    
-    // Returns the first day of this cell's calendar week that the event is in ... this could be
-    // the event's starting day or the first day of this week (i.e. Monday or Sunday)
-    private Calendar.Date get_event_first_day_this_week(Component.Event event) {
-        Calendar.Date event_start_date = event.get_event_date_span(Calendar.Timezone.local).start_date;
-        
-        Calendar.Week cell_week = date.week_of(owner.first_of_week);
-        Calendar.Week event_start_week = event_start_date.week_of(owner.first_of_week);
-        
-        return cell_week.equal_to(event_start_week) ? event_start_date : cell_week.start_date;
-    }
-    
-    private bool on_draw(Cairo.Context ctx) {
-        // calculate extents if not already calculated;
-        if (line_height_px < 0 || top_line_height_px < 0)
-            calculate_extents(out top_line_height_px, out line_height_px);
-        
-        // shade background of cell for selection or if today
-        if (selected) {
-            Gdk.cairo_set_source_rgba(ctx, RGBA_SELECTED);
-            ctx.paint();
-        } else if (date != null && date.equal_to(Calendar.System.today)) {
-            Gdk.cairo_set_source_rgba(ctx, RGBA_CURRENT_DAY);
-            ctx.paint();
-        }
-        
+    protected override void draw_borders(Cairo.Context ctx) {
         int width = get_allocated_width();
         int height = get_allocated_height();
         
         // draw border lines (creates grid effect)
-        Gdk.cairo_set_source_rgba(ctx, RGBA_BORDER);
-        ctx.set_line_width(0.5);
+        Palette.prepare_hairline(ctx, Palette.instance.border);
         
         // only draw top line if on the top row
         if (row == 0) {
@@ -433,201 +71,20 @@ private class Cell : Gtk.EventBox {
         }
         
         ctx.stroke();
-        
-        // draw day of month as the top line
-        if (date != null) {
-            Gdk.RGBA color = (date in owner.month_of_year) ? RGBA_DAY_OF_MONTH : RGBA_DAY_OUTSIDE_MONTH;
-            draw_line_of_text(ctx, -1, color, date.day_of_month.informal_number, CapEffect.NONE,
-                CapEffect.NONE);
-        }
-        
-        // walk the assigned line numbers for each event and draw
-        Gee.MapIterator<int, Component.Event> iter = line_to_event.map_iterator();
-        while (iter.next()) {
-            Component.Event event = iter.get_value();
-            Calendar.DateSpan date_span = event.get_event_date_span(Calendar.Timezone.local);
-            
-            bool display_text = true;
-            if (requires_span(event)) {
-                // only show the title if (a) the first day of an all-day event or (b) this is the
-                // first day of a new week of a multi-day even.  (b) handles the contingency of a
-                // multi-day event starting in a previous week prior to the top of the current view
-                display_text = date_span.start_date.equal_to(date)
-                    || owner.first_of_week.as_day_of_week().equal_to(date.day_of_week);
-            }
-            
-            string text;
-            if (display_text) {
-                if (event.is_all_day) {
-                    text = event.summary;
-                } else {
-                    Calendar.ExactTime local_start = event.exact_time_span.start_exact_time.to_timezone(
-                        Calendar.Timezone.local);
-                    text = "%s %s".printf(local_start.to_pretty_time_string(PRETTY_TIME_FLAGS), 
event.summary);
-                }
-            } else {
-                text = "";
-            }
-            
-            // use caps on both ends of all-day events depending whether this is the start, end,
-            // or start/end of week of continuing event
-            CapEffect left_effect = CapEffect.NONE;
-            CapEffect right_effect = CapEffect.NONE;
-            if (requires_span(event)) {
-                if (date_span.start_date.equal_to(date))
-                    left_effect = CapEffect.ROUNDED;
-                else if (date.day_of_week == owner.first_of_week.as_day_of_week())
-                    left_effect = CapEffect.POINTED;
-                else
-                    left_effect = CapEffect.BLOCKED;
-                
-                if (date_span.end_date.equal_to(date))
-                    right_effect = CapEffect.ROUNDED;
-                else if (date.day_of_week == owner.first_of_week.as_day_of_week().previous())
-                    right_effect = CapEffect.POINTED;
-                else
-                    right_effect = CapEffect.BLOCKED;
-            }
-            
-            Pango.Layout layout = draw_line_of_text(ctx, iter.get_key(), 
event.calendar_source.color_as_rgba(),
-                text, left_effect, right_effect);
-            event.set_data<string?>(KEY_TOOLTIP, layout.is_ellipsized() ? text : null);
-        }
-        
-        return true;
     }
     
-    private void calculate_extents(out int top_line_height_px, out int line_height_px) {
-        Pango.Layout layout = create_pango_layout("Gg");
-        layout.set_font_description(top_line_font);
-        
-        int width;
-        layout.get_pixel_size(out width, out top_line_height_px);
-        
-        layout = create_pango_layout("Gg");
-        layout.set_font_description(line_font);
-        
-        layout.get_pixel_size(out width, out line_height_px);
-    }
-    
-    // Returns top y position of line; negative line numbers are treated as top line
-    // The number is currently not clamped to the height of the widget.
-    private int get_line_top_y(int line_number) {
-        int y;
-        if (line_number < 0) {
-            y = TEXT_MARGIN_PX;
-        } else {
-            // starting y of "regular" lines
-            y = TEXT_MARGIN_PX + top_line_height_px + LINE_SPACING_PX;
+    private void update_top_line() {
+         if (!owner.owner.show_outside_month && !(date in owner.month_of_year)) {
+            top_line_text = null;
             
-            // add additional lines
-            y += line_number * (line_height_px + LINE_SPACING_PX);
-        }
-        
-        return y;
-    }
-    
-    // If line number is negative, the top line is drawn; otherwise, zero-based line numbers get
-    // "regular" treatment
-    private Pango.Layout draw_line_of_text(Cairo.Context ctx, int line_number, Gdk.RGBA rgba,
-        string text, CapEffect left_effect, CapEffect right_effect) {
-        bool is_reversed = (left_effect != CapEffect.NONE || right_effect != CapEffect.NONE);
-        
-        int left = 0;
-        int right = get_allocated_width();
-        int top = get_line_top_y(line_number);
-        int bottom = top + line_height_px;
-        
-        // use event color for text unless reversed, where it becomes the background color
-        Gdk.cairo_set_source_rgba(ctx, rgba);
-        if (is_reversed) {
-            // draw background rectangle in spec'd color with text in white
-            switch (right_effect) {
-                case CapEffect.ROUNDED:
-                    ctx.new_sub_path();
-                    // sub 2 to avoid touching right calendar line
-                    ctx.arc(right - 2 - ROUNDED_CAP_RADIUS, top + ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
-                        -90.0 * DEGREES, 0 * DEGREES);
-                    ctx.arc(right - 2 - ROUNDED_CAP_RADIUS, bottom - ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
-                        0 * DEGREES, 90.0 * DEGREES);
-                break;
-                
-                case CapEffect.POINTED:
-                    ctx.move_to(right - POINTED_CAP_WIDTH_PX, top);
-                    ctx.line_to(right, top + (line_height_px / 2));
-                    ctx.line_to(right - POINTED_CAP_WIDTH_PX, bottom);
-                break;
-                
-                case CapEffect.BLOCKED:
-                default:
-                    ctx.move_to(right, top);
-                    ctx.line_to(right, bottom);
-                break;
-            }
-            
-            switch (left_effect) {
-                case CapEffect.ROUNDED:
-                    // add one to avoid touching cell to the left's right calendar line
-                    ctx.arc(left + 1 + ROUNDED_CAP_RADIUS, bottom - ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
-                        90.0 * DEGREES, 180.0 * DEGREES);
-                    ctx.arc(left + 1 + ROUNDED_CAP_RADIUS, top + ROUNDED_CAP_RADIUS, ROUNDED_CAP_RADIUS,
-                        180.0 * DEGREES, 270.0 * DEGREES);
-                break;
-                
-                case CapEffect.POINTED:
-                    ctx.line_to(left + POINTED_CAP_WIDTH_PX, bottom);
-                    ctx.line_to(left, top + (line_height_px / 2));
-                    ctx.line_to(left + POINTED_CAP_WIDTH_PX, top);
-                break;
-                
-                case CapEffect.BLOCKED:
-                default:
-                    ctx.line_to(left, bottom);
-                    ctx.line_to(left, top);
-                break;
-            }
-            
-            // fill with event color
-            ctx.fill_preserve();
-            
-            // close path from last point (deals with capped and uncapped ends) and paint
-            ctx.close_path();
-            ctx.stroke ();
-            
-            // set to white for text
-            Gdk.cairo_set_source_rgba(ctx, Gdk.RGBA() { red = 1.0, green = 1.0, blue = 1.0, alpha = 1.0 });
-        }
-        
-        // add a couple of pixels to the text margins if capped
-        int left_text_margin = TEXT_MARGIN_PX + (left_effect != CapEffect.NONE ? 3 : 0);
-        int right_text_margin = TEXT_MARGIN_PX + (right_effect != CapEffect.NONE ? 3 : 0);
-        
-        Pango.Layout layout = create_pango_layout(text);
-        layout.set_font_description((line_number < 0) ? top_line_font : line_font);
-        layout.set_ellipsize(Pango.EllipsizeMode.END);
-        layout.set_width((right - left - left_text_margin - right_text_margin) * Pango.SCALE);
-        
-        ctx.move_to(left_text_margin, top);
-        Pango.cairo_show_layout(ctx, layout);
-        
-        return layout;
-    }
-    
-    /**
-     * Returns a hit result for { link Component.Event}, if hit at all.
-     *
-     * The Gdk.Point must be relative to the widget's coordinate system.
-     */
-    public Component.Event? get_event_at(Gdk.Point point) {
-        for (int line_number = 0; line_number < line_to_event.size; line_number++) {
-            int y = get_line_top_y(line_number);
-            if (point.y >= y && point.y < (y + line_height_px))
-                return line_to_event.get(line_number);
+            return;
         }
         
-        return null;
+        top_line_text = date.day_of_month.informal_number;
+        top_line_rgba = (date in owner.month_of_year)
+            ? Palette.instance.day_in_range
+            : Palette.instance.day_outside_range;
     }
 }
 
 }
-
diff --git a/src/view/month/month-controller.vala b/src/view/month/month-controller.vala
index 2273104..f4e9fac 100644
--- a/src/view/month/month-controller.vala
+++ b/src/view/month/month-controller.vala
@@ -17,13 +17,20 @@ public class Controller : BaseObject, View.Controllable {
     public const string PROP_MONTH_OF_YEAR = "month-of-year";
     public const string PROP_SHOW_OUTSIDE_MONTH = "show-outside-month";
     
-    // Slower than default to make more apparent to user what's occurring
-    private const int TRANSITION_DURATION_MSEC = 500;
-    
     // number of Grids to keep in GtkStack and cache (in terms of months) ... this should be an
     // even number, as it is halved to determine neighboring months depths
     private const int CACHE_NEIGHBORS_COUNT = 4;
     
+    // MasterGrid holds the day of week labels and Month.Cells
+    private class MasterGrid : Gtk.Grid, View.Container {
+        private Controller _owner;
+        public unowned View.Controllable owner { get { return _owner; } }
+        
+        public MasterGrid(Controller owner) {
+            _owner = owner;
+        }
+    }
+    
     /**
      * The month and year being displayed.
      *
@@ -44,6 +51,11 @@ public class Controller : BaseObject, View.Controllable {
     /**
      * @inheritDoc
      */
+    public string title { get { return _("Month"); } }
+    
+    /**
+     * @inheritDoc
+     */
     public string current_label { get; protected set; }
     
     /**
@@ -56,18 +68,24 @@ public class Controller : BaseObject, View.Controllable {
      */
     public Calendar.Date default_date { get; protected set; }
     
-    private Gtk.Grid master_grid = new Gtk.Grid();
+    private MasterGrid master_grid;
     private Gtk.Stack stack = new Gtk.Stack();
-    private Gee.HashMap<Calendar.MonthOfYear, Grid> month_grids = new Gee.HashMap<Calendar.MonthOfYear, 
Grid>();
+    private Toolkit.StackModel<Calendar.MonthOfYear> stack_model;
+    private Calendar.MonthSpan cache_span;
     
     public Controller() {
+        master_grid = new MasterGrid(this);
         master_grid.column_homogeneous = true;
         master_grid.column_spacing = 0;
         master_grid.row_homogeneous = false;
         master_grid.row_spacing = 0;
         master_grid.expand = true;
         
-        stack.transition_duration = TRANSITION_DURATION_MSEC;
+        stack.transition_duration = Toolkit.SLOW_STACK_TRANSITION_DURATION_MSEC;
+        
+        stack_model = new Toolkit.StackModel<Calendar.MonthOfYear>(stack,
+            Toolkit.StackModel.OrderedTransitionType.SLIDE_LEFT_RIGHT, model_presentation,
+            trim_presentation_from_cache, ensure_presentation_in_cache);
         
         // insert labels for days of the week across top of master grid
         for (int col = 0; col < Grid.COLS; col++) {
@@ -102,49 +120,30 @@ public class Controller : BaseObject, View.Controllable {
         Calendar.System.instance.today_changed.disconnect(on_today_changed);
     }
     
-    // Creates a new Grid for the MonthOfYear, storing locally and adding to the GtkStack.  Will
-    // reuse existing Grids whenever possible.
-    private void ensure_month_grid_exists(Calendar.MonthOfYear month_of_year) {
-        if (month_grids.has_key(month_of_year))
-            return;
+    private Gtk.Widget model_presentation(Calendar.MonthOfYear moy, out string? id) {
+        Grid grid = new Grid(this, moy);
+        id = grid.id;
         
-        Grid month_grid = new Grid(this, month_of_year);
-        month_grid.show_all();
+        return grid;
+    }
+    
+    private bool trim_presentation_from_cache(Calendar.MonthOfYear moy, Calendar.MonthOfYear? visible_moy) {
+        // always keep current month in cache
+        if (moy.equal_to(Calendar.System.today.month_of_year()))
+            return false;
         
-        // add to local store and to the GtkStack itself
-        month_grids.set(month_of_year, month_grid);
-        stack.add_named(month_grid, month_grid.id);
+        return !(moy in cache_span);
     }
     
-    // Performs Grid caching by ensuring that Grids are available for the current, next, and
-    // previous month and that Grids outside that range are dropped.  The current chronological
-    // month is never discarded.
-    private void update_month_grid_cache() {
-        Calendar.MonthSpan cache_span = new Calendar.MonthSpan(
-            month_of_year.adjust(0 - (CACHE_NEIGHBORS_COUNT / 2)),
-            month_of_year.adjust(CACHE_NEIGHBORS_COUNT / 2));
+    private Gee.Collection<Calendar.MonthOfYear>? ensure_presentation_in_cache(
+        Calendar.MonthOfYear? visible_moy) {
+        // convert cache span into a collection on months
+        Gee.List<Calendar.MonthOfYear> months = cache_span.as_list();
         
-        // trim cache
-        Gee.MapIterator<Calendar.MonthOfYear, Grid> iter = month_grids.map_iterator();
-        while (iter.next()) {
-            Calendar.MonthOfYear grid_moy = iter.get_key();
-            
-            // always keep current month
-            if (grid_moy.equal_to(Calendar.System.today.month_of_year()))
-                continue;
-            
-            // keep if grid is in cache span
-            if (grid_moy in cache_span)
-                continue;
-            
-            // drop, remove from GtkStack and local storage
-            stack.remove(iter.get_value());
-            iter.unset();
-        }
+        // add today's month
+        months.add(Calendar.System.today.month_of_year());
         
-        // ensure all-months in span are available
-        foreach (Calendar.MonthOfYear moy in cache_span)
-            ensure_month_grid_exists(moy);
+        return months;
     }
     
     private unowned Grid? get_current_month_grid() {
@@ -168,22 +167,12 @@ public class Controller : BaseObject, View.Controllable {
     /**
      * @inheritDoc
      */
-    public Gtk.Widget today() {
+    public void today() {
         // since changing the date is expensive in terms of adding/removing subscriptions, only
         // update the property if it's actually different
         Calendar.MonthOfYear now = Calendar.System.today.month_of_year();
         if (!now.equal_to(month_of_year))
             month_of_year = now;
-        
-        // current should be set by the month_of_year being set
-        Grid? current_grid = get_current_month_grid();
-        assert(current_grid != null);
-        
-        // this grid better have a cell with this date in it
-        Cell? cell = current_grid.get_cell_for_date(Calendar.System.today);
-        assert(cell != null);
-        
-        return cell;
     }
     
     /**
@@ -198,7 +187,7 @@ public class Controller : BaseObject, View.Controllable {
     /**
      * @inheritDoc
      */
-    public Gtk.Widget get_container() {
+    public View.Container get_container() {
         return master_grid;
     }
     
@@ -215,49 +204,14 @@ public class Controller : BaseObject, View.Controllable {
         current_label = month_of_year.full_name;
         update_is_viewing_today();
         
-        // default date is first of month unless displaying current month, in which case it's
-        // current date
-        try {
-            default_date = is_viewing_today ? Calendar.System.today
-                : month_of_year.date_for(month_of_year.first_day_of_month());
-        } catch (CalendarError calerr) {
-            // this should always work
-            error("Unable to set default date for %s: %s", month_of_year.to_string(), calerr.message);
-        }
-        
-        // set up transition to give appearance of moving chronologically through the pages of
-        // a calendar
-        Grid? current_grid = get_current_month_grid();
-        if (current_grid != null) {
-            Calendar.MonthOfYear current_moy = current_grid.month_of_year;
-            int compare = month_of_year.compare_to(current_moy);
-            if (compare < 0)
-                stack.transition_type = Gtk.StackTransitionType.SLIDE_RIGHT;
-            else if (compare > 0)
-                stack.transition_type = Gtk.StackTransitionType.SLIDE_LEFT;
-            else
-                return;
-        }
-        
-        // because grid cache is populated/trimmed after sliding month into view, ensure the
-        // desired month already exists
-        ensure_month_grid_exists(month_of_year);
-        
-        // make visible using proper transition type
-        stack.set_visible_child(month_grids.get(month_of_year));
+        // update cache span, splitting down the middle of the current month
+        cache_span = new Calendar.MonthSpan(
+            month_of_year.adjust(0 - (CACHE_NEIGHBORS_COUNT / 2)),
+            month_of_year.adjust(CACHE_NEIGHBORS_COUNT / 2)
+        );
         
-        // now update the cache to store current month and neighbors ... do this after doing above
-        // comparison because this update affects the GtkStack, which may revert to another page
-        // when the cache is trimmed, making the notion of "current" indeterminate; the most
-        // visible symptom of this is navigating far from today's month then clicking the Today
-        // button and no transition occurs because, when the cache is trimmed, today's month is
-        // the current child ... to avoid dropping the Widget before the transition completes,
-        // wait before doing this; 3.12's "transition-running" property would be useful here
-        Idle.add(() => {
-            update_month_grid_cache();
-            
-            return false;
-        }, Priority.LOW);
+        // show (and add if not present) the current month
+        stack_model.show(month_of_year);
     }
     
     public override string to_string() {
diff --git a/src/view/month/month-grid.vala b/src/view/month/month-grid.vala
index 8661140..6127986 100644
--- a/src/view/month/month-grid.vala
+++ b/src/view/month/month-grid.vala
@@ -77,7 +77,9 @@ private class Grid : Gtk.Grid {
         // pre-add grid elements for every cell, which are updated when the MonthYear changes
         for (int row = 0; row < ROWS; row++) {
             for (int col = 0; col < COLS; col++) {
-                Cell cell = new Cell(this, row, col);
+                // use today's date as placeholder until update_cells() is called
+                // TODO: try to avoid this on first pass
+                Cell cell = new Cell(this, Calendar.System.today, row, col);
                 cell.expand = true;
                 cell.events |= Gdk.EventMask.BUTTON_PRESS_MASK & Gdk.EventMask.BUTTON1_MOTION_MASK;
                 cell.button_press_event.connect(on_cell_button_event);
@@ -155,15 +157,12 @@ private class Grid : Gtk.Grid {
     }
     
     private void update_week(int row, Calendar.Week week) {
+        Calendar.DateSpan week_as_date_span = week.to_date_span();
         foreach (Calendar.Date date in week) {
             int col = date.day_of_week.ordinal(owner.first_of_week) - 1;
             
             Cell cell = get_cell(row, col);
-            
-            // if the date is in the month or configured to show days outside the month, set
-            // the cell to show that date; otherwise, it'll be cleared
-            cell.clear();
-            cell.date = (date in month_of_year) || owner.show_outside_month ? date : null;
+            cell.change_date_and_neighbors(date, week_as_date_span);
             
             // add to map for quick lookups
             date_to_cell.set(date, cell);
@@ -190,7 +189,7 @@ private class Grid : Gtk.Grid {
     
     private void update_subscriptions() {
         // convert DateSpan window into an ExactTimeSpan, which is what the subscription wants
-        Calendar.ExactTimeSpan time_window = new Calendar.ExactTimeSpan.from_date_span(window,
+        Calendar.ExactTimeSpan time_window = new Calendar.ExactTimeSpan.from_span(window,
             Calendar.Timezone.local);
         
         if (subscriptions != null && subscriptions.window.equal_to(time_window))
diff --git a/src/view/month/month.vala b/src/view/month/month.vala
index 8f07f50..45096e3 100644
--- a/src/view/month/month.vala
+++ b/src/view/month/month.vala
@@ -17,23 +17,20 @@ public void init() throws Error {
         return;
     
     // unit initialization
+    View.Common.init();
     Calendar.init();
     Component.init();
     Backing.init();
-    
-    // internal initialization
-    Cell.init();
 }
 
 public void terminate() {
     if (!Unit.do_terminate(ref init_count))
         return;
     
-    Cell.terminate();
-    
     Backing.terminate();
     Component.terminate();
     Calendar.terminate();
+    View.Common.terminate();
 }
 
 }
diff --git a/src/view/view-container.vala b/src/view/view-container.vala
new file mode 100644
index 0000000..137338a
--- /dev/null
+++ b/src/view/view-container.vala
@@ -0,0 +1,24 @@
+/* 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.View {
+
+/**
+ * A Gtk.Widget returned by { link Controllable} that acts as the container for the entire view.
+ *
+ * As tempting as it is to make this interface depend on Gtk.Container, we'll leave this fairly
+ * generic for now.
+ */
+
+public interface Container : Gtk.Widget {
+    /**
+     * The { link Controllable} that owns this { link Container}.
+     */
+    public abstract unowned Controllable owner { get; }
+}
+
+}
+
diff --git a/src/view/view-controllable.vala b/src/view/view-controllable.vala
index d8633c5..c95f9d8 100644
--- a/src/view/view-controllable.vala
+++ b/src/view/view-controllable.vala
@@ -21,6 +21,11 @@ public interface Controllable : Object {
     public const string PROP_FIRST_OF_WEEK = "first-of-week";
     
     /**
+     * A user-visible string (short) representing this view.
+     */
+    public abstract string title { get; }
+    
+    /**
      * A user-visible string representing the current calendar view.
      */
     public abstract string current_label { get; protected set; }
@@ -35,11 +40,6 @@ public interface Controllable : Object {
     public abstract bool is_viewing_today { get; protected set; }
     
     /**
-     * Default { link Calendar.Date} for the calendar unit in view.
-     */
-    public abstract Calendar.Date default_date { get; protected set; }
-    
-    /**
      * The first day of the week.
      */
     public abstract Calendar.FirstOfWeek first_of_week { get; set; }
@@ -65,10 +65,13 @@ public interface Controllable : Object {
         Gdk.Point? for_location);
     
     /**
-     * Returns the Gtk.Widget container that should be used to display the { link Controllable}'s
+     * Returns the { link Container} that should be used to display the { link Controllable}'s
      * contents.
+     *
+     * This should not return a new Gtk.Widget each time, rather it returns the Widget the
+     * Controllable is maintaining the current view(s) in.
      */
-    public abstract Gtk.Widget get_container();
+    public abstract View.Container get_container();
     
     /**
      * Move forward one calendar unit.
@@ -82,10 +85,8 @@ public interface Controllable : Object {
     
     /**
      * Jump to calendar unit representing the current date.
-     *
-     * Returns the Gtk.Widget displaying the current date.
      */
-    public abstract Gtk.Widget today();
+    public abstract void today();
     
     /**
      * If the view supports a notion of selection, this unselects all selected items.
diff --git a/src/view/view-palette.vala b/src/view/view-palette.vala
new file mode 100644
index 0000000..68a8238
--- /dev/null
+++ b/src/view/view-palette.vala
@@ -0,0 +1,209 @@
+/* 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.View {
+
+/**
+ * A singleton holding colors and theme information for drawing the various views.
+ *
+ * TODO: Currently colors are hard-coded.  In the future we'll probably need to get these from the
+ * system or the theme.
+ */
+
+public class Palette : BaseObject {
+    /**
+     * Margins around text (in pixels).
+     */
+    public const int TEXT_MARGIN_PX = 2;
+    
+    /**
+     * Line padding when painting text (in pixels).
+     */
+    public const int LINE_PADDING_PX = 4;
+    
+    /**
+     * Hairline line width.
+     */
+    public const double HAIRLINE_WIDTH = 0.5;
+    
+    /**
+     * Dash pattern for Cairo.
+     */
+    public const double DASHES[] = { 1.0, 3.0 };
+    
+    private const int NORMAL_FONT_SIZE_PT = 11;
+    private const int SMALL_FONT_SIZE_PT = 8;
+    
+    public static Palette instance { get; private set; }
+    
+    /**
+     * Border color (when separating days, for example).
+     */
+    public Gdk.RGBA border { get; private set; }
+    
+    /**
+     * Color to use when drawing details of a day inside the current { link View} range.
+     *
+     * @see day_outside_range
+     */
+    public Gdk.RGBA day_in_range { get; private set; }
+    
+    /**
+     * Color to use when drawing details of a day outside the current { link View} range.
+     *
+     * @see day_in_range
+     */
+    public Gdk.RGBA day_outside_range { get; private set; }
+    
+    /**
+     * Background color for day representing current date.
+     */
+    public Gdk.RGBA current_day { get; private set; }
+    
+    /**
+     * Foreground color representing current time of day.
+     */
+    public Gdk.RGBA current_time { get; private set; }
+    
+    /**
+     * Background color to use for selected days/time.
+     */
+    public Gdk.RGBA selection { get; private set; }
+    
+    /**
+     * Normal-sized font.
+     *
+     * In general this should be used sparingly, as most calendar views need to conserve screen
+     * real estate and use { link Host.ShowEvent} to display a greater amount of detail.
+     *
+     * @see small_font
+     */
+    public Pango.FontDescription normal_font;
+    
+    /**
+     * Font height extent for { link normal_font} (in pixels).
+     *
+     * This will be a negative value until the main window is mapped to the screen.
+     *
+     * @see main_window_mapped
+     */
+    public int normal_font_height_px { get; private set; default = -1; }
+    
+    /**
+     * Small font.
+     *
+     * This is more appropriate than { link normal_font} when displaying calendar information,
+     * especially event detail.
+     */
+    public Pango.FontDescription small_font;
+    
+    /**
+     * Font height extent for { link small_font} (in pixels).
+     *
+     * This will be a negative value until the main window is mapped to the screen.
+     *
+     * @see main_window_mapped
+     */
+    public int small_font_height_px { get; private set; default = -1; }
+    
+    /**
+     * Fired when palette has changed.
+     *
+     * It's generally simpler to subscribe to this signal rather than the "notify" for every
+     * property.
+     */
+    public signal void palette_changed();
+    
+    private Palette() {
+        border = { red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0 };
+        day_in_range = { red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0 };
+        day_outside_range = { red: 0.6, green: 0.6, blue: 0.6, alpha: 1.0 };
+        current_day = { red: 0.0, green: 0.25, blue: 0.50, alpha: 0.10 };
+        current_time = { red: 1.0, green: 0.0, blue: 0.0, alpha: 0.90 };
+        selection = { red: 0.0, green: 0.50, blue: 0.50, alpha: 0.10 };
+        
+        normal_font = new Pango.FontDescription();
+        normal_font.set_size(NORMAL_FONT_SIZE_PT * Pango.SCALE);
+        
+        small_font = new Pango.FontDescription();
+        small_font.set_size(SMALL_FONT_SIZE_PT * Pango.SCALE);
+    }
+    
+    internal static void init() {
+        instance = new Palette();
+    }
+    
+    internal static void terminate() {
+        instance = null;
+    }
+    
+    /**
+     * Called by { link Host.MainWindow} when it's mapped to the screen.
+     *
+     * This allows for { link Palette} to retrieve display metrics and other information.
+     */
+    public void main_window_mapped(Gtk.Window window) {
+        bool updated = false;
+        
+        int height = get_height_extent(window, normal_font);
+        if (height != normal_font_height_px) {
+            normal_font_height_px = height;
+            updated = true;
+        }
+        
+        height = get_height_extent(window, small_font);
+        if (height != small_font_height_px) {
+            small_font_height_px = height;
+            updated = true;
+        }
+        
+        if (updated)
+            palette_changed();
+    }
+    
+    private static int get_height_extent(Gtk.Widget widget, Pango.FontDescription font) {
+        Pango.Layout layout = widget.create_pango_layout("Gg");
+        layout.set_font_description(font);
+        
+        int width, height;
+        layout.get_pixel_size(out width, out height);
+        
+        return height;
+    }
+    
+    /**
+     * Prepare a Cairo.Context for drawing hairlines.
+     */
+    public static Cairo.Context prepare_hairline(Cairo.Context ctx, Gdk.RGBA rgba) {
+        Gdk.cairo_set_source_rgba(ctx, rgba);
+        ctx.set_line_width(HAIRLINE_WIDTH);
+        ctx.set_line_cap(Cairo.LineCap.ROUND);
+        ctx.set_line_join(Cairo.LineJoin.ROUND);
+        ctx.set_dash(null, 0);
+        
+        return ctx;
+    }
+    
+    /**
+     * Prepare a Cairo.Context for drawing hairline dashed lines.
+     */
+    public static Cairo.Context prepare_hairline_dashed(Cairo.Context ctx, Gdk.RGBA rgba) {
+        Gdk.cairo_set_source_rgba(ctx, rgba);
+        ctx.set_line_width(HAIRLINE_WIDTH);
+        ctx.set_line_cap(Cairo.LineCap.ROUND);
+        ctx.set_line_join(Cairo.LineJoin.ROUND);
+        ctx.set_dash(DASHES, 0);
+        
+        return ctx;
+    }
+    
+    public override string to_string() {
+        return "View.Palette";
+    }
+}
+
+}
+
diff --git a/src/view/view.vala b/src/view/view.vala
index e60f7ee..ef61572 100644
--- a/src/view/view.vala
+++ b/src/view/view.vala
@@ -7,7 +7,7 @@
 /**
  * User views of the calendar data.
  *
- * The { link MainWindow} hosts all views and offers an interface to switch between them.
+ * The { link Host.MainWindow} hosts all views and offers an interface to switch between them.
  */
 
 namespace California.View {
@@ -18,15 +18,23 @@ public void init() throws Error {
     if (!Unit.do_init(ref init_count))
         return;
     
+    Palette.init();
+    
     // subunit initialization
+    View.Common.init();
     View.Month.init();
+    View.Week.init();
 }
 
 public void terminate() {
     if (!Unit.do_terminate(ref init_count))
         return;
     
+    View.Week.terminate();
     View.Month.terminate();
+    View.Common.terminate();
+    
+    Palette.terminate();
 }
 
 }
diff --git a/src/view/week/week-all-day-cell.vala b/src/view/week/week-all-day-cell.vala
new file mode 100644
index 0000000..8886056
--- /dev/null
+++ b/src/view/week/week-all-day-cell.vala
@@ -0,0 +1,69 @@
+/* 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.View.Week {
+
+/**
+ * All-day events that span a particular day are drawn in this container.
+ *
+ * @see DayPane
+ */
+
+internal class AllDayCell : Common.EventsCell {
+    public const string PROP_OWNER = "owner";
+    
+    private const int LINES_SHOWN = 3;
+    
+    public Grid owner { get; private set; }
+    
+    public AllDayCell(Grid owner, Calendar.Date date) {
+        base (date, date.week_of(owner.owner.first_of_week).to_date_span());
+        
+        this.owner = owner;
+        
+        Palette.instance.palette_changed.connect(on_palette_changed);
+        
+        // use for initialization
+        on_palette_changed();
+    }
+    
+    ~AllDayCell() {
+        Palette.instance.palette_changed.disconnect(on_palette_changed);
+    }
+    
+    protected override Common.EventsCell? get_cell_for_date(Calendar.Date cell_date) {
+        return owner.get_all_day_cell_for_date(cell_date);
+    }
+    
+    private void on_palette_changed() {
+        // set fixed size for cell, as it won't grow with the toplevel window
+        set_size_request(-1, (Palette.instance.small_font_height_px + Palette.LINE_PADDING_PX) * 
LINES_SHOWN);
+    }
+    
+    protected override void draw_borders(Cairo.Context ctx) {
+        int width = get_allocated_width();
+        int height = get_allocated_height();
+        
+        // draw border lines (creates grid effect)
+        Palette.prepare_hairline(ctx, Palette.instance.border);
+        
+        // draw right border, unless last one in row, in which case spacer deals with that
+        if (date.equal_to(neighbors.end_date)) {
+            ctx.move_to(width, height);
+        } else {
+            ctx.move_to(width, 0);
+            ctx.line_to(width, height);
+        }
+        
+        // draw bottom border
+        ctx.line_to(0, height);
+        
+        ctx.stroke();
+    }
+}
+
+}
+
diff --git a/src/view/week/week-controller.vala b/src/view/week/week-controller.vala
new file mode 100644
index 0000000..4a4789c
--- /dev/null
+++ b/src/view/week/week-controller.vala
@@ -0,0 +1,178 @@
+/* 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.View.Week {
+
+/**
+ * The { link View.Controllable} for the week view.
+ */
+
+public class Controller : BaseObject, View.Controllable {
+    public const string PROP_WEEK = "week";
+    
+    private const int CACHE_NEIGHBORS_COUNT = 4;
+    
+    private class ViewContainer : Gtk.Stack, View.Container {
+        private Controller _owner;
+        public unowned View.Controllable owner { get { return _owner; } }
+        
+        public ViewContainer(Controller owner) {
+            _owner = owner;
+        }
+    }
+    
+    /**
+     * The current week of the year being displayed.
+     */
+    public Calendar.Week week { get; private set; }
+    
+    /**
+     * @inheritDoc
+     */
+    public string title { get { return _("Week"); } }
+    
+    /**
+     * @inheritDoc
+     */
+    public string current_label { get; protected set; }
+    
+    /**
+     * @inheritDoc
+     */
+    public bool is_viewing_today { get; protected set; }
+    
+    /**
+     * @inheritDoc
+     */
+    public Calendar.FirstOfWeek first_of_week { get; set; }
+    
+    private ViewContainer stack;
+    private Toolkit.StackModel<Calendar.Week> stack_model;
+    private Calendar.WeekSpan cache_span;
+    
+    public Controller() {
+        stack = new ViewContainer(this);
+        stack.homogeneous = true;
+        stack.transition_duration = Toolkit.SLOW_STACK_TRANSITION_DURATION_MSEC;
+        
+        stack_model = new Toolkit.StackModel<Calendar.Week>(stack,
+            Toolkit.StackModel.OrderedTransitionType.SLIDE_LEFT_RIGHT, model_presentation,
+            trim_presentation_from_cache, ensure_presentation_in_cache);
+        
+        // set this before signal handlers are in place (week and first_of_week are very closely
+        // tied in this view)
+        first_of_week = Calendar.FirstOfWeek.SUNDAY;
+        
+        // changing these properties drives a lot of the what the view displays
+        notify[View.Controllable.PROP_FIRST_OF_WEEK].connect(on_first_of_week_changed);
+        notify[PROP_WEEK].connect(on_week_changed);
+        
+        // set this now that signal handlers are in place
+        week = Calendar.System.today.week_of(first_of_week);
+    }
+    
+    /**
+     * @inheritDoc
+     */
+    public View.Container get_container() {
+        return stack;
+    }
+    
+    /**
+     * @inheritDoc
+     */
+    public void next() {
+        week = week.next();
+    }
+    
+    /**
+     * @inheritDoc
+     */
+    public void previous() {
+        week = week.previous();
+    }
+    
+    /**
+     * @inheritDoc
+     */
+    public void today() {
+        Calendar.Week this_week = Calendar.System.today.week_of(first_of_week);
+        if (!week.equal_to(this_week))
+            week = this_week;
+    }
+    
+    /**
+     * @inheritDoc
+     */
+    public void unselect_all() {
+    }
+    
+    private Gtk.Widget model_presentation(Calendar.Week week, out string? id) {
+        Grid week_grid = new Grid(this, week);
+        id = week_grid.id;
+        
+        return week_grid;
+    }
+    
+    private bool trim_presentation_from_cache(Calendar.Week week, Calendar.Week? visible_week) {
+        // always keep today's week in cache
+        if (week.equal_to(Calendar.System.today.week_of(first_of_week)))
+            return false;
+        
+        // otherwise only keep weeks that are in the current cache span
+        return !(week in cache_span);
+    }
+    
+    private Gee.Collection<Calendar.Week>? ensure_presentation_in_cache(Calendar.Week? visible_week) {
+        // return current cache span as a collection
+        Gee.List<Calendar.Week> weeks = cache_span.as_list();
+        
+        // add today's week to the mix
+        weeks.add(Calendar.System.today.week_of(first_of_week));
+        
+        return weeks;
+    }
+    
+    private void on_first_of_week_changed() {
+        // update week to reflect this change, but only if necessary
+        if (first_of_week != week.first_of_week)
+            week = week.start_date.week_of(first_of_week);
+    }
+    
+    private void on_week_changed() {
+        // current_label is Start Date - End Date, Year, unless bounding two years, in which case
+        // Start Date, Year - End Date, Year
+        Calendar.Date.PrettyFlag start_flags =
+            Calendar.Date.PrettyFlag.ABBREV | Calendar.Date.PrettyFlag.NO_DAY_OF_WEEK;
+        if (!week.start_date.year.equal_to(week.end_date.year))
+            start_flags |= Calendar.Date.PrettyFlag.INCLUDE_YEAR;
+        Calendar.Date.PrettyFlag end_flags =
+            Calendar.Date.PrettyFlag.ABBREV | Calendar.Date.PrettyFlag.INCLUDE_YEAR
+            | Calendar.Date.PrettyFlag.NO_DAY_OF_WEEK;
+        
+        // date formatting: "<Start Date> to <End Date>"
+        current_label = _("%s to %s").printf(week.start_date.to_pretty_string(start_flags),
+            week.end_date.to_pretty_string(end_flags));
+        
+        is_viewing_today = Calendar.System.today in week;
+        
+        // cache span is split between neighbors ahead and neighbors behind this week
+        cache_span = new Calendar.WeekSpan(
+            week.adjust(0 - (CACHE_NEIGHBORS_COUNT / 2)),
+            week.adjust(CACHE_NEIGHBORS_COUNT / 2)
+        );
+        
+        // show this week via the stack model (which implies adding it to the model)
+        stack_model.show(week);
+    }
+    
+    public override string to_string() {
+        return "Week.Controller %s".printf(week.to_string());
+    }
+}
+
+}
+
diff --git a/src/view/week/week-day-pane.vala b/src/view/week/week-day-pane.vala
new file mode 100644
index 0000000..2365e7f
--- /dev/null
+++ b/src/view/week/week-day-pane.vala
@@ -0,0 +1,222 @@
+/* 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.View.Week {
+
+/**
+ * A long pane displaying hour and half-hour delineations with events displayed as proportional
+ * boxes along the span.
+ *
+ * @see AllDayCell
+ */
+
+internal class DayPane : Pane {
+    public const string PROP_OWNER = "owner";
+    public const string PROP_DATE = "date";
+    public const string PROP_SELECTED = "selected";
+    
+    // No matter how wide the event is in the day, always leave a little peeking out so the hour/min
+    // lines are visible
+    private const int RIGHT_MARGIN_PX = 10;
+    
+    public Calendar.Date date { get; set; }
+    
+    public bool selected { get; set; default = false; }
+    
+    private Gee.TreeSet<Component.Event> days_events = new Gee.TreeSet<Component.Event>();
+    private uint minutes_timeout_id = 0;
+    
+    public DayPane(Grid owner, Calendar.Date date) {
+        base (owner, -1);
+        
+        this.date = date;
+        
+        notify[PROP_DATE].connect(queue_draw);
+        notify[PROP_SELECTED].connect(queue_draw);
+        Calendar.System.instance.is_24hr_changed.connect(queue_draw);
+        Calendar.System.instance.today_changed.connect(on_today_changed);
+        
+        schedule_monitor_minutes();
+    }
+    
+    ~DayPane() {
+        Calendar.System.instance.is_24hr_changed.disconnect(queue_draw);
+        Calendar.System.instance.today_changed.disconnect(on_today_changed);
+        
+        cancel_monitor_minutes();
+    }
+    
+    private void on_today_changed(Calendar.Date old_today, Calendar.Date new_today) {
+        // need to know re: redrawing background color to indicate current day
+        if (date.equal_to(old_today) || date.equal_to(new_today)) {
+            schedule_monitor_minutes();
+            queue_draw();
+        }
+    }
+    
+    // If this pane is showing the current date, need to update once a minute to move the horizontal
+    // minute indicator
+    private void schedule_monitor_minutes() {
+        cancel_monitor_minutes();
+        
+        if (!date.equal_to(Calendar.System.today))
+            return;
+        
+        // find the number of seconds remaining in this minute and schedule an update then
+        int remaining_sec = (Calendar.WallTime.SECONDS_PER_MINUTE - Calendar.System.now.second).clamp(
+            0, Calendar.WallTime.SECONDS_PER_MINUTE);
+        minutes_timeout_id = Timeout.add_seconds(remaining_sec, on_minute_changed);
+    }
+    
+    private bool on_minute_changed() {
+        // done this iteration
+        minutes_timeout_id = 0;
+        
+        // repaint time indicator
+        queue_draw();
+        
+        // reschedule
+        schedule_monitor_minutes();
+        
+        return false;
+    }
+    
+    private void cancel_monitor_minutes() {
+        if (minutes_timeout_id == 0)
+            return;
+        
+        Source.remove(minutes_timeout_id);
+        minutes_timeout_id = 0;
+    }
+    
+    public void add_event(Component.Event event) {
+        if (!days_events.add(event))
+            return;
+        
+        queue_draw();
+    }
+    
+    public void remove_event(Component.Event event) {
+        if (!days_events.remove(event))
+            return;
+        
+        queue_draw();
+    }
+    
+    public Component.Event? get_event_at(Gdk.Point point) {
+        Calendar.ExactTime exact_time = new Calendar.ExactTime(Calendar.Timezone.local, date,
+            get_wall_time(point.y));
+        foreach (Component.Event event in days_events) {
+            if (event.is_all_day)
+                continue;
+            
+            if (exact_time in event.exact_time_span)
+                return event;
+        }
+        
+        return null;
+    }
+    
+    // note that a painter's algorithm should be used here: background should be painted before
+    // calling base method, and foreground afterward
+    protected override bool on_draw(Cairo.Context ctx) {
+        // shade background color if this is current day or selected
+        if (selected) {
+            Gdk.cairo_set_source_rgba(ctx, Palette.instance.selection);
+            ctx.paint();
+        } else if (date.equal_to(Calendar.System.today)) {
+            Gdk.cairo_set_source_rgba(ctx, Palette.instance.current_day);
+            ctx.paint();
+        }
+        
+        base.on_draw(ctx);
+        
+        // each event is drawn with a slightly-transparent rectangle with a solid hairline bounding
+        Palette.prepare_hairline(ctx, Palette.instance.border);
+        
+        foreach (Component.Event event in days_events) {
+            // All-day events are handled in separate container ...
+            if (event.is_all_day)
+                continue;
+            
+            // ... as are events that span days (or outside this date, although that technically
+            // shouldn't happen)
+            Calendar.DateSpan date_span = event.get_event_date_span(Calendar.Timezone.local);
+            if (!date_span.is_same_day || !(date in date_span))
+                continue;
+            
+            Calendar.WallTime start_time = new Calendar.WallTime.from_exact_time(
+                event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local));
+            Calendar.WallTime end_time = new Calendar.WallTime.from_exact_time(
+                event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local));
+            
+            int start_y = get_line_y(start_time);
+            int end_y = get_line_y(end_time);
+            
+            Gdk.RGBA rgba = event.calendar_source.color_as_rgba();
+            
+            // event rectangle ... take some space off the right side to let the hour lines show
+            int rect_width = get_allocated_width() - RIGHT_MARGIN_PX;
+            ctx.rectangle(0, start_y, rect_width, end_y - start_y);
+            
+            // background rectangle (to prevent hour lines from showing when using alpha, below)
+            Gdk.cairo_set_source_rgba(ctx, Gfx.RGBA_WHITE);
+            ctx.fill_preserve();
+            
+            // interior rectangle (use alpha to mute colors)
+            rgba.alpha = 0.25;
+            Gdk.cairo_set_source_rgba(ctx, rgba);
+            ctx.fill_preserve();
+            
+            // bounding border line and text color
+            rgba.alpha = 1.0;
+            Gdk.cairo_set_source_rgba(ctx, rgba);
+            ctx.stroke();
+            
+            // time range on first line, summary on second ... note that separator character is an
+            // endash
+            string timespan = "%s &#x2013; %s".printf(
+                start_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE),
+                end_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE));
+            print_line(ctx, start_time, 0, timespan, rgba, rect_width, true);
+            print_line(ctx, start_time, 1, event.summary, rgba, rect_width, false);
+        }
+        
+        // draw horizontal line indicating current time
+        if (date.equal_to(Calendar.System.today)) {
+            int time_of_day_y = get_line_y(new Calendar.WallTime.from_exact_time(Calendar.System.now));
+            
+            Palette.prepare_hairline(ctx, Palette.instance.current_time);
+            ctx.move_to(0, time_of_day_y);
+            ctx.line_to(get_allocated_width(), time_of_day_y);
+            ctx.stroke();
+        }
+        
+        return true;
+    }
+    
+    private void print_line(Cairo.Context ctx, Calendar.WallTime start_time, int lineno, string text,
+        Gdk.RGBA rgba, int total_width, bool is_markup) {
+        Pango.Layout layout = create_pango_layout(null);
+        if (is_markup)
+            layout.set_markup(text, -1);
+        else
+            layout.set_text(text, -1);
+        layout.set_font_description(Palette.instance.small_font);
+        layout.set_width((total_width - (Palette.TEXT_MARGIN_PX * 2)) * Pango.SCALE);
+        layout.set_ellipsize(Pango.EllipsizeMode.END);
+        
+        int y = get_line_y(start_time) + Palette.LINE_PADDING_PX
+            + (Palette.instance.small_font_height_px * lineno);
+        
+        ctx.move_to(Palette.TEXT_MARGIN_PX, y);
+        Gdk.cairo_set_source_rgba(ctx, rgba);
+        Pango.cairo_show_layout(ctx, layout);
+    }
+}
+
+}
+
diff --git a/src/view/week/week-grid.vala b/src/view/week/week-grid.vala
new file mode 100644
index 0000000..8936e98
--- /dev/null
+++ b/src/view/week/week-grid.vala
@@ -0,0 +1,333 @@
+/* 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.View.Week {
+
+/**
+ * A GTK container that holds the various { link Pane}s for each day of thw week.
+ *
+ * Although this looks to be the perfect use of Gtk.Grid, some serious limitations with that widget
+ * forced this implementation to fall back on the old "boxes within boxes" of GTK 2.0.
+ * Specifically, the top-left cell in this widget must be a fixed width (the same as
+ * { link HourRunner}'s) and Gtk.Grid wouldn't let that occur, always giving it more space than it
+ * needed (although, strangely, always honoring the requested width for HourRunner).  This ruined
+ * the effect of an "empty" box in the top left corner where the date labels met the hour runner.
+ *
+ * The basic layout is a top row of date labels (with a spacer at the beginning, as mentioned)
+ * with a scrollable box of { link DayPane}s with an HourRunner on the left side which scrolls
+ * as well.  This layout ensures the date labels are always visible as the user scrolls down the
+ * time of day for all the panes.
+ */
+
+internal class Grid : Gtk.Box {
+    public const string PROP_WEEK = "week";
+    
+    public weak Controller owner { get; private set; }
+    
+    /**
+     * The calendar { link Week} this { link Grid} displays.
+     */
+    public Calendar.Week week { get; private set; }
+    
+    /**
+     * Name (id) of { link Grid}.
+     *
+     * This is for use in a Gtk.Stack.
+     */
+    public string id { owned get { return "%d:%s".printf(week.week_of_month, 
week.month_of_year.abbrev_name); } }
+    
+    private Backing.CalendarSubscriptionManager subscriptions;
+    private Gee.HashMap<Calendar.Date, DayPane> date_to_panes = new Gee.HashMap<Calendar.Date, DayPane>();
+    private Gee.HashMap<Calendar.Date, AllDayCell> date_to_all_day = new Gee.HashMap<Calendar.Date,
+        AllDayCell>();
+    private Toolkit.ButtonConnector day_pane_button_connector = new Toolkit.ButtonConnector();
+    private Gtk.ScrolledWindow scrolled_panes;
+    private Gtk.Widget right_spacer;
+    private bool vadj_init = false;
+    
+    public Grid(Controller owner, Calendar.Week week) {
+        Object(orientation: Gtk.Orientation.VERTICAL, spacing: 0);
+        
+        this.owner = owner;
+        this.week = week;
+        
+        // use a top horizontal box to properly space the spacer next to the horizontal grid of
+        // day labels and all-day cells
+        Gtk.Box top_box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
+        pack_start(top_box, false, true, 8);
+        
+        // fixed size space in top left corner of overall grid
+        Gtk.DrawingArea left_spacer = new Gtk.DrawingArea();
+        left_spacer.set_size_request(HourRunner.REQUESTED_WIDTH, -1);
+        left_spacer.draw.connect(on_draw_bottom_line);
+        left_spacer.draw.connect(on_draw_left_spacer_right_border);
+        top_box.pack_start(left_spacer, false, false, 0);
+        
+        // hold day labels and all-day cells in a non-scrolling horizontal grid
+        Gtk.Grid top_grid = new Gtk.Grid();
+        top_grid.column_homogeneous = true;
+        top_grid.column_spacing = 0;
+        top_grid.row_homogeneous = false;
+        top_grid.row_spacing = 0;
+        top_box.pack_start(top_grid, true, true, 0);
+        
+        // to line up with day panes grid below, need to account for the space of the ScrolledWindow's
+        // scrollbar
+        right_spacer = new Gtk.DrawingArea();
+        right_spacer.draw.connect(on_draw_right_spacer_left_border);
+        top_box.pack_end(right_spacer, false, false, 0);
+        
+        // hold Panes (DayPanes and HourRunner) in a scrolling Gtk.Grid
+        Gtk.Grid pane_grid = new Gtk.Grid();
+        pane_grid.column_homogeneous = false;
+        pane_grid.column_spacing = 0;
+        pane_grid.row_homogeneous = false;
+        pane_grid.row_spacing = 0;
+        
+        // attach an HourRunner to the left side of the Panes grid
+        pane_grid.attach(new HourRunner(this), 0, 1, 1, 1);
+        
+        // date labels across the top, week panes extending across the bottom ... start col at one
+        // to account for spacer/HourRunner
+        int col = 1;
+        foreach (Calendar.Date date in week) {
+            Gtk.Label date_label = new Gtk.Label("%s %d/%d".printf(date.day_of_week.abbrev_name,
+                date.month_of_year().month.value, date.day_of_month.value));
+            // draw a line along the bottom of the label
+            date_label.draw.connect(on_draw_bottom_line);
+            top_grid.attach(date_label, col, 0, 1, 1);
+            
+            // All-day cells (for drawing all-day and day-spanning events) go between the date
+            // label and the day panes
+            AllDayCell all_day_cell = new AllDayCell(this, date);
+            top_grid.attach(all_day_cell, col, 1, 1, 1);
+            
+            // save mapping
+            date_to_all_day.set(date, all_day_cell);
+            
+            DayPane pane = new DayPane(this, date);
+            pane.expand = true;
+            day_pane_button_connector.connect_to(pane);
+            pane_grid.attach(pane, col, 1, 1, 1);
+            
+            // save mapping
+            date_to_panes.set(date, pane);
+            
+            col++;
+        }
+        
+        // place Panes grid into a GtkScrolledWindow
+        scrolled_panes = new Gtk.ScrolledWindow(null, null);
+        scrolled_panes.hscrollbar_policy = Gtk.PolicyType.NEVER;
+        scrolled_panes.vscrollbar_policy = Gtk.PolicyType.ALWAYS;
+        scrolled_panes.add(pane_grid);
+        // connect_after to ensure border is last thing drawn
+        scrolled_panes.draw.connect_after(on_draw_top_line);
+        pack_end(scrolled_panes, true, true, 0);
+        
+        // connect scrollbar width to right_spacer (above) so it's the same width
+        scrolled_panes.get_vscrollbar().realize.connect(on_realloc_right_spacer);
+        scrolled_panes.get_vscrollbar().size_allocate.connect(on_realloc_right_spacer);
+        
+        // connect panes' event signal handlers
+        day_pane_button_connector.clicked.connect(on_day_pane_clicked);
+        day_pane_button_connector.double_clicked.connect(on_day_pane_double_clicked);
+        
+        // set up calendar subscriptions for the week
+        subscriptions = new Backing.CalendarSubscriptionManager(
+            new Calendar.ExactTimeSpan.from_span(week, Calendar.Timezone.local));
+        subscriptions.calendar_added.connect(on_calendar_added);
+        subscriptions.calendar_removed.connect(on_calendar_removed);
+        subscriptions.instance_added.connect(on_calendar_instance_added_or_altered);
+        subscriptions.instance_altered.connect(on_calendar_instance_added_or_altered);
+        subscriptions.instance_removed.connect(on_calendar_instance_removed);
+        
+        // only start now if owner is display this week, otherwise use timeout (to prevent
+        // subscriptions all coming up at once) ... use distance from current week as a way to
+        // spread out the timings, also assume that user will go forward rather than go backward,
+        // so weeks in past get +1 dinged against them
+        int diff = owner.week.difference(week);
+        if (diff < 0)
+            diff = diff.abs() + 1;
+        
+        if (diff != 0)
+            diff = 300 + (diff * 100);
+        
+        Timeout.add(diff, () => {
+            subscriptions.start_async.begin();
+            
+            return false;
+        });
+        
+        // watch for vertical adjustment to initialize to set the starting scroll position
+        scrolled_panes.vadjustment.changed.connect(on_vadjustment_changed);
+    }
+    
+    private void on_vadjustment_changed(Gtk.Adjustment vadj) {
+        // wait for vadjustment to look like something reasonable; also, only do this once
+        if (vadj.upper <= 1.0 || vadj_init)
+            return;
+        
+        // scroll to 6am when first created, unless in the current date, in which case scroll to
+        // current time
+        Calendar.WallTime start_time = Calendar.System.today in week
+            ? new Calendar.WallTime.from_exact_time(Calendar.System.now)
+            : new Calendar.WallTime(6, 0, 0);
+        
+        // scroll there
+        scrolled_panes.vadjustment.value = date_to_panes.get(week.start_date).get_line_y(start_time);
+        
+        // don't do this again
+        vadj_init = true;
+    }
+    
+    private bool on_draw_top_line(Gtk.Widget widget, Cairo.Context ctx) {
+        Palette.prepare_hairline(ctx, Palette.instance.border);
+        
+        ctx.move_to(0, 0);
+        ctx.line_to(widget.get_allocated_width(), 0);
+        ctx.stroke();
+        
+        return false;
+    }
+    
+    private bool on_draw_bottom_line(Gtk.Widget widget, Cairo.Context ctx) {
+        int width = widget.get_allocated_width();
+        int height = widget.get_allocated_height();
+        
+        Palette.prepare_hairline(ctx, Palette.instance.border);
+        
+        ctx.move_to(0, height);
+        ctx.line_to(width, height);
+        ctx.stroke();
+        
+        return false;
+    }
+    
+    // Draw the left spacer's right-hand line, which only goes up from the bottom to the top of the
+    // all-day cell it's adjacent to
+    private bool on_draw_left_spacer_right_border(Gtk.Widget widget, Cairo.Context ctx) {
+        int width = widget.get_allocated_width();
+        int height = widget.get_allocated_height();
+        Gtk.Widget adjacent = date_to_all_day.get(week.start_date);
+        
+        Palette.prepare_hairline(ctx, Palette.instance.border);
+        
+        ctx.move_to(width, height - adjacent.get_allocated_height());
+        ctx.line_to(width, height);
+        ctx.stroke();
+        
+        return false;
+    }
+    
+    // Like on_draw_left_spacer_right_line, this line is for the right spacer's left border
+    private bool on_draw_right_spacer_left_border(Gtk.Widget widget, Cairo.Context ctx) {
+        int height = widget.get_allocated_height();
+        Gtk.Widget adjacent = date_to_all_day.get(week.end_date);
+        
+        Palette.prepare_hairline(ctx, Palette.instance.border);
+        
+        ctx.move_to(0, height - adjacent.get_allocated_height());
+        ctx.line_to(0, height);
+        ctx.stroke();
+        
+        return false;
+    }
+    
+    private void on_realloc_right_spacer() {
+        // need to do outside of allocation signal due to some mechanism in GTK that prevents resizes
+        // while resizing
+        Idle.add(() => {
+            right_spacer.set_size_request(scrolled_panes.get_vscrollbar().get_allocated_width(), -1);
+            
+            return false;
+        });
+    }
+    
+    private void on_calendar_added(Backing.CalendarSource calendar) {
+    }
+    
+    private void on_calendar_removed(Backing.CalendarSource calendar) {
+    }
+    
+    private void on_calendar_instance_added_or_altered(Component.Instance instance) {
+        Component.Event? event = instance as Component.Event;
+        if (event == null)
+            return;
+        
+        foreach (Calendar.Date date in event.get_event_date_span(Calendar.Timezone.local)) {
+            if (event.is_day_spanning) {
+                AllDayCell? all_day_cell = date_to_all_day.get(date);
+                if (all_day_cell != null)
+                    all_day_cell.add_event(event);
+            } else {
+                DayPane? day_pane = date_to_panes.get(date);
+                if (day_pane != null)
+                    day_pane.add_event(event);
+            }
+        }
+    }
+    
+    private void on_calendar_instance_removed(Component.Instance instance) {
+        Component.Event? event = instance as Component.Event;
+        if (event == null)
+            return;
+        
+        foreach (Calendar.Date date in event.get_event_date_span(Calendar.Timezone.local)) {
+            if (event.is_day_spanning) {
+                AllDayCell? all_day_cell = date_to_all_day.get(date);
+                if (all_day_cell != null)
+                    all_day_cell.remove_event(event);
+            } else {
+                DayPane? day_pane = date_to_panes.get(date);
+                if (day_pane != null)
+                    day_pane.remove_event(event);
+            }
+        }
+    }
+    
+    internal AllDayCell? get_all_day_cell_for_date(Calendar.Date cell_date) {
+        return date_to_all_day.get(cell_date);
+    }
+    
+    private void on_day_pane_clicked(Toolkit.ButtonEvent details, bool guaranteed) {
+        // only interested in unguaranteed clicks on the primary mouse button
+        if (details.button != Toolkit.Button.PRIMARY || guaranteed)
+            return;
+        
+        DayPane day_pane = (DayPane) details.widget;
+        
+        Component.Event? event = day_pane.get_event_at(details.press_point);
+        if (event != null)
+            owner.request_display_event(event, day_pane, details.press_point);
+    }
+    
+    private void on_day_pane_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;
+        
+        DayPane day_pane = (DayPane) details.widget;
+        
+        // if an event is at this location, don't process
+        if (day_pane.get_event_at(details.press_point) != null)
+            return;
+        
+        // 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.ExactTime start_time = new Calendar.ExactTime(Calendar.Timezone.local,
+            day_pane.date, wall_time);
+        
+        owner.request_create_timed_event(
+            new Calendar.ExactTimeSpan(start_time, start_time.adjust_time(1, Calendar.TimeUnit.HOUR)),
+            day_pane, details.press_point);
+    }
+}
+
+}
+
diff --git a/src/view/week/week-hour-runner.vala b/src/view/week/week-hour-runner.vala
new file mode 100644
index 0000000..9a843c3
--- /dev/null
+++ b/src/view/week/week-hour-runner.vala
@@ -0,0 +1,59 @@
+/* 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.View.Week {
+
+internal class HourRunner : Pane {
+    public const int REQUESTED_WIDTH = 50;
+    
+    private const Calendar.WallTime.PrettyFlag TIME_FLAGS =
+        Calendar.WallTime.PrettyFlag.OPTIONAL_MINUTES;
+    
+    public HourRunner(Grid owner) {
+        base (owner, REQUESTED_WIDTH);
+        
+        Calendar.System.instance.is_24hr_changed.connect(queue_draw);
+    }
+    
+    ~HourRunner() {
+        Calendar.System.instance.is_24hr_changed.disconnect(queue_draw);
+    }
+    
+    // note that a painter's algorithm should be used here: background should be painted before
+    // calling base method, and foreground afterward
+    protected override bool on_draw(Cairo.Context ctx) {
+        if (!base.on_draw(ctx))
+            return false;
+        
+        int right_justify_px = get_allocated_width() - Palette.TEXT_MARGIN_PX;
+        
+        // draw hours in the border color
+        Gdk.cairo_set_source_rgba(ctx, Palette.instance.border);
+        
+        // draw time-of-day down right-hand side of HourRunner pane, which acts as tick marks for
+        // the rest of the week view
+        Calendar.WallTime wall_time = Calendar.WallTime.earliest;
+        for (;;) {
+            Pango.Layout layout = create_pango_layout(wall_time.to_pretty_string(TIME_FLAGS));
+            layout.set_font_description(Palette.instance.small_font);
+            layout.set_width(right_justify_px);
+            layout.set_alignment(Pango.Alignment.RIGHT);
+            
+            ctx.move_to(right_justify_px, get_text_y(wall_time));
+            Pango.cairo_show_layout(ctx, layout);
+            
+            bool rollover;
+            wall_time = wall_time.adjust(1, Calendar.TimeUnit.HOUR, out rollover);
+            if (rollover)
+                break;
+        }
+        
+        return true;
+    }
+}
+
+}
+
diff --git a/src/view/week/week-pane.vala b/src/view/week/week-pane.vala
new file mode 100644
index 0000000..b9603ec
--- /dev/null
+++ b/src/view/week/week-pane.vala
@@ -0,0 +1,135 @@
+/* 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.View.Week {
+
+internal abstract class Pane : Gtk.EventBox {
+    public weak Grid owner { get; private set; }
+    
+    // The height of each "line" of text, including top and bottom padding
+    protected int line_height_px { get; private set; default = 0; }
+    
+    private int requested_width;
+    private Gtk.DrawingArea canvas = new Gtk.DrawingArea();
+    
+    public Pane(Grid owner, int requested_width) {
+        this.owner = owner;
+        this.requested_width = requested_width;
+        
+        margin = 0;
+        
+        add(canvas);
+        
+        update_palette_metrics();
+        Palette.instance.palette_changed.connect(on_palette_changed);
+        
+        canvas.draw.connect(on_draw);
+    }
+    
+    ~Pane() {
+        Palette.instance.palette_changed.disconnect(on_palette_changed);
+    }
+    
+    private void update_palette_metrics() {
+        // calculate the amount of space each "line" gets when drawing (normal font height plus
+        // padding on top and bottom)
+        line_height_px = Palette.instance.normal_font_height_px + (Palette.LINE_PADDING_PX * 2);
+        
+        // update the height request based on the number of lines needed to show the entire day
+        canvas.set_size_request(requested_width, get_line_y(Calendar.WallTime.latest));
+    }
+    
+    private void on_palette_changed() {
+        update_palette_metrics();
+        queue_draw();
+    }
+    
+    protected virtual bool on_draw(Cairo.Context ctx) {
+        int width = get_allocated_width();
+        int height = get_allocated_height();
+        
+        // save and restore so child override doesn't have to deal with context state issues
+        ctx.save();
+        
+        // draw right-side border line
+        Palette.prepare_hairline(ctx, Palette.instance.border);
+        ctx.move_to(width, 0);
+        ctx.line_to(width, height);
+        ctx.line_to(0, height);
+        ctx.stroke();
+        
+        // draw hour and half-hour lines
+        Calendar.WallTime wall_time = Calendar.WallTime.earliest;
+        for(;;) {
+            bool rollover;
+            wall_time = wall_time.adjust(30, Calendar.TimeUnit.MINUTE, out rollover);
+            if (rollover)
+                break;
+            
+            int line_y = get_line_y(wall_time);
+            
+            // solid line on the hour, dashed on the half-hour
+            if (wall_time.minute == 0)
+                Palette.prepare_hairline(ctx, Palette.instance.border);
+            else
+                Palette.prepare_hairline_dashed(ctx, Palette.instance.border);
+            
+            ctx.move_to(0, line_y);
+            ctx.line_to(width, line_y);
+            ctx.stroke();
+        }
+        
+        ctx.restore();
+        
+        return true;
+    }
+    
+    /**
+     * Returns the y (in pixels) for a particular line of text for the { link Calendar.WallTime}.
+     *
+     * If displaying text, use { link get_text_y}, as that will deduct padding.
+     */
+    public int get_line_y(Calendar.WallTime wall_time) {
+        // every hour gets two "lines" of text
+        int line_y = line_height_px * 2 * wall_time.hour;
+        
+        // break up space for each minute in the two lines per hour
+        if (wall_time.minute != 0) {
+            double fraction = (double) wall_time.minute / (double) Calendar.WallTime.MINUTES_PER_HOUR;
+            double amt = (double) line_height_px * 2.0 * fraction;
+            
+            line_y += (int) Math.round(amt);
+        }
+        
+        return line_y;
+    }
+    
+    /**
+     * Returns the y (in pixels) for the top of a line of text at { link Calendar.WallTime}.
+     *
+     * Use this when displaying text.  Drawing lines, borders, etc. should use { link get_line_y}.
+     */
+    public int get_text_y(Calendar.WallTime wall_time) {
+        return get_line_y(wall_time) + Palette.LINE_PADDING_PX;
+    }
+    
+    /**
+     * Returns the { link Calendar.WallTime} for a y-coordinate down to the minute;
+     */
+    public Calendar.WallTime get_wall_time(int y) {
+        // every hour gets two "lines" of text
+        int one_hour = line_height_px * 2;
+        
+        int hour = y / one_hour;
+        int rem = y % one_hour;
+        double min = ((double) rem / (double) one_hour) * 60.0;
+        
+        return new Calendar.WallTime(hour, (int) min, 0);
+    }
+}
+
+}
+
diff --git a/src/view/week/week.vala b/src/view/week/week.vala
new file mode 100644
index 0000000..4dc39c7
--- /dev/null
+++ b/src/view/week/week.vala
@@ -0,0 +1,39 @@
+/* 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.
+ */
+
+/**
+ * Views for displaying calendar information by the week.
+ */
+
+namespace California.View.Week {
+
+private int init_count = 0;
+
+public void init() throws Error {
+    if (!Unit.do_init(ref init_count))
+        return;
+    
+    // unit initialization
+    Calendar.init();
+    Backing.init();
+    Component.init();
+    Toolkit.init();
+    View.Common.init();
+}
+
+public void terminate() {
+    if (!Unit.do_terminate(ref init_count))
+        return;
+    
+    View.Common.terminate();
+    Toolkit.terminate();
+    Component.terminate();
+    Backing.terminate();
+    Calendar.terminate();
+}
+
+}
+


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