1/**
2 * Presentater window
3 *
4 * This file is part of pdfpc.
5 *
6 * Copyright (C) 2010-2011 Jakob Westhoff <jakob@westhoffswelt.de>
7 * Copyright 2012 David Vilar
8 * Copyright 2012, 2015 Robert Schroll
9 * Copyright 2015 Andreas Bilke
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License as published by
13 * the Free Software Foundation; either version 3 of the License, or
14 * (at your option) any later version.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19 * GNU General Public License for more details.
20 *
21 * You should have received a copy of the GNU General Public License along
22 * with this program; if not, write to the Free Software Foundation, Inc.,
23 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
24 */
25
26namespace pdfpc.Window {
27    /**
28     * An overview of all the slides in the form of a table
29     */
30    public class Overview: Gtk.ScrolledWindow {
31
32        /*
33         * The store of all the slides.
34         */
35        protected Gtk.ListStore slides;
36        /*
37         * The view of the above.
38         */
39        protected Gtk.IconView slides_view;
40
41        /**
42         * Metadata of the slides
43         */
44        protected Metadata.Pdf metadata {
45            get {
46                return this.controller.metadata;
47            }
48        }
49
50        /**
51         * How many (user) slides we have.
52         */
53        protected int n_slides = 0;
54
55        /**
56         * The target height and width of the scaled images, a bit smaller than
57         * the button dimensions to allow some margin
58         */
59        protected int target_width;
60        protected int target_height;
61
62        /**
63         * The presentation controller
64         */
65        protected PresentationController controller;
66
67        /**
68         * The maximal size of the slides_view.
69         */
70        protected int max_width = -1;
71        protected int max_height = -1;
72
73        /**
74         * The currently selected slide.
75         */
76        private int _current_slide = 0;
77        public int current_slide {
78            get { return _current_slide; }
79            set { var path = new Gtk.TreePath.from_indices(value);
80                  this.slides_view.select_path(path);
81                  // _current_slide set in on_selection_changed, below
82                  this.slides_view.set_cursor(path, null, false);
83                }
84        }
85
86        /**
87         * The used cell renderer, for later usage
88         */
89        private CellRendererHighlight cell_renderer;
90
91        /*
92         * When the section changes, we need to update the current slide number.
93         * Also, make sure we don't end up with no selection.
94         */
95        public void on_selection_changed() {
96            var ltp = this.slides_view.get_selected_items();
97            if (ltp != null) {
98                var tp = ltp.data;
99                if (tp.get_indices() != null) {  // Seg fault if we save tp.get_indices locally
100                    this._current_slide = tp.get_indices()[0];
101                    return;
102                }
103            }
104            // If there's no selection, reset the old one
105            this.current_slide = this._current_slide;
106        }
107
108        /**
109         * Constructor
110         */
111        public Overview(PresentationController controller) {
112            this.controller = controller;
113
114            this.get_style_context().add_class("overviewWindow");
115
116            this.slides = new Gtk.ListStore(1, typeof(int));
117
118            this.slides_view = new Gtk.IconView.with_model(this.slides);
119            this.slides_view.selection_mode = Gtk.SelectionMode.SINGLE;
120            this.slides_view.halign = Gtk.Align.CENTER;
121
122            this.cell_renderer = new CellRendererHighlight();
123            this.cell_renderer.metadata = metadata;
124
125            Gtk.StyleContext style_context = this.get_style_context();
126            Pango.FontDescription font_description;
127            style_context.get(style_context.get_state(), "font", out font_description, null);
128            this.cell_renderer.font_description = font_description;
129
130            this.slides_view.pack_start(cell_renderer, true);
131            this.slides_view.add_attribute(cell_renderer, "slide_id", 0);
132            this.slides_view.set_item_padding(0);
133            this.add(this.slides_view);
134
135            this.slides_view.motion_notify_event.connect(this.on_mouse_move);
136            this.slides_view.button_release_event.connect(this.on_mouse_release);
137            this.slides_view.key_press_event.connect(this.on_key_press);
138            this.slides_view.selection_changed.connect(this.on_selection_changed);
139            this.key_press_event.connect((event) => this.slides_view.key_press_event(event));
140        }
141
142        public void set_available_space(int width, int height) {
143            this.max_width = width;
144            this.max_height = height;
145            this.prepare_layout();
146        }
147
148        /**
149         * Get keyboard focus.  This requires that the window has focus.
150         */
151        public void ensure_focus() {
152            Gtk.Window top = this.get_toplevel() as Gtk.Window;
153            if (top != null && !top.has_toplevel_focus) {
154                top.present();
155            }
156            this.slides_view.grab_focus();
157        }
158
159        /**
160         * Figure out the sizes for the icons, and create entries in slides
161         * for all the slides.
162         */
163        protected void prepare_layout() {
164            if (!this.metadata.is_ready) {
165                return;
166            }
167            if (this.max_width == -1) {
168                return;
169            }
170
171            double aspect_ratio = this.metadata.get_page_width() / this.metadata.get_page_height();
172
173            this.slides_view.set_margin(0);
174
175            var margin = this.slides_view.get_margin();
176            var padding = this.slides_view.get_item_padding() + 1; // Additional mystery pixel
177            var row_spacing = this.slides_view.get_row_spacing();
178            var col_spacing = this.slides_view.get_column_spacing();
179
180            var eff_max_width = this.max_width - 2 * margin;
181            var eff_max_height = this.max_height - 2 * margin;
182            int cols = eff_max_width / (Options.min_overview_width + 2 * padding + col_spacing);
183            int widthx, widthy, min_width, rows;
184            int tc = 0;
185
186            // Search for the layout with the widest icons.  We do this by considering
187            // layouts with different numbers of columns, and figuring the maximum
188            // width for the icon so that all the icons fit both horizontally and
189            // vertically.  We start with the largest number of columns that fit the
190            // icons at the minimum allowed width, and we decrease the number of columns
191            // until we cannot fit the icons vertically at the minimal allowed size.
192            // Note that there may be NO solution, in which case target_width == 0.
193            this.target_width = 0;
194            while (cols > 0) {
195                widthx = eff_max_width / cols - 2*padding - 2*col_spacing;
196                rows = (int)Math.ceil((float)this.n_slides / cols);
197                widthy = (int)Math.floor((eff_max_height / rows - 2*padding - 2*row_spacing)
198                                         * aspect_ratio);  // floor so that later round
199                                                           // doesn't increase height
200                if (widthy < Options.min_overview_width) {
201                    break;
202                }
203
204                min_width = widthx < widthy ? widthx : widthy;
205                if (min_width >= this.target_width) {  // If two layouts give the same width
206                    this.target_width = min_width;     // (which happens when they're limited
207                    tc = cols;                         // by height), prefer the one with fewer
208                }                                      // columns for a more filled block.
209                cols -= 1;
210            }
211            if (this.target_width < Options.min_overview_width) {
212                this.target_width = Options.min_overview_width;
213                this.slides_view.columns = (eff_max_width - 20) // Guess for scrollbar width
214                    / (Options.min_overview_width + 2 * padding + col_spacing);
215            } else {
216                this.slides_view.columns = tc;
217            }
218            this.set_policy(Gtk.PolicyType.ALWAYS, Gtk.PolicyType.ALWAYS);
219            this.target_height = (int)Math.round(this.target_width / aspect_ratio);
220            rows = (int)Math.ceil((float)this.n_slides / this.slides_view.columns);
221            int full_height = rows*(this.target_height + 2*padding + 2*row_spacing) + 2*margin;
222            if (full_height > this.max_height) {
223                full_height = this.max_height;
224            }
225
226            this.cell_renderer.slide_width = this.target_width;
227            this.cell_renderer.slide_height = this.target_height;
228
229            this.slides.clear();
230            Gtk.TreeIter iter;
231            for (int i = 0; i < this.n_slides; i++) {
232                this.slides.append(out iter);
233                this.slides.set_value(iter, 0, i);
234            }
235        }
236
237        /**
238         * Set the number of slides. If it is different to what we know, it
239         * triggers a rebuilding of the widget.
240         */
241        public void set_n_slides(int n) {
242            if (n != this.n_slides) {
243                var currently_selected = this.current_slide;
244                this.n_slides = n;
245                this.prepare_layout();
246                if (currently_selected >= this.n_slides) {
247                    currently_selected = this.n_slides - 1;
248                }
249                this.current_slide = currently_selected;
250            }
251        }
252
253        /**
254         * Remove the current slide from the overview, and set the total number
255         * of slides to the new value.  Perpare to regenerate the structure the
256         * next time the overview is hidden.
257         */
258        public void remove_current(int newn) {
259            this.n_slides = newn;
260            Gtk.TreeIter iter;
261            this.slides.get_iter_from_string(out iter, @"$(this.current_slide)");
262#if VALA_0_36
263            // Updated bindings in Vala 0.36: "iter" param of ListStore.remove() marked as ref
264            this.slides.remove(ref iter);
265#else
266            this.slides.remove(iter);
267#endif
268            if (this.current_slide >= this.n_slides) {
269                this.current_slide = this.n_slides - 1;
270            }
271        }
272
273        /**
274         * We handle some "navigation" key presses ourselves. Others are left to
275         * the standard IconView controls, the rest are passed back to the
276         * PresentationController.
277         */
278        public bool on_key_press(Gtk.Widget source, Gdk.EventKey key) {
279            bool handled = false;
280            switch (key.keyval) {
281                case Gdk.Key.Left:
282                case Gdk.Key.Page_Up:
283                    if (this.current_slide > 0) {
284                        this.current_slide -= 1;
285                    }
286                    handled = true;
287                    break;
288                case Gdk.Key.Right:
289                case Gdk.Key.Page_Down:
290                    if (this.current_slide < this.n_slides - 1) {
291                        this.current_slide += 1;
292                    }
293                    handled = true;
294                    break;
295                case Gdk.Key.Return:
296                    bool gotoFirst = (key.state & Gdk.ModifierType.SHIFT_MASK) != 0;
297                    this.controller.goto_user_page(this.current_slide, !gotoFirst);
298                    handled = true;
299                    break;
300                case Gdk.Key.Escape:
301                    this.controller.controllables_hide_overview();
302                    handled = true;
303                    break;
304            }
305
306            return handled;
307        }
308
309        /*
310         * Update the selection when the mouse moves over a new slides.
311         */
312        public bool on_mouse_move(Gtk.Widget source, Gdk.EventMotion event) {
313            Gtk.TreePath path;
314            path = this.slides_view.get_path_at_pos((int)event.x, (int)event.y);
315            if (path != null && path.get_indices()[0] != this.current_slide) {
316                this.current_slide = path.get_indices()[0];
317            }
318            return false;
319        }
320
321        /*
322         * Go to selected slide when the mouse button is released.  On a simple
323         * click, the button_press event will have set the current slide.  On
324         * a drag, the current slide will have been updated by the motion.
325         */
326        public bool on_mouse_release(Gdk.EventButton event) {
327            if (event.button == 1) {
328                bool gotoFirst = (event.state & Gdk.ModifierType.SHIFT_MASK) != 0;
329                this.controller.goto_user_page(this.current_slide, !gotoFirst);
330            }
331            return false;
332        }
333    }
334
335    /*
336     * Render a surface that is slightly shaded, unless it is the selected one.
337     */
338    class CellRendererHighlight : Gtk.CellRenderer {
339        protected int slide_id { get; set; }
340
341        public Pango.FontDescription font_description { get; set; }
342        public Metadata.Pdf metadata { get; set; }
343        public int slide_width { get; set; }
344        public int slide_height { get; set; }
345
346        public override void get_size(Gtk.Widget widget, Gdk.Rectangle? cell_area,
347                                      out int x_offset, out int y_offset,
348                                      out int width, out int height) {
349            x_offset = 0;
350            y_offset = 0;
351            width = this.slide_width;
352            height = this.slide_height;
353        }
354
355        public override void render(Cairo.Context cr, Gtk.Widget widget,
356                                    Gdk.Rectangle background_area, Gdk.Rectangle cell_area,
357                                    Gtk.CellRendererState flags) {
358            var renderer = this.metadata.renderer;
359            Cairo.ImageSurface slide_to_fill = null;
360
361            slide_to_fill = null;
362            try {
363                var real_slide_id =
364                    metadata.user_slide_to_real_slide(this.slide_id, true);
365                slide_to_fill = renderer.render(real_slide_id,
366                    false, slide_width, slide_height,
367                    true, true);
368            } catch (Renderer.RenderError e) {
369                ;
370            }
371            // nothing to show
372            if (slide_to_fill == null) {
373                cr.set_source_rgba(0.5, 0.5, 0.5, 1);
374                cr.rectangle(cell_area.x, cell_area.y, cell_area.width, cell_area.height);
375                cr.fill();
376            } else {
377                double scale_factor = (double)slide_width/slide_to_fill.get_width();
378                cr.scale(scale_factor, scale_factor);
379                cr.set_source_surface(slide_to_fill, (double)cell_area.x/scale_factor, (double)cell_area.y/scale_factor);
380                cr.paint();
381                cr.scale(1.0/scale_factor, 1.0/scale_factor);
382            }
383
384            if ((flags & Gtk.CellRendererState.SELECTED) == 0) {
385                cr.rectangle(cell_area.x, cell_area.y, cell_area.width, cell_area.height);
386                cr.set_source_rgba(0, 0, 0, 0.4);
387                cr.fill();
388            }
389
390            // draw slide number
391            var layout = Pango.cairo_create_layout(cr);
392            layout.set_font_description(this.font_description);
393            layout.set_text(@"$(slide_id + 1)", -1);
394            layout.set_width(cell_area.width);
395            layout.set_alignment(Pango.Alignment.CENTER);
396
397            Pango.Rectangle logical_extent;
398            layout.get_pixel_extents(null, out logical_extent);
399            cr.move_to(cell_area.x + (cell_area.width / 2), cell_area.y + (cell_area.height / 2) - (logical_extent.height / 2));
400
401            if ((flags & Gtk.CellRendererState.SELECTED) == 0) {
402                cr.set_source_rgba(0.7, 0.7, 0.7, 0.7);
403            } else {
404                cr.set_source_rgba(0.7, 0.7, 0.7, 0.2);
405            }
406
407            Pango.cairo_show_layout(cr, layout);
408        }
409    }
410}
411