[california/wip/730602-stack] First steps at making Week view more organized



commit e028d4b1e3942cc35ee2ed84cf06bfe1e166ab18
Author: Jim Nelson <jim yorba org>
Date:   Fri Sep 12 16:38:02 2014 -0700

    First steps at making Week view more organized

 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           |  231 ++++++++++++++++++----------
 4 files changed, 269 insertions(+), 79 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index ea6468a..0ac35d6 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -145,6 +145,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 40e563a..3d67185 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,6 +85,8 @@ 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;
     
     public DayPane(Grid owner, Calendar.Date date) {
@@ -106,6 +140,52 @@ 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;
+        
+        // ... 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))
+            return false;
+        
+        return true;
+    }
+    
+    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,14 +295,7 @@ 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;
         
@@ -234,20 +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)
-        Calendar.DateSpan date_span = event.get_event_date_span(Calendar.Timezone.local);
-        if (!date_span.is_same_day || !(date in date_span))
-            return false;
-        
-        return true;
-    }
-    
     // 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) {
@@ -262,53 +322,66 @@ 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) {
-            Calendar.WallTime start_time =
-                event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
-            Calendar.WallTime end_time =
-                event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
+        foreach (EventStack event_stack in event_stacks) {
+            int stack_count = event_stack.events.size;
             
-            int start_y = get_line_y(start_time);
-            int end_y = get_line_y(end_time);
-            
-            Gdk.RGBA rgba = event.calendar_source.color_as_rgba();
+            debug("stacked events: size=%d earliest=%s", stack_count, event_stack.earliest.to_string());
+            if (stack_count > 1) {
+                foreach (Component.Event event in event_stack.events)
+                    debug("%s %s", event.exact_time_span.start_exact_time.to_string(), event.summary);
+            }
             
             // 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(
-                start_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE),
-                end_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE));
-            Pango.Layout layout_0 = print_line(ctx, start_time, 0, timespan, rgba, rect_width, true);
-            Pango.Layout layout_1 = print_line(ctx, start_time, 1, event.summary, rgba, rect_width, false);
+            int rect_width = (get_allocated_width() - RIGHT_MARGIN_PX) / stack_count;
             
-            // 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) {
+                Calendar.WallTime start_time =
+                    
event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
+                Calendar.WallTime end_time =
+                    event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local).to_wall_time();
+                
+                int start_x = ctr * rect_width;
+                int start_y = get_line_y(start_time);
+                int end_y = get_line_y(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(
+                    start_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE),
+                    end_time.to_pretty_string(Calendar.WallTime.PrettyFlag.NONE));
+                Pango.Layout layout_0 = print_line(ctx, start_time, 0, timespan, rgba, start_x, rect_width,
+                    true);
+                Pango.Layout layout_1 = print_line(ctx, 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,
+                    is_ellipsized ? "%s\n%s".printf(timespan, GLib.Markup.escape_text(event.summary)) : 
null);
+                
+                ctr++;
+            }
         }
         
         // draw horizontal line indicating current time
@@ -343,8 +416,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);
@@ -357,7 +430,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]