# -*- 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]
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 modified 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 modified 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 modified 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()