summaryrefslogtreecommitdiff
path: root/src/Page.vala
diff options
context:
space:
mode:
Diffstat (limited to 'src/Page.vala')
-rw-r--r--src/Page.vala2589
1 files changed, 2589 insertions, 0 deletions
diff --git a/src/Page.vala b/src/Page.vala
new file mode 100644
index 0000000..fd69431
--- /dev/null
+++ b/src/Page.vala
@@ -0,0 +1,2589 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class InjectionGroup {
+ public class Element {
+ public string name;
+ public string action;
+ public Gtk.UIManagerItemType kind;
+
+ public Element(string name, string? action, Gtk.UIManagerItemType kind) {
+ this.name = name;
+ this.action = action != null ? action : name;
+ this.kind = kind;
+ }
+ }
+
+ private string path;
+ private Gee.ArrayList<Element?> elements = new Gee.ArrayList<Element?>();
+ private int separator_id = 0;
+
+ public InjectionGroup(string path) {
+ this.path = path;
+ }
+
+ public string get_path() {
+ return path;
+ }
+
+ public Gee.List<Element?> get_elements() {
+ return elements;
+ }
+
+ public void add_menu_item(string name, string? action = null) {
+ elements.add(new Element(name, action, Gtk.UIManagerItemType.MENUITEM));
+ }
+
+ public void add_menu(string name, string? action = null) {
+ elements.add(new Element(name, action, Gtk.UIManagerItemType.MENU));
+ }
+
+ public void add_separator() {
+ elements.add(new Element("%d-separator".printf(separator_id++), null, Gtk.UIManagerItemType.SEPARATOR));
+ }
+}
+
+public abstract class Page : Gtk.ScrolledWindow {
+ private const int CONSIDER_CONFIGURE_HALTED_MSEC = 400;
+
+ protected Gtk.UIManager ui;
+ protected Gtk.Toolbar toolbar;
+ protected bool in_view = false;
+
+ private string page_name;
+ private ViewCollection view = null;
+ private Gtk.Window container = null;
+ private string toolbar_path;
+ private Gdk.Rectangle last_position = Gdk.Rectangle();
+ private Gtk.Widget event_source = null;
+ private bool dnd_enabled = false;
+ private ulong last_configure_ms = 0;
+ private bool report_move_finished = false;
+ private bool report_resize_finished = false;
+ private Gdk.Point last_down = Gdk.Point();
+ private bool is_destroyed = false;
+ private bool ctrl_pressed = false;
+ private bool alt_pressed = false;
+ private bool shift_pressed = false;
+ private bool super_pressed = false;
+ private Gdk.CursorType last_cursor = Gdk.CursorType.LEFT_PTR;
+ private bool cursor_hidden = false;
+ private int cursor_hide_msec = 0;
+ private uint last_timeout_id = 0;
+ private int cursor_hide_time_cached = 0;
+ private bool are_actions_attached = false;
+ private OneShotScheduler? update_actions_scheduler = null;
+ private Gtk.ActionGroup? action_group = null;
+ private Gtk.ActionGroup[]? common_action_groups = null;
+
+ private uint[] merge_ids = new uint[0];
+
+ protected Page(string page_name) {
+ this.page_name = page_name;
+
+ view = new ViewCollection("ViewCollection for Page %s".printf(page_name));
+
+ last_down = { -1, -1 };
+
+ set_can_focus(true);
+
+ popup_menu.connect(on_context_keypress);
+
+ init_ui();
+
+ realize.connect(attach_view_signals);
+
+ Resources.style_widget(this, Resources.SCROLL_FRAME_STYLESHEET);
+ }
+
+ ~Page() {
+#if TRACE_DTORS
+ debug("DTOR: Page %s", page_name);
+#endif
+ }
+
+ // This is called by the page controller when it has removed this page ... pages should override
+ // this (or the signal) to clean up
+ public override void destroy() {
+ if (is_destroyed)
+ return;
+
+ // untie signals
+ detach_event_source();
+ detach_view_signals();
+ view.close();
+
+ // remove refs to external objects which may be pointing to the Page
+ clear_container();
+
+ if (toolbar != null)
+ toolbar.destroy();
+
+ // halt any pending callbacks
+ if (update_actions_scheduler != null)
+ update_actions_scheduler.cancel();
+
+ is_destroyed = true;
+
+ base.destroy();
+
+ debug("Page %s Destroyed", get_page_name());
+ }
+
+ public string get_page_name() {
+ return page_name;
+ }
+
+ public virtual void set_page_name(string page_name) {
+ this.page_name = page_name;
+ }
+
+ public string to_string() {
+ return page_name;
+ }
+
+ public ViewCollection get_view() {
+ return view;
+ }
+
+ public Gtk.Window? get_container() {
+ return container;
+ }
+
+ public virtual void set_container(Gtk.Window container) {
+ assert(this.container == null);
+
+ this.container = container;
+ ui = ((PageWindow) container).get_ui_manager();
+ }
+
+ public virtual void clear_container() {
+ container = null;
+ }
+
+ public void set_event_source(Gtk.Widget event_source) {
+ assert(this.event_source == null);
+
+ this.event_source = event_source;
+ event_source.set_can_focus(true);
+
+ // interested in mouse button and motion events on the event source
+ event_source.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.POINTER_MOTION_HINT_MASK
+ | Gdk.EventMask.BUTTON_MOTION_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
+ | Gdk.EventMask.SCROLL_MASK);
+ event_source.button_press_event.connect(on_button_pressed_internal);
+ event_source.button_release_event.connect(on_button_released_internal);
+ event_source.motion_notify_event.connect(on_motion_internal);
+ event_source.leave_notify_event.connect(on_leave_notify_event);
+ event_source.scroll_event.connect(on_mousewheel_internal);
+ event_source.realize.connect(on_event_source_realize);
+ }
+
+ private void detach_event_source() {
+ if (event_source == null)
+ return;
+
+ event_source.button_press_event.disconnect(on_button_pressed_internal);
+ event_source.button_release_event.disconnect(on_button_released_internal);
+ event_source.motion_notify_event.disconnect(on_motion_internal);
+ event_source.leave_notify_event.disconnect(on_leave_notify_event);
+ event_source.scroll_event.disconnect(on_mousewheel_internal);
+
+ disable_drag_source();
+
+ event_source = null;
+ }
+
+ public Gtk.Widget? get_event_source() {
+ return event_source;
+ }
+
+ public virtual Gtk.MenuBar get_menubar() {
+ Gtk.MenuBar? menubar = ui.get_widget("/MenuBar") as Gtk.MenuBar;
+ assert(menubar != null);
+
+ return menubar;
+ }
+
+ public virtual unowned Gtk.Widget get_page_ui_widget(string path) {
+ return ui.get_widget(path);
+ }
+
+ public virtual Gtk.Toolbar get_toolbar() {
+ if (toolbar == null) {
+ toolbar = toolbar_path == null ? new Gtk.Toolbar() :
+ ui.get_widget(toolbar_path) as Gtk.Toolbar;
+ toolbar.get_style_context().add_class("bottom-toolbar"); // for elementary theme
+ }
+ return toolbar;
+ }
+
+ public virtual Gtk.Menu? get_page_context_menu() {
+ return null;
+ }
+
+ public virtual void switching_from() {
+ in_view = false;
+ remove_ui();
+ if (toolbar_path != null)
+ toolbar = null;
+ }
+
+ public virtual void switched_to() {
+ in_view = true;
+ add_ui();
+ update_modifiers();
+ }
+
+ public virtual void ready() {
+ }
+
+ public bool is_in_view() {
+ return in_view;
+ }
+
+ public virtual void switching_to_fullscreen(FullscreenWindow fsw) {
+ }
+
+ public virtual void returning_from_fullscreen(FullscreenWindow fsw) {
+ }
+
+ public Gtk.Action? get_action(string name) {
+ if (action_group == null)
+ return null;
+
+ Gtk.Action? action = action_group.get_action(name);
+ if (action == null)
+ action = get_common_action(name, false);
+
+ if (action == null)
+ warning("Page %s: Unable to locate action %s", get_page_name(), name);
+
+ return action;
+ }
+
+ public void set_action_sensitive(string name, bool sensitive) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.sensitive = sensitive;
+ }
+
+ public void set_action_important(string name, bool important) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.is_important = important;
+ }
+
+ public void set_action_visible(string name, bool visible) {
+ Gtk.Action? action = get_action(name);
+ if (action == null)
+ return;
+
+ action.visible = visible;
+ action.sensitive = visible;
+ }
+
+ public void set_action_short_label(string name, string short_label) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.short_label = short_label;
+ }
+
+ public void set_action_details(string name, string? label, string? tooltip, bool sensitive) {
+ Gtk.Action? action = get_action(name);
+ if (action == null)
+ return;
+
+ if (label != null)
+ action.label = label;
+
+ if (tooltip != null)
+ action.tooltip = tooltip;
+
+ action.sensitive = sensitive;
+ }
+
+ public void activate_action(string name) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.activate();
+ }
+
+ public Gtk.Action? get_common_action(string name, bool log_warning = true) {
+ if (common_action_groups == null)
+ return null;
+
+ foreach (Gtk.ActionGroup group in common_action_groups) {
+ Gtk.Action? action = group.get_action(name);
+ if (action != null)
+ return action;
+ }
+
+ if (log_warning)
+ warning("Page %s: Unable to locate common action %s", get_page_name(), name);
+
+ return null;
+ }
+
+ public void set_common_action_sensitive(string name, bool sensitive) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.sensitive = sensitive;
+ }
+
+ public void set_common_action_label(string name, string label) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.set_label(label);
+ }
+
+ public void set_common_action_important(string name, bool important) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.is_important = important;
+ }
+
+ public void activate_common_action(string name) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.activate();
+ }
+
+ public bool get_ctrl_pressed() {
+ return ctrl_pressed;
+ }
+
+ public bool get_alt_pressed() {
+ return alt_pressed;
+ }
+
+ public bool get_shift_pressed() {
+ return shift_pressed;
+ }
+
+ public bool get_super_pressed() {
+ return super_pressed;
+ }
+
+ private bool get_modifiers(out bool ctrl, out bool alt, out bool shift, out bool super) {
+ if (AppWindow.get_instance().get_window() == null) {
+ ctrl = false;
+ alt = false;
+ shift = false;
+ super = false;
+
+ return false;
+ }
+
+ int x, y;
+ Gdk.ModifierType mask;
+ AppWindow.get_instance().get_window().get_device_position(Gdk.Display.get_default().
+ get_device_manager().get_client_pointer(), out x, out y, out mask);
+
+ ctrl = (mask & Gdk.ModifierType.CONTROL_MASK) != 0;
+ alt = (mask & Gdk.ModifierType.MOD1_MASK) != 0;
+ shift = (mask & Gdk.ModifierType.SHIFT_MASK) != 0;
+ super = (mask & Gdk.ModifierType.MOD4_MASK) != 0; // not SUPER_MASK
+
+ return true;
+ }
+
+ private void update_modifiers() {
+ bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
+ super_currently_pressed;
+ if (!get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
+ out shift_currently_pressed, out super_currently_pressed)) {
+ return;
+ }
+
+ if (ctrl_pressed && !ctrl_currently_pressed)
+ on_ctrl_released(null);
+ else if (!ctrl_pressed && ctrl_currently_pressed)
+ on_ctrl_pressed(null);
+
+ if (alt_pressed && !alt_currently_pressed)
+ on_alt_released(null);
+ else if (!alt_pressed && alt_currently_pressed)
+ on_alt_pressed(null);
+
+ if (shift_pressed && !shift_currently_pressed)
+ on_shift_released(null);
+ else if (!shift_pressed && shift_currently_pressed)
+ on_shift_pressed(null);
+
+ if(super_pressed && !super_currently_pressed)
+ on_super_released(null);
+ else if (!super_pressed && super_currently_pressed)
+ on_super_pressed(null);
+
+ ctrl_pressed = ctrl_currently_pressed;
+ alt_pressed = alt_currently_pressed;
+ shift_pressed = shift_currently_pressed;
+ super_pressed = super_currently_pressed;
+ }
+
+ public PageWindow? get_page_window() {
+ Gtk.Widget p = parent;
+ while (p != null) {
+ if (p is PageWindow)
+ return (PageWindow) p;
+
+ p = p.parent;
+ }
+
+ return null;
+ }
+
+ public CommandManager get_command_manager() {
+ return AppWindow.get_command_manager();
+ }
+
+ private void init_ui() {
+ action_group = new Gtk.ActionGroup("PageActionGroup");
+
+ // Collect all Gtk.Actions and add them to the Page's Gtk.ActionGroup
+ Gtk.ActionEntry[] action_entries = init_collect_action_entries();
+ if (action_entries.length > 0)
+ action_group.add_actions(action_entries, this);
+
+ // Collect all Gtk.ToggleActionEntries and add them to the Gtk.ActionGroup
+ Gtk.ToggleActionEntry[] toggle_entries = init_collect_toggle_action_entries();
+ if (toggle_entries.length > 0)
+ action_group.add_toggle_actions(toggle_entries, this);
+
+ // Collect all Gtk.RadioActionEntries and add them to the Gtk.ActionGroup
+ // (Would use a similar collection scheme as the other calls, but there is a binding
+ // problem with Gtk.RadioActionCallback that doesn't allow it to be stored in a struct)
+ register_radio_actions(action_group);
+
+ // Get global (common) action groups from the application window
+ common_action_groups = AppWindow.get_instance().get_common_action_groups();
+ }
+
+ private void add_ui() {
+ // Collect all UI filenames and load them into the UI manager
+ Gee.List<string> ui_filenames = new Gee.ArrayList<string>();
+ init_collect_ui_filenames(ui_filenames);
+ if (ui_filenames.size == 0)
+ message("No UI file specified for %s", get_page_name());
+
+ foreach (string ui_filename in ui_filenames)
+ init_load_ui(ui_filename);
+
+ ui.insert_action_group(action_group, 0);
+
+ // Collect injected UI elements and add them to the UI manager
+ InjectionGroup[] injection_groups = init_collect_injection_groups();
+ foreach (InjectionGroup group in injection_groups) {
+ foreach (InjectionGroup.Element element in group.get_elements()) {
+ uint merge_id = ui.new_merge_id();
+ ui.add_ui(merge_id, group.get_path(), element.name, element.action,
+ element.kind, false);
+ merge_ids += merge_id;
+ }
+ }
+
+ AppWindow.get_instance().replace_common_placeholders(ui);
+
+ ui.ensure_update();
+ }
+
+ private void remove_ui() {
+ for (int i = merge_ids.length - 1 ; i >= 0 ; --i)
+ ui.remove_ui(merge_ids[i]);
+ ui.remove_action_group(action_group);
+ merge_ids.resize(0);
+
+ ui.ensure_update();
+ }
+
+ public void init_toolbar(string path) {
+ toolbar_path = path;
+ }
+
+ // Called from "realize"
+ private void attach_view_signals() {
+ if (are_actions_attached)
+ return;
+
+ // initialize the Gtk.Actions according to current state
+ int selected_count = get_view().get_selected_count();
+ int count = get_view().get_count();
+ init_actions(selected_count, count);
+ update_actions(selected_count, count);
+
+ // monitor state changes to update actions
+ get_view().items_state_changed.connect(on_update_actions);
+ get_view().selection_group_altered.connect(on_update_actions);
+ get_view().items_visibility_changed.connect(on_update_actions);
+ get_view().contents_altered.connect(on_update_actions);
+
+ are_actions_attached = true;
+ }
+
+ // Called from destroy()
+ private void detach_view_signals() {
+ if (!are_actions_attached)
+ return;
+
+ get_view().items_state_changed.disconnect(on_update_actions);
+ get_view().selection_group_altered.disconnect(on_update_actions);
+ get_view().items_visibility_changed.disconnect(on_update_actions);
+ get_view().contents_altered.disconnect(on_update_actions);
+
+ are_actions_attached = false;
+ }
+
+ private void on_update_actions() {
+ if (update_actions_scheduler == null) {
+ update_actions_scheduler = new OneShotScheduler(
+ "Update actions scheduler for %s".printf(get_page_name()),
+ on_update_actions_on_idle);
+ }
+
+ update_actions_scheduler.at_priority_idle(Priority.LOW);
+ }
+
+ private void on_update_actions_on_idle() {
+ if (is_destroyed)
+ return;
+
+ update_actions(get_view().get_selected_count(), get_view().get_count());
+ }
+
+ private void init_load_ui(string ui_filename) {
+ File ui_file = Resources.get_ui(ui_filename);
+
+ try {
+ merge_ids += ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error err) {
+ AppWindow.error_message("Error loading UI file %s: %s".printf(
+ ui_file.get_path(), err.message));
+ Application.get_instance().panic();
+ }
+ }
+
+ // This is called during init_ui() to collect all the UI files to be loaded into the UI
+ // manager. Because order is important here, call the base method *first*, then add the
+ // classes' filename.
+ protected virtual void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ }
+
+ // This is called during init_ui() to collect all Gtk.ActionEntries for the page.
+ protected virtual Gtk.ActionEntry[] init_collect_action_entries() {
+ return new Gtk.ActionEntry[0];
+ }
+
+ // This is called during init_ui() to collect all Gtk.ToggleActionEntries for the page
+ protected virtual Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() {
+ return new Gtk.ToggleActionEntry[0];
+ }
+
+ // This is called during init_ui() to collect all Gtk.RadioActionEntries for the page
+ protected virtual void register_radio_actions(Gtk.ActionGroup action_group) {
+ }
+
+ // This is called during init_ui() to collect all Page.InjectedUIElements for the page. They
+ // should be added to the MultiSet using the injection path as the key.
+ protected virtual InjectionGroup[] init_collect_injection_groups() {
+ return new InjectionGroup[0];
+ }
+
+ // This is called during "map" allowing for Gtk.Actions to be updated at
+ // initialization time.
+ protected virtual void init_actions(int selected_count, int count) {
+ }
+
+ // This is called during "map" and during ViewCollection selection, visibility,
+ // and collection content altered events. This can be used to both initialize Gtk.Actions and
+ // update them when selection or visibility has been altered.
+ protected virtual void update_actions(int selected_count, int count) {
+ }
+
+ // This method enables drag-and-drop on the event source and routes its events through this
+ // object
+ public void enable_drag_source(Gdk.DragAction actions, Gtk.TargetEntry[] source_target_entries) {
+ if (dnd_enabled)
+ return;
+
+ assert(event_source != null);
+
+ Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, source_target_entries, actions);
+
+ // hook up handlers which route the event_source's DnD signals to the Page's (necessary
+ // because Page is a NO_WINDOW widget and cannot support DnD on its own).
+ event_source.drag_begin.connect(on_drag_begin);
+ event_source.drag_data_get.connect(on_drag_data_get);
+ event_source.drag_data_delete.connect(on_drag_data_delete);
+ event_source.drag_end.connect(on_drag_end);
+ event_source.drag_failed.connect(on_drag_failed);
+
+ dnd_enabled = true;
+ }
+
+ public void disable_drag_source() {
+ if (!dnd_enabled)
+ return;
+
+ assert(event_source != null);
+
+ event_source.drag_begin.disconnect(on_drag_begin);
+ event_source.drag_data_get.disconnect(on_drag_data_get);
+ event_source.drag_data_delete.disconnect(on_drag_data_delete);
+ event_source.drag_end.disconnect(on_drag_end);
+ event_source.drag_failed.disconnect(on_drag_failed);
+ Gtk.drag_source_unset(event_source);
+
+ dnd_enabled = false;
+ }
+
+ public bool is_dnd_enabled() {
+ return dnd_enabled;
+ }
+
+ private void on_drag_begin(Gdk.DragContext context) {
+ drag_begin(context);
+ }
+
+ private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
+ uint info, uint time) {
+ drag_data_get(context, selection_data, info, time);
+ }
+
+ private void on_drag_data_delete(Gdk.DragContext context) {
+ drag_data_delete(context);
+ }
+
+ private void on_drag_end(Gdk.DragContext context) {
+ drag_end(context);
+ }
+
+ // wierdly, Gtk 2.16.1 doesn't supply a drag_failed virtual method in the GtkWidget impl ...
+ // Vala binds to it, but it's not available in gtkwidget.h, and so gcc complains. Have to
+ // makeshift one for now.
+ // https://bugzilla.gnome.org/show_bug.cgi?id=584247
+ public virtual bool source_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
+ return false;
+ }
+
+ private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
+ return source_drag_failed(context, drag_result);
+ }
+
+ // Use this function rather than GDK or GTK's get_pointer, especially if called during a
+ // button-down mouse drag (i.e. a window grab).
+ //
+ // For more information, see: https://bugzilla.gnome.org/show_bug.cgi?id=599937
+ public bool get_event_source_pointer(out int x, out int y, out Gdk.ModifierType mask) {
+ if (event_source == null) {
+ x = 0;
+ y = 0;
+ mask = 0;
+
+ return false;
+ }
+
+ event_source.get_window().get_device_position(Gdk.Display.get_default().get_device_manager()
+ .get_client_pointer(), out x, out y, out mask);
+
+ if (last_down.x < 0 || last_down.y < 0)
+ return true;
+
+ // check for bogus values inside a drag which goes outside the window
+ // caused by (most likely) X windows signed 16-bit int overflow and fixup
+ // (https://bugzilla.gnome.org/show_bug.cgi?id=599937)
+
+ if ((x - last_down.x).abs() >= 0x7FFF)
+ x += 0xFFFF;
+
+ if ((y - last_down.y).abs() >= 0x7FFF)
+ y += 0xFFFF;
+
+ return true;
+ }
+
+ protected virtual bool on_left_click(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_middle_click(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_right_click(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_left_released(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_middle_released(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_right_released(Gdk.EventButton event) {
+ return false;
+ }
+
+ private bool on_button_pressed_internal(Gdk.EventButton event) {
+ switch (event.button) {
+ case 1:
+ if (event_source != null)
+ event_source.grab_focus();
+
+ // stash location of mouse down for drag fixups
+ last_down.x = (int) event.x;
+ last_down.y = (int) event.y;
+
+ return on_left_click(event);
+
+ case 2:
+ return on_middle_click(event);
+
+ case 3:
+ return on_right_click(event);
+
+ default:
+ return false;
+ }
+ }
+
+ private bool on_button_released_internal(Gdk.EventButton event) {
+ switch (event.button) {
+ case 1:
+ // clear when button released, only for drag fixups
+ last_down = { -1, -1 };
+
+ return on_left_released(event);
+
+ case 2:
+ return on_middle_released(event);
+
+ case 3:
+ return on_right_released(event);
+
+ default:
+ return false;
+ }
+ }
+
+ protected virtual bool on_ctrl_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_ctrl_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_alt_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_alt_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_shift_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_shift_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_super_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_super_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_app_key_pressed(Gdk.EventKey event) {
+ return false;
+ }
+
+ protected virtual bool on_app_key_released(Gdk.EventKey event) {
+ return false;
+ }
+
+ public bool notify_app_key_pressed(Gdk.EventKey event) {
+ bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
+ super_currently_pressed;
+ get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
+ out shift_currently_pressed, out super_currently_pressed);
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Control_L":
+ case "Control_R":
+ if (!ctrl_currently_pressed || ctrl_pressed)
+ return false;
+
+ ctrl_pressed = true;
+
+ return on_ctrl_pressed(event);
+
+ case "Meta_L":
+ case "Meta_R":
+ case "Alt_L":
+ case "Alt_R":
+ if (!alt_currently_pressed || alt_pressed)
+ return false;
+
+ alt_pressed = true;
+
+ return on_alt_pressed(event);
+
+ case "Shift_L":
+ case "Shift_R":
+ if (!shift_currently_pressed || shift_pressed)
+ return false;
+
+ shift_pressed = true;
+
+ return on_shift_pressed(event);
+
+ case "Super_L":
+ case "Super_R":
+ if (!super_currently_pressed || super_pressed)
+ return false;
+
+ super_pressed = true;
+
+ return on_super_pressed(event);
+ }
+
+ return on_app_key_pressed(event);
+ }
+
+ public bool notify_app_key_released(Gdk.EventKey event) {
+ bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
+ super_currently_pressed;
+ get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
+ out shift_currently_pressed, out super_currently_pressed);
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Control_L":
+ case "Control_R":
+ if (ctrl_currently_pressed || !ctrl_pressed)
+ return false;
+
+ ctrl_pressed = false;
+
+ return on_ctrl_released(event);
+
+ case "Meta_L":
+ case "Meta_R":
+ case "Alt_L":
+ case "Alt_R":
+ if (alt_currently_pressed || !alt_pressed)
+ return false;
+
+ alt_pressed = false;
+
+ return on_alt_released(event);
+
+ case "Shift_L":
+ case "Shift_R":
+ if (shift_currently_pressed || !shift_pressed)
+ return false;
+
+ shift_pressed = false;
+
+ return on_shift_released(event);
+
+ case "Super_L":
+ case "Super_R":
+ if (super_currently_pressed || !super_pressed)
+ return false;
+
+ super_pressed = false;
+
+ return on_super_released(event);
+ }
+
+ return on_app_key_released(event);
+ }
+
+ public bool notify_app_focus_in(Gdk.EventFocus event) {
+ update_modifiers();
+
+ return false;
+ }
+
+ public bool notify_app_focus_out(Gdk.EventFocus event) {
+ return false;
+ }
+
+ protected virtual void on_move(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_move_start(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_move_finished(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_resize(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_resize_start(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_resize_finished(Gdk.Rectangle rect) {
+ }
+
+ protected virtual bool on_configure(Gdk.EventConfigure event, Gdk.Rectangle rect) {
+ return false;
+ }
+
+ public bool notify_configure_event(Gdk.EventConfigure event) {
+ Gdk.Rectangle rect = Gdk.Rectangle();
+ rect.x = event.x;
+ rect.y = event.y;
+ rect.width = event.width;
+ rect.height = event.height;
+
+ // special case events, to report when a configure first starts (and appears to end)
+ if (last_configure_ms == 0) {
+ if (last_position.x != rect.x || last_position.y != rect.y) {
+ on_move_start(rect);
+ report_move_finished = true;
+ }
+
+ if (last_position.width != rect.width || last_position.height != rect.height) {
+ on_resize_start(rect);
+ report_resize_finished = true;
+ }
+
+ // need to check more often then the timeout, otherwise it could be up to twice the
+ // wait time before it's noticed
+ Timeout.add(CONSIDER_CONFIGURE_HALTED_MSEC / 8, check_configure_halted);
+ }
+
+ if (last_position.x != rect.x || last_position.y != rect.y)
+ on_move(rect);
+
+ if (last_position.width != rect.width || last_position.height != rect.height)
+ on_resize(rect);
+
+ last_position = rect;
+ last_configure_ms = now_ms();
+
+ return on_configure(event, rect);
+ }
+
+ private bool check_configure_halted() {
+ if (is_destroyed)
+ return false;
+
+ if ((now_ms() - last_configure_ms) < CONSIDER_CONFIGURE_HALTED_MSEC)
+ return true;
+
+ Gtk.Allocation allocation;
+ get_allocation(out allocation);
+
+ if (report_move_finished)
+ on_move_finished((Gdk.Rectangle) allocation);
+
+ if (report_resize_finished)
+ on_resize_finished((Gdk.Rectangle) allocation);
+
+ last_configure_ms = 0;
+ report_move_finished = false;
+ report_resize_finished = false;
+
+ return false;
+ }
+
+ protected virtual bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) {
+ check_cursor_hiding();
+
+ return false;
+ }
+
+ protected virtual bool on_leave_notify_event() {
+ return false;
+ }
+
+ private bool on_motion_internal(Gdk.EventMotion event) {
+ int x, y;
+ Gdk.ModifierType mask;
+ if (event.is_hint == 1) {
+ get_event_source_pointer(out x, out y, out mask);
+ } else {
+ x = (int) event.x;
+ y = (int) event.y;
+ mask = event.state;
+ }
+
+ return on_motion(event, x, y, mask);
+ }
+
+ private bool on_mousewheel_internal(Gdk.EventScroll event) {
+ switch (event.direction) {
+ case Gdk.ScrollDirection.UP:
+ return on_mousewheel_up(event);
+
+ case Gdk.ScrollDirection.DOWN:
+ return on_mousewheel_down(event);
+
+ case Gdk.ScrollDirection.LEFT:
+ return on_mousewheel_left(event);
+
+ case Gdk.ScrollDirection.RIGHT:
+ return on_mousewheel_right(event);
+
+ default:
+ return false;
+ }
+ }
+
+ protected virtual bool on_mousewheel_up(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_mousewheel_down(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_mousewheel_left(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_mousewheel_right(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_context_keypress() {
+ return false;
+ }
+
+ protected virtual bool on_context_buttonpress(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_context_invoked() {
+ return true;
+ }
+
+ protected bool popup_context_menu(Gtk.Menu? context_menu,
+ Gdk.EventButton? event = null) {
+
+ if (context_menu == null || !on_context_invoked())
+ return false;
+
+ if (event == null)
+ context_menu.popup(null, null, null, 0, Gtk.get_current_event_time());
+ else
+ context_menu.popup(null, null, null, event.button, event.time);
+
+ return true;
+ }
+
+ protected void on_event_source_realize() {
+ assert(event_source.get_window() != null); // the realize event means the Widget has a window
+
+ if (event_source.get_window().get_cursor() != null) {
+ last_cursor = event_source.get_window().get_cursor().get_cursor_type();
+ return;
+ }
+
+ // no custom cursor defined, check parents
+ Gdk.Window? parent_window = event_source.get_window();
+ do {
+ parent_window = parent_window.get_parent();
+ } while (parent_window != null && parent_window.get_cursor() == null);
+
+ if (parent_window != null)
+ last_cursor = parent_window.get_cursor().get_cursor_type();
+ }
+
+ public void set_cursor_hide_time(int hide_time) {
+ cursor_hide_msec = hide_time;
+ }
+
+ public void start_cursor_hiding() {
+ check_cursor_hiding();
+ }
+
+ public void stop_cursor_hiding() {
+ if (last_timeout_id != 0)
+ Source.remove(last_timeout_id);
+ }
+
+ public void suspend_cursor_hiding() {
+ cursor_hide_time_cached = cursor_hide_msec;
+
+ if (last_timeout_id != 0)
+ Source.remove(last_timeout_id);
+
+ cursor_hide_msec = 0;
+ }
+
+ public void restore_cursor_hiding() {
+ cursor_hide_msec = cursor_hide_time_cached;
+ check_cursor_hiding();
+ }
+
+ // Use this method to set the cursor for a page, NOT window.set_cursor(...)
+ protected virtual void set_page_cursor(Gdk.CursorType cursor_type) {
+ last_cursor = cursor_type;
+
+ if (!cursor_hidden && event_source != null)
+ event_source.get_window().set_cursor(new Gdk.Cursor(cursor_type));
+ }
+
+ private void check_cursor_hiding() {
+ if (cursor_hidden) {
+ cursor_hidden = false;
+ set_page_cursor(last_cursor);
+ }
+
+ if (cursor_hide_msec != 0) {
+ if (last_timeout_id != 0)
+ Source.remove(last_timeout_id);
+ last_timeout_id = Timeout.add(cursor_hide_msec, on_hide_cursor);
+ }
+ }
+
+ private bool on_hide_cursor() {
+ cursor_hidden = true;
+
+ if (event_source != null)
+ event_source.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.BLANK_CURSOR));
+
+ return false;
+ }
+}
+
+public abstract class CheckerboardPage : Page {
+ private const int AUTOSCROLL_PIXELS = 50;
+ private const int AUTOSCROLL_TICKS_MSEC = 50;
+
+ private CheckerboardLayout layout;
+ private string item_context_menu_path = null;
+ private string page_context_menu_path = null;
+ private Gtk.Viewport viewport = new Gtk.Viewport(null, null);
+ protected CheckerboardItem anchor = null;
+ protected CheckerboardItem cursor = null;
+ private CheckerboardItem highlighted = null;
+ private bool autoscroll_scheduled = false;
+ private CheckerboardItem activated_item = null;
+ private Gee.ArrayList<CheckerboardItem> previously_selected = null;
+
+ public enum Activator {
+ KEYBOARD,
+ MOUSE
+ }
+
+ public struct KeyboardModifiers {
+ public KeyboardModifiers(Page page) {
+ ctrl_pressed = page.get_ctrl_pressed();
+ alt_pressed = page.get_alt_pressed();
+ shift_pressed = page.get_shift_pressed();
+ super_pressed = page.get_super_pressed();
+ }
+
+ public bool ctrl_pressed;
+ public bool alt_pressed;
+ public bool shift_pressed;
+ public bool super_pressed;
+ }
+
+ public CheckerboardPage(string page_name) {
+ base (page_name);
+
+ layout = new CheckerboardLayout(get_view());
+ layout.set_name(page_name);
+
+ set_event_source(layout);
+
+ set_border_width(0);
+ set_shadow_type(Gtk.ShadowType.NONE);
+
+ viewport.set_border_width(0);
+ viewport.set_shadow_type(Gtk.ShadowType.NONE);
+
+ Resources.style_widget(viewport, Resources.VIEWPORT_STYLESHEET);
+
+ viewport.add(layout);
+
+ // want to set_adjustments before adding to ScrolledWindow to let our signal handlers
+ // run first ... otherwise, the thumbnails draw late
+ layout.set_adjustments(get_hadjustment(), get_vadjustment());
+
+ add(viewport);
+
+ // need to monitor items going hidden when dealing with anchor/cursor/highlighted items
+ get_view().items_hidden.connect(on_items_hidden);
+ get_view().contents_altered.connect(on_contents_altered);
+ get_view().items_state_changed.connect(on_items_state_changed);
+ get_view().items_visibility_changed.connect(on_items_visibility_changed);
+
+ // scrollbar policy
+ set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
+
+ Resources.style_widget(this, Resources.PAGE_STYLESHEET);
+ }
+
+ public void init_item_context_menu(string path) {
+ item_context_menu_path = path;
+ }
+
+ public void init_page_context_menu(string path) {
+ page_context_menu_path = path;
+ }
+
+ public Gtk.Menu? get_context_menu() {
+ // show page context menu if nothing is selected
+ return (get_view().get_selected_count() != 0) ? get_item_context_menu() :
+ get_page_context_menu();
+ }
+
+ public virtual Gtk.Menu? get_item_context_menu() {
+ Gtk.Menu menu = (Gtk.Menu) ui.get_widget(item_context_menu_path);
+ assert(menu != null);
+ return menu;
+ }
+
+ public override Gtk.Menu? get_page_context_menu() {
+ if (page_context_menu_path == null)
+ return null;
+ Gtk.Menu menu = (Gtk.Menu) ui.get_widget(page_context_menu_path);
+ assert(menu != null);
+ return menu;
+ }
+
+ protected override bool on_context_keypress() {
+ return popup_context_menu(get_context_menu());
+ }
+
+ protected virtual string get_view_empty_message() {
+ return _("No photos/videos");
+ }
+
+ protected virtual string get_filter_no_match_message() {
+ return _("No photos/videos found");
+ }
+
+ protected virtual void on_item_activated(CheckerboardItem item, Activator activator,
+ KeyboardModifiers modifiers) {
+ }
+
+ public CheckerboardLayout get_checkerboard_layout() {
+ return layout;
+ }
+
+ // Gets the search view filter for this page.
+ public abstract SearchViewFilter get_search_view_filter();
+
+ public virtual Core.ViewTracker? get_view_tracker() {
+ return null;
+ }
+
+ public override void switching_from() {
+ layout.set_in_view(false);
+ get_search_view_filter().refresh.disconnect(on_view_filter_refresh);
+
+ // unselect everything so selection won't persist after page loses focus
+ get_view().unselect_all();
+
+ base.switching_from();
+ }
+
+ public override void switched_to() {
+ layout.set_in_view(true);
+ get_search_view_filter().refresh.connect(on_view_filter_refresh);
+ on_view_filter_refresh();
+
+ if (get_view().get_selected_count() > 0) {
+ CheckerboardItem? item = (CheckerboardItem?) get_view().get_selected_at(0);
+
+ // if item is in any way out of view, scroll to it
+ Gtk.Adjustment vadj = get_vadjustment();
+ if (!(get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE
+ && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))) {
+
+ // scroll to see the new item
+ int top = 0;
+ if (item.allocation.y < vadj.get_value()) {
+ top = item.allocation.y;
+ top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2;
+ } else {
+ top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size();
+ top += CheckerboardLayout.ROW_GUTTER_PADDING / 2;
+ }
+
+ vadj.set_value(top);
+
+ }
+ }
+
+ base.switched_to();
+ }
+
+ private void on_view_filter_refresh() {
+ update_view_filter_message();
+ }
+
+ private void on_contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ update_view_filter_message();
+ }
+
+ private void on_items_state_changed(Gee.Iterable<DataView> changed) {
+ update_view_filter_message();
+ }
+
+ private void on_items_visibility_changed(Gee.Collection<DataView> changed) {
+ update_view_filter_message();
+ }
+
+ private void update_view_filter_message() {
+ if (get_view().are_items_filtered_out() && get_view().get_count() == 0) {
+ set_page_message(get_filter_no_match_message());
+ } else if (get_view().get_count() == 0) {
+ set_page_message(get_view_empty_message());
+ } else {
+ unset_page_message();
+ }
+ }
+
+ public void set_page_message(string message) {
+ layout.set_message(message);
+ if (is_in_view())
+ layout.queue_draw();
+ }
+
+ public void unset_page_message() {
+ layout.unset_message();
+ if (is_in_view())
+ layout.queue_draw();
+ }
+
+ public override void set_page_name(string name) {
+ base.set_page_name(name);
+
+ layout.set_name(name);
+ }
+
+ public CheckerboardItem? get_item_at_pixel(double x, double y) {
+ return layout.get_item_at_pixel(x, y);
+ }
+
+ private void on_items_hidden(Gee.Iterable<DataView> hidden) {
+ foreach (DataView view in hidden) {
+ CheckerboardItem item = (CheckerboardItem) view;
+
+ if (anchor == item)
+ anchor = null;
+
+ if (cursor == item)
+ cursor = null;
+
+ if (highlighted == item)
+ highlighted = null;
+ }
+ }
+
+ protected override bool key_press_event(Gdk.EventKey event) {
+ bool handled = true;
+
+ // mask out the modifiers we're interested in
+ uint state = event.state & Gdk.ModifierType.SHIFT_MASK;
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Up":
+ case "KP_Up":
+ move_cursor(CompassPoint.NORTH);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Down":
+ case "KP_Down":
+ move_cursor(CompassPoint.SOUTH);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Left":
+ case "KP_Left":
+ move_cursor(CompassPoint.WEST);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Right":
+ case "KP_Right":
+ move_cursor(CompassPoint.EAST);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Home":
+ case "KP_Home":
+ CheckerboardItem? first = (CheckerboardItem?) get_view().get_first();
+ if (first != null)
+ cursor_to_item(first);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "End":
+ case "KP_End":
+ CheckerboardItem? last = (CheckerboardItem?) get_view().get_last();
+ if (last != null)
+ cursor_to_item(last);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Return":
+ case "KP_Enter":
+ if (get_view().get_selected_count() == 1)
+ on_item_activated((CheckerboardItem) get_view().get_selected_at(0),
+ Activator.KEYBOARD, KeyboardModifiers(this));
+ else
+ handled = false;
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ if (handled)
+ return true;
+
+ return (base.key_press_event != null) ? base.key_press_event(event) : true;
+ }
+
+ protected override bool on_left_click(Gdk.EventButton event) {
+ // only interested in single-click and double-clicks for now
+ if ((event.type != Gdk.EventType.BUTTON_PRESS) && (event.type != Gdk.EventType.2BUTTON_PRESS))
+ return false;
+
+ // mask out the modifiers we're interested in
+ uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK);
+
+ // use clicks for multiple selection and activation only; single selects are handled by
+ // button release, to allow for multiple items to be selected then dragged
+ CheckerboardItem item = get_item_at_pixel(event.x, event.y);
+ if (item != null) {
+ switch (state) {
+ case Gdk.ModifierType.CONTROL_MASK:
+ // with only Ctrl pressed, multiple selections are possible ... chosen item
+ // is toggled
+ Marker marker = get_view().mark(item);
+ get_view().toggle_marked(marker);
+
+ if (item.is_selected()) {
+ anchor = item;
+ cursor = item;
+ }
+ break;
+
+ case Gdk.ModifierType.SHIFT_MASK:
+ get_view().unselect_all();
+
+ if (anchor == null)
+ anchor = item;
+
+ select_between_items(anchor, item);
+
+ cursor = item;
+ break;
+
+ case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK:
+ // Ticket #853 - Make Ctrl + Shift + Mouse Button 1 able to start a new run
+ // of contiguous selected items without unselecting previously-selected items
+ // a la Nautilus.
+ // Same as the case for SHIFT_MASK, but don't unselect anything first.
+ if (anchor == null)
+ anchor = item;
+
+ select_between_items(anchor, item);
+
+ cursor = item;
+ break;
+
+ default:
+ if (event.type == Gdk.EventType.2BUTTON_PRESS) {
+ activated_item = item;
+ } else {
+ // if the user has selected one or more items and is preparing for a drag,
+ // don't want to blindly unselect: if they've clicked on an unselected item
+ // unselect all and select that one; if they've clicked on a previously
+ // selected item, do nothing
+ if (!item.is_selected()) {
+ Marker all = get_view().start_marking();
+ all.mark_many(get_view().get_selected());
+
+ get_view().unselect_and_select_marked(all, get_view().mark(item));
+ }
+ }
+
+ anchor = item;
+ cursor = item;
+ break;
+ }
+ } else {
+ // user clicked on "dead" area; only unselect if control is not pressed
+ // do we want similar behavior for shift as well?
+ if (state != Gdk.ModifierType.CONTROL_MASK)
+ get_view().unselect_all();
+
+ // grab previously marked items
+ previously_selected = new Gee.ArrayList<CheckerboardItem>();
+ foreach (DataView view in get_view().get_selected())
+ previously_selected.add((CheckerboardItem) view);
+
+ layout.set_drag_select_origin((int) event.x, (int) event.y);
+
+ return true;
+ }
+
+ // need to determine if the signal should be passed to the DnD handlers
+ // Return true to block the DnD handler, false otherwise
+
+ return get_view().get_selected_count() == 0;
+ }
+
+ protected override bool on_left_released(Gdk.EventButton event) {
+ previously_selected = null;
+
+ // if drag-selecting, stop here and do nothing else
+ if (layout.is_drag_select_active()) {
+ layout.clear_drag_select();
+ anchor = cursor;
+
+ return true;
+ }
+
+ // only interested in non-modified button releases
+ if ((event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) != 0)
+ return false;
+
+ // if the item was activated in the double-click, report it now
+ if (activated_item != null) {
+ on_item_activated(activated_item, Activator.MOUSE, KeyboardModifiers(this));
+ activated_item = null;
+
+ return true;
+ }
+
+ CheckerboardItem item = get_item_at_pixel(event.x, event.y);
+ if (item == null) {
+ // released button on "dead" area
+ return true;
+ }
+
+ if (cursor != item) {
+ // user released mouse button after moving it off the initial item, or moved from dead
+ // space onto one. either way, unselect everything
+ get_view().unselect_all();
+ } else {
+ // the idea is, if a user single-clicks on an item with no modifiers, then all other items
+ // should be deselected, however, if they single-click in order to drag one or more items,
+ // they should remain selected, hence performing this here rather than on_left_click
+ // (item may not be selected if an unimplemented modifier key was used)
+ if (item.is_selected())
+ get_view().unselect_all_but(item);
+ }
+
+ return true;
+ }
+
+ protected override bool on_right_click(Gdk.EventButton event) {
+ // only interested in single-clicks for now
+ if (event.type != Gdk.EventType.BUTTON_PRESS)
+ return false;
+
+ // get what's right-clicked upon
+ CheckerboardItem item = get_item_at_pixel(event.x, event.y);
+ if (item != null) {
+ // mask out the modifiers we're interested in
+ switch (event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) {
+ case Gdk.ModifierType.CONTROL_MASK:
+ // chosen item is toggled
+ Marker marker = get_view().mark(item);
+ get_view().toggle_marked(marker);
+ break;
+
+ case Gdk.ModifierType.SHIFT_MASK:
+ // TODO
+ break;
+
+ case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK:
+ // TODO
+ break;
+
+ default:
+ // if the item is already selected, proceed; if item is not selected, a bare right
+ // click unselects everything else but it
+ if (!item.is_selected()) {
+ Marker all = get_view().start_marking();
+ all.mark_many(get_view().get_selected());
+
+ get_view().unselect_and_select_marked(all, get_view().mark(item));
+ }
+ break;
+ }
+ } else {
+ // clicked in "dead" space, unselect everything
+ get_view().unselect_all();
+ }
+
+ Gtk.Menu context_menu = get_context_menu();
+ return popup_context_menu(context_menu, event);
+ }
+
+ protected virtual bool on_mouse_over(CheckerboardItem? item, int x, int y, Gdk.ModifierType mask) {
+ // if hovering over the last hovered item, or both are null (nothing highlighted and
+ // hovering over empty space), do nothing
+ if (item == highlighted)
+ return true;
+
+ // either something new is highlighted or now hovering over empty space, so dim old item
+ if (highlighted != null) {
+ highlighted.unbrighten();
+ highlighted = null;
+ }
+
+ // if over empty space, done
+ if (item == null)
+ return true;
+
+ // brighten the new item
+ item.brighten();
+ highlighted = item;
+
+ return true;
+ }
+
+ protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) {
+ // report what item the mouse is hovering over
+ if (!on_mouse_over(get_item_at_pixel(x, y), x, y, mask))
+ return false;
+
+ // go no further if not drag-selecting
+ if (!layout.is_drag_select_active())
+ return false;
+
+ // set the new endpoint of the drag selection
+ layout.set_drag_select_endpoint(x, y);
+
+ updated_selection_band();
+
+ // if out of bounds, schedule a check to auto-scroll the viewport
+ if (!autoscroll_scheduled
+ && get_adjustment_relation(get_vadjustment(), y) != AdjustmentRelation.IN_RANGE) {
+ Timeout.add(AUTOSCROLL_TICKS_MSEC, selection_autoscroll);
+ autoscroll_scheduled = true;
+ }
+
+ // return true to stop a potential drag-and-drop operation
+ return true;
+ }
+
+ private void updated_selection_band() {
+ assert(layout.is_drag_select_active());
+
+ // get all items inside the selection
+ Gee.List<CheckerboardItem>? intersection = layout.items_in_selection_band();
+ if (intersection == null)
+ return;
+
+ Marker to_unselect = get_view().start_marking();
+ Marker to_select = get_view().start_marking();
+
+ // mark all selected items to be unselected
+ to_unselect.mark_many(get_view().get_selected());
+
+ // except for the items that were selected before the drag began
+ assert(previously_selected != null);
+ to_unselect.unmark_many(previously_selected);
+ to_select.mark_many(previously_selected);
+
+ // toggle selection on everything in the intersection and update the cursor
+ cursor = null;
+
+ foreach (CheckerboardItem item in intersection) {
+ if (to_select.toggle(item))
+ to_unselect.unmark(item);
+ else
+ to_unselect.mark(item);
+
+ if (cursor == null)
+ cursor = item;
+ }
+
+ get_view().select_marked(to_select);
+ get_view().unselect_marked(to_unselect);
+ }
+
+ private bool selection_autoscroll() {
+ if (!layout.is_drag_select_active()) {
+ autoscroll_scheduled = false;
+
+ return false;
+ }
+
+ // as the viewport never scrolls horizontally, only interested in vertical
+ Gtk.Adjustment vadj = get_vadjustment();
+
+ int x, y;
+ Gdk.ModifierType mask;
+ get_event_source_pointer(out x, out y, out mask);
+
+ int new_value = (int) vadj.get_value();
+ switch (get_adjustment_relation(vadj, y)) {
+ case AdjustmentRelation.BELOW:
+ // pointer above window, scroll up
+ new_value -= AUTOSCROLL_PIXELS;
+ layout.set_drag_select_endpoint(x, new_value);
+ break;
+
+ case AdjustmentRelation.ABOVE:
+ // pointer below window, scroll down, extend selection to bottom of page
+ new_value += AUTOSCROLL_PIXELS;
+ layout.set_drag_select_endpoint(x, new_value + (int) vadj.get_page_size());
+ break;
+
+ case AdjustmentRelation.IN_RANGE:
+ autoscroll_scheduled = false;
+
+ return false;
+
+ default:
+ warn_if_reached();
+ break;
+ }
+
+ // It appears that in GTK+ 2.18, the adjustment is not clamped the way it was in 2.16.
+ // This may have to do with how adjustments are different w/ scrollbars, that they're upper
+ // clamp is upper - page_size ... either way, enforce these limits here
+ vadj.set_value(new_value.clamp((int) vadj.get_lower(),
+ (int) vadj.get_upper() - (int) vadj.get_page_size()));
+
+ updated_selection_band();
+
+ return true;
+ }
+
+ public void cursor_to_item(CheckerboardItem item) {
+ assert(get_view().contains(item));
+
+ cursor = item;
+
+ get_view().unselect_all();
+
+ Marker marker = get_view().mark(item);
+ get_view().select_marked(marker);
+
+ // if item is in any way out of view, scroll to it
+ Gtk.Adjustment vadj = get_vadjustment();
+ if (get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE
+ && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))
+ return;
+
+ // scroll to see the new item
+ int top = 0;
+ if (item.allocation.y < vadj.get_value()) {
+ top = item.allocation.y;
+ top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2;
+ } else {
+ top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size();
+ top += CheckerboardLayout.ROW_GUTTER_PADDING / 2;
+ }
+
+ vadj.set_value(top);
+ }
+
+ public void move_cursor(CompassPoint point) {
+ // if no items, nothing to do
+ if (get_view().get_count() == 0)
+ return;
+
+ // if nothing is selected, simply select the first and exit
+ if (get_view().get_selected_count() == 0 || cursor == null) {
+ CheckerboardItem item = layout.get_item_at_coordinate(0, 0);
+ cursor_to_item(item);
+ anchor = item;
+
+ return;
+ }
+
+ // move the cursor relative to the "first" item
+ CheckerboardItem? item = layout.get_item_relative_to(cursor, point);
+ if (item != null)
+ cursor_to_item(item);
+ }
+
+ public void set_cursor(CheckerboardItem item) {
+ Marker marker = get_view().mark(item);
+ get_view().select_marked(marker);
+
+ cursor = item;
+ anchor = item;
+ }
+
+ public void select_between_items(CheckerboardItem item_start, CheckerboardItem item_end) {
+ Marker marker = get_view().start_marking();
+
+ bool passed_start = false;
+ bool passed_end = false;
+
+ foreach (DataObject object in get_view().get_all()) {
+ CheckerboardItem item = (CheckerboardItem) object;
+
+ if (item_start == item)
+ passed_start = true;
+
+ if (item_end == item)
+ passed_end = true;
+
+ if (passed_start || passed_end)
+ marker.mark((DataView) object);
+
+ if (passed_start && passed_end)
+ break;
+ }
+
+ get_view().select_marked(marker);
+ }
+
+ public void select_anchor_to_cursor(uint state) {
+ if (cursor == null || anchor == null)
+ return;
+
+ if (state == Gdk.ModifierType.SHIFT_MASK) {
+ get_view().unselect_all();
+ select_between_items(anchor, cursor);
+ } else {
+ anchor = cursor;
+ }
+ }
+
+ protected virtual void set_display_titles(bool display) {
+ get_view().freeze_notifications();
+ get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES, display);
+ get_view().thaw_notifications();
+ }
+
+ protected virtual void set_display_comments(bool display) {
+ get_view().freeze_notifications();
+ get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS, display);
+ get_view().thaw_notifications();
+ }
+}
+
+public abstract class SinglePhotoPage : Page {
+ public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST;
+ public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR;
+ public const int KEY_REPEAT_INTERVAL_MSEC = 200;
+
+ public enum UpdateReason {
+ NEW_PIXBUF,
+ QUALITY_IMPROVEMENT,
+ RESIZED_CANVAS
+ }
+
+ protected Gtk.DrawingArea canvas = new Gtk.DrawingArea();
+ protected Gtk.Viewport viewport = new Gtk.Viewport(null, null);
+
+ private bool scale_up_to_viewport;
+ private TransitionClock transition_clock;
+ private int transition_duration_msec = 0;
+ private Cairo.Surface pixmap = null;
+ private Cairo.Context pixmap_ctx = null;
+ private Cairo.Context text_ctx = null;
+ private Dimensions pixmap_dim = Dimensions();
+ private Gdk.Pixbuf unscaled = null;
+ private Dimensions max_dim = Dimensions();
+ private Gdk.Pixbuf scaled = null;
+ private Gdk.Pixbuf old_scaled = null; // previous scaled image
+ private Gdk.Rectangle scaled_pos = Gdk.Rectangle();
+ private ZoomState static_zoom_state;
+ private bool zoom_high_quality = true;
+ private ZoomState saved_zoom_state;
+ private bool has_saved_zoom_state = false;
+ private uint32 last_nav_key = 0;
+
+ public SinglePhotoPage(string page_name, bool scale_up_to_viewport) {
+ base(page_name);
+
+ this.scale_up_to_viewport = scale_up_to_viewport;
+
+ transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
+
+ // With the current code automatically resizing the image to the viewport, scrollbars
+ // should never be shown, but this may change if/when zooming is supported
+ set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
+
+ set_border_width(0);
+ set_shadow_type(Gtk.ShadowType.NONE);
+
+ viewport.set_shadow_type(Gtk.ShadowType.NONE);
+ viewport.set_border_width(0);
+ viewport.add(canvas);
+
+ add(viewport);
+
+ // We used to disable GTK double buffering here. We've had to reenable it
+ // due to this bug: http://redmine.yorba.org/issues/4775 .
+ //
+ // all painting happens in pixmap, and is sent to the window wholesale in on_canvas_expose
+ // canvas.set_double_buffered(false);
+
+ canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK
+ | Gdk.EventMask.SUBSTRUCTURE_MASK);
+
+ viewport.size_allocate.connect(on_viewport_resize);
+ canvas.draw.connect(on_canvas_exposed);
+
+ set_event_source(canvas);
+
+ // style the viewport
+ Resources.style_widget(viewport, Resources.VIEWPORT_STYLESHEET);
+ }
+
+ public bool is_transition_in_progress() {
+ return transition_clock.is_in_progress();
+ }
+
+ public void cancel_transition() {
+ if (transition_clock.is_in_progress())
+ transition_clock.cancel();
+ }
+
+ public void set_transition(string effect_id, int duration_msec) {
+ cancel_transition();
+
+ transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id);
+ if (transition_clock == null)
+ transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
+
+ transition_duration_msec = duration_msec;
+ }
+
+ // This method includes a call to pixmap_ctx.paint().
+ private void render_zoomed_to_pixmap(ZoomState zoom_state) {
+ assert(is_zoom_supported());
+
+ Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
+
+ Gdk.Pixbuf zoomed;
+ if (get_zoom_buffer() != null) {
+ zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) :
+ get_zoom_buffer().get_zoom_preview_image(zoom_state);
+ } else {
+ Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled);
+
+ Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x,
+ view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);
+
+ zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
+ Gdk.InterpType.BILINEAR);
+ }
+
+ if (zoomed == null) {
+ return;
+ }
+
+ int draw_x = (pixmap_dim.width - view_rect.width) / 2;
+ draw_x = draw_x.clamp(0, int.MAX);
+
+ int draw_y = (pixmap_dim.height - view_rect.height) / 2;
+ draw_y = draw_y.clamp(0, int.MAX);
+
+ Gdk.cairo_set_source_pixbuf(pixmap_ctx, zoomed, draw_x, draw_y);
+ pixmap_ctx.paint();
+ }
+
+ protected void on_interactive_zoom(ZoomState interactive_zoom_state) {
+ assert(is_zoom_supported());
+ Cairo.Context canvas_ctx = Gdk.cairo_create(canvas.get_window());
+
+ set_source_color_from_string(pixmap_ctx, "#000");
+ pixmap_ctx.paint();
+
+ bool old_quality_setting = zoom_high_quality;
+ zoom_high_quality = false;
+ render_zoomed_to_pixmap(interactive_zoom_state);
+ zoom_high_quality = old_quality_setting;
+
+ canvas_ctx.set_source_surface(pixmap, 0, 0);
+ canvas_ctx.paint();
+ }
+
+ protected void on_interactive_pan(ZoomState interactive_zoom_state) {
+ assert(is_zoom_supported());
+ Cairo.Context canvas_ctx = Gdk.cairo_create(canvas.get_window());
+
+ set_source_color_from_string(pixmap_ctx, "#000");
+ pixmap_ctx.paint();
+
+ bool old_quality_setting = zoom_high_quality;
+ zoom_high_quality = true;
+ render_zoomed_to_pixmap(interactive_zoom_state);
+ zoom_high_quality = old_quality_setting;
+
+ canvas_ctx.set_source_surface(pixmap, 0, 0);
+ canvas_ctx.paint();
+ }
+
+ protected virtual bool is_zoom_supported() {
+ return false;
+ }
+
+ protected virtual void cancel_zoom() {
+ if (pixmap != null) {
+ set_source_color_from_string(pixmap_ctx, "#000");
+ pixmap_ctx.paint();
+ }
+ }
+
+ protected virtual void save_zoom_state() {
+ saved_zoom_state = static_zoom_state;
+ has_saved_zoom_state = true;
+ }
+
+ protected virtual void restore_zoom_state() {
+ if (!has_saved_zoom_state)
+ return;
+
+ static_zoom_state = saved_zoom_state;
+ repaint();
+ has_saved_zoom_state = false;
+ }
+
+ protected virtual ZoomBuffer? get_zoom_buffer() {
+ return null;
+ }
+
+ protected ZoomState get_saved_zoom_state() {
+ return saved_zoom_state;
+ }
+
+ protected void set_zoom_state(ZoomState zoom_state) {
+ assert(is_zoom_supported());
+
+ static_zoom_state = zoom_state;
+ }
+
+ protected ZoomState get_zoom_state() {
+ assert(is_zoom_supported());
+
+ return static_zoom_state;
+ }
+
+ public override void switched_to() {
+ base.switched_to();
+
+ if (unscaled != null)
+ repaint();
+ }
+
+ public override void set_container(Gtk.Window container) {
+ base.set_container(container);
+
+ // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift
+ // off the screen
+ if (container is FullscreenWindow)
+ set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
+ }
+
+ // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and
+ // the caller capable of producing larger ones depending on the viewport size). max_dim
+ // is used when scale_up_to_viewport is set to true. Pass a Dimensions with no area if
+ // max_dim should be ignored (i.e. scale_up_to_viewport is false).
+ public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) {
+ static_zoom_state = ZoomState(max_dim, pixmap_dim,
+ static_zoom_state.get_interpolation_factor(),
+ static_zoom_state.get_viewport_center());
+
+ cancel_transition();
+
+ this.unscaled = unscaled;
+ this.max_dim = max_dim;
+ this.old_scaled = scaled;
+ scaled = null;
+
+ // need to make sure this has happened
+ canvas.realize();
+
+ repaint(direction);
+ }
+
+ public void blank_display() {
+ unscaled = null;
+ max_dim = Dimensions();
+ scaled = null;
+ pixmap = null;
+
+ // this has to have happened
+ canvas.realize();
+
+ // force a redraw
+ invalidate_all();
+ }
+
+ public Cairo.Surface? get_surface() {
+ return pixmap;
+ }
+
+ public Dimensions get_surface_dim() {
+ return pixmap_dim;
+ }
+
+ public Cairo.Context get_cairo_context() {
+ return pixmap_ctx;
+ }
+
+ public void paint_text(Pango.Layout pango_layout, int x, int y) {
+ text_ctx.move_to(x, y);
+ Pango.cairo_show_layout(text_ctx, pango_layout);
+ }
+
+ public Scaling get_canvas_scaling() {
+ return (get_container() is FullscreenWindow) ? Scaling.for_screen(get_container(), scale_up_to_viewport)
+ : Scaling.for_widget(viewport, scale_up_to_viewport);
+ }
+
+ public Gdk.Pixbuf? get_unscaled_pixbuf() {
+ return unscaled;
+ }
+
+ public Gdk.Pixbuf? get_scaled_pixbuf() {
+ return scaled;
+ }
+
+ // Returns a rectangle describing the pixbuf in relation to the canvas
+ public Gdk.Rectangle get_scaled_pixbuf_position() {
+ return scaled_pos;
+ }
+
+ public bool is_inside_pixbuf(int x, int y) {
+ return coord_in_rectangle(x, y, scaled_pos);
+ }
+
+ public void invalidate(Gdk.Rectangle rect) {
+ if (canvas.get_window() != null)
+ canvas.get_window().invalidate_rect(rect, false);
+ }
+
+ public void invalidate_all() {
+ if (canvas.get_window() != null)
+ canvas.get_window().invalidate_rect(null, false);
+ }
+
+ private void on_viewport_resize() {
+ // do fast repaints while resizing
+ internal_repaint(true, null);
+ }
+
+ protected override void on_resize_finished(Gdk.Rectangle rect) {
+ base.on_resize_finished(rect);
+
+ // when the resize is completed, do a high-quality repaint
+ repaint();
+ }
+
+ private bool on_canvas_exposed(Cairo.Context exposed_ctx) {
+ // draw pixmap onto canvas unless it's not been instantiated, in which case draw black
+ // (so either old image or contents of another page is not left on screen)
+ if (pixmap != null)
+ exposed_ctx.set_source_surface(pixmap, 0, 0);
+ else
+ set_source_color_from_string(exposed_ctx, "#000");
+
+ exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height());
+ exposed_ctx.paint();
+
+ return true;
+ }
+
+ protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) {
+ }
+
+ protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) {
+ }
+
+ protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) {
+ if (is_zoom_supported() && (!static_zoom_state.is_default())) {
+ set_source_color_from_string(ctx, "#000");
+ ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
+ ctx.fill();
+
+ render_zoomed_to_pixmap(static_zoom_state);
+ } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) {
+ // transition is not running, so paint the full image on a black background
+ set_source_color_from_string(ctx, "#000");
+
+ ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
+ ctx.fill();
+
+ Gdk.cairo_set_source_pixbuf(ctx, scaled, scaled_pos.x, scaled_pos.y);
+ ctx.paint();
+ }
+ }
+
+ private void repaint_pixmap() {
+ if (pixmap_ctx == null)
+ return;
+
+ paint(pixmap_ctx, pixmap_dim);
+ invalidate_all();
+ }
+
+ public void repaint(Direction? direction = null) {
+ internal_repaint(false, direction);
+ }
+
+ private void internal_repaint(bool fast, Direction? direction) {
+ // if not in view, assume a full repaint needed in future but do nothing more
+ if (!is_in_view()) {
+ pixmap = null;
+ scaled = null;
+
+ return;
+ }
+
+ // no image or window, no painting
+ if (unscaled == null || canvas.get_window() == null)
+ return;
+
+ Gtk.Allocation allocation;
+ viewport.get_allocation(out allocation);
+
+ int width = allocation.width;
+ int height = allocation.height;
+
+ if (width <= 0 || height <= 0)
+ return;
+
+ bool new_pixbuf = (scaled == null);
+
+ // save if reporting an image being rescaled
+ Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos);
+ Gdk.Rectangle old_scaled_pos = scaled_pos;
+
+ // attempt to reuse pixmap
+ if (pixmap_dim.width != width || pixmap_dim.height != height)
+ pixmap = null;
+
+ // if necessary, create a pixmap as large as the entire viewport
+ bool new_pixmap = false;
+ if (pixmap == null) {
+ init_pixmap(width, height);
+ new_pixmap = true;
+ }
+
+ if (new_pixbuf || new_pixmap) {
+ Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled);
+
+ // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up,
+ // respect it
+ Dimensions scaled_dim = Dimensions();
+ if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < height)
+ scaled_dim = max_dim;
+ else
+ scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim);
+
+ assert(width >= scaled_dim.width);
+ assert(height >= scaled_dim.height);
+
+ // center pixbuf on the canvas
+ scaled_pos.x = (width - scaled_dim.width) / 2;
+ scaled_pos.y = (height - scaled_dim.height) / 2;
+ scaled_pos.width = scaled_dim.width;
+ scaled_pos.height = scaled_dim.height;
+ }
+
+ Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP;
+
+ // rescale if canvas rescaled or better quality is requested
+ if (scaled == null) {
+ scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp);
+
+ UpdateReason reason = UpdateReason.RESIZED_CANVAS;
+ if (new_pixbuf)
+ reason = UpdateReason.NEW_PIXBUF;
+ else if (!new_pixmap && interp == QUALITY_INTERP)
+ reason = UpdateReason.QUALITY_IMPROVEMENT;
+
+ static_zoom_state = ZoomState(max_dim, pixmap_dim,
+ static_zoom_state.get_interpolation_factor(),
+ static_zoom_state.get_viewport_center());
+
+ updated_pixbuf(scaled, reason, old_scaled_dim);
+ }
+
+ zoom_high_quality = !fast;
+
+ if (direction != null && !transition_clock.is_in_progress()) {
+ Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled,
+ old_scaled_pos, scaled, scaled_pos, parse_color("#000"));
+
+ transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec,
+ repaint_pixmap);
+ }
+
+ if (!transition_clock.is_in_progress())
+ repaint_pixmap();
+ }
+
+ private void init_pixmap(int width, int height) {
+ assert(unscaled != null);
+ assert(canvas.get_window() != null);
+
+ // Cairo backing surface (manual double-buffering)
+ pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height);
+ pixmap_dim = Dimensions(width, height);
+
+ // Cairo context for drawing on the pixmap
+ pixmap_ctx = new Cairo.Context(pixmap);
+
+ // need a new pixbuf to fit this scale
+ scaled = null;
+
+ // Cairo context for drawing text on the pixmap
+ text_ctx = new Cairo.Context(pixmap);
+ set_source_color_from_string(text_ctx, "#fff");
+
+
+ // no need to resize canvas, viewport does that automatically
+
+ new_surface(pixmap_ctx, pixmap_dim);
+ }
+
+ protected override bool on_context_keypress() {
+ return popup_context_menu(get_page_context_menu());
+ }
+
+ protected virtual void on_previous_photo() {
+ }
+
+ protected virtual void on_next_photo() {
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ // if the user holds the arrow keys down, we will receive a steady stream of key press
+ // events for an operation that isn't designed for a rapid succession of output ...
+ // we staunch the supply of new photos to under a quarter second (#533)
+ bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC;
+
+ bool handled = true;
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Left":
+ case "KP_Left":
+ case "BackSpace":
+ if (nav_ok) {
+ on_previous_photo();
+ last_nav_key = event.time;
+ }
+ break;
+
+ case "Right":
+ case "KP_Right":
+ case "space":
+ if (nav_ok) {
+ on_next_photo();
+ last_nav_key = event.time;
+ }
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ if (handled)
+ return true;
+
+ return (base.key_press_event != null) ? base.key_press_event(event) : true;
+ }
+}
+
+//
+// DragAndDropHandler attaches signals to a Page to properly handle drag-and-drop requests for the
+// Page as a DnD Source. (DnD Destination handling is handled by the appropriate AppWindow, i.e.
+// LibraryWindow and DirectWindow). Assumes the Page's ViewCollection holds MediaSources.
+//
+public class DragAndDropHandler {
+ private enum TargetType {
+ XDS,
+ MEDIA_LIST
+ }
+
+ private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = {
+ { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS },
+ { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST }
+ };
+
+ private static Gdk.Atom? XDS_ATOM = null;
+ private static Gdk.Atom? TEXT_ATOM = null;
+ private static uint8[]? XDS_FAKE_TARGET = null;
+
+ private weak Page page;
+ private Gtk.Widget event_source;
+ private File? drag_destination = null;
+ private ExporterUI exporter = null;
+
+ public DragAndDropHandler(Page page) {
+ this.page = page;
+ this.event_source = page.get_event_source();
+ assert(event_source != null);
+ assert(event_source.get_has_window());
+
+ // Need to do this because static member variables are not properly handled
+ if (XDS_ATOM == null)
+ XDS_ATOM = Gdk.Atom.intern_static_string("XdndDirectSave0");
+
+ if (TEXT_ATOM == null)
+ TEXT_ATOM = Gdk.Atom.intern_static_string("text/plain");
+
+ if (XDS_FAKE_TARGET == null)
+ XDS_FAKE_TARGET = string_to_uchar_array("shotwell.txt");
+
+ // register what's available on this DnD Source
+ Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES,
+ Gdk.DragAction.COPY);
+
+ // attach to the event source's DnD signals, not the Page's, which is a NO_WINDOW widget
+ // and does not emit them
+ event_source.drag_begin.connect(on_drag_begin);
+ event_source.drag_data_get.connect(on_drag_data_get);
+ event_source.drag_end.connect(on_drag_end);
+ event_source.drag_failed.connect(on_drag_failed);
+ }
+
+ ~DragAndDropHandler() {
+ if (event_source != null) {
+ event_source.drag_begin.disconnect(on_drag_begin);
+ event_source.drag_data_get.disconnect(on_drag_data_get);
+ event_source.drag_end.disconnect(on_drag_end);
+ event_source.drag_failed.disconnect(on_drag_failed);
+ }
+
+ page = null;
+ event_source = null;
+ }
+
+ private void on_drag_begin(Gdk.DragContext context) {
+ debug("on_drag_begin (%s)", page.get_page_name());
+
+ if (page == null || page.get_view().get_selected_count() == 0 || exporter != null)
+ return;
+
+ drag_destination = null;
+
+ // use the first media item as the icon
+ ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source();
+
+ try {
+ Gdk.Pixbuf icon = thumb.get_thumbnail(AppWindow.DND_ICON_SCALE);
+ Gtk.drag_source_set_icon_pixbuf(event_source, icon);
+ } catch (Error err) {
+ warning("Unable to fetch icon for drag-and-drop from %s: %s", thumb.to_string(),
+ err.message);
+ }
+
+ // set the XDS property to indicate an XDS save is available
+#if VALA_0_20
+ Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
+ XDS_FAKE_TARGET, 1);
+#else
+ Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
+ XDS_FAKE_TARGET);
+#endif
+ }
+
+ private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
+ uint target_type, uint time) {
+ debug("on_drag_data_get (%s)", page.get_page_name());
+
+ if (page == null || page.get_view().get_selected_count() == 0)
+ return;
+
+ switch (target_type) {
+ case TargetType.XDS:
+ // Fetch the XDS property that has been set with the destination path
+ uchar[] data = new uchar[4096];
+ Gdk.Atom actual_type;
+ int actual_format = 0;
+ bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM,
+ 0, data.length, 0, out actual_type, out actual_format, out data);
+
+ // the destination path is actually for our XDS_FAKE_TARGET, use its parent
+ // to determine where the file(s) should go
+ if (fetched && data != null && data.length > 0)
+ drag_destination = File.new_for_uri(uchar_array_to_string(data)).get_parent();
+
+ debug("on_drag_data_get (%s): %s", page.get_page_name(),
+ (drag_destination != null) ? drag_destination.get_path() : "(no path)");
+
+ // Set the property to "S" for Success or "E" for Error
+ selection_data.set(XDS_ATOM, 8,
+ string_to_uchar_array((drag_destination != null) ? "S" : "E"));
+ break;
+
+ case TargetType.MEDIA_LIST:
+ Gee.Collection<MediaSource> sources =
+ (Gee.Collection<MediaSource>) page.get_view().get_selected_sources();
+
+ // convert the selected media sources to Gdk.Atom-encoded sourceID strings for
+ // internal drag-and-drop
+ selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom),
+ serialize_media_sources(sources));
+ break;
+
+ default:
+ warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(),
+ target_type);
+ break;
+ }
+ }
+
+ private void on_drag_end() {
+ debug("on_drag_end (%s)", page.get_page_name());
+
+ if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null
+ || exporter != null) {
+ return;
+ }
+
+ debug("Exporting to %s", drag_destination.get_path());
+
+ // drag-and-drop export doesn't pop up an export dialog, so use what are likely the
+ // most common export settings (the current -- or "working" -- file format, with
+ // all transformations applied, at the image's original size).
+ if (drag_destination.get_path() != null) {
+ exporter = new ExporterUI(new Exporter(
+ (Gee.Collection<Photo>) page.get_view().get_selected_sources(),
+ drag_destination, Scaling.for_original(), ExportFormatParameters.current()));
+ exporter.export(on_export_completed);
+ } else {
+ AppWindow.error_message(_("Photos cannot be exported to this directory."));
+ }
+
+ drag_destination = null;
+ }
+
+ private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
+ debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result);
+
+ if (page == null)
+ return false;
+
+ drag_destination = null;
+
+ return false;
+ }
+
+ private void on_export_completed() {
+ exporter = null;
+ }
+}