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