Source code for pympress.deck

# -*- coding: utf-8 -*-
#
#       deck.py
#
#       Copyright 2023 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.deck` -- Manage user drawings on the current slide
---------------------------------------------------------------------
"""

import logging
logger = logging.getLogger(__name__)

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

from pympress import builder


[docs] class Overview(builder.Builder): """ UI that allows to draw free-hand on top of the current slide. Args: config (:class:`~pympress.config.Config`): A config object containing preferences builder (:class:`~pympress.builder.Builder`): A builder from which to load widgets """ #: Whether we are displaying the deck overview on screen deck_mode = False #: :class:`~Gtk.Viewport` that replaces normal panes when deck is shown deck_viewport = None #: :class:`~Gtk.Grid` that displays all the slides of the overview deck_grid = None #: The :class:`~Gtk.DrawingArea` for the first slide deck0 = None #: A :class:`~Gtk.OffscreenWindow` where we render the deck interface when it's not shown deck_off_render = None #: :class:`~Gtk.Box` in the Presenter window, where we insert deck. p_central = None #: callback, to be connected to :func:`~pympress.ui.UI.compute_frame_grid` compute_frame_grid = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.load_layout` load_layout = lambda *args: None #: callback, to be connected to :func:`~pympress.surfacecache.SurfaceCache.resize_widget` resize_cache = lambda *args: None #: callback, to be connected to :func:`~pympress.ui.UI.goto_page` goto_page = lambda *args: None #: :class:`~pympress.surfacecache.SurfaceCache` instance. cache = None #: `tuple` of rows/columns in the grid grid_size = (0, 0) #: `bool` whether we show all pages or remove consecutive identically labeled pages, keeping only the last all_pages = False #: `int` How large (at most) to make rows max_row_size = 6 #: The :class:`~Gtk.DrawingArea` in the content window c_da = None def __init__(self, config, builder): super(Overview, self).__init__() self.cache = builder.cache self.load_ui('deck') builder.load_widgets(self) self.deck_da_list = [self.deck0] self.get_application().add_window(self.deck_off_render) self.load_layout = builder.get_callback_handler('load_layout') self.goto_page = builder.get_callback_handler('goto_page') self.compute_frame_grid = builder.get_callback_handler('compute_frame_grid') self.setup_doc_callbacks(builder.doc) self.connect_signals(self) self.max_row_size = config.getint('deck-overview', 'max-slides-per-row') # Whether to show all pages or only distinctly labeled pages (useful for latex) self.all_pages = not config.get('deck-overview', 'distinct-labels-only') self.setup_actions({ 'deck-overview': dict(activate=self.switch_deck_overview, state=False), })
[docs] def on_deck_hover(self, widget, event): """ Track when each deck in the slide is hovered """ ctx = widget.get_style_context() ctx.set_state(Gtk.StateFlags.PRELIGHT if event.type == Gdk.EventType.ENTER_NOTIFY else Gtk.StateFlags.NORMAL) widget.queue_draw()
[docs] def setup_doc_callbacks(self, doc): """ Callbacks that need to be setup again at every new document Args: doc (:class:`~pympress.document.Document`): The new document that got loaded """ self.pages_number = doc.pages_number self.has_labels = doc.has_labels self.get_last_label_pages = doc.get_last_label_pages self.create_drawing_areas()
[docs] def try_cancel(self): """ Cancel deck, if it is enabled. Returns: `bool`: `True` if deck got cancelled, `False` if it was already disabled. """ if not self.deck_mode: return False self.disable_deck_overview() return True
[docs] def create_drawing_areas(self): """ Build DrawingArea and AspectFrame elements to display later on """ pages = self.get_last_label_pages() if not self.all_pages and self.has_labels() else range(self.pages_number()) self.grid_size = (len(pages), 1) # Always keep the first drawing area as it is used to provide surfaces in the cache for row in range(1, self.grid_size[0]): self.deck_grid.remove_row(row) for col in range(1, self.grid_size[1]): self.deck_grid.remove_row(col) # Set drawing areas for num, da in enumerate(self.deck_da_list[1:], 1): self.deck_grid.attach(da.get_parent(), 1, num, 1, 1) for num in range(len(self.deck_da_list), len(pages)): da = Gtk.DrawingArea() da.add_events(Gdk.EventMask.TOUCH_MASK | Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK) da.connect('draw', self.on_deck_draw) da.connect('button-release-event', self.on_deck_click) da.connect('touch-event', self.on_deck_click) da.connect('enter-notify-event', self.on_deck_hover) da.connect('leave-notify-event', self.on_deck_hover) self.deck_da_list.append(da) frame = Gtk.AspectFrame() frame.get_style_context().add_class('grid-frame') frame.set_shadow_type(Gtk.ShadowType.NONE) frame.add(da) self.deck_grid.attach(frame, 1, num, 1, 1) ratio = self.c_da.get_allocated_width() / self.c_da.get_allocated_height() for page, da in zip(pages, self.deck_da_list): da.get_parent().set(.5, .5, ratio, False) da.set_name('deck{}'.format(page))
[docs] def reset_grid(self, *args): """ Set the slides configuration and size in the grid """ # Gather info about slides to display num_pages = self.pages_number() if self.all_pages or not self.has_labels() else len(self.get_last_label_pages()) ratio = self.c_da.get_allocated_width() / self.c_da.get_allocated_height() ww, wh = self.deck_grid.get_allocated_width(), self.deck_grid.get_allocated_height() sw, sh = self.deck_grid.get_row_spacing(), self.deck_grid.get_column_spacing() rows, cols = self.compute_frame_grid(ww / wh, num_pages) window = self.deck_viewport.get_window() scale = window.get_scale_factor() if not rows or not cols: rows, cols = 1, 1 dw, dh = (ww + sw) / cols - sw, (wh + sh) / rows - sh dw, dh = min(dw, dh * ratio), min(dw / ratio, dh) if cols > self.max_row_size: cols = self.max_row_size rows = (num_pages + cols - 1) // cols dw = (ww + sw) / cols - sw dh = dw / ratio self.cache.resize_widget('deck', int(dw * scale), int(dh * scale)) frames = [da.get_parent() for da in self.deck_da_list] for frame in frames: frame.set(.5, .5, ratio, False) frame.set_size_request(dw / scale, dh / scale) if self.grid_size != (rows, cols): for frame in frames[1:]: self.deck_grid.remove(frame) # Always keep the first drawing area as it is used to provide surfaces in the cache for row in range(rows, self.grid_size[0]): self.deck_grid.remove_row(row) for col in range(cols, self.grid_size[1]): self.deck_grid.remove_row(col) for num, frame in enumerate(frames[1:], 1): self.deck_grid.attach(frame, num % cols, num // cols, 1, 1) # resize grid and cache self.grid_size = rows, cols for da in self.deck_da_list: GLib.idle_add(self.prerender, da) self.deck_grid.show_all()
[docs] def prerender(self, da): """ Perform in-cache rendering Args: da (:class:`~Gtk.DrawingArea`): the widget for which we’re rendering """ self.cache.renderer('deck', int(da.get_name()[4:])) da.queue_draw() return GLib.SOURCE_REMOVE
[docs] def on_deck_draw(self, widget, cairo_context): """ Actually draw the deck slide -- only do this from cache, to limit overhead Args: widget (:class:`~Gtk.Widget`): the widget to update cairo_context (:class:`~cairo.Context`): the Cairo context (or `None` if called directly) """ page_num = int(widget.get_name()[4:]) pb = self.cache.get('deck', page_num) if pb is None: # We’ll redraw later widget.queue_draw() return window = widget.get_window() scale = window.get_scale_factor() cairo_context.scale(1. / scale, 1. / scale) cairo_context.set_source_surface(pb, 0, 0) cairo_context.paint() ctx = widget.get_style_context() if ctx.get_state() != Gtk.StateFlags.PRELIGHT: return # Draw a hover border manually color = ctx.get_property('border-color', Gtk.StateFlags.PRELIGHT) width = 2 cairo_context.set_source_rgba(*color) cairo_context.set_line_width(width) ww, wh = widget.get_allocated_width(), widget.get_allocated_height() cairo_context.move_to(width / 2, width / 2) cairo_context.line_to(width / 2, wh - width / 2) cairo_context.line_to(ww - width / 2, wh - width / 2) cairo_context.line_to(ww - width / 2, width / 2) cairo_context.close_path() cairo_context.stroke()
[docs] def on_deck_click(self, widget, event): """ A slide has been clicked, go to it Args: widget (:class:`~Gtk.Widget`): the widget which has received the key stroke event (:class:`~Gdk.Event`): the GTK event, which contains the key stroke details """ page_num = int(widget.get_name()[4:]) self.goto_page(page_num, False) self.disable_deck_overview()
[docs] def switch_deck_overview(self, gaction, target=None): """ Starts the mode where one can read on top of the screen. Args: Returns: `bool`: whether the event was consumed """ if target is not None and target == self.deck_mode: return False # Perform the state toggle if self.deck_mode: return self.disable_deck_overview() else: return self.enable_deck_overview()
[docs] def enable_deck_overview(self): """ Enable the deck view. Returns: `bool`: whether it was possible to enable (thus if it was not enabled already) """ if self.deck_mode: return False self.deck_off_render.remove(self.deck_viewport) self.load_layout('deck-overview') self.p_central.queue_draw() self.deck_viewport.queue_draw() GLib.idle_add(self.reset_grid) self.deck_mode = True self.get_application().lookup_action('deck-overview').change_state(GLib.Variant.new_boolean(self.deck_mode)) self.p_central.queue_draw() return True
[docs] def disable_deck_overview(self): """ Disable the deck view. Returns: `bool`: whether it was possible to disable (thus if it was not disabled already) """ if not self.deck_mode: return False self.deck_mode = False self.load_layout(None) self.deck_off_render.add(self.deck_viewport) self.get_application().lookup_action('deck-overview').change_state(GLib.Variant.new_boolean(self.deck_mode)) self.p_central.queue_draw() return True