[california/wip/725767-week] Display all-day events in top of week grid



commit 3484831bc2b3d49ee533051c6d32efb1b435b2ae
Author: Jim Nelson <jim yorba org>
Date:   Wed May 21 19:51:45 2014 -0700

    Display all-day events in top of week grid

 src/Makefile.am                         |    4 +
 src/calendar/calendar-week.vala         |    2 +-
 src/component/component-event.vala      |    9 +
 src/view/common/common-events-cell.vala |  615 +++++++++++++++++++++++++++++++
 src/view/common/common.vala             |   31 ++
 src/view/month/month-cell.vala          |  544 ++--------------------------
 src/view/month/month-grid.vala          |   11 +-
 src/view/month/month.vala               |    2 +
 src/view/view.vala                      |    2 +
 src/view/week/week-all-day-cell.vala    |   73 ++++
 src/view/week/week-day-pane.vala        |   12 +-
 src/view/week/week-grid.vala            |  106 +++++-
 src/view/week/week.vala                 |    2 +
 13 files changed, 864 insertions(+), 549 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index 3728f67..6f66f9f 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -133,12 +133,16 @@ california_VALASOURCES = \
        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 \
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/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/view/common/common-events-cell.vala b/src/view/common/common-events-cell.vala
new file mode 100644
index 0000000..45dddcc
--- /dev/null
+++ b/src/view/common/common-events-cell.vala
@@ -0,0 +1,615 @@
+/* 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 = "top-line";
+    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}.
+     */
+    public Gdk.RGBA top_line_rgba { get; set; }
+    
+    // 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].connect(queue_draw);
+        notify[PROP_TOP_LINE_RGBA].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) {
+    }
+    
+    /**
+     * Can be overridden by children to achieve painters' strategy effects while drawing.
+     */
+    protected virtual bool on_draw(Cairo.Context ctx) {
+        // save and restore context so subclasses don't have to deal with it
+        ctx.save();
+        
+        // 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);
+        }
+        
+        ctx.restore();
+        
+        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, 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, 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);
+        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 aa3b391..ed41744 100644
--- a/src/view/month/month-cell.vala
+++ b/src/view/month/month-cell.vala
@@ -7,370 +7,43 @@
 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 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;
-        }
-        
-        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;
-        }
+    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 (_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 Cell(Grid owner, int row, int col) {
         this.owner = owner;
         this.row = row;
         this.col = col;
         
-        // see query_tooltip() for implementation
-        has_tooltip = true;
+        notify[PROP_DATE].connect(update_top_line);
         
-        // wrap the EventBox around the DrawingArea, which is the real widget of interest for this
-        // class
-        add(canvas);
+        owner.notify[Grid.PROP_FIRST_OF_WEEK].connect(on_first_of_week_changed);
         
-        notify["date"].connect(queue_draw);
-        notify["selected"].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);
+        update_top_line();
     }
     
     ~Cell() {
-        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);
-    }
-    
-    // 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();
-    }
-    
-    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();
-    }
-    
-    // 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();
-    }
-    
-    // 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;
+        owner.notify[Grid.PROP_FIRST_OF_WEEK].disconnect(on_first_of_week_changed);
     }
     
-    // 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;
+    protected override Common.EventsCell? get_cell_for_date(Calendar.Date cell_date) {
+        return owner.get_cell_for_date(cell_date);
     }
     
-    private void on_24hr_changed() {
-        if (has_events())
-            queue_draw();
+    private void on_first_of_week_changed() {
+        change_date_and_neighbors(date, date.week_of(owner.first_of_week).to_date_span());
     }
     
-    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) {
-        // 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 != null && date.equal_to(Calendar.System.today)) {
-            Gdk.cairo_set_source_rgba(ctx, Palette.instance.current_day);
-            ctx.paint();
-        }
-        
+    protected override void draw_borders(Cairo.Context ctx) {
         int width = get_allocated_width();
         int height = get_allocated_height();
         
@@ -396,192 +69,21 @@ private class Cell : Gtk.EventBox {
         }
         
         ctx.stroke();
-        
-        // draw day of month as the top line
-        if (date != null) {
-            unowned Gdk.RGBA color = (date in owner.month_of_year)
-                ? Palette.instance.day_in_range
-                : Palette.instance.day_outside_range;
-            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;
     }
     
-    // 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 = Palette.TEXT_MARGIN_PX;
-        } else {
-            // starting y of "regular" lines
-            y = Palette.TEXT_MARGIN_PX + Palette.instance.normal_font_height_px + Palette.LINE_PADDING_PX;
+    private void update_top_line() {
+         if (owner.owner.show_outside_month) {
+            top_line_text = null;
             
-            // 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, 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, 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 });
+            return;
         }
         
-        // 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);
+        top_line_text = date.day_of_month.informal_number;
         
-        Pango.Layout layout = create_pango_layout(text);
-        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;
+        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-grid.vala b/src/view/month/month-grid.vala
index 29cb3ad..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);
diff --git a/src/view/month/month.vala b/src/view/month/month.vala
index 6c4f016..45096e3 100644
--- a/src/view/month/month.vala
+++ b/src/view/month/month.vala
@@ -17,6 +17,7 @@ public void init() throws Error {
         return;
     
     // unit initialization
+    View.Common.init();
     Calendar.init();
     Component.init();
     Backing.init();
@@ -29,6 +30,7 @@ public void terminate() {
     Backing.terminate();
     Component.terminate();
     Calendar.terminate();
+    View.Common.terminate();
 }
 
 }
diff --git a/src/view/view.vala b/src/view/view.vala
index 1af104e..ef61572 100644
--- a/src/view/view.vala
+++ b/src/view/view.vala
@@ -21,6 +21,7 @@ public void init() throws Error {
     Palette.init();
     
     // subunit initialization
+    View.Common.init();
     View.Month.init();
     View.Week.init();
 }
@@ -31,6 +32,7 @@ public void terminate() {
     
     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..617e24a
--- /dev/null
+++ b/src/view/week/week-all-day-cell.vala
@@ -0,0 +1,73 @@
+/* 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);
+        
+        // if last day of week, draw right border prepping for bottom border
+        if (date.equal_to(neighbors.end_date)) {
+            ctx.move_to(width, 0);
+            ctx.line_to(width, height);
+        } else {
+            // otherwise, prepare for bottom border
+            ctx.move_to(width, height);
+        }
+        
+        // draw bottom border
+        ctx.line_to(0, height);
+        
+        // draw left border
+        ctx.line_to(0, 0);
+        
+        ctx.stroke();
+    }
+}
+
+}
+
diff --git a/src/view/week/week-day-pane.vala b/src/view/week/week-day-pane.vala
index 4412386..2365e7f 100644
--- a/src/view/week/week-day-pane.vala
+++ b/src/view/week/week-day-pane.vala
@@ -6,6 +6,13 @@
 
 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";
@@ -131,11 +138,12 @@ internal class DayPane : Pane {
         Palette.prepare_hairline(ctx, Palette.instance.border);
         
         foreach (Component.Event event in days_events) {
-            // TODO: Show all-day events
+            // All-day events are handled in separate container ...
             if (event.is_all_day)
                 continue;
             
-            // TODO: Show events that spans days
+            // ... 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;
diff --git a/src/view/week/week-grid.vala b/src/view/week/week-grid.vala
index 4a1ad47..b55a7d0 100644
--- a/src/view/week/week-grid.vala
+++ b/src/view/week/week-grid.vala
@@ -41,7 +41,11 @@ internal class Grid : Gtk.Box {
     
     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();
+    Gtk.ScrolledWindow scrolled_panes;
+    Gtk.Widget right_spacer;
     
     public Grid(Controller owner, Calendar.Week week) {
         Object(orientation: Gtk.Orientation.VERTICAL, spacing: 0);
@@ -49,15 +53,29 @@ internal class Grid : Gtk.Box {
         this.owner = owner;
         this.week = week;
         
-        // hold date labels in a horizontal box
-        Gtk.Box label_box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
-        pack_start(label_box, false, true, 0);
+        // 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 "grid"
-        Gtk.DrawingArea spacer = new Gtk.DrawingArea();
-        spacer.set_size_request(HourRunner.REQUESTED_WIDTH, -1);
-        spacer.draw.connect(on_draw_bottom_line);
-        label_box.pack_start(spacer, false, false, 0);
+        // 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);
+        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();
+        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();
@@ -67,7 +85,7 @@ internal class Grid : Gtk.Box {
         pane_grid.row_spacing = 0;
         
         // attach an HourRunner to the left side of the Panes grid
-        pane_grid.attach(new HourRunner(this), 0, 0, 1, 1);
+        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
@@ -75,29 +93,42 @@ internal class Grid : Gtk.Box {
         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);
             
-            label_box.pack_start(date_label, true, true, 0);
+            // 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, 0, 1, 1);
+            pane_grid.attach(pane, col, 1, 1, 1);
             
+            // save mapping
             date_to_panes.set(date, pane);
             
             col++;
         }
         
         // place Panes grid into a GtkScrolledWindow
-        Gtk.ScrolledWindow scrolled_panes = new Gtk.ScrolledWindow(null, null);
+        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);
@@ -129,11 +160,22 @@ internal class Grid : Gtk.Box {
         });
     }
     
+    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();
@@ -141,6 +183,16 @@ internal class Grid : Gtk.Box {
         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) {
     }
     
@@ -153,9 +205,15 @@ internal class Grid : Gtk.Box {
             return;
         
         foreach (Calendar.Date date in event.get_event_date_span(Calendar.Timezone.local)) {
-            DayPane? day_pane = date_to_panes.get(date);
-            if (day_pane != null)
-                day_pane.add_event(event);
+            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);
+            }
         }
     }
     
@@ -165,12 +223,22 @@ internal class Grid : Gtk.Box {
             return;
         
         foreach (Calendar.Date date in event.get_event_date_span(Calendar.Timezone.local)) {
-            DayPane? day_pane = date_to_panes.get(date);
-            if (day_pane != null)
-                day_pane.remove_event(event);
+            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) {
         if (details.button != Toolkit.Button.PRIMARY)
             return;
diff --git a/src/view/week/week.vala b/src/view/week/week.vala
index 3cf1252..4dc39c7 100644
--- a/src/view/week/week.vala
+++ b/src/view/week/week.vala
@@ -21,12 +21,14 @@ public void init() throws Error {
     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();


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