Source code for pympress.dialog

# -*- coding: utf-8 -*-
#
#       extras.py
#
#       Copyright 2021 Cimbali <me@cimba.li>
#
#       This program is free software; you can redistribute it and/or modify
#       it under the terms of the GNU General Public License as published by
#       the Free Software Foundation; either version 2 of the License, or
#       (at your option) any later version.
#
#       This program is distributed in the hope that it will be useful,
#       but WITHOUT ANY WARRANTY; without even the implied warranty of
#       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#       GNU General Public License for more details.
#
#       You should have received a copy of the GNU General Public License
#       along with this program; if not, write to the Free Software
#       Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#       MA 02110-1301, USA.
"""
:mod:`pympress.extras` -- Manages the display of fancy extras such as annotations, videos and cursors
-----------------------------------------------------------------------------------------------------
"""

import logging
logger = logging.getLogger(__name__)

import sys
import copy
import itertools

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib

from pympress import builder


[docs] class TimingReport(builder.Builder): """ Widget tracking and displaying hierachically how much time was spent in each page/section of the presentation. """ #: `list` of time at which each page was reached page_time = [] #: `int` the time at which the clock was reset end_time = -1 #: The :class:`~Gtk.TreeView` containing the timing data to display in the dialog timing_treeview = None #: A :class:`~Gtk.Dialog` to contain the timing to show time_report_dialog = None #: `bool` marking whether next page transition should reset the history of page timings clear_on_next_transition = False #: A `dict` containing the structure of the current document doc_structure = {} #: A `list` with the page label of each page of the current document page_labels = [] #: `bool` tracking whether a document is opened document_open = False def __init__(self, parent): super(TimingReport, self).__init__() self.load_ui('time_report_dialog') self.time_report_dialog.set_transient_for(parent.p_win) self.time_report_dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE) self.connect_signals(self) parent.setup_actions({ 'timing-report': dict(activate=self.show_report), })
[docs] def transition(self, page, time): """ Record a transition time between slides. Args: page (`int`): the page number of the current slide time (`int`): the number of seconds elapsed since the beginning of the presentation """ if not self.document_open: return if self.clear_on_next_transition: self.clear_on_next_transition = False del self.page_time[:] self.page_time.append((page, time))
[docs] def reset(self, reset_time): """ A timer reset. Clear the history as soon as we start changing pages again. """ self.end_time = reset_time self.clear_on_next_transition = True
[docs] @staticmethod def format_time(secs): """ Formats a number of seconds as `minutes:seconds`. Returns: `str`: The formatted time, with 2+ digits for minutes and 2 digits for seconds. """ return '{:02}:{:02}'.format(*divmod(int(secs), 60))
[docs] def set_document_metadata(self, doc_structure, page_labels): """ Show the popup with the timing infortmation. Args: doc_structure (`dict`): the structure of the document page_labels (`list`): the page labels for each of the pages """ self.document_open = len(page_labels) != 0 # Do not update if we only close the document. # That way, the report is still accessible when the document is closed. if not self.document_open: return self.doc_structure = doc_structure self.page_labels = page_labels # Clear the report when there is a new document opened. del self.page_time[:]
[docs] def show_report(self, gaction, param=None): """ Show the popup with the timing infortmation. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ times = [time for page, time in self.page_time] durations = (e - s for s, e in zip(times, times[1:] + [self.end_time])) min_time = min(time for page, time in self.page_time) if self.page_time else 0 infos = {'time': min_time, 'duration': 0, 'children': [], 'page': 0} infos['title'] = 'Full presentation' for (page, start_time), duration in zip(self.page_time, durations): if not duration: continue infos['duration'] += duration # lookup the position of the page in the document structure (section etc) lookup = self.doc_structure cur_info_pos = infos while lookup: try: pos = max(p for p in lookup if p <= page) except ValueError: break item = lookup[pos] lookup = item.get('children', None) if cur_info_pos['children'] and cur_info_pos['children'][-1]['page'] == pos: cur_info_pos['children'][-1]['duration'] += duration else: cur_info_pos['children'].append({'page': pos, 'title': item['title'], 'children': [], 'duration': duration, 'time': start_time}) cur_info_pos = cur_info_pos['children'][-1] # add the actual page as a leaf node label = self.page_labels[page] if 0 <= page < len(self.page_labels) else 'None' cur_info_pos['children'].append({'page': page, 'title': _('slide #') + label, 'duration': duration, 'time': start_time}) treemodel = self.timing_treeview.get_model() if treemodel: treemodel.clear() treemodel = Gtk.TreeStore(str, str, str, str) npages = len(self.page_labels) maxlen = len(str(npages)) dfs_info = [(None, infos)] while dfs_info: first_it, first = dfs_info.pop() page = first['page'] label = self.page_labels[page] if 0 <= page < len(self.page_labels) else 'None' label += '\u2007' * (maxlen - len(str(page))) last_col = '{} ({}/{})'.format(label, page, npages) row = [first['title'], self.format_time(first['time']), self.format_time(first['duration']), last_col] it = treemodel.append(first_it, row) if 'children' in first: dfs_info.extend((it, child) for child in reversed(first['children'])) self.timing_treeview.set_model(treemodel) self.timing_treeview.expand_row(Gtk.TreePath.new_first(), False) self.time_report_dialog.run() self.time_report_dialog.hide()
[docs] class LayoutEditor(builder.Builder): """ Widget tracking and displaying hierachically how much time was spent in each page/section of the presentation. """ #: The :class:`~Gtk.TreeView` displaying the hierarchical layouts layout_treeview = None #: The :class:`~Gtk.TreeModel` containing the model of the layouts to view in the treeview layout_treemodel = None #: The :class:`~Gtk.ListModel` containing the possible orientations orientations_model = None #: A :class:`~Gtk.Dialog` to contain the layout edition dialog layout_dialog = None #: A :class:`~Gtk.Label` to contain the description of the layout layout_description = None #: A :class:`~Gtk.ComboBoxText` to select the layout to edit layout_selector = None #: :class:`~pympress.config.Config` to remember preferences config = None #: :class:`~Gio.Action` containing the number of next frames next_frames_action = None #: :class:`~Gio.Action` containing the orientation hltools_orientation_action = None #: `str` containing the layout currently edited current_layout = 'plain' #: callback, to be connected to :func:`~pympress.ui.UI.load_layout` ui_load_layout = lambda *args: None layout_descriptions = { 'notes': _('Layout for beamer notes on second screen (no current slide preview in notes)'), 'plain': _('Plain layout, without note slides'), 'note_pages': _('Layout for libreoffice notes on separate pages (with current slide preview in notes)'), 'highlight': _('Layout to draw on the current slide'), 'highlight_notes': _('Layout to draw on the current slide with notes displayed'), } _model_columns = ['widget', 'has_resizeable', 'resizeable', 'has_orientation', 'orientation', 'next_slide_count', 'widget_name'] def __init__(self, parent, config): super(LayoutEditor, self).__init__() self.load_ui('layout_dialog') self.layout_dialog.set_transient_for(parent.p_win) self.layout_dialog.add_button(Gtk.STOCK_APPLY, Gtk.ResponseType.APPLY) self.layout_dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.layout_selector.get_child().set_editable(False) self.config = config self.ui_load_layout = parent.get_callback_handler('load_layout') self.connect_signals(self) parent.setup_actions({ 'edit-layout': dict(activate=self.show_editor), })
[docs] def load_layout(self): """ Load the given layout in the treemodel for display and manipulation in the treeview """ self.layout_description.set_text(self.layout_descriptions[self.current_layout]) self.layout_treemodel.clear() # Display names for the widget ids names = { 'box': _('box'), 'notes': _('notes'), 'current': _('current slide'), 'next': _('next slide(s)'), 'highlight': _('highlighting'), 'annotations': _('annotations (hideable)'), 'vertical': _('vertical'), 'horizontal': _('horizontal'), } next_count = self.next_frames_action.get_state().get_int64() hltools_orientation = self.hltools_orientation_action.get_state().get_string() dfs_info = [(None, self.config.get_layout(self.current_layout))] while dfs_info: it, node = dfs_info.pop() if type(node) is str: orientation = names[hltools_orientation] if node == 'highlight' else '' next_slides = next_count if node == 'next' else 0 self.layout_treemodel.append(it, [node, False, None, bool(orientation), orientation, next_slides, names[node]]) else: next_it = self.layout_treemodel.append(it, ['box', True, node['resizeable'], True, names[node['orientation']], 0, names['box']]) dfs_info.extend((next_it, child) for child in reversed(node['children'])) self.layout_treeview.expand_all()
[docs] def set_current_layout(self, layout): """ Update which is the layout currently used by the UI Args: layout (`str`): the layout id """ self.current_layout = layout
[docs] def layout_selected(self, widget, event=None): """ Manage events for the layout selector drop-down menu Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified event (:class:`~Gdk.Event`): the GTK event """ self.current_layout = widget.get_active_id() self.load_layout()
[docs] def get_info(self, path): """ Given a path string, look up the appropriate item in both the actual and GtkStore models Args: path (`str`): A string representing a path in the treemodel Returns: `dict`, :class:`~Gtk.TreeIter`: the node and iterator representing the position in the layout and model """ pos = Gtk.TreePath.new_from_string(path) tree_it = self.layout_treemodel.get_iter(pos) node = {'children': [self.config.get_layout(self.current_layout)]} for n in pos.get_indices(): node = node['children'][n] return node, tree_it
[docs] def resizeable_toggled(self, widget, path): """ Handle when box’ resizeable value is toggled Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified path (`str`): A string representing the path to the modfied item """ node, tree_it = self.get_info(path) value = not node['resizeable'] node['resizeable'] = value self.layout_treemodel.set_value(tree_it, self._model_columns.index('resizeable'), value) self.normalize_layout(reload=False)
[docs] def orientation_changed(self, widget, path, orient_it): """ Handle when the orientation of a box is changed Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified path (`str`): A string representing the path to the modfied item orient_it (:class:`~Gtk.TreeIter`): the row of the newly selected value in the orientations liststore model """ value = self.orientations_model.get_value(orient_it, 1) node, tree_it = self.get_info(path) if node == 'highlight': self.hltools_orientation_action.activate(GLib.Variant.new_string(value)) else: node['orientation'] = value self.layout_treemodel.set_value(tree_it, self._model_columns.index('orientation'), value) self.normalize_layout(reload=False)
[docs] def next_slide_count_edited(self, widget, path, value): """ Handle when the next slide count is modified Args: widget (:class:`~Gtk.ComboBox`): the widget which has been modified path (`str`): A string representing the path to the modfied item value (`int`): the new number of next slides """ node, tree_it = self.get_info(path) self.layout_treemodel.set_value(tree_it, self._model_columns.index('next_slide_count'), int(value)) self.next_frames_action.activate(GLib.Variant.new_int64(int(value)))
[docs] def treemodel_to_tree(self, iterator, parent_horizontal=False, parent_resizeable=False): """ Recursive function to transform the treemodel back into our dict-based representation of the layout Args: iterator (:class:`~Gtk.TreeIter`): the position in the treemodel parent_horizontal (`bool`): whether the parent node is horizontal parent_resieable (`bool`): whether the parent node is resizeable Returns: `list`: the list of `dict` or `str` representing the widgets at this level """ nodes = [] while iterator is not None: values = self.layout_treemodel.get(iterator, *range(len(self._model_columns[:-2]))) node = dict(zip(self._model_columns, values)) # Make the node conform to either a string or a dictionary with 'children' key if node.pop('has_resizeable'): node['children'] = [] del node['widget'] else: node = node['widget'] if self.layout_treemodel.iter_has_child(iterator): children = self.treemodel_to_tree(self.layout_treemodel.iter_children(iterator), *( [parent_horizontal, parent_resizeable] if type(node) is str else [node['orientation'] == 'horizontal', node['resizeable']] )) if len(children) > 1 and type(node) is not str: # Only assign children if there are any, allows to prune empty boxes node['children'] = children elif children and type(node) is not str: # Single-child box replaced by its children node = children[0] elif children: # Non-box node with children: create a new box and set the non-box as first child node = {'children': [node] + children, 'resizeable': not parent_resizeable, 'orientation': 'vertical' if parent_horizontal else 'horizontal'} # Only append widgets, and box nodes that have children if type(node) is str or node['children']: nodes.append(node) iterator = self.layout_treemodel.iter_next(iterator) return nodes
[docs] def normalize_layout(self, widget=None, drag_context=None, reload=True): """ Handler at the end of a drag-and-drop in the treeview Here we transform the listmodel modified by drag-and-drop back to a valid `dict` and `str` hierarchy, and then trigger the loading of the layout again to display the corrected layout. Args: widget (:class:`~Gtk.Widget`): The object which received the signal drag_context (:class:`~Gdk.DragContext`): the drag context reload (`bool`): whether to reload the layout into the treemodel """ layout = self.treemodel_to_tree(self.layout_treemodel.get_iter_first()) if len(layout) > 1: layout = {'children': layout, 'orientation': 'horizontal', 'resizeable': True} else: layout = layout[0] # This validates self.config.update_layout_tree(self.current_layout, layout) self.ui_load_layout(None) if reload: self.load_layout()
[docs] def show_editor(self, gaction, param=None): """ Show the popup to edit the layout. Gather info to populate it, and handle apply/cancel at the end. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ restore_layouts = {layout: copy.deepcopy(self.config.get_layout(layout)) for layout in self.layout_descriptions} self.next_frames_action = self.get_application().lookup_action('next-frames') self.hltools_orientation_action = self.get_application().lookup_action('highlight-tools-orientation') restore_next_count = self.next_frames_action.get_state().get_int64() restore_hltools_orientation = self.hltools_orientation_action.get_state().get_string() self.layout_selector.set_active_id(self.current_layout) self.load_layout() if self.layout_dialog.run() != Gtk.ResponseType.APPLY: for layout_name, layout in restore_layouts.items(): self.config.update_layout_tree(layout_name, layout) self.next_frames_action.activate(GLib.Variant.new_int64(restore_next_count)) self.hltools_orientation_action.activate(GLib.Variant.new_string(restore_hltools_orientation)) self.ui_load_layout(None) self.layout_dialog.hide()
[docs] class AutoPlay(builder.Builder): """ Widget and machinery to setup and play slides automatically, optionally in a loop """ #: A :class:`~Gtk.Dialog` to contain the layout edition dialog autoplay_dialog = None #: The :class:`~Gtk.SpinButton` for the lower page autoplay_spin_lower = None #: The :class:`~Gtk.SpinButton` for the upper page autoplay_spin_upper = None #: The :class:`~Gtk.SpinButton` for the transition between slides autoplay_spin_time = None #: The :class:`~Gtk.CheckButton` to loop autoplay_button_loop = None #: :class:`~Glib.Source` which is the source id of the periodic slide transition, or `None` if there is no autoplay source = None #: if the timeout has been paused, `int` which represents the number of milliseconds until the next page slide remain = None #: callback, to be connected to :func:`~pympress.ui.UI.goto_page` goto_page = lambda *args: None def __init__(self, parent): super(AutoPlay, self).__init__() self.load_ui('autoplay') self.autoplay_dialog.set_transient_for(parent.p_win) self.autoplay_dialog.add_button(Gtk.STOCK_APPLY, Gtk.ResponseType.APPLY) self.autoplay_dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.autoplay_dialog.set_default_response(Gtk.ResponseType.APPLY) parent.setup_actions({ 'autoplay': dict(activate=self.run), }) self.connect_signals(self) self.goto_page = parent.get_callback_handler('goto_page')
[docs] def set_doc_pages(self, n_pages): """ Callback for when a document number of pages changes Args: n_pages (`int`): the number of pages of the loaded document """ self.autoplay_spin_lower.set_range(1, n_pages - 2) self.autoplay_spin_lower.set_value(1) self.autoplay_spin_upper.set_range(2, n_pages) self.autoplay_spin_upper.set_value(n_pages)
[docs] def page_changed(self, spin_button, scroll_direction): """ Callback for when a page spin button is modified, maintains a delta of at least 2 pages between first and last page of the intended loop. (No loops needed to loop a single slide.) Args: spin_button (:class:`~Gtk.SpinButton`): The button whose value was changed scroll_direction (:class:`~Gtk.ScrollType`): The speed and amount of change """ if spin_button == self.autoplay_spin_lower: minval = self.autoplay_spin_lower.get_value() + 2 if self.autoplay_spin_upper.get_value() < minval: self.autoplay_spin_upper.set_value(minval) elif spin_button == self.autoplay_spin_upper: maxval = self.autoplay_spin_upper.get_value() - 2 if self.autoplay_spin_lower.get_value() > maxval: self.autoplay_spin_lower.set_value(maxval)
[docs] def pause(self): """ Pause the looping if it’s running """ if self.source is None or self.remain is not None: return self.remain = self.source.get_ready_time() - self.source.get_time() self.source.set_ready_time(sys.maxsize)
[docs] def unpause(self): """ Unpause the looping if it’s paused """ if self.source is None or self.remain is None: return self.source.set_ready_time(self.source.get_time() + self.remain) self.remain = None
[docs] def is_looping(self): """ Return whether an auto-playing """ return self.source is not None
[docs] def stop_looping(self): """ Stop the auto-playing """ if self.source is not None: self.source.destroy() self.source = None self.remain = None
[docs] def start_looping(self): """ Start the auto-playing """ self.stop_looping() it = itertools.cycle(range(*self.pages[:2])) if self.pages[2] else iter(range(*self.pages[:2])) self.next_page(it) self.source = GLib.timeout_source_new(self.pages[3]) self.source.attach(GLib.MainContext.default()) self.source.set_callback(self.next_page, it)
[docs] def next_page(self, it): """ Callback to turn the page to the next slide Args: it (`iterator`): An iterator that contains the next pages to load. Stop when there are no more pages. Returns: `bool`: `True` if he callback needs to be called again, otherwise `False` """ try: self.goto_page(next(it), autoplay=True) except StopIteration: self.stop_looping() return False else: return True
[docs] def get_page_range(self): """ Return the autoplay info Returns: `tuple`: (first page, stop page, looping, delay i ms) """ return self.pages
[docs] def run(self, gaction, param=None): """ Show the dialog to setup auto-play, and start the autoplay if « apply » is selected Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ reply = self.autoplay_dialog.run() self.autoplay_dialog.hide() if reply != Gtk.ResponseType.APPLY: return self.pages = (self.autoplay_spin_lower.get_value_as_int() - 1, self.autoplay_spin_upper.get_value_as_int(), self.autoplay_button_loop.get_active(), int(self.autoplay_spin_time.get_value() * 1000)) self.start_looping()