Source code for pympress.builder

# -*- coding: utf-8 -*-
#
#       builder.py
#
#       Copyright 2017 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.ui_builder` -- abstract GUI management
-----------------------------------------------------

This module contains the tools to load the graphical user interface of pympress,
building the widgets/objects from XML (glade) files, applying translation "manually"
to avoid dealing with all the mess of C/GNU gettext's bad portability.
"""

from __future__ import print_function, unicode_literals

import logging
logger = logging.getLogger(__name__)

from collections import deque

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

from pympress import util


[docs]class Builder(Gtk.Builder): """ GUI builder, inherits from :class:`~Gtk.Builder` to read XML descriptions of GUIs and load them. """ #: `set` of :class:`~Gtk.Widget`s that have been built by the builder, and translated __built_widgets = set() #: `dict` mapping :class:`~Gtk.Paned` names to the handler ids of their size-allocate signal pending_pane_resizes = {} def __init__(self): super(Builder, self).__init__() @staticmethod def __translate_widget_strings(a_widget): """ Calls gettext on all strings we can find in a_widgets. Args: a_widget (:class:`~GObject.Object`): an object built by the builder, usually a widget """ for str_prop in (prop.name for prop in a_widget.props if prop.value_type == GObject.TYPE_STRING): try: setattr(a_widget.props, str_prop, _(getattr(a_widget.props, str_prop))) except TypeError: # Thrown when a string property is not readable pass @staticmethod def __recursive_translate_widgets(a_widget): """ Calls gettext on all strings we can find in widgets, and recursively on its children. Args: a_widget (:class:`~GObject.Object`): an object built by the builder, usually a widget """ Builder.__translate_widget_strings(a_widget) if issubclass(type(a_widget), Gtk.Container): #NB: Parent-loop in widgets would cause infinite loop here, but that's absurd (right?) #NB2: maybe forall instead of foreach if we miss some strings? a_widget.foreach(Builder.__recursive_translate_widgets) if issubclass(type(a_widget), Gtk.MenuItem) and a_widget.get_submenu() is not None: Builder.__recursive_translate_widgets(a_widget.get_submenu())
[docs] @staticmethod def signal_resolver(target, attr_list): """ Dynamically resolves a signal that is target.a.b.c() when attr_list is ['a', 'b', 'c']. This allows to specify multi-level signals in the XML files, instead of targeting everything at the main UI object. Also, resolving signals dynamically means the object properties of the top-level object can be replaced, and the signal will still connect to something meaningful. The downside is that this connection is done at runtime, thus probably less efficient and might fail to find the target if any attribute along the way has an unexpected value. Args: attr_list (`list`): a list of attribute names, designating objects except the last one designating a function Returns: function: The function to which we want to connect """ top = target for attr in attr_list: try: target = getattr(target, attr) except AttributeError as e: logger.error('Can not reach target of signal {}.{}()'.format(top, '.'.join(attr_list)), exc_info = True) target = None return target
[docs] @staticmethod def find_callback_handler(target, handler_name): """ Returns the handler from its name, searching in target. Parse handler names and split on '.' to use recursion. Args: target (`object`): An object that has a method called `handler_name` handler_name (`str`): The name of the function to be connected to a signal Returns: `function`: A function bound to an object or, if the object may change, a lambda calling Builder.signal_resolver to get said function bound to an object """ try: return getattr(target, handler_name) except AttributeError: attr_list = handler_name.split('.') if len(attr_list) == 1: logger.error('Handler name not in target object. Expected "." but got: {}'.format(handler_name), exc_info = True) raise # Dynamically resolved handler for 'doc' (only) since target.doc may change if 'doc' in attr_list: return lambda *args, **kwargs: Builder.signal_resolver(target, attr_list)(*args, **kwargs) else: return Builder.signal_resolver(target, attr_list)
[docs] def get_callback_handler(self, handler_name): """ Convenience non-static wrapper function for :func:`find_callback_handler` to search in the builder object. The `handler_name` function must be a method of this builder (realistically, a method of an inherited UI class' instance). Args: handler_name (`str`): The name of the function to be connected to a signal Returns: `function`: A function bound to an object or, if the object may change, a lambda calling Builder.signal_resolver to get said function bound to an object """ return self.find_callback_handler(self, handler_name)
[docs] def signal_connector(self, builder, object, signal_name, handler_name, connect_object, flags, *user_data): """ Callback for signal connection. Implements the `~Gtk.BuilderConnectFunc` function interface. Args: builder (:class:`~pympress.builder.Builder`): The builder, unused object (:class:`~GObject.Object`): The object (usually a wiget) that has a signal to be connected signal_name (`str`): The name of the signal handler_name (`str`): The name of the function to be connected to the signal connect_object (:class:`~GObject.Object`): unused flags (:class:`~GObject.ConnectFlags`): unused user_data (`tuple`): supplementary positional arguments to be passed to the handler """ try: handler = self.get_callback_handler(handler_name) object.connect(signal_name, handler, *user_data) except: logger.critical('Impossible to connect signal {} from object {} to handler {}'.format(signal_name, object, handler_name), exc_info = True)
[docs] def connect_signals(self, base_target): """ Override default signal connector so we can map signals to the methods of (any depth of) object that are properties of self Args: base_target (:class:`~pympress.builder.Builder`): The target object, that has functions to be connected to signals loaded in this builder. """ Builder.connect_signals_full(base_target, self.signal_connector)
[docs] def load_ui(self, resource_name): """ Loads the UI defined in the file named resource_name using the builder. Args: resource_name (`str`): the name of the glade file (basename without extension), identifying which resource to load. """ self.add_from_file(util.get_ui_resource_file(resource_name)) # Get all newly built objects new_objects = set(self.get_objects()) - self.__built_widgets self.__built_widgets.update(new_objects) for obj in new_objects: # pass new objects to manual translation self.__translate_widget_strings(obj) # instrospectively load objects. If we have a self.attr == None and this attr is the name of a built object, link it together. if issubclass(type(obj), Gtk.Buildable): obj_id = Gtk.Buildable.get_name(obj) if hasattr(self, obj_id) and getattr(self, obj_id) is None: setattr(self, obj_id, obj)
[docs] def list_attributes(self, target): """ List the None-valued attributes of target. Args: target (`dict`): An object with None-valued attributes """ for attr in dir(target): try: if attr[:2] + attr[-2:] != '____' and getattr(target, attr) is None: yield attr except RuntimeError: pass
[docs] def load_widgets(self, target): """ Fill in target with the missing elements introspectively. This means that all attributes of target that are None at this time must exist under the same name in the builder. Args: target (`dict`): An object with None-valued attributes that have the same name as built widgets, loaded from the glade file. """ for attr in self.list_attributes(target): setattr(target, attr, self.get_object(attr))
[docs] def replace_layout(self, layout, top_widget, leaf_widgets, pane_resize_handler = None): """ Remix the layout below top_widget with the layout configuration given in 'layout' (assumed to be valid!). Args: layout (`dict`): the json-parsed config string, thus a hierarchy of lists/dicts, with strings as leaves top_widget (:class:`~Gtk.Container`): The top-level widget under which we build the hierachyy leaf_widgets (`dict`): the map of valid leaf identifiers (strings) to the corresponding :class:`~Gtk.Widget` pane_resize_handler (function): callback function to be called when the panes are resized Returns: `dict`: The mapping of the used :class:`~Gtk.Paned` widgets to their relative handle position (float between 0 and 1) """ # take apart the previous/default layout containers = [] widgets = top_widget.get_children() i = 0 while i < len(widgets): w = widgets[i] if w in self.placeable_widgets.values(): pass elif issubclass(type(w), Gtk.Box) or issubclass(type(w), Gtk.Paned): widgets.extend(w.get_children()) containers.append(w) w.get_parent().remove(w) i += 1 # cleanup widgets del widgets[:] while containers: containers.pop().destroy() self.pending_pane_resizes.clear() # iterate over new layout to build it, using a BFS widgets_to_add = deque([(top_widget, layout)]) pane_resize = set() pane_handle_pos = {} while widgets_to_add: parent, w_desc = widgets_to_add.popleft() if type(w_desc) is str: w = leaf_widgets[w_desc] else: # get new container widget if 'resizeable' in w_desc and w_desc['resizeable']: orientation = getattr(Gtk.Orientation, w_desc['orientation'].upper()) w = Gtk.Paned.new(orientation) w.set_wide_handle(True) # Add on resize events if pane_resize_handler: w.connect("notify::position", pane_resize_handler) w.connect("button-release-event", pane_resize_handler) # left pane is first child widgets_to_add.append((w, w_desc['children'].pop())) if 'proportions' in w_desc: right_pane = w_desc['proportions'].pop() left_pane = w_desc['proportions'].pop() w_desc['proportions'].append(left_pane + right_pane) pane_handle_pos[w] = float(left_pane) / (left_pane + right_pane) pane_resize.add(w) else: pane_handle_pos[w] = 0.5 hid = w.connect("size-allocate", self.resize_paned, pane_handle_pos[w]) w.set_name('GtkPaned{}'.format(len(self.pending_pane_resizes))) self.pending_pane_resizes[w.get_name()] = hid # if more than 2 children are to be added, add the 2+ from the right side in a new child Gtk.Paned widgets_to_add.append((w, w_desc['children'][0] if len(w_desc['children']) == 1 else w_desc)) else: w = Gtk.Box.new(getattr(Gtk.Orientation, w_desc['orientation'].upper()), 5) w.set_homogeneous(True) w.set_spacing(10) widgets_to_add.extend((w, c) for c in w_desc['children']) if issubclass(type(parent), Gtk.Box): parent.pack_start(w, True, True, 0) else: #it's a Gtk.Paned if parent.get_child2() is None: parent.pack2(w, True, True) if parent.get_orientation() == Gtk.Orientation.HORIZONTAL: w.set_margin_start(8) else: w.set_margin_top(8) else: parent.pack1(w, True, True) if parent.get_orientation() == Gtk.Orientation.HORIZONTAL: w.set_margin_end(8) else: w.set_margin_bottom(8) return pane_handle_pos
[docs] def resize_paned(self, paned, rect, relpos): """ Resize `~paned` to have its handle at `~relpos`, then disconnect this signal handler. Called from the :func:`Gtk.Widget.signals.size_allocate` signal. Args: paned (:class:`~Gtk.Paned`): Panel whose size has just been allocated, and whose handle needs initial placement. rect (:class:`~Gdk.Rectangle`): The rectangle specifying the size that has just been allocated to `~paned` relpos (`float`): A number between `0.` and `1.` that specifies the handle position Returns: `True` """ size = rect.width if paned.get_orientation() == Gtk.Orientation.HORIZONTAL else rect.height handle_pos = int(round(relpos * size)) GLib.idle_add(paned.set_position, handle_pos) paned.disconnect(self.pending_pane_resizes.pop(paned.get_name())) return True