[california] Stack overlapping events in Week view: Bug #730602



commit faccd2935222858b460cfd4b59c8862c5de11379
Author: Jim Nelson <jim yorba org>
Date:   Tue Oct 21 18:41:12 2014 -0700

    Stack overlapping events in Week view: Bug #730602
    
    Stagger event rectangles in Week view so they don't overlap and block
    each other.
    
    This isn't perfect, in the sense that the rectangles don't overlap at
    all, which would be a bit more efficient with space.  However, that
    kind of refinement will have to wait for later.

 src/Makefile.am                            |    1 +
 src/calendar/calendar-exact-time-span.vala |    8 +
 src/toolkit/toolkit-region-manager.vala    |  108 ++++++++++++
 src/view/week/week-day-pane.vala           |  255 ++++++++++++++++++----------
 4 files changed, 279 insertions(+), 93 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index 5b025d7..270ff7a 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -142,6 +142,7 @@ california_VALASOURCES = \
        toolkit/toolkit-motion-event.vala \
        toolkit/toolkit-mutable-widget.vala \
        toolkit/toolkit-popup.vala \
+       toolkit/toolkit-region-manager.vala \
        toolkit/toolkit-rotating-button-box.vala \
        toolkit/toolkit-stack-model.vala \
        \
diff --git a/src/calendar/calendar-exact-time-span.vala b/src/calendar/calendar-exact-time-span.vala
index d4632af..e26d4f4 100644
--- a/src/calendar/calendar-exact-time-span.vala
+++ b/src/calendar/calendar-exact-time-span.vala
@@ -108,6 +108,14 @@ public class ExactTimeSpan : BaseObject, Gee.Comparable<ExactTimeSpan>, Gee.Hash
     }
     
     /**
+     * Returns true if there's a union between the two { link ExactTimeSpan}s.
+     */
+    public bool coincides_with(ExactTimeSpan other) {
+        return contains(other.start_exact_time) || contains(other.end_exact_time)
+            || other.contains(start_exact_time) || other.contains(end_exact_time);
+    }
+    
+    /**
      * Returns a prettified string describing the { link Event}'s time span in as concise and
      * economical manner possible.
      *
diff --git a/src/toolkit/toolkit-region-manager.vala b/src/toolkit/toolkit-region-manager.vala
new file mode 100644
index 0000000..84b773f
--- /dev/null
+++ b/src/toolkit/toolkit-region-manager.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 {
+
+/**
+ * RegionManager associates Cairo regions with elements of abstract type and provides basic hit
+ * detection.
+ */
+
+public class RegionManager<G> : BaseObject {
+    /**
+     * Callback for { link iterate_hits}.
+     *
+     * Return false if iterate_hits() should halt iteration.
+     */
+    public delegate bool HitDetected<G>(G element, Cairo.Region region);
+    
+    private Gee.HashMap<G, Cairo.Region> regions;
+    
+    public RegionManager(owned Gee.HashDataFunc<G>? key_hash_func = null, owned Gee.EqualDataFunc<G>? 
key_equal_func = null) {
+        regions = new Gee.HashMap<G, Cairo.Region>(key_hash_func, key_equal_func, region_equal_func);
+    }
+    
+    /**
+     * Associate a Cairo.Region with the element.
+     *
+     * Any regions previously associated with the element are dropped.  This does not expand or
+     * contract an existing region.
+     */
+    public void add_region(G element, Cairo.Region region) {
+        regions.set(element, region);
+    }
+    
+    /**
+     * Associate a Cairo.Region defined as a rectangle with the element
+     *
+     * @see add_region
+     */
+    public void add_rectangle(G element, Cairo.RectangleInt rect) {
+        add_region(element, new Cairo.Region.rectangle(rect));
+    }
+    
+    /**
+     * Associate a Cairo.Region defined as a set of rectangle points with the element
+     *
+     * @see add_region
+     */
+    public void add_points(G element, int x, int y, int width, int height) {
+        add_rectangle(element, { x, y, width, height });
+    }
+    
+    /**
+     * Unassociated a Cairo.Region from the element.
+     *
+     * Returns false if the element was unknown to { link RegionManager}.
+     */
+    public bool remove_region(G element) {
+        return regions.unset(element);
+    }
+    
+    /**
+     * Iterate all elements whose regions contain the supplied point.
+     *
+     * The { link HitDetected} callback should return true to continue iteration, false to halt it.
+     */
+    public void iterate_hits(Gdk.Point point, HitDetected<G> hit_detected) {
+        // TODO: Obviously there are more sophisticated hit-detection algorithms out there, but
+        // this brute-force approach will have to do for now.
+        Gee.MapIterator<G, Cairo.Region> iter = regions.map_iterator();
+        while (iter.next()) {
+            if (iter.get_value().contains_point(point.x, point.y)) {
+                if (!hit_detected(iter.get_key(), iter.get_value()))
+                    break;
+            }
+        }
+    }
+    
+    /**
+     * Returns a list of elements whose regions contain the supplied point.
+     *
+     * "Grab air, youse mugs!"
+     */
+    public Gee.List<G> hit_list(Gdk.Point point) {
+        Gee.List<G> list = new Gee.ArrayList<G>();
+        iterate_hits(point, (element, region) => {
+            list.add(element);
+            
+            return true;
+        });
+        
+        return list;
+    }
+    
+    private static bool region_equal_func(Cairo.Region a, Cairo.Region b) {
+        return a.equal(b);
+    }
+    
+    public override string to_string() {
+        return classname;
+    }
+}
+
+}
+
diff --git a/src/view/week/week-day-pane.vala b/src/view/week/week-day-pane.vala
index 40fd842..d728d72 100644
--- a/src/view/week/week-day-pane.vala
+++ b/src/view/week/week-day-pane.vala
@@ -25,6 +25,38 @@ internal class DayPane : Pane, Common.InstanceContainer {
     // lines are visible
     private const int RIGHT_MARGIN_PX = 10;
     
+    // Groups coinciding Events together, held in sorted order by their earliest Event
+    private class EventStack : Object, Gee.Comparable<EventStack> {
+        public Gee.TreeSet<Component.Event> events = new Gee.TreeSet<Component.Event>();
+        public Calendar.ExactTimeSpan earliest;
+        
+        public EventStack(Component.Event initial) {
+            add(initial);
+        }
+        
+        public bool coincides_with(Component.Event event) {
+            assert(event.exact_time_span != null);
+            assert(!events.contains(event));
+            
+            return traverse<Component.Event>(events)
+                .any(contained => event.exact_time_span.coincides_with(contained.exact_time_span));
+        }
+        
+        public void add(Component.Event event) {
+            assert(event.exact_time_span != null);
+            assert(!events.contains(event));
+            
+            if (earliest == null || earliest.compare_to(event.exact_time_span) > 0)
+                earliest = event.exact_time_span;
+            
+            events.add(event);
+        }
+        
+        public int compare_to(EventStack other) {
+            return earliest.compare_to(other.earliest);
+        }
+    }
+    
     public Calendar.Date date { get; set; }
     
     /**
@@ -53,7 +85,10 @@ internal class DayPane : Pane, Common.InstanceContainer {
     public Calendar.Span contained_span { get { return date; } }
     
     private Gee.HashSet<Component.Event> days_events = new Gee.HashSet<Component.Event>();
+    private Gee.TreeSet<EventStack> event_stacks = new Gee.TreeSet<EventStack>();
+    private Toolkit.RegionManager<Component.Event> region_manager = new 
Toolkit.RegionManager<Component.Event>();
     private Scheduled? scheduled_monitor = null;
+    private string key_tooltip_date;
     
     public DayPane(Grid owner, Calendar.Date date) {
         base (owner, -1);
@@ -63,6 +98,10 @@ internal class DayPane : Pane, Common.InstanceContainer {
         // see query_tooltip()
         has_tooltip = true;
         
+        // store individual tooltip keys so tooltips can be maintained across days for the same
+        // event
+        key_tooltip_date = KEY_TOOLTIP + date.to_string();;
+        
         notify[PROP_DATE].connect(queue_draw);
         
         Calendar.System.instance.is_24hr_changed.connect(queue_draw);
@@ -106,6 +145,47 @@ internal class DayPane : Pane, Common.InstanceContainer {
         schedule_monitor_minutes();
     }
     
+    private bool filter_date_spanning_events(Component.Event event) {
+        // All-day events are handled in separate container ...
+        if (event.is_all_day)
+            return false;
+        
+        // filter events entirely outside this date (although that should've been caught before)
+        return date in event.get_event_date_span(Calendar.Timezone.local);
+    }
+    
+    private void restack_events() {
+        // filter out date-spanning Events and not-visible calendars
+        Gee.ArrayList<Component.Event> filtered_events = traverse<Component.Event>(days_events)
+            .filter(filter_date_spanning_events)
+            .filter(event => event.calendar_source != null && event.calendar_source.visible)
+            .to_array_list();
+        
+        Gee.ArrayList<EventStack> stack_list = new Gee.ArrayList<EventStack>();
+        foreach (Component.Event event in filtered_events) {
+            // search existing stacks for a place for this Event
+            EventStack? found = null;
+            foreach (EventStack event_stack in stack_list) {
+                if (event_stack.coincides_with(event)) {
+                    found = event_stack;
+                    
+                    break;
+                }
+            }
+            
+            if (found == null) {
+                found = new EventStack(event);
+                stack_list.add(found);
+            } else {
+                found.add(event);
+            }
+        }
+        
+        // Can't persist EventStacks in TreeSet because mutation is not handled well, see
+        // https://bugzilla.gnome.org/show_bug.cgi?id=736444
+        event_stacks = traverse<EventStack>(stack_list).to_tree_set();
+    }
+    
     public void add_event(Component.Event event) {
         if (!days_events.add(event)) {
             debug("Unable to add event %s to day pane for %s: already present", event.to_string(),
@@ -114,6 +194,8 @@ internal class DayPane : Pane, Common.InstanceContainer {
             return;
         }
         
+        restack_events();
+        
         event.notify[Component.Event.PROP_SUMMARY].connect(queue_draw);
         event.notify[Component.Event.PROP_DATE_SPAN].connect(on_update_date_time);
         event.notify[Component.Event.PROP_EXACT_TIME_SPAN].connect(on_update_date_time);
@@ -129,6 +211,12 @@ internal class DayPane : Pane, Common.InstanceContainer {
             return;
         }
         
+        // ignore return code because it's possible the event was not added to the region manager
+        // (if a redraw did not occur, for example)
+        region_manager.remove_region(event);
+        
+        restack_events();
+        
         event.notify[Component.Event.PROP_SUMMARY].disconnect(queue_draw);
         event.notify[Component.Event.PROP_DATE_SPAN].disconnect(on_update_date_time);
         event.notify[Component.Event.PROP_EXACT_TIME_SPAN].disconnect(on_update_date_time);
@@ -138,6 +226,7 @@ internal class DayPane : Pane, Common.InstanceContainer {
     
     public void clear_events() {
         days_events.clear();
+        restack_events();
         
         queue_draw();
     }
@@ -149,21 +238,13 @@ internal class DayPane : Pane, Common.InstanceContainer {
         if (!(date in event.get_event_date_span(Calendar.System.timezone)))
             remove_event(event);
         
+        restack_events();
+        
         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;
+        return traverse<Component.Event>(region_manager.hit_list(point)).first();
     }
     
     public void update_selection(Calendar.WallTime wall_time) {
@@ -214,18 +295,11 @@ internal class DayPane : Pane, Common.InstanceContainer {
     }
     
     public override bool query_tooltip(int x, int y, bool keyboard_mode, Gtk.Tooltip tooltip) {
-        // convery y into a time of day
-        Calendar.WallTime wall_time = get_wall_time(y);
-        Calendar.ExactTime exact_time = new Calendar.ExactTime(Calendar.Timezone.local, date, wall_time);
-        
-        // find event in list that spans this time
-        // TODO: This won't work when events are stacked in the UI
-        Component.Event? found = traverse<Component.Event>(days_events)
-            .first_matching(event => 
event.exact_time_span.to_timezone(Calendar.Timezone.local).contains(exact_time));
+        Component.Event? found = get_event_at({ x, y });
         if (found == null)
             return false;
         
-        string? tooltip_text = found.get_data<string?>(KEY_TOOLTIP);
+        string? tooltip_text = found.get_data<string?>(key_tooltip_date);
         if (String.is_empty(tooltip_text))
             return false;
         
@@ -234,16 +308,6 @@ internal class DayPane : Pane, Common.InstanceContainer {
         return true;
     }
     
-    private bool filter_date_spanning_events(Component.Event event) {
-        // All-day events are handled in separate container ...
-        if (event.is_all_day)
-            return false;
-        
-        // ... as are events that span days (or outside this date, although that technically
-        // shouldn't happen)
-        return date in event.get_event_date_span(Calendar.Timezone.local);
-    }
-    
     // 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) {
@@ -258,68 +322,73 @@ internal class DayPane : Pane, Common.InstanceContainer {
         // each event is drawn with a slightly-transparent rectangle with a solid hairline bounding
         Palette.prepare_hairline(ctx, palette.border);
         
-        // Can't persist events in TreeSet because mutation is not handled well, see
-        // https://bugzilla.gnome.org/show_bug.cgi?id=736444
-        Gee.TreeSet<Component.Event> sorted_events = traverse<Component.Event>(days_events)
-            .filter(filter_date_spanning_events)
-            .filter(event => event.calendar_source != null && event.calendar_source.visible)
-            .to_tree_set();
-        foreach (Component.Event event in sorted_events) {
-            // The actual WallTime is the time on the starting date (which may not be this Pane's
-            // date).  The draw WallTime is the time on this Pane's date to start and end drawing
-            // the rectangle
-            
-            Calendar.WallTime actual_start_time =
-                event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
-            Calendar.WallTime draw_start_time;
-            if (event.exact_time_span.start_date.equal_to(date))
-                draw_start_time = actual_start_time;
-            else
-                draw_start_time = Calendar.WallTime.earliest;
-            
-            Calendar.WallTime actual_end_time =
-                event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
-            Calendar.WallTime draw_end_time;
-            if (event.exact_time_span.end_date.equal_to(date))
-                draw_end_time = actual_end_time;
-            else
-                draw_end_time = Calendar.WallTime.latest;
-            
-            int start_y = get_line_y(draw_start_time);
-            int end_y = get_line_y(draw_end_time);
-            
-            Gdk.RGBA rgba = event.calendar_source.color_as_rgba();
-            
+        foreach (EventStack event_stack in event_stacks) {
             // 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.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(
-                actual_start_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE),
-                actual_end_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE));
-            Pango.Layout layout_0 = print_line(ctx, draw_start_time, 0, timespan, rgba, rect_width, true);
-            Pango.Layout layout_1 = print_line(ctx, draw_start_time, 1, event.summary, rgba, rect_width, 
false);
+            int rect_width = (get_allocated_width() - RIGHT_MARGIN_PX) / event_stack.events.size;
             
-            // if either was ellipsized, set tooltip (otherwise clear any existing)
-            bool is_ellipsized = layout_0.is_ellipsized() || layout_1.is_ellipsized();
-            event.set_data<string?>(KEY_TOOLTIP,
-                is_ellipsized ? "%s\n%s".printf(timespan, GLib.Markup.escape_text(event.summary)) : null);
+            int ctr = 0;
+            foreach (Component.Event event in event_stack.events) {
+                // The actual WallTime is the time on the starting date (which may not be this Pane's
+                // date).  The draw WallTime is the time on this Pane's date to start and end drawing
+                // the rectangle
+                
+                Calendar.WallTime actual_start_time =
+                    
event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
+                Calendar.WallTime draw_start_time;
+                if (event.exact_time_span.start_date.equal_to(date))
+                    draw_start_time = actual_start_time;
+                else
+                    draw_start_time = Calendar.WallTime.earliest;
+                
+                Calendar.WallTime actual_end_time =
+                    event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
+                Calendar.WallTime draw_end_time;
+                if (event.exact_time_span.end_date.equal_to(date))
+                    draw_end_time = actual_end_time;
+                else
+                    draw_end_time = Calendar.WallTime.latest;
+                
+                int start_x = ctr * rect_width;
+                int start_y = get_line_y(draw_start_time);
+                int end_y = get_line_y(draw_end_time);
+                int rect_height = end_y - start_y;
+                
+                Gdk.RGBA rgba = event.calendar_source.color_as_rgba();
+                
+                ctx.rectangle(start_x, start_y, rect_width, rect_height);
+                region_manager.add_points(event, start_x, start_y, rect_width, rect_height);
+                
+                // background rectangle (to prevent hour lines from showing when using alpha, below)
+                Gdk.cairo_set_source_rgba(ctx, Gfx.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(
+                    actual_start_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE),
+                    actual_end_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE));
+                Pango.Layout layout_0 = print_line(ctx, draw_start_time, 0, timespan, rgba, start_x, 
rect_width,
+                    true);
+                Pango.Layout layout_1 = print_line(ctx, draw_start_time, 1, event.summary, rgba, start_x,
+                    rect_width, false);
+                
+                // if either was ellipsized, set tooltip (otherwise clear any existing)
+                bool is_ellipsized = layout_0.is_ellipsized() || layout_1.is_ellipsized();
+                event.set_data<string?>(key_tooltip_date,
+                    is_ellipsized ? "%s\n%s".printf(timespan, GLib.Markup.escape_text(event.summary)) : 
null);
+                
+                ctr++;
+            }
         }
         
         // draw horizontal line indicating current time
@@ -354,8 +423,8 @@ internal class DayPane : Pane, Common.InstanceContainer {
         return true;
     }
     
-    private Pango.Layout print_line(Cairo.Context ctx, Calendar.WallTime start_time, int lineno, string text,
-        Gdk.RGBA rgba, int total_width, bool is_markup) {
+    private Pango.Layout print_line(Cairo.Context ctx, Calendar.WallTime start_time, int lineno,
+        string text, Gdk.RGBA rgba, int start_x, int total_width, bool is_markup) {
         Pango.Layout layout = create_pango_layout(null);
         if (is_markup)
             layout.set_markup(text, -1);
@@ -368,7 +437,7 @@ internal class DayPane : Pane, Common.InstanceContainer {
         int y = get_line_y(start_time) + Palette.LINE_PADDING_PX
             + (palette.small_font_height_px * lineno);
         
-        ctx.move_to(Palette.TEXT_MARGIN_PX, y);
+        ctx.move_to(Palette.TEXT_MARGIN_PX + start_x, y);
         Gdk.cairo_set_source_rgba(ctx, rgba);
         Pango.cairo_show_layout(ctx, layout);
         


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