Source code for pympress.builder

# -*- coding: utf-8 -*-
#       Copyright 2017 Cimbali <>
#       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
#       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.

import logging
logger = logging.getLogger(__name__)

import copy
from collections import deque

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

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 a `tuple` of (handler id of the size-allocate signal, remaining #: number of times we allow this signal to run), and we run the signal 2 * (depth + 1) for each pane. #: This is because size allocation is done bottom-up but each pane sets a top-down constraint. pending_pane_resizes = {} _glib_type_strings = { float: 'd', bool: 'b', int: 'x', str: 's', } _glib_type_getters = { 'd': GLib.Variant.get_double, 'b': GLib.Variant.get_boolean, 'x': GLib.Variant.get_int64, 's': GLib.Variant.get_string, } 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 ( for prop in a_widget.props if prop.value_type == GObject.TYPE_STRING): try: str_val = getattr(a_widget.props, str_prop) if str_val: setattr(a_widget.props, str_prop, _(str_val)) except TypeError: # Thrown when a string property is not readable pass @staticmethod def __recursive_translate_menu(menu): """ Calls gettext on all strings we can find in widgets, and recursively on its children. Args: menu (:class:`~Gio.Menu`): the menu to translate """ menu_items = [] for n in range(menu.get_n_items()): item = Gio.MenuItem.new_from_model(menu, n) label = item.get_attribute_value(Gio.MENU_ATTRIBUTE_LABEL, GLib.VariantType('s')) if label: item.set_label(_(label.get_string())) menu_items.append(item) link_iter = menu.iterate_item_links(n) while Builder.__recursive_translate_menu(link_iter.get_value()) menu.remove_all() for item in menu_items: menu.append_item(item)
[docs] def get_callback_handler(self, 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 """ target = self for attr in handler_name.split('.'): try: target = getattr(target, attr) except AttributeError: logger.error('Can not reach target of signal {}.{}()'.format(self, handler_name), exc_info=True) return None return target
[docs] def signal_connector(self, builder, obj, 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 obj (: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) obj.connect(signal_name, handler, *user_data) except Exception: logger.critical('Impossible to connect signal {} from object {} to handler {}' .format(signal_name, obj, handler_name), exc_info = True)
[docs] def connect_signals(self, base_target): """ Signal connector connecting to properties of `base_target`, or properties of its properties, etc. 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, **kwargs): """ Loads the UI defined in the file named resource_name using the builder. Args: resource_name (`str`): the basename of the glade file (without extension), identifying the resource to load. """ self.add_from_file(util.get_ui_resource_file(resource_name, **kwargs)) # 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 if isinstance(obj, Gio.Menu): self.__recursive_translate_menu(obj) else: 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) # set property to the value of the Gtk.Buildable id if issubclass(type(obj), Gtk.Widget): Gtk.Widget.set_name(obj, obj_id) 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 now must exist under the same name in the builder. Args: target (`dict`): An object with None-valued properties whose names correspond to ids of built widgets. """ 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 (in 0..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, copy.deepcopy(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 = 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] = 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, 2 * len(self.pending_pane_resizes) + 1) # 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 =, 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) name = paned.get_name() handler_id, sizes = self.pending_pane_resizes[name] self.pending_pane_resizes[name] = (handler_id, sizes - 1) if sizes <= 0: paned.disconnect(handler_id) return True
[docs] @staticmethod def setup_actions(actions, action_map=None): """ Sets up actions with a given prefix, using the Application as the ActionMap. Args: actions (`dict`): Maps the action names to dictionaries containing their parameters. action_map (:class:`~Gio.ActionMap`): The object implementing the action map interface to register actions """ if action_map is None: action_map = Gio.Application.get_default() for action_name, details in actions.items(): state = details.get('state') param = details.get('parameter_type') enabled = details.get('enabled') if param is not None: param =[param]) if state is not None: state = GLib.Variant(Builder._glib_type_strings[type(state)], state) action = Gio.SimpleAction.new_stateful(action_name, param, state) else: action =, param) for event in ['activate', 'change-state']: handler = details.get(event.replace('-', '_')) if handler is not None: action.connect(event, handler) action_map.add_action(action) if enabled is not None: action.set_enabled(enabled) return action_map