# -*- 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.
"""
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 (prop.name 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 link_iter.next():
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 Gtk.Widget.name 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 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 = 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] = 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 = 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)
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 = GLib.VariantType.new(Builder._glib_type_strings[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 = Gio.SimpleAction.new(action_name, 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