pympress.builder — Pympress developer documentation

# 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

    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())

        @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

        @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)

        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)

        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)

        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)

        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)

        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

        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))

        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

        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