# 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.
    """

    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

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

        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

        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)

        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)

        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)

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

        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

        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

        @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