# -*- 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