1/* Copyright 2016 Software Freedom Conservancy Inc.
2 *
3 * This software is licensed under the GNU LGPL (version 2.1 or later).
4 * See the COPYING file in this distribution.
5 */
6
7public class InjectionGroup {
8    public class Element {
9        public enum ItemType {
10            MENUITEM,
11            MENU,
12            SEPARATOR
13        }
14        public string name;
15        public string action;
16        public string? accellerator;
17        public ItemType kind;
18
19        public Element(string name, string? action, string? accellerator, ItemType kind) {
20            this.name = name;
21            this.action = action != null ? action : name;
22            this.accellerator = accellerator;
23            this.kind = kind;
24        }
25    }
26
27    private string path;
28    private Gee.ArrayList<Element?> elements = new Gee.ArrayList<Element?>();
29    private int separator_id = 0;
30
31    public InjectionGroup(string path) {
32        this.path = path;
33    }
34
35    public string get_path() {
36        return path;
37    }
38
39    public Gee.List<Element?> get_elements() {
40        return elements;
41    }
42
43    public void add_menu_item(string name, string? action = null, string? accellerator = null) {
44        elements.add(new Element(name, action, accellerator, Element.ItemType.MENUITEM));
45    }
46
47    public void add_menu(string name, string? action = null) {
48        elements.add(new Element(name, action, null, Element.ItemType.MENU));
49    }
50
51    public void add_separator() {
52        elements.add(new Element("%d-separator".printf(separator_id++), null,
53                    null,
54                    Element.ItemType.SEPARATOR));
55    }
56}
57
58public abstract class Page : Gtk.ScrolledWindow {
59    private const int CONSIDER_CONFIGURE_HALTED_MSEC = 400;
60
61    protected Gtk.Builder builder = new Gtk.Builder ();
62    protected Gtk.Toolbar toolbar;
63    protected bool in_view = false;
64
65    private string page_name;
66    private ViewCollection view = null;
67    private Gtk.Window container = null;
68    private string toolbar_path;
69    private Gdk.Rectangle last_position = Gdk.Rectangle();
70    private Gtk.Widget event_source = null;
71    private bool dnd_enabled = false;
72    private ulong last_configure_ms = 0;
73    private bool report_move_finished = false;
74    private bool report_resize_finished = false;
75    private Gdk.Point last_down = Gdk.Point();
76    private bool is_destroyed = false;
77    private bool ctrl_pressed = false;
78    private bool alt_pressed = false;
79    private bool shift_pressed = false;
80    private bool super_pressed = false;
81    private Gdk.CursorType last_cursor = Gdk.CursorType.LEFT_PTR;
82    private bool cursor_hidden = false;
83    private int cursor_hide_msec = 0;
84    private uint last_timeout_id = 0;
85    private int cursor_hide_time_cached = 0;
86    private bool are_actions_attached = false;
87    private OneShotScheduler? update_actions_scheduler = null;
88
89    protected Page(string page_name) {
90        this.page_name = page_name;
91
92        view = new ViewCollection("ViewCollection for Page %s".printf(page_name));
93
94        last_down = { -1, -1 };
95
96        set_can_focus(true);
97
98        popup_menu.connect(on_context_keypress);
99
100        realize.connect(attach_view_signals);
101    }
102
103    ~Page() {
104#if TRACE_DTORS
105        debug("DTOR: Page %s", page_name);
106#endif
107    }
108
109    // This is called by the page controller when it has removed this page ... pages should override
110    // this (or the signal) to clean up
111    public override void destroy() {
112        if (is_destroyed)
113            return;
114
115        // untie signals
116        detach_event_source();
117        detach_view_signals();
118        view.close();
119
120        // remove refs to external objects which may be pointing to the Page
121        clear_container();
122
123        if (toolbar != null)
124            toolbar.destroy();
125
126        // halt any pending callbacks
127        if (update_actions_scheduler != null)
128            update_actions_scheduler.cancel();
129
130        is_destroyed = true;
131
132        base.destroy();
133
134        debug("Page %s Destroyed", get_page_name());
135    }
136
137    public string get_page_name() {
138        return page_name;
139    }
140
141    public virtual void set_page_name(string page_name) {
142        this.page_name = page_name;
143    }
144
145    public string to_string() {
146        return page_name;
147    }
148
149    public ViewCollection get_view() {
150        return view;
151    }
152
153    public Gtk.Window? get_container() {
154        return container;
155    }
156
157    public virtual void set_container(Gtk.Window container) {
158        assert(this.container == null);
159
160        this.container = container;
161    }
162
163    public virtual void clear_container() {
164        container = null;
165    }
166
167    public void set_event_source(Gtk.Widget event_source) {
168        assert(this.event_source == null);
169
170        this.event_source = event_source;
171        event_source.set_can_focus(true);
172
173        // interested in mouse button and motion events on the event source
174        event_source.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK
175            | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.POINTER_MOTION_HINT_MASK
176            | Gdk.EventMask.BUTTON_MOTION_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
177            | Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK);
178        event_source.button_press_event.connect(on_button_pressed_internal);
179        event_source.button_release_event.connect(on_button_released_internal);
180        event_source.motion_notify_event.connect(on_motion_internal);
181        event_source.leave_notify_event.connect(on_leave_notify_event);
182        event_source.scroll_event.connect(on_mousewheel_internal);
183        event_source.realize.connect(on_event_source_realize);
184    }
185
186    private void detach_event_source() {
187        if (event_source == null)
188            return;
189
190        event_source.button_press_event.disconnect(on_button_pressed_internal);
191        event_source.button_release_event.disconnect(on_button_released_internal);
192        event_source.motion_notify_event.disconnect(on_motion_internal);
193        event_source.leave_notify_event.disconnect(on_leave_notify_event);
194        event_source.scroll_event.disconnect(on_mousewheel_internal);
195
196        disable_drag_source();
197
198        event_source = null;
199    }
200
201    public Gtk.Widget? get_event_source() {
202        return event_source;
203    }
204
205    private bool menubar_injected = false;
206    public GLib.MenuModel get_menubar() {
207        var model = builder.get_object ("MenuBar") as GLib.Menu;
208        if (model == null) {
209            return new GLib.Menu();
210        }
211
212        if (!menubar_injected) {
213            // Collect injected UI elements and add them to the UI manager
214            InjectionGroup[] injection_groups = init_collect_injection_groups();
215            foreach (InjectionGroup group in injection_groups) {
216                var items = model.get_n_items ();
217                for (int i = 0; i < items; i++) {
218                    var submenu = model.get_item_link (i, GLib.Menu.LINK_SUBMENU);
219
220                    var section = this.find_extension_point (submenu,
221                                                             group.get_path ());
222
223                    if (section == null) {
224                        continue;
225                    }
226
227                    foreach (var element in group.get_elements ()) {
228                        var menu = section as GLib.Menu;
229                        switch (element.kind) {
230                            case InjectionGroup.Element.ItemType.MENUITEM:
231                                var item = new GLib.MenuItem (element.name,
232                                                              "win." + element.action);
233                                if (element.accellerator != null) {
234                                    item.set_attribute ("accel",
235                                                        "s",
236                                                        element.accellerator);
237                                }
238
239                                menu.append_item (item);
240                                break;
241                            default:
242                                break;
243                        }
244                    }
245                }
246            }
247
248            this.menubar_injected = true;
249        }
250
251        return model;
252    }
253
254    public virtual Gtk.Toolbar get_toolbar() {
255        if (toolbar == null) {
256            toolbar = toolbar_path == null ? new Gtk.Toolbar() :
257                                             builder.get_object (toolbar_path)
258                                             as Gtk.Toolbar;
259            toolbar.get_style_context().add_class("bottom-toolbar");  // for elementary theme
260            toolbar.set_icon_size(Gtk.IconSize.SMALL_TOOLBAR);
261        }
262        return toolbar;
263    }
264
265    public virtual Gtk.Menu? get_page_context_menu() {
266        return null;
267    }
268
269    public virtual void switching_from() {
270        in_view = false;
271        //remove_actions(AppWindow.get_instance());
272        var map = get_container() as GLib.ActionMap;
273        if (map != null) {
274            remove_actions(map);
275        }
276        if (toolbar_path != null)
277            toolbar = null;
278    }
279
280    public virtual void switched_to() {
281        in_view = true;
282        add_ui();
283        var map = get_container() as GLib.ActionMap;
284        if (map != null) {
285            add_actions(map);
286        }
287        int selected_count = get_view().get_selected_count();
288        int count = get_view().get_count();
289        init_actions(selected_count, count);
290        update_actions(selected_count, count);
291        update_modifiers();
292    }
293
294    public virtual void ready() {
295    }
296
297    public bool is_in_view() {
298        return in_view;
299    }
300
301    public virtual void switching_to_fullscreen(FullscreenWindow fsw) {
302        add_actions(fsw);
303    }
304
305    public virtual void returning_from_fullscreen(FullscreenWindow fsw) {
306        remove_actions(fsw);
307        switched_to();
308    }
309
310    public GLib.Action? get_action (string name) {
311        GLib.ActionMap? map = null;
312        if (container is FullscreenWindow) {
313            map = container as GLib.ActionMap;
314        } else {
315            map = AppWindow.get_instance () as GLib.ActionMap;
316        }
317
318        if (map != null) {
319            return map.lookup_action(name);
320        }
321
322        return null;
323    }
324
325    public void set_action_sensitive(string name, bool sensitive) {
326        GLib.SimpleAction? action = get_action(name) as GLib.SimpleAction;
327        if (action != null)
328            action.set_enabled (sensitive);
329    }
330
331    public void set_action_details(string name, string? label, string? tooltip, bool sensitive) {
332        GLib.SimpleAction? action = get_action(name) as GLib.SimpleAction;
333
334        if (action == null)
335            return;
336
337        if (label != null)
338            this.update_menu_item_label (name, label);
339
340        action.set_enabled (sensitive);
341    }
342
343    public void activate_action(string name) {
344        var action = get_action(name);
345
346        if (action != null)
347            action.activate (null);
348    }
349
350    public GLib.Action? get_common_action(string name, bool log_warning = true) {
351        var action = get_action (name);
352
353        if (action != null)
354            return action;
355
356        if (log_warning)
357            warning("Page %s: Unable to locate common action %s", get_page_name(), name);
358
359        return null;
360    }
361
362    public void set_common_action_sensitive(string name, bool sensitive) {
363        var action = get_common_action(name) as GLib.SimpleAction;
364        if (action != null)
365            action.set_enabled (sensitive);
366    }
367
368    public void set_common_action_label(string name, string label) {
369        debug ("Trying to set common action label for %s", name);
370    }
371
372    public void set_common_action_important(string name, bool important) {
373        debug ("Setting action to important: %s", name);
374    }
375
376    public void activate_common_action(string name) {
377        var action = get_common_action(name) as GLib.SimpleAction;
378        if (action != null)
379            action.activate(null);
380    }
381
382    public bool get_ctrl_pressed() {
383        return ctrl_pressed;
384    }
385
386    public bool get_alt_pressed() {
387        return alt_pressed;
388    }
389
390    public bool get_shift_pressed() {
391        return shift_pressed;
392    }
393
394    public bool get_super_pressed() {
395        return super_pressed;
396    }
397
398     protected void set_action_active (string name, bool active) {
399        var action = get_action (name) as GLib.SimpleAction;
400        if (action != null) {
401            action.set_state (active);
402        }
403    }
404
405    private bool get_modifiers(out bool ctrl, out bool alt, out bool shift, out bool super) {
406        if (AppWindow.get_instance().get_window() == null) {
407            ctrl = false;
408            alt = false;
409            shift = false;
410            super = false;
411
412            return false;
413        }
414
415        int x, y;
416        Gdk.ModifierType mask;
417        var seat = Gdk.Display.get_default().get_default_seat();
418        AppWindow.get_instance().get_window().get_device_position(seat.get_pointer(), out x, out y, out mask);
419
420        ctrl = (mask & Gdk.ModifierType.CONTROL_MASK) != 0;
421        alt = (mask & Gdk.ModifierType.MOD1_MASK) != 0;
422        shift = (mask & Gdk.ModifierType.SHIFT_MASK) != 0;
423        super = (mask & Gdk.ModifierType.MOD4_MASK) != 0; // not SUPER_MASK
424
425        return true;
426    }
427
428    private void update_modifiers() {
429        bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
430            super_currently_pressed;
431        if (!get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
432            out shift_currently_pressed, out super_currently_pressed)) {
433            return;
434        }
435
436        if (ctrl_pressed && !ctrl_currently_pressed)
437            on_ctrl_released(null);
438        else if (!ctrl_pressed && ctrl_currently_pressed)
439            on_ctrl_pressed(null);
440
441        if (alt_pressed && !alt_currently_pressed)
442            on_alt_released(null);
443        else if (!alt_pressed && alt_currently_pressed)
444            on_alt_pressed(null);
445
446        if (shift_pressed && !shift_currently_pressed)
447            on_shift_released(null);
448        else if (!shift_pressed && shift_currently_pressed)
449            on_shift_pressed(null);
450
451        if(super_pressed && !super_currently_pressed)
452            on_super_released(null);
453        else if (!super_pressed && super_currently_pressed)
454            on_super_pressed(null);
455
456        ctrl_pressed = ctrl_currently_pressed;
457        alt_pressed = alt_currently_pressed;
458        shift_pressed = shift_currently_pressed;
459        super_pressed = super_currently_pressed;
460    }
461
462    public PageWindow? get_page_window() {
463        Gtk.Widget p = parent;
464        while (p != null) {
465            if (p is PageWindow)
466                return (PageWindow) p;
467
468            p = p.parent;
469        }
470
471        return null;
472    }
473
474    public CommandManager get_command_manager() {
475        return AppWindow.get_command_manager();
476    }
477
478    protected virtual void add_actions (GLib.ActionMap map) { }
479    protected virtual void remove_actions (GLib.ActionMap map) { }
480
481    protected void on_action_toggle (GLib.Action action, Variant? value) {
482        Variant new_state = ! (bool) action.get_state ();
483        action.change_state (new_state);
484    }
485
486    protected void on_action_radio (GLib.Action action, Variant? value) {
487        action.change_state (value);
488    }
489
490    private void add_ui() {
491        // Collect all UI filenames and load them into the UI manager
492        Gee.List<string> ui_filenames = new Gee.ArrayList<string>();
493        init_collect_ui_filenames(ui_filenames);
494        if (ui_filenames.size == 0)
495            message("No UI file specified for %s", get_page_name());
496
497        foreach (string ui_filename in ui_filenames)
498            init_load_ui(ui_filename);
499
500        //ui.insert_action_group(action_group, 0);
501    }
502
503    public void init_toolbar(string path) {
504        toolbar_path = path;
505    }
506
507    // Called from "realize"
508    private void attach_view_signals() {
509        if (are_actions_attached)
510            return;
511
512        // initialize the Gtk.Actions according to current state
513        int selected_count = get_view().get_selected_count();
514        int count = get_view().get_count();
515        init_actions(selected_count, count);
516        update_actions(selected_count, count);
517
518        // monitor state changes to update actions
519        get_view().items_state_changed.connect(on_update_actions);
520        get_view().selection_group_altered.connect(on_update_actions);
521        get_view().items_visibility_changed.connect(on_update_actions);
522        get_view().contents_altered.connect(on_update_actions);
523
524        are_actions_attached = true;
525    }
526
527    // Called from destroy()
528    private void detach_view_signals() {
529        if (!are_actions_attached)
530            return;
531
532        get_view().items_state_changed.disconnect(on_update_actions);
533        get_view().selection_group_altered.disconnect(on_update_actions);
534        get_view().items_visibility_changed.disconnect(on_update_actions);
535        get_view().contents_altered.disconnect(on_update_actions);
536
537        are_actions_attached = false;
538    }
539
540    private void on_update_actions() {
541        if (update_actions_scheduler == null) {
542            update_actions_scheduler = new OneShotScheduler(
543                "Update actions scheduler for %s".printf(get_page_name()),
544                on_update_actions_on_idle);
545        }
546
547        update_actions_scheduler.at_priority_idle(Priority.LOW);
548    }
549
550    private void on_update_actions_on_idle() {
551        if (is_destroyed)
552            return;
553
554        if (!this.in_view)
555            return;
556
557        update_actions(get_view().get_selected_count(), get_view().get_count());
558    }
559
560    private void init_load_ui(string ui_filename) {
561        var ui_resource = Resources.get_ui(ui_filename);
562        try {
563            builder.add_from_resource(ui_resource);
564            this.menubar_injected = false;
565        } catch (Error err) {
566            AppWindow.error_message("Error loading UI resource %s: %s".printf(
567                ui_resource, err.message));
568            Application.get_instance().panic();
569        }
570    }
571
572    // This is called during add_ui() to collect all the UI files to be loaded into the UI
573    // manager.  Because order is important here, call the base method *first*, then add the
574    // classes' filename.
575    protected virtual void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
576    }
577
578    // This is called during add_ui() to collect all Page.InjectedUIElements for the page.  They
579    // should be added to the MultiSet using the injection path as the key.
580    protected virtual InjectionGroup[] init_collect_injection_groups() {
581        return new InjectionGroup[0];
582    }
583
584    // This is called during "map" allowing for Gtk.Actions to be updated at
585    // initialization time.
586    protected virtual void init_actions(int selected_count, int count) {
587    }
588
589    // This is called during "map" and during ViewCollection selection, visibility,
590    // and collection content altered events.  This can be used to both initialize Gtk.Actions and
591    // update them when selection or visibility has been altered.
592    protected virtual void update_actions(int selected_count, int count) {
593    }
594
595    // This method enables drag-and-drop on the event source and routes its events through this
596    // object
597    public void enable_drag_source(Gdk.DragAction actions, Gtk.TargetEntry[] source_target_entries) {
598        if (dnd_enabled)
599            return;
600
601        assert(event_source != null);
602
603        Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, source_target_entries, actions);
604
605        // hook up handlers which route the event_source's DnD signals to the Page's (necessary
606        // because Page is a NO_WINDOW widget and cannot support DnD on its own).
607        event_source.drag_begin.connect(on_drag_begin);
608        event_source.drag_data_get.connect(on_drag_data_get);
609        event_source.drag_data_delete.connect(on_drag_data_delete);
610        event_source.drag_end.connect(on_drag_end);
611        event_source.drag_failed.connect(on_drag_failed);
612
613        dnd_enabled = true;
614    }
615
616    public void disable_drag_source() {
617        if (!dnd_enabled)
618            return;
619
620        assert(event_source != null);
621
622        event_source.drag_begin.disconnect(on_drag_begin);
623        event_source.drag_data_get.disconnect(on_drag_data_get);
624        event_source.drag_data_delete.disconnect(on_drag_data_delete);
625        event_source.drag_end.disconnect(on_drag_end);
626        event_source.drag_failed.disconnect(on_drag_failed);
627        Gtk.drag_source_unset(event_source);
628
629        dnd_enabled = false;
630    }
631
632    public bool is_dnd_enabled() {
633        return dnd_enabled;
634    }
635
636    private void on_drag_begin(Gdk.DragContext context) {
637        drag_begin(context);
638    }
639
640    private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
641        uint info, uint time) {
642        drag_data_get(context, selection_data, info, time);
643    }
644
645    private void on_drag_data_delete(Gdk.DragContext context) {
646        drag_data_delete(context);
647    }
648
649    private void on_drag_end(Gdk.DragContext context) {
650        drag_end(context);
651    }
652
653    // wierdly, Gtk 2.16.1 doesn't supply a drag_failed virtual method in the GtkWidget impl ...
654    // Vala binds to it, but it's not available in gtkwidget.h, and so gcc complains.  Have to
655    // makeshift one for now.
656    // https://bugzilla.gnome.org/show_bug.cgi?id=584247
657    public virtual bool source_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
658        return false;
659    }
660
661    private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
662        return source_drag_failed(context, drag_result);
663    }
664
665    // Use this function rather than GDK or GTK's get_pointer, especially if called during a
666    // button-down mouse drag (i.e. a window grab).
667    //
668    // For more information, see: https://bugzilla.gnome.org/show_bug.cgi?id=599937
669    public bool get_event_source_pointer(out int x, out int y, out Gdk.ModifierType mask) {
670        if (event_source == null) {
671            x = 0;
672            y = 0;
673            mask = 0;
674
675            return false;
676        }
677
678        var seat = Gdk.Display.get_default().get_default_seat();
679        event_source.get_window().get_device_position(seat.get_pointer(), out x, out y, out mask);
680
681        if (last_down.x < 0 || last_down.y < 0)
682            return true;
683
684        // check for bogus values inside a drag which goes outside the window
685        // caused by (most likely) X windows signed 16-bit int overflow and fixup
686        // (https://bugzilla.gnome.org/show_bug.cgi?id=599937)
687
688        if ((x - last_down.x).abs() >= 0x7FFF)
689            x += 0xFFFF;
690
691        if ((y - last_down.y).abs() >= 0x7FFF)
692            y += 0xFFFF;
693
694        return true;
695    }
696
697    protected virtual bool on_left_click(Gdk.EventButton event) {
698        return false;
699    }
700
701    protected virtual bool on_middle_click(Gdk.EventButton event) {
702        return false;
703    }
704
705    protected virtual bool on_right_click(Gdk.EventButton event) {
706        return false;
707    }
708
709    protected virtual bool on_left_released(Gdk.EventButton event) {
710        return false;
711    }
712
713    protected virtual bool on_middle_released(Gdk.EventButton event) {
714        return false;
715    }
716
717    protected virtual bool on_right_released(Gdk.EventButton event) {
718        return false;
719    }
720
721    private bool on_button_pressed_internal(Gdk.EventButton event) {
722        switch (event.button) {
723            case 1:
724                if (event_source != null)
725                    event_source.grab_focus();
726
727                // stash location of mouse down for drag fixups
728                last_down.x = (int) event.x;
729                last_down.y = (int) event.y;
730
731                return on_left_click(event);
732
733            case 2:
734                return on_middle_click(event);
735
736            case 3:
737                return on_right_click(event);
738
739            default:
740                return false;
741        }
742    }
743
744    private bool on_button_released_internal(Gdk.EventButton event) {
745        switch (event.button) {
746            case 1:
747                // clear when button released, only for drag fixups
748                last_down = { -1, -1 };
749
750                return on_left_released(event);
751
752            case 2:
753                return on_middle_released(event);
754
755            case 3:
756                return on_right_released(event);
757
758            default:
759                return false;
760        }
761    }
762
763    protected virtual bool on_ctrl_pressed(Gdk.EventKey? event) {
764        return false;
765    }
766
767    protected virtual bool on_ctrl_released(Gdk.EventKey? event) {
768        return false;
769    }
770
771    protected virtual bool on_alt_pressed(Gdk.EventKey? event) {
772        return false;
773    }
774
775    protected virtual bool on_alt_released(Gdk.EventKey? event) {
776        return false;
777    }
778
779    protected virtual bool on_shift_pressed(Gdk.EventKey? event) {
780        return false;
781    }
782
783    protected virtual bool on_shift_released(Gdk.EventKey? event) {
784        return false;
785    }
786
787    protected virtual bool on_super_pressed(Gdk.EventKey? event) {
788        return false;
789    }
790
791    protected virtual bool on_super_released(Gdk.EventKey? event) {
792        return false;
793    }
794
795    protected virtual bool on_app_key_pressed(Gdk.EventKey event) {
796        return false;
797    }
798
799    protected virtual bool on_app_key_released(Gdk.EventKey event) {
800        return false;
801    }
802
803    public bool notify_app_key_pressed(Gdk.EventKey event) {
804        bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
805            super_currently_pressed;
806        get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
807            out shift_currently_pressed, out super_currently_pressed);
808
809        switch (Gdk.keyval_name(event.keyval)) {
810            case "Control_L":
811            case "Control_R":
812                if (!ctrl_currently_pressed || ctrl_pressed)
813                    return false;
814
815                ctrl_pressed = true;
816
817                return on_ctrl_pressed(event);
818
819            case "Meta_L":
820            case "Meta_R":
821            case "Alt_L":
822            case "Alt_R":
823                if (!alt_currently_pressed || alt_pressed)
824                    return false;
825
826                alt_pressed = true;
827
828                return on_alt_pressed(event);
829
830            case "Shift_L":
831            case "Shift_R":
832                if (!shift_currently_pressed || shift_pressed)
833                    return false;
834
835                shift_pressed = true;
836
837                return on_shift_pressed(event);
838
839            case "Super_L":
840            case "Super_R":
841                if (!super_currently_pressed || super_pressed)
842                    return false;
843
844                super_pressed = true;
845
846                return on_super_pressed(event);
847        }
848
849        return on_app_key_pressed(event);
850    }
851
852    public bool notify_app_key_released(Gdk.EventKey event) {
853        bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
854            super_currently_pressed;
855        get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
856            out shift_currently_pressed, out super_currently_pressed);
857
858        switch (Gdk.keyval_name(event.keyval)) {
859            case "Control_L":
860            case "Control_R":
861                if (ctrl_currently_pressed || !ctrl_pressed)
862                    return false;
863
864                ctrl_pressed = false;
865
866                return on_ctrl_released(event);
867
868            case "Meta_L":
869            case "Meta_R":
870            case "Alt_L":
871            case "Alt_R":
872                if (alt_currently_pressed || !alt_pressed)
873                    return false;
874
875                alt_pressed = false;
876
877                return on_alt_released(event);
878
879            case "Shift_L":
880            case "Shift_R":
881                if (shift_currently_pressed || !shift_pressed)
882                    return false;
883
884                shift_pressed = false;
885
886                return on_shift_released(event);
887
888            case "Super_L":
889            case "Super_R":
890                if (super_currently_pressed || !super_pressed)
891                    return false;
892
893                super_pressed = false;
894
895                return on_super_released(event);
896        }
897
898        return on_app_key_released(event);
899    }
900
901    public bool notify_app_focus_in(Gdk.EventFocus event) {
902        update_modifiers();
903
904        return false;
905    }
906
907    public bool notify_app_focus_out(Gdk.EventFocus event) {
908        return false;
909    }
910
911    protected virtual void on_move(Gdk.Rectangle rect) {
912    }
913
914    protected virtual void on_move_start(Gdk.Rectangle rect) {
915    }
916
917    protected virtual void on_move_finished(Gdk.Rectangle rect) {
918    }
919
920    protected virtual void on_resize(Gdk.Rectangle rect) {
921    }
922
923    protected virtual void on_resize_start(Gdk.Rectangle rect) {
924    }
925
926    protected virtual void on_resize_finished(Gdk.Rectangle rect) {
927    }
928
929    protected virtual bool on_configure(Gdk.EventConfigure event, Gdk.Rectangle rect) {
930        return false;
931    }
932
933    public bool notify_configure_event(Gdk.EventConfigure event) {
934        Gdk.Rectangle rect = Gdk.Rectangle();
935        rect.x = event.x;
936        rect.y = event.y;
937        rect.width = event.width;
938        rect.height = event.height;
939
940        // special case events, to report when a configure first starts (and appears to end)
941        if (last_configure_ms == 0) {
942            if (last_position.x != rect.x || last_position.y != rect.y) {
943                on_move_start(rect);
944                report_move_finished = true;
945            }
946
947            if (last_position.width != rect.width || last_position.height != rect.height) {
948                on_resize_start(rect);
949                report_resize_finished = true;
950            }
951
952            // need to check more often then the timeout, otherwise it could be up to twice the
953            // wait time before it's noticed
954            Timeout.add(CONSIDER_CONFIGURE_HALTED_MSEC / 8, check_configure_halted);
955        }
956
957        if (last_position.x != rect.x || last_position.y != rect.y)
958            on_move(rect);
959
960        if (last_position.width != rect.width || last_position.height != rect.height)
961            on_resize(rect);
962
963        last_position = rect;
964        last_configure_ms = now_ms();
965
966        return on_configure(event, rect);
967    }
968
969    private bool check_configure_halted() {
970        if (is_destroyed)
971            return false;
972
973        if ((now_ms() - last_configure_ms) < CONSIDER_CONFIGURE_HALTED_MSEC)
974            return true;
975
976        Gtk.Allocation allocation;
977        get_allocation(out allocation);
978
979        if (report_move_finished)
980            on_move_finished((Gdk.Rectangle) allocation);
981
982        if (report_resize_finished)
983            on_resize_finished((Gdk.Rectangle) allocation);
984
985        last_configure_ms = 0;
986        report_move_finished = false;
987        report_resize_finished = false;
988
989        return false;
990    }
991
992    protected virtual bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) {
993        check_cursor_hiding();
994
995        return false;
996    }
997
998    protected virtual bool on_leave_notify_event() {
999        return false;
1000    }
1001
1002    private bool on_motion_internal(Gdk.EventMotion event) {
1003        int x, y;
1004        Gdk.ModifierType mask;
1005        if (event.is_hint == 1) {
1006            get_event_source_pointer(out x, out y, out mask);
1007        } else {
1008            x = (int) event.x;
1009            y = (int) event.y;
1010            mask = event.state;
1011        }
1012
1013        return on_motion(event, x, y, mask);
1014    }
1015
1016    private bool on_mousewheel_internal(Gdk.EventScroll event) {
1017        switch (event.direction) {
1018            case Gdk.ScrollDirection.UP:
1019                return on_mousewheel_up(event);
1020
1021            case Gdk.ScrollDirection.DOWN:
1022                return on_mousewheel_down(event);
1023
1024            case Gdk.ScrollDirection.LEFT:
1025                return on_mousewheel_left(event);
1026
1027            case Gdk.ScrollDirection.RIGHT:
1028                return on_mousewheel_right(event);
1029
1030            case Gdk.ScrollDirection.SMOOTH:
1031                {
1032                    double dx, dy;
1033                    event.get_scroll_deltas(out dx, out dy);
1034
1035                    if (dy < 0)
1036                        return on_mousewheel_up(event);
1037                    else if (dy > 0)
1038                        return on_mousewheel_down(event);
1039                    else if (dx < 0)
1040                        return on_mousewheel_left(event);
1041                    else if (dx > 0)
1042                        return on_mousewheel_right(event);
1043                    else
1044                        return false;
1045                }
1046
1047            default:
1048                return false;
1049        }
1050    }
1051
1052    protected virtual bool on_mousewheel_up(Gdk.EventScroll event) {
1053        return false;
1054    }
1055
1056    protected virtual bool on_mousewheel_down(Gdk.EventScroll event) {
1057        return false;
1058    }
1059
1060    protected virtual bool on_mousewheel_left(Gdk.EventScroll event) {
1061        return false;
1062    }
1063
1064    protected virtual bool on_mousewheel_right(Gdk.EventScroll event) {
1065        return false;
1066    }
1067
1068    protected virtual bool on_context_keypress() {
1069        return false;
1070    }
1071
1072    protected virtual bool on_context_buttonpress(Gdk.EventButton event) {
1073        return false;
1074    }
1075
1076    protected virtual bool on_context_invoked() {
1077        return true;
1078    }
1079
1080    protected bool popup_context_menu(Gtk.Menu? context_menu,
1081        Gdk.EventButton? event = null) {
1082
1083        if (context_menu == null || !on_context_invoked())
1084            return false;
1085
1086        context_menu.popup_at_pointer(event);
1087
1088        return true;
1089    }
1090
1091    protected void on_event_source_realize() {
1092        assert(event_source.get_window() != null); // the realize event means the Widget has a window
1093
1094        if (event_source.get_window().get_cursor() != null) {
1095            last_cursor = event_source.get_window().get_cursor().get_cursor_type();
1096            return;
1097        }
1098
1099        // no custom cursor defined, check parents
1100        Gdk.Window? parent_window = event_source.get_window();
1101        do {
1102            parent_window = parent_window.get_parent();
1103        } while (parent_window != null && parent_window.get_cursor() == null);
1104
1105        if (parent_window != null)
1106            last_cursor = parent_window.get_cursor().get_cursor_type();
1107    }
1108
1109    public void set_cursor_hide_time(int hide_time) {
1110        cursor_hide_msec = hide_time;
1111    }
1112
1113    public void start_cursor_hiding() {
1114        check_cursor_hiding();
1115    }
1116
1117    public void stop_cursor_hiding() {
1118        if (last_timeout_id != 0)
1119            Source.remove(last_timeout_id);
1120    }
1121
1122    public void suspend_cursor_hiding() {
1123        cursor_hide_time_cached = cursor_hide_msec;
1124
1125        if (last_timeout_id != 0)
1126            Source.remove(last_timeout_id);
1127
1128        cursor_hide_msec = 0;
1129    }
1130
1131    public void restore_cursor_hiding() {
1132        cursor_hide_msec = cursor_hide_time_cached;
1133        check_cursor_hiding();
1134    }
1135
1136    // Use this method to set the cursor for a page, NOT window.set_cursor(...)
1137    protected virtual void set_page_cursor(Gdk.CursorType cursor_type) {
1138        last_cursor = cursor_type;
1139
1140        if (!cursor_hidden && event_source != null) {
1141            var display = event_source.get_window ().get_display ();
1142            event_source.get_window().set_cursor(new Gdk.Cursor.for_display(display, cursor_type));
1143        }
1144    }
1145
1146    private void check_cursor_hiding() {
1147        if (cursor_hidden) {
1148            cursor_hidden = false;
1149            set_page_cursor(last_cursor);
1150        }
1151
1152        if (cursor_hide_msec != 0) {
1153            if (last_timeout_id != 0)
1154                Source.remove(last_timeout_id);
1155            last_timeout_id = Timeout.add(cursor_hide_msec, on_hide_cursor);
1156        }
1157    }
1158
1159    private bool on_hide_cursor() {
1160        cursor_hidden = true;
1161
1162        if (event_source != null) {
1163            var display = event_source.get_window().get_display ();
1164            event_source.get_window().set_cursor(new Gdk.Cursor.for_display(display, Gdk.CursorType.BLANK_CURSOR));
1165        }
1166
1167        // We remove the timeout so reset the id
1168        last_timeout_id = 0;
1169
1170        return false;
1171    }
1172
1173    protected void update_menu_item_label (string id,
1174                                         string new_label) {
1175        AppWindow.get_instance().update_menu_item_label (id, new_label);
1176    }
1177
1178    protected GLib.MenuModel? find_extension_point (GLib.MenuModel model,
1179                                                    string extension_point) {
1180        var items = model.get_n_items ();
1181        GLib.MenuModel? section = null;
1182
1183        for (int i = 0; i < items && section == null; i++) {
1184            string? name = null;
1185            model.get_item_attribute (i, "id", "s", out name);
1186            if (name == extension_point) {
1187                section = model.get_item_link (i, GLib.Menu.LINK_SECTION);
1188            } else {
1189                var subsection = model.get_item_link (i, GLib.Menu.LINK_SECTION);
1190
1191                if (subsection == null)
1192                    continue;
1193
1194                // Recurse into submenus
1195                var sub_items = subsection.get_n_items ();
1196                for (int j = 0; j < sub_items && section == null; j++) {
1197                    var submenu = subsection.get_item_link
1198                                                (j, GLib.Menu.LINK_SUBMENU);
1199                    if (submenu != null) {
1200                        section = this.find_extension_point (submenu,
1201                                                             extension_point);
1202                    }
1203                }
1204            }
1205        }
1206
1207        return section;
1208    }
1209
1210}
1211
1212public abstract class CheckerboardPage : Page {
1213    private const int AUTOSCROLL_PIXELS = 50;
1214    private const int AUTOSCROLL_TICKS_MSEC = 50;
1215
1216    private CheckerboardLayout layout;
1217    private string item_context_menu_path = null;
1218    private string page_context_menu_path = null;
1219    private Gtk.Viewport viewport = new Gtk.Viewport(null, null);
1220    protected CheckerboardItem anchor = null;
1221    protected CheckerboardItem cursor = null;
1222    private CheckerboardItem current_hovered_item = null;
1223    private bool autoscroll_scheduled = false;
1224    private CheckerboardItem activated_item = null;
1225    private Gee.ArrayList<CheckerboardItem> previously_selected = null;
1226
1227    public enum Activator {
1228        KEYBOARD,
1229        MOUSE
1230    }
1231
1232    public struct KeyboardModifiers {
1233        public KeyboardModifiers(Page page) {
1234            ctrl_pressed = page.get_ctrl_pressed();
1235            alt_pressed = page.get_alt_pressed();
1236            shift_pressed = page.get_shift_pressed();
1237            super_pressed = page.get_super_pressed();
1238        }
1239
1240        public bool ctrl_pressed;
1241        public bool alt_pressed;
1242        public bool shift_pressed;
1243        public bool super_pressed;
1244    }
1245
1246    protected CheckerboardPage(string page_name) {
1247        base (page_name);
1248
1249        layout = new CheckerboardLayout(get_view());
1250        layout.set_name(page_name);
1251
1252        set_event_source(layout);
1253
1254        set_border_width(0);
1255        set_shadow_type(Gtk.ShadowType.NONE);
1256
1257        viewport.set_border_width(0);
1258        viewport.set_shadow_type(Gtk.ShadowType.NONE);
1259
1260        viewport.add(layout);
1261
1262        // want to set_adjustments before adding to ScrolledWindow to let our signal handlers
1263        // run first ... otherwise, the thumbnails draw late
1264        layout.set_adjustments(get_hadjustment(), get_vadjustment());
1265
1266        add(viewport);
1267
1268        // need to monitor items going hidden when dealing with anchor/cursor/highlighted items
1269        get_view().items_hidden.connect(on_items_hidden);
1270        get_view().contents_altered.connect(on_contents_altered);
1271        get_view().items_state_changed.connect(on_items_state_changed);
1272        get_view().items_visibility_changed.connect(on_items_visibility_changed);
1273
1274        // scrollbar policy
1275        set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
1276    }
1277
1278    public void init_item_context_menu(string path) {
1279        item_context_menu_path = path;
1280    }
1281
1282    public void init_page_context_menu(string path) {
1283        page_context_menu_path = path;
1284    }
1285
1286    public Gtk.Menu? get_context_menu() {
1287        // show page context menu if nothing is selected
1288        return (get_view().get_selected_count() != 0) ? get_item_context_menu() :
1289            get_page_context_menu();
1290    }
1291
1292    private Gtk.Menu item_context_menu;
1293    public virtual Gtk.Menu? get_item_context_menu() {
1294        if (item_context_menu == null) {
1295            var model = this.builder.get_object (item_context_menu_path)
1296                as GLib.MenuModel;
1297            item_context_menu = new Gtk.Menu.from_model (model);
1298            item_context_menu.attach_to_widget (this, null);
1299        }
1300
1301        return item_context_menu;
1302    }
1303
1304    private Gtk.Menu page_context_menu;
1305    public override Gtk.Menu? get_page_context_menu() {
1306        if (page_context_menu_path == null)
1307            return null;
1308
1309        if (page_context_menu == null) {
1310            var model = this.builder.get_object (page_context_menu_path)
1311                as GLib.MenuModel;
1312            page_context_menu = new Gtk.Menu.from_model (model);
1313            page_context_menu.attach_to_widget (this, null);
1314        }
1315
1316        return page_context_menu;
1317    }
1318
1319    protected override bool on_context_keypress() {
1320        return popup_context_menu(get_context_menu());
1321    }
1322
1323    protected virtual string get_view_empty_message() {
1324        return _("No photos/videos");
1325    }
1326
1327    protected virtual string get_filter_no_match_message() {
1328        return _("No photos/videos found which match the current filter");
1329    }
1330
1331    protected virtual void on_item_activated(CheckerboardItem item, Activator activator,
1332        KeyboardModifiers modifiers) {
1333    }
1334
1335    public CheckerboardLayout get_checkerboard_layout() {
1336        return layout;
1337    }
1338
1339    // Gets the search view filter for this page.
1340    public abstract SearchViewFilter get_search_view_filter();
1341
1342    public virtual Core.ViewTracker? get_view_tracker() {
1343        return null;
1344    }
1345
1346    public override void switching_from() {
1347        layout.set_in_view(false);
1348        get_search_view_filter().refresh.disconnect(on_view_filter_refresh);
1349
1350        // unselect everything so selection won't persist after page loses focus
1351        get_view().unselect_all();
1352
1353        base.switching_from();
1354    }
1355
1356    public override void switched_to() {
1357        layout.set_in_view(true);
1358        get_search_view_filter().refresh.connect(on_view_filter_refresh);
1359        on_view_filter_refresh();
1360
1361        if (get_view().get_selected_count() > 0) {
1362            CheckerboardItem? item = (CheckerboardItem?) get_view().get_selected_at(0);
1363
1364            // if item is in any way out of view, scroll to it
1365            Gtk.Adjustment vadj = get_vadjustment();
1366            if (!(get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE
1367                && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))) {
1368
1369                    // scroll to see the new item
1370                    int top = 0;
1371                    if (item.allocation.y < vadj.get_value()) {
1372                        top = item.allocation.y;
1373                        top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2;
1374                    } else {
1375                        top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size();
1376                        top += CheckerboardLayout.ROW_GUTTER_PADDING / 2;
1377                    }
1378
1379                    vadj.set_value(top);
1380
1381                }
1382        }
1383
1384        base.switched_to();
1385    }
1386
1387    private void on_view_filter_refresh() {
1388        update_view_filter_message();
1389    }
1390
1391    private void on_contents_altered(Gee.Iterable<DataObject>? added,
1392        Gee.Iterable<DataObject>? removed) {
1393        update_view_filter_message();
1394    }
1395
1396    private void on_items_state_changed(Gee.Iterable<DataView> changed) {
1397        update_view_filter_message();
1398    }
1399
1400    private void on_items_visibility_changed(Gee.Collection<DataView> changed) {
1401        update_view_filter_message();
1402    }
1403
1404    private void update_view_filter_message() {
1405        if (get_view().are_items_filtered_out() && get_view().get_count() == 0) {
1406            set_page_message(get_filter_no_match_message());
1407        } else if (get_view().get_count() == 0) {
1408            set_page_message(get_view_empty_message());
1409        } else {
1410            unset_page_message();
1411        }
1412    }
1413
1414    public void set_page_message(string message) {
1415        layout.set_message(message);
1416        if (is_in_view())
1417            layout.queue_draw();
1418    }
1419
1420    public void unset_page_message() {
1421        layout.unset_message();
1422        if (is_in_view())
1423            layout.queue_draw();
1424    }
1425
1426    public override void set_page_name(string name) {
1427        base.set_page_name(name);
1428
1429        layout.set_name(name);
1430    }
1431
1432    public CheckerboardItem? get_item_at_pixel(double x, double y) {
1433        return layout.get_item_at_pixel(x, y);
1434    }
1435
1436    private void on_items_hidden(Gee.Iterable<DataView> hidden) {
1437        foreach (DataView view in hidden) {
1438            CheckerboardItem item = (CheckerboardItem) view;
1439
1440            if (anchor == item)
1441                anchor = null;
1442
1443            if (cursor == item)
1444                cursor = null;
1445
1446            if (current_hovered_item == item)
1447                current_hovered_item = null;
1448        }
1449    }
1450
1451    protected override bool key_press_event(Gdk.EventKey event) {
1452        bool handled = true;
1453
1454        // mask out the modifiers we're interested in
1455        uint state = event.state & Gdk.ModifierType.SHIFT_MASK;
1456
1457        switch (Gdk.keyval_name(event.keyval)) {
1458            case "Up":
1459            case "KP_Up":
1460                move_cursor(CompassPoint.NORTH);
1461                select_anchor_to_cursor(state);
1462            break;
1463
1464            case "Down":
1465            case "KP_Down":
1466                move_cursor(CompassPoint.SOUTH);
1467                select_anchor_to_cursor(state);
1468            break;
1469
1470            case "Left":
1471            case "KP_Left":
1472                move_cursor(CompassPoint.WEST);
1473                select_anchor_to_cursor(state);
1474            break;
1475
1476            case "Right":
1477            case "KP_Right":
1478                move_cursor(CompassPoint.EAST);
1479                select_anchor_to_cursor(state);
1480            break;
1481
1482            case "Home":
1483            case "KP_Home":
1484                CheckerboardItem? first = (CheckerboardItem?) get_view().get_first();
1485                if (first != null)
1486                    cursor_to_item(first);
1487                select_anchor_to_cursor(state);
1488            break;
1489
1490            case "End":
1491            case "KP_End":
1492                CheckerboardItem? last = (CheckerboardItem?) get_view().get_last();
1493                if (last != null)
1494                    cursor_to_item(last);
1495                select_anchor_to_cursor(state);
1496            break;
1497
1498            case "Return":
1499            case "KP_Enter":
1500                if (get_view().get_selected_count() == 1)
1501                    on_item_activated((CheckerboardItem) get_view().get_selected_at(0),
1502                        Activator.KEYBOARD, KeyboardModifiers(this));
1503                else
1504                    handled = false;
1505            break;
1506
1507            case "space":
1508                Marker marker = get_view().mark(layout.get_cursor());
1509                get_view().toggle_marked(marker);
1510            break;
1511
1512            default:
1513                handled = false;
1514            break;
1515        }
1516
1517        if (handled)
1518            return true;
1519
1520        return (base.key_press_event != null) ? base.key_press_event(event) : true;
1521    }
1522
1523    protected override bool on_left_click(Gdk.EventButton event) {
1524        // only interested in single-click and double-clicks for now
1525        if ((event.type != Gdk.EventType.BUTTON_PRESS) && (event.type != Gdk.EventType.2BUTTON_PRESS))
1526            return false;
1527
1528        // mask out the modifiers we're interested in
1529        uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK);
1530
1531        // use clicks for multiple selection and activation only; single selects are handled by
1532        // button release, to allow for multiple items to be selected then dragged ...
1533        CheckerboardItem item = get_item_at_pixel(event.x, event.y);
1534        if (item != null) {
1535            // ... however, there is no dragging if the user clicks on an interactive part of the
1536            // CheckerboardItem (e.g. a tag)
1537            if (layout.handle_left_click(item, event.x, event.y, event.state))
1538                return true;
1539
1540            switch (state) {
1541                case Gdk.ModifierType.CONTROL_MASK:
1542                    // with only Ctrl pressed, multiple selections are possible ... chosen item
1543                    // is toggled
1544                    Marker marker = get_view().mark(item);
1545                    get_view().toggle_marked(marker);
1546
1547                    if (item.is_selected()) {
1548                        anchor = item;
1549                        cursor = item;
1550                    }
1551                break;
1552
1553                case Gdk.ModifierType.SHIFT_MASK:
1554                    get_view().unselect_all();
1555
1556                    if (anchor == null)
1557                        anchor = item;
1558
1559                    select_between_items(anchor, item);
1560
1561                    cursor = item;
1562                break;
1563
1564                case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK:
1565                    // Ticket #853 - Make Ctrl + Shift + Mouse Button 1 able to start a new run
1566                    // of contiguous selected items without unselecting previously-selected items
1567                    // a la Nautilus.
1568                    // Same as the case for SHIFT_MASK, but don't unselect anything first.
1569                    if (anchor == null)
1570                        anchor = item;
1571
1572                    select_between_items(anchor, item);
1573
1574                    cursor = item;
1575                break;
1576
1577                default:
1578                    if (event.type == Gdk.EventType.2BUTTON_PRESS) {
1579                        activated_item = item;
1580                    } else {
1581                        // if the user has selected one or more items and is preparing for a drag,
1582                        // don't want to blindly unselect: if they've clicked on an unselected item
1583                        // unselect all and select that one; if they've clicked on a previously
1584                        // selected item, do nothing
1585                        if (!item.is_selected()) {
1586                            Marker all = get_view().start_marking();
1587                            all.mark_many(get_view().get_selected());
1588
1589                            get_view().unselect_and_select_marked(all, get_view().mark(item));
1590                        }
1591                    }
1592
1593                    anchor = item;
1594                    cursor = item;
1595                break;
1596            }
1597            layout.set_cursor(item);
1598        } else {
1599            // user clicked on "dead" area; only unselect if control is not pressed
1600            // do we want similar behavior for shift as well?
1601            if (state != Gdk.ModifierType.CONTROL_MASK)
1602                get_view().unselect_all();
1603
1604            // grab previously marked items
1605            previously_selected = new Gee.ArrayList<CheckerboardItem>();
1606            foreach (DataView view in get_view().get_selected())
1607                previously_selected.add((CheckerboardItem) view);
1608
1609            layout.set_drag_select_origin((int) event.x, (int) event.y);
1610
1611            return true;
1612        }
1613
1614        // need to determine if the signal should be passed to the DnD handlers
1615        // Return true to block the DnD handler, false otherwise
1616
1617        return get_view().get_selected_count() == 0;
1618    }
1619
1620    protected override bool on_left_released(Gdk.EventButton event) {
1621        previously_selected = null;
1622
1623        // if drag-selecting, stop here and do nothing else
1624        if (layout.is_drag_select_active()) {
1625            layout.clear_drag_select();
1626            anchor = cursor;
1627
1628            return true;
1629        }
1630
1631        // only interested in non-modified button releases
1632        if ((event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) != 0)
1633            return false;
1634
1635        // if the item was activated in the double-click, report it now
1636        if (activated_item != null) {
1637            on_item_activated(activated_item, Activator.MOUSE, KeyboardModifiers(this));
1638            activated_item = null;
1639
1640            return true;
1641        }
1642
1643        CheckerboardItem item = get_item_at_pixel(event.x, event.y);
1644        if (item == null) {
1645            // released button on "dead" area
1646            return true;
1647        }
1648
1649        if (cursor != item) {
1650            // user released mouse button after moving it off the initial item, or moved from dead
1651            // space onto one.  either way, unselect everything
1652            get_view().unselect_all();
1653        } else {
1654            // the idea is, if a user single-clicks on an item with no modifiers, then all other items
1655            // should be deselected, however, if they single-click in order to drag one or more items,
1656            // they should remain selected, hence performing this here rather than on_left_click
1657            // (item may not be selected if an unimplemented modifier key was used)
1658            if (item.is_selected())
1659                get_view().unselect_all_but(item);
1660        }
1661
1662        return true;
1663    }
1664
1665    protected override bool on_right_click(Gdk.EventButton event) {
1666        // only interested in single-clicks for now
1667        if (event.type != Gdk.EventType.BUTTON_PRESS)
1668            return false;
1669
1670        // get what's right-clicked upon
1671        CheckerboardItem item = get_item_at_pixel(event.x, event.y);
1672        if (item != null) {
1673            // mask out the modifiers we're interested in
1674            switch (event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) {
1675                case Gdk.ModifierType.CONTROL_MASK:
1676                    // chosen item is toggled
1677                    Marker marker = get_view().mark(item);
1678                    get_view().toggle_marked(marker);
1679                break;
1680
1681                case Gdk.ModifierType.SHIFT_MASK:
1682                    // TODO
1683                break;
1684
1685                case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK:
1686                    // TODO
1687                break;
1688
1689                default:
1690                    // if the item is already selected, proceed; if item is not selected, a bare right
1691                    // click unselects everything else but it
1692                    if (!item.is_selected()) {
1693                        Marker all = get_view().start_marking();
1694                        all.mark_many(get_view().get_selected());
1695
1696                        get_view().unselect_and_select_marked(all, get_view().mark(item));
1697                    }
1698                break;
1699            }
1700        } else {
1701            // clicked in "dead" space, unselect everything
1702            get_view().unselect_all();
1703        }
1704
1705        Gtk.Menu context_menu = get_context_menu();
1706        return popup_context_menu(context_menu, event);
1707    }
1708
1709    protected virtual bool on_mouse_over(CheckerboardItem? item, int x, int y, Gdk.ModifierType mask) {
1710        if (item != null)
1711            layout.handle_mouse_motion(item, x, y, mask);
1712
1713        // if hovering over the last hovered item, or both are null (nothing highlighted and
1714        // hovering over empty space), do nothing
1715        if (item == current_hovered_item)
1716            return true;
1717
1718        // either something new is highlighted or now hovering over empty space, so dim old item
1719        if (current_hovered_item != null) {
1720            current_hovered_item.handle_mouse_leave();
1721            current_hovered_item = null;
1722        }
1723
1724        // if over empty space, done
1725        if (item == null)
1726            return true;
1727
1728        // brighten the new item
1729        current_hovered_item = item;
1730        current_hovered_item.handle_mouse_enter();
1731
1732        return true;
1733    }
1734
1735    protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) {
1736        // report what item the mouse is hovering over
1737        if (!on_mouse_over(get_item_at_pixel(x, y), x, y, mask))
1738            return false;
1739
1740        // go no further if not drag-selecting
1741        if (!layout.is_drag_select_active())
1742            return false;
1743
1744        // set the new endpoint of the drag selection
1745        layout.set_drag_select_endpoint(x, y);
1746
1747        updated_selection_band();
1748
1749        // if out of bounds, schedule a check to auto-scroll the viewport
1750        if (!autoscroll_scheduled
1751            && get_adjustment_relation(get_vadjustment(), y) != AdjustmentRelation.IN_RANGE) {
1752            Timeout.add(AUTOSCROLL_TICKS_MSEC, selection_autoscroll);
1753            autoscroll_scheduled = true;
1754        }
1755
1756        // return true to stop a potential drag-and-drop operation
1757        return true;
1758    }
1759
1760    private void updated_selection_band() {
1761        assert(layout.is_drag_select_active());
1762
1763        // get all items inside the selection
1764        Gee.List<CheckerboardItem>? intersection = layout.items_in_selection_band();
1765        if (intersection == null)
1766            return;
1767
1768        Marker to_unselect = get_view().start_marking();
1769        Marker to_select = get_view().start_marking();
1770
1771        // mark all selected items to be unselected
1772        to_unselect.mark_many(get_view().get_selected());
1773
1774        // except for the items that were selected before the drag began
1775        assert(previously_selected != null);
1776        to_unselect.unmark_many(previously_selected);
1777        to_select.mark_many(previously_selected);
1778
1779        // toggle selection on everything in the intersection and update the cursor
1780        cursor = null;
1781
1782        foreach (CheckerboardItem item in intersection) {
1783            if (to_select.toggle(item))
1784                to_unselect.unmark(item);
1785            else
1786                to_unselect.mark(item);
1787
1788            if (cursor == null)
1789                cursor = item;
1790        }
1791
1792        get_view().select_marked(to_select);
1793        get_view().unselect_marked(to_unselect);
1794    }
1795
1796    private bool selection_autoscroll() {
1797        if (!layout.is_drag_select_active()) {
1798            autoscroll_scheduled = false;
1799
1800            return false;
1801        }
1802
1803        // as the viewport never scrolls horizontally, only interested in vertical
1804        Gtk.Adjustment vadj = get_vadjustment();
1805
1806        int x, y;
1807        Gdk.ModifierType mask;
1808        get_event_source_pointer(out x, out y, out mask);
1809
1810        int new_value = (int) vadj.get_value();
1811        switch (get_adjustment_relation(vadj, y)) {
1812            case AdjustmentRelation.BELOW:
1813                // pointer above window, scroll up
1814                new_value -= AUTOSCROLL_PIXELS;
1815                layout.set_drag_select_endpoint(x, new_value);
1816            break;
1817
1818            case AdjustmentRelation.ABOVE:
1819                // pointer below window, scroll down, extend selection to bottom of page
1820                new_value += AUTOSCROLL_PIXELS;
1821                layout.set_drag_select_endpoint(x, new_value + (int) vadj.get_page_size());
1822            break;
1823
1824            case AdjustmentRelation.IN_RANGE:
1825                autoscroll_scheduled = false;
1826
1827                return false;
1828
1829            default:
1830                warn_if_reached();
1831            break;
1832        }
1833
1834        // It appears that in GTK+ 2.18, the adjustment is not clamped the way it was in 2.16.
1835        // This may have to do with how adjustments are different w/ scrollbars, that they're upper
1836        // clamp is upper - page_size ... either way, enforce these limits here
1837        vadj.set_value(new_value.clamp((int) vadj.get_lower(),
1838            (int) vadj.get_upper() - (int) vadj.get_page_size()));
1839
1840        updated_selection_band();
1841
1842        return true;
1843    }
1844
1845    public void cursor_to_item(CheckerboardItem item) {
1846        assert(get_view().contains(item));
1847
1848        cursor = item;
1849
1850        if (!get_ctrl_pressed()) {
1851            get_view().unselect_all();
1852            Marker marker = get_view().mark(item);
1853            get_view().select_marked(marker);
1854        }
1855        layout.set_cursor(item);
1856
1857        // if item is in any way out of view, scroll to it
1858        Gtk.Adjustment vadj = get_vadjustment();
1859        if (get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE
1860            && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))
1861            return;
1862
1863        // scroll to see the new item
1864        int top = 0;
1865        if (item.allocation.y < vadj.get_value()) {
1866            top = item.allocation.y;
1867            top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2;
1868        } else {
1869            top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size();
1870            top += CheckerboardLayout.ROW_GUTTER_PADDING / 2;
1871        }
1872
1873        vadj.set_value(top);
1874    }
1875
1876    public void move_cursor(CompassPoint point) {
1877        // if no items, nothing to do
1878        if (get_view().get_count() == 0)
1879            return;
1880
1881        // if there is no better starting point, simply select the first and exit
1882        // The right half of the or is related to Bug #732334, the cursor might be non-null and still not contained in
1883        // the view, if the user dragged a full screen Photo off screen
1884        if (cursor == null && layout.get_cursor() == null || cursor != null && !get_view().contains(cursor)) {
1885            CheckerboardItem item = layout.get_item_at_coordinate(0, 0);
1886            cursor_to_item(item);
1887            anchor = item;
1888
1889            return;
1890        }
1891
1892        if (cursor == null) {
1893            cursor = layout.get_cursor() as CheckerboardItem;
1894        }
1895
1896        // move the cursor relative to the "first" item
1897        CheckerboardItem? item = layout.get_item_relative_to(cursor, point);
1898        if (item != null)
1899            cursor_to_item(item);
1900   }
1901
1902    public void set_cursor(CheckerboardItem item) {
1903        Marker marker = get_view().mark(item);
1904        get_view().select_marked(marker);
1905
1906        cursor = item;
1907        anchor = item;
1908   }
1909
1910    public void select_between_items(CheckerboardItem item_start, CheckerboardItem item_end) {
1911        Marker marker = get_view().start_marking();
1912
1913        bool passed_start = false;
1914        bool passed_end = false;
1915
1916        foreach (DataObject object in get_view().get_all()) {
1917            CheckerboardItem item = (CheckerboardItem) object;
1918
1919            if (item_start == item)
1920                passed_start = true;
1921
1922            if (item_end == item)
1923                passed_end = true;
1924
1925            if (passed_start || passed_end)
1926                marker.mark((DataView) object);
1927
1928            if (passed_start && passed_end)
1929                break;
1930        }
1931
1932        get_view().select_marked(marker);
1933    }
1934
1935    public void select_anchor_to_cursor(uint state) {
1936        if (cursor == null || anchor == null)
1937            return;
1938
1939        if (state == Gdk.ModifierType.SHIFT_MASK) {
1940            get_view().unselect_all();
1941            select_between_items(anchor, cursor);
1942        } else {
1943            anchor = cursor;
1944        }
1945    }
1946
1947    protected virtual void set_display_titles(bool display) {
1948        get_view().freeze_notifications();
1949        get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES, display);
1950        get_view().thaw_notifications();
1951    }
1952
1953    protected virtual void set_display_comments(bool display) {
1954        get_view().freeze_notifications();
1955        get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS, display);
1956        get_view().thaw_notifications();
1957    }
1958}
1959
1960public abstract class SinglePhotoPage : Page {
1961    public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST;
1962    public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR;
1963    public const int KEY_REPEAT_INTERVAL_MSEC = 200;
1964
1965    public enum UpdateReason {
1966        NEW_PIXBUF,
1967        QUALITY_IMPROVEMENT,
1968        RESIZED_CANVAS
1969    }
1970
1971    protected Gtk.DrawingArea canvas = new Gtk.DrawingArea();
1972    protected Gtk.Viewport viewport = new Gtk.Viewport(null, null);
1973
1974    private bool scale_up_to_viewport;
1975    private TransitionClock transition_clock;
1976    private int transition_duration_msec = 0;
1977    private Cairo.Surface pixmap = null;
1978    private Cairo.Context pixmap_ctx = null;
1979    private Cairo.Context text_ctx = null;
1980    private Dimensions pixmap_dim = Dimensions();
1981    private Gdk.Pixbuf unscaled = null;
1982    private Dimensions max_dim = Dimensions();
1983    private Gdk.Pixbuf scaled = null;
1984    private Gdk.Pixbuf old_scaled = null; // previous scaled image
1985    private Gdk.Rectangle scaled_pos = Gdk.Rectangle();
1986    private ZoomState static_zoom_state;
1987    private bool zoom_high_quality = true;
1988    private ZoomState saved_zoom_state;
1989    private bool has_saved_zoom_state = false;
1990    private uint32 last_nav_key = 0;
1991
1992    protected SinglePhotoPage(string page_name, bool scale_up_to_viewport) {
1993        base(page_name);
1994
1995        this.scale_up_to_viewport = scale_up_to_viewport;
1996
1997        transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
1998
1999        // With the current code automatically resizing the image to the viewport, scrollbars
2000        // should never be shown, but this may change if/when zooming is supported
2001        set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
2002
2003        set_border_width(0);
2004        set_shadow_type(Gtk.ShadowType.NONE);
2005
2006        viewport.set_shadow_type(Gtk.ShadowType.NONE);
2007        viewport.set_border_width(0);
2008        viewport.add(canvas);
2009
2010        add(viewport);
2011
2012        canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK
2013            | Gdk.EventMask.SUBSTRUCTURE_MASK);
2014
2015        viewport.size_allocate.connect(on_viewport_resize);
2016        canvas.draw.connect(on_canvas_exposed);
2017
2018        set_event_source(canvas);
2019        Config.Facade.get_instance().colors_changed.connect(on_colors_changed);
2020    }
2021
2022    ~SinglePhotoPage() {
2023        Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed);
2024    }
2025
2026    public bool is_transition_in_progress() {
2027        return transition_clock.is_in_progress();
2028    }
2029
2030    public void cancel_transition() {
2031        if (transition_clock.is_in_progress())
2032            transition_clock.cancel();
2033    }
2034
2035    public void set_transition(string effect_id, int duration_msec) {
2036        cancel_transition();
2037
2038        transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id);
2039        if (transition_clock == null)
2040            transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
2041
2042        transition_duration_msec = duration_msec;
2043    }
2044
2045    // This method includes a call to pixmap_ctx.paint().
2046    private void render_zoomed_to_pixmap(ZoomState zoom_state) {
2047        assert(is_zoom_supported());
2048
2049        Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
2050
2051        Gdk.Pixbuf zoomed;
2052        if (get_zoom_buffer() != null) {
2053            zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) :
2054                get_zoom_buffer().get_zoom_preview_image(zoom_state);
2055        } else {
2056            Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled);
2057
2058            Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x,
2059                view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);
2060
2061            zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
2062                Gdk.InterpType.BILINEAR);
2063        }
2064
2065        if (zoomed == null) {
2066            return;
2067        }
2068
2069        int draw_x = (pixmap_dim.width - view_rect.width) / 2;
2070        draw_x = draw_x.clamp(0, int.MAX);
2071
2072        int draw_y = (pixmap_dim.height - view_rect.height) / 2;
2073        draw_y = draw_y.clamp(0, int.MAX);
2074        paint_pixmap_with_background(pixmap_ctx, zoomed, draw_x, draw_y);
2075    }
2076
2077    protected void on_interactive_zoom(ZoomState interactive_zoom_state) {
2078        assert(is_zoom_supported());
2079
2080        set_source_color_from_string(pixmap_ctx, "#000");
2081        pixmap_ctx.paint();
2082
2083        bool old_quality_setting = zoom_high_quality;
2084        zoom_high_quality = false;
2085        render_zoomed_to_pixmap(interactive_zoom_state);
2086        zoom_high_quality = old_quality_setting;
2087
2088        canvas.queue_draw();
2089    }
2090
2091    protected void on_interactive_pan(ZoomState interactive_zoom_state) {
2092        assert(is_zoom_supported());
2093
2094        set_source_color_from_string(pixmap_ctx, "#000");
2095        pixmap_ctx.paint();
2096
2097        bool old_quality_setting = zoom_high_quality;
2098        zoom_high_quality = true;
2099        render_zoomed_to_pixmap(interactive_zoom_state);
2100        zoom_high_quality = old_quality_setting;
2101
2102        canvas.queue_draw();
2103    }
2104
2105    protected virtual bool is_zoom_supported() {
2106        return false;
2107    }
2108
2109    protected virtual void cancel_zoom() {
2110        if (pixmap != null) {
2111            set_source_color_from_string(pixmap_ctx, "#000");
2112            pixmap_ctx.paint();
2113        }
2114    }
2115
2116    protected virtual void save_zoom_state() {
2117        saved_zoom_state = static_zoom_state;
2118        has_saved_zoom_state = true;
2119    }
2120
2121    protected virtual void restore_zoom_state() {
2122        if (!has_saved_zoom_state)
2123            return;
2124
2125        static_zoom_state = saved_zoom_state;
2126        repaint();
2127        has_saved_zoom_state = false;
2128    }
2129
2130    protected virtual ZoomBuffer? get_zoom_buffer() {
2131        return null;
2132    }
2133
2134    protected ZoomState get_saved_zoom_state() {
2135        return saved_zoom_state;
2136    }
2137
2138    protected void set_zoom_state(ZoomState zoom_state) {
2139        assert(is_zoom_supported());
2140
2141        static_zoom_state = zoom_state;
2142    }
2143
2144    protected ZoomState get_zoom_state() {
2145        assert(is_zoom_supported());
2146
2147        return static_zoom_state;
2148    }
2149
2150    public override void switched_to() {
2151        base.switched_to();
2152
2153        if (unscaled != null)
2154            repaint();
2155    }
2156
2157    public override void set_container(Gtk.Window container) {
2158        base.set_container(container);
2159
2160        // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift
2161        // off the screen
2162        if (container is FullscreenWindow)
2163            set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
2164    }
2165
2166    // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and
2167    // the caller capable of producing larger ones depending on the viewport size).  max_dim
2168    // is used when scale_up_to_viewport is set to true.  Pass a Dimensions with no area if
2169    // max_dim should be ignored (i.e. scale_up_to_viewport is false).
2170    public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) {
2171        static_zoom_state = ZoomState(max_dim, pixmap_dim,
2172            static_zoom_state.get_interpolation_factor(),
2173            static_zoom_state.get_viewport_center());
2174
2175        cancel_transition();
2176
2177        this.unscaled = unscaled;
2178        this.max_dim = max_dim;
2179        this.old_scaled = scaled;
2180        scaled = null;
2181
2182        // need to make sure this has happened
2183        canvas.realize();
2184
2185        repaint(direction);
2186    }
2187
2188    public void blank_display() {
2189        unscaled = null;
2190        max_dim = Dimensions();
2191        scaled = null;
2192        pixmap = null;
2193
2194        // this has to have happened
2195        canvas.realize();
2196
2197        // force a redraw
2198        invalidate_all();
2199    }
2200
2201    public Cairo.Surface? get_surface() {
2202        return pixmap;
2203    }
2204
2205    public Dimensions get_surface_dim() {
2206        return pixmap_dim;
2207    }
2208
2209    public Cairo.Context get_cairo_context() {
2210        return pixmap_ctx;
2211    }
2212
2213    public void paint_text(Pango.Layout pango_layout, int x, int y) {
2214        text_ctx.move_to(x, y);
2215        Pango.cairo_show_layout(text_ctx, pango_layout);
2216    }
2217
2218    public Scaling get_canvas_scaling() {
2219        return (get_container() is FullscreenWindow) ? Scaling.for_screen(AppWindow.get_instance(), scale_up_to_viewport)
2220            : Scaling.for_widget(viewport, scale_up_to_viewport);
2221    }
2222
2223    public Gdk.Pixbuf? get_unscaled_pixbuf() {
2224        return unscaled;
2225    }
2226
2227    public Gdk.Pixbuf? get_scaled_pixbuf() {
2228        return scaled;
2229    }
2230
2231    // Returns a rectangle describing the pixbuf in relation to the canvas
2232    public Gdk.Rectangle get_scaled_pixbuf_position() {
2233        return scaled_pos;
2234    }
2235
2236    public bool is_inside_pixbuf(int x, int y) {
2237        return coord_in_rectangle(x, y, scaled_pos);
2238    }
2239
2240    public void invalidate(Gdk.Rectangle rect) {
2241        if (canvas.get_window() != null)
2242            canvas.get_window().invalidate_rect(rect, false);
2243    }
2244
2245    public void invalidate_all() {
2246        if (canvas.get_window() != null)
2247            canvas.get_window().invalidate_rect(null, false);
2248    }
2249
2250    private void on_viewport_resize() {
2251        // do fast repaints while resizing
2252        internal_repaint(true, null);
2253    }
2254
2255    protected override void on_resize_finished(Gdk.Rectangle rect) {
2256        base.on_resize_finished(rect);
2257
2258        // when the resize is completed, do a high-quality repaint
2259        repaint();
2260    }
2261
2262    private bool on_canvas_exposed(Cairo.Context exposed_ctx) {
2263        // draw pixmap onto canvas unless it's not been instantiated, in which case draw black
2264        // (so either old image or contents of another page is not left on screen)
2265        if (pixmap != null)
2266            exposed_ctx.set_source_surface(pixmap, 0, 0);
2267        else
2268            set_source_color_from_string(exposed_ctx, "#000");
2269
2270        exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height());
2271        exposed_ctx.paint();
2272
2273        return true;
2274    }
2275
2276    protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) {
2277    }
2278
2279    protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) {
2280    }
2281
2282    protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) {
2283        if (is_zoom_supported() && (!static_zoom_state.is_default())) {
2284            set_source_color_from_string(ctx, "#000");
2285            ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
2286            ctx.fill();
2287
2288            render_zoomed_to_pixmap(static_zoom_state);
2289        } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) {
2290            // transition is not running, so paint the full image on a black background
2291            set_source_color_from_string(ctx, "#000");
2292
2293            ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
2294            ctx.fill();
2295
2296            paint_pixmap_with_background(ctx, scaled, scaled_pos.x, scaled_pos.y);
2297        }
2298    }
2299
2300    private void repaint_pixmap() {
2301        if (pixmap_ctx == null)
2302            return;
2303
2304        paint(pixmap_ctx, pixmap_dim);
2305        invalidate_all();
2306    }
2307
2308    public void repaint(Direction? direction = null) {
2309        internal_repaint(false, direction);
2310    }
2311
2312    private void internal_repaint(bool fast, Direction? direction) {
2313        // if not in view, assume a full repaint needed in future but do nothing more
2314        if (!is_in_view()) {
2315            pixmap = null;
2316            scaled = null;
2317
2318            return;
2319        }
2320
2321        // no image or window, no painting
2322        if (unscaled == null || canvas.get_window() == null)
2323            return;
2324
2325        Gtk.Allocation allocation;
2326        viewport.get_allocation(out allocation);
2327
2328        int width = allocation.width;
2329        int height = allocation.height;
2330
2331        if (width <= 0 || height <= 0)
2332            return;
2333
2334        bool new_pixbuf = (scaled == null);
2335
2336        // save if reporting an image being rescaled
2337        Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos);
2338        Gdk.Rectangle old_scaled_pos = scaled_pos;
2339
2340        // attempt to reuse pixmap
2341        if (pixmap_dim.width != width || pixmap_dim.height != height)
2342            pixmap = null;
2343
2344        // if necessary, create a pixmap as large as the entire viewport
2345        bool new_pixmap = false;
2346        if (pixmap == null) {
2347            init_pixmap(width, height);
2348            new_pixmap = true;
2349        }
2350
2351        if (new_pixbuf || new_pixmap) {
2352            Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled);
2353
2354            // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up,
2355            // respect it
2356            Dimensions scaled_dim = Dimensions();
2357            if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < height)
2358                scaled_dim = max_dim;
2359            else
2360                scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim);
2361
2362            assert(width >= scaled_dim.width);
2363            assert(height >= scaled_dim.height);
2364
2365            // center pixbuf on the canvas
2366            scaled_pos.x = (width - scaled_dim.width) / 2;
2367            scaled_pos.y = (height - scaled_dim.height) / 2;
2368            scaled_pos.width = scaled_dim.width;
2369            scaled_pos.height = scaled_dim.height;
2370        }
2371
2372        Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP;
2373
2374        // rescale if canvas rescaled or better quality is requested
2375        if (scaled == null) {
2376            scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp);
2377
2378            UpdateReason reason = UpdateReason.RESIZED_CANVAS;
2379            if (new_pixbuf)
2380                reason = UpdateReason.NEW_PIXBUF;
2381            else if (!new_pixmap && interp == QUALITY_INTERP)
2382                reason = UpdateReason.QUALITY_IMPROVEMENT;
2383
2384            static_zoom_state = ZoomState(max_dim, pixmap_dim,
2385                static_zoom_state.get_interpolation_factor(),
2386                static_zoom_state.get_viewport_center());
2387
2388            updated_pixbuf(scaled, reason, old_scaled_dim);
2389        }
2390
2391        zoom_high_quality = !fast;
2392
2393        if (direction != null && !transition_clock.is_in_progress()) {
2394            Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled,
2395                old_scaled_pos, scaled, scaled_pos, parse_color("#000"));
2396
2397            transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec,
2398                repaint_pixmap);
2399        }
2400
2401        if (!transition_clock.is_in_progress())
2402            repaint_pixmap();
2403    }
2404
2405    private void init_pixmap(int width, int height) {
2406        assert(unscaled != null);
2407        assert(canvas.get_window() != null);
2408
2409        // Cairo backing surface (manual double-buffering)
2410        pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height);
2411        pixmap_dim = Dimensions(width, height);
2412
2413        // Cairo context for drawing on the pixmap
2414        pixmap_ctx = new Cairo.Context(pixmap);
2415
2416        // need a new pixbuf to fit this scale
2417        scaled = null;
2418
2419        // Cairo context for drawing text on the pixmap
2420        text_ctx = new Cairo.Context(pixmap);
2421        set_source_color_from_string(text_ctx, "#fff");
2422
2423
2424        // no need to resize canvas, viewport does that automatically
2425
2426        new_surface(pixmap_ctx, pixmap_dim);
2427    }
2428
2429    protected override bool on_context_keypress() {
2430        return popup_context_menu(get_page_context_menu());
2431    }
2432
2433    protected virtual void on_previous_photo() {
2434    }
2435
2436    protected virtual void on_next_photo() {
2437    }
2438
2439    public override bool key_press_event(Gdk.EventKey event) {
2440        // if the user holds the arrow keys down, we will receive a steady stream of key press
2441        // events for an operation that isn't designed for a rapid succession of output ...
2442        // we staunch the supply of new photos to under a quarter second (#533)
2443        bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC;
2444
2445        bool handled = true;
2446        switch (Gdk.keyval_name(event.keyval)) {
2447            case "Left":
2448            case "KP_Left":
2449            case "BackSpace":
2450                if (nav_ok) {
2451                    on_previous_photo();
2452                    last_nav_key = event.time;
2453                }
2454            break;
2455
2456            case "Right":
2457            case "KP_Right":
2458            case "space":
2459                if (nav_ok) {
2460                    on_next_photo();
2461                    last_nav_key = event.time;
2462                }
2463            break;
2464
2465            default:
2466                handled = false;
2467            break;
2468        }
2469
2470        if (handled)
2471            return true;
2472
2473        return (base.key_press_event != null) ? base.key_press_event(event) : true;
2474    }
2475
2476    private void on_colors_changed() {
2477        invalidate_transparent_background();
2478        repaint();
2479    }
2480}
2481
2482//
2483// DragAndDropHandler attaches signals to a Page to properly handle drag-and-drop requests for the
2484// Page as a DnD Source.  (DnD Destination handling is handled by the appropriate AppWindow, i.e.
2485// LibraryWindow and DirectWindow). Assumes the Page's ViewCollection holds MediaSources.
2486//
2487public class DragAndDropHandler {
2488    private enum TargetType {
2489        XDS,
2490        MEDIA_LIST
2491    }
2492
2493    private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = {
2494        { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS },
2495        { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST }
2496    };
2497
2498    private static Gdk.Atom? XDS_ATOM = null;
2499    private static Gdk.Atom? TEXT_ATOM = null;
2500    private static uint8[]? XDS_FAKE_TARGET = null;
2501
2502    private weak Page page;
2503    private Gtk.Widget event_source;
2504    private File? drag_destination = null;
2505    private ExporterUI exporter = null;
2506
2507    public DragAndDropHandler(Page page) {
2508        this.page = page;
2509        this.event_source = page.get_event_source();
2510        assert(event_source != null);
2511        assert(event_source.get_has_window());
2512
2513        // Need to do this because static member variables are not properly handled
2514        if (XDS_ATOM == null)
2515            XDS_ATOM = Gdk.Atom.intern_static_string("XdndDirectSave0");
2516
2517        if (TEXT_ATOM == null)
2518            TEXT_ATOM = Gdk.Atom.intern_static_string("text/plain");
2519
2520        if (XDS_FAKE_TARGET == null)
2521            XDS_FAKE_TARGET = string_to_uchar_array("shotwell.txt");
2522
2523        // register what's available on this DnD Source
2524        Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES,
2525            Gdk.DragAction.COPY);
2526
2527        // attach to the event source's DnD signals, not the Page's, which is a NO_WINDOW widget
2528        // and does not emit them
2529        event_source.drag_begin.connect(on_drag_begin);
2530        event_source.drag_data_get.connect(on_drag_data_get);
2531        event_source.drag_end.connect(on_drag_end);
2532        event_source.drag_failed.connect(on_drag_failed);
2533    }
2534
2535    ~DragAndDropHandler() {
2536        if (event_source != null) {
2537            event_source.drag_begin.disconnect(on_drag_begin);
2538            event_source.drag_data_get.disconnect(on_drag_data_get);
2539            event_source.drag_end.disconnect(on_drag_end);
2540            event_source.drag_failed.disconnect(on_drag_failed);
2541        }
2542
2543        page = null;
2544        event_source = null;
2545    }
2546
2547    private void on_drag_begin(Gdk.DragContext context) {
2548        debug("on_drag_begin (%s)", page.get_page_name());
2549
2550        if (page == null || page.get_view().get_selected_count() == 0 || exporter != null)
2551            return;
2552
2553        drag_destination = null;
2554
2555        // use the first media item as the icon
2556        ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source();
2557
2558        try {
2559            Gdk.Pixbuf icon = thumb.get_thumbnail(AppWindow.DND_ICON_SCALE);
2560            Gtk.drag_source_set_icon_pixbuf(event_source, icon);
2561        } catch (Error err) {
2562            warning("Unable to fetch icon for drag-and-drop from %s: %s", thumb.to_string(),
2563                err.message);
2564        }
2565
2566        // set the XDS property to indicate an XDS save is available
2567#if VALA_0_20
2568        Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
2569            XDS_FAKE_TARGET, 1);
2570#else
2571        Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
2572            XDS_FAKE_TARGET);
2573#endif
2574    }
2575
2576    private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
2577        uint target_type, uint time) {
2578        debug("on_drag_data_get (%s)", page.get_page_name());
2579
2580        if (page == null || page.get_view().get_selected_count() == 0)
2581            return;
2582
2583        switch (target_type) {
2584            case TargetType.XDS:
2585                // Fetch the XDS property that has been set with the destination path
2586                uchar[] data = new uchar[4096];
2587                Gdk.Atom actual_type;
2588                int actual_format = 0;
2589                bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM,
2590                    0, data.length, 0, out actual_type, out actual_format, out data);
2591
2592                // the destination path is actually for our XDS_FAKE_TARGET, use its parent
2593                // to determine where the file(s) should go
2594                if (fetched && data != null && data.length > 0)
2595                    drag_destination = File.new_for_uri(uchar_array_to_string(data)).get_parent();
2596
2597                debug("on_drag_data_get (%s): %s", page.get_page_name(),
2598                    (drag_destination != null) ? drag_destination.get_path() : "(no path)");
2599
2600                // Set the property to "S" for Success or "E" for Error
2601                selection_data.set(XDS_ATOM, 8,
2602                    string_to_uchar_array((drag_destination != null) ? "S" : "E"));
2603            break;
2604
2605            case TargetType.MEDIA_LIST:
2606                Gee.Collection<MediaSource> sources =
2607                    (Gee.Collection<MediaSource>) page.get_view().get_selected_sources();
2608
2609                // convert the selected media sources to Gdk.Atom-encoded sourceID strings for
2610                // internal drag-and-drop
2611                selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom),
2612                    serialize_media_sources(sources));
2613            break;
2614
2615            default:
2616                warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(),
2617                    target_type);
2618            break;
2619        }
2620    }
2621
2622    private void on_drag_end() {
2623        debug("on_drag_end (%s)", page.get_page_name());
2624
2625        if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null
2626            || exporter != null) {
2627            return;
2628        }
2629
2630        debug("Exporting to %s", drag_destination.get_path());
2631
2632        // drag-and-drop export doesn't pop up an export dialog, so use what are likely the
2633        // most common export settings (the current -- or "working" -- file format, with
2634        // all transformations applied, at the image's original size).
2635        if (drag_destination.get_path() != null) {
2636            exporter = new ExporterUI(new Exporter(
2637                (Gee.Collection<Photo>) page.get_view().get_selected_sources(),
2638                drag_destination, Scaling.for_original(), ExportFormatParameters.current()));
2639            exporter.export(on_export_completed);
2640        } else {
2641            AppWindow.error_message(_("Photos cannot be exported to this directory."));
2642        }
2643
2644        drag_destination = null;
2645    }
2646
2647    private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
2648        debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result);
2649
2650        if (page == null)
2651            return false;
2652
2653        drag_destination = null;
2654
2655        return false;
2656    }
2657
2658    private void on_export_completed() {
2659        exporter = null;
2660    }
2661
2662}
2663